| author | |
| committer | |
| log | fd7ee2c30a7eeef910c99e7bc92e944526555db7 |
| tree | 58372fc5149b9aeba3435ddd1dabb4fe57db12b2 |
| parent | a8927712f8b3338fa3c47a0c657b6e11d7e9aaec |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>86 files changed, 6133 insertions(+), 6050 deletions(-)
apps/desktop/scripts/browser-secret-native.test.mjs+6-8| ... | ... | @@ -78,7 +78,7 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { |
| 78 | 78 | expect(result.stderr.length).toBe(0); |
| 79 | 79 | }); |
| 80 | 80 | |
| 81 | for (const [scenario, code] of [ | |
| 81 | it.each([ | |
| 82 | 82 | ["missing", 2], |
| 83 | 83 | ["empty", 2], |
| 84 | 84 | ["locked", 3], |
| ... | ... | @@ -86,13 +86,11 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { |
| 86 | 86 | ["denied", 3], |
| 87 | 87 | ["unavailable", 4], |
| 88 | 88 | ["unloaded", 4], |
| 89 | ]) { | |
| 90 | it(`reports ${scenario} without emitting a secret`, () => { | |
| 91 | const result = run([scenario]); | |
| 92 | expect(result.status).toBe(code); | |
| 93 | expect(result.stdout.length).toBe(0); | |
| 94 | }); | |
| 95 | } | |
| 89 | ])("reports %s without emitting a secret", (scenario, code) => { | |
| 90 | const result = run([scenario]); | |
| 91 | expect(result.status).toBe(code); | |
| 92 | expect(result.stdout.length).toBe(0); | |
| 93 | }); | |
| 96 | 94 | it("rejects invalid arguments before accessing the keyring", () => { |
| 97 | 95 | for (const args of [[], [""], ["chrome", "extra"]]) { |
| 98 | 96 | const result = run(args); |
apps/desktop/src/app/DesktopClerk.test.ts+4-4| ... | ... | @@ -303,8 +303,9 @@ it.effect( |
| 303 | 303 | }, |
| 304 | 304 | ); |
| 305 | 305 | |
| 306 | for (const entry of ["startup", "open-url"] as const) { | |
| 307 | it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () => | |
| 306 | it.effect.each(["startup", "open-url"] as const)( | |
| 307 | "receives hosted web sign-in through the desktop %s handler", | |
| 308 | (entry) => | |
| 308 | 309 | Effect.gen(function* () { |
| 309 | 310 | storageMock.mockReturnValue(storageAdapter); |
| 310 | 311 | createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); |
| ... | ... | @@ -382,5 +383,4 @@ for (const entry of ["startup", "open-url"] as const) { |
| 382 | 383 | ), |
| 383 | 384 | ); |
| 384 | 385 | }).pipe(Effect.scoped), |
| 385 | ); | |
| 386 | } | |
| 386 | ); |
apps/desktop/src/app/DesktopLifecycle.test.ts+5-4| ... | ... | @@ -101,8 +101,9 @@ function makeDesktopWindowLayer( |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | 103 | describe("DesktopLifecycle", () => { |
| 104 | for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) { | |
| 105 | it.effect(`lets the updater's quit event proceed on ${platform}`, () => { | |
| 104 | it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>)( | |
| 105 | "lets the updater's quit event proceed on %s", | |
| 106 | (platform) => { | |
| 106 | 107 | const appListeners = new Map<string, (...args: readonly unknown[]) => void>(); |
| 107 | 108 | let windowsDestroyed = false; |
| 108 | 109 | const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { |
| ... | ... | @@ -152,8 +153,8 @@ describe("DesktopLifecycle", () => { |
| 152 | 153 | assert.isTrue(yield* Ref.get(state.quitting)); |
| 153 | 154 | }), |
| 154 | 155 | ).pipe(Effect.provide(layer)); |
| 155 | }); | |
| 156 | } | |
| 156 | }, | |
| 157 | ); | |
| 157 | 158 | |
| 158 | 159 | it.effect("destroys windows before waiting for backend shutdown", () => |
| 159 | 160 | Effect.gen(function* () { |
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+45-47| ... | ... | @@ -81,54 +81,52 @@ describe("DesktopPreReadyPlatform", () => { |
| 81 | 81 | ); |
| 82 | 82 | }); |
| 83 | 83 | |
| 84 | for (const previousEntry of [undefined, 'Exec="/Applications/deleted-previous.AppImage" %U']) { | |
| 85 | it.effect( | |
| 86 | `prepares a ${previousEntry ? "stale" : "missing"} Linux desktop entry before startup yields`, | |
| 87 | () => { | |
| 88 | vi.stubEnv("VITE_DEV_SERVER_URL", ""); | |
| 89 | vi.stubEnv("XDG_DATA_HOME", "/xdg"); | |
| 90 | vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); | |
| 91 | getSwitchValueMock.mockReturnValue(""); | |
| 92 | let desktopName = "t3code.desktop"; | |
| 93 | let desktopEntry = previousEntry; | |
| 94 | let iconInstalled = false; | |
| 95 | copyFileSyncMock.mockImplementation((_source: string, destination: string) => { | |
| 96 | iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; | |
| 97 | }); | |
| 98 | setDesktopNameMock.mockImplementation((name: string) => { | |
| 99 | desktopName = name; | |
| 100 | }); | |
| 101 | writeFileSyncMock.mockImplementation((path: string, contents: string) => { | |
| 102 | if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; | |
| 103 | }); | |
| 84 | it.effect.each([ | |
| 85 | { previousEntry: undefined, label: "missing" }, | |
| 86 | { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, | |
| 87 | ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => { | |
| 88 | vi.stubEnv("VITE_DEV_SERVER_URL", ""); | |
| 89 | vi.stubEnv("XDG_DATA_HOME", "/xdg"); | |
| 90 | vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); | |
| 91 | getSwitchValueMock.mockReturnValue(""); | |
| 92 | let desktopName = "t3code.desktop"; | |
| 93 | let desktopEntry = previousEntry; | |
| 94 | let iconInstalled = false; | |
| 95 | copyFileSyncMock.mockImplementation((_source: string, destination: string) => { | |
| 96 | iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; | |
| 97 | }); | |
| 98 | setDesktopNameMock.mockImplementation((name: string) => { | |
| 99 | desktopName = name; | |
| 100 | }); | |
| 101 | writeFileSyncMock.mockImplementation((path: string, contents: string) => { | |
| 102 | if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; | |
| 103 | }); | |
| 104 | 104 | |
| 105 | return Effect.scoped( | |
| 106 | Effect.gen(function* () { | |
| 107 | const portalIdentity = Promise.resolve().then(() => ({ | |
| 108 | desktopName, | |
| 109 | desktopEntry, | |
| 110 | iconInstalled, | |
| 111 | })); | |
| 112 | yield* Layer.build( | |
| 113 | DesktopPreReadyPlatform.layer.pipe( | |
| 114 | Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), | |
| 115 | ), | |
| 116 | ); | |
| 117 | const identity = yield* Effect.promise(() => portalIdentity); | |
| 118 | assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); | |
| 119 | assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); | |
| 120 | assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); | |
| 121 | assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); | |
| 122 | assert.include( | |
| 123 | identity.desktopEntry ?? "", | |
| 124 | "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", | |
| 125 | ); | |
| 126 | assert.isTrue(identity.iconInstalled); | |
| 127 | }), | |
| 128 | ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); | |
| 129 | }, | |
| 130 | ); | |
| 131 | } | |
| 105 | return Effect.scoped( | |
| 106 | Effect.gen(function* () { | |
| 107 | const portalIdentity = Promise.resolve().then(() => ({ | |
| 108 | desktopName, | |
| 109 | desktopEntry, | |
| 110 | iconInstalled, | |
| 111 | })); | |
| 112 | yield* Layer.build( | |
| 113 | DesktopPreReadyPlatform.layer.pipe( | |
| 114 | Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), | |
| 115 | ), | |
| 116 | ); | |
| 117 | const identity = yield* Effect.promise(() => portalIdentity); | |
| 118 | assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); | |
| 119 | assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); | |
| 120 | assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); | |
| 121 | assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); | |
| 122 | assert.include( | |
| 123 | identity.desktopEntry ?? "", | |
| 124 | "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", | |
| 125 | ); | |
| 126 | assert.isTrue(identity.iconInstalled); | |
| 127 | }), | |
| 128 | ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); | |
| 129 | }); | |
| 132 | 130 | |
| 133 | 131 | it.effect("keeps startup available when the early desktop entry cannot be written", () => { |
| 134 | 132 | getSwitchValueMock.mockReturnValue(""); |
apps/desktop/src/app/DesktopUserData.test.ts+34-36| ... | ... | @@ -37,39 +37,37 @@ it.effect("identifies a failed source read and preserves its cause", () => { |
| 37 | 37 | ); |
| 38 | 38 | }); |
| 39 | 39 | |
| 40 | for (const sourceName of ["t3code", "T3 Code (Alpha)"]) { | |
| 41 | it.effect( | |
| 42 | `preserves Windows credential keys from ${sourceName} without copying browser databases`, | |
| 43 | () => | |
| 44 | Effect.gen(function* () { | |
| 45 | const fs = yield* FileSystem.FileSystem; | |
| 46 | const path = yield* Path.Path; | |
| 47 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); | |
| 48 | const source = path.join(directory, sourceName); | |
| 49 | const destination = path.join(directory, "t3code-v2"); | |
| 50 | const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; | |
| 51 | yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); | |
| 52 | yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); | |
| 53 | yield* fs.writeFileString(path.join(source, "Local State"), state); | |
| 54 | yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); | |
| 55 | yield* resolveUserDataPath({ | |
| 56 | appDataDirectory: directory, | |
| 57 | isDevelopment: false, | |
| 58 | platform: "win32", | |
| 59 | }); | |
| 60 | assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state); | |
| 61 | assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state); | |
| 62 | assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB"))); | |
| 63 | yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state"); | |
| 64 | yield* resolveUserDataPath({ | |
| 65 | appDataDirectory: directory, | |
| 66 | isDevelopment: false, | |
| 67 | platform: "win32", | |
| 68 | }); | |
| 69 | assert.equal( | |
| 70 | yield* fs.readFileString(path.join(destination, "Local State")), | |
| 71 | "existing V2 state", | |
| 72 | ); | |
| 73 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 74 | ); | |
| 75 | } | |
| 40 | it.effect.each(["t3code", "T3 Code (Alpha)"])( | |
| 41 | "preserves Windows credential keys from %s without copying browser databases", | |
| 42 | (sourceName) => | |
| 43 | Effect.gen(function* () { | |
| 44 | const fs = yield* FileSystem.FileSystem; | |
| 45 | const path = yield* Path.Path; | |
| 46 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); | |
| 47 | const source = path.join(directory, sourceName); | |
| 48 | const destination = path.join(directory, "t3code-v2"); | |
| 49 | const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; | |
| 50 | yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); | |
| 51 | yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); | |
| 52 | yield* fs.writeFileString(path.join(source, "Local State"), state); | |
| 53 | yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); | |
| 54 | yield* resolveUserDataPath({ | |
| 55 | appDataDirectory: directory, | |
| 56 | isDevelopment: false, | |
| 57 | platform: "win32", | |
| 58 | }); | |
| 59 | assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state); | |
| 60 | assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state); | |
| 61 | assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB"))); | |
| 62 | yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state"); | |
| 63 | yield* resolveUserDataPath({ | |
| 64 | appDataDirectory: directory, | |
| 65 | isDevelopment: false, | |
| 66 | platform: "win32", | |
| 67 | }); | |
| 68 | assert.equal( | |
| 69 | yield* fs.readFileString(path.join(destination, "Local State")), | |
| 70 | "existing V2 state", | |
| 71 | ); | |
| 72 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 73 | ); |
apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts+36-38| ... | ... | @@ -386,45 +386,43 @@ describe("parseFirefoxProfiles", () => { |
| 386 | 386 | }), |
| 387 | 387 | ); |
| 388 | 388 | |
| 389 | for (const [platform, root] of [ | |
| 390 | ["Linux", "/home/user/.mozilla/firefox"], | |
| 391 | ["macOS", "/Users/user/Library/Application Support/Firefox"], | |
| 392 | ] as const) { | |
| 393 | it.effect(`validates relative and absolute ${platform} profile paths`, () => | |
| 394 | Effect.gen(function* () { | |
| 395 | const parsed = yield* parsePosixFirefoxProfiles( | |
| 396 | [ | |
| 397 | "[Profile0]", | |
| 398 | "Name=Relative", | |
| 399 | "IsRelative=1", | |
| 400 | "Path=Profiles/relative.default", | |
| 401 | "[Profile1]", | |
| 402 | "Name=Custom", | |
| 403 | "IsRelative=0", | |
| 404 | "Path=/mnt/custom/firefox-profile", | |
| 405 | "[Profile2]", | |
| 406 | "IsRelative=1", | |
| 407 | "Path=../../escape", | |
| 408 | "[Profile3]", | |
| 409 | "IsRelative=1", | |
| 410 | "Path=/absolute-marked-relative", | |
| 411 | "[Profile4]", | |
| 412 | "IsRelative=0", | |
| 413 | "Path=relative-marked-absolute", | |
| 414 | "[Profile5]", | |
| 415 | "IsRelative=1", | |
| 416 | "Path=Profiles/nul\u0000escape", | |
| 417 | ].join("\n"), | |
| 418 | root, | |
| 419 | ); | |
| 389 | it.effect.each([ | |
| 390 | { platform: "Linux", root: "/home/user/.mozilla/firefox" }, | |
| 391 | { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" }, | |
| 392 | ])("validates relative and absolute $platform profile paths", ({ root }) => | |
| 393 | Effect.gen(function* () { | |
| 394 | const parsed = yield* parsePosixFirefoxProfiles( | |
| 395 | [ | |
| 396 | "[Profile0]", | |
| 397 | "Name=Relative", | |
| 398 | "IsRelative=1", | |
| 399 | "Path=Profiles/relative.default", | |
| 400 | "[Profile1]", | |
| 401 | "Name=Custom", | |
| 402 | "IsRelative=0", | |
| 403 | "Path=/mnt/custom/firefox-profile", | |
| 404 | "[Profile2]", | |
| 405 | "IsRelative=1", | |
| 406 | "Path=../../escape", | |
| 407 | "[Profile3]", | |
| 408 | "IsRelative=1", | |
| 409 | "Path=/absolute-marked-relative", | |
| 410 | "[Profile4]", | |
| 411 | "IsRelative=0", | |
| 412 | "Path=relative-marked-absolute", | |
| 413 | "[Profile5]", | |
| 414 | "IsRelative=1", | |
| 415 | "Path=Profiles/nul\u0000escape", | |
| 416 | ].join("\n"), | |
| 417 | root, | |
| 418 | ); | |
| 420 | 419 | |
| 421 | expect(parsed).toEqual([ | |
| 422 | { directory: "Profiles/relative.default", name: "Relative" }, | |
| 423 | { directory: "/mnt/custom/firefox-profile", name: "Custom" }, | |
| 424 | ]); | |
| 425 | }), | |
| 426 | ); | |
| 427 | } | |
| 420 | expect(parsed).toEqual([ | |
| 421 | { directory: "Profiles/relative.default", name: "Relative" }, | |
| 422 | { directory: "/mnt/custom/firefox-profile", name: "Custom" }, | |
| 423 | ]); | |
| 424 | }), | |
| 425 | ); | |
| 428 | 426 | |
| 429 | 427 | it.effect("uses Windows path rules for relative and absolute profiles", () => |
| 430 | 428 | Effect.gen(function* () { |
apps/desktop/src/preview/BrowserImport/Sources.test.ts+8-8| ... | ... | @@ -763,8 +763,9 @@ describe("listSourceProfiles Firefox fallback", () => { |
| 763 | 763 | { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") }, |
| 764 | 764 | ]; |
| 765 | 765 | |
| 766 | for (const { platform, profileDirectory } of cases) { | |
| 767 | it.effect(`scans the ${platform} profile location and excludes stale entries`, () => | |
| 766 | it.effect.each(cases)( | |
| 767 | "scans the $platform profile location and excludes stale entries", | |
| 768 | ({ platform, profileDirectory }) => | |
| 768 | 769 | run( |
| 769 | 770 | Effect.gen(function* () { |
| 770 | 771 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -800,8 +801,7 @@ describe("listSourceProfiles Firefox fallback", () => { |
| 800 | 801 | ]); |
| 801 | 802 | }), |
| 802 | 803 | ), |
| 803 | ); | |
| 804 | } | |
| 804 | ); | |
| 805 | 805 | |
| 806 | 806 | it.effect("scans for profiles when profiles.ini declares only ones without cookies", () => |
| 807 | 807 | run( |
| ... | ... | @@ -1166,8 +1166,9 @@ describe("Safari profiles", () => { |
| 1166 | 1166 | ), |
| 1167 | 1167 | ); |
| 1168 | 1168 | |
| 1169 | for (const metadataState of ["missing", "corrupt"] as const) { | |
| 1170 | it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () => | |
| 1169 | it.effect.each(["missing", "corrupt"] as const)( | |
| 1170 | "recovers separate cookie stores when metadata is %s", | |
| 1171 | (metadataState) => | |
| 1171 | 1172 | run( |
| 1172 | 1173 | Effect.gen(function* () { |
| 1173 | 1174 | const { context, store, metadata } = yield* fixture(); |
| ... | ... | @@ -1181,8 +1182,7 @@ describe("Safari profiles", () => { |
| 1181 | 1182 | assert.isTrue(yield* isSourceInstalled(safari, context)); |
| 1182 | 1183 | }), |
| 1183 | 1184 | ), |
| 1184 | ); | |
| 1185 | } | |
| 1185 | ); | |
| 1186 | 1186 | |
| 1187 | 1187 | it.effect("keeps Safari without profiles available", () => |
| 1188 | 1188 | run( |
apps/desktop/src/settings/DesktopClientSettings.test.ts+67-71| ... | ... | @@ -149,58 +149,56 @@ describe("DesktopClientSettings", () => { |
| 149 | 149 | ), |
| 150 | 150 | ); |
| 151 | 151 | |
| 152 | for (const failure of [ | |
| 152 | it.effect.each([ | |
| 153 | 153 | { label: "permission", reason: "PermissionDenied" }, |
| 154 | 154 | { label: "I/O", reason: "Unknown" }, |
| 155 | ] as const) { | |
| 156 | it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => | |
| 157 | withClientSettings( | |
| 158 | Effect.gen(function* () { | |
| 159 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 160 | const fileSystem = yield* FileSystem.FileSystem; | |
| 161 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 162 | const savedSettings = { | |
| 163 | ...clientSettings, | |
| 164 | onboardingCompletedAt: "2026-09-05T12:00:00.000Z", | |
| 165 | }; | |
| 166 | yield* settings.set(savedSettings); | |
| 167 | const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); | |
| 168 | const cause = PlatformError.systemError({ | |
| 169 | _tag: failure.reason, | |
| 170 | module: "FileSystem", | |
| 171 | method: "readFileString", | |
| 172 | pathOrDescriptor: environment.clientSettingsPath, | |
| 173 | }); | |
| 174 | let failRead = true; | |
| 175 | const retryableSettings = yield* DesktopClientSettings.make.pipe( | |
| 176 | Effect.provideService( | |
| 177 | FileSystem.FileSystem, | |
| 178 | FileSystem.FileSystem.of({ | |
| 179 | ...fileSystem, | |
| 180 | readFileString: (path) => | |
| 181 | Effect.suspend(() => | |
| 182 | failRead ? Effect.fail(cause) : fileSystem.readFileString(path), | |
| 183 | ), | |
| 184 | }), | |
| 185 | ), | |
| 186 | ); | |
| 155 | ] as const)("preserves saved preferences across $label read failures and retries", (failure) => | |
| 156 | withClientSettings( | |
| 157 | Effect.gen(function* () { | |
| 158 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 159 | const fileSystem = yield* FileSystem.FileSystem; | |
| 160 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 161 | const savedSettings = { | |
| 162 | ...clientSettings, | |
| 163 | onboardingCompletedAt: "2026-09-05T12:00:00.000Z", | |
| 164 | }; | |
| 165 | yield* settings.set(savedSettings); | |
| 166 | const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); | |
| 167 | const cause = PlatformError.systemError({ | |
| 168 | _tag: failure.reason, | |
| 169 | module: "FileSystem", | |
| 170 | method: "readFileString", | |
| 171 | pathOrDescriptor: environment.clientSettingsPath, | |
| 172 | }); | |
| 173 | let failRead = true; | |
| 174 | const retryableSettings = yield* DesktopClientSettings.make.pipe( | |
| 175 | Effect.provideService( | |
| 176 | FileSystem.FileSystem, | |
| 177 | FileSystem.FileSystem.of({ | |
| 178 | ...fileSystem, | |
| 179 | readFileString: (path) => | |
| 180 | Effect.suspend(() => | |
| 181 | failRead ? Effect.fail(cause) : fileSystem.readFileString(path), | |
| 182 | ), | |
| 183 | }), | |
| 184 | ), | |
| 185 | ); | |
| 187 | 186 | |
| 188 | const error = yield* retryableSettings.get.pipe(Effect.flip); | |
| 189 | assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); | |
| 190 | assert.equal(error.operation, "read-file"); | |
| 191 | assert.equal(error.path, environment.clientSettingsPath); | |
| 192 | assert.strictEqual(error.cause, cause); | |
| 193 | assert.equal( | |
| 194 | yield* fileSystem.readFileString(environment.clientSettingsPath), | |
| 195 | savedContents, | |
| 196 | ); | |
| 187 | const error = yield* retryableSettings.get.pipe(Effect.flip); | |
| 188 | assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); | |
| 189 | assert.equal(error.operation, "read-file"); | |
| 190 | assert.equal(error.path, environment.clientSettingsPath); | |
| 191 | assert.strictEqual(error.cause, cause); | |
| 192 | assert.equal( | |
| 193 | yield* fileSystem.readFileString(environment.clientSettingsPath), | |
| 194 | savedContents, | |
| 195 | ); | |
| 197 | 196 | |
| 198 | failRead = false; | |
| 199 | assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); | |
| 200 | }), | |
| 201 | ), | |
| 202 | ); | |
| 203 | } | |
| 197 | failRead = false; | |
| 198 | assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); | |
| 199 | }), | |
| 200 | ), | |
| 201 | ); | |
| 204 | 202 | |
| 205 | 203 | it.effect("reports the failed client settings write operation and path", () => |
| 206 | 204 | withClientSettings( |
| ... | ... | @@ -288,31 +286,29 @@ describe("DesktopClientSettings", () => { |
| 288 | 286 | ), |
| 289 | 287 | ); |
| 290 | 288 | |
| 291 | for (const document of [ | |
| 289 | it.effect.each([ | |
| 292 | 290 | { label: "malformed JSON", contents: "{not-json" }, |
| 293 | 291 | { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, |
| 294 | 292 | { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, |
| 295 | ]) { | |
| 296 | it.effect(`reports ${document.label} without treating the settings file as absent`, () => | |
| 297 | withClientSettings( | |
| 298 | Effect.gen(function* () { | |
| 299 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 300 | const fileSystem = yield* FileSystem.FileSystem; | |
| 301 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 302 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 303 | yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); | |
| 293 | ])("reports $label without treating the settings file as absent", (document) => | |
| 294 | withClientSettings( | |
| 295 | Effect.gen(function* () { | |
| 296 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 297 | const fileSystem = yield* FileSystem.FileSystem; | |
| 298 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 299 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 300 | yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); | |
| 304 | 301 | |
| 305 | const error = yield* settings.get.pipe(Effect.flip); | |
| 306 | assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); | |
| 307 | assert.equal(error.operation, "decode-document"); | |
| 308 | assert.equal(error.path, environment.clientSettingsPath); | |
| 309 | assert.instanceOf(error.cause, Schema.SchemaError); | |
| 310 | assert.equal( | |
| 311 | yield* fileSystem.readFileString(environment.clientSettingsPath), | |
| 312 | document.contents, | |
| 313 | ); | |
| 314 | }), | |
| 315 | ), | |
| 316 | ); | |
| 317 | } | |
| 302 | const error = yield* settings.get.pipe(Effect.flip); | |
| 303 | assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); | |
| 304 | assert.equal(error.operation, "decode-document"); | |
| 305 | assert.equal(error.path, environment.clientSettingsPath); | |
| 306 | assert.instanceOf(error.cause, Schema.SchemaError); | |
| 307 | assert.equal( | |
| 308 | yield* fileSystem.readFileString(environment.clientSettingsPath), | |
| 309 | document.contents, | |
| 310 | ); | |
| 311 | }), | |
| 312 | ), | |
| 313 | ); | |
| 318 | 314 | }); |
apps/desktop/src/snapShot/DesktopSnapShot.test.ts+5-4| ... | ... | @@ -3873,8 +3873,9 @@ it.effect("waits to apply settings while permissions are pending", () => { |
| 3873 | 3873 | ).pipe(Effect.provide(layer)); |
| 3874 | 3874 | }); |
| 3875 | 3875 | |
| 3876 | for (const fails of [false, true]) { | |
| 3877 | it.effect(`tests macOS capture without publishing it and cleans up, failure=${fails}`, () => { | |
| 3876 | it.effect.each([false, true])( | |
| 3877 | "tests macOS capture without publishing it and cleans up, failure=%s", | |
| 3878 | (fails) => { | |
| 3878 | 3879 | const active = { |
| 3879 | 3880 | platform: "macos", |
| 3880 | 3881 | id: 42, |
| ... | ... | @@ -3915,8 +3916,8 @@ for (const fails of [false, true]) { |
| 3915 | 3916 | }), |
| 3916 | 3917 | ), |
| 3917 | 3918 | ); |
| 3918 | }); | |
| 3919 | } | |
| 3919 | }, | |
| 3920 | ); | |
| 3920 | 3921 | |
| 3921 | 3922 | it.effect("rejects macOS test capture on other platforms", () => |
| 3922 | 3923 | Effect.scoped( |
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+48-50| ... | ... | @@ -949,59 +949,57 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 949 | 949 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 950 | 950 | expect(widgetMocks.start).toHaveBeenCalledTimes(1); |
| 951 | 951 | }); |
| 952 | for (const os of ["ios", "android"] as const) { | |
| 953 | it.effect( | |
| 954 | `does not enable ${os} notifications when a token rotates after permission is revoked`, | |
| 955 | () => { | |
| 956 | vi.spyOn(Platform, "OS", "get").mockReturnValue(os); | |
| 957 | vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); | |
| 958 | vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ | |
| 959 | type: os, | |
| 960 | data: "initial", | |
| 952 | it.effect.each(["ios", "android"] as const)( | |
| 953 | "does not enable %s notifications when a token rotates after permission is revoked", | |
| 954 | (os) => { | |
| 955 | vi.spyOn(Platform, "OS", "get").mockReturnValue(os); | |
| 956 | vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); | |
| 957 | vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ | |
| 958 | type: os, | |
| 959 | data: "initial", | |
| 960 | }); | |
| 961 | const registrations: unknown[] = []; | |
| 962 | vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 963 | const request = new Request(input, init); | |
| 964 | if (request.url.endsWith("/v1/client/dpop-token")) { | |
| 965 | return Response.json({ | |
| 966 | access_token: "dpop", | |
| 967 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 968 | token_type: "DPoP", | |
| 969 | expires_in: 300, | |
| 970 | scope: "mobile:registration", | |
| 971 | }); | |
| 972 | } | |
| 973 | registrations.push(await request.json()); | |
| 974 | return Response.json({ ok: true }); | |
| 975 | }); | |
| 976 | Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; | |
| 977 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); | |
| 978 | return Effect.gen(function* () { | |
| 979 | yield* runBackgroundOperations(); | |
| 980 | expect(registrations.at(-1)).toMatchObject({ | |
| 981 | preferences: { notificationsEnabled: true }, | |
| 961 | 982 | }); |
| 962 | const registrations: unknown[] = []; | |
| 963 | vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 964 | const request = new Request(input, init); | |
| 965 | if (request.url.endsWith("/v1/client/dpop-token")) { | |
| 966 | return Response.json({ | |
| 967 | access_token: "dpop", | |
| 968 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 969 | token_type: "DPoP", | |
| 970 | expires_in: 300, | |
| 971 | scope: "mobile:registration", | |
| 972 | }); | |
| 973 | } | |
| 974 | registrations.push(await request.json()); | |
| 975 | return Response.json({ ok: true }); | |
| 983 | vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ | |
| 984 | granted: false, | |
| 985 | } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>); | |
| 986 | const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; | |
| 987 | listener({ type: os, data: "rotated" }); | |
| 988 | yield* runBackgroundOperations(); | |
| 989 | expect(registrations.at(-1)).toMatchObject({ | |
| 990 | preferences: { notificationsEnabled: false }, | |
| 976 | 991 | }); |
| 977 | Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; | |
| 978 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); | |
| 979 | return Effect.gen(function* () { | |
| 980 | yield* runBackgroundOperations(); | |
| 981 | expect(registrations.at(-1)).toMatchObject({ | |
| 982 | preferences: { notificationsEnabled: true }, | |
| 983 | }); | |
| 984 | vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ | |
| 985 | granted: false, | |
| 986 | } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>); | |
| 987 | const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; | |
| 988 | listener({ type: os, data: "rotated" }); | |
| 989 | yield* runBackgroundOperations(); | |
| 990 | expect(registrations.at(-1)).toMatchObject({ | |
| 991 | preferences: { notificationsEnabled: false }, | |
| 992 | }); | |
| 993 | expect(registrations.at(-1)).not.toHaveProperty("pushToken"); | |
| 994 | }).pipe( | |
| 995 | Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), | |
| 996 | Effect.provide( | |
| 997 | managedRelayClientLayer("https://permission-relay.example.test").pipe( | |
| 998 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), | |
| 999 | ), | |
| 992 | expect(registrations.at(-1)).not.toHaveProperty("pushToken"); | |
| 993 | }).pipe( | |
| 994 | Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), | |
| 995 | Effect.provide( | |
| 996 | managedRelayClientLayer("https://permission-relay.example.test").pipe( | |
| 997 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), | |
| 1000 | 998 | ), |
| 1001 | ); | |
| 1002 | }, | |
| 1003 | ); | |
| 1004 | } | |
| 999 | ), | |
| 1000 | ); | |
| 1001 | }, | |
| 1002 | ); | |
| 1005 | 1003 | it.effect("preserves relay rejection errors with React Native response headers", () => { |
| 1006 | 1004 | vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); |
| 1007 | 1005 | vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ |
apps/server/src/cli/app.test.ts+4-4| ... | ... | @@ -256,8 +256,9 @@ describe("t3 app", () => { |
| 256 | 256 | ), |
| 257 | 257 | ); |
| 258 | 258 | |
| 259 | for (const responseKind of ["failure", "invalid"] as const) { | |
| 260 | it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () => | |
| 259 | it.effect.each(["failure", "invalid"] as const)( | |
| 260 | "never falls back after the default desktop sends a %s response", | |
| 261 | (responseKind) => | |
| 261 | 262 | withTempDirectory("t3-app-response-test-", (root) => |
| 262 | 263 | Effect.gen(function* () { |
| 263 | 264 | vi.mocked(NodeOS.homedir).mockReturnValue(root); |
| ... | ... | @@ -302,6 +303,5 @@ describe("t3 app", () => { |
| 302 | 303 | } |
| 303 | 304 | }).pipe(Effect.scoped), |
| 304 | 305 | ), |
| 305 | ); | |
| 306 | } | |
| 306 | ); | |
| 307 | 307 | }); |
apps/server/src/device/DeviceService.test.ts+33-23| ... | ... | @@ -361,29 +361,39 @@ describe("device discovery after server restart", () => { |
| 361 | 361 | ); |
| 362 | 362 | }); |
| 363 | 363 | |
| 364 | for (const [diagnostic, reason, message] of [ | |
| 365 | ["Insufficient disk space at /private/user/path", "disk_space", "not enough free disk space"], | |
| 366 | ["Timed out spawning /private/user/command", "timeout", "did not become ready in time"], | |
| 367 | ["Unexpected failure: secret-token", "launch_failed", "could not start"], | |
| 368 | ] as const) { | |
| 369 | it.effect(`normalizes boot failure: ${reason}`, () => | |
| 370 | Effect.gen(function* () { | |
| 371 | const { service } = yield* fixture(Effect.void, diagnostic); | |
| 372 | yield* service.configure({ enabled: true }); | |
| 373 | const error = yield* service | |
| 374 | .open({ | |
| 375 | threadId: ThreadId.make("boot-failure"), | |
| 376 | deviceId: "Pixel_API_35", | |
| 377 | platform: "android", | |
| 378 | }) | |
| 379 | .pipe(Effect.flip); | |
| 380 | expect(error._tag).toBe("DeviceBootError"); | |
| 381 | expect(error.message).toContain(message); | |
| 382 | expect(error.message).not.toContain(diagnostic); | |
| 383 | expect((yield* service.state).bootingDevices).toEqual([]); | |
| 384 | }).pipe(Effect.scoped), | |
| 385 | ); | |
| 386 | } | |
| 364 | it.effect.each([ | |
| 365 | { | |
| 366 | diagnostic: "Insufficient disk space at /private/user/path", | |
| 367 | reason: "disk_space", | |
| 368 | message: "not enough free disk space", | |
| 369 | }, | |
| 370 | { | |
| 371 | diagnostic: "Timed out spawning /private/user/command", | |
| 372 | reason: "timeout", | |
| 373 | message: "did not become ready in time", | |
| 374 | }, | |
| 375 | { | |
| 376 | diagnostic: "Unexpected failure: secret-token", | |
| 377 | reason: "launch_failed", | |
| 378 | message: "could not start", | |
| 379 | }, | |
| 380 | ] as const)("normalizes boot failure: $reason", ({ diagnostic, message }) => | |
| 381 | Effect.gen(function* () { | |
| 382 | const { service } = yield* fixture(Effect.void, diagnostic); | |
| 383 | yield* service.configure({ enabled: true }); | |
| 384 | const error = yield* service | |
| 385 | .open({ | |
| 386 | threadId: ThreadId.make("boot-failure"), | |
| 387 | deviceId: "Pixel_API_35", | |
| 388 | platform: "android", | |
| 389 | }) | |
| 390 | .pipe(Effect.flip); | |
| 391 | expect(error._tag).toBe("DeviceBootError"); | |
| 392 | expect(error.message).toContain(message); | |
| 393 | expect(error.message).not.toContain(diagnostic); | |
| 394 | expect((yield* service.state).bootingDevices).toEqual([]); | |
| 395 | }).pipe(Effect.scoped), | |
| 396 | ); | |
| 387 | 397 | |
| 388 | 398 | it.effect("keeps shutdown successful when subsequent discovery fails", () => |
| 389 | 399 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+8-8| ... | ... | @@ -604,8 +604,9 @@ function makeTurnInput(input: { |
| 604 | 604 | } |
| 605 | 605 | |
| 606 | 606 | describe("AcpAdapterV2", () => { |
| 607 | for (const outcome of ["failed", "recovered", "completed", "cancelled"] as const) { | |
| 608 | it.live(`projects Mistral retry notices and their ${outcome} outcome`, () => | |
| 607 | it.live.each(["failed", "recovered", "completed", "cancelled"] as const)( | |
| 608 | "projects Mistral retry notices and their %s outcome", | |
| 609 | (outcome) => | |
| 609 | 610 | Effect.gen(function* () { |
| 610 | 611 | const path = yield* Path.Path; |
| 611 | 612 | const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`); |
| ... | ... | @@ -692,8 +693,7 @@ describe("AcpAdapterV2", () => { |
| 692 | 693 | assert.equal(retries.at(-1)?.title, "Provider recovered"); |
| 693 | 694 | } |
| 694 | 695 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 695 | ); | |
| 696 | } | |
| 696 | ); | |
| 697 | 697 | |
| 698 | 698 | it("preserves legacy ids and scopes v2 ids by provider instance", () => { |
| 699 | 699 | const instanceId = ProviderInstanceId.make("acp-identity-test"); |
| ... | ... | @@ -2914,8 +2914,9 @@ describe("AcpAdapterV2", () => { |
| 2914 | 2914 | }).pipe(Effect.provide(testLayer)), |
| 2915 | 2915 | ); |
| 2916 | 2916 | |
| 2917 | for (const model of ["grok-build", "composer-2"]) { | |
| 2918 | it.effect(`Grok configures the native session for ${model}`, () => | |
| 2917 | it.effect.each(["grok-build", "composer-2"])( | |
| 2918 | "Grok configures the native session for %s", | |
| 2919 | (model) => | |
| 2919 | 2920 | Effect.gen(function* () { |
| 2920 | 2921 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 2921 | 2922 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -2968,8 +2969,7 @@ describe("AcpAdapterV2", () => { |
| 2968 | 2969 | model === "grok-build" ? 0 : 1, |
| 2969 | 2970 | ); |
| 2970 | 2971 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 2971 | ); | |
| 2972 | } | |
| 2972 | ); | |
| 2973 | 2973 | |
| 2974 | 2974 | it.live("Grok reapplies an explicit return to the session's setup-time model", () => |
| 2975 | 2975 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+120-122| ... | ... | @@ -2252,78 +2252,78 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2252 | 2252 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 2253 | 2253 | ); |
| 2254 | 2254 | |
| 2255 | for (const terminalReason of ["aborted_tools", "aborted_streaming"] as const) { | |
| 2256 | for (const steered of [true, false]) { | |
| 2257 | it.effect(`handles ${terminalReason} with active steering=${steered}`, () => | |
| 2258 | Effect.scoped( | |
| 2259 | Effect.gen(function* () { | |
| 2260 | const harness = yield* makeWakeHarness; | |
| 2261 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2262 | const attemptId = RunAttemptId.make("attempt-steering-abort"); | |
| 2263 | const input = makeClaudeTestTurnInput({ | |
| 2264 | threadId: harness.threadId, | |
| 2265 | providerThread: harness.providerThread, | |
| 2266 | now: yield* DateTime.now, | |
| 2267 | attemptId, | |
| 2268 | text: "Audit the settings pages.", | |
| 2255 | it.effect.each( | |
| 2256 | (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => | |
| 2257 | [true, false].map((steered) => ({ terminalReason, steered })), | |
| 2258 | ), | |
| 2259 | )("handles $terminalReason with active steering=$steered", ({ terminalReason, steered }) => | |
| 2260 | Effect.scoped( | |
| 2261 | Effect.gen(function* () { | |
| 2262 | const harness = yield* makeWakeHarness; | |
| 2263 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2264 | const attemptId = RunAttemptId.make("attempt-steering-abort"); | |
| 2265 | const input = makeClaudeTestTurnInput({ | |
| 2266 | threadId: harness.threadId, | |
| 2267 | providerThread: harness.providerThread, | |
| 2268 | now: yield* DateTime.now, | |
| 2269 | attemptId, | |
| 2270 | text: "Audit the settings pages.", | |
| 2271 | attachments: [], | |
| 2272 | }); | |
| 2273 | yield* harness.runtime.startTurn(input); | |
| 2274 | if (steered) { | |
| 2275 | yield* harness.runtime.steerTurn({ | |
| 2276 | threadId: harness.threadId, | |
| 2277 | runId: input.runId, | |
| 2278 | providerThread: harness.providerThread, | |
| 2279 | providerTurnId: idAllocator.derive.providerTurn({ | |
| 2280 | driver: ClaudeAdapterV2.CLAUDE_PROVIDER, | |
| 2281 | nativeTurnId: `turn:${attemptId}`, | |
| 2282 | }), | |
| 2283 | message: { | |
| 2284 | createdBy: "user", | |
| 2285 | creationSource: "web", | |
| 2286 | messageId: MessageId.make("message-steering-abort"), | |
| 2287 | text: "Include the hierarchy mock.", | |
| 2269 | 2288 | attachments: [], |
| 2270 | }); | |
| 2271 | yield* harness.runtime.startTurn(input); | |
| 2272 | if (steered) { | |
| 2273 | yield* harness.runtime.steerTurn({ | |
| 2274 | threadId: harness.threadId, | |
| 2275 | runId: input.runId, | |
| 2276 | providerThread: harness.providerThread, | |
| 2277 | providerTurnId: idAllocator.derive.providerTurn({ | |
| 2278 | driver: ClaudeAdapterV2.CLAUDE_PROVIDER, | |
| 2279 | nativeTurnId: `turn:${attemptId}`, | |
| 2280 | }), | |
| 2281 | message: { | |
| 2282 | createdBy: "user", | |
| 2283 | creationSource: "web", | |
| 2284 | messageId: MessageId.make("message-steering-abort"), | |
| 2285 | text: "Include the hierarchy mock.", | |
| 2286 | attachments: [], | |
| 2287 | }, | |
| 2288 | }); | |
| 2289 | assert.equal(harness.offeredMessages[1]?.priority, "now"); | |
| 2290 | } | |
| 2291 | yield* Queue.offer( | |
| 2292 | harness.sdkMessages, | |
| 2293 | makeResultFrame({ | |
| 2294 | uuid: "00000000-0000-4000-8000-000000000901", | |
| 2295 | result: "", | |
| 2296 | terminalReason, | |
| 2297 | }), | |
| 2298 | ); | |
| 2299 | if (steered) { | |
| 2300 | yield* Queue.offer(harness.sdkMessages, wakeAssistant); | |
| 2301 | yield* Queue.offer( | |
| 2302 | harness.sdkMessages, | |
| 2303 | makeResultFrame({ | |
| 2304 | uuid: "00000000-0000-4000-8000-000000000902", | |
| 2305 | result: "Audit finished after the steer.", | |
| 2306 | }), | |
| 2307 | ); | |
| 2308 | } | |
| 2309 | const terminal = yield* Queue.take(harness.terminalReceipts); | |
| 2310 | assert.equal(terminal.status, steered ? "completed" : "interrupted"); | |
| 2311 | if (steered) { | |
| 2312 | assert.isTrue( | |
| 2313 | harness.events.some( | |
| 2314 | (event) => | |
| 2315 | event.type === "turn_item.updated" && | |
| 2316 | event.turnItem.type === "assistant_message" && | |
| 2317 | event.turnItem.text === WAKE_ASSISTANT_TEXT, | |
| 2318 | ), | |
| 2319 | ); | |
| 2320 | } | |
| 2321 | assert.lengthOf(harness.terminalEvents(), 1); | |
| 2322 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | |
| 2323 | ), | |
| 2324 | ); | |
| 2325 | } | |
| 2326 | } | |
| 2289 | }, | |
| 2290 | }); | |
| 2291 | assert.equal(harness.offeredMessages[1]?.priority, "now"); | |
| 2292 | } | |
| 2293 | yield* Queue.offer( | |
| 2294 | harness.sdkMessages, | |
| 2295 | makeResultFrame({ | |
| 2296 | uuid: "00000000-0000-4000-8000-000000000901", | |
| 2297 | result: "", | |
| 2298 | terminalReason, | |
| 2299 | }), | |
| 2300 | ); | |
| 2301 | if (steered) { | |
| 2302 | yield* Queue.offer(harness.sdkMessages, wakeAssistant); | |
| 2303 | yield* Queue.offer( | |
| 2304 | harness.sdkMessages, | |
| 2305 | makeResultFrame({ | |
| 2306 | uuid: "00000000-0000-4000-8000-000000000902", | |
| 2307 | result: "Audit finished after the steer.", | |
| 2308 | }), | |
| 2309 | ); | |
| 2310 | } | |
| 2311 | const terminal = yield* Queue.take(harness.terminalReceipts); | |
| 2312 | assert.equal(terminal.status, steered ? "completed" : "interrupted"); | |
| 2313 | if (steered) { | |
| 2314 | assert.isTrue( | |
| 2315 | harness.events.some( | |
| 2316 | (event) => | |
| 2317 | event.type === "turn_item.updated" && | |
| 2318 | event.turnItem.type === "assistant_message" && | |
| 2319 | event.turnItem.text === WAKE_ASSISTANT_TEXT, | |
| 2320 | ), | |
| 2321 | ); | |
| 2322 | } | |
| 2323 | assert.lengthOf(harness.terminalEvents(), 1); | |
| 2324 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | |
| 2325 | ), | |
| 2326 | ); | |
| 2327 | 2327 | |
| 2328 | 2328 | it.effect("announces usage-limit pauses once per window and again on a new turn", () => |
| 2329 | 2329 | Effect.gen(function* () { |
| ... | ... | @@ -3187,7 +3187,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3187 | 3187 | ), |
| 3188 | 3188 | ); |
| 3189 | 3189 | |
| 3190 | for (const terminalReason of [ | |
| 3190 | it.effect.each([ | |
| 3191 | 3191 | "api_error", |
| 3192 | 3192 | "malformed_tool_use_exhausted", |
| 3193 | 3193 | "budget_exhausted", |
| ... | ... | @@ -3200,56 +3200,54 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3200 | 3200 | "image_error", |
| 3201 | 3201 | "model_error", |
| 3202 | 3202 | "overloaded_status", |
| 3203 | ] as const) { | |
| 3204 | it.effect(`fails a success-shaped Claude result with ${terminalReason}`, () => | |
| 3205 | Effect.scoped( | |
| 3206 | Effect.gen(function* () { | |
| 3207 | const harness = yield* makeWakeHarness; | |
| 3208 | const now = yield* DateTime.now; | |
| 3209 | yield* harness.runtime.startTurn( | |
| 3210 | makeClaudeTestTurnInput({ | |
| 3211 | threadId: harness.threadId, | |
| 3212 | providerThread: harness.providerThread, | |
| 3213 | now, | |
| 3214 | attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), | |
| 3215 | text: "Complete the task.", | |
| 3216 | attachments: [], | |
| 3217 | }), | |
| 3218 | ); | |
| 3219 | yield* Queue.offer( | |
| 3220 | harness.sdkMessages, | |
| 3221 | makeResultFrame({ | |
| 3222 | uuid: "00000000-0000-4000-8000-000000000205", | |
| 3223 | result: "Provider failure details.", | |
| 3224 | isError: false, | |
| 3225 | ...(terminalReason === "overloaded_status" | |
| 3226 | ? { apiErrorStatus: 529 } | |
| 3227 | : { terminalReason }), | |
| 3228 | }), | |
| 3229 | ); | |
| 3230 | const terminal = yield* Queue.take(harness.terminalReceipts); | |
| 3231 | assert.equal(terminal.status, "failed"); | |
| 3232 | if (terminal.status !== "failed") return; | |
| 3233 | assert.isNotEmpty(terminal.failure.message); | |
| 3234 | assert.equal( | |
| 3235 | terminal.failure.class, | |
| 3236 | terminalReason === "blocking_limit" ? "usage_limit" : "provider_error", | |
| 3237 | ); | |
| 3238 | assert.isFalse( | |
| 3239 | harness.events.some( | |
| 3240 | (event) => | |
| 3241 | event.type === "message.updated" && | |
| 3242 | event.message.text === "Provider failure details.", | |
| 3243 | ), | |
| 3244 | ); | |
| 3245 | assert.equal( | |
| 3246 | terminal.failure.code, | |
| 3247 | terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, | |
| 3248 | ); | |
| 3249 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | |
| 3250 | ), | |
| 3251 | ); | |
| 3252 | } | |
| 3203 | ] as const)("fails a success-shaped Claude result with %s", (terminalReason) => | |
| 3204 | Effect.scoped( | |
| 3205 | Effect.gen(function* () { | |
| 3206 | const harness = yield* makeWakeHarness; | |
| 3207 | const now = yield* DateTime.now; | |
| 3208 | yield* harness.runtime.startTurn( | |
| 3209 | makeClaudeTestTurnInput({ | |
| 3210 | threadId: harness.threadId, | |
| 3211 | providerThread: harness.providerThread, | |
| 3212 | now, | |
| 3213 | attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), | |
| 3214 | text: "Complete the task.", | |
| 3215 | attachments: [], | |
| 3216 | }), | |
| 3217 | ); | |
| 3218 | yield* Queue.offer( | |
| 3219 | harness.sdkMessages, | |
| 3220 | makeResultFrame({ | |
| 3221 | uuid: "00000000-0000-4000-8000-000000000205", | |
| 3222 | result: "Provider failure details.", | |
| 3223 | isError: false, | |
| 3224 | ...(terminalReason === "overloaded_status" | |
| 3225 | ? { apiErrorStatus: 529 } | |
| 3226 | : { terminalReason }), | |
| 3227 | }), | |
| 3228 | ); | |
| 3229 | const terminal = yield* Queue.take(harness.terminalReceipts); | |
| 3230 | assert.equal(terminal.status, "failed"); | |
| 3231 | if (terminal.status !== "failed") return; | |
| 3232 | assert.isNotEmpty(terminal.failure.message); | |
| 3233 | assert.equal( | |
| 3234 | terminal.failure.class, | |
| 3235 | terminalReason === "blocking_limit" ? "usage_limit" : "provider_error", | |
| 3236 | ); | |
| 3237 | assert.isFalse( | |
| 3238 | harness.events.some( | |
| 3239 | (event) => | |
| 3240 | event.type === "message.updated" && | |
| 3241 | event.message.text === "Provider failure details.", | |
| 3242 | ), | |
| 3243 | ); | |
| 3244 | assert.equal( | |
| 3245 | terminal.failure.code, | |
| 3246 | terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, | |
| 3247 | ); | |
| 3248 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | |
| 3249 | ), | |
| 3250 | ); | |
| 3253 | 3251 | |
| 3254 | 3252 | const providerThreadRosterEvents = (events: ReadonlyArray<ProviderAdapterV2Event>) => |
| 3255 | 3253 | events.filter( |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+317-316| ... | ... | @@ -1728,8 +1728,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 1728 | 1728 | }; |
| 1729 | 1729 | }); |
| 1730 | 1730 | |
| 1731 | for (const response of ["supported", "unsupported", "invalid"] as const) { | |
| 1732 | it.effect(`delivers native history with ${response} app-server protocol`, () => | |
| 1731 | it.effect.each(["supported", "unsupported", "invalid"] as const)( | |
| 1732 | "delivers native history with %s app-server protocol", | |
| 1733 | (response) => | |
| 1733 | 1734 | Effect.gen(function* () { |
| 1734 | 1735 | const nativeThreadId = `inject-${response}`; |
| 1735 | 1736 | const prompt = "Only the current request"; |
| ... | ... | @@ -1830,8 +1831,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 1830 | 1831 | assert.equal(requests.filter((method) => method === "turn/start").length, 1); |
| 1831 | 1832 | assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); |
| 1832 | 1833 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 1833 | ); | |
| 1834 | } | |
| 1834 | ); | |
| 1835 | 1835 | |
| 1836 | 1836 | it.effect("identifies sessions to Codex with the same client info as main", () => |
| 1837 | 1837 | Effect.gen(function* () { |
| ... | ... | @@ -2926,8 +2926,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2926 | 2926 | ), |
| 2927 | 2927 | ); |
| 2928 | 2928 | |
| 2929 | for (const terminalStatus of ["completed", "interrupted"] as const) { | |
| 2930 | it.effect(`retains Codex reasoning parts when the turn is ${terminalStatus}`, () => | |
| 2929 | it.effect.each(["completed", "interrupted"] as const)( | |
| 2930 | "retains Codex reasoning parts when the turn is %s", | |
| 2931 | (terminalStatus) => | |
| 2931 | 2932 | Effect.scoped( |
| 2932 | 2933 | Effect.gen(function* () { |
| 2933 | 2934 | const scenario = `codex-reasoning-${terminalStatus}`; |
| ... | ... | @@ -3087,8 +3088,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3087 | 3088 | assert.deepEqual(assistantMessages(harness.events), []); |
| 3088 | 3089 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 3089 | 3090 | ), |
| 3090 | ); | |
| 3091 | } | |
| 3091 | ); | |
| 3092 | 3092 | |
| 3093 | 3093 | const finalAnswerTranscript = ( |
| 3094 | 3094 | scenario: string, |
| ... | ... | @@ -3714,9 +3714,10 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3714 | 3714 | ), |
| 3715 | 3715 | ); |
| 3716 | 3716 | |
| 3717 | for (const terminated of [true, false, "still_running"] as const) { | |
| 3717 | const backgroundStopCases = [true, false, "still_running"] as const; | |
| 3718 | const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { | |
| 3718 | 3719 | const stillRunning = terminated === "still_running"; |
| 3719 | const transcript = makeCodexReplayTranscript({ | |
| 3720 | return makeCodexReplayTranscript({ | |
| 3720 | 3721 | scenario: `codex-bg-stop-${terminated}`, |
| 3721 | 3722 | entries: [ |
| 3722 | 3723 | ...backgroundExecTranscript.entries.slice(0, -1), |
| ... | ... | @@ -3796,9 +3797,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3796 | 3797 | backgroundExecTranscript.entries.at(-1)!, |
| 3797 | 3798 | ], |
| 3798 | 3799 | }); |
| 3800 | }; | |
| 3799 | 3801 | |
| 3800 | it.effect(`stops a command after root completion when termination returns ${terminated}`, () => | |
| 3801 | Effect.scoped( | |
| 3802 | it.effect.each(backgroundStopCases)( | |
| 3803 | "stops a command after root completion when termination returns %s", | |
| 3804 | (terminated) => { | |
| 3805 | const stillRunning = terminated === "still_running"; | |
| 3806 | const transcript = makeBackgroundStopTranscript(terminated); | |
| 3807 | return Effect.scoped( | |
| 3802 | 3808 | Effect.gen(function* () { |
| 3803 | 3809 | const stopped = yield* Deferred.make<void>(); |
| 3804 | 3810 | const harness = yield* makeCodexReplayHarness(transcript, (event) => |
| ... | ... | @@ -3852,140 +3858,137 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3852 | 3858 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 3853 | 3859 | assert.lengthOf(harness.continuationRequests, 0); |
| 3854 | 3860 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 3855 | ), | |
| 3856 | ); | |
| 3857 | if (terminated === true) { | |
| 3858 | it.effect("interrupts a completed run's background command through orchestration", () => | |
| 3859 | Effect.scoped( | |
| 3860 | Effect.gen(function* () { | |
| 3861 | const fs = yield* FileSystem.FileSystem; | |
| 3862 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); | |
| 3863 | const localTranscript = yield* decodeReplayTranscriptJson( | |
| 3864 | (yield* encodeReplayTranscriptJson(transcript)).replaceAll( | |
| 3865 | yield* encodeStringJson("/workspace"), | |
| 3866 | yield* encodeStringJson(cwd), | |
| 3867 | ), | |
| 3868 | ); | |
| 3869 | const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); | |
| 3870 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3871 | assert.equal( | |
| 3872 | Number( | |
| 3873 | yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd })), | |
| 3874 | ), | |
| 3875 | 0, | |
| 3876 | ); | |
| 3877 | assert.equal( | |
| 3878 | Number( | |
| 3879 | yield* spawner.exitCode( | |
| 3880 | ChildProcess.make( | |
| 3881 | "git", | |
| 3882 | [ | |
| 3883 | "-c", | |
| 3884 | "user.name=Test", | |
| 3885 | "-c", | |
| 3886 | "user.email=test@example.com", | |
| 3887 | "commit", | |
| 3888 | "--allow-empty", | |
| 3889 | "--quiet", | |
| 3890 | "-m", | |
| 3891 | "Initial commit", | |
| 3892 | ], | |
| 3893 | { cwd }, | |
| 3894 | ), | |
| 3895 | ), | |
| 3896 | ), | |
| 3897 | 0, | |
| 3898 | ); | |
| 3899 | yield* Effect.gen(function* () { | |
| 3900 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 3901 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 3902 | const threadId = ThreadId.make("thread:background-stop"); | |
| 3903 | yield* orchestrator.dispatch({ | |
| 3904 | type: "thread.create", | |
| 3905 | commandId: CommandId.make("create-background-stop"), | |
| 3906 | threadId, | |
| 3907 | projectId: ProjectId.make("project:background-stop"), | |
| 3908 | title: "Background stop", | |
| 3909 | modelSelection: CODEX_TEST_MODEL_SELECTION, | |
| 3910 | runtimeMode: "full-access", | |
| 3911 | interactionMode: "default", | |
| 3912 | branch: null, | |
| 3913 | worktreePath: cwd, | |
| 3914 | createdBy: "user", | |
| 3915 | creationSource: "web", | |
| 3916 | }); | |
| 3917 | const waiting = yield* orchestrator.streamDomainEvents.pipe( | |
| 3918 | Stream.filter( | |
| 3919 | (event) => event.type === "run.updated" && event.payload.status === "waiting", | |
| 3920 | ), | |
| 3921 | Stream.runHead, | |
| 3922 | Effect.forkChild({ startImmediately: true }), | |
| 3923 | ); | |
| 3924 | yield* orchestrator.dispatch({ | |
| 3925 | type: "message.dispatch", | |
| 3926 | commandId: CommandId.make("start-background-stop"), | |
| 3927 | threadId, | |
| 3928 | messageId: MessageId.make("message:background-stop"), | |
| 3929 | text: BG_PROMPT, | |
| 3930 | attachments: [], | |
| 3931 | createdBy: "user", | |
| 3932 | creationSource: "web", | |
| 3933 | dispatchMode: { type: "start_immediately" }, | |
| 3934 | }); | |
| 3935 | yield* worker.drain(); | |
| 3936 | assert.isNull((yield* Ref.get(replayDriver.state)).failure); | |
| 3937 | yield* Fiber.join(waiting); | |
| 3938 | yield* worker.drain(); | |
| 3939 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 3940 | const run = projection.runs.at(-1)!; | |
| 3941 | assert.equal(run.status, "completed"); | |
| 3942 | assert.equal( | |
| 3943 | (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, | |
| 3944 | 1, | |
| 3945 | ); | |
| 3946 | const stopped = yield* orchestrator.streamDomainEvents.pipe( | |
| 3947 | Stream.filter( | |
| 3948 | (event) => | |
| 3949 | event.type === "turn-item.updated" && | |
| 3950 | event.payload.type === "command_execution" && | |
| 3951 | event.payload.status === "interrupted", | |
| 3952 | ), | |
| 3953 | Stream.runHead, | |
| 3954 | Effect.forkChild({ startImmediately: true }), | |
| 3955 | ); | |
| 3956 | yield* orchestrator.dispatch({ | |
| 3957 | type: "run.interrupt", | |
| 3958 | commandId: CommandId.make("stop-background-command"), | |
| 3959 | threadId, | |
| 3960 | runId: run.id, | |
| 3961 | }); | |
| 3962 | yield* worker.drain(); | |
| 3963 | yield* Fiber.join(stopped); | |
| 3964 | assert.equal( | |
| 3965 | (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, | |
| 3966 | "completed", | |
| 3967 | ); | |
| 3968 | assert.deepEqual( | |
| 3969 | (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, | |
| 3970 | [], | |
| 3971 | ); | |
| 3972 | }).pipe( | |
| 3973 | Effect.provide( | |
| 3974 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 3975 | { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, | |
| 3976 | makeCodexProviderAdapterRegistryReplayLayer({ | |
| 3977 | transcript: localTranscript, | |
| 3978 | driver: replayDriver, | |
| 3979 | }), | |
| 3980 | { runEffectWorker: false }, | |
| 3981 | ), | |
| 3982 | ), | |
| 3983 | ); | |
| 3984 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 3985 | ), | |
| 3986 | 3861 | ); |
| 3987 | } | |
| 3988 | } | |
| 3862 | }, | |
| 3863 | ); | |
| 3864 | it.effect("interrupts a completed run's background command through orchestration", () => { | |
| 3865 | const transcript = makeBackgroundStopTranscript(true); | |
| 3866 | return Effect.scoped( | |
| 3867 | Effect.gen(function* () { | |
| 3868 | const fs = yield* FileSystem.FileSystem; | |
| 3869 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); | |
| 3870 | const localTranscript = yield* decodeReplayTranscriptJson( | |
| 3871 | (yield* encodeReplayTranscriptJson(transcript)).replaceAll( | |
| 3872 | yield* encodeStringJson("/workspace"), | |
| 3873 | yield* encodeStringJson(cwd), | |
| 3874 | ), | |
| 3875 | ); | |
| 3876 | const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); | |
| 3877 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3878 | assert.equal( | |
| 3879 | Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), | |
| 3880 | 0, | |
| 3881 | ); | |
| 3882 | assert.equal( | |
| 3883 | Number( | |
| 3884 | yield* spawner.exitCode( | |
| 3885 | ChildProcess.make( | |
| 3886 | "git", | |
| 3887 | [ | |
| 3888 | "-c", | |
| 3889 | "user.name=Test", | |
| 3890 | "-c", | |
| 3891 | "user.email=test@example.com", | |
| 3892 | "commit", | |
| 3893 | "--allow-empty", | |
| 3894 | "--quiet", | |
| 3895 | "-m", | |
| 3896 | "Initial commit", | |
| 3897 | ], | |
| 3898 | { cwd }, | |
| 3899 | ), | |
| 3900 | ), | |
| 3901 | ), | |
| 3902 | 0, | |
| 3903 | ); | |
| 3904 | yield* Effect.gen(function* () { | |
| 3905 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 3906 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 3907 | const threadId = ThreadId.make("thread:background-stop"); | |
| 3908 | yield* orchestrator.dispatch({ | |
| 3909 | type: "thread.create", | |
| 3910 | commandId: CommandId.make("create-background-stop"), | |
| 3911 | threadId, | |
| 3912 | projectId: ProjectId.make("project:background-stop"), | |
| 3913 | title: "Background stop", | |
| 3914 | modelSelection: CODEX_TEST_MODEL_SELECTION, | |
| 3915 | runtimeMode: "full-access", | |
| 3916 | interactionMode: "default", | |
| 3917 | branch: null, | |
| 3918 | worktreePath: cwd, | |
| 3919 | createdBy: "user", | |
| 3920 | creationSource: "web", | |
| 3921 | }); | |
| 3922 | const waiting = yield* orchestrator.streamDomainEvents.pipe( | |
| 3923 | Stream.filter( | |
| 3924 | (event) => event.type === "run.updated" && event.payload.status === "waiting", | |
| 3925 | ), | |
| 3926 | Stream.runHead, | |
| 3927 | Effect.forkChild({ startImmediately: true }), | |
| 3928 | ); | |
| 3929 | yield* orchestrator.dispatch({ | |
| 3930 | type: "message.dispatch", | |
| 3931 | commandId: CommandId.make("start-background-stop"), | |
| 3932 | threadId, | |
| 3933 | messageId: MessageId.make("message:background-stop"), | |
| 3934 | text: BG_PROMPT, | |
| 3935 | attachments: [], | |
| 3936 | createdBy: "user", | |
| 3937 | creationSource: "web", | |
| 3938 | dispatchMode: { type: "start_immediately" }, | |
| 3939 | }); | |
| 3940 | yield* worker.drain(); | |
| 3941 | assert.isNull((yield* Ref.get(replayDriver.state)).failure); | |
| 3942 | yield* Fiber.join(waiting); | |
| 3943 | yield* worker.drain(); | |
| 3944 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 3945 | const run = projection.runs.at(-1)!; | |
| 3946 | assert.equal(run.status, "completed"); | |
| 3947 | assert.equal( | |
| 3948 | (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, | |
| 3949 | 1, | |
| 3950 | ); | |
| 3951 | const stopped = yield* orchestrator.streamDomainEvents.pipe( | |
| 3952 | Stream.filter( | |
| 3953 | (event) => | |
| 3954 | event.type === "turn-item.updated" && | |
| 3955 | event.payload.type === "command_execution" && | |
| 3956 | event.payload.status === "interrupted", | |
| 3957 | ), | |
| 3958 | Stream.runHead, | |
| 3959 | Effect.forkChild({ startImmediately: true }), | |
| 3960 | ); | |
| 3961 | yield* orchestrator.dispatch({ | |
| 3962 | type: "run.interrupt", | |
| 3963 | commandId: CommandId.make("stop-background-command"), | |
| 3964 | threadId, | |
| 3965 | runId: run.id, | |
| 3966 | }); | |
| 3967 | yield* worker.drain(); | |
| 3968 | yield* Fiber.join(stopped); | |
| 3969 | assert.equal( | |
| 3970 | (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, | |
| 3971 | "completed", | |
| 3972 | ); | |
| 3973 | assert.deepEqual( | |
| 3974 | (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, | |
| 3975 | [], | |
| 3976 | ); | |
| 3977 | }).pipe( | |
| 3978 | Effect.provide( | |
| 3979 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 3980 | { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, | |
| 3981 | makeCodexProviderAdapterRegistryReplayLayer({ | |
| 3982 | transcript: localTranscript, | |
| 3983 | driver: replayDriver, | |
| 3984 | }), | |
| 3985 | { runEffectWorker: false }, | |
| 3986 | ), | |
| 3987 | ), | |
| 3988 | ); | |
| 3989 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 3990 | ); | |
| 3991 | }); | |
| 3989 | 3992 | |
| 3990 | 3993 | // The app-server exits after the root turn, before the command's own |
| 3991 | 3994 | // item/completed (Codex always sends one, so only a lost notification or a |
| ... | ... | @@ -5506,7 +5509,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 5506 | 5509 | ), |
| 5507 | 5510 | ); |
| 5508 | 5511 | |
| 5509 | for (const scenario of [ | |
| 5512 | it.effect.each([ | |
| 5510 | 5513 | { |
| 5511 | 5514 | name: "usage", |
| 5512 | 5515 | code: "usageLimitExceeded", |
| ... | ... | @@ -5557,192 +5560,190 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 5557 | 5560 | expectedClass: "usage_limit", |
| 5558 | 5561 | }, |
| 5559 | 5562 | { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, |
| 5560 | ] as const) { | |
| 5561 | it.effect(`classifies Codex terminal failures from ${scenario.name} evidence`, () => | |
| 5562 | Effect.scoped( | |
| 5563 | Effect.gen(function* () { | |
| 5564 | const nativeThreadId = `native-limit-${scenario.name}`; | |
| 5565 | const nativeTurnId = `turn-limit-${scenario.name}`; | |
| 5566 | const message = "Provider stopped this request."; | |
| 5567 | const resetAt = "2033-05-19T07:20:00.000Z"; | |
| 5568 | const snapshot = { | |
| 5569 | type: "emit_inbound" as const, | |
| 5570 | label: "account/rateLimits/updated", | |
| 5571 | frame: { | |
| 5572 | method: "account/rateLimits/updated", | |
| 5573 | params: { | |
| 5574 | rateLimits: { | |
| 5575 | limitId: "codex", | |
| 5576 | primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, | |
| 5577 | }, | |
| 5563 | ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => | |
| 5564 | Effect.scoped( | |
| 5565 | Effect.gen(function* () { | |
| 5566 | const nativeThreadId = `native-limit-${scenario.name}`; | |
| 5567 | const nativeTurnId = `turn-limit-${scenario.name}`; | |
| 5568 | const message = "Provider stopped this request."; | |
| 5569 | const resetAt = "2033-05-19T07:20:00.000Z"; | |
| 5570 | const snapshot = { | |
| 5571 | type: "emit_inbound" as const, | |
| 5572 | label: "account/rateLimits/updated", | |
| 5573 | frame: { | |
| 5574 | method: "account/rateLimits/updated", | |
| 5575 | params: { | |
| 5576 | rateLimits: { | |
| 5577 | limitId: "codex", | |
| 5578 | primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, | |
| 5578 | 5579 | }, |
| 5579 | 5580 | }, |
| 5580 | }; | |
| 5581 | const transcript = makeCodexReplayTranscript({ | |
| 5582 | scenario: `codex-limit-${scenario.name}`, | |
| 5583 | entries: [ | |
| 5584 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), | |
| 5585 | ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" | |
| 5586 | ? [snapshot] | |
| 5587 | : []), | |
| 5588 | ...(scenario.name === "deferred-reset" | |
| 5589 | ? [ | |
| 5590 | { | |
| 5591 | type: "emit_inbound" as const, | |
| 5592 | label: "item/completed/subAgentActivity-started", | |
| 5593 | frame: { | |
| 5594 | method: "item/completed", | |
| 5595 | params: { | |
| 5596 | threadId: nativeThreadId, | |
| 5597 | turnId: nativeTurnId, | |
| 5598 | item: { | |
| 5599 | type: "subAgentActivity", | |
| 5600 | id: "limit-child-spawn", | |
| 5601 | kind: "started", | |
| 5602 | agentThreadId: "native-limit-child", | |
| 5603 | agentPath: "/root/limit_child", | |
| 5604 | }, | |
| 5581 | }, | |
| 5582 | }; | |
| 5583 | const transcript = makeCodexReplayTranscript({ | |
| 5584 | scenario: `codex-limit-${scenario.name}`, | |
| 5585 | entries: [ | |
| 5586 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), | |
| 5587 | ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" | |
| 5588 | ? [snapshot] | |
| 5589 | : []), | |
| 5590 | ...(scenario.name === "deferred-reset" | |
| 5591 | ? [ | |
| 5592 | { | |
| 5593 | type: "emit_inbound" as const, | |
| 5594 | label: "item/completed/subAgentActivity-started", | |
| 5595 | frame: { | |
| 5596 | method: "item/completed", | |
| 5597 | params: { | |
| 5598 | threadId: nativeThreadId, | |
| 5599 | turnId: nativeTurnId, | |
| 5600 | item: { | |
| 5601 | type: "subAgentActivity", | |
| 5602 | id: "limit-child-spawn", | |
| 5603 | kind: "started", | |
| 5604 | agentThreadId: "native-limit-child", | |
| 5605 | agentPath: "/root/limit_child", | |
| 5605 | 5606 | }, |
| 5606 | 5607 | }, |
| 5607 | 5608 | }, |
| 5608 | { | |
| 5609 | type: "emit_inbound" as const, | |
| 5610 | label: "turn/started/child", | |
| 5611 | frame: { | |
| 5612 | method: "turn/started", | |
| 5613 | params: { | |
| 5614 | threadId: "native-limit-child", | |
| 5615 | turn: makeCodexReplayTurn({ | |
| 5616 | id: "limit-child-turn", | |
| 5617 | status: "inProgress", | |
| 5618 | }), | |
| 5619 | }, | |
| 5609 | }, | |
| 5610 | { | |
| 5611 | type: "emit_inbound" as const, | |
| 5612 | label: "turn/started/child", | |
| 5613 | frame: { | |
| 5614 | method: "turn/started", | |
| 5615 | params: { | |
| 5616 | threadId: "native-limit-child", | |
| 5617 | turn: makeCodexReplayTurn({ | |
| 5618 | id: "limit-child-turn", | |
| 5619 | status: "inProgress", | |
| 5620 | }), | |
| 5620 | 5621 | }, |
| 5621 | 5622 | }, |
| 5622 | ] | |
| 5623 | : []), | |
| 5624 | ...(scenario.notification | |
| 5625 | ? [ | |
| 5626 | { | |
| 5627 | type: "emit_inbound" as const, | |
| 5628 | label: "error", | |
| 5629 | frame: { | |
| 5630 | method: "error", | |
| 5631 | params: { | |
| 5632 | threadId: nativeThreadId, | |
| 5633 | turnId: nativeTurnId, | |
| 5634 | willRetry: scenario.name === "retry", | |
| 5635 | error: { | |
| 5636 | message, | |
| 5637 | codexErrorInfo: scenario.code, | |
| 5638 | additionalDetails: | |
| 5639 | scenario.name === "matching-details" | |
| 5640 | ? "Detailed provider allowance explanation." | |
| 5641 | : null, | |
| 5642 | }, | |
| 5623 | }, | |
| 5624 | ] | |
| 5625 | : []), | |
| 5626 | ...(scenario.notification | |
| 5627 | ? [ | |
| 5628 | { | |
| 5629 | type: "emit_inbound" as const, | |
| 5630 | label: "error", | |
| 5631 | frame: { | |
| 5632 | method: "error", | |
| 5633 | params: { | |
| 5634 | threadId: nativeThreadId, | |
| 5635 | turnId: nativeTurnId, | |
| 5636 | willRetry: scenario.name === "retry", | |
| 5637 | error: { | |
| 5638 | message, | |
| 5639 | codexErrorInfo: scenario.code, | |
| 5640 | additionalDetails: | |
| 5641 | scenario.name === "matching-details" | |
| 5642 | ? "Detailed provider allowance explanation." | |
| 5643 | : null, | |
| 5643 | 5644 | }, |
| 5644 | 5645 | }, |
| 5645 | 5646 | }, |
| 5646 | ] | |
| 5647 | : []), | |
| 5648 | { | |
| 5649 | type: "emit_inbound", | |
| 5650 | label: "turn/completed", | |
| 5651 | frame: { | |
| 5652 | method: "turn/completed", | |
| 5653 | params: { | |
| 5654 | threadId: nativeThreadId, | |
| 5655 | turn: { | |
| 5656 | ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), | |
| 5657 | error: { | |
| 5658 | message: scenario.name === "replacement" ? "A different failure." : message, | |
| 5659 | ...(scenario.notification && scenario.name !== "matching-details" | |
| 5660 | ? {} | |
| 5661 | : { codexErrorInfo: scenario.code }), | |
| 5662 | }, | |
| 5647 | }, | |
| 5648 | ] | |
| 5649 | : []), | |
| 5650 | { | |
| 5651 | type: "emit_inbound", | |
| 5652 | label: "turn/completed", | |
| 5653 | frame: { | |
| 5654 | method: "turn/completed", | |
| 5655 | params: { | |
| 5656 | threadId: nativeThreadId, | |
| 5657 | turn: { | |
| 5658 | ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), | |
| 5659 | error: { | |
| 5660 | message: scenario.name === "replacement" ? "A different failure." : message, | |
| 5661 | ...(scenario.notification && scenario.name !== "matching-details" | |
| 5662 | ? {} | |
| 5663 | : { codexErrorInfo: scenario.code }), | |
| 5663 | 5664 | }, |
| 5664 | 5665 | }, |
| 5665 | 5666 | }, |
| 5666 | 5667 | }, |
| 5667 | ...(scenario.name === "late-reset" ? [snapshot] : []), | |
| 5668 | ...(scenario.name === "deferred-reset" | |
| 5669 | ? [ | |
| 5670 | { | |
| 5671 | ...snapshot, | |
| 5672 | frame: { | |
| 5673 | method: "account/rateLimits/updated", | |
| 5674 | params: { | |
| 5675 | rateLimits: { | |
| 5676 | limitId: "codex", | |
| 5677 | primary: { | |
| 5678 | usedPercent: 100, | |
| 5679 | resetsAt: 2000200000, | |
| 5680 | windowDurationMins: 300, | |
| 5681 | }, | |
| 5668 | }, | |
| 5669 | ...(scenario.name === "late-reset" ? [snapshot] : []), | |
| 5670 | ...(scenario.name === "deferred-reset" | |
| 5671 | ? [ | |
| 5672 | { | |
| 5673 | ...snapshot, | |
| 5674 | frame: { | |
| 5675 | method: "account/rateLimits/updated", | |
| 5676 | params: { | |
| 5677 | rateLimits: { | |
| 5678 | limitId: "codex", | |
| 5679 | primary: { | |
| 5680 | usedPercent: 100, | |
| 5681 | resetsAt: 2000200000, | |
| 5682 | windowDurationMins: 300, | |
| 5682 | 5683 | }, |
| 5683 | 5684 | }, |
| 5684 | 5685 | }, |
| 5685 | 5686 | }, |
| 5686 | { | |
| 5687 | type: "emit_inbound" as const, | |
| 5688 | label: "turn/completed/child", | |
| 5689 | frame: { | |
| 5690 | method: "turn/completed", | |
| 5691 | params: { | |
| 5692 | threadId: "native-limit-child", | |
| 5693 | turn: makeCodexReplayTurn({ | |
| 5694 | id: "limit-child-turn", | |
| 5695 | status: "completed", | |
| 5696 | }), | |
| 5697 | }, | |
| 5687 | }, | |
| 5688 | { | |
| 5689 | type: "emit_inbound" as const, | |
| 5690 | label: "turn/completed/child", | |
| 5691 | frame: { | |
| 5692 | method: "turn/completed", | |
| 5693 | params: { | |
| 5694 | threadId: "native-limit-child", | |
| 5695 | turn: makeCodexReplayTurn({ | |
| 5696 | id: "limit-child-turn", | |
| 5697 | status: "completed", | |
| 5698 | }), | |
| 5698 | 5699 | }, |
| 5699 | 5700 | }, |
| 5700 | ] | |
| 5701 | : []), | |
| 5702 | ], | |
| 5703 | }); | |
| 5704 | const resetReceipt = yield* Deferred.make<void>(); | |
| 5705 | const harness = yield* makeCodexReplayHarness(transcript, (event) => | |
| 5706 | event.type === "turn_item.updated" && | |
| 5707 | event.turnItem.type === "error" && | |
| 5708 | event.turnItem.failure.resetAt === resetAt | |
| 5709 | ? Deferred.succeed(resetReceipt, undefined) | |
| 5710 | : Effect.void, | |
| 5711 | ); | |
| 5712 | yield* harness.runtime.startTurn( | |
| 5713 | makeCodexTestTurnInput({ | |
| 5714 | threadId: harness.threadId, | |
| 5715 | providerThread: harness.providerThread, | |
| 5716 | now: yield* DateTime.now, | |
| 5717 | text: "Continue.", | |
| 5718 | attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), | |
| 5719 | }), | |
| 5701 | }, | |
| 5702 | ] | |
| 5703 | : []), | |
| 5704 | ], | |
| 5705 | }); | |
| 5706 | const resetReceipt = yield* Deferred.make<void>(); | |
| 5707 | const harness = yield* makeCodexReplayHarness(transcript, (event) => | |
| 5708 | event.type === "turn_item.updated" && | |
| 5709 | event.turnItem.type === "error" && | |
| 5710 | event.turnItem.failure.resetAt === resetAt | |
| 5711 | ? Deferred.succeed(resetReceipt, undefined) | |
| 5712 | : Effect.void, | |
| 5713 | ); | |
| 5714 | yield* harness.runtime.startTurn( | |
| 5715 | makeCodexTestTurnInput({ | |
| 5716 | threadId: harness.threadId, | |
| 5717 | providerThread: harness.providerThread, | |
| 5718 | now: yield* DateTime.now, | |
| 5719 | text: "Continue.", | |
| 5720 | attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), | |
| 5721 | }), | |
| 5722 | ); | |
| 5723 | yield* harness.firstTerminal; | |
| 5724 | const terminal = harness.terminalEvents()[0]; | |
| 5725 | assert.equal(terminal?.status, "failed"); | |
| 5726 | if (terminal?.status !== "failed") return; | |
| 5727 | assert.equal(terminal.failure.class, scenario.expectedClass); | |
| 5728 | assert.equal(terminal.threadDisposition, "reusable"); | |
| 5729 | if (scenario.name === "known-reset" || scenario.name === "deferred-reset") | |
| 5730 | assert.equal(terminal.failure.resetAt, resetAt); | |
| 5731 | if (scenario.name === "matching-details") | |
| 5732 | assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); | |
| 5733 | if (scenario.name === "late-reset") { | |
| 5734 | yield* Deferred.await(resetReceipt); | |
| 5735 | const item = harness.events.find( | |
| 5736 | (event) => | |
| 5737 | event.type === "turn_item.updated" && | |
| 5738 | event.turnItem.type === "error" && | |
| 5739 | event.turnItem.failure.resetAt === resetAt, | |
| 5720 | 5740 | ); |
| 5721 | yield* harness.firstTerminal; | |
| 5722 | const terminal = harness.terminalEvents()[0]; | |
| 5723 | assert.equal(terminal?.status, "failed"); | |
| 5724 | if (terminal?.status !== "failed") return; | |
| 5725 | assert.equal(terminal.failure.class, scenario.expectedClass); | |
| 5726 | assert.equal(terminal.threadDisposition, "reusable"); | |
| 5727 | if (scenario.name === "known-reset" || scenario.name === "deferred-reset") | |
| 5728 | assert.equal(terminal.failure.resetAt, resetAt); | |
| 5729 | if (scenario.name === "matching-details") | |
| 5730 | assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); | |
| 5731 | if (scenario.name === "late-reset") { | |
| 5732 | yield* Deferred.await(resetReceipt); | |
| 5733 | const item = harness.events.find( | |
| 5734 | (event) => | |
| 5735 | event.type === "turn_item.updated" && | |
| 5736 | event.turnItem.type === "error" && | |
| 5737 | event.turnItem.failure.resetAt === resetAt, | |
| 5738 | ); | |
| 5739 | assert.isDefined(item); | |
| 5740 | } | |
| 5741 | if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); | |
| 5742 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | |
| 5743 | ), | |
| 5744 | ); | |
| 5745 | } | |
| 5741 | assert.isDefined(item); | |
| 5742 | } | |
| 5743 | if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); | |
| 5744 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | |
| 5745 | ), | |
| 5746 | ); | |
| 5746 | 5747 | |
| 5747 | 5748 | const FAILED_SCENARIO = "codex-failed-mid-command"; |
| 5748 | 5749 | const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; |
| ... | ... | @@ -6511,7 +6512,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6511 | 6512 | ), |
| 6512 | 6513 | ); |
| 6513 | 6514 | |
| 6514 | for (const [nativeStatus, expectedStatus] of [ | |
| 6515 | it.effect.each([ | |
| 6515 | 6516 | ["pendingInit", "pending"], |
| 6516 | 6517 | ["running", "running"], |
| 6517 | 6518 | ["interrupted", "interrupted"], |
| ... | ... | @@ -6523,8 +6524,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6523 | 6524 | ["late-activity-completed", "completed"], |
| 6524 | 6525 | ["stale-running", "completed"], |
| 6525 | 6526 | ["duplicate-completed", "completed"], |
| 6526 | ] as const) { | |
| 6527 | it.effect(`normalizes subagent ${nativeStatus} without losing its lifecycle`, () => | |
| 6527 | ] as const)( | |
| 6528 | "normalizes subagent %s without losing its lifecycle", | |
| 6529 | ([nativeStatus, expectedStatus]) => | |
| 6528 | 6530 | Effect.scoped( |
| 6529 | 6531 | Effect.gen(function* () { |
| 6530 | 6532 | const marker = yield* Deferred.make<void>(); |
| ... | ... | @@ -6670,8 +6672,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6670 | 6672 | } |
| 6671 | 6673 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 6672 | 6674 | ), |
| 6673 | ); | |
| 6674 | } | |
| 6675 | ); | |
| 6675 | 6676 | |
| 6676 | 6677 | const codexReplayThreadResult = (input: { |
| 6677 | 6678 | readonly nativeThreadId: string; |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts+5-5| ... | ... | @@ -39,12 +39,13 @@ import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAge |
| 39 | 39 | const decodeCursorSettings = Schema.decodeEffect(CursorSettings); |
| 40 | 40 | |
| 41 | 41 | describe("CursorAdapterV2", () => { |
| 42 | for (const { status, model } of [ | |
| 42 | it.effect.each([ | |
| 43 | 43 | { status: "finished", model: undefined }, |
| 44 | 44 | { status: "cancelled", model: "claude-opus-4-6" }, |
| 45 | 45 | { status: "error", model: "custom-fable" }, |
| 46 | ] as const) { | |
| 47 | it.effect(`settles missing task completions when the Cursor run is ${status}`, () => | |
| 46 | ] as const)( | |
| 47 | "settles missing task completions when the Cursor run is $status", | |
| 48 | ({ status, model }) => | |
| 48 | 49 | Effect.gen(function* () { |
| 49 | 50 | const fileSystem = yield* FileSystem.FileSystem; |
| 50 | 51 | const path = yield* Path.Path; |
| ... | ... | @@ -185,8 +186,7 @@ describe("CursorAdapterV2", () => { |
| 185 | 186 | ); |
| 186 | 187 | assert.isNotNull(rows.at(-1)?.subagent.completedAt); |
| 187 | 188 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), |
| 188 | ); | |
| 189 | } | |
| 189 | ); | |
| 190 | 190 | |
| 191 | 191 | it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => |
| 192 | 192 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+13-11| ... | ... | @@ -371,17 +371,19 @@ describe("Grok launch permission mode", () => { |
| 371 | 371 | ...override, |
| 372 | 372 | }); |
| 373 | 373 | |
| 374 | for (const [runtimeMode, args] of [ | |
| 375 | ["approval-required", ["--permission-mode", "default", "agent", "stdio"]], | |
| 376 | ["auto", ["--permission-mode", "auto", "agent", "stdio"]], | |
| 377 | ["full-access", ["agent", "--always-approve", "stdio"]], | |
| 378 | ] as const) { | |
| 379 | it.effect(`launches ${runtimeMode} threads with ${args.join(" ")}`, () => | |
| 380 | Effect.gen(function* () { | |
| 381 | assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]); | |
| 382 | }), | |
| 383 | ); | |
| 384 | } | |
| 374 | it.effect.each( | |
| 375 | ( | |
| 376 | [ | |
| 377 | ["approval-required", ["--permission-mode", "default", "agent", "stdio"]], | |
| 378 | ["auto", ["--permission-mode", "auto", "agent", "stdio"]], | |
| 379 | ["full-access", ["agent", "--always-approve", "stdio"]], | |
| 380 | ] as const | |
| 381 | ).map(([runtimeMode, args]) => [runtimeMode, args.join(" "), args] as const), | |
| 382 | )("launches %s threads with %s", ([runtimeMode, , args]) => | |
| 383 | Effect.gen(function* () { | |
| 384 | assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]); | |
| 385 | }), | |
| 386 | ); | |
| 385 | 387 | |
| 386 | 388 | it.effect("launches a thread stored as Auto-accept edits asking", () => |
| 387 | 389 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+158-161| ... | ... | @@ -3090,81 +3090,78 @@ describe("OpenCode2 adapter", () => { |
| 3090 | 3090 | }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), |
| 3091 | 3091 | ); |
| 3092 | 3092 | |
| 3093 | for (const running of [true, false]) { | |
| 3094 | it.effect( | |
| 3095 | running | |
| 3096 | ? "refuses a rollback while a timed-out Stop's run is still going" | |
| 3097 | : "rolls back once a timed-out Stop's run has left the server", | |
| 3098 | () => | |
| 3099 | Effect.gen(function* () { | |
| 3100 | const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`; | |
| 3101 | const { runtime, thread } = yield* resumed([ | |
| 3102 | ...stopTimedOut, | |
| 3103 | // The server says whether the stopped run still goes. | |
| 3104 | out("session.active"), | |
| 3105 | reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), | |
| 3106 | // Gone: the cut is made. Running: nothing is read or cut meanwhile. | |
| 3107 | ...(running | |
| 3108 | ? [] | |
| 3109 | : [ | |
| 3110 | out("message.list", "<any>"), | |
| 3111 | reply("message.list", { | |
| 3112 | data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }], | |
| 3113 | cursor: {}, | |
| 3114 | }), | |
| 3115 | out("session.revert.stage", { | |
| 3116 | sessionID: SESSION, | |
| 3117 | messageID: prompt, | |
| 3118 | files: false, | |
| 3119 | }), | |
| 3120 | replyData("session.revert.stage", { messageID: prompt, files: [] }), | |
| 3121 | out("session.revert.commit", { sessionID: SESSION }), | |
| 3122 | reply("session.revert.commit", null), | |
| 3123 | out("message.list", "<any>"), | |
| 3124 | reply("message.list", { data: [], cursor: {} }), | |
| 3125 | ]), | |
| 3126 | ]); | |
| 3127 | yield* stopFirstTurn(runtime, thread); | |
| 3128 | const now = yield* DateTime.now; | |
| 3129 | const first = { | |
| 3130 | id: yield* providerTurnId, | |
| 3131 | providerThreadId: thread.id, | |
| 3132 | nodeId: NodeId.make("node:opencode2-adapter"), | |
| 3133 | runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"), | |
| 3134 | nativeTurnRef: { | |
| 3135 | driver: OPENCODE_PROVIDER, | |
| 3136 | nativeId: prompt, | |
| 3137 | strength: "weak" as const, | |
| 3138 | }, | |
| 3139 | ordinal: 1, | |
| 3140 | status: "interrupted" as const, | |
| 3141 | startedAt: now, | |
| 3142 | completedAt: now, | |
| 3143 | }; | |
| 3144 | const rollback = yield* runtime | |
| 3145 | .rollbackThread({ | |
| 3146 | providerThread: thread, | |
| 3147 | target: { | |
| 3148 | type: "thread_start", | |
| 3149 | checkpointId: CheckpointId.make("checkpoint:start"), | |
| 3150 | appRunOrdinal: 0, | |
| 3151 | }, | |
| 3152 | providerThreadTurns: [first], | |
| 3153 | }) | |
| 3154 | .pipe(Effect.exit); | |
| 3155 | if (running) { | |
| 3156 | assert.isTrue(Exit.isFailure(rollback)); | |
| 3157 | const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; | |
| 3158 | assert.equal( | |
| 3159 | (error as { _tag?: string } | undefined)?._tag, | |
| 3160 | "ProviderAdapterProtocolError", | |
| 3161 | ); | |
| 3162 | } else { | |
| 3163 | assert.isTrue(Exit.isSuccess(rollback)); | |
| 3164 | } | |
| 3165 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), | |
| 3166 | ); | |
| 3167 | } | |
| 3093 | it.effect.each([ | |
| 3094 | ["refuses a rollback while a timed-out Stop's run is still going", true], | |
| 3095 | ["rolls back once a timed-out Stop's run has left the server", false], | |
| 3096 | ] as const)("%s", ([, running]) => | |
| 3097 | Effect.gen(function* () { | |
| 3098 | const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`; | |
| 3099 | const { runtime, thread } = yield* resumed([ | |
| 3100 | ...stopTimedOut, | |
| 3101 | // The server says whether the stopped run still goes. | |
| 3102 | out("session.active"), | |
| 3103 | reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), | |
| 3104 | // Gone: the cut is made. Running: nothing is read or cut meanwhile. | |
| 3105 | ...(running | |
| 3106 | ? [] | |
| 3107 | : [ | |
| 3108 | out("message.list", "<any>"), | |
| 3109 | reply("message.list", { | |
| 3110 | data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }], | |
| 3111 | cursor: {}, | |
| 3112 | }), | |
| 3113 | out("session.revert.stage", { | |
| 3114 | sessionID: SESSION, | |
| 3115 | messageID: prompt, | |
| 3116 | files: false, | |
| 3117 | }), | |
| 3118 | replyData("session.revert.stage", { messageID: prompt, files: [] }), | |
| 3119 | out("session.revert.commit", { sessionID: SESSION }), | |
| 3120 | reply("session.revert.commit", null), | |
| 3121 | out("message.list", "<any>"), | |
| 3122 | reply("message.list", { data: [], cursor: {} }), | |
| 3123 | ]), | |
| 3124 | ]); | |
| 3125 | yield* stopFirstTurn(runtime, thread); | |
| 3126 | const now = yield* DateTime.now; | |
| 3127 | const first = { | |
| 3128 | id: yield* providerTurnId, | |
| 3129 | providerThreadId: thread.id, | |
| 3130 | nodeId: NodeId.make("node:opencode2-adapter"), | |
| 3131 | runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"), | |
| 3132 | nativeTurnRef: { | |
| 3133 | driver: OPENCODE_PROVIDER, | |
| 3134 | nativeId: prompt, | |
| 3135 | strength: "weak" as const, | |
| 3136 | }, | |
| 3137 | ordinal: 1, | |
| 3138 | status: "interrupted" as const, | |
| 3139 | startedAt: now, | |
| 3140 | completedAt: now, | |
| 3141 | }; | |
| 3142 | const rollback = yield* runtime | |
| 3143 | .rollbackThread({ | |
| 3144 | providerThread: thread, | |
| 3145 | target: { | |
| 3146 | type: "thread_start", | |
| 3147 | checkpointId: CheckpointId.make("checkpoint:start"), | |
| 3148 | appRunOrdinal: 0, | |
| 3149 | }, | |
| 3150 | providerThreadTurns: [first], | |
| 3151 | }) | |
| 3152 | .pipe(Effect.exit); | |
| 3153 | if (running) { | |
| 3154 | assert.isTrue(Exit.isFailure(rollback)); | |
| 3155 | const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; | |
| 3156 | assert.equal( | |
| 3157 | (error as { _tag?: string } | undefined)?._tag, | |
| 3158 | "ProviderAdapterProtocolError", | |
| 3159 | ); | |
| 3160 | } else { | |
| 3161 | assert.isTrue(Exit.isSuccess(rollback)); | |
| 3162 | } | |
| 3163 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), | |
| 3164 | ); | |
| 3168 | 3165 | |
| 3169 | 3166 | it.effect("takes back a stranded steer again when its first cancel failed", () => |
| 3170 | 3167 | Effect.gen(function* () { |
| ... | ... | @@ -3376,92 +3373,92 @@ describe("OpenCode2 adapter", () => { |
| 3376 | 3373 | }).pipe(Effect.scoped), |
| 3377 | 3374 | ); |
| 3378 | 3375 | |
| 3379 | for (const running of [true, false]) { | |
| 3380 | it.effect( | |
| 3381 | running | |
| 3382 | ? "refuses to roll back a session not loaded yet while the server runs it" | |
| 3383 | : "keeps the recorded turns before the target when rolling back a session not loaded yet", | |
| 3384 | () => | |
| 3385 | Effect.gen(function* () { | |
| 3386 | const first = `msg_t3_turn_${SESSION}:attempt:first`; | |
| 3387 | const second = `msg_t3_turn_${SESSION}:attempt:second`; | |
| 3388 | // A runtime that never loaded the session, as after a T3 restart | |
| 3389 | // against a server that kept running. | |
| 3390 | const runtime = yield* openCode2ReplayRuntime([ | |
| 3391 | ...opening, | |
| 3392 | out("session.active"), | |
| 3393 | reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), | |
| 3394 | ...(running | |
| 3395 | ? [] | |
| 3396 | : [ | |
| 3397 | out("session.get", { sessionID: SESSION }), | |
| 3398 | replyData("session.get", sessionInfo()), | |
| 3399 | ...noOpenRequests, | |
| 3400 | out("message.list", "<any>"), | |
| 3401 | reply("message.list", { | |
| 3402 | data: [ | |
| 3403 | { id: second, time: { created: 2 }, text: "second", type: "user" }, | |
| 3404 | { id: first, time: { created: 1 }, text: "first", type: "user" }, | |
| 3405 | ], | |
| 3406 | cursor: {}, | |
| 3407 | }), | |
| 3408 | out("session.revert.stage", { | |
| 3409 | sessionID: SESSION, | |
| 3410 | messageID: second, | |
| 3411 | files: false, | |
| 3412 | }), | |
| 3413 | replyData("session.revert.stage", { messageID: second, files: [] }), | |
| 3414 | out("session.revert.commit", { sessionID: SESSION }), | |
| 3415 | reply("session.revert.commit", null), | |
| 3416 | out("message.list", "<any>"), | |
| 3417 | reply("message.list", { | |
| 3418 | data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }], | |
| 3419 | cursor: {}, | |
| 3420 | }), | |
| 3421 | ]), | |
| 3422 | ]); | |
| 3423 | const thread = providerThread(yield* DateTime.now); | |
| 3424 | const now = yield* DateTime.now; | |
| 3425 | const recorded = (key: string, ordinal: number, nativeId: string) => ({ | |
| 3426 | id: ProviderTurnId.make(`provider-turn:${key}`), | |
| 3427 | providerThreadId: thread.id, | |
| 3428 | nodeId: NodeId.make(`node:${key}`), | |
| 3429 | runAttemptId: RunAttemptId.make(`attempt:${key}`), | |
| 3430 | nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const }, | |
| 3431 | ordinal, | |
| 3432 | status: "completed" as const, | |
| 3433 | startedAt: now, | |
| 3434 | completedAt: now, | |
| 3435 | }); | |
| 3436 | const kept = recorded("first", 1, first); | |
| 3437 | const rollback = yield* runtime | |
| 3438 | .rollbackThread({ | |
| 3439 | providerThread: thread, | |
| 3440 | target: { | |
| 3441 | type: "provider_turn", | |
| 3442 | checkpointId: CheckpointId.make("checkpoint:first"), | |
| 3443 | appRunOrdinal: 1, | |
| 3444 | providerTurn: kept, | |
| 3445 | }, | |
| 3446 | providerThreadTurns: [kept, recorded("second", 2, second)], | |
| 3447 | }) | |
| 3448 | .pipe(Effect.exit); | |
| 3449 | if (running) { | |
| 3450 | const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; | |
| 3451 | assert.equal( | |
| 3452 | (error as { _tag?: string } | undefined)?._tag, | |
| 3453 | "ProviderAdapterProtocolError", | |
| 3454 | ); | |
| 3455 | } else { | |
| 3456 | assert.isTrue(Exit.isSuccess(rollback)); | |
| 3457 | assert.deepEqual( | |
| 3458 | Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [], | |
| 3459 | [kept.id], | |
| 3460 | ); | |
| 3461 | } | |
| 3462 | }).pipe(Effect.scoped), | |
| 3463 | ); | |
| 3464 | } | |
| 3376 | it.effect.each([ | |
| 3377 | ["refuses to roll back a session not loaded yet while the server runs it", true], | |
| 3378 | [ | |
| 3379 | "keeps the recorded turns before the target when rolling back a session not loaded yet", | |
| 3380 | false, | |
| 3381 | ], | |
| 3382 | ] as const)("%s", ([, running]) => | |
| 3383 | Effect.gen(function* () { | |
| 3384 | const first = `msg_t3_turn_${SESSION}:attempt:first`; | |
| 3385 | const second = `msg_t3_turn_${SESSION}:attempt:second`; | |
| 3386 | // A runtime that never loaded the session, as after a T3 restart | |
| 3387 | // against a server that kept running. | |
| 3388 | const runtime = yield* openCode2ReplayRuntime([ | |
| 3389 | ...opening, | |
| 3390 | out("session.active"), | |
| 3391 | reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), | |
| 3392 | ...(running | |
| 3393 | ? [] | |
| 3394 | : [ | |
| 3395 | out("session.get", { sessionID: SESSION }), | |
| 3396 | replyData("session.get", sessionInfo()), | |
| 3397 | ...noOpenRequests, | |
| 3398 | out("message.list", "<any>"), | |
| 3399 | reply("message.list", { | |
| 3400 | data: [ | |
| 3401 | { id: second, time: { created: 2 }, text: "second", type: "user" }, | |
| 3402 | { id: first, time: { created: 1 }, text: "first", type: "user" }, | |
| 3403 | ], | |
| 3404 | cursor: {}, | |
| 3405 | }), | |
| 3406 | out("session.revert.stage", { | |
| 3407 | sessionID: SESSION, | |
| 3408 | messageID: second, | |
| 3409 | files: false, | |
| 3410 | }), | |
| 3411 | replyData("session.revert.stage", { messageID: second, files: [] }), | |
| 3412 | out("session.revert.commit", { sessionID: SESSION }), | |
| 3413 | reply("session.revert.commit", null), | |
| 3414 | out("message.list", "<any>"), | |
| 3415 | reply("message.list", { | |
| 3416 | data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }], | |
| 3417 | cursor: {}, | |
| 3418 | }), | |
| 3419 | ]), | |
| 3420 | ]); | |
| 3421 | const thread = providerThread(yield* DateTime.now); | |
| 3422 | const now = yield* DateTime.now; | |
| 3423 | const recorded = (key: string, ordinal: number, nativeId: string) => ({ | |
| 3424 | id: ProviderTurnId.make(`provider-turn:${key}`), | |
| 3425 | providerThreadId: thread.id, | |
| 3426 | nodeId: NodeId.make(`node:${key}`), | |
| 3427 | runAttemptId: RunAttemptId.make(`attempt:${key}`), | |
| 3428 | nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const }, | |
| 3429 | ordinal, | |
| 3430 | status: "completed" as const, | |
| 3431 | startedAt: now, | |
| 3432 | completedAt: now, | |
| 3433 | }); | |
| 3434 | const kept = recorded("first", 1, first); | |
| 3435 | const rollback = yield* runtime | |
| 3436 | .rollbackThread({ | |
| 3437 | providerThread: thread, | |
| 3438 | target: { | |
| 3439 | type: "provider_turn", | |
| 3440 | checkpointId: CheckpointId.make("checkpoint:first"), | |
| 3441 | appRunOrdinal: 1, | |
| 3442 | providerTurn: kept, | |
| 3443 | }, | |
| 3444 | providerThreadTurns: [kept, recorded("second", 2, second)], | |
| 3445 | }) | |
| 3446 | .pipe(Effect.exit); | |
| 3447 | if (running) { | |
| 3448 | const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; | |
| 3449 | assert.equal( | |
| 3450 | (error as { _tag?: string } | undefined)?._tag, | |
| 3451 | "ProviderAdapterProtocolError", | |
| 3452 | ); | |
| 3453 | } else { | |
| 3454 | assert.isTrue(Exit.isSuccess(rollback)); | |
| 3455 | assert.deepEqual( | |
| 3456 | Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [], | |
| 3457 | [kept.id], | |
| 3458 | ); | |
| 3459 | } | |
| 3460 | }).pipe(Effect.scoped), | |
| 3461 | ); | |
| 3465 | 3462 | |
| 3466 | 3463 | it.effect("refuses to fork a session while OpenCode runs a follow-up on it", () => |
| 3467 | 3464 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+12-12| ... | ... | @@ -237,8 +237,9 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct |
| 237 | 237 | }); |
| 238 | 238 | |
| 239 | 239 | describe("OpenCodeAdapterV2", () => { |
| 240 | for (const ending of ["completed", "failed", "unresolved", "unavailable", "reconnect"] as const) { | |
| 241 | it.effect(`normalizes OpenCode step usage for ${ending} turns`, () => | |
| 240 | it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( | |
| 241 | "normalizes OpenCode step usage for %s turns", | |
| 242 | (ending) => | |
| 242 | 243 | Effect.gen(function* () { |
| 243 | 244 | const nativeEvents = asyncEventStream(); |
| 244 | 245 | let promptId = ""; |
| ... | ... | @@ -375,11 +376,11 @@ describe("OpenCodeAdapterV2", () => { |
| 375 | 376 | }, |
| 376 | 377 | ); |
| 377 | 378 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), |
| 378 | ); | |
| 379 | } | |
| 379 | ); | |
| 380 | 380 | |
| 381 | for (const kind of ["permission", "question"] as const) { | |
| 382 | it.effect(`cancels an undelivered ${kind} reply at its deadline`, () => | |
| 381 | it.effect.each(["permission", "question"] as const)( | |
| 382 | "cancels an undelivered %s reply at its deadline", | |
| 383 | (kind) => | |
| 383 | 384 | Effect.gen(function* () { |
| 384 | 385 | const nativeEvents = asyncEventStream(); |
| 385 | 386 | const called = promiseGate<void>(); |
| ... | ... | @@ -453,8 +454,7 @@ describe("OpenCodeAdapterV2", () => { |
| 453 | 454 | // Failed delivery leaves the request available for an explicit retry. |
| 454 | 455 | yield* harness.runtime.respondToRuntimeRequest(response); |
| 455 | 456 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), |
| 456 | ); | |
| 457 | } | |
| 457 | ); | |
| 458 | 458 | |
| 459 | 459 | it.effect("aborts external root and descendants before closing the event stream", () => |
| 460 | 460 | Effect.gen(function* () { |
| ... | ... | @@ -494,8 +494,9 @@ describe("OpenCodeAdapterV2", () => { |
| 494 | 494 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), |
| 495 | 495 | ); |
| 496 | 496 | |
| 497 | for (const failure of ["enumeration", "abort", "not-found", "timeout"] as const) { | |
| 498 | it.effect(`reports descendant cleanup ${failure}`, () => | |
| 497 | it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( | |
| 498 | "reports descendant cleanup %s", | |
| 499 | (failure) => | |
| 499 | 500 | Effect.gen(function* () { |
| 500 | 501 | const nativeEvents = asyncEventStream(); |
| 501 | 502 | const called = promiseGate<void>(); |
| ... | ... | @@ -545,8 +546,7 @@ describe("OpenCodeAdapterV2", () => { |
| 545 | 546 | assert.equal(Exit.isSuccess(result), failure === "not-found"); |
| 546 | 547 | if (failure === "timeout") assert.isTrue(childSignal?.aborted); |
| 547 | 548 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), |
| 548 | ); | |
| 549 | } | |
| 549 | ); | |
| 550 | 550 | |
| 551 | 551 | it.effect( |
| 552 | 552 | "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts+83-88| ... | ... | @@ -589,8 +589,9 @@ describe("PiAdapterV2", () => { |
| 589 | 589 | }).pipe(Effect.scoped, Effect.provide(testLayer)), |
| 590 | 590 | ); |
| 591 | 591 | |
| 592 | for (const invalidReplacement of ["veto", "same identity"] as const) { | |
| 593 | it.effect(`rejects a replacement with ${invalidReplacement}`, () => | |
| 592 | it.effect.each(["veto", "same identity"] as const)( | |
| 593 | "rejects a replacement with %s", | |
| 594 | (invalidReplacement) => | |
| 594 | 595 | Effect.gen(function* () { |
| 595 | 596 | const fake = yield* makeFakePi; |
| 596 | 597 | const { runtime } = yield* openRuntime(fake); |
| ... | ... | @@ -617,8 +618,7 @@ describe("PiAdapterV2", () => { |
| 617 | 618 | yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip); |
| 618 | 619 | assert.isFalse(fake.allRequests().some((request) => request.type === "prompt")); |
| 619 | 620 | }).pipe(Effect.scoped, Effect.provide(testLayer)), |
| 620 | ); | |
| 621 | } | |
| 621 | ); | |
| 622 | 622 | |
| 623 | 623 | it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () => |
| 624 | 624 | Effect.gen(function* () { |
| ... | ... | @@ -983,90 +983,85 @@ describe("PiAdapterV2", () => { |
| 983 | 983 | }).pipe(Effect.scoped, Effect.provide(testLayer)), |
| 984 | 984 | ); |
| 985 | 985 | |
| 986 | for (const historical of [false, true]) { | |
| 987 | it.effect( | |
| 988 | `natively forks ${historical ? "a historical turn" : "the latest turn"} into an independent session`, | |
| 989 | () => | |
| 990 | Effect.gen(function* () { | |
| 991 | const fake = yield* makeFakePi; | |
| 992 | const forkFake = yield* makeFakePi; | |
| 993 | const forkFile = "/fake/forked.jsonl"; | |
| 994 | const { runtime, takeEvent } = yield* openRuntime( | |
| 995 | fake, | |
| 996 | "default", | |
| 997 | THREAD_ID, | |
| 998 | SESSION_ID, | |
| 999 | forkFake, | |
| 1000 | ); | |
| 1001 | const source = yield* runtime.ensureThread({ | |
| 1002 | threadId: THREAD_ID, | |
| 1003 | modelSelection: modelSelection("default"), | |
| 1004 | runtimePolicy, | |
| 1005 | }); | |
| 1006 | const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ | |
| 1007 | id: ProviderTurnId.make(`turn-${ordinal}`), | |
| 1008 | providerThreadId: source.id, | |
| 1009 | nodeId: NodeId.make(`node-${ordinal}`), | |
| 1010 | runAttemptId: null, | |
| 1011 | nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, | |
| 1012 | ordinal, | |
| 1013 | status: "completed", | |
| 1014 | startedAt: null, | |
| 1015 | completedAt: null, | |
| 1016 | }); | |
| 1017 | forkFake.queueState({ sessionFile: forkFile }); | |
| 1018 | fake.queueState({ sessionFile: forkFile }); | |
| 1019 | const target = ThreadId.make("fork-target"); | |
| 1020 | const forked = yield* runtime.forkThread({ | |
| 1021 | sourceProviderThread: source, | |
| 1022 | sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], | |
| 1023 | providerTurnId: turn(1).id, | |
| 1024 | targetThreadId: target, | |
| 1025 | }); | |
| 1026 | assert.equal(forked.appThreadId, target); | |
| 1027 | assert.equal(forked.nativeThreadRef?.nativeId, forkFile); | |
| 1028 | assert.notEqual(forked.id, source.id); | |
| 1029 | assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1030 | const args = forkFake.lastSpawn().args; | |
| 1031 | assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); | |
| 1032 | assert.include(args, "--no-extensions"); | |
| 1033 | assert.include(args, "--no-tools"); | |
| 1034 | assert.notInclude(args, "--no-session"); | |
| 1035 | assert.deepEqual( | |
| 1036 | forkFake | |
| 1037 | .allRequests() | |
| 1038 | .filter((request) => request.type === "fork") | |
| 1039 | .map((request) => request.entryId), | |
| 1040 | historical ? ["u2"] : [], | |
| 1041 | ); | |
| 1042 | assert.isFalse( | |
| 1043 | fake | |
| 1044 | .allRequests() | |
| 1045 | .some((request) => request.type === "fork" || request.type === "clone"), | |
| 1046 | ); | |
| 1047 | // ProviderTurnStartService adopts the fork into its pending row. | |
| 1048 | const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; | |
| 1049 | yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); | |
| 1050 | yield* fake.emit({ type: "agent_start" }); | |
| 1051 | yield* fake.emit({ type: "agent_settled" }); | |
| 1052 | const updated = yield* takeEvent( | |
| 1053 | (event) => | |
| 1054 | event.type === "provider_thread.updated" && | |
| 1055 | event.providerThread.appThreadId === target, | |
| 1056 | ); | |
| 1057 | assert.isTrue( | |
| 1058 | updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, | |
| 1059 | ); | |
| 1060 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1061 | yield* runtime.resumeThread({ providerThread: adopted }); | |
| 1062 | assert.equal( | |
| 1063 | fake.allRequests().findLast((request) => request.type === "switch_session") | |
| 1064 | ?.sessionPath, | |
| 1065 | forkFile, | |
| 1066 | ); | |
| 1067 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 1068 | ); | |
| 1069 | } | |
| 986 | it.effect.each([ | |
| 987 | { historical: false, label: "the latest turn" }, | |
| 988 | { historical: true, label: "a historical turn" }, | |
| 989 | ])("natively forks $label into an independent session", ({ historical }) => | |
| 990 | Effect.gen(function* () { | |
| 991 | const fake = yield* makeFakePi; | |
| 992 | const forkFake = yield* makeFakePi; | |
| 993 | const forkFile = "/fake/forked.jsonl"; | |
| 994 | const { runtime, takeEvent } = yield* openRuntime( | |
| 995 | fake, | |
| 996 | "default", | |
| 997 | THREAD_ID, | |
| 998 | SESSION_ID, | |
| 999 | forkFake, | |
| 1000 | ); | |
| 1001 | const source = yield* runtime.ensureThread({ | |
| 1002 | threadId: THREAD_ID, | |
| 1003 | modelSelection: modelSelection("default"), | |
| 1004 | runtimePolicy, | |
| 1005 | }); | |
| 1006 | const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ | |
| 1007 | id: ProviderTurnId.make(`turn-${ordinal}`), | |
| 1008 | providerThreadId: source.id, | |
| 1009 | nodeId: NodeId.make(`node-${ordinal}`), | |
| 1010 | runAttemptId: null, | |
| 1011 | nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, | |
| 1012 | ordinal, | |
| 1013 | status: "completed", | |
| 1014 | startedAt: null, | |
| 1015 | completedAt: null, | |
| 1016 | }); | |
| 1017 | forkFake.queueState({ sessionFile: forkFile }); | |
| 1018 | fake.queueState({ sessionFile: forkFile }); | |
| 1019 | const target = ThreadId.make("fork-target"); | |
| 1020 | const forked = yield* runtime.forkThread({ | |
| 1021 | sourceProviderThread: source, | |
| 1022 | sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], | |
| 1023 | providerTurnId: turn(1).id, | |
| 1024 | targetThreadId: target, | |
| 1025 | }); | |
| 1026 | assert.equal(forked.appThreadId, target); | |
| 1027 | assert.equal(forked.nativeThreadRef?.nativeId, forkFile); | |
| 1028 | assert.notEqual(forked.id, source.id); | |
| 1029 | assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1030 | const args = forkFake.lastSpawn().args; | |
| 1031 | assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); | |
| 1032 | assert.include(args, "--no-extensions"); | |
| 1033 | assert.include(args, "--no-tools"); | |
| 1034 | assert.notInclude(args, "--no-session"); | |
| 1035 | assert.deepEqual( | |
| 1036 | forkFake | |
| 1037 | .allRequests() | |
| 1038 | .filter((request) => request.type === "fork") | |
| 1039 | .map((request) => request.entryId), | |
| 1040 | historical ? ["u2"] : [], | |
| 1041 | ); | |
| 1042 | assert.isFalse( | |
| 1043 | fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"), | |
| 1044 | ); | |
| 1045 | // ProviderTurnStartService adopts the fork into its pending row. | |
| 1046 | const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; | |
| 1047 | yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); | |
| 1048 | yield* fake.emit({ type: "agent_start" }); | |
| 1049 | yield* fake.emit({ type: "agent_settled" }); | |
| 1050 | const updated = yield* takeEvent( | |
| 1051 | (event) => | |
| 1052 | event.type === "provider_thread.updated" && event.providerThread.appThreadId === target, | |
| 1053 | ); | |
| 1054 | assert.isTrue( | |
| 1055 | updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, | |
| 1056 | ); | |
| 1057 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1058 | yield* runtime.resumeThread({ providerThread: adopted }); | |
| 1059 | assert.equal( | |
| 1060 | fake.allRequests().findLast((request) => request.type === "switch_session")?.sessionPath, | |
| 1061 | forkFile, | |
| 1062 | ); | |
| 1063 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 1064 | ); | |
| 1070 | 1065 | |
| 1071 | 1066 | it.effect("observes official subagent results without inventing child threads", () => |
| 1072 | 1067 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts+5-4| ... | ... | @@ -28,8 +28,9 @@ async function loadRequestHook(): Promise<RequestHook> { |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | describe("Pi upstream output-budget workaround", () => { |
| 31 | for (const key of ["max_tokens", "max_completion_tokens"]) { | |
| 32 | it(`caps ${key} without changing the conversation or tools`, async () => { | |
| 31 | it.each(["max_tokens", "max_completion_tokens"])( | |
| 32 | "caps %s without changing the conversation or tools", | |
| 33 | async (key) => { | |
| 33 | 34 | const hook = await loadRequestHook(); |
| 34 | 35 | const payload = { |
| 35 | 36 | model: "moonshotai/kimi-k2.6", |
| ... | ... | @@ -43,8 +44,8 @@ describe("Pi upstream output-budget workaround", () => { |
| 43 | 44 | assert.strictEqual(result?.tools, payload.tools); |
| 44 | 45 | assert.equal(result?.model, payload.model); |
| 45 | 46 | assert.equal(payload[key], 231_969); |
| 46 | }); | |
| 47 | } | |
| 47 | }, | |
| 48 | ); | |
| 48 | 49 | |
| 49 | 50 | it("preserves smaller budgets and other providers' payloads", async () => { |
| 50 | 51 | const hook = await loadRequestHook(); |
apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts+44-48| ... | ... | @@ -413,55 +413,51 @@ describe("handoff budget", () => { |
| 413 | 413 | }); |
| 414 | 414 | |
| 415 | 415 | describe("handoff delivery", () => { |
| 416 | for (const native of [true, false]) { | |
| 417 | it.effect( | |
| 418 | `records omitted recovery coverage separately from ${native ? "injected" : "inline"} text`, | |
| 419 | () => | |
| 420 | Effect.gen(function* () { | |
| 421 | const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation"); | |
| 422 | const oversized = message("item:oversized", "user", "x".repeat(20_000)); | |
| 423 | let durable: OrchestrationV2ContextHandoff = { | |
| 424 | ...handoff, | |
| 425 | history: { | |
| 426 | ...handoff.history!, | |
| 427 | messages: [...messages, oversized], | |
| 428 | omittedItems: 1, | |
| 429 | omittedItemIds: [omittedBeforeDelivery], | |
| 430 | }, | |
| 431 | }; | |
| 432 | const result = yield* deliverContextHandoffs({ | |
| 433 | handoffs: [durable], | |
| 434 | providerThread, | |
| 435 | budget: 16_000, | |
| 436 | alreadyDeliveredItemIds: new Set(), | |
| 437 | inject: (value) => { | |
| 438 | assert.include(value.context, "omitted 2 items"); | |
| 439 | assert.notInclude( | |
| 440 | value.messages.map((item) => item.itemId), | |
| 441 | oversized.itemId, | |
| 442 | ); | |
| 443 | return Effect.succeed(native); | |
| 444 | }, | |
| 445 | persist: (value) => | |
| 446 | Effect.sync(() => { | |
| 447 | durable = value; | |
| 448 | }), | |
| 449 | }); | |
| 450 | assert.equal(durable.delivery?.status, native ? "injected" : "pending"); | |
| 451 | yield* result.delivered; | |
| 452 | assert.equal(durable.delivery?.status, native ? "injected" : "inline"); | |
| 453 | assert.deepEqual( | |
| 454 | durable.delivery?.itemIds, | |
| 455 | messages.map((item) => item.itemId), | |
| 456 | ); | |
| 457 | assert.deepEqual(durable.delivery?.omittedItemIds, [ | |
| 458 | omittedBeforeDelivery, | |
| 416 | it.effect.each([ | |
| 417 | { native: true, label: "injected" }, | |
| 418 | { native: false, label: "inline" }, | |
| 419 | ])("records omitted recovery coverage separately from $label text", ({ native }) => | |
| 420 | Effect.gen(function* () { | |
| 421 | const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation"); | |
| 422 | const oversized = message("item:oversized", "user", "x".repeat(20_000)); | |
| 423 | let durable: OrchestrationV2ContextHandoff = { | |
| 424 | ...handoff, | |
| 425 | history: { | |
| 426 | ...handoff.history!, | |
| 427 | messages: [...messages, oversized], | |
| 428 | omittedItems: 1, | |
| 429 | omittedItemIds: [omittedBeforeDelivery], | |
| 430 | }, | |
| 431 | }; | |
| 432 | const result = yield* deliverContextHandoffs({ | |
| 433 | handoffs: [durable], | |
| 434 | providerThread, | |
| 435 | budget: 16_000, | |
| 436 | alreadyDeliveredItemIds: new Set(), | |
| 437 | inject: (value) => { | |
| 438 | assert.include(value.context, "omitted 2 items"); | |
| 439 | assert.notInclude( | |
| 440 | value.messages.map((item) => item.itemId), | |
| 459 | 441 | oversized.itemId, |
| 460 | ]); | |
| 461 | assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); | |
| 462 | }), | |
| 463 | ); | |
| 464 | } | |
| 442 | ); | |
| 443 | return Effect.succeed(native); | |
| 444 | }, | |
| 445 | persist: (value) => | |
| 446 | Effect.sync(() => { | |
| 447 | durable = value; | |
| 448 | }), | |
| 449 | }); | |
| 450 | assert.equal(durable.delivery?.status, native ? "injected" : "pending"); | |
| 451 | yield* result.delivered; | |
| 452 | assert.equal(durable.delivery?.status, native ? "injected" : "inline"); | |
| 453 | assert.deepEqual( | |
| 454 | durable.delivery?.itemIds, | |
| 455 | messages.map((item) => item.itemId), | |
| 456 | ); | |
| 457 | assert.deepEqual(durable.delivery?.omittedItemIds, [omittedBeforeDelivery, oversized.itemId]); | |
| 458 | assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); | |
| 459 | }), | |
| 460 | ); | |
| 465 | 461 | it.effect("loads the history budget only when a handoff needs delivery", () => |
| 466 | 462 | Effect.gen(function* () { |
| 467 | 463 | let reads = 0; |
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+60-62| ... | ... | @@ -433,8 +433,9 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { |
| 433 | 433 | ), |
| 434 | 434 | ); |
| 435 | 435 | |
| 436 | for (const phase of ["high-water", "replay"] as const) { | |
| 437 | it.effect(`bounds live events while the V2 ${phase} query is blocked`, () => | |
| 436 | it.effect.each(["high-water", "replay"] as const)( | |
| 437 | "bounds live events while the V2 %s query is blocked", | |
| 438 | (phase) => | |
| 438 | 439 | Effect.scoped( |
| 439 | 440 | Effect.gen(function* () { |
| 440 | 441 | const sink = yield* EventSink.EventSinkV2; |
| ... | ... | @@ -486,8 +487,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { |
| 486 | 487 | } |
| 487 | 488 | }), |
| 488 | 489 | ), |
| 489 | ); | |
| 490 | } | |
| 490 | ); | |
| 491 | 491 | |
| 492 | 492 | it.effect( |
| 493 | 493 | "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) => { |
| 2661 | 2661 | }), |
| 2662 | 2662 | ); |
| 2663 | 2663 | |
| 2664 | for (const replayRequest of [ | |
| 2664 | it.effect.each([ | |
| 2665 | 2665 | { type: "terminal.cleanup" }, |
| 2666 | 2666 | { type: "provider-runtime.continue", sourceRunId: RunId.make("run:restart-replay") }, |
| 2667 | ] as const) { | |
| 2668 | it.effect( | |
| 2669 | `retires live provider effects and requeues ${replayRequest.type} after process loss`, | |
| 2670 | () => | |
| 2671 | Effect.gen(function* () { | |
| 2672 | const outbox = yield* EffectOutbox.EffectOutboxV2; | |
| 2673 | const commandId = CommandId.make( | |
| 2674 | `command:foundation-reclaim-running:${replayRequest.type}`, | |
| 2675 | ); | |
| 2676 | yield* outbox.enqueue([ | |
| 2677 | { | |
| 2678 | id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, | |
| 2679 | commandId, | |
| 2680 | threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`), | |
| 2681 | request: { | |
| 2682 | type: "provider-turn.start", | |
| 2683 | runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`), | |
| 2684 | }, | |
| 2685 | }, | |
| 2686 | { | |
| 2687 | id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`, | |
| 2688 | commandId, | |
| 2689 | threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`), | |
| 2690 | request: replayRequest, | |
| 2667 | ] as const)( | |
| 2668 | "retires live provider effects and requeues $type after process loss", | |
| 2669 | (replayRequest) => | |
| 2670 | Effect.gen(function* () { | |
| 2671 | const outbox = yield* EffectOutbox.EffectOutboxV2; | |
| 2672 | const commandId = CommandId.make( | |
| 2673 | `command:foundation-reclaim-running:${replayRequest.type}`, | |
| 2674 | ); | |
| 2675 | yield* outbox.enqueue([ | |
| 2676 | { | |
| 2677 | id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, | |
| 2678 | commandId, | |
| 2679 | threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`), | |
| 2680 | request: { | |
| 2681 | type: "provider-turn.start", | |
| 2682 | runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`), | |
| 2691 | 2683 | }, |
| 2692 | ]); | |
| 2693 | assert.isTrue( | |
| 2694 | Option.isSome( | |
| 2695 | yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), | |
| 2696 | ), | |
| 2697 | ); | |
| 2698 | assert.isTrue( | |
| 2699 | Option.isSome( | |
| 2700 | yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), | |
| 2701 | ), | |
| 2702 | ); | |
| 2703 | assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, { | |
| 2704 | cancelled: 1, | |
| 2705 | requeued: 1, | |
| 2706 | }); | |
| 2707 | const cancelled = yield* outbox.get( | |
| 2708 | `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, | |
| 2709 | ); | |
| 2710 | assert.isTrue(Option.isSome(cancelled)); | |
| 2711 | if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled"); | |
| 2684 | }, | |
| 2685 | { | |
| 2686 | id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`, | |
| 2687 | commandId, | |
| 2688 | threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`), | |
| 2689 | request: replayRequest, | |
| 2690 | }, | |
| 2691 | ]); | |
| 2692 | assert.isTrue( | |
| 2693 | Option.isSome( | |
| 2694 | yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), | |
| 2695 | ), | |
| 2696 | ); | |
| 2697 | assert.isTrue( | |
| 2698 | Option.isSome( | |
| 2699 | yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), | |
| 2700 | ), | |
| 2701 | ); | |
| 2702 | assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, { | |
| 2703 | cancelled: 1, | |
| 2704 | requeued: 1, | |
| 2705 | }); | |
| 2706 | const cancelled = yield* outbox.get( | |
| 2707 | `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, | |
| 2708 | ); | |
| 2709 | assert.isTrue(Option.isSome(cancelled)); | |
| 2710 | if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled"); | |
| 2712 | 2711 | |
| 2713 | const reclaimed = yield* outbox.claimNext({ | |
| 2714 | workerId: "recovery-worker", | |
| 2715 | leaseDurationMs: 30_000, | |
| 2716 | }); | |
| 2717 | assert.isTrue(Option.isSome(reclaimed)); | |
| 2718 | if (Option.isSome(reclaimed)) { | |
| 2719 | assert.equal(reclaimed.value.request.type, replayRequest.type); | |
| 2720 | assert.equal(reclaimed.value.attemptCount, 2); | |
| 2721 | yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" }); | |
| 2722 | } | |
| 2723 | }), | |
| 2724 | ); | |
| 2725 | } | |
| 2712 | const reclaimed = yield* outbox.claimNext({ | |
| 2713 | workerId: "recovery-worker", | |
| 2714 | leaseDurationMs: 30_000, | |
| 2715 | }); | |
| 2716 | assert.isTrue(Option.isSome(reclaimed)); | |
| 2717 | if (Option.isSome(reclaimed)) { | |
| 2718 | assert.equal(reclaimed.value.request.type, replayRequest.type); | |
| 2719 | assert.equal(reclaimed.value.attemptCount, 2); | |
| 2720 | yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" }); | |
| 2721 | } | |
| 2722 | }), | |
| 2723 | ); | |
| 2726 | 2724 | |
| 2727 | 2725 | it.effect("atomically cancels stale runs and their process-bound effects", () => |
| 2728 | 2726 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/LiveStreamBudget.test.ts+8-8| ... | ... | @@ -78,8 +78,9 @@ it.effect("stops draining a slow subscriber when its unacknowledged tail fills", |
| 78 | 78 | type Event = { readonly sequence: number; readonly text: string; readonly threadId?: string }; |
| 79 | 79 | |
| 80 | 80 | describe("replayAndBufferLiveEvents", () => { |
| 81 | for (const phase of ["high-water", "replay"] as const) { | |
| 82 | it.effect(`unsubscribes and cancels a blocked ${phase} read on live overflow`, () => | |
| 81 | it.effect.each(["high-water", "replay"] as const)( | |
| 82 | "unsubscribes and cancels a blocked %s read on live overflow", | |
| 83 | (phase) => | |
| 83 | 84 | Effect.scoped( |
| 84 | 85 | Effect.gen(function* () { |
| 85 | 86 | const pubsub = yield* PubSub.unbounded<Event>(); |
| ... | ... | @@ -121,11 +122,11 @@ describe("replayAndBufferLiveEvents", () => { |
| 121 | 122 | expect(yield* PubSub.size(pubsub)).toBe(0); |
| 122 | 123 | }), |
| 123 | 124 | ), |
| 124 | ); | |
| 125 | } | |
| 125 | ); | |
| 126 | 126 | |
| 127 | for (const limit of ["items", "bytes"] as const) { | |
| 128 | it.effect(`counts an unacknowledged replay batch toward the live ${limit} limit`, () => | |
| 127 | it.effect.each(["items", "bytes"] as const)( | |
| 128 | "counts an unacknowledged replay batch toward the live %s limit", | |
| 129 | (limit) => | |
| 129 | 130 | Effect.scoped( |
| 130 | 131 | Effect.gen(function* () { |
| 131 | 132 | const pubsub = yield* PubSub.unbounded<Event>(); |
| ... | ... | @@ -158,8 +159,7 @@ describe("replayAndBufferLiveEvents", () => { |
| 158 | 159 | if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError"); |
| 159 | 160 | }), |
| 160 | 161 | ), |
| 161 | ); | |
| 162 | } | |
| 162 | ); | |
| 163 | 163 | |
| 164 | 164 | it.effect("lets a fast reader replay a database page larger than both buffer limits", () => |
| 165 | 165 | Effect.scoped( |
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts+53-55| ... | ... | @@ -332,61 +332,59 @@ const runScenario = (input: { |
| 332 | 332 | }); |
| 333 | 333 | |
| 334 | 334 | describe("OpenCode 2 through the orchestrator", () => { |
| 335 | for (const via of ["message", "thread settings"] as const) { | |
| 336 | it.effect( | |
| 337 | `switches the session's model before the next prompt when changed from the ${via}`, | |
| 338 | () => | |
| 339 | Effect.gen(function* () { | |
| 340 | const name = `opencode2-model-switch-${via.replace(" ", "-")}`; | |
| 341 | const cwd = yield* checkpointWorkspace(name); | |
| 342 | const thread = threadCommands({ name, worktreePath: cwd }); | |
| 343 | const projection = yield* runScenario({ | |
| 344 | name, | |
| 345 | threadId: thread.threadId, | |
| 346 | entries: [ | |
| 347 | ...createdSession(cwd, name), | |
| 348 | ...instructionsWritten, | |
| 349 | ...answeredPrompt("FIRST"), | |
| 350 | // The next turn resumes the session at its new selection. | |
| 351 | out("session.get", { sessionID: SESSION }), | |
| 352 | reply("session.get", sessionInfo(cwd, t3Rules(name))), | |
| 353 | out("session.switchModel", { | |
| 354 | sessionID: SESSION, | |
| 355 | model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, | |
| 356 | }), | |
| 357 | reply("session.switchModel", null), | |
| 358 | // The instructions name the model, so they are written again. | |
| 359 | ...instructionsWritten, | |
| 360 | ...answeredPrompt("SECOND"), | |
| 361 | ], | |
| 362 | commands: [ | |
| 363 | thread.create, | |
| 364 | thread.message("first"), | |
| 365 | ...(via === "thread settings" | |
| 366 | ? [ | |
| 367 | { | |
| 368 | type: "thread.model-selection.set", | |
| 369 | commandId: thread.command("model"), | |
| 370 | threadId: thread.threadId, | |
| 371 | modelSelection: mimo, | |
| 372 | } satisfies OrchestrationV2Command, | |
| 373 | ] | |
| 374 | : []), | |
| 375 | thread.message("second", mimo), | |
| 376 | ], | |
| 377 | }); | |
| 378 | assert.deepEqual( | |
| 379 | projection.runs.map((run) => [run.status, run.modelSelection.model]), | |
| 380 | [ | |
| 381 | ["completed", bigPickle.model], | |
| 382 | ["completed", mimo.model], | |
| 383 | ], | |
| 384 | ); | |
| 385 | // One native session carried both turns. | |
| 386 | assert.lengthOf(projection.providerThreads, 1); | |
| 387 | }).pipe(Effect.scoped), | |
| 388 | ); | |
| 389 | } | |
| 335 | it.effect.each(["message", "thread settings"] as const)( | |
| 336 | "switches the session's model before the next prompt when changed from the %s", | |
| 337 | (via) => | |
| 338 | Effect.gen(function* () { | |
| 339 | const name = `opencode2-model-switch-${via.replace(" ", "-")}`; | |
| 340 | const cwd = yield* checkpointWorkspace(name); | |
| 341 | const thread = threadCommands({ name, worktreePath: cwd }); | |
| 342 | const projection = yield* runScenario({ | |
| 343 | name, | |
| 344 | threadId: thread.threadId, | |
| 345 | entries: [ | |
| 346 | ...createdSession(cwd, name), | |
| 347 | ...instructionsWritten, | |
| 348 | ...answeredPrompt("FIRST"), | |
| 349 | // The next turn resumes the session at its new selection. | |
| 350 | out("session.get", { sessionID: SESSION }), | |
| 351 | reply("session.get", sessionInfo(cwd, t3Rules(name))), | |
| 352 | out("session.switchModel", { | |
| 353 | sessionID: SESSION, | |
| 354 | model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, | |
| 355 | }), | |
| 356 | reply("session.switchModel", null), | |
| 357 | // The instructions name the model, so they are written again. | |
| 358 | ...instructionsWritten, | |
| 359 | ...answeredPrompt("SECOND"), | |
| 360 | ], | |
| 361 | commands: [ | |
| 362 | thread.create, | |
| 363 | thread.message("first"), | |
| 364 | ...(via === "thread settings" | |
| 365 | ? [ | |
| 366 | { | |
| 367 | type: "thread.model-selection.set", | |
| 368 | commandId: thread.command("model"), | |
| 369 | threadId: thread.threadId, | |
| 370 | modelSelection: mimo, | |
| 371 | } satisfies OrchestrationV2Command, | |
| 372 | ] | |
| 373 | : []), | |
| 374 | thread.message("second", mimo), | |
| 375 | ], | |
| 376 | }); | |
| 377 | assert.deepEqual( | |
| 378 | projection.runs.map((run) => [run.status, run.modelSelection.model]), | |
| 379 | [ | |
| 380 | ["completed", bigPickle.model], | |
| 381 | ["completed", mimo.model], | |
| 382 | ], | |
| 383 | ); | |
| 384 | // One native session carried both turns. | |
| 385 | assert.lengthOf(projection.providerThreads, 1); | |
| 386 | }).pipe(Effect.scoped), | |
| 387 | ); | |
| 390 | 388 | |
| 391 | 389 | it.effect("moves the session to the thread's new worktree before the next prompt", () => |
| 392 | 390 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/ProjectCommands.test.ts+5-4| ... | ... | @@ -128,16 +128,17 @@ describe("planProjectCommand", () => { |
| 128 | 128 | assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode); |
| 129 | 129 | }); |
| 130 | 130 | |
| 131 | for (const id of ["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)]) { | |
| 132 | it(`rejects a new script ID that cannot have a shortcut: ${id}`, () => { | |
| 131 | it.each(["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)])( | |
| 132 | "rejects a new script ID that cannot have a shortcut: %s", | |
| 133 | (id) => { | |
| 133 | 134 | const failure = failureOf(update({ scripts: [script("lint"), script(id)] })); |
| 134 | 135 | assert.equal(failure._tag, "ProjectCommandInvariantError"); |
| 135 | 136 | assert.include(failure.message, "Script ID"); |
| 136 | 137 | assert.include(failure.message, "24"); |
| 137 | 138 | // The detail is persisted in the rejected receipt, so it omits the raw ID. |
| 138 | 139 | assert.notInclude(failure.message, `'${id}'`); |
| 139 | }); | |
| 140 | } | |
| 140 | }, | |
| 141 | ); | |
| 141 | 142 | |
| 142 | 143 | it("accepts a script ID at the shortcut length limit", () => { |
| 143 | 144 | const scripts = [script("a".repeat(24))]; |
apps/server/src/orchestration-v2/ProjectionControlReads.test.ts+14-8| ... | ... | @@ -186,12 +186,17 @@ function fixtureEvents(now: DateTime.Utc): ReadonlyArray<OrchestrationV2DomainEv |
| 186 | 186 | ]; |
| 187 | 187 | } |
| 188 | 188 | |
| 189 | for (const storage of ["sqlite", "memory"] as const) { | |
| 190 | const storeLayer = | |
| 189 | const storageCases = (["sqlite", "memory"] as const).map((storage) => ({ | |
| 190 | storage, | |
| 191 | storeLayer: | |
| 191 | 192 | storage === "sqlite" |
| 192 | 193 | ? ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)) |
| 193 | : ProjectionStore.layerMemory; | |
| 194 | it.effect(`${storage}: finds the active root turn without an attempt reverse link`, () => | |
| 194 | : ProjectionStore.layerMemory, | |
| 195 | })); | |
| 196 | ||
| 197 | it.effect.each(storageCases)( | |
| 198 | "$storage: finds the active root turn without an attempt reverse link", | |
| 199 | ({ storeLayer }) => | |
| 195 | 200 | Effect.gen(function* () { |
| 196 | 201 | const store = yield* ProjectionStore.ProjectionStoreV2; |
| 197 | 202 | const now = yield* DateTime.now; |
| ... | ... | @@ -210,8 +215,10 @@ for (const storage of ["sqlite", "memory"] as const) { |
| 210 | 215 | }); |
| 211 | 216 | assert.isUndefined((yield* store.getRunningTurnContext(threadId)).providerTurn); |
| 212 | 217 | }).pipe(Effect.provide(storeLayer)), |
| 213 | ); | |
| 214 | it.effect(`${storage}: controls and replies read only their exact durable targets`, () => | |
| 218 | ); | |
| 219 | it.effect.each(storageCases)( | |
| 220 | "$storage: controls and replies read only their exact durable targets", | |
| 221 | ({ storage, storeLayer }) => | |
| 215 | 222 | Effect.gen(function* () { |
| 216 | 223 | const store = yield* ProjectionStore.ProjectionStoreV2; |
| 217 | 224 | const now = yield* DateTime.now; |
| ... | ... | @@ -344,5 +351,4 @@ for (const storage of ["sqlite", "memory"] as const) { |
| 344 | 351 | ), |
| 345 | 352 | ); |
| 346 | 353 | }).pipe(Effect.provide(Layer.merge(storeLayer, SqlitePersistenceMemory))), |
| 347 | ); | |
| 348 | } | |
| 354 | ); |
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+166-173| ... | ... | @@ -140,159 +140,157 @@ const createItem = Effect.fn(function* ( |
| 140 | 140 | }); |
| 141 | 141 | }); |
| 142 | 142 | |
| 143 | for (const [name, testLayer] of [ | |
| 143 | it.effect.each([ | |
| 144 | 144 | ["sql", SqlLayer], |
| 145 | 145 | ["memory", ProjectionStore.layerMemory], |
| 146 | ] as const) { | |
| 147 | it.effect( | |
| 148 | `${name}: discovers settlement work with the same activity and background semantics as the shell`, | |
| 149 | () => | |
| 150 | Effect.gen(function* () { | |
| 151 | const store = yield* ProjectionStore.ProjectionStoreV2; | |
| 152 | const idle = yield* createThread("idle"); | |
| 153 | const completed = yield* createThread("completed"); | |
| 154 | yield* createRun(completed); | |
| 155 | for (const [name, overrides] of [ | |
| 156 | ["archived", { archivedAt: old }], | |
| 157 | ["deleted", { deletedAt: old }], | |
| 158 | ["settled", { settledOverride: "settled" }], | |
| 159 | ["unsettled", { settledOverride: "active" }], | |
| 160 | ["pinned", { pinnedAt: old }], | |
| 161 | ["auto-settle-disabled", { autoSettleDisabledAt: old }], | |
| 162 | ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) { | |
| 163 | yield* createRun(yield* createThread(name, overrides)); | |
| 164 | } | |
| 165 | for (const status of ["preparing", "starting", "running", "waiting"] as const) { | |
| 166 | yield* createRun(yield* createThread(status), status); | |
| 167 | } | |
| 168 | const queued = yield* createThread("queued"); | |
| 169 | yield* createRun(queued, "queued"); | |
| 170 | const blocked = yield* createThread("blocked"); | |
| 171 | yield* store.apply({ | |
| 172 | id: EventId.make("event:settlement:request"), | |
| 173 | type: "runtime-request.updated", | |
| 174 | threadId: blocked, | |
| 175 | occurredAt: old, | |
| 176 | payload: { | |
| 177 | id: RuntimeRequestId.make("request:settlement"), | |
| 178 | nodeId: NodeId.make("node:settlement"), | |
| 179 | providerTurnId: null, | |
| 180 | nativeRequestRef: null, | |
| 181 | kind: "user_input", | |
| 182 | status: "pending", | |
| 183 | responseCapability: { type: "not_resumable", reason: "Process stopped" }, | |
| 184 | createdAt: old, | |
| 185 | resolvedAt: null, | |
| 186 | }, | |
| 187 | }); | |
| 188 | const recent = yield* createThread("recent-message"); | |
| 189 | yield* createRun(recent); | |
| 190 | yield* store.apply({ | |
| 191 | id: EventId.make("event:settlement:message"), | |
| 192 | type: "message.updated", | |
| 146 | ] as const)( | |
| 147 | "%s: discovers settlement work with the same activity and background semantics as the shell", | |
| 148 | ([, testLayer]) => | |
| 149 | Effect.gen(function* () { | |
| 150 | const store = yield* ProjectionStore.ProjectionStoreV2; | |
| 151 | const idle = yield* createThread("idle"); | |
| 152 | const completed = yield* createThread("completed"); | |
| 153 | yield* createRun(completed); | |
| 154 | for (const [name, overrides] of [ | |
| 155 | ["archived", { archivedAt: old }], | |
| 156 | ["deleted", { deletedAt: old }], | |
| 157 | ["settled", { settledOverride: "settled" }], | |
| 158 | ["unsettled", { settledOverride: "active" }], | |
| 159 | ["pinned", { pinnedAt: old }], | |
| 160 | ["auto-settle-disabled", { autoSettleDisabledAt: old }], | |
| 161 | ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) { | |
| 162 | yield* createRun(yield* createThread(name, overrides)); | |
| 163 | } | |
| 164 | for (const status of ["preparing", "starting", "running", "waiting"] as const) { | |
| 165 | yield* createRun(yield* createThread(status), status); | |
| 166 | } | |
| 167 | const queued = yield* createThread("queued"); | |
| 168 | yield* createRun(queued, "queued"); | |
| 169 | const blocked = yield* createThread("blocked"); | |
| 170 | yield* store.apply({ | |
| 171 | id: EventId.make("event:settlement:request"), | |
| 172 | type: "runtime-request.updated", | |
| 173 | threadId: blocked, | |
| 174 | occurredAt: old, | |
| 175 | payload: { | |
| 176 | id: RuntimeRequestId.make("request:settlement"), | |
| 177 | nodeId: NodeId.make("node:settlement"), | |
| 178 | providerTurnId: null, | |
| 179 | nativeRequestRef: null, | |
| 180 | kind: "user_input", | |
| 181 | status: "pending", | |
| 182 | responseCapability: { type: "not_resumable", reason: "Process stopped" }, | |
| 183 | createdAt: old, | |
| 184 | resolvedAt: null, | |
| 185 | }, | |
| 186 | }); | |
| 187 | const recent = yield* createThread("recent-message"); | |
| 188 | yield* createRun(recent); | |
| 189 | yield* store.apply({ | |
| 190 | id: EventId.make("event:settlement:message"), | |
| 191 | type: "message.updated", | |
| 192 | threadId: recent, | |
| 193 | occurredAt: now, | |
| 194 | payload: { | |
| 195 | createdBy: "user", | |
| 196 | creationSource: "web", | |
| 197 | id: MessageId.make("message:settlement:recent"), | |
| 193 | 198 | threadId: recent, |
| 194 | occurredAt: now, | |
| 195 | payload: { | |
| 196 | createdBy: "user", | |
| 197 | creationSource: "web", | |
| 198 | id: MessageId.make("message:settlement:recent"), | |
| 199 | threadId: recent, | |
| 200 | runId: null, | |
| 201 | nodeId: null, | |
| 202 | role: "user", | |
| 203 | text: "Next turn", | |
| 204 | attachments: [], | |
| 205 | streaming: false, | |
| 206 | createdAt: now, | |
| 207 | updatedAt: now, | |
| 208 | }, | |
| 209 | }); | |
| 210 | yield* createRun( | |
| 211 | yield* createThread("snoozed", { | |
| 212 | snoozedUntil: DateTime.add(now, { days: 1 }), | |
| 213 | snoozedAt: now, | |
| 214 | }), | |
| 215 | ); | |
| 216 | const woke = yield* createThread("woke", { | |
| 199 | runId: null, | |
| 200 | nodeId: null, | |
| 201 | role: "user", | |
| 202 | text: "Next turn", | |
| 203 | attachments: [], | |
| 204 | streaming: false, | |
| 205 | createdAt: now, | |
| 206 | updatedAt: now, | |
| 207 | }, | |
| 208 | }); | |
| 209 | yield* createRun( | |
| 210 | yield* createThread("snoozed", { | |
| 217 | 211 | snoozedUntil: DateTime.add(now, { days: 1 }), |
| 218 | snoozedAt: DateTime.subtract(old, { days: 1 }), | |
| 219 | }); | |
| 220 | yield* createRun(woke); | |
| 221 | const background = yield* createThread("idle-background"); | |
| 222 | yield* createItem(background, yield* createRun(background), "idle"); | |
| 223 | const persistent = yield* createThread("persistent-monitor"); | |
| 224 | yield* createItem(persistent, yield* createRun(persistent), "running", true); | |
| 225 | const rolledBack = yield* createThread("rolled-back-background"); | |
| 226 | yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); | |
| 227 | yield* createRun(rolledBack, "completed", 2); | |
| 228 | const roster = yield* createThread("provider-roster"); | |
| 229 | yield* createRun(roster); | |
| 230 | yield* store.apply({ | |
| 231 | id: EventId.make("event:settlement:roster"), | |
| 232 | type: "provider-thread.updated", | |
| 233 | threadId: roster, | |
| 234 | occurredAt: old, | |
| 235 | payload: { | |
| 236 | id: ProviderThreadId.make("provider-thread:settlement"), | |
| 237 | appThreadId: roster, | |
| 238 | ownerNodeId: null, | |
| 239 | driver: ProviderDriverKind.make("codex"), | |
| 240 | providerInstanceId, | |
| 241 | providerSessionId: null, | |
| 242 | nativeThreadRef: null, | |
| 243 | nativeConversationHeadRef: null, | |
| 244 | status: "idle", | |
| 245 | firstRunOrdinal: null, | |
| 246 | lastRunOrdinal: null, | |
| 247 | handoffIds: [], | |
| 248 | forkedFrom: null, | |
| 249 | createdAt: old, | |
| 250 | updatedAt: old, | |
| 251 | pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }], | |
| 252 | }, | |
| 253 | }); | |
| 254 | const candidates = yield* store.getSettlementCandidates(); | |
| 255 | const shell = yield* store.getShellSnapshot({ location: "active" }); | |
| 256 | const eligible = candidates.filter((thread) => | |
| 257 | isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), | |
| 258 | ); | |
| 259 | assert.deepEqual( | |
| 260 | new Set(eligible.map((thread) => thread.id)), | |
| 261 | new Set([idle, completed, queued, woke, background, persistent, rolledBack]), | |
| 262 | ); | |
| 212 | snoozedAt: now, | |
| 213 | }), | |
| 214 | ); | |
| 215 | const woke = yield* createThread("woke", { | |
| 216 | snoozedUntil: DateTime.add(now, { days: 1 }), | |
| 217 | snoozedAt: DateTime.subtract(old, { days: 1 }), | |
| 218 | }); | |
| 219 | yield* createRun(woke); | |
| 220 | const background = yield* createThread("idle-background"); | |
| 221 | yield* createItem(background, yield* createRun(background), "idle"); | |
| 222 | const persistent = yield* createThread("persistent-monitor"); | |
| 223 | yield* createItem(persistent, yield* createRun(persistent), "running", true); | |
| 224 | const rolledBack = yield* createThread("rolled-back-background"); | |
| 225 | yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); | |
| 226 | yield* createRun(rolledBack, "completed", 2); | |
| 227 | const roster = yield* createThread("provider-roster"); | |
| 228 | yield* createRun(roster); | |
| 229 | yield* store.apply({ | |
| 230 | id: EventId.make("event:settlement:roster"), | |
| 231 | type: "provider-thread.updated", | |
| 232 | threadId: roster, | |
| 233 | occurredAt: old, | |
| 234 | payload: { | |
| 235 | id: ProviderThreadId.make("provider-thread:settlement"), | |
| 236 | appThreadId: roster, | |
| 237 | ownerNodeId: null, | |
| 238 | driver: ProviderDriverKind.make("codex"), | |
| 239 | providerInstanceId, | |
| 240 | providerSessionId: null, | |
| 241 | nativeThreadRef: null, | |
| 242 | nativeConversationHeadRef: null, | |
| 243 | status: "idle", | |
| 244 | firstRunOrdinal: null, | |
| 245 | lastRunOrdinal: null, | |
| 246 | handoffIds: [], | |
| 247 | forkedFrom: null, | |
| 248 | createdAt: old, | |
| 249 | updatedAt: old, | |
| 250 | pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }], | |
| 251 | }, | |
| 252 | }); | |
| 253 | const candidates = yield* store.getSettlementCandidates(); | |
| 254 | const shell = yield* store.getShellSnapshot({ location: "active" }); | |
| 255 | const eligible = candidates.filter((thread) => | |
| 256 | isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), | |
| 257 | ); | |
| 258 | assert.deepEqual( | |
| 259 | new Set(eligible.map((thread) => thread.id)), | |
| 260 | new Set([idle, completed, queued, woke, background, persistent, rolledBack]), | |
| 261 | ); | |
| 262 | assert.deepEqual( | |
| 263 | new Set(eligible.map((thread) => thread.id)), | |
| 264 | new Set( | |
| 265 | shell.threads | |
| 266 | .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) | |
| 267 | .map((thread) => thread.id), | |
| 268 | ), | |
| 269 | ); | |
| 270 | for (const candidate of candidates) { | |
| 271 | const expected = shell.threads.find((thread) => thread.id === candidate.id)!; | |
| 272 | assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); | |
| 273 | const settings = { | |
| 274 | pullRequest: null, | |
| 275 | nowMs: DateTime.toEpochMillis(now), | |
| 276 | autoSettleAfterDays: 7, | |
| 277 | autoSettleOnMerge: false, | |
| 278 | }; | |
| 263 | 279 | assert.deepEqual( |
| 264 | new Set(eligible.map((thread) => thread.id)), | |
| 265 | new Set( | |
| 266 | shell.threads | |
| 267 | .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) | |
| 268 | .map((thread) => thread.id), | |
| 269 | ), | |
| 270 | ); | |
| 271 | for (const candidate of candidates) { | |
| 272 | const expected = shell.threads.find((thread) => thread.id === candidate.id)!; | |
| 273 | assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); | |
| 274 | const settings = { | |
| 275 | pullRequest: null, | |
| 276 | nowMs: DateTime.toEpochMillis(now), | |
| 277 | autoSettleAfterDays: 7, | |
| 278 | autoSettleOnMerge: false, | |
| 279 | }; | |
| 280 | assert.deepEqual( | |
| 281 | resolveAutoSettlementAt({ ...settings, thread: candidate }), | |
| 282 | resolveAutoSettlementAt({ ...settings, thread: expected }), | |
| 283 | ); | |
| 284 | } | |
| 285 | assert.equal( | |
| 286 | DateTime.formatIso((yield* store.getThread(completed)).createdAt), | |
| 287 | DateTime.formatIso(old), | |
| 288 | ); | |
| 289 | assert.equal( | |
| 290 | (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, | |
| 291 | "ProjectionStoreThreadNotFoundError", | |
| 280 | resolveAutoSettlementAt({ ...settings, thread: candidate }), | |
| 281 | resolveAutoSettlementAt({ ...settings, thread: expected }), | |
| 292 | 282 | ); |
| 293 | }).pipe(Effect.provide(testLayer)), | |
| 294 | ); | |
| 295 | } | |
| 283 | } | |
| 284 | assert.equal( | |
| 285 | DateTime.formatIso((yield* store.getThread(completed)).createdAt), | |
| 286 | DateTime.formatIso(old), | |
| 287 | ); | |
| 288 | assert.equal( | |
| 289 | (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, | |
| 290 | "ProjectionStoreThreadNotFoundError", | |
| 291 | ); | |
| 292 | }).pipe(Effect.provide(testLayer)), | |
| 293 | ); | |
| 296 | 294 | |
| 297 | 295 | const pullRequestLink = (number: number) => ({ |
| 298 | 296 | host: "github.com", |
| ... | ... | @@ -305,11 +303,12 @@ const pullRequestLink = (number: number) => ({ |
| 305 | 303 | stack: null, |
| 306 | 304 | }); |
| 307 | 305 | |
| 308 | for (const [name, testLayer] of [ | |
| 306 | it.effect.each([ | |
| 309 | 307 | ["sql", SqlLayer], |
| 310 | 308 | ["memory", ProjectionStore.layerMemory], |
| 311 | ] as const) { | |
| 312 | it.effect(`${name}: lists only active threads with pull request links, oldest first`, () => | |
| 309 | ] as const)( | |
| 310 | "%s: lists only active threads with pull request links, oldest first", | |
| 311 | ([, testLayer]) => | |
| 313 | 312 | Effect.gen(function* () { |
| 314 | 313 | const store = yield* ProjectionStore.ProjectionStoreV2; |
| 315 | 314 | yield* createThread("no-links"); |
| ... | ... | @@ -337,33 +336,27 @@ for (const [name, testLayer] of [ |
| 337 | 336 | ], |
| 338 | 337 | ); |
| 339 | 338 | }).pipe(Effect.provide(testLayer)), |
| 340 | ); | |
| 341 | } | |
| 339 | ); | |
| 342 | 340 | |
| 343 | for (const [name, testLayer] of [ | |
| 341 | it.effect.each([ | |
| 344 | 342 | ["sql", SqlLayer], |
| 345 | 343 | ["memory", ProjectionStore.layerMemory], |
| 346 | ] as const) { | |
| 347 | it.effect(`${name}: an unsettled-only shell read skips settled threads`, () => | |
| 348 | Effect.gen(function* () { | |
| 349 | const store = yield* ProjectionStore.ProjectionStoreV2; | |
| 350 | const open = yield* createThread("unsettled-open"); | |
| 351 | const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" }); | |
| 352 | yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old }); | |
| 353 | yield* createThread("unsettled-auto", { settledAt: old }); | |
| 354 | yield* createThread("unsettled-archived", { archivedAt: old }); | |
| 344 | ] as const)("%s: an unsettled-only shell read skips settled threads", ([, testLayer]) => | |
| 345 | Effect.gen(function* () { | |
| 346 | const store = yield* ProjectionStore.ProjectionStoreV2; | |
| 347 | const open = yield* createThread("unsettled-open"); | |
| 348 | const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" }); | |
| 349 | yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old }); | |
| 350 | yield* createThread("unsettled-auto", { settledAt: old }); | |
| 351 | yield* createThread("unsettled-archived", { archivedAt: old }); | |
| 355 | 352 | |
| 356 | const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true }); | |
| 357 | assert.deepEqual( | |
| 358 | new Set(shell.threads.map((thread) => thread.id)), | |
| 359 | new Set([open, reopened]), | |
| 360 | ); | |
| 361 | assert.equal(shell.archivedThreads.length, 0); | |
| 362 | const all = yield* store.getShellSnapshot({ location: "active" }); | |
| 363 | assert.equal(all.threads.length, 4); | |
| 364 | }).pipe(Effect.provide(testLayer)), | |
| 365 | ); | |
| 366 | } | |
| 353 | const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true }); | |
| 354 | assert.deepEqual(new Set(shell.threads.map((thread) => thread.id)), new Set([open, reopened])); | |
| 355 | assert.equal(shell.archivedThreads.length, 0); | |
| 356 | const all = yield* store.getShellSnapshot({ location: "active" }); | |
| 357 | assert.equal(all.threads.length, 4); | |
| 358 | }).pipe(Effect.provide(testLayer)), | |
| 359 | ); | |
| 367 | 360 | |
| 368 | 361 | it.effect( |
| 369 | 362 | "reads settlement candidates and thread metadata without loading historical or archived payloads", |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+4-4| ... | ... | @@ -724,8 +724,9 @@ layer("ProviderEventIngestorV2", (it) => { |
| 724 | 724 | }), |
| 725 | 725 | ); |
| 726 | 726 | |
| 727 | for (const terminal of ["completed", "interrupted", "failed", "cancelled", "control"] as const) { | |
| 728 | it.effect(`dismisses only native questions when a provider turn ends with ${terminal}`, () => | |
| 727 | it.effect.each(["completed", "interrupted", "failed", "cancelled", "control"] as const)( | |
| 728 | "dismisses only native questions when a provider turn ends with %s", | |
| 729 | (terminal) => | |
| 729 | 730 | Effect.gen(function* () { |
| 730 | 731 | const now = yield* DateTime.now; |
| 731 | 732 | const eventSink = yield* EventSink.EventSinkV2; |
| ... | ... | @@ -909,8 +910,7 @@ layer("ProviderEventIngestorV2", (it) => { |
| 909 | 910 | const repeated = yield* ingestor.ingestNormalized(input); |
| 910 | 911 | assert.isFalse(repeated.some((entry) => entry.event.type === "runtime-request.updated")); |
| 911 | 912 | }), |
| 912 | ); | |
| 913 | } | |
| 913 | ); | |
| 914 | 914 | |
| 915 | 915 | it.effect( |
| 916 | 916 | "preserves an answer committed after terminal normalization reads a pending question", |
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+4-4| ... | ... | @@ -3007,8 +3007,9 @@ it.effect( |
| 3007 | 3007 | }), |
| 3008 | 3008 | ); |
| 3009 | 3009 | |
| 3010 | for (const workspaceState of ["missing", "file"] as const) { | |
| 3011 | it.effect(`rejects a ${workspaceState} workspace before opening a provider session`, () => | |
| 3010 | it.effect.each(["missing", "file"] as const)( | |
| 3011 | "rejects a %s workspace before opening a provider session", | |
| 3012 | (workspaceState) => | |
| 3012 | 3013 | Effect.gen(function* () { |
| 3013 | 3014 | const fileSystem = yield* FileSystem.FileSystem; |
| 3014 | 3015 | const root = yield* fileSystem.makeTempDirectoryScoped(); |
| ... | ... | @@ -3053,8 +3054,7 @@ for (const workspaceState of ["missing", "file"] as const) { |
| 3053 | 3054 | ); |
| 3054 | 3055 | }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 }))); |
| 3055 | 3056 | }).pipe(Effect.provide(NodeServices.layer)), |
| 3056 | ); | |
| 3057 | } | |
| 3057 | ); | |
| 3058 | 3058 | |
| 3059 | 3059 | it.effect( |
| 3060 | 3060 | "rejects a deleted workspace before reusing a live session without changing its state", |
apps/server/src/orchestration-v2/ProviderSwitchService.test.ts+44-49| ... | ... | @@ -120,32 +120,30 @@ it.effect( |
| 120 | 120 | ), |
| 121 | 121 | ); |
| 122 | 122 | |
| 123 | for (const deadStatus of ["stopped", "error"] as const) { | |
| 124 | it.effect( | |
| 125 | `restarts and releases the live session when a newer ${deadStatus} session exists`, | |
| 126 | () => | |
| 127 | Effect.gen(function* () { | |
| 128 | const service = yield* ProviderSwitch.ProviderSwitchServiceV2; | |
| 129 | const thread = projection(); | |
| 130 | const result = yield* service.plan({ | |
| 131 | projection: { | |
| 132 | ...thread, | |
| 133 | providerSessions: [ | |
| 134 | ...thread.providerSessions, | |
| 135 | deadSessionRecord("dead_session", deadStatus), | |
| 136 | ], | |
| 137 | }, | |
| 138 | targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, | |
| 139 | }); | |
| 140 | assert.equal(result.transition.type, "restart_and_resume"); | |
| 141 | assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); | |
| 142 | }).pipe( | |
| 143 | Effect.provide( | |
| 144 | testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), | |
| 145 | ), | |
| 123 | it.effect.each(["stopped", "error"] as const)( | |
| 124 | "restarts and releases the live session when a newer %s session exists", | |
| 125 | (deadStatus) => | |
| 126 | Effect.gen(function* () { | |
| 127 | const service = yield* ProviderSwitch.ProviderSwitchServiceV2; | |
| 128 | const thread = projection(); | |
| 129 | const result = yield* service.plan({ | |
| 130 | projection: { | |
| 131 | ...thread, | |
| 132 | providerSessions: [ | |
| 133 | ...thread.providerSessions, | |
| 134 | deadSessionRecord("dead_session", deadStatus), | |
| 135 | ], | |
| 136 | }, | |
| 137 | targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, | |
| 138 | }); | |
| 139 | assert.equal(result.transition.type, "restart_and_resume"); | |
| 140 | assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); | |
| 141 | }).pipe( | |
| 142 | Effect.provide( | |
| 143 | testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), | |
| 146 | 144 | ), |
| 147 | ); | |
| 148 | } | |
| 145 | ), | |
| 146 | ); | |
| 149 | 147 | |
| 150 | 148 | it.effect("releases the newest live session, not the newest record overall", () => |
| 151 | 149 | Effect.gen(function* () { |
| ... | ... | @@ -275,32 +273,29 @@ it.effect("hands off from a native provider thread after its session detaches", |
| 275 | 273 | ), |
| 276 | 274 | ); |
| 277 | 275 | |
| 278 | for (const deadStatus of ["stopped", "error"] as const) { | |
| 279 | it.effect( | |
| 280 | `applies a model change on next turn when a ${deadStatus} session negotiated model switching`, | |
| 281 | () => | |
| 282 | Effect.gen(function* () { | |
| 283 | const service = yield* ProviderSwitch.ProviderSwitchServiceV2; | |
| 284 | const result = yield* service.plan({ | |
| 285 | projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2), | |
| 286 | targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, | |
| 287 | }); | |
| 288 | // Static capabilities report no in-session switch, but the dead | |
| 289 | // record's negotiated capabilities describe the provider: without | |
| 290 | // them the ACP classification rejects the selection instead of | |
| 291 | // reopening with the requested model on the next run. | |
| 292 | assert.equal(result.transition.type, "switch_model_in_session"); | |
| 293 | assert.deepEqual(result.releaseProviderSessionIds, []); | |
| 294 | }).pipe( | |
| 295 | Effect.provide( | |
| 296 | testLayer( | |
| 297 | { [currentInstanceId]: { continuationKey: "codex:account:primary" } }, | |
| 298 | (input) => Effect.succeed(acpSelectionTransition(input)), | |
| 299 | ), | |
| 276 | it.effect.each(["stopped", "error"] as const)( | |
| 277 | "applies a model change on next turn when a %s session negotiated model switching", | |
| 278 | (deadStatus) => | |
| 279 | Effect.gen(function* () { | |
| 280 | const service = yield* ProviderSwitch.ProviderSwitchServiceV2; | |
| 281 | const result = yield* service.plan({ | |
| 282 | projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2), | |
| 283 | targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, | |
| 284 | }); | |
| 285 | // Static capabilities report no in-session switch, but the dead | |
| 286 | // record's negotiated capabilities describe the provider: without | |
| 287 | // them the ACP classification rejects the selection instead of | |
| 288 | // reopening with the requested model on the next run. | |
| 289 | assert.equal(result.transition.type, "switch_model_in_session"); | |
| 290 | assert.deepEqual(result.releaseProviderSessionIds, []); | |
| 291 | }).pipe( | |
| 292 | Effect.provide( | |
| 293 | testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) => | |
| 294 | Effect.succeed(acpSelectionTransition(input)), | |
| 300 | 295 | ), |
| 301 | 296 | ), |
| 302 | ); | |
| 303 | } | |
| 297 | ), | |
| 298 | ); | |
| 304 | 299 | |
| 305 | 300 | it.effect("rejects a model change the dead record never negotiated support for", () => |
| 306 | 301 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/RestartContinuation.test.ts+53-55| ... | ... | @@ -259,67 +259,65 @@ it.effect("does not continue a failed run that lost background work", () => |
| 259 | 259 | }), |
| 260 | 260 | ); |
| 261 | 261 | |
| 262 | for (const [enabled, projectOverride] of [ | |
| 262 | it.effect.each([ | |
| 263 | 263 | [false, undefined], |
| 264 | 264 | [true, undefined], |
| 265 | 265 | [false, true], |
| 266 | 266 | [true, false], |
| 267 | ] as const) { | |
| 268 | it.effect( | |
| 269 | `atomically records restart intent with cancellation when opt-in is ${enabled} and project override is ${projectOverride}`, | |
| 270 | () => | |
| 271 | Effect.gen(function* () { | |
| 272 | let committed: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | undefined; | |
| 273 | const recovery = yield* ProviderRuntimeRecovery.make.pipe( | |
| 274 | Effect.provide( | |
| 275 | Layer.mergeAll( | |
| 276 | ServerSettings.layerTest({ | |
| 277 | continueThreadsAfterServerUpdate: enabled, | |
| 278 | projectSettingsOverrides: | |
| 279 | projectOverride === undefined | |
| 280 | ? {} | |
| 281 | : { | |
| 282 | [ProjectId.make("restart-project")]: { | |
| 283 | continueThreadsAfterServerUpdate: projectOverride, | |
| 284 | }, | |
| 267 | ] as const)( | |
| 268 | "atomically records restart intent with cancellation when opt-in is %s and project override is %s", | |
| 269 | ([enabled, projectOverride]) => | |
| 270 | Effect.gen(function* () { | |
| 271 | let committed: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | undefined; | |
| 272 | const recovery = yield* ProviderRuntimeRecovery.make.pipe( | |
| 273 | Effect.provide( | |
| 274 | Layer.mergeAll( | |
| 275 | ServerSettings.layerTest({ | |
| 276 | continueThreadsAfterServerUpdate: enabled, | |
| 277 | projectSettingsOverrides: | |
| 278 | projectOverride === undefined | |
| 279 | ? {} | |
| 280 | : { | |
| 281 | [ProjectId.make("restart-project")]: { | |
| 282 | continueThreadsAfterServerUpdate: projectOverride, | |
| 285 | 283 | }, |
| 286 | }), | |
| 287 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | |
| 288 | getRecoveryThreadIds: () => Effect.succeed([threadId]), | |
| 289 | getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()), | |
| 290 | }), | |
| 291 | Layer.mock(EventSink.EventSinkV2)({ | |
| 292 | commitCommand: (input) => { | |
| 293 | committed = input; | |
| 294 | return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never); | |
| 295 | }, | |
| 296 | }), | |
| 297 | IdAllocator.layer, | |
| 298 | Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ | |
| 299 | runRecoveryOnce: Effect.succeed(false), | |
| 300 | }), | |
| 301 | Layer.mock(EffectOutbox.EffectOutboxV2)({ | |
| 302 | reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), | |
| 303 | }), | |
| 304 | ), | |
| 305 | ), | |
| 306 | ); | |
| 307 | yield* recovery.reconcile("startup"); | |
| 308 | assert.isDefined(committed); | |
| 309 | assert.isTrue( | |
| 310 | committed!.events.some( | |
| 311 | (event) => event.type === "run.updated" && event.payload.status === "cancelled", | |
| 284 | }, | |
| 285 | }), | |
| 286 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | |
| 287 | getRecoveryThreadIds: () => Effect.succeed([threadId]), | |
| 288 | getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()), | |
| 289 | }), | |
| 290 | Layer.mock(EventSink.EventSinkV2)({ | |
| 291 | commitCommand: (input) => { | |
| 292 | committed = input; | |
| 293 | return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never); | |
| 294 | }, | |
| 295 | }), | |
| 296 | IdAllocator.layer, | |
| 297 | Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ | |
| 298 | runRecoveryOnce: Effect.succeed(false), | |
| 299 | }), | |
| 300 | Layer.mock(EffectOutbox.EffectOutboxV2)({ | |
| 301 | reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), | |
| 302 | }), | |
| 312 | 303 | ), |
| 313 | ); | |
| 314 | assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0); | |
| 315 | if (projectOverride ?? enabled) | |
| 316 | assert.deepEqual(committed!.effects[0]?.request, { | |
| 317 | type: "provider-runtime.continue", | |
| 318 | sourceRunId: runId, | |
| 319 | }); | |
| 320 | }), | |
| 321 | ); | |
| 322 | } | |
| 304 | ), | |
| 305 | ); | |
| 306 | yield* recovery.reconcile("startup"); | |
| 307 | assert.isDefined(committed); | |
| 308 | assert.isTrue( | |
| 309 | committed!.events.some( | |
| 310 | (event) => event.type === "run.updated" && event.payload.status === "cancelled", | |
| 311 | ), | |
| 312 | ); | |
| 313 | assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0); | |
| 314 | if (projectOverride ?? enabled) | |
| 315 | assert.deepEqual(committed!.effects[0]?.request, { | |
| 316 | type: "provider-runtime.continue", | |
| 317 | sourceRunId: runId, | |
| 318 | }); | |
| 319 | }), | |
| 320 | ); | |
| 323 | 321 | |
| 324 | 322 | it.effect("does not duplicate delivery and yields to newer user work or opt-out", () => |
| 325 | 323 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/RunExecutionService.test.ts+8-8| ... | ... | @@ -862,8 +862,9 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => |
| 862 | 862 | }), |
| 863 | 863 | ); |
| 864 | 864 | |
| 865 | for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard"] as const) { | |
| 866 | it.effect(`handles ${scenario} before the provider turn starts`, () => | |
| 865 | it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)( | |
| 866 | "handles %s before the provider turn starts", | |
| 867 | (scenario) => | |
| 867 | 868 | Effect.gen(function* () { |
| 868 | 869 | const threadId = ThreadId.make("thread:run-execution-settings-failure"); |
| 869 | 870 | const runId = RunId.make("run:run-execution-settings-failure"); |
| ... | ... | @@ -1030,8 +1031,7 @@ for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard |
| 1030 | 1031 | assert.equal(errorItem.payload.failure.message, "Run preparation failed."); |
| 1031 | 1032 | } |
| 1032 | 1033 | }), |
| 1033 | ); | |
| 1034 | } | |
| 1034 | ); | |
| 1035 | 1035 | |
| 1036 | 1036 | it.effect("keeps ingesting owned child events after the root turn terminalizes", () => |
| 1037 | 1037 | Effect.gen(function* () { |
| ... | ... | @@ -3149,8 +3149,9 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp |
| 3149 | 3149 | }), |
| 3150 | 3150 | ); |
| 3151 | 3151 | |
| 3152 | for (const status of ["completed", "interrupted", "cancelled", "failed"] as const) { | |
| 3153 | it.effect(`refreshes pull requests after the current root run ${status}`, () => | |
| 3152 | it.effect.each(["completed", "interrupted", "cancelled", "failed"] as const)( | |
| 3153 | "refreshes pull requests after the current root run %s", | |
| 3154 | (status) => | |
| 3154 | 3155 | Effect.gen(function* () { |
| 3155 | 3156 | const { observed } = yield* captureRootRunTermination({ |
| 3156 | 3157 | key: `pull-request-refresh:${status}`, |
| ... | ... | @@ -3162,8 +3163,7 @@ for (const status of ["completed", "interrupted", "cancelled", "failed"] as cons |
| 3162 | 3163 | "pull-requests-refreshed", |
| 3163 | 3164 | ]); |
| 3164 | 3165 | }), |
| 3165 | ); | |
| 3166 | } | |
| 3166 | ); | |
| 3167 | 3167 | |
| 3168 | 3168 | it.effect("does not refresh pull requests for auxiliary or stale provider terminals", () => |
| 3169 | 3169 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/RunFinalizationService.test.ts+78-76| ... | ... | @@ -50,80 +50,82 @@ it.effect("refreshes workspace after checkpoint capture without reading history" |
| 50 | 50 | }).pipe(Effect.provide(layer)); |
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | for (const scenario of [ | |
| 54 | { | |
| 55 | label: "discovers a new PR for the completed run's branch", | |
| 56 | branch: "feature", | |
| 57 | checkedOut: "feature", | |
| 58 | activeRun: null, | |
| 59 | expected: ["/repo"], | |
| 60 | }, | |
| 61 | { | |
| 62 | label: "leaves the default branch's PR cache alone", | |
| 63 | branch: "main", | |
| 64 | checkedOut: "main", | |
| 65 | activeRun: null, | |
| 66 | expected: [], | |
| 67 | }, | |
| 68 | { | |
| 69 | label: "does not refresh another thread's checkout", | |
| 70 | branch: "feature", | |
| 71 | checkedOut: "other", | |
| 72 | activeRun: null, | |
| 73 | expected: [], | |
| 74 | }, | |
| 75 | { | |
| 76 | label: "does not refresh during a newer active run", | |
| 77 | branch: "feature", | |
| 78 | checkedOut: "feature", | |
| 79 | activeRun: "newer-run", | |
| 80 | expected: [], | |
| 81 | }, | |
| 82 | ] as const) { | |
| 83 | it.effect(scenario.label, () => { | |
| 84 | const refreshed: string[] = []; | |
| 85 | const threadId = ThreadId.make("thread-pr-refresh"); | |
| 86 | const runId = RunId.make("completed-run"); | |
| 87 | const layer = RunFinalization.observerLive.pipe( | |
| 88 | Layer.provide( | |
| 89 | Layer.mergeAll( | |
| 90 | Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), | |
| 91 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 92 | refreshAfterTurn: () => Effect.void, | |
| 93 | }), | |
| 94 | Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ | |
| 95 | refreshLocalStatus: () => | |
| 96 | Effect.succeed({ | |
| 97 | isRepo: true, | |
| 98 | hasPrimaryRemote: true, | |
| 99 | isDefaultRef: scenario.checkedOut === "main", | |
| 100 | refName: scenario.checkedOut, | |
| 101 | hasWorkingTreeChanges: false, | |
| 102 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 103 | }), | |
| 104 | refreshStatus: () => | |
| 105 | Effect.die("turn completion must preserve known PRs and lookup backoff"), | |
| 106 | refreshPullRequestStatus: (cwd) => | |
| 107 | Effect.sync(() => { | |
| 108 | refreshed.push(cwd); | |
| 109 | return null; | |
| 110 | }), | |
| 111 | }), | |
| 112 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | |
| 113 | getThreadShell: () => | |
| 114 | Effect.succeed({ | |
| 115 | id: threadId, | |
| 116 | branch: scenario.branch, | |
| 117 | activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun), | |
| 118 | } as OrchestrationV2ThreadShell), | |
| 119 | }), | |
| 120 | ), | |
| 53 | it.effect.each( | |
| 54 | ( | |
| 55 | [ | |
| 56 | { | |
| 57 | label: "discovers a new PR for the completed run's branch", | |
| 58 | branch: "feature", | |
| 59 | checkedOut: "feature", | |
| 60 | activeRun: null, | |
| 61 | expected: ["/repo"], | |
| 62 | }, | |
| 63 | { | |
| 64 | label: "leaves the default branch's PR cache alone", | |
| 65 | branch: "main", | |
| 66 | checkedOut: "main", | |
| 67 | activeRun: null, | |
| 68 | expected: [], | |
| 69 | }, | |
| 70 | { | |
| 71 | label: "does not refresh another thread's checkout", | |
| 72 | branch: "feature", | |
| 73 | checkedOut: "other", | |
| 74 | activeRun: null, | |
| 75 | expected: [], | |
| 76 | }, | |
| 77 | { | |
| 78 | label: "does not refresh during a newer active run", | |
| 79 | branch: "feature", | |
| 80 | checkedOut: "feature", | |
| 81 | activeRun: "newer-run", | |
| 82 | expected: [], | |
| 83 | }, | |
| 84 | ] as const | |
| 85 | ).map((scenario) => [scenario.label, scenario] as const), | |
| 86 | )("%s", ([, scenario]) => { | |
| 87 | const refreshed: string[] = []; | |
| 88 | const threadId = ThreadId.make("thread-pr-refresh"); | |
| 89 | const runId = RunId.make("completed-run"); | |
| 90 | const layer = RunFinalization.observerLive.pipe( | |
| 91 | Layer.provide( | |
| 92 | Layer.mergeAll( | |
| 93 | Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), | |
| 94 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 95 | refreshAfterTurn: () => Effect.void, | |
| 96 | }), | |
| 97 | Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ | |
| 98 | refreshLocalStatus: () => | |
| 99 | Effect.succeed({ | |
| 100 | isRepo: true, | |
| 101 | hasPrimaryRemote: true, | |
| 102 | isDefaultRef: scenario.checkedOut === "main", | |
| 103 | refName: scenario.checkedOut, | |
| 104 | hasWorkingTreeChanges: false, | |
| 105 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 106 | }), | |
| 107 | refreshStatus: () => | |
| 108 | Effect.die("turn completion must preserve known PRs and lookup backoff"), | |
| 109 | refreshPullRequestStatus: (cwd) => | |
| 110 | Effect.sync(() => { | |
| 111 | refreshed.push(cwd); | |
| 112 | return null; | |
| 113 | }), | |
| 114 | }), | |
| 115 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | |
| 116 | getThreadShell: () => | |
| 117 | Effect.succeed({ | |
| 118 | id: threadId, | |
| 119 | branch: scenario.branch, | |
| 120 | activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun), | |
| 121 | } as OrchestrationV2ThreadShell), | |
| 122 | }), | |
| 121 | 123 | ), |
| 122 | ); | |
| 123 | return Effect.gen(function* () { | |
| 124 | const observer = yield* RunFinalization.RunFinalizationObserver; | |
| 125 | yield* observer.refresh({ cwd: "/repo", threadId, runId }); | |
| 126 | assert.deepEqual(refreshed, [...scenario.expected]); | |
| 127 | }).pipe(Effect.provide(layer)); | |
| 128 | }); | |
| 129 | } | |
| 124 | ), | |
| 125 | ); | |
| 126 | return Effect.gen(function* () { | |
| 127 | const observer = yield* RunFinalization.RunFinalizationObserver; | |
| 128 | yield* observer.refresh({ cwd: "/repo", threadId, runId }); | |
| 129 | assert.deepEqual(refreshed, [...scenario.expected]); | |
| 130 | }).pipe(Effect.provide(layer)); | |
| 131 | }); |
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+164-166| ... | ... | @@ -537,176 +537,174 @@ it.live("restarts selection as a new attempt and retries after old-session clean |
| 537 | 537 | ), |
| 538 | 538 | ); |
| 539 | 539 | |
| 540 | for (const deadStatus of ["stopped", "error"] as const) { | |
| 541 | it.live( | |
| 542 | `restarts the live session on a model change when a newer ${deadStatus} session record exists`, | |
| 543 | () => | |
| 544 | Effect.scoped( | |
| 545 | Effect.gen(function* () { | |
| 546 | const name = `selection-restart-dead-${deadStatus}`; | |
| 547 | const cwd = yield* checkpointWorkspace(name); | |
| 548 | const threadId = ThreadId.make(`thread:${name}`); | |
| 549 | const state = yield* Ref.make<RestartAdapterState>({ | |
| 550 | activeTurn: null, | |
| 551 | opened: [], | |
| 552 | started: [], | |
| 553 | closedSessionCount: 0, | |
| 554 | // The dead record is seeded directly, so the adapter's one-shot | |
| 555 | // simulated replacement-open failure is skipped. | |
| 556 | failedReplacementOpen: true, | |
| 557 | }); | |
| 558 | const registry = ProviderAdapterRegistry.makeSingleLayer( | |
| 559 | makeRestartAdapter(state, exclusiveCapabilities), | |
| 560 | ); | |
| 540 | it.live.each(["stopped", "error"] as const)( | |
| 541 | "restarts the live session on a model change when a newer %s session record exists", | |
| 542 | (deadStatus) => | |
| 543 | Effect.scoped( | |
| 544 | Effect.gen(function* () { | |
| 545 | const name = `selection-restart-dead-${deadStatus}`; | |
| 546 | const cwd = yield* checkpointWorkspace(name); | |
| 547 | const threadId = ThreadId.make(`thread:${name}`); | |
| 548 | const state = yield* Ref.make<RestartAdapterState>({ | |
| 549 | activeTurn: null, | |
| 550 | opened: [], | |
| 551 | started: [], | |
| 552 | closedSessionCount: 0, | |
| 553 | // The dead record is seeded directly, so the adapter's one-shot | |
| 554 | // simulated replacement-open failure is skipped. | |
| 555 | failedReplacementOpen: true, | |
| 556 | }); | |
| 557 | const registry = ProviderAdapterRegistry.makeSingleLayer( | |
| 558 | makeRestartAdapter(state, exclusiveCapabilities), | |
| 559 | ); | |
| 561 | 560 | |
| 562 | const result = yield* Effect.gen(function* () { | |
| 563 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 564 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 565 | const eventSink = yield* EventSink.EventSinkV2; | |
| 566 | const dispatch = (step: string, modelSelection: ModelSelection) => | |
| 567 | Effect.gen(function* () { | |
| 568 | const terminal = yield* orchestrator.streamDomainEvents.pipe( | |
| 569 | Stream.filter( | |
| 570 | (event) => event.type === "run.updated" && event.payload.status === "completed", | |
| 571 | ), | |
| 572 | Stream.take(1), | |
| 573 | Stream.runDrain, | |
| 574 | Effect.forkScoped, | |
| 575 | ); | |
| 576 | yield* orchestrator.dispatch({ | |
| 577 | type: "message.dispatch", | |
| 578 | createdBy: "user", | |
| 579 | creationSource: "web", | |
| 580 | commandId: CommandId.make(`${name}:${step}`), | |
| 581 | threadId, | |
| 582 | messageId: MessageId.make(`${name}:${step}`), | |
| 583 | text: step, | |
| 584 | attachments: [], | |
| 585 | modelSelection, | |
| 586 | dispatchMode: { type: "start_immediately" }, | |
| 587 | }); | |
| 588 | yield* worker.drain(); | |
| 589 | yield* Fiber.join(terminal); | |
| 590 | yield* worker.drain(); | |
| 591 | return yield* orchestrator.getThreadProjection(threadId); | |
| 561 | const result = yield* Effect.gen(function* () { | |
| 562 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 563 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 564 | const eventSink = yield* EventSink.EventSinkV2; | |
| 565 | const dispatch = (step: string, modelSelection: ModelSelection) => | |
| 566 | Effect.gen(function* () { | |
| 567 | const terminal = yield* orchestrator.streamDomainEvents.pipe( | |
| 568 | Stream.filter( | |
| 569 | (event) => event.type === "run.updated" && event.payload.status === "completed", | |
| 570 | ), | |
| 571 | Stream.take(1), | |
| 572 | Stream.runDrain, | |
| 573 | Effect.forkScoped, | |
| 574 | ); | |
| 575 | yield* orchestrator.dispatch({ | |
| 576 | type: "message.dispatch", | |
| 577 | createdBy: "user", | |
| 578 | creationSource: "web", | |
| 579 | commandId: CommandId.make(`${name}:${step}`), | |
| 580 | threadId, | |
| 581 | messageId: MessageId.make(`${name}:${step}`), | |
| 582 | text: step, | |
| 583 | attachments: [], | |
| 584 | modelSelection, | |
| 585 | dispatchMode: { type: "start_immediately" }, | |
| 592 | 586 | }); |
| 593 | yield* orchestrator.dispatch({ | |
| 594 | type: "thread.create", | |
| 595 | createdBy: "user", | |
| 596 | creationSource: "web", | |
| 597 | commandId: CommandId.make(`${name}:create`), | |
| 598 | threadId, | |
| 599 | projectId: ProjectId.make(`project:${name}`), | |
| 600 | title: name, | |
| 601 | modelSelection: seedSelection, | |
| 602 | runtimeMode: "full-access", | |
| 603 | interactionMode: "default", | |
| 604 | branch: null, | |
| 605 | worktreePath: cwd, | |
| 587 | yield* worker.drain(); | |
| 588 | yield* Fiber.join(terminal); | |
| 589 | yield* worker.drain(); | |
| 590 | return yield* orchestrator.getThreadProjection(threadId); | |
| 606 | 591 | }); |
| 607 | const first = yield* dispatch("first", seedSelection); | |
| 608 | const liveSession = first.providerSessions.find( | |
| 609 | (session) => session.status !== "stopped" && session.status !== "error", | |
| 610 | ); | |
| 611 | assert.isDefined(liveSession); | |
| 592 | yield* orchestrator.dispatch({ | |
| 593 | type: "thread.create", | |
| 594 | createdBy: "user", | |
| 595 | creationSource: "web", | |
| 596 | commandId: CommandId.make(`${name}:create`), | |
| 597 | threadId, | |
| 598 | projectId: ProjectId.make(`project:${name}`), | |
| 599 | title: name, | |
| 600 | modelSelection: seedSelection, | |
| 601 | runtimeMode: "full-access", | |
| 602 | interactionMode: "default", | |
| 603 | branch: null, | |
| 604 | worktreePath: cwd, | |
| 605 | }); | |
| 606 | const first = yield* dispatch("first", seedSelection); | |
| 607 | const liveSession = first.providerSessions.find( | |
| 608 | (session) => session.status !== "stopped" && session.status !== "error", | |
| 609 | ); | |
| 610 | assert.isDefined(liveSession); | |
| 612 | 611 | |
| 613 | // Dead session records stay bound in the projection until | |
| 614 | // detachment. A stale stopped/error record written after the | |
| 615 | // live session attached must not hide it. | |
| 616 | const deadAt = yield* DateTime.now; | |
| 617 | const deadSession: OrchestrationV2ProviderSession = { | |
| 618 | id: ProviderSessionId.make(`session:${name}:dead`), | |
| 619 | driver, | |
| 620 | providerInstanceId, | |
| 621 | status: "ready", | |
| 622 | cwd, | |
| 623 | model: seedSelection.model, | |
| 624 | capabilities: exclusiveCapabilities, | |
| 625 | createdAt: deadAt, | |
| 626 | updatedAt: deadAt, | |
| 627 | lastError: null, | |
| 628 | }; | |
| 629 | yield* eventSink.write({ | |
| 630 | events: [ | |
| 631 | { | |
| 632 | id: EventId.make(`event:${name}:dead-attached`), | |
| 633 | type: "provider-session.attached", | |
| 634 | threadId, | |
| 635 | driver, | |
| 636 | providerInstanceId, | |
| 637 | occurredAt: deadAt, | |
| 638 | payload: deadSession, | |
| 639 | }, | |
| 640 | { | |
| 641 | id: EventId.make(`event:${name}:dead-updated`), | |
| 642 | type: "provider-session.updated", | |
| 643 | threadId, | |
| 644 | driver, | |
| 645 | providerInstanceId, | |
| 646 | occurredAt: deadAt, | |
| 647 | payload: { | |
| 648 | ...deadSession, | |
| 649 | status: deadStatus, | |
| 650 | updatedAt: deadAt, | |
| 651 | lastError: deadStatus === "error" ? "Simulated session failure." : null, | |
| 652 | }, | |
| 612 | // Dead session records stay bound in the projection until | |
| 613 | // detachment. A stale stopped/error record written after the | |
| 614 | // live session attached must not hide it. | |
| 615 | const deadAt = yield* DateTime.now; | |
| 616 | const deadSession: OrchestrationV2ProviderSession = { | |
| 617 | id: ProviderSessionId.make(`session:${name}:dead`), | |
| 618 | driver, | |
| 619 | providerInstanceId, | |
| 620 | status: "ready", | |
| 621 | cwd, | |
| 622 | model: seedSelection.model, | |
| 623 | capabilities: exclusiveCapabilities, | |
| 624 | createdAt: deadAt, | |
| 625 | updatedAt: deadAt, | |
| 626 | lastError: null, | |
| 627 | }; | |
| 628 | yield* eventSink.write({ | |
| 629 | events: [ | |
| 630 | { | |
| 631 | id: EventId.make(`event:${name}:dead-attached`), | |
| 632 | type: "provider-session.attached", | |
| 633 | threadId, | |
| 634 | driver, | |
| 635 | providerInstanceId, | |
| 636 | occurredAt: deadAt, | |
| 637 | payload: deadSession, | |
| 638 | }, | |
| 639 | { | |
| 640 | id: EventId.make(`event:${name}:dead-updated`), | |
| 641 | type: "provider-session.updated", | |
| 642 | threadId, | |
| 643 | driver, | |
| 644 | providerInstanceId, | |
| 645 | occurredAt: deadAt, | |
| 646 | payload: { | |
| 647 | ...deadSession, | |
| 648 | status: deadStatus, | |
| 649 | updatedAt: deadAt, | |
| 650 | lastError: deadStatus === "error" ? "Simulated session failure." : null, | |
| 653 | 651 | }, |
| 654 | ], | |
| 655 | }); | |
| 652 | }, | |
| 653 | ], | |
| 654 | }); | |
| 656 | 655 | |
| 657 | const switchCommandId = CommandId.make(`${name}:switch`); | |
| 658 | yield* orchestrator.dispatch({ | |
| 659 | type: "thread.model-selection.set", | |
| 660 | commandId: switchCommandId, | |
| 661 | threadId, | |
| 662 | modelSelection: replacementSelection, | |
| 663 | }); | |
| 664 | yield* worker.drain(); | |
| 665 | const storedSwitchEvents = yield* eventSink | |
| 666 | .readByCommandId({ commandId: switchCommandId }) | |
| 667 | .pipe(Stream.runCollect); | |
| 668 | const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) => | |
| 669 | stored.event.type === "provider-session.detached" | |
| 670 | ? [stored.event.payload.providerSessionId] | |
| 671 | : [], | |
| 672 | ); | |
| 656 | const switchCommandId = CommandId.make(`${name}:switch`); | |
| 657 | yield* orchestrator.dispatch({ | |
| 658 | type: "thread.model-selection.set", | |
| 659 | commandId: switchCommandId, | |
| 660 | threadId, | |
| 661 | modelSelection: replacementSelection, | |
| 662 | }); | |
| 663 | yield* worker.drain(); | |
| 664 | const storedSwitchEvents = yield* eventSink | |
| 665 | .readByCommandId({ commandId: switchCommandId }) | |
| 666 | .pipe(Stream.runCollect); | |
| 667 | const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) => | |
| 668 | stored.event.type === "provider-session.detached" | |
| 669 | ? [stored.event.payload.providerSessionId] | |
| 670 | : [], | |
| 671 | ); | |
| 673 | 672 | |
| 674 | const second = yield* dispatch("second", replacementSelection); | |
| 675 | return { | |
| 676 | projection: second, | |
| 677 | captured: yield* Ref.get(state), | |
| 678 | liveSessionId: liveSession.id, | |
| 679 | detachedSessionIds, | |
| 680 | }; | |
| 681 | }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); | |
| 673 | const second = yield* dispatch("second", replacementSelection); | |
| 674 | return { | |
| 675 | projection: second, | |
| 676 | captured: yield* Ref.get(state), | |
| 677 | liveSessionId: liveSession.id, | |
| 678 | detachedSessionIds, | |
| 679 | }; | |
| 680 | }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); | |
| 682 | 681 | |
| 683 | const { projection, captured } = result; | |
| 684 | assert.lengthOf(projection.runs, 2); | |
| 685 | assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); | |
| 686 | // The exact released session is the older live one, never the newer | |
| 687 | // dead record. | |
| 688 | assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); | |
| 689 | assert.equal(captured.closedSessionCount, 1); | |
| 690 | assert.deepEqual( | |
| 691 | captured.opened.map((open) => open.model), | |
| 692 | [seedSelection.model, replacementSelection.model], | |
| 693 | ); | |
| 694 | assert.deepEqual( | |
| 695 | captured.started.map((turn) => turn.model), | |
| 696 | [seedSelection.model, replacementSelection.model], | |
| 697 | ); | |
| 698 | const servingSession = projection.providerSessions.find( | |
| 699 | (session) => | |
| 700 | session.id === | |
| 701 | projection.providerThreads.find( | |
| 702 | (providerThread) => providerThread.id === projection.thread.activeProviderThreadId, | |
| 703 | )?.providerSessionId, | |
| 704 | ); | |
| 705 | assert.equal(servingSession?.model, replacementSelection.model); | |
| 706 | }), | |
| 707 | ), | |
| 708 | ); | |
| 709 | } | |
| 682 | const { projection, captured } = result; | |
| 683 | assert.lengthOf(projection.runs, 2); | |
| 684 | assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); | |
| 685 | // The exact released session is the older live one, never the newer | |
| 686 | // dead record. | |
| 687 | assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); | |
| 688 | assert.equal(captured.closedSessionCount, 1); | |
| 689 | assert.deepEqual( | |
| 690 | captured.opened.map((open) => open.model), | |
| 691 | [seedSelection.model, replacementSelection.model], | |
| 692 | ); | |
| 693 | assert.deepEqual( | |
| 694 | captured.started.map((turn) => turn.model), | |
| 695 | [seedSelection.model, replacementSelection.model], | |
| 696 | ); | |
| 697 | const servingSession = projection.providerSessions.find( | |
| 698 | (session) => | |
| 699 | session.id === | |
| 700 | projection.providerThreads.find( | |
| 701 | (providerThread) => providerThread.id === projection.thread.activeProviderThreadId, | |
| 702 | )?.providerSessionId, | |
| 703 | ); | |
| 704 | assert.equal(servingSession?.model, replacementSelection.model); | |
| 705 | }), | |
| 706 | ), | |
| 707 | ); | |
| 710 | 708 | |
| 711 | 709 | it.live("detaches the old provider session after an active provider handoff", () => |
| 712 | 710 | Effect.scoped( |
| ... | ... | @@ -822,8 +820,9 @@ it.live("detaches the old provider session after an active provider handoff", () |
| 822 | 820 | ), |
| 823 | 821 | ); |
| 824 | 822 | |
| 825 | for (const mode of ["active", "idle", "selection-command", "pooled", "separate-home"] as const) { | |
| 826 | it.live(`preserves native history only for compatible account switches (${mode})`, () => | |
| 823 | it.live.each(["active", "idle", "selection-command", "pooled", "separate-home"] as const)( | |
| 824 | "preserves native history only for compatible account switches (%s)", | |
| 825 | (mode) => | |
| 827 | 826 | Effect.scoped( |
| 828 | 827 | Effect.gen(function* () { |
| 829 | 828 | const name = `shared-home-${mode}`; |
| ... | ... | @@ -994,5 +993,4 @@ for (const mode of ["active", "idle", "selection-command", "pooled", "separate-h |
| 994 | 993 | }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); |
| 995 | 994 | }), |
| 996 | 995 | ), |
| 997 | ); | |
| 998 | } | |
| 996 | ); |
apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts+354-352| ... | ... | @@ -37,371 +37,373 @@ const driver = ProviderDriverKind.make("codex"); |
| 37 | 37 | const instanceId = ProviderInstanceId.make("codex"); |
| 38 | 38 | const modelSelection = { instanceId, model: "test-model" }; |
| 39 | 39 | |
| 40 | for (const mailbox of [false, true]) { | |
| 41 | for (const timing of [ | |
| 42 | "before delivery", | |
| 43 | "during delivery", | |
| 44 | "before dispatch", | |
| 45 | "after delivery", | |
| 46 | "without native steering", | |
| 47 | "settled only", | |
| 48 | ] as const) { | |
| 49 | if (!mailbox && (timing === "without native steering" || timing === "settled only")) continue; | |
| 50 | it.effect( | |
| 51 | `delivers ${mailbox ? "mailbox notification" : "steering"} when completion wins ${timing}`, | |
| 52 | () => | |
| 53 | Effect.scoped( | |
| 40 | it.effect.each( | |
| 41 | [false, true] | |
| 42 | .flatMap((mailbox) => | |
| 43 | ( | |
| 44 | [ | |
| 45 | "before delivery", | |
| 46 | "during delivery", | |
| 47 | "before dispatch", | |
| 48 | "after delivery", | |
| 49 | "without native steering", | |
| 50 | "settled only", | |
| 51 | ] as const | |
| 52 | ).map((timing) => ({ | |
| 53 | mailbox, | |
| 54 | timing, | |
| 55 | label: mailbox ? "mailbox notification" : "steering", | |
| 56 | })), | |
| 57 | ) | |
| 58 | .filter( | |
| 59 | ({ mailbox, timing }) => | |
| 60 | mailbox || (timing !== "without native steering" && timing !== "settled only"), | |
| 61 | ), | |
| 62 | )("delivers $label when completion wins $timing", ({ mailbox, timing }) => | |
| 63 | Effect.scoped( | |
| 64 | Effect.gen(function* () { | |
| 65 | const cwd = yield* checkpointWorkspace(`steering-completion-${timing.replaceAll(" ", "-")}`); | |
| 66 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 67 | const started: ProviderAdapterV2TurnInput[] = []; | |
| 68 | const steerEntered = yield* Deferred.make<void>(); | |
| 69 | const rejectSteer = yield* Deferred.make<void>(); | |
| 70 | let steerCalls = 0; | |
| 71 | const capabilities = { | |
| 72 | ...CodexProviderCapabilitiesV2, | |
| 73 | turns: { | |
| 74 | ...CodexProviderCapabilitiesV2.turns, | |
| 75 | supportsActiveSteering: timing !== "without native steering", | |
| 76 | }, | |
| 77 | }; | |
| 78 | const adapter: ProviderAdapterV2Shape = { | |
| 79 | instanceId, | |
| 80 | driver, | |
| 81 | getCapabilities: () => Effect.succeed(capabilities), | |
| 82 | planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), | |
| 83 | openSession: (input) => | |
| 54 | 84 | Effect.gen(function* () { |
| 55 | const cwd = yield* checkpointWorkspace( | |
| 56 | `steering-completion-${timing.replaceAll(" ", "-")}`, | |
| 57 | ); | |
| 58 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 59 | const started: ProviderAdapterV2TurnInput[] = []; | |
| 60 | const steerEntered = yield* Deferred.make<void>(); | |
| 61 | const rejectSteer = yield* Deferred.make<void>(); | |
| 62 | let steerCalls = 0; | |
| 63 | const capabilities = { | |
| 64 | ...CodexProviderCapabilitiesV2, | |
| 65 | turns: { | |
| 66 | ...CodexProviderCapabilitiesV2.turns, | |
| 67 | supportsActiveSteering: timing !== "without native steering", | |
| 68 | }, | |
| 69 | }; | |
| 70 | const adapter: ProviderAdapterV2Shape = { | |
| 85 | const now = yield* DateTime.now; | |
| 86 | return { | |
| 71 | 87 | instanceId, |
| 72 | 88 | driver, |
| 73 | getCapabilities: () => Effect.succeed(capabilities), | |
| 74 | planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), | |
| 75 | openSession: (input) => | |
| 89 | providerSessionId: input.providerSessionId, | |
| 90 | providerSession: { | |
| 91 | id: input.providerSessionId, | |
| 92 | driver, | |
| 93 | providerInstanceId: instanceId, | |
| 94 | status: "ready", | |
| 95 | cwd, | |
| 96 | model: modelSelection.model, | |
| 97 | capabilities, | |
| 98 | createdAt: now, | |
| 99 | updatedAt: now, | |
| 100 | lastError: null, | |
| 101 | }, | |
| 102 | events: Stream.fromQueue(events), | |
| 103 | ensureThread: ({ threadId }) => | |
| 104 | Effect.succeed({ | |
| 105 | id: ProviderThreadId.make(`provider-thread:${threadId}`), | |
| 106 | driver, | |
| 107 | providerInstanceId: instanceId, | |
| 108 | providerSessionId: input.providerSessionId, | |
| 109 | appThreadId: threadId, | |
| 110 | ownerNodeId: null, | |
| 111 | nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, | |
| 112 | nativeConversationHeadRef: null, | |
| 113 | status: "idle", | |
| 114 | firstRunOrdinal: null, | |
| 115 | lastRunOrdinal: null, | |
| 116 | handoffIds: [], | |
| 117 | forkedFrom: null, | |
| 118 | createdAt: now, | |
| 119 | updatedAt: now, | |
| 120 | }), | |
| 121 | resumeThread: ({ providerThread }) => Effect.succeed(providerThread), | |
| 122 | startTurn: (turn) => | |
| 76 | 123 | Effect.gen(function* () { |
| 77 | const now = yield* DateTime.now; | |
| 78 | return { | |
| 79 | instanceId, | |
| 124 | started.push(turn); | |
| 125 | yield* Queue.offer(events, { | |
| 126 | type: "provider_turn.updated", | |
| 80 | 127 | driver, |
| 81 | providerSessionId: input.providerSessionId, | |
| 82 | providerSession: { | |
| 83 | id: input.providerSessionId, | |
| 84 | driver, | |
| 85 | providerInstanceId: instanceId, | |
| 86 | status: "ready", | |
| 87 | cwd, | |
| 88 | model: modelSelection.model, | |
| 89 | capabilities, | |
| 90 | createdAt: now, | |
| 91 | updatedAt: now, | |
| 92 | lastError: null, | |
| 93 | }, | |
| 94 | events: Stream.fromQueue(events), | |
| 95 | ensureThread: ({ threadId }) => | |
| 96 | Effect.succeed({ | |
| 97 | id: ProviderThreadId.make(`provider-thread:${threadId}`), | |
| 128 | providerTurn: { | |
| 129 | id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), | |
| 130 | providerThreadId: turn.providerThread.id, | |
| 131 | nodeId: turn.rootNodeId, | |
| 132 | runAttemptId: turn.attemptId, | |
| 133 | nativeTurnRef: { | |
| 98 | 134 | driver, |
| 99 | providerInstanceId: instanceId, | |
| 100 | providerSessionId: input.providerSessionId, | |
| 101 | appThreadId: threadId, | |
| 102 | ownerNodeId: null, | |
| 103 | nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, | |
| 104 | nativeConversationHeadRef: null, | |
| 105 | status: "idle", | |
| 106 | firstRunOrdinal: null, | |
| 107 | lastRunOrdinal: null, | |
| 108 | handoffIds: [], | |
| 109 | forkedFrom: null, | |
| 110 | createdAt: now, | |
| 111 | updatedAt: now, | |
| 112 | }), | |
| 113 | resumeThread: ({ providerThread }) => Effect.succeed(providerThread), | |
| 114 | startTurn: (turn) => | |
| 115 | Effect.gen(function* () { | |
| 116 | started.push(turn); | |
| 117 | yield* Queue.offer(events, { | |
| 118 | type: "provider_turn.updated", | |
| 119 | driver, | |
| 120 | providerTurn: { | |
| 121 | id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), | |
| 122 | providerThreadId: turn.providerThread.id, | |
| 123 | nodeId: turn.rootNodeId, | |
| 124 | runAttemptId: turn.attemptId, | |
| 125 | nativeTurnRef: { | |
| 126 | driver, | |
| 127 | nativeId: `native:${turn.attemptId}`, | |
| 128 | strength: "strong", | |
| 129 | }, | |
| 130 | ordinal: turn.providerTurnOrdinal, | |
| 131 | status: "running", | |
| 132 | startedAt: now, | |
| 133 | completedAt: null, | |
| 134 | }, | |
| 135 | }); | |
| 136 | }), | |
| 137 | steerTurn: (turn) => | |
| 138 | Effect.gen(function* () { | |
| 139 | steerCalls += 1; | |
| 140 | if (timing === "after delivery") return; | |
| 141 | yield* Deferred.succeed(steerEntered, undefined); | |
| 142 | yield* Deferred.await(rejectSteer); | |
| 143 | return yield* new ProviderAdapterSteerRunError({ | |
| 144 | driver, | |
| 145 | providerThreadId: turn.providerThread.id, | |
| 146 | providerTurnId: turn.providerTurnId, | |
| 147 | cause: "turn already completed", | |
| 148 | }); | |
| 149 | }), | |
| 150 | interruptTurn: () => Effect.void, | |
| 151 | respondToRuntimeRequest: () => Effect.void, | |
| 152 | readThreadSnapshot: () => Effect.die("unused"), | |
| 153 | rollbackThread: () => Effect.die("unused"), | |
| 154 | forkThread: () => Effect.die("unused"), | |
| 155 | }; | |
| 156 | }), | |
| 157 | }; | |
| 158 | yield* Effect.gen(function* () { | |
| 159 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 160 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 161 | const threadId = ThreadId.make("thread:steering-completion"); | |
| 162 | const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => | |
| 163 | orchestrator.streamDomainEvents.pipe( | |
| 164 | Stream.filter(predicate), | |
| 165 | Stream.take(1), | |
| 166 | Stream.runDrain, | |
| 167 | Effect.forkScoped, | |
| 168 | ); | |
| 169 | yield* orchestrator.dispatch({ | |
| 170 | type: "thread.create", | |
| 171 | commandId: CommandId.make("create"), | |
| 172 | threadId, | |
| 173 | projectId: ProjectId.make("project:steering-completion"), | |
| 174 | title: "Steering race", | |
| 175 | modelSelection, | |
| 176 | runtimeMode: "full-access", | |
| 177 | interactionMode: "default", | |
| 178 | branch: null, | |
| 179 | worktreePath: cwd, | |
| 180 | createdBy: "user", | |
| 181 | creationSource: "web", | |
| 182 | }); | |
| 183 | yield* orchestrator.dispatch({ | |
| 184 | type: "message.dispatch", | |
| 185 | commandId: CommandId.make("first"), | |
| 186 | threadId, | |
| 187 | messageId: MessageId.make("message:first"), | |
| 188 | text: "first", | |
| 189 | attachments: [], | |
| 190 | dispatchMode: { type: "start_immediately" }, | |
| 191 | createdBy: "user", | |
| 192 | creationSource: "web", | |
| 193 | }); | |
| 194 | const running = yield* watch( | |
| 195 | (event) => | |
| 196 | event.type === "provider-turn.updated" && event.payload.status === "running", | |
| 197 | ); | |
| 198 | yield* worker.drain(); | |
| 199 | yield* Fiber.join(running); | |
| 200 | const first = started[0]!; | |
| 201 | const messageId = MessageId.make("message:steering"); | |
| 202 | const taskId = NodeId.make("task:mailbox"); | |
| 203 | if (mailbox) { | |
| 204 | const sink = yield* EventSink.EventSinkV2; | |
| 205 | const current = yield* orchestrator.getThreadProjection(threadId); | |
| 206 | const parentRun = current.runs.find((run) => run.id === first.runId)!; | |
| 207 | const now = yield* DateTime.now; | |
| 208 | yield* sink.write({ | |
| 209 | events: [ | |
| 210 | { | |
| 211 | id: EventId.make("mailbox:cohort"), | |
| 212 | type: "run.updated", | |
| 213 | threadId, | |
| 214 | runId: first.runId, | |
| 215 | occurredAt: now, | |
| 216 | payload: { | |
| 217 | ...parentRun, | |
| 218 | delegatedCompletion: { | |
| 219 | disposition: "open", | |
| 220 | nextGeneration: 2, | |
| 221 | delivery: { generation: 1, messageId, taskIds: [taskId] }, | |
| 222 | }, | |
| 135 | nativeId: `native:${turn.attemptId}`, | |
| 136 | strength: "strong", | |
| 223 | 137 | }, |
| 138 | ordinal: turn.providerTurnOrdinal, | |
| 139 | status: "running", | |
| 140 | startedAt: now, | |
| 141 | completedAt: null, | |
| 224 | 142 | }, |
| 225 | { | |
| 226 | id: EventId.make("mailbox:task"), | |
| 227 | type: "subagent.updated", | |
| 228 | threadId, | |
| 229 | runId: first.runId, | |
| 230 | nodeId: taskId, | |
| 231 | occurredAt: now, | |
| 232 | payload: { | |
| 233 | id: taskId, | |
| 234 | threadId, | |
| 235 | runId: first.runId, | |
| 236 | parentNodeId: first.rootNodeId, | |
| 237 | origin: "app_owned", | |
| 238 | createdBy: "agent", | |
| 239 | driver, | |
| 240 | providerInstanceId: instanceId, | |
| 241 | providerThreadId: null, | |
| 242 | childThreadId: null, | |
| 243 | nativeTaskRef: null, | |
| 244 | prompt: "Do background work", | |
| 245 | title: "Background test", | |
| 246 | model: null, | |
| 247 | completionWake: timing === "settled only" ? "settled_only" : "always", | |
| 248 | completionDelivery: { state: "claimed", observedByRunId: null }, | |
| 249 | status: "completed", | |
| 250 | result: "done", | |
| 251 | startedAt: now, | |
| 252 | completedAt: now, | |
| 253 | updatedAt: now, | |
| 254 | }, | |
| 255 | }, | |
| 256 | ], | |
| 257 | }); | |
| 258 | } | |
| 259 | const dispatchSteer = orchestrator.dispatch({ | |
| 260 | type: "message.dispatch", | |
| 261 | commandId: CommandId.make("steer"), | |
| 262 | threadId, | |
| 263 | messageId, | |
| 264 | text: "fix the popover", | |
| 265 | attachments: [ | |
| 266 | { | |
| 267 | type: "image", | |
| 268 | id: "steering-screenshot", | |
| 269 | name: "image.png", | |
| 270 | mimeType: "image/png", | |
| 271 | sizeBytes: 10, | |
| 272 | }, | |
| 273 | ], | |
| 274 | dispatchMode: mailbox | |
| 275 | ? { type: "queue_after_active" } | |
| 276 | : { type: "steer_active", targetRunId: first.runId }, | |
| 277 | createdBy: mailbox ? "agent" : "user", | |
| 278 | creationSource: mailbox ? "server" : "web", | |
| 279 | ...(mailbox | |
| 280 | ? { | |
| 281 | delegatedCompletion: { | |
| 282 | parentRunId: first.runId, | |
| 283 | generation: 1, | |
| 284 | taskIds: [taskId], | |
| 285 | }, | |
| 286 | } | |
| 287 | : {}), | |
| 288 | }); | |
| 289 | if (timing !== "before dispatch") yield* dispatchSteer; | |
| 290 | if (timing === "after delivery") yield* worker.drain(); | |
| 291 | const delivery = | |
| 292 | timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null; | |
| 293 | if (delivery !== null) yield* Deferred.await(steerEntered); | |
| 294 | const completed = yield* watch( | |
| 295 | (event) => | |
| 296 | event.type === "run.updated" && | |
| 297 | event.payload.id === first.runId && | |
| 298 | event.payload.status === "waiting", | |
| 299 | ); | |
| 300 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 301 | const turn = projection.providerTurns[0]!; | |
| 302 | yield* Queue.offer(events, { | |
| 303 | type: "provider_turn.updated", | |
| 304 | driver, | |
| 305 | providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, | |
| 306 | }); | |
| 307 | yield* Queue.offer(events, { | |
| 308 | type: "turn.terminal", | |
| 309 | driver, | |
| 310 | providerThreadId: turn.providerThreadId, | |
| 311 | providerTurnId: turn.id, | |
| 312 | runOrdinal: first.runOrdinal, | |
| 313 | status: "completed", | |
| 314 | failure: null, | |
| 315 | threadDisposition: "reusable", | |
| 316 | }); | |
| 317 | yield* Fiber.join(completed); | |
| 318 | if (delivery !== null) { | |
| 319 | yield* Deferred.succeed(rejectSteer, undefined); | |
| 320 | yield* Fiber.join(delivery); | |
| 321 | } | |
| 322 | if (timing === "before dispatch") yield* dispatchSteer; | |
| 323 | yield* worker.drain(); | |
| 324 | yield* orchestrator.resumeQueuedRuns; | |
| 325 | yield* worker.drain(); | |
| 326 | if (timing === "after delivery") { | |
| 327 | assert.equal(steerCalls, 1); | |
| 328 | assert.equal(started.length, 1); | |
| 329 | if (mailbox) { | |
| 330 | const delivered = yield* orchestrator.getThreadProjection(threadId); | |
| 331 | assert.equal(delivered.subagents[0]?.completionDelivery?.state, "delivered"); | |
| 332 | assert.equal(delivered.subagents[0]?.completionDelivery?.observedByRunId, null); | |
| 333 | assert.equal(delivered.runs[0]?.delegatedCompletion?.delivery, null); | |
| 334 | assert.equal( | |
| 335 | delivered.turnItems.filter((item) => item.type === "notification").length, | |
| 336 | 1, | |
| 337 | ); | |
| 338 | yield* orchestrator.dispatch({ | |
| 339 | type: "notification.delivery.accept", | |
| 340 | commandId: CommandId.make("duplicate-acceptance"), | |
| 341 | threadId, | |
| 342 | messageId, | |
| 343 | 143 | }); |
| 344 | yield* worker.drain(); | |
| 345 | assert.equal(steerCalls, 1); | |
| 346 | yield* orchestrator.dispatch({ | |
| 347 | type: "delegated_task.completion-delivery.acknowledge", | |
| 348 | commandId: CommandId.make("read-result"), | |
| 349 | parentThreadId: threadId, | |
| 350 | taskId, | |
| 351 | observedByRunId: first.runId, | |
| 144 | }), | |
| 145 | steerTurn: (turn) => | |
| 146 | Effect.gen(function* () { | |
| 147 | steerCalls += 1; | |
| 148 | if (timing === "after delivery") return; | |
| 149 | yield* Deferred.succeed(steerEntered, undefined); | |
| 150 | yield* Deferred.await(rejectSteer); | |
| 151 | return yield* new ProviderAdapterSteerRunError({ | |
| 152 | driver, | |
| 153 | providerThreadId: turn.providerThread.id, | |
| 154 | providerTurnId: turn.providerTurnId, | |
| 155 | cause: "turn already completed", | |
| 352 | 156 | }); |
| 353 | const acknowledged = yield* orchestrator.getThreadProjection(threadId); | |
| 354 | assert.equal( | |
| 355 | acknowledged.subagents[0]?.completionDelivery?.state, | |
| 356 | "acknowledged", | |
| 357 | ); | |
| 358 | } | |
| 359 | return; | |
| 360 | } | |
| 361 | assert.equal(started.length, 2); | |
| 362 | assert.equal(started[1]?.message.messageId, messageId); | |
| 363 | if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId)); | |
| 364 | else assert.equal(started[1]?.message.text, "fix the popover"); | |
| 365 | assert.deepEqual(started[1]?.message.attachments, [ | |
| 366 | { | |
| 367 | type: "image", | |
| 368 | id: "steering-screenshot", | |
| 369 | name: "image.png", | |
| 370 | mimeType: "image/png", | |
| 371 | sizeBytes: 10, | |
| 157 | }), | |
| 158 | interruptTurn: () => Effect.void, | |
| 159 | respondToRuntimeRequest: () => Effect.void, | |
| 160 | readThreadSnapshot: () => Effect.die("unused"), | |
| 161 | rollbackThread: () => Effect.die("unused"), | |
| 162 | forkThread: () => Effect.die("unused"), | |
| 163 | }; | |
| 164 | }), | |
| 165 | }; | |
| 166 | yield* Effect.gen(function* () { | |
| 167 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 168 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 169 | const threadId = ThreadId.make("thread:steering-completion"); | |
| 170 | const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => | |
| 171 | orchestrator.streamDomainEvents.pipe( | |
| 172 | Stream.filter(predicate), | |
| 173 | Stream.take(1), | |
| 174 | Stream.runDrain, | |
| 175 | Effect.forkScoped, | |
| 176 | ); | |
| 177 | yield* orchestrator.dispatch({ | |
| 178 | type: "thread.create", | |
| 179 | commandId: CommandId.make("create"), | |
| 180 | threadId, | |
| 181 | projectId: ProjectId.make("project:steering-completion"), | |
| 182 | title: "Steering race", | |
| 183 | modelSelection, | |
| 184 | runtimeMode: "full-access", | |
| 185 | interactionMode: "default", | |
| 186 | branch: null, | |
| 187 | worktreePath: cwd, | |
| 188 | createdBy: "user", | |
| 189 | creationSource: "web", | |
| 190 | }); | |
| 191 | yield* orchestrator.dispatch({ | |
| 192 | type: "message.dispatch", | |
| 193 | commandId: CommandId.make("first"), | |
| 194 | threadId, | |
| 195 | messageId: MessageId.make("message:first"), | |
| 196 | text: "first", | |
| 197 | attachments: [], | |
| 198 | dispatchMode: { type: "start_immediately" }, | |
| 199 | createdBy: "user", | |
| 200 | creationSource: "web", | |
| 201 | }); | |
| 202 | const running = yield* watch( | |
| 203 | (event) => event.type === "provider-turn.updated" && event.payload.status === "running", | |
| 204 | ); | |
| 205 | yield* worker.drain(); | |
| 206 | yield* Fiber.join(running); | |
| 207 | const first = started[0]!; | |
| 208 | const messageId = MessageId.make("message:steering"); | |
| 209 | const taskId = NodeId.make("task:mailbox"); | |
| 210 | if (mailbox) { | |
| 211 | const sink = yield* EventSink.EventSinkV2; | |
| 212 | const current = yield* orchestrator.getThreadProjection(threadId); | |
| 213 | const parentRun = current.runs.find((run) => run.id === first.runId)!; | |
| 214 | const now = yield* DateTime.now; | |
| 215 | yield* sink.write({ | |
| 216 | events: [ | |
| 217 | { | |
| 218 | id: EventId.make("mailbox:cohort"), | |
| 219 | type: "run.updated", | |
| 220 | threadId, | |
| 221 | runId: first.runId, | |
| 222 | occurredAt: now, | |
| 223 | payload: { | |
| 224 | ...parentRun, | |
| 225 | delegatedCompletion: { | |
| 226 | disposition: "open", | |
| 227 | nextGeneration: 2, | |
| 228 | delivery: { generation: 1, messageId, taskIds: [taskId] }, | |
| 229 | }, | |
| 230 | }, | |
| 231 | }, | |
| 232 | { | |
| 233 | id: EventId.make("mailbox:task"), | |
| 234 | type: "subagent.updated", | |
| 235 | threadId, | |
| 236 | runId: first.runId, | |
| 237 | nodeId: taskId, | |
| 238 | occurredAt: now, | |
| 239 | payload: { | |
| 240 | id: taskId, | |
| 241 | threadId, | |
| 242 | runId: first.runId, | |
| 243 | parentNodeId: first.rootNodeId, | |
| 244 | origin: "app_owned", | |
| 245 | createdBy: "agent", | |
| 246 | driver, | |
| 247 | providerInstanceId: instanceId, | |
| 248 | providerThreadId: null, | |
| 249 | childThreadId: null, | |
| 250 | nativeTaskRef: null, | |
| 251 | prompt: "Do background work", | |
| 252 | title: "Background test", | |
| 253 | model: null, | |
| 254 | completionWake: timing === "settled only" ? "settled_only" : "always", | |
| 255 | completionDelivery: { state: "claimed", observedByRunId: null }, | |
| 256 | status: "completed", | |
| 257 | result: "done", | |
| 258 | startedAt: now, | |
| 259 | completedAt: now, | |
| 260 | updatedAt: now, | |
| 372 | 261 | }, |
| 373 | ]); | |
| 374 | assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); | |
| 375 | const final = yield* orchestrator.getThreadProjection(threadId); | |
| 376 | assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); | |
| 377 | assert.equal( | |
| 378 | final.messages.find((message) => message.id === messageId)?.runId, | |
| 379 | started[1]?.runId, | |
| 380 | ); | |
| 381 | assert.equal( | |
| 382 | final.turnItems.filter((item) => | |
| 383 | mailbox | |
| 384 | ? item.type === "notification" | |
| 385 | : item.type === "user_message" && item.messageId === messageId, | |
| 386 | ).length, | |
| 387 | 1, | |
| 388 | ); | |
| 389 | yield* worker.drain(); | |
| 390 | assert.equal(started.length, 2); | |
| 391 | }).pipe( | |
| 392 | Effect.provide( | |
| 393 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 394 | { name: `steering-completion-${timing}` }, | |
| 395 | ProviderAdapterRegistry.makeSingleLayer(adapter), | |
| 396 | { runEffectWorker: false }, | |
| 397 | ), | |
| 398 | ), | |
| 262 | }, | |
| 263 | ], | |
| 264 | }); | |
| 265 | } | |
| 266 | const dispatchSteer = orchestrator.dispatch({ | |
| 267 | type: "message.dispatch", | |
| 268 | commandId: CommandId.make("steer"), | |
| 269 | threadId, | |
| 270 | messageId, | |
| 271 | text: "fix the popover", | |
| 272 | attachments: [ | |
| 273 | { | |
| 274 | type: "image", | |
| 275 | id: "steering-screenshot", | |
| 276 | name: "image.png", | |
| 277 | mimeType: "image/png", | |
| 278 | sizeBytes: 10, | |
| 279 | }, | |
| 280 | ], | |
| 281 | dispatchMode: mailbox | |
| 282 | ? { type: "queue_after_active" } | |
| 283 | : { type: "steer_active", targetRunId: first.runId }, | |
| 284 | createdBy: mailbox ? "agent" : "user", | |
| 285 | creationSource: mailbox ? "server" : "web", | |
| 286 | ...(mailbox | |
| 287 | ? { | |
| 288 | delegatedCompletion: { | |
| 289 | parentRunId: first.runId, | |
| 290 | generation: 1, | |
| 291 | taskIds: [taskId], | |
| 292 | }, | |
| 293 | } | |
| 294 | : {}), | |
| 295 | }); | |
| 296 | if (timing !== "before dispatch") yield* dispatchSteer; | |
| 297 | if (timing === "after delivery") yield* worker.drain(); | |
| 298 | const delivery = | |
| 299 | timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null; | |
| 300 | if (delivery !== null) yield* Deferred.await(steerEntered); | |
| 301 | const completed = yield* watch( | |
| 302 | (event) => | |
| 303 | event.type === "run.updated" && | |
| 304 | event.payload.id === first.runId && | |
| 305 | event.payload.status === "waiting", | |
| 306 | ); | |
| 307 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 308 | const turn = projection.providerTurns[0]!; | |
| 309 | yield* Queue.offer(events, { | |
| 310 | type: "provider_turn.updated", | |
| 311 | driver, | |
| 312 | providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, | |
| 313 | }); | |
| 314 | yield* Queue.offer(events, { | |
| 315 | type: "turn.terminal", | |
| 316 | driver, | |
| 317 | providerThreadId: turn.providerThreadId, | |
| 318 | providerTurnId: turn.id, | |
| 319 | runOrdinal: first.runOrdinal, | |
| 320 | status: "completed", | |
| 321 | failure: null, | |
| 322 | threadDisposition: "reusable", | |
| 323 | }); | |
| 324 | yield* Fiber.join(completed); | |
| 325 | if (delivery !== null) { | |
| 326 | yield* Deferred.succeed(rejectSteer, undefined); | |
| 327 | yield* Fiber.join(delivery); | |
| 328 | } | |
| 329 | if (timing === "before dispatch") yield* dispatchSteer; | |
| 330 | yield* worker.drain(); | |
| 331 | yield* orchestrator.resumeQueuedRuns; | |
| 332 | yield* worker.drain(); | |
| 333 | if (timing === "after delivery") { | |
| 334 | assert.equal(steerCalls, 1); | |
| 335 | assert.equal(started.length, 1); | |
| 336 | if (mailbox) { | |
| 337 | const delivered = yield* orchestrator.getThreadProjection(threadId); | |
| 338 | assert.equal(delivered.subagents[0]?.completionDelivery?.state, "delivered"); | |
| 339 | assert.equal(delivered.subagents[0]?.completionDelivery?.observedByRunId, null); | |
| 340 | assert.equal(delivered.runs[0]?.delegatedCompletion?.delivery, null); | |
| 341 | assert.equal( | |
| 342 | delivered.turnItems.filter((item) => item.type === "notification").length, | |
| 343 | 1, | |
| 399 | 344 | ); |
| 400 | }), | |
| 345 | yield* orchestrator.dispatch({ | |
| 346 | type: "notification.delivery.accept", | |
| 347 | commandId: CommandId.make("duplicate-acceptance"), | |
| 348 | threadId, | |
| 349 | messageId, | |
| 350 | }); | |
| 351 | yield* worker.drain(); | |
| 352 | assert.equal(steerCalls, 1); | |
| 353 | yield* orchestrator.dispatch({ | |
| 354 | type: "delegated_task.completion-delivery.acknowledge", | |
| 355 | commandId: CommandId.make("read-result"), | |
| 356 | parentThreadId: threadId, | |
| 357 | taskId, | |
| 358 | observedByRunId: first.runId, | |
| 359 | }); | |
| 360 | const acknowledged = yield* orchestrator.getThreadProjection(threadId); | |
| 361 | assert.equal(acknowledged.subagents[0]?.completionDelivery?.state, "acknowledged"); | |
| 362 | } | |
| 363 | return; | |
| 364 | } | |
| 365 | assert.equal(started.length, 2); | |
| 366 | assert.equal(started[1]?.message.messageId, messageId); | |
| 367 | if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId)); | |
| 368 | else assert.equal(started[1]?.message.text, "fix the popover"); | |
| 369 | assert.deepEqual(started[1]?.message.attachments, [ | |
| 370 | { | |
| 371 | type: "image", | |
| 372 | id: "steering-screenshot", | |
| 373 | name: "image.png", | |
| 374 | mimeType: "image/png", | |
| 375 | sizeBytes: 10, | |
| 376 | }, | |
| 377 | ]); | |
| 378 | assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); | |
| 379 | const final = yield* orchestrator.getThreadProjection(threadId); | |
| 380 | assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); | |
| 381 | assert.equal( | |
| 382 | final.messages.find((message) => message.id === messageId)?.runId, | |
| 383 | started[1]?.runId, | |
| 384 | ); | |
| 385 | assert.equal( | |
| 386 | final.turnItems.filter((item) => | |
| 387 | mailbox | |
| 388 | ? item.type === "notification" | |
| 389 | : item.type === "user_message" && item.messageId === messageId, | |
| 390 | ).length, | |
| 391 | 1, | |
| 392 | ); | |
| 393 | yield* worker.drain(); | |
| 394 | assert.equal(started.length, 2); | |
| 395 | }).pipe( | |
| 396 | Effect.provide( | |
| 397 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 398 | { name: `steering-completion-${timing}` }, | |
| 399 | ProviderAdapterRegistry.makeSingleLayer(adapter), | |
| 400 | { runEffectWorker: false }, | |
| 401 | ), | |
| 401 | 402 | ), |
| 402 | ); | |
| 403 | } | |
| 404 | } | |
| 403 | ); | |
| 404 | }), | |
| 405 | ), | |
| 406 | ); | |
| 405 | 407 | |
| 406 | 408 | // Claude and Pi steer live but cannot interrupt-and-restart, so a changed |
| 407 | 409 | // selection waits for the next turn as the thread's saved selection. |
apps/server/src/orchestration-v2/ThreadFork.execution.test.ts+187-179| ... | ... | @@ -25,7 +25,7 @@ import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; |
| 25 | 25 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 26 | 26 | import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; |
| 27 | 27 | |
| 28 | for (const driverName of ["codex", "claudeAgent"] as const) { | |
| 28 | const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => { | |
| 29 | 29 | const driver = ProviderDriverKind.make(driverName); |
| 30 | 30 | const instanceId = ProviderInstanceId.make(driver); |
| 31 | 31 | const modelSelection = { instanceId, model: "test-model" }; |
| ... | ... | @@ -45,199 +45,207 @@ for (const driverName of ["codex", "claudeAgent"] as const) { |
| 45 | 45 | { runEffectWorker: false }, |
| 46 | 46 | ); |
| 47 | 47 | |
| 48 | for (const status of ["failed", "interrupted", "cancelled"] as const) { | |
| 49 | it.effect(`bounds ${driver} context when continuing a fork of a ${status} run`, () => | |
| 50 | Effect.gen(function* () { | |
| 51 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 52 | const eventSink = yield* EventSink.EventSinkV2; | |
| 53 | const now = yield* DateTime.now; | |
| 54 | const sourceThreadId = ThreadId.make("fork-boundary-source"); | |
| 55 | const targetThreadId = ThreadId.make("fork-boundary-target"); | |
| 56 | const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread"); | |
| 57 | const sourceRunId = RunId.make("fork-boundary-source-run"); | |
| 58 | const attemptId = RunAttemptId.make("interrupted-source-attempt"); | |
| 59 | const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); | |
| 60 | const rootNodeId = NodeId.make("interrupted-source-root"); | |
| 48 | return (["failed", "interrupted", "cancelled"] as const).map((status) => ({ | |
| 49 | driver, | |
| 50 | status, | |
| 51 | instanceId, | |
| 52 | modelSelection, | |
| 53 | layer, | |
| 54 | })); | |
| 55 | }); | |
| 61 | 56 | |
| 62 | yield* orchestrator.dispatch({ | |
| 63 | type: "thread.create", | |
| 64 | commandId: CommandId.make("create-source"), | |
| 65 | threadId: sourceThreadId, | |
| 66 | projectId: ProjectId.make("fork-boundary-project"), | |
| 67 | title: "Fork boundary source", | |
| 68 | modelSelection, | |
| 69 | runtimeMode: "full-access", | |
| 70 | interactionMode: "default", | |
| 71 | branch: null, | |
| 72 | worktreePath: null, | |
| 73 | createdBy: "user", | |
| 74 | creationSource: "web", | |
| 75 | }); | |
| 57 | it.effect.each(forkCases)( | |
| 58 | "bounds $driver context when continuing a fork of a $status run", | |
| 59 | ({ driver, status, instanceId, modelSelection, layer }) => | |
| 60 | Effect.gen(function* () { | |
| 61 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 62 | const eventSink = yield* EventSink.EventSinkV2; | |
| 63 | const now = yield* DateTime.now; | |
| 64 | const sourceThreadId = ThreadId.make("fork-boundary-source"); | |
| 65 | const targetThreadId = ThreadId.make("fork-boundary-target"); | |
| 66 | const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread"); | |
| 67 | const sourceRunId = RunId.make("fork-boundary-source-run"); | |
| 68 | const attemptId = RunAttemptId.make("interrupted-source-attempt"); | |
| 69 | const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); | |
| 70 | const rootNodeId = NodeId.make("interrupted-source-root"); | |
| 71 | ||
| 72 | yield* orchestrator.dispatch({ | |
| 73 | type: "thread.create", | |
| 74 | commandId: CommandId.make("create-source"), | |
| 75 | threadId: sourceThreadId, | |
| 76 | projectId: ProjectId.make("fork-boundary-project"), | |
| 77 | title: "Fork boundary source", | |
| 78 | modelSelection, | |
| 79 | runtimeMode: "full-access", | |
| 80 | interactionMode: "default", | |
| 81 | branch: null, | |
| 82 | worktreePath: null, | |
| 83 | createdBy: "user", | |
| 84 | creationSource: "web", | |
| 85 | }); | |
| 86 | yield* eventSink.write({ | |
| 87 | events: [ | |
| 88 | { | |
| 89 | id: EventId.make("source-provider-thread"), | |
| 90 | type: "provider-thread.updated", | |
| 91 | threadId: sourceThreadId, | |
| 92 | occurredAt: now, | |
| 93 | payload: { | |
| 94 | id: providerThreadId, | |
| 95 | driver, | |
| 96 | providerInstanceId: instanceId, | |
| 97 | providerSessionId: null, | |
| 98 | appThreadId: sourceThreadId, | |
| 99 | ownerNodeId: null, | |
| 100 | nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, | |
| 101 | nativeConversationHeadRef: null, | |
| 102 | status: "idle", | |
| 103 | firstRunOrdinal: 1, | |
| 104 | lastRunOrdinal: 2, | |
| 105 | handoffIds: [], | |
| 106 | forkedFrom: null, | |
| 107 | createdAt: now, | |
| 108 | updatedAt: now, | |
| 109 | }, | |
| 110 | }, | |
| 111 | ], | |
| 112 | }); | |
| 113 | // A cancelled queue entry has no provider turn; an early interruption | |
| 114 | // can have a turn but no native assistant cursor. | |
| 115 | if (status === "interrupted") { | |
| 76 | 116 | yield* eventSink.write({ |
| 77 | 117 | events: [ |
| 78 | 118 | { |
| 79 | id: EventId.make("source-provider-thread"), | |
| 80 | type: "provider-thread.updated", | |
| 119 | id: EventId.make("source-attempt"), | |
| 120 | type: "run-attempt.created", | |
| 81 | 121 | threadId: sourceThreadId, |
| 122 | runId: sourceRunId, | |
| 82 | 123 | occurredAt: now, |
| 83 | 124 | payload: { |
| 84 | id: providerThreadId, | |
| 85 | driver, | |
| 125 | id: attemptId, | |
| 126 | runId: sourceRunId, | |
| 127 | attemptOrdinal: 1, | |
| 128 | rootNodeId, | |
| 86 | 129 | providerInstanceId: instanceId, |
| 87 | providerSessionId: null, | |
| 88 | appThreadId: sourceThreadId, | |
| 89 | ownerNodeId: null, | |
| 90 | nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, | |
| 91 | nativeConversationHeadRef: null, | |
| 92 | status: "idle", | |
| 93 | firstRunOrdinal: 1, | |
| 94 | lastRunOrdinal: 2, | |
| 95 | handoffIds: [], | |
| 96 | forkedFrom: null, | |
| 97 | createdAt: now, | |
| 98 | updatedAt: now, | |
| 130 | providerThreadId, | |
| 131 | providerTurnId, | |
| 132 | reason: "initial", | |
| 133 | status, | |
| 134 | startedAt: now, | |
| 135 | completedAt: now, | |
| 136 | }, | |
| 137 | }, | |
| 138 | { | |
| 139 | id: EventId.make("source-provider-turn"), | |
| 140 | type: "provider-turn.updated", | |
| 141 | threadId: sourceThreadId, | |
| 142 | occurredAt: now, | |
| 143 | payload: { | |
| 144 | id: providerTurnId, | |
| 145 | providerThreadId, | |
| 146 | nodeId: rootNodeId, | |
| 147 | runAttemptId: attemptId, | |
| 148 | nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" }, | |
| 149 | ordinal: 1, | |
| 150 | status, | |
| 151 | startedAt: now, | |
| 152 | completedAt: now, | |
| 99 | 153 | }, |
| 100 | 154 | }, |
| 101 | 155 | ], |
| 102 | 156 | }); |
| 103 | // A cancelled queue entry has no provider turn; an early interruption | |
| 104 | // can have a turn but no native assistant cursor. | |
| 105 | if (status === "interrupted") { | |
| 106 | yield* eventSink.write({ | |
| 107 | events: [ | |
| 108 | { | |
| 109 | id: EventId.make("source-attempt"), | |
| 110 | type: "run-attempt.created", | |
| 111 | threadId: sourceThreadId, | |
| 112 | runId: sourceRunId, | |
| 113 | occurredAt: now, | |
| 114 | payload: { | |
| 115 | id: attemptId, | |
| 116 | runId: sourceRunId, | |
| 117 | attemptOrdinal: 1, | |
| 118 | rootNodeId, | |
| 119 | providerInstanceId: instanceId, | |
| 120 | providerThreadId, | |
| 121 | providerTurnId, | |
| 122 | reason: "initial", | |
| 123 | status, | |
| 124 | startedAt: now, | |
| 125 | completedAt: now, | |
| 126 | }, | |
| 127 | }, | |
| 128 | { | |
| 129 | id: EventId.make("source-provider-turn"), | |
| 130 | type: "provider-turn.updated", | |
| 131 | threadId: sourceThreadId, | |
| 132 | occurredAt: now, | |
| 133 | payload: { | |
| 134 | id: providerTurnId, | |
| 135 | providerThreadId, | |
| 136 | nodeId: rootNodeId, | |
| 137 | runAttemptId: attemptId, | |
| 138 | nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" }, | |
| 139 | ordinal: 1, | |
| 140 | status, | |
| 141 | startedAt: now, | |
| 142 | completedAt: now, | |
| 143 | }, | |
| 144 | }, | |
| 145 | ], | |
| 146 | }); | |
| 147 | } | |
| 148 | for (const ordinal of [1, 2]) { | |
| 149 | const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); | |
| 150 | const messageId = MessageId.make(`source-message-${ordinal}`); | |
| 151 | yield* eventSink.write({ | |
| 152 | events: [ | |
| 153 | { | |
| 154 | id: EventId.make(`run-${ordinal}`), | |
| 155 | type: "run.created", | |
| 157 | } | |
| 158 | for (const ordinal of [1, 2]) { | |
| 159 | const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); | |
| 160 | const messageId = MessageId.make(`source-message-${ordinal}`); | |
| 161 | yield* eventSink.write({ | |
| 162 | events: [ | |
| 163 | { | |
| 164 | id: EventId.make(`run-${ordinal}`), | |
| 165 | type: "run.created", | |
| 166 | threadId: sourceThreadId, | |
| 167 | runId, | |
| 168 | occurredAt: now, | |
| 169 | payload: { | |
| 170 | id: runId, | |
| 156 | 171 | threadId: sourceThreadId, |
| 157 | runId, | |
| 158 | occurredAt: now, | |
| 159 | payload: { | |
| 160 | id: runId, | |
| 161 | threadId: sourceThreadId, | |
| 162 | ordinal, | |
| 163 | providerInstanceId: instanceId, | |
| 164 | modelSelection, | |
| 165 | providerThreadId, | |
| 166 | userMessageId: messageId, | |
| 167 | rootNodeId: null, | |
| 168 | activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, | |
| 169 | status: ordinal === 1 ? status : "completed", | |
| 170 | queuePosition: null, | |
| 171 | requestedAt: now, | |
| 172 | startedAt: now, | |
| 173 | completedAt: now, | |
| 174 | checkpointId: null, | |
| 175 | contextHandoffId: null, | |
| 176 | }, | |
| 172 | ordinal, | |
| 173 | providerInstanceId: instanceId, | |
| 174 | modelSelection, | |
| 175 | providerThreadId, | |
| 176 | userMessageId: messageId, | |
| 177 | rootNodeId: null, | |
| 178 | activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, | |
| 179 | status: ordinal === 1 ? status : "completed", | |
| 180 | queuePosition: null, | |
| 181 | requestedAt: now, | |
| 182 | startedAt: now, | |
| 183 | completedAt: now, | |
| 184 | checkpointId: null, | |
| 185 | contextHandoffId: null, | |
| 177 | 186 | }, |
| 178 | { | |
| 179 | id: EventId.make(`item-${ordinal}`), | |
| 180 | type: "turn-item.updated", | |
| 187 | }, | |
| 188 | { | |
| 189 | id: EventId.make(`item-${ordinal}`), | |
| 190 | type: "turn-item.updated", | |
| 191 | threadId: sourceThreadId, | |
| 192 | runId, | |
| 193 | occurredAt: now, | |
| 194 | payload: { | |
| 195 | id: TurnItemId.make(`item-${ordinal}`), | |
| 181 | 196 | threadId: sourceThreadId, |
| 182 | 197 | runId, |
| 183 | occurredAt: now, | |
| 184 | payload: { | |
| 185 | id: TurnItemId.make(`item-${ordinal}`), | |
| 186 | threadId: sourceThreadId, | |
| 187 | runId, | |
| 188 | nodeId: null, | |
| 189 | providerThreadId, | |
| 190 | providerTurnId: null, | |
| 191 | nativeItemRef: null, | |
| 192 | parentItemId: null, | |
| 193 | ordinal, | |
| 194 | status: "completed", | |
| 195 | title: null, | |
| 196 | startedAt: now, | |
| 197 | completedAt: now, | |
| 198 | updatedAt: now, | |
| 199 | type: "user_message", | |
| 200 | createdBy: "user", | |
| 201 | creationSource: "web", | |
| 202 | inputIntent: "turn_start", | |
| 203 | messageId, | |
| 204 | text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", | |
| 205 | attachments: [], | |
| 206 | }, | |
| 198 | nodeId: null, | |
| 199 | providerThreadId, | |
| 200 | providerTurnId: null, | |
| 201 | nativeItemRef: null, | |
| 202 | parentItemId: null, | |
| 203 | ordinal, | |
| 204 | status: "completed", | |
| 205 | title: null, | |
| 206 | startedAt: now, | |
| 207 | completedAt: now, | |
| 208 | updatedAt: now, | |
| 209 | type: "user_message", | |
| 210 | createdBy: "user", | |
| 211 | creationSource: "web", | |
| 212 | inputIntent: "turn_start", | |
| 213 | messageId, | |
| 214 | text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", | |
| 215 | attachments: [], | |
| 207 | 216 | }, |
| 208 | ], | |
| 209 | }); | |
| 210 | } | |
| 211 | yield* orchestrator.dispatch({ | |
| 212 | type: "thread.fork", | |
| 213 | commandId: CommandId.make("fork-source"), | |
| 214 | sourceThreadId, | |
| 215 | targetThreadId, | |
| 216 | sourcePoint: { type: "run", runId: sourceRunId }, | |
| 217 | createdBy: "user", | |
| 218 | creationSource: "web", | |
| 219 | }); | |
| 220 | yield* orchestrator.dispatch({ | |
| 221 | type: "message.dispatch", | |
| 222 | commandId: CommandId.make("continue-fork"), | |
| 223 | threadId: targetThreadId, | |
| 224 | messageId: MessageId.make("continue-fork"), | |
| 225 | text: "Continue from the selected source run", | |
| 226 | attachments: [], | |
| 227 | modelSelection, | |
| 228 | dispatchMode: { type: "start_immediately" }, | |
| 229 | createdBy: "user", | |
| 230 | creationSource: "web", | |
| 217 | }, | |
| 218 | ], | |
| 231 | 219 | }); |
| 232 | const target = yield* orchestrator.getThreadProjection(targetThreadId); | |
| 233 | assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context"); | |
| 234 | assert.lengthOf(target.contextHandoffs, 1); | |
| 235 | const handoff = target.contextHandoffs[0]!; | |
| 236 | const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; | |
| 237 | assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); | |
| 238 | assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); | |
| 239 | assert.isNull(target.providerThreads[0]?.forkedFrom); | |
| 240 | }).pipe(Effect.provide(layer)), | |
| 241 | ); | |
| 242 | } | |
| 243 | } | |
| 220 | } | |
| 221 | yield* orchestrator.dispatch({ | |
| 222 | type: "thread.fork", | |
| 223 | commandId: CommandId.make("fork-source"), | |
| 224 | sourceThreadId, | |
| 225 | targetThreadId, | |
| 226 | sourcePoint: { type: "run", runId: sourceRunId }, | |
| 227 | createdBy: "user", | |
| 228 | creationSource: "web", | |
| 229 | }); | |
| 230 | yield* orchestrator.dispatch({ | |
| 231 | type: "message.dispatch", | |
| 232 | commandId: CommandId.make("continue-fork"), | |
| 233 | threadId: targetThreadId, | |
| 234 | messageId: MessageId.make("continue-fork"), | |
| 235 | text: "Continue from the selected source run", | |
| 236 | attachments: [], | |
| 237 | modelSelection, | |
| 238 | dispatchMode: { type: "start_immediately" }, | |
| 239 | createdBy: "user", | |
| 240 | creationSource: "web", | |
| 241 | }); | |
| 242 | const target = yield* orchestrator.getThreadProjection(targetThreadId); | |
| 243 | assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context"); | |
| 244 | assert.lengthOf(target.contextHandoffs, 1); | |
| 245 | const handoff = target.contextHandoffs[0]!; | |
| 246 | const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; | |
| 247 | assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); | |
| 248 | assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); | |
| 249 | assert.isNull(target.providerThreads[0]?.forkedFrom); | |
| 250 | }).pipe(Effect.provide(layer)), | |
| 251 | ); |
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+155-159| ... | ... | @@ -271,64 +271,64 @@ function waitUntil<E, R>(predicate: () => Effect.Effect<boolean, E, R>): Effect. |
| 271 | 271 | }); |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | for (const target of ["new", "existing"] as const) { | |
| 275 | for (const createdBy of ["user", "agent"] as const) { | |
| 276 | it.effect( | |
| 277 | `attributes ${createdBy}-configured automations in ${target} threads without changing their prompt`, | |
| 278 | () => { | |
| 279 | const harness = makeHarness(); | |
| 280 | const scheduledTasks = ScheduledTasks.layer.pipe( | |
| 281 | Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)), | |
| 282 | ); | |
| 283 | return Effect.gen(function* () { | |
| 284 | const tasks = yield* ScheduledTasks.ScheduledTaskService; | |
| 285 | const launches = yield* ThreadLaunch.ThreadLaunchService; | |
| 286 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 287 | const existing = | |
| 288 | target === "existing" | |
| 289 | ? yield* launches.launch( | |
| 290 | launchInput({ command: "command:existing", thread: "thread:existing" }), | |
| 291 | ) | |
| 292 | : null; | |
| 293 | const { task } = yield* tasks.upsert({ | |
| 294 | id: ScheduledTaskId.make("scheduled-task:attribution"), | |
| 295 | title: "Daily audit", | |
| 296 | prompt: "Audit performance and crashes.", | |
| 297 | enabled: false, | |
| 298 | schedule: { type: "interval", everyMs: 60_000 }, | |
| 299 | projectId, | |
| 300 | threadId: existing?.threadId ?? null, | |
| 301 | workspaceStrategy: { type: "root" }, | |
| 302 | modelSelection, | |
| 303 | runtimeMode: "full-access", | |
| 304 | interactionMode: "default", | |
| 305 | createdBy, | |
| 306 | creationSource: createdBy === "agent" ? "mcp" : "web", | |
| 307 | }); | |
| 308 | const result = yield* tasks.runNow({ id: task.id }); | |
| 309 | assert.equal(result.task.lastRunStatus, "succeeded"); | |
| 310 | const projectThreads = yield* threads.listProjectThreads({ | |
| 311 | projectId, | |
| 312 | includeSubagents: false, | |
| 313 | }); | |
| 314 | const thread = | |
| 315 | projectThreads.find((candidate) => candidate.id === existing?.threadId) ?? | |
| 316 | projectThreads[0]; | |
| 317 | assert.isDefined(thread); | |
| 318 | const projection = yield* threads.getThreadProjection(thread!.id); | |
| 319 | // Encoding the persisted projection exercises both message and turn-item wire schemas. | |
| 320 | const wire = yield* encodeThreadProjection(projection); | |
| 321 | assert.equal(wire.messages[0]?.text, task.prompt); | |
| 322 | assert.equal(wire.messages[0]?.scheduledTaskId, task.id); | |
| 323 | assert.equal(wire.messages[0]?.createdBy, createdBy); | |
| 324 | const turnItem = wire.turnItems.find((item) => item.type === "user_message"); | |
| 325 | assert.equal(turnItem?.text, task.prompt); | |
| 326 | assert.equal(turnItem?.scheduledTaskId, task.id); | |
| 327 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); | |
| 328 | }, | |
| 274 | it.effect.each( | |
| 275 | (["new", "existing"] as const).flatMap((target) => | |
| 276 | (["user", "agent"] as const).map((createdBy) => ({ target, createdBy })), | |
| 277 | ), | |
| 278 | )( | |
| 279 | "attributes $createdBy-configured automations in $target threads without changing their prompt", | |
| 280 | ({ target, createdBy }) => { | |
| 281 | const harness = makeHarness(); | |
| 282 | const scheduledTasks = ScheduledTasks.layer.pipe( | |
| 283 | Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)), | |
| 329 | 284 | ); |
| 330 | } | |
| 331 | } | |
| 285 | return Effect.gen(function* () { | |
| 286 | const tasks = yield* ScheduledTasks.ScheduledTaskService; | |
| 287 | const launches = yield* ThreadLaunch.ThreadLaunchService; | |
| 288 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 289 | const existing = | |
| 290 | target === "existing" | |
| 291 | ? yield* launches.launch( | |
| 292 | launchInput({ command: "command:existing", thread: "thread:existing" }), | |
| 293 | ) | |
| 294 | : null; | |
| 295 | const { task } = yield* tasks.upsert({ | |
| 296 | id: ScheduledTaskId.make("scheduled-task:attribution"), | |
| 297 | title: "Daily audit", | |
| 298 | prompt: "Audit performance and crashes.", | |
| 299 | enabled: false, | |
| 300 | schedule: { type: "interval", everyMs: 60_000 }, | |
| 301 | projectId, | |
| 302 | threadId: existing?.threadId ?? null, | |
| 303 | workspaceStrategy: { type: "root" }, | |
| 304 | modelSelection, | |
| 305 | runtimeMode: "full-access", | |
| 306 | interactionMode: "default", | |
| 307 | createdBy, | |
| 308 | creationSource: createdBy === "agent" ? "mcp" : "web", | |
| 309 | }); | |
| 310 | const result = yield* tasks.runNow({ id: task.id }); | |
| 311 | assert.equal(result.task.lastRunStatus, "succeeded"); | |
| 312 | const projectThreads = yield* threads.listProjectThreads({ | |
| 313 | projectId, | |
| 314 | includeSubagents: false, | |
| 315 | }); | |
| 316 | const thread = | |
| 317 | projectThreads.find((candidate) => candidate.id === existing?.threadId) ?? | |
| 318 | projectThreads[0]; | |
| 319 | assert.isDefined(thread); | |
| 320 | const projection = yield* threads.getThreadProjection(thread!.id); | |
| 321 | // Encoding the persisted projection exercises both message and turn-item wire schemas. | |
| 322 | const wire = yield* encodeThreadProjection(projection); | |
| 323 | assert.equal(wire.messages[0]?.text, task.prompt); | |
| 324 | assert.equal(wire.messages[0]?.scheduledTaskId, task.id); | |
| 325 | assert.equal(wire.messages[0]?.createdBy, createdBy); | |
| 326 | const turnItem = wire.turnItems.find((item) => item.type === "user_message"); | |
| 327 | assert.equal(turnItem?.text, task.prompt); | |
| 328 | assert.equal(turnItem?.scheduledTaskId, task.id); | |
| 329 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); | |
| 330 | }, | |
| 331 | ); | |
| 332 | 332 | |
| 333 | 333 | it.effect("retains automation and sender attribution while a message waits in the queue", () => { |
| 334 | 334 | const harness = makeHarness({ runSetup: () => Effect.never }); |
| ... | ... | @@ -603,8 +603,9 @@ it.effect( |
| 603 | 603 | }), |
| 604 | 604 | ); |
| 605 | 605 | |
| 606 | for (const nativeCommand of [" /COMPACT ", "/logout"]) { | |
| 607 | it.effect(`uses the first conversation message for a title after ${nativeCommand}`, () => | |
| 606 | it.effect.each([" /COMPACT ", "/logout"])( | |
| 607 | "uses the first conversation message for a title after %s", | |
| 608 | (nativeCommand) => | |
| 608 | 609 | Effect.gen(function* () { |
| 609 | 610 | const harness = makeHarness(); |
| 610 | 611 | yield* Effect.gen(function* () { |
| ... | ... | @@ -653,8 +654,7 @@ for (const nativeCommand of [" /COMPACT ", "/logout"]) { |
| 653 | 654 | ); |
| 654 | 655 | }).pipe(Effect.provide(harness.layer)); |
| 655 | 656 | }), |
| 656 | ); | |
| 657 | } | |
| 657 | ); | |
| 658 | 658 | |
| 659 | 659 | it.effect("keeps native maintenance commands out of steering and restart messages", () => |
| 660 | 660 | Effect.gen(function* () { |
| ... | ... | @@ -1293,47 +1293,45 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails |
| 1293 | 1293 | }).pipe(Effect.provide(harness.layer)); |
| 1294 | 1294 | }); |
| 1295 | 1295 | |
| 1296 | for (const failurePoint of ["worktree", "setup"] as const) { | |
| 1297 | it.effect( | |
| 1298 | `${failurePoint} failure keeps the thread and message visible and emits failure items`, | |
| 1299 | () => | |
| 1300 | Effect.gen(function* () { | |
| 1301 | const failure = new Error(`${failurePoint} failed`); | |
| 1302 | const harness = makeHarness( | |
| 1303 | failurePoint === "worktree" | |
| 1304 | ? { createWorktree: () => Effect.fail(failure as never) } | |
| 1305 | : { runSetup: () => Effect.fail(failure as never) }, | |
| 1296 | it.effect.each(["worktree", "setup"] as const)( | |
| 1297 | "%s failure keeps the thread and message visible and emits failure items", | |
| 1298 | (failurePoint) => | |
| 1299 | Effect.gen(function* () { | |
| 1300 | const failure = new Error(`${failurePoint} failed`); | |
| 1301 | const harness = makeHarness( | |
| 1302 | failurePoint === "worktree" | |
| 1303 | ? { createWorktree: () => Effect.fail(failure as never) } | |
| 1304 | : { runSetup: () => Effect.fail(failure as never) }, | |
| 1305 | ); | |
| 1306 | yield* Effect.gen(function* () { | |
| 1307 | const launches = yield* ThreadLaunch.ThreadLaunchService; | |
| 1308 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 1309 | const input = launchInput({ | |
| 1310 | command: `command:launch:${failurePoint}-failure`, | |
| 1311 | thread: `thread:launch:${failurePoint}-failure`, | |
| 1312 | message: `Fail during ${failurePoint}`, | |
| 1313 | workspace: { type: "worktree", baseRef: "main" }, | |
| 1314 | }); | |
| 1315 | const launched = yield* launches.launch(input); | |
| 1316 | yield* waitUntil(() => | |
| 1317 | threads | |
| 1318 | .getThreadProjection(launched.threadId) | |
| 1319 | .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), | |
| 1306 | 1320 | ); |
| 1307 | yield* Effect.gen(function* () { | |
| 1308 | const launches = yield* ThreadLaunch.ThreadLaunchService; | |
| 1309 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 1310 | const input = launchInput({ | |
| 1311 | command: `command:launch:${failurePoint}-failure`, | |
| 1312 | thread: `thread:launch:${failurePoint}-failure`, | |
| 1313 | message: `Fail during ${failurePoint}`, | |
| 1314 | workspace: { type: "worktree", baseRef: "main" }, | |
| 1315 | }); | |
| 1316 | const launched = yield* launches.launch(input); | |
| 1317 | yield* waitUntil(() => | |
| 1318 | threads | |
| 1319 | .getThreadProjection(launched.threadId) | |
| 1320 | .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), | |
| 1321 | ); | |
| 1322 | const projection = yield* threads.getThreadProjection(launched.threadId); | |
| 1323 | assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`); | |
| 1324 | assert.equal(projection.runs[0]?.status, "failed"); | |
| 1325 | assert.equal( | |
| 1326 | projection.turnItems.find((item) => item.type === "command_execution")?.status, | |
| 1327 | "failed", | |
| 1328 | ); | |
| 1329 | assert.match( | |
| 1330 | projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", | |
| 1331 | new RegExp(`${failurePoint} failed`, "u"), | |
| 1332 | ); | |
| 1333 | }).pipe(Effect.provide(harness.layer)); | |
| 1334 | }), | |
| 1335 | ); | |
| 1336 | } | |
| 1321 | const projection = yield* threads.getThreadProjection(launched.threadId); | |
| 1322 | assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`); | |
| 1323 | assert.equal(projection.runs[0]?.status, "failed"); | |
| 1324 | assert.equal( | |
| 1325 | projection.turnItems.find((item) => item.type === "command_execution")?.status, | |
| 1326 | "failed", | |
| 1327 | ); | |
| 1328 | assert.match( | |
| 1329 | projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", | |
| 1330 | new RegExp(`${failurePoint} failed`, "u"), | |
| 1331 | ); | |
| 1332 | }).pipe(Effect.provide(harness.layer)); | |
| 1333 | }), | |
| 1334 | ); | |
| 1337 | 1335 | |
| 1338 | 1336 | it.effect("replays a server-allocated launch", () => |
| 1339 | 1337 | Effect.gen(function* () { |
| ... | ... | @@ -1989,61 +1987,59 @@ it.effect("cancels tracked setup before provider work is released", () => |
| 1989 | 1987 | }), |
| 1990 | 1988 | ); |
| 1991 | 1989 | |
| 1992 | for (const exitCode of [0, 1]) { | |
| 1993 | it.effect(`releases an async setup before its completion with exit ${exitCode}`, () => | |
| 1994 | Effect.gen(function* () { | |
| 1995 | const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); | |
| 1996 | const harness = makeHarness({ | |
| 1997 | runSetup: () => | |
| 1998 | Effect.succeed({ | |
| 1999 | status: "started" as const, | |
| 2000 | async: true, | |
| 2001 | scriptId: "setup", | |
| 2002 | scriptName: "Setup", | |
| 2003 | scriptCommand: "vp install", | |
| 2004 | terminalId: "setup", | |
| 2005 | cwd: "/repo-worktrees/feature", | |
| 2006 | completion: Deferred.await(completion), | |
| 2007 | }), | |
| 2008 | }); | |
| 2009 | yield* Effect.gen(function* () { | |
| 2010 | const launches = yield* ThreadLaunch.ThreadLaunchService; | |
| 2011 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 2012 | const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; | |
| 2013 | const launched = yield* launches.launch( | |
| 2014 | launchInput({ | |
| 2015 | command: `command:launch:async-${exitCode}`, | |
| 2016 | thread: `thread:launch:async-${exitCode}`, | |
| 2017 | message: "Start during setup", | |
| 2018 | workspace: { type: "worktree", baseRef: "main" }, | |
| 2019 | }), | |
| 2020 | ); | |
| 2021 | yield* tracker.stream(launched.threadId).pipe( | |
| 2022 | Stream.filter( | |
| 2023 | (snapshot) => | |
| 2024 | snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") === | |
| 2025 | true, | |
| 2026 | ), | |
| 2027 | Stream.runHead, | |
| 2028 | ); | |
| 2029 | const running = yield* threads.getThreadProjection(launched.threadId); | |
| 2030 | assert.equal(running.runs[0]?.status, "starting"); | |
| 2031 | assert.equal((yield* tracker.get(launched.threadId))?.phase, "running"); | |
| 2032 | yield* Deferred.succeed(completion, { exitCode, durationMs: 1 }); | |
| 2033 | yield* tracker.stream(launched.threadId).pipe( | |
| 2034 | Stream.filter((snapshot) => snapshot?.phase === "done"), | |
| 2035 | Stream.runHead, | |
| 2036 | ); | |
| 2037 | const settled = yield* tracker.get(launched.threadId); | |
| 2038 | assert.equal( | |
| 2039 | settled?.stages.find((stage) => stage.id === "setup-script")?.status, | |
| 2040 | exitCode === 0 ? "done" : "failed", | |
| 2041 | ); | |
| 2042 | assert.equal( | |
| 2043 | (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, | |
| 2044 | "starting", | |
| 2045 | ); | |
| 2046 | }).pipe(Effect.provide(harness.layer)); | |
| 2047 | }), | |
| 2048 | ); | |
| 2049 | } | |
| 1990 | it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) => | |
| 1991 | Effect.gen(function* () { | |
| 1992 | const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); | |
| 1993 | const harness = makeHarness({ | |
| 1994 | runSetup: () => | |
| 1995 | Effect.succeed({ | |
| 1996 | status: "started" as const, | |
| 1997 | async: true, | |
| 1998 | scriptId: "setup", | |
| 1999 | scriptName: "Setup", | |
| 2000 | scriptCommand: "vp install", | |
| 2001 | terminalId: "setup", | |
| 2002 | cwd: "/repo-worktrees/feature", | |
| 2003 | completion: Deferred.await(completion), | |
| 2004 | }), | |
| 2005 | }); | |
| 2006 | yield* Effect.gen(function* () { | |
| 2007 | const launches = yield* ThreadLaunch.ThreadLaunchService; | |
| 2008 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 2009 | const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; | |
| 2010 | const launched = yield* launches.launch( | |
| 2011 | launchInput({ | |
| 2012 | command: `command:launch:async-${exitCode}`, | |
| 2013 | thread: `thread:launch:async-${exitCode}`, | |
| 2014 | message: "Start during setup", | |
| 2015 | workspace: { type: "worktree", baseRef: "main" }, | |
| 2016 | }), | |
| 2017 | ); | |
| 2018 | yield* tracker.stream(launched.threadId).pipe( | |
| 2019 | Stream.filter( | |
| 2020 | (snapshot) => | |
| 2021 | snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") === | |
| 2022 | true, | |
| 2023 | ), | |
| 2024 | Stream.runHead, | |
| 2025 | ); | |
| 2026 | const running = yield* threads.getThreadProjection(launched.threadId); | |
| 2027 | assert.equal(running.runs[0]?.status, "starting"); | |
| 2028 | assert.equal((yield* tracker.get(launched.threadId))?.phase, "running"); | |
| 2029 | yield* Deferred.succeed(completion, { exitCode, durationMs: 1 }); | |
| 2030 | yield* tracker.stream(launched.threadId).pipe( | |
| 2031 | Stream.filter((snapshot) => snapshot?.phase === "done"), | |
| 2032 | Stream.runHead, | |
| 2033 | ); | |
| 2034 | const settled = yield* tracker.get(launched.threadId); | |
| 2035 | assert.equal( | |
| 2036 | settled?.stages.find((stage) => stage.id === "setup-script")?.status, | |
| 2037 | exitCode === 0 ? "done" : "failed", | |
| 2038 | ); | |
| 2039 | assert.equal( | |
| 2040 | (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, | |
| 2041 | "starting", | |
| 2042 | ); | |
| 2043 | }).pipe(Effect.provide(harness.layer)); | |
| 2044 | }), | |
| 2045 | ); |
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+67-69| ... | ... | @@ -344,7 +344,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte |
| 344 | 344 | }).pipe(Effect.provide(testLayer)); |
| 345 | 345 | }); |
| 346 | 346 | |
| 347 | for (const scenario of [ | |
| 347 | it.effect.each([ | |
| 348 | 348 | { finalStatus: "completed" as const, timedOut: false }, |
| 349 | 349 | { finalStatus: "failed" as const, timedOut: false }, |
| 350 | 350 | { finalStatus: "cancelled" as const, timedOut: false }, |
| ... | ... | @@ -352,72 +352,70 @@ for (const scenario of [ |
| 352 | 352 | { finalStatus: "rolled_back" as const, timedOut: false }, |
| 353 | 353 | { finalStatus: "running" as const, timedOut: true }, |
| 354 | 354 | { finalStatus: "missing" as const }, |
| 355 | ]) { | |
| 356 | it.effect(`waitForThread timeout final read when selected run is ${scenario.finalStatus}`, () => | |
| 357 | Effect.gen(function* () { | |
| 358 | const projectId = ProjectId.make("project:thread-management:wait-timeout"); | |
| 359 | const threadId = ThreadId.make("thread:thread-management:wait-timeout"); | |
| 360 | const runId = RunId.make("run:thread-management:wait-timeout"); | |
| 361 | const loopRead = yield* Deferred.make<void>(); | |
| 362 | let reads = 0; | |
| 363 | const projection = (status: OrchestrationV2Run["status"] | "missing") => | |
| 364 | ({ | |
| 365 | thread: { id: threadId, projectId, deletedAt: null }, | |
| 366 | runs: status === "missing" ? [] : [{ id: runId, status }], | |
| 367 | }) as unknown as OrchestrationV2ThreadProjection; | |
| 368 | const testLayer = ThreadManagementService.layer.pipe( | |
| 369 | Layer.provide( | |
| 370 | Layer.mock(Orchestrator.OrchestratorV2)({ | |
| 371 | getThreadRecords: () => | |
| 372 | Effect.gen(function* () { | |
| 373 | reads += 1; | |
| 374 | if (reads === 1) { | |
| 375 | return projection("running"); | |
| 376 | } | |
| 377 | if (reads === 2) { | |
| 378 | // Park inside the wait loop so the timeout path runs while a | |
| 379 | // final projection read can still observe a terminal run. | |
| 380 | yield* Deferred.succeed(loopRead, undefined); | |
| 381 | return yield* Effect.never; | |
| 382 | } | |
| 383 | return projection(scenario.finalStatus); | |
| 384 | }), | |
| 385 | }), | |
| 386 | ), | |
| 387 | ); | |
| 388 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( | |
| 389 | Effect.provide(testLayer), | |
| 390 | ); | |
| 391 | const fiber = yield* service | |
| 392 | .waitForThread({ | |
| 393 | projectId, | |
| 355 | ])("waitForThread timeout final read when selected run is $finalStatus", (scenario) => | |
| 356 | Effect.gen(function* () { | |
| 357 | const projectId = ProjectId.make("project:thread-management:wait-timeout"); | |
| 358 | const threadId = ThreadId.make("thread:thread-management:wait-timeout"); | |
| 359 | const runId = RunId.make("run:thread-management:wait-timeout"); | |
| 360 | const loopRead = yield* Deferred.make<void>(); | |
| 361 | let reads = 0; | |
| 362 | const projection = (status: OrchestrationV2Run["status"] | "missing") => | |
| 363 | ({ | |
| 364 | thread: { id: threadId, projectId, deletedAt: null }, | |
| 365 | runs: status === "missing" ? [] : [{ id: runId, status }], | |
| 366 | }) as unknown as OrchestrationV2ThreadProjection; | |
| 367 | const testLayer = ThreadManagementService.layer.pipe( | |
| 368 | Layer.provide( | |
| 369 | Layer.mock(Orchestrator.OrchestratorV2)({ | |
| 370 | getThreadRecords: () => | |
| 371 | Effect.gen(function* () { | |
| 372 | reads += 1; | |
| 373 | if (reads === 1) { | |
| 374 | return projection("running"); | |
| 375 | } | |
| 376 | if (reads === 2) { | |
| 377 | // Park inside the wait loop so the timeout path runs while a | |
| 378 | // final projection read can still observe a terminal run. | |
| 379 | yield* Deferred.succeed(loopRead, undefined); | |
| 380 | return yield* Effect.never; | |
| 381 | } | |
| 382 | return projection(scenario.finalStatus); | |
| 383 | }), | |
| 384 | }), | |
| 385 | ), | |
| 386 | ); | |
| 387 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( | |
| 388 | Effect.provide(testLayer), | |
| 389 | ); | |
| 390 | const fiber = yield* service | |
| 391 | .waitForThread({ | |
| 392 | projectId, | |
| 393 | threadId, | |
| 394 | runId, | |
| 395 | timeoutMs: 1, | |
| 396 | }) | |
| 397 | .pipe(Effect.result, Effect.forkChild); | |
| 398 | yield* Deferred.await(loopRead); | |
| 399 | yield* TestClock.adjust(Duration.millis(1)); | |
| 400 | const result = yield* Fiber.join(fiber); | |
| 401 | ||
| 402 | if (scenario.finalStatus === "missing") { | |
| 403 | expect(result._tag).toBe("Failure"); | |
| 404 | expect(result).toMatchObject({ | |
| 405 | failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError), | |
| 406 | }); | |
| 407 | expect(result).toMatchObject({ | |
| 408 | failure: { threadId, runId }, | |
| 409 | }); | |
| 410 | } else { | |
| 411 | expect(result._tag).toBe("Success"); | |
| 412 | expect(result).toMatchObject({ | |
| 413 | success: { | |
| 394 | 414 | threadId, |
| 395 | runId, | |
| 396 | timeoutMs: 1, | |
| 397 | }) | |
| 398 | .pipe(Effect.result, Effect.forkChild); | |
| 399 | yield* Deferred.await(loopRead); | |
| 400 | yield* TestClock.adjust(Duration.millis(1)); | |
| 401 | const result = yield* Fiber.join(fiber); | |
| 402 | ||
| 403 | if (scenario.finalStatus === "missing") { | |
| 404 | expect(result._tag).toBe("Failure"); | |
| 405 | expect(result).toMatchObject({ | |
| 406 | failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError), | |
| 407 | }); | |
| 408 | expect(result).toMatchObject({ | |
| 409 | failure: { threadId, runId }, | |
| 410 | }); | |
| 411 | } else { | |
| 412 | expect(result._tag).toBe("Success"); | |
| 413 | expect(result).toMatchObject({ | |
| 414 | success: { | |
| 415 | threadId, | |
| 416 | timedOut: scenario.timedOut, | |
| 417 | run: { id: runId, status: scenario.finalStatus }, | |
| 418 | }, | |
| 419 | }); | |
| 420 | } | |
| 421 | }), | |
| 422 | ); | |
| 423 | } | |
| 415 | timedOut: scenario.timedOut, | |
| 416 | run: { id: runId, status: scenario.finalStatus }, | |
| 417 | }, | |
| 418 | }); | |
| 419 | } | |
| 420 | }), | |
| 421 | ); |
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts+4-4| ... | ... | @@ -438,8 +438,9 @@ describe("ThreadTitleRegenerationService", () => { |
| 438 | 438 | ); |
| 439 | 439 | }); |
| 440 | 440 | |
| 441 | for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) { | |
| 442 | it.effect(`initial title retry: ${outcome}`, () => | |
| 441 | it.effect.each(["success", "exhausted", "stale", "interrupted"] as const)( | |
| 442 | "initial title retry: %s", | |
| 443 | (outcome) => | |
| 443 | 444 | Effect.gen(function* () { |
| 444 | 445 | const attempted = yield* Deferred.make<void>(); |
| 445 | 446 | let attempts = 0; |
| ... | ... | @@ -502,5 +503,4 @@ for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) |
| 502 | 503 | else assert.isNotOk(projection.thread.titleRegeneration); |
| 503 | 504 | }).pipe(Effect.provide(harness.layer)); |
| 504 | 505 | }), |
| 505 | ); | |
| 506 | } | |
| 506 | ); |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+275-285| ... | ... | @@ -2599,210 +2599,206 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2599 | 2599 | }), |
| 2600 | 2600 | ); |
| 2601 | 2601 | |
| 2602 | for (const automatic of [false, true]) { | |
| 2603 | it.effect( | |
| 2604 | `promotes only one queued run after each terminal run (notification: ${automatic})`, | |
| 2605 | () => | |
| 2606 | Effect.gen(function* () { | |
| 2607 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 2608 | const eventSink = yield* EventSink.EventSinkV2; | |
| 2609 | const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`); | |
| 2602 | it.effect.each([false, true])( | |
| 2603 | "promotes only one queued run after each terminal run (notification: %s)", | |
| 2604 | (automatic) => | |
| 2605 | Effect.gen(function* () { | |
| 2606 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 2607 | const eventSink = yield* EventSink.EventSinkV2; | |
| 2608 | const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`); | |
| 2610 | 2609 | |
| 2611 | yield* orchestrator.dispatch({ | |
| 2612 | type: "thread.create", | |
| 2613 | createdBy: "user", | |
| 2614 | creationSource: "web", | |
| 2615 | commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`), | |
| 2616 | threadId, | |
| 2617 | projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`), | |
| 2618 | title: "Serialized queue", | |
| 2619 | modelSelection, | |
| 2620 | runtimeMode: "full-access", | |
| 2621 | interactionMode: "default", | |
| 2622 | branch: null, | |
| 2623 | worktreePath: process.cwd(), | |
| 2624 | }); | |
| 2625 | yield* orchestrator.dispatch({ | |
| 2626 | type: "message.dispatch", | |
| 2627 | createdBy: "user", | |
| 2628 | creationSource: "web", | |
| 2629 | commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`), | |
| 2630 | threadId, | |
| 2631 | messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`), | |
| 2632 | text: "Active", | |
| 2633 | attachments: [], | |
| 2634 | modelSelection, | |
| 2635 | dispatchMode: { type: "start_immediately" }, | |
| 2636 | }); | |
| 2637 | yield* orchestrator.dispatch({ | |
| 2638 | type: "message.dispatch", | |
| 2639 | createdBy: automatic ? "agent" : "user", | |
| 2640 | creationSource: automatic ? "provider" : "web", | |
| 2641 | ...(automatic | |
| 2642 | ? { | |
| 2643 | notification: { | |
| 2644 | source: { kind: "monitor" as const }, | |
| 2645 | outcome: "updated" as const, | |
| 2646 | summary: "Monitor updated", | |
| 2647 | detail: "Build is green", | |
| 2648 | }, | |
| 2649 | } | |
| 2650 | : {}), | |
| 2651 | commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`), | |
| 2652 | threadId, | |
| 2653 | messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`), | |
| 2654 | text: "First queued", | |
| 2655 | attachments: [], | |
| 2656 | modelSelection, | |
| 2657 | dispatchMode: { type: "queue_after_active" }, | |
| 2658 | }); | |
| 2659 | yield* orchestrator.dispatch({ | |
| 2660 | type: "message.dispatch", | |
| 2661 | createdBy: "user", | |
| 2662 | creationSource: "web", | |
| 2663 | commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`), | |
| 2664 | threadId, | |
| 2665 | messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`), | |
| 2666 | text: "Second queued", | |
| 2667 | attachments: [], | |
| 2668 | modelSelection, | |
| 2669 | dispatchMode: { type: "queue_after_active" }, | |
| 2670 | }); | |
| 2610 | yield* orchestrator.dispatch({ | |
| 2611 | type: "thread.create", | |
| 2612 | createdBy: "user", | |
| 2613 | creationSource: "web", | |
| 2614 | commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`), | |
| 2615 | threadId, | |
| 2616 | projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`), | |
| 2617 | title: "Serialized queue", | |
| 2618 | modelSelection, | |
| 2619 | runtimeMode: "full-access", | |
| 2620 | interactionMode: "default", | |
| 2621 | branch: null, | |
| 2622 | worktreePath: process.cwd(), | |
| 2623 | }); | |
| 2624 | yield* orchestrator.dispatch({ | |
| 2625 | type: "message.dispatch", | |
| 2626 | createdBy: "user", | |
| 2627 | creationSource: "web", | |
| 2628 | commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`), | |
| 2629 | threadId, | |
| 2630 | messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`), | |
| 2631 | text: "Active", | |
| 2632 | attachments: [], | |
| 2633 | modelSelection, | |
| 2634 | dispatchMode: { type: "start_immediately" }, | |
| 2635 | }); | |
| 2636 | yield* orchestrator.dispatch({ | |
| 2637 | type: "message.dispatch", | |
| 2638 | createdBy: automatic ? "agent" : "user", | |
| 2639 | creationSource: automatic ? "provider" : "web", | |
| 2640 | ...(automatic | |
| 2641 | ? { | |
| 2642 | notification: { | |
| 2643 | source: { kind: "monitor" as const }, | |
| 2644 | outcome: "updated" as const, | |
| 2645 | summary: "Monitor updated", | |
| 2646 | detail: "Build is green", | |
| 2647 | }, | |
| 2648 | } | |
| 2649 | : {}), | |
| 2650 | commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`), | |
| 2651 | threadId, | |
| 2652 | messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`), | |
| 2653 | text: "First queued", | |
| 2654 | attachments: [], | |
| 2655 | modelSelection, | |
| 2656 | dispatchMode: { type: "queue_after_active" }, | |
| 2657 | }); | |
| 2658 | yield* orchestrator.dispatch({ | |
| 2659 | type: "message.dispatch", | |
| 2660 | createdBy: "user", | |
| 2661 | creationSource: "web", | |
| 2662 | commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`), | |
| 2663 | threadId, | |
| 2664 | messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`), | |
| 2665 | text: "Second queued", | |
| 2666 | attachments: [], | |
| 2667 | modelSelection, | |
| 2668 | dispatchMode: { type: "queue_after_active" }, | |
| 2669 | }); | |
| 2671 | 2670 | |
| 2672 | const before = yield* orchestrator.getThreadProjection(threadId); | |
| 2673 | const activeRun = before.runs.find((run) => run.status === "starting"); | |
| 2674 | const queuedRuns = before.runs | |
| 2675 | .filter((run) => run.status === "queued") | |
| 2676 | .toSorted((left, right) => left.ordinal - right.ordinal); | |
| 2677 | const firstQueuedRun = queuedRuns[0]; | |
| 2678 | const secondQueuedRun = queuedRuns[1]; | |
| 2679 | assert.isDefined(activeRun); | |
| 2680 | assert.isDefined(firstQueuedRun); | |
| 2681 | assert.isDefined(secondQueuedRun); | |
| 2682 | assert.isFalse( | |
| 2683 | before.turnItems.some( | |
| 2684 | (item) => | |
| 2685 | item.type === "user_message" && | |
| 2686 | (item.messageId === firstQueuedRun.userMessageId || | |
| 2687 | item.messageId === secondQueuedRun.userMessageId), | |
| 2688 | ), | |
| 2689 | "queued messages must not exist as turn items before dispatch", | |
| 2690 | ); | |
| 2671 | const before = yield* orchestrator.getThreadProjection(threadId); | |
| 2672 | const activeRun = before.runs.find((run) => run.status === "starting"); | |
| 2673 | const queuedRuns = before.runs | |
| 2674 | .filter((run) => run.status === "queued") | |
| 2675 | .toSorted((left, right) => left.ordinal - right.ordinal); | |
| 2676 | const firstQueuedRun = queuedRuns[0]; | |
| 2677 | const secondQueuedRun = queuedRuns[1]; | |
| 2678 | assert.isDefined(activeRun); | |
| 2679 | assert.isDefined(firstQueuedRun); | |
| 2680 | assert.isDefined(secondQueuedRun); | |
| 2681 | assert.isFalse( | |
| 2682 | before.turnItems.some( | |
| 2683 | (item) => | |
| 2684 | item.type === "user_message" && | |
| 2685 | (item.messageId === firstQueuedRun.userMessageId || | |
| 2686 | item.messageId === secondQueuedRun.userMessageId), | |
| 2687 | ), | |
| 2688 | "queued messages must not exist as turn items before dispatch", | |
| 2689 | ); | |
| 2691 | 2690 | |
| 2692 | const promotedRunIds = yield* Queue.unbounded<RunId>(); | |
| 2693 | const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); | |
| 2694 | yield* eventSink.stream({ threadId, afterSequence }).pipe( | |
| 2695 | Stream.runForEach((stored) => | |
| 2696 | stored.event.type === "run.updated" && stored.event.payload.status === "starting" | |
| 2697 | ? Queue.offer(promotedRunIds, stored.event.payload.id) | |
| 2698 | : Effect.void, | |
| 2699 | ), | |
| 2700 | Effect.forkScoped, | |
| 2701 | ); | |
| 2702 | yield* Effect.yieldNow; | |
| 2691 | const promotedRunIds = yield* Queue.unbounded<RunId>(); | |
| 2692 | const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); | |
| 2693 | yield* eventSink.stream({ threadId, afterSequence }).pipe( | |
| 2694 | Stream.runForEach((stored) => | |
| 2695 | stored.event.type === "run.updated" && stored.event.payload.status === "starting" | |
| 2696 | ? Queue.offer(promotedRunIds, stored.event.payload.id) | |
| 2697 | : Effect.void, | |
| 2698 | ), | |
| 2699 | Effect.forkScoped, | |
| 2700 | ); | |
| 2701 | yield* Effect.yieldNow; | |
| 2703 | 2702 | |
| 2704 | const activeCompletedAt = yield* DateTime.now; | |
| 2705 | yield* eventSink.write({ | |
| 2706 | events: [ | |
| 2707 | { | |
| 2708 | id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`), | |
| 2709 | type: "run.updated", | |
| 2710 | threadId, | |
| 2711 | runId: activeRun.id, | |
| 2712 | ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), | |
| 2713 | providerInstanceId: activeRun.providerInstanceId, | |
| 2714 | occurredAt: activeCompletedAt, | |
| 2715 | payload: { | |
| 2716 | ...activeRun, | |
| 2717 | status: "completed", | |
| 2718 | completedAt: activeCompletedAt, | |
| 2719 | }, | |
| 2703 | const activeCompletedAt = yield* DateTime.now; | |
| 2704 | yield* eventSink.write({ | |
| 2705 | events: [ | |
| 2706 | { | |
| 2707 | id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`), | |
| 2708 | type: "run.updated", | |
| 2709 | threadId, | |
| 2710 | runId: activeRun.id, | |
| 2711 | ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), | |
| 2712 | providerInstanceId: activeRun.providerInstanceId, | |
| 2713 | occurredAt: activeCompletedAt, | |
| 2714 | payload: { | |
| 2715 | ...activeRun, | |
| 2716 | status: "completed", | |
| 2717 | completedAt: activeCompletedAt, | |
| 2720 | 2718 | }, |
| 2721 | ], | |
| 2722 | }); | |
| 2719 | }, | |
| 2720 | ], | |
| 2721 | }); | |
| 2723 | 2722 | |
| 2724 | assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id); | |
| 2725 | const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId); | |
| 2723 | assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id); | |
| 2724 | const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId); | |
| 2725 | assert.equal( | |
| 2726 | afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, | |
| 2727 | "starting", | |
| 2728 | ); | |
| 2729 | assert.equal( | |
| 2730 | afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, | |
| 2731 | "queued", | |
| 2732 | ); | |
| 2733 | const promotedMessageItem = afterFirstPromotion.turnItems.find( | |
| 2734 | (item) => | |
| 2735 | item.runId === firstQueuedRun.id && | |
| 2736 | (item.type === "user_message" || item.type === "notification"), | |
| 2737 | ); | |
| 2738 | assert.isDefined(promotedMessageItem); | |
| 2739 | if (automatic) { | |
| 2740 | assert.equal(promotedMessageItem.type, "notification"); | |
| 2726 | 2741 | assert.equal( |
| 2727 | afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, | |
| 2728 | "starting", | |
| 2742 | afterFirstPromotion.messages.find( | |
| 2743 | (message) => message.id === firstQueuedRun.userMessageId, | |
| 2744 | )?.text, | |
| 2745 | "First queued", | |
| 2729 | 2746 | ); |
| 2730 | 2747 | assert.equal( |
| 2731 | afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, | |
| 2732 | "queued", | |
| 2748 | afterFirstPromotion.messages.find( | |
| 2749 | (message) => message.id === firstQueuedRun.userMessageId, | |
| 2750 | )?.notification?.summary, | |
| 2751 | "Monitor updated", | |
| 2733 | 2752 | ); |
| 2734 | const promotedMessageItem = afterFirstPromotion.turnItems.find( | |
| 2735 | (item) => | |
| 2736 | item.runId === firstQueuedRun.id && | |
| 2737 | (item.type === "user_message" || item.type === "notification"), | |
| 2738 | ); | |
| 2739 | assert.isDefined(promotedMessageItem); | |
| 2740 | if (automatic) { | |
| 2741 | assert.equal(promotedMessageItem.type, "notification"); | |
| 2742 | assert.equal( | |
| 2743 | afterFirstPromotion.messages.find( | |
| 2744 | (message) => message.id === firstQueuedRun.userMessageId, | |
| 2745 | )?.text, | |
| 2746 | "First queued", | |
| 2747 | ); | |
| 2748 | assert.equal( | |
| 2749 | afterFirstPromotion.messages.find( | |
| 2750 | (message) => message.id === firstQueuedRun.userMessageId, | |
| 2751 | )?.notification?.summary, | |
| 2752 | "Monitor updated", | |
| 2753 | ); | |
| 2754 | assert.isFalse( | |
| 2755 | afterFirstPromotion.turnItems.some( | |
| 2756 | (item) => | |
| 2757 | item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId, | |
| 2758 | ), | |
| 2759 | ); | |
| 2760 | } else { | |
| 2761 | assert.equal(promotedMessageItem.type, "user_message"); | |
| 2762 | } | |
| 2763 | assert.isTrue( | |
| 2764 | promotedMessageItem.startedAt !== null && | |
| 2765 | DateTime.toEpochMillis(promotedMessageItem.startedAt) >= | |
| 2766 | DateTime.toEpochMillis(activeCompletedAt), | |
| 2753 | assert.isFalse( | |
| 2754 | afterFirstPromotion.turnItems.some( | |
| 2755 | (item) => | |
| 2756 | item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId, | |
| 2757 | ), | |
| 2767 | 2758 | ); |
| 2759 | } else { | |
| 2760 | assert.equal(promotedMessageItem.type, "user_message"); | |
| 2761 | } | |
| 2762 | assert.isTrue( | |
| 2763 | promotedMessageItem.startedAt !== null && | |
| 2764 | DateTime.toEpochMillis(promotedMessageItem.startedAt) >= | |
| 2765 | DateTime.toEpochMillis(activeCompletedAt), | |
| 2766 | ); | |
| 2768 | 2767 | |
| 2769 | const promotedFirst = afterFirstPromotion.runs.find( | |
| 2770 | (run) => run.id === firstQueuedRun.id, | |
| 2771 | ); | |
| 2772 | assert.isDefined(promotedFirst); | |
| 2773 | const firstCompletedAt = yield* DateTime.now; | |
| 2774 | yield* eventSink.write({ | |
| 2775 | events: [ | |
| 2776 | { | |
| 2777 | id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`), | |
| 2778 | type: "run.updated", | |
| 2779 | threadId, | |
| 2780 | runId: promotedFirst.id, | |
| 2781 | ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }), | |
| 2782 | providerInstanceId: promotedFirst.providerInstanceId, | |
| 2783 | occurredAt: firstCompletedAt, | |
| 2784 | payload: { | |
| 2785 | ...promotedFirst, | |
| 2786 | status: "completed", | |
| 2787 | completedAt: firstCompletedAt, | |
| 2788 | }, | |
| 2768 | const promotedFirst = afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id); | |
| 2769 | assert.isDefined(promotedFirst); | |
| 2770 | const firstCompletedAt = yield* DateTime.now; | |
| 2771 | yield* eventSink.write({ | |
| 2772 | events: [ | |
| 2773 | { | |
| 2774 | id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`), | |
| 2775 | type: "run.updated", | |
| 2776 | threadId, | |
| 2777 | runId: promotedFirst.id, | |
| 2778 | ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }), | |
| 2779 | providerInstanceId: promotedFirst.providerInstanceId, | |
| 2780 | occurredAt: firstCompletedAt, | |
| 2781 | payload: { | |
| 2782 | ...promotedFirst, | |
| 2783 | status: "completed", | |
| 2784 | completedAt: firstCompletedAt, | |
| 2789 | 2785 | }, |
| 2790 | ], | |
| 2791 | }); | |
| 2786 | }, | |
| 2787 | ], | |
| 2788 | }); | |
| 2792 | 2789 | |
| 2793 | assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id); | |
| 2794 | const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId); | |
| 2795 | assert.equal( | |
| 2796 | afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, | |
| 2797 | "completed", | |
| 2798 | ); | |
| 2799 | assert.equal( | |
| 2800 | afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, | |
| 2801 | "starting", | |
| 2802 | ); | |
| 2803 | }), | |
| 2804 | ); | |
| 2805 | } | |
| 2790 | assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id); | |
| 2791 | const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId); | |
| 2792 | assert.equal( | |
| 2793 | afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, | |
| 2794 | "completed", | |
| 2795 | ); | |
| 2796 | assert.equal( | |
| 2797 | afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, | |
| 2798 | "starting", | |
| 2799 | ); | |
| 2800 | }), | |
| 2801 | ); | |
| 2806 | 2802 | |
| 2807 | 2803 | it.effect.each(["usage_limit", "provider_error"] as const)( |
| 2808 | 2804 | "handles a queued message after a %s failure", |
| ... | ... | @@ -2993,112 +2989,106 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2993 | 2989 | }), |
| 2994 | 2990 | ); |
| 2995 | 2991 | |
| 2996 | for (const trigger of ["startup", "shutdown"] as const) { | |
| 2997 | it.effect( | |
| 2998 | `preserves and holds queued messages across ${trigger} until explicitly resumed`, | |
| 2999 | () => | |
| 3000 | Effect.gen(function* () { | |
| 3001 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 3002 | const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; | |
| 3003 | const threadId = ThreadId.make(`queue-hold-${trigger}`); | |
| 2992 | it.effect.each(["startup", "shutdown"] as const)( | |
| 2993 | "preserves and holds queued messages across %s until explicitly resumed", | |
| 2994 | (trigger) => | |
| 2995 | Effect.gen(function* () { | |
| 2996 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 2997 | const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; | |
| 2998 | const threadId = ThreadId.make(`queue-hold-${trigger}`); | |
| 2999 | yield* orchestrator.dispatch({ | |
| 3000 | type: "thread.create", | |
| 3001 | createdBy: "user", | |
| 3002 | creationSource: "web", | |
| 3003 | commandId: CommandId.make(`${threadId}:create`), | |
| 3004 | threadId, | |
| 3005 | projectId: ProjectId.make(`${threadId}:project`), | |
| 3006 | title: "Recover queue", | |
| 3007 | modelSelection, | |
| 3008 | runtimeMode: "full-access", | |
| 3009 | interactionMode: "default", | |
| 3010 | branch: null, | |
| 3011 | worktreePath: process.cwd(), | |
| 3012 | }); | |
| 3013 | for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) { | |
| 3004 | 3014 | yield* orchestrator.dispatch({ |
| 3005 | type: "thread.create", | |
| 3015 | type: "message.dispatch", | |
| 3006 | 3016 | createdBy: "user", |
| 3007 | 3017 | creationSource: "web", |
| 3008 | commandId: CommandId.make(`${threadId}:create`), | |
| 3018 | commandId: CommandId.make(`${threadId}:message:${index}`), | |
| 3009 | 3019 | threadId, |
| 3010 | projectId: ProjectId.make(`${threadId}:project`), | |
| 3011 | title: "Recover queue", | |
| 3020 | messageId: MessageId.make(`${threadId}:message:${index}`), | |
| 3021 | text, | |
| 3022 | attachments: [], | |
| 3012 | 3023 | modelSelection, |
| 3013 | runtimeMode: "full-access", | |
| 3014 | interactionMode: "default", | |
| 3015 | branch: null, | |
| 3016 | worktreePath: process.cwd(), | |
| 3024 | dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, | |
| 3017 | 3025 | }); |
| 3018 | for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) { | |
| 3019 | yield* orchestrator.dispatch({ | |
| 3020 | type: "message.dispatch", | |
| 3021 | createdBy: "user", | |
| 3022 | creationSource: "web", | |
| 3023 | commandId: CommandId.make(`${threadId}:message:${index}`), | |
| 3024 | threadId, | |
| 3025 | messageId: MessageId.make(`${threadId}:message:${index}`), | |
| 3026 | text, | |
| 3027 | attachments: [], | |
| 3028 | modelSelection, | |
| 3029 | dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, | |
| 3030 | }); | |
| 3031 | } | |
| 3032 | const before = yield* orchestrator.getThreadProjection(threadId); | |
| 3033 | const queued = before.runs.filter((run) => run.status === "queued"); | |
| 3034 | assert.equal(queued.length, 2); | |
| 3035 | yield* recovery.reconcile(trigger); | |
| 3036 | // A second boot must preserve the hold, even when only queued work remains. | |
| 3037 | yield* recovery.reconcile("startup"); | |
| 3038 | const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; | |
| 3039 | assert.isTrue((yield* maintenance.rebuild).valid); | |
| 3040 | assert.equal(yield* orchestrator.resumeQueuedRuns, 0); | |
| 3041 | const held = yield* orchestrator.getThreadProjection(threadId); | |
| 3026 | } | |
| 3027 | const before = yield* orchestrator.getThreadProjection(threadId); | |
| 3028 | const queued = before.runs.filter((run) => run.status === "queued"); | |
| 3029 | assert.equal(queued.length, 2); | |
| 3030 | yield* recovery.reconcile(trigger); | |
| 3031 | // A second boot must preserve the hold, even when only queued work remains. | |
| 3032 | yield* recovery.reconcile("startup"); | |
| 3033 | const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; | |
| 3034 | assert.isTrue((yield* maintenance.rebuild).valid); | |
| 3035 | assert.equal(yield* orchestrator.resumeQueuedRuns, 0); | |
| 3036 | const held = yield* orchestrator.getThreadProjection(threadId); | |
| 3037 | assert.deepEqual( | |
| 3038 | held.runs.map((run) => run.status), | |
| 3039 | ["cancelled", "queued", "queued"], | |
| 3040 | ); | |
| 3041 | for (const run of queued) { | |
| 3042 | 3042 | assert.deepEqual( |
| 3043 | held.runs.map((run) => run.status), | |
| 3044 | ["cancelled", "queued", "queued"], | |
| 3043 | held.runs.find((row) => row.id === run.id), | |
| 3044 | { ...run, queueHeld: true }, | |
| 3045 | 3045 | ); |
| 3046 | for (const run of queued) { | |
| 3047 | assert.deepEqual( | |
| 3048 | held.runs.find((row) => row.id === run.id), | |
| 3049 | { ...run, queueHeld: true }, | |
| 3050 | ); | |
| 3051 | assert.deepEqual( | |
| 3052 | held.messages.find((row) => row.id === run.userMessageId), | |
| 3053 | before.messages.find((row) => row.id === run.userMessageId), | |
| 3054 | ); | |
| 3055 | assert.equal( | |
| 3056 | held.attempts.find((row) => row.id === run.activeAttemptId)?.status, | |
| 3057 | "pending", | |
| 3058 | ); | |
| 3059 | assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending"); | |
| 3060 | } | |
| 3061 | // Editing and reordering are allowed without releasing the hold. | |
| 3062 | const first = queued[0]!; | |
| 3063 | const second = queued[1]!; | |
| 3064 | yield* orchestrator.dispatch({ | |
| 3065 | type: "queued-run.edit", | |
| 3066 | commandId: CommandId.make(`${threadId}:edit`), | |
| 3067 | threadId, | |
| 3068 | runId: second.id, | |
| 3069 | text: "Edited second message", | |
| 3070 | }); | |
| 3071 | yield* orchestrator.dispatch({ | |
| 3072 | type: "queued-run.reorder", | |
| 3073 | commandId: CommandId.make(`${threadId}:reorder`), | |
| 3074 | threadId, | |
| 3075 | runId: second.id, | |
| 3076 | beforeRunId: first.id, | |
| 3077 | }); | |
| 3078 | assert.equal(yield* orchestrator.resumeQueuedRuns, 0); | |
| 3079 | const resume = { | |
| 3080 | type: "queue.resume" as const, | |
| 3081 | commandId: CommandId.make(`${threadId}:resume`), | |
| 3082 | threadId, | |
| 3083 | }; | |
| 3084 | yield* orchestrator.dispatch(resume); | |
| 3085 | yield* orchestrator.dispatch(resume); | |
| 3086 | const resumed = yield* orchestrator.getThreadProjection(threadId); | |
| 3087 | assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting"); | |
| 3088 | assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued"); | |
| 3089 | assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld)); | |
| 3090 | assert.equal( | |
| 3091 | resumed.messages.find((row) => row.id === second.userMessageId)?.text, | |
| 3092 | "Edited second message", | |
| 3046 | assert.deepEqual( | |
| 3047 | held.messages.find((row) => row.id === run.userMessageId), | |
| 3048 | before.messages.find((row) => row.id === run.userMessageId), | |
| 3093 | 3049 | ); |
| 3094 | 3050 | assert.equal( |
| 3095 | resumed.runs.length, | |
| 3096 | 3, | |
| 3097 | "resume retries must not duplicate messages or runs", | |
| 3051 | held.attempts.find((row) => row.id === run.activeAttemptId)?.status, | |
| 3052 | "pending", | |
| 3098 | 3053 | ); |
| 3099 | }), | |
| 3100 | ); | |
| 3101 | } | |
| 3054 | assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending"); | |
| 3055 | } | |
| 3056 | // Editing and reordering are allowed without releasing the hold. | |
| 3057 | const first = queued[0]!; | |
| 3058 | const second = queued[1]!; | |
| 3059 | yield* orchestrator.dispatch({ | |
| 3060 | type: "queued-run.edit", | |
| 3061 | commandId: CommandId.make(`${threadId}:edit`), | |
| 3062 | threadId, | |
| 3063 | runId: second.id, | |
| 3064 | text: "Edited second message", | |
| 3065 | }); | |
| 3066 | yield* orchestrator.dispatch({ | |
| 3067 | type: "queued-run.reorder", | |
| 3068 | commandId: CommandId.make(`${threadId}:reorder`), | |
| 3069 | threadId, | |
| 3070 | runId: second.id, | |
| 3071 | beforeRunId: first.id, | |
| 3072 | }); | |
| 3073 | assert.equal(yield* orchestrator.resumeQueuedRuns, 0); | |
| 3074 | const resume = { | |
| 3075 | type: "queue.resume" as const, | |
| 3076 | commandId: CommandId.make(`${threadId}:resume`), | |
| 3077 | threadId, | |
| 3078 | }; | |
| 3079 | yield* orchestrator.dispatch(resume); | |
| 3080 | yield* orchestrator.dispatch(resume); | |
| 3081 | const resumed = yield* orchestrator.getThreadProjection(threadId); | |
| 3082 | assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting"); | |
| 3083 | assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued"); | |
| 3084 | assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld)); | |
| 3085 | assert.equal( | |
| 3086 | resumed.messages.find((row) => row.id === second.userMessageId)?.text, | |
| 3087 | "Edited second message", | |
| 3088 | ); | |
| 3089 | assert.equal(resumed.runs.length, 3, "resume retries must not duplicate messages or runs"); | |
| 3090 | }), | |
| 3091 | ); | |
| 3102 | 3092 | |
| 3103 | 3093 | it.effect("edits and removes queued runs", () => |
| 3104 | 3094 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts+44-39| ... | ... | @@ -221,19 +221,19 @@ function runFixtureProviderWithRegisteredHarness(input: { |
| 221 | 221 | } |
| 222 | 222 | |
| 223 | 223 | describe("orchestrator replay fixtures", () => { |
| 224 | for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { | |
| 225 | for (const provider of fixture.providers) { | |
| 226 | it.effect( | |
| 227 | `runs ${fixture.name}/${provider.driver} through OrchestratorV2 using deterministic replay`, | |
| 228 | () => | |
| 229 | runFixtureProviderWithRegisteredHarness({ | |
| 230 | fixtureName: fixture.name, | |
| 231 | buildInput: fixture.buildInput, | |
| 232 | driver: provider, | |
| 233 | }), | |
| 234 | ); | |
| 235 | } | |
| 236 | } | |
| 224 | it.effect.each( | |
| 225 | ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => | |
| 226 | fixture.providers.map( | |
| 227 | (provider) => [fixture.name, provider.driver, fixture, provider] as const, | |
| 228 | ), | |
| 229 | ), | |
| 230 | )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) => | |
| 231 | runFixtureProviderWithRegisteredHarness({ | |
| 232 | fixtureName: fixture.name, | |
| 233 | buildInput: fixture.buildInput, | |
| 234 | driver: provider, | |
| 235 | }), | |
| 236 | ); | |
| 237 | 237 | |
| 238 | 238 | const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( |
| 239 | 239 | (fixture) => fixture.name === "message_steering", |
| ... | ... | @@ -254,33 +254,38 @@ describe("orchestrator replay fixtures", () => { |
| 254 | 254 | // A later OpenCode may change an execution start's shape; the client then |
| 255 | 255 | // reads it as `unreadable.execution.started`. A subagent's turn and a |
| 256 | 256 | // background follow-up must still start from it. |
| 257 | for (const [fixtureName, label] of [ | |
| 258 | ["opencode2_subagent", "session.execution.started.2"], | |
| 259 | ["opencode2_background", "session.execution.started.3"], | |
| 260 | ] as const) { | |
| 261 | const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( | |
| 262 | (candidate) => candidate.name === fixtureName, | |
| 263 | ); | |
| 264 | const provider = fixture?.providers[0]; | |
| 265 | if (fixture === undefined || provider === undefined) continue; | |
| 266 | it.effect( | |
| 267 | `runs ${fixtureName} when ${label} is an execution start this build cannot decode`, | |
| 268 | () => | |
| 269 | runFixtureProviderWithRegisteredHarness({ | |
| 270 | fixtureName, | |
| 271 | buildInput: fixture.buildInput, | |
| 272 | driver: provider, | |
| 273 | transformTranscript: (transcript) => ({ | |
| 274 | ...transcript, | |
| 275 | entries: transcript.entries.map((entry) => | |
| 276 | entry.type === "emit_inbound" && entry.label === label | |
| 277 | ? undecodableEvent(entry) | |
| 278 | : entry, | |
| 279 | ), | |
| 280 | }), | |
| 257 | it.effect.each( | |
| 258 | ( | |
| 259 | [ | |
| 260 | ["opencode2_subagent", "session.execution.started.2"], | |
| 261 | ["opencode2_background", "session.execution.started.3"], | |
| 262 | ] as const | |
| 263 | ).flatMap(([fixtureName, label]) => { | |
| 264 | const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( | |
| 265 | (candidate) => candidate.name === fixtureName, | |
| 266 | ); | |
| 267 | const provider = fixture?.providers[0]; | |
| 268 | return fixture === undefined || provider === undefined | |
| 269 | ? [] | |
| 270 | : [[fixtureName, label, fixture, provider] as const]; | |
| 271 | }), | |
| 272 | )( | |
| 273 | "runs %s when %s is an execution start this build cannot decode", | |
| 274 | ([fixtureName, label, fixture, provider]) => | |
| 275 | runFixtureProviderWithRegisteredHarness({ | |
| 276 | fixtureName, | |
| 277 | buildInput: fixture.buildInput, | |
| 278 | driver: provider, | |
| 279 | transformTranscript: (transcript) => ({ | |
| 280 | ...transcript, | |
| 281 | entries: transcript.entries.map((entry) => | |
| 282 | entry.type === "emit_inbound" && entry.label === label | |
| 283 | ? undecodableEvent(entry) | |
| 284 | : entry, | |
| 285 | ), | |
| 281 | 286 | }), |
| 282 | ); | |
| 283 | } | |
| 287 | }), | |
| 288 | ); | |
| 284 | 289 | }); |
| 285 | 290 | |
| 286 | 291 | /** The same event with an envelope this build cannot decode, as a newer OpenCode may send. */ |
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+955-975| ... | ... | @@ -374,7 +374,7 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* ( |
| 374 | 374 | }); |
| 375 | 375 | |
| 376 | 376 | describe("orchestration v2 provider switching", () => { |
| 377 | for (const scenario of [ | |
| 377 | it.live.each([ | |
| 378 | 378 | "compact-native", |
| 379 | 379 | "compact-fallback", |
| 380 | 380 | "compact-legacy", |
| ... | ... | @@ -408,35 +408,496 @@ describe("orchestration v2 provider switching", () => { |
| 408 | 408 | "prior-images-replacement-native", |
| 409 | 409 | "prior-images-legacy-replacement-native", |
| 410 | 410 | "delivery-write-failure", |
| 411 | ] as const) { | |
| 412 | it.live(`preserves handoffs through ${scenario}`, () => | |
| 411 | ] as const)("preserves handoffs through %s", (scenario) => | |
| 412 | Effect.scoped( | |
| 413 | Effect.gen(function* () { | |
| 414 | const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); | |
| 415 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); | |
| 416 | const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]); | |
| 417 | const reasoningScenario = scenario.includes("reasoning-change"); | |
| 418 | const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage"); | |
| 419 | const modelScenario = | |
| 420 | scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; | |
| 421 | const failStartOnce = yield* Ref.make(false); | |
| 422 | const failResumeOnce = yield* Ref.make(false); | |
| 423 | const generation = yield* Ref.make(0); | |
| 424 | const priorImages = scenario.includes("prior-images"); | |
| 425 | const replaceNative = scenario.includes("replacement"); | |
| 426 | const capacityScenario = modelScenario || scenario.includes("reported-capacity"); | |
| 427 | const returning = | |
| 428 | scenario === "large-current-input" || scenario.includes("-change-") || priorImages; | |
| 429 | const targetSelection: ModelSelection = !modelScenario | |
| 430 | ? CLAUDE_MODEL_SELECTION | |
| 431 | : { | |
| 432 | ...CLAUDE_MODEL_SELECTION, | |
| 433 | ...(reasoningScenario | |
| 434 | ? { options: [{ id: "reasoningEffort", value: "low" }] } | |
| 435 | : scenario.includes("option-change") | |
| 436 | ? { options: [{ id: "contextWindow", value: "1m" }] } | |
| 437 | : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), | |
| 438 | }; | |
| 439 | const registry = ProviderAdapterRegistry.makeLayer([ | |
| 440 | makeTestAdapter({ | |
| 441 | instanceId: CODEX_MODEL_SELECTION.instanceId, | |
| 442 | driver: CODEX_DRIVER, | |
| 443 | capabilities: CodexProviderCapabilitiesV2, | |
| 444 | modelSelection: CODEX_MODEL_SELECTION, | |
| 445 | responseByRunOrdinal: { 1: "Original partial work" }, | |
| 446 | capturedTurns, | |
| 447 | }), | |
| 448 | makeTestAdapter({ | |
| 449 | instanceId: CLAUDE_MODEL_SELECTION.instanceId, | |
| 450 | driver: CLAUDE_DRIVER, | |
| 451 | capabilities: ClaudeProviderCapabilitiesV2, | |
| 452 | modelSelection: CLAUDE_MODEL_SELECTION, | |
| 453 | responseByRunOrdinal: {}, | |
| 454 | capturedTurns, | |
| 455 | ...(scenario.includes("retry") || scenario.includes("unsent") ? { failStartOnce } : {}), | |
| 456 | ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}), | |
| 457 | ...(scenario.includes("reported-capacity") | |
| 458 | ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } | |
| 459 | : {}), | |
| 460 | ...(turnUsageScenario | |
| 461 | ? { | |
| 462 | canReuseContextUsage: canReuseCodexContextUsage, | |
| 463 | tokenUsageByRunOrdinal: { | |
| 464 | 2: { | |
| 465 | usedTokens: replaceNative ? 30_000 : 37_321, | |
| 466 | maxTokens: replaceNative | |
| 467 | ? scenario.includes("small") | |
| 468 | ? 32_000 | |
| 469 | : 64_000 | |
| 470 | : 258_400, | |
| 471 | }, | |
| 472 | }, | |
| 473 | } | |
| 474 | : modelScenario | |
| 475 | ? { | |
| 476 | getModelContextWindow: (selection: ModelSelection) => | |
| 477 | selection.model.endsWith("-large") || | |
| 478 | selection.options?.some( | |
| 479 | (option) => option.id === "contextWindow" && option.value === "1m", | |
| 480 | ) | |
| 481 | ? 1_000_000 | |
| 482 | : 32_000, | |
| 483 | } | |
| 484 | : scenario === "large-current-input" || priorImages | |
| 485 | ? { getModelContextWindow: () => 32_000 } | |
| 486 | : {}), | |
| 487 | ...(scenario.endsWith("-native") || scenario === "large-current-input" | |
| 488 | ? { injectedHistory } | |
| 489 | : {}), | |
| 490 | }), | |
| 491 | ]); | |
| 492 | yield* Effect.gen(function* () { | |
| 493 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 494 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 495 | const eventSink = yield* EventSink.EventSinkV2; | |
| 496 | const screenshot: ChatAttachment = { | |
| 497 | type: "image", | |
| 498 | id: "screenshot", | |
| 499 | name: "screenshot.png", | |
| 500 | mimeType: "image/png", | |
| 501 | sizeBytes: 100_000, | |
| 502 | }; | |
| 503 | const screenshots = Array.from( | |
| 504 | { | |
| 505 | length: | |
| 506 | scenario.includes("eight") || capacityScenario | |
| 507 | ? 8 | |
| 508 | : scenario.includes("pair") | |
| 509 | ? 2 | |
| 510 | : 1, | |
| 511 | }, | |
| 512 | (_, index) => ({ ...screenshot, id: `screenshot-${index}` }), | |
| 513 | ); | |
| 514 | const targetOrdinal = returning ? 4 : 2; | |
| 515 | const dispatch = (ordinal: number, text: string, selection: ModelSelection) => | |
| 516 | orchestrator.dispatch({ | |
| 517 | type: "message.dispatch", | |
| 518 | commandId: CommandId.make(`regression:${ordinal}`), | |
| 519 | threadId, | |
| 520 | messageId: MessageId.make(`regression:${ordinal}`), | |
| 521 | createdBy: "user", | |
| 522 | creationSource: "web", | |
| 523 | text, | |
| 524 | attachments: | |
| 525 | turnUsageScenario && ordinal === 2 | |
| 526 | ? Array.from({ length: 8 }, (_, index) => ({ | |
| 527 | ...screenshot, | |
| 528 | id: `prior-${index}`, | |
| 529 | })) | |
| 530 | : turnUsageScenario && ordinal >= targetOrdinal | |
| 531 | ? [screenshot, { ...screenshot, id: "current-2" }] | |
| 532 | : scenario.startsWith("screenshot") && ordinal >= targetOrdinal | |
| 533 | ? screenshots | |
| 534 | : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) | |
| 535 | ? [screenshot] | |
| 536 | : [], | |
| 537 | modelSelection: selection, | |
| 538 | dispatchMode: { type: "start_immediately" }, | |
| 539 | }); | |
| 540 | const wait = (ordinal: number) => | |
| 541 | orchestrator.streamStoredEvents.pipe( | |
| 542 | Stream.filter( | |
| 543 | ({ event }) => | |
| 544 | event.type === "run.updated" && | |
| 545 | event.payload.ordinal === ordinal && | |
| 546 | (event.payload.status === "completed" || event.payload.status === "failed"), | |
| 547 | ), | |
| 548 | Stream.runHead, | |
| 549 | Effect.andThen(worker.drain()), | |
| 550 | ); | |
| 551 | yield* orchestrator.dispatch({ | |
| 552 | type: "thread.create", | |
| 553 | commandId: CommandId.make("regression:create"), | |
| 554 | threadId, | |
| 555 | projectId, | |
| 556 | createdBy: "user", | |
| 557 | creationSource: "web", | |
| 558 | title: "Handoff regression", | |
| 559 | modelSelection: CODEX_MODEL_SELECTION, | |
| 560 | runtimeMode: "full-access", | |
| 561 | interactionMode: "default", | |
| 562 | branch: null, | |
| 563 | worktreePath: null, | |
| 564 | }); | |
| 565 | yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); | |
| 566 | yield* wait(1); | |
| 567 | const write = eventSink.write; | |
| 568 | const spy = | |
| 569 | scenario === "delivery-write-failure" | |
| 570 | ? vi.spyOn(eventSink, "write").mockImplementation((input) => | |
| 571 | input.events.some( | |
| 572 | (event) => | |
| 573 | event.type === "context-handoff.updated" && | |
| 574 | event.payload.delivery?.status === "inline", | |
| 575 | ) | |
| 576 | ? Effect.fail( | |
| 577 | new EventSink.EventSinkWriteError({ | |
| 578 | eventCount: input.events.length, | |
| 579 | cause: "bookkeeping unavailable", | |
| 580 | }), | |
| 581 | ) | |
| 582 | : write(input), | |
| 583 | ) | |
| 584 | : undefined; | |
| 585 | yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); | |
| 586 | const current = scenario.startsWith("compact") | |
| 587 | ? "/compact" | |
| 588 | : capacityScenario && !turnUsageScenario | |
| 589 | ? "x".repeat(70_000) | |
| 590 | : scenario === "large-current-input" | |
| 591 | ? "x".repeat(9_000) | |
| 592 | : "Continue work"; | |
| 593 | // First establish the returning native thread: the current request must | |
| 594 | // not be charged as existing context on the subsequent handoff. | |
| 595 | if (returning) { | |
| 596 | if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); | |
| 597 | yield* dispatch( | |
| 598 | 2, | |
| 599 | turnUsageScenario ? "x".repeat(27_460) : "Establish target", | |
| 600 | CLAUDE_MODEL_SELECTION, | |
| 601 | ); | |
| 602 | yield* wait(2); | |
| 603 | if (modelScenario && !turnUsageScenario) { | |
| 604 | const target = (yield* orchestrator.getThreadProjection( | |
| 605 | threadId, | |
| 606 | )).providerThreads.find( | |
| 607 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 608 | )!; | |
| 609 | yield* eventSink.write({ | |
| 610 | events: [ | |
| 611 | { | |
| 612 | id: EventId.make("old-model-usage"), | |
| 613 | type: "provider-thread.updated", | |
| 614 | threadId, | |
| 615 | occurredAt: yield* DateTime.now, | |
| 616 | payload: { | |
| 617 | ...target, | |
| 618 | contextUsage: { | |
| 619 | usedTokens: 30_000, | |
| 620 | maxTokens: 32_000, | |
| 621 | autoCompactThreshold: 31_000, | |
| 622 | }, | |
| 623 | }, | |
| 624 | }, | |
| 625 | ], | |
| 626 | }); | |
| 627 | } | |
| 628 | if (scenario.includes("legacy-replacement")) { | |
| 629 | const existing = yield* orchestrator.getThreadProjection(threadId); | |
| 630 | yield* eventSink.write({ | |
| 631 | events: existing.attempts.map( | |
| 632 | ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ | |
| 633 | id: EventId.make(`legacy-attempt:${index}`), | |
| 634 | type: "run-attempt.updated" as const, | |
| 635 | threadId, | |
| 636 | occurredAt: existing.thread.createdAt, | |
| 637 | payload: legacy, | |
| 638 | }), | |
| 639 | ), | |
| 640 | }); | |
| 641 | } | |
| 642 | if (scenario.includes("telemetry")) { | |
| 643 | const target = (yield* orchestrator.getThreadProjection( | |
| 644 | threadId, | |
| 645 | )).providerThreads.find( | |
| 646 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 647 | )!; | |
| 648 | yield* eventSink.write({ | |
| 649 | events: [ | |
| 650 | { | |
| 651 | id: EventId.make("compacted-context-usage"), | |
| 652 | type: "provider-thread.updated", | |
| 653 | threadId, | |
| 654 | occurredAt: yield* DateTime.now, | |
| 655 | payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } }, | |
| 656 | }, | |
| 657 | ], | |
| 658 | }); | |
| 659 | } | |
| 660 | yield* dispatch( | |
| 661 | 3, | |
| 662 | priorImages && !replaceNative | |
| 663 | ? "New source constraint " + "q".repeat(9_000) | |
| 664 | : "New source constraint", | |
| 665 | CODEX_MODEL_SELECTION, | |
| 666 | ); | |
| 667 | yield* wait(3); | |
| 668 | } | |
| 669 | if (replaceNative) { | |
| 670 | const target = (yield* orchestrator.getThreadProjection(threadId)).providerThreads.find( | |
| 671 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 672 | )!; | |
| 673 | yield* orchestrator.dispatch({ | |
| 674 | type: "provider-session.detach", | |
| 675 | commandId: CommandId.make("detach-for-native-replacement"), | |
| 676 | threadId, | |
| 677 | providerSessionId: target.providerSessionId!, | |
| 678 | }); | |
| 679 | yield* worker.drain(); | |
| 680 | yield* Ref.set(failResumeOnce, true); | |
| 681 | } | |
| 682 | if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); | |
| 683 | yield* dispatch(targetOrdinal, current, targetSelection); | |
| 684 | yield* wait(targetOrdinal); | |
| 685 | if (scenario.includes("retry")) { | |
| 686 | const failed = yield* orchestrator.getThreadProjection(threadId); | |
| 687 | assert.equal(failed.runs.at(-1)?.status, "failed"); | |
| 688 | const failedUsage = failed.providerThreads.find( | |
| 689 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 690 | )!.contextUsage; | |
| 691 | if (reasoningScenario || scenario.includes("turn-usage")) { | |
| 692 | assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 }); | |
| 693 | } else { | |
| 694 | assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 }); | |
| 695 | } | |
| 696 | if (reasoningScenario) { | |
| 697 | const target = failed.providerThreads.find( | |
| 698 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 699 | )!; | |
| 700 | yield* orchestrator.dispatch({ | |
| 701 | type: "provider-session.detach", | |
| 702 | commandId: CommandId.make("detach-before-reasoning-retry"), | |
| 703 | threadId, | |
| 704 | providerSessionId: target.providerSessionId!, | |
| 705 | }); | |
| 706 | yield* worker.drain(); | |
| 707 | } | |
| 708 | yield* dispatch(targetOrdinal + 1, current, targetSelection); | |
| 709 | yield* wait(targetOrdinal + 1); | |
| 710 | } | |
| 711 | if (reasoningScenario && replaceNative) { | |
| 712 | const replaced = yield* orchestrator.getThreadProjection(threadId); | |
| 713 | assert.equal( | |
| 714 | replaced.runs.at(-1)?.status, | |
| 715 | scenario.includes("small") ? "failed" : "completed", | |
| 716 | ); | |
| 717 | const target = replaced.providerThreads.find( | |
| 718 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 719 | )!; | |
| 720 | assert.isNull(target.contextUsage); | |
| 721 | assert.equal(yield* Ref.get(generation), 2); | |
| 722 | assert.equal( | |
| 723 | (yield* Ref.get(capturedTurns)).at(-1)!.driver, | |
| 724 | scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER, | |
| 725 | ); | |
| 726 | return; | |
| 727 | } | |
| 728 | if (replaceNative) { | |
| 729 | if (scenario.includes("legacy-replacement")) { | |
| 730 | const recovered = yield* orchestrator.getThreadProjection(threadId); | |
| 731 | const handoff = recovered.contextHandoffs.at(-1)!; | |
| 732 | const { history: _history, ...legacyHandoff } = handoff; | |
| 733 | yield* eventSink.write({ | |
| 734 | events: [ | |
| 735 | { | |
| 736 | id: EventId.make("legacy-replacement-handoff"), | |
| 737 | type: "context-handoff.updated", | |
| 738 | threadId, | |
| 739 | occurredAt: yield* DateTime.now, | |
| 740 | payload: { | |
| 741 | ...legacyHandoff, | |
| 742 | delivery: { | |
| 743 | nativeThreadId: handoff.delivery!.nativeThreadId, | |
| 744 | status: handoff.delivery!.status, | |
| 745 | itemIds: [], | |
| 746 | }, | |
| 747 | }, | |
| 748 | }, | |
| 749 | ], | |
| 750 | }); | |
| 751 | } | |
| 752 | yield* dispatch( | |
| 753 | targetOrdinal + 1, | |
| 754 | "Continue after native replacement", | |
| 755 | CLAUDE_MODEL_SELECTION, | |
| 756 | ); | |
| 757 | yield* wait(targetOrdinal + 1); | |
| 758 | yield* dispatch( | |
| 759 | targetOrdinal + 2, | |
| 760 | "Another source constraint " + "q".repeat(9_000), | |
| 761 | CODEX_MODEL_SELECTION, | |
| 762 | ); | |
| 763 | yield* wait(targetOrdinal + 2); | |
| 764 | yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION); | |
| 765 | yield* wait(targetOrdinal + 3); | |
| 766 | } | |
| 767 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 768 | assert.equal(projection.runs.at(-1)?.status, "completed"); | |
| 769 | if (priorImages) { | |
| 770 | const handoff = projection.contextHandoffs.at(-1)!; | |
| 771 | const context = scenario.endsWith("-native") | |
| 772 | ? yield* encodeJson(yield* Ref.get(injectedHistory)) | |
| 773 | : (yield* Ref.get(capturedTurns)).at(-1)!.text; | |
| 774 | const shouldFit = | |
| 775 | turnUsageScenario || | |
| 776 | scenario.startsWith("imported") || | |
| 777 | scenario.includes("telemetry") || | |
| 778 | scenario.includes("unsent") || | |
| 779 | replaceNative; | |
| 780 | const sourceText = | |
| 781 | `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000); | |
| 782 | const sourceItem = projection.turnItems.find( | |
| 783 | (item) => item.type === "user_message" && item.text === sourceText, | |
| 784 | )!; | |
| 785 | assert.isDefined(sourceItem); | |
| 786 | if (shouldFit) { | |
| 787 | assert.include(context, sourceText); | |
| 788 | assert.include( | |
| 789 | projection.contextHandoffs | |
| 790 | .filter( | |
| 791 | (record) => | |
| 792 | record.targetRunId === | |
| 793 | (scenario.includes("retry") | |
| 794 | ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id | |
| 795 | : projection.runs.at(-1)!.id), | |
| 796 | ) | |
| 797 | .flatMap((record) => record.delivery?.itemIds ?? []), | |
| 798 | sourceItem.id, | |
| 799 | ); | |
| 800 | } else { | |
| 801 | assert.notInclude(context, "q".repeat(9_000)); | |
| 802 | assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0); | |
| 803 | } | |
| 804 | const latestRun = projection.runs.at(-1)!; | |
| 805 | const latestAttempt = projection.attempts.find( | |
| 806 | (attempt) => attempt.id === latestRun.activeAttemptId, | |
| 807 | )!; | |
| 808 | const target = projection.providerThreads.find( | |
| 809 | (thread) => thread.id === latestAttempt.providerThreadId, | |
| 810 | )!; | |
| 811 | assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); | |
| 812 | if (turnUsageScenario) { | |
| 813 | if (replaceNative) assert.isNull(target.contextUsage); | |
| 814 | else | |
| 815 | assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); | |
| 816 | assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); | |
| 817 | } | |
| 818 | if (replaceNative) assert.equal(yield* Ref.get(generation), 2); | |
| 819 | return; | |
| 820 | } | |
| 821 | const handoff = projection.contextHandoffs.at(-1)!; | |
| 822 | if (scenario.startsWith("screenshot")) { | |
| 823 | assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); | |
| 824 | } | |
| 825 | if (scenario === "compact-fallback" || scenario === "compact-legacy") { | |
| 826 | if (scenario === "compact-legacy") { | |
| 827 | const { history: _history, ...legacyHandoff } = handoff; | |
| 828 | yield* eventSink.write({ | |
| 829 | events: [ | |
| 830 | { | |
| 831 | id: EventId.make("legacy-handoff-shape"), | |
| 832 | type: "context-handoff.updated", | |
| 833 | threadId, | |
| 834 | runId: handoff.targetRunId, | |
| 835 | occurredAt: yield* DateTime.now, | |
| 836 | payload: legacyHandoff, | |
| 837 | }, | |
| 838 | ], | |
| 839 | }); | |
| 840 | } | |
| 841 | assert.isUndefined(handoff.delivery); | |
| 842 | yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); | |
| 843 | yield* wait(3); | |
| 844 | assert.include( | |
| 845 | (yield* Ref.get(capturedTurns)).at(-1)!.text, | |
| 846 | "Original request with constraints", | |
| 847 | ); | |
| 848 | assert.equal( | |
| 849 | (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery | |
| 850 | ?.status, | |
| 851 | "inline", | |
| 852 | ); | |
| 853 | } else if ( | |
| 854 | scenario === "delivery-write-failure" || | |
| 855 | (scenario.startsWith("screenshot") && scenario.endsWith("fallback")) | |
| 856 | ) { | |
| 857 | assert.equal( | |
| 858 | handoff.delivery?.status, | |
| 859 | scenario.startsWith("screenshot") ? "inline" : "pending", | |
| 860 | ); | |
| 861 | assert.include( | |
| 862 | (yield* Ref.get(capturedTurns)).at(-1)!.text, | |
| 863 | "Original request with constraints", | |
| 864 | ); | |
| 865 | } else { | |
| 866 | assert.equal(handoff.delivery?.status, "injected"); | |
| 867 | assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current); | |
| 868 | assert.include( | |
| 869 | yield* encodeJson(yield* Ref.get(injectedHistory)), | |
| 870 | "Original request with constraints", | |
| 871 | ); | |
| 872 | } | |
| 873 | }).pipe( | |
| 874 | Effect.provide( | |
| 875 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 876 | { | |
| 877 | name: `handoff-${scenario}`, | |
| 878 | runtimePolicyOverride: { | |
| 879 | cwd, | |
| 880 | approvalPolicy: "never", | |
| 881 | sandboxPolicy: { type: "readOnly" }, | |
| 882 | }, | |
| 883 | }, | |
| 884 | registry, | |
| 885 | ), | |
| 886 | ), | |
| 887 | ); | |
| 888 | }), | |
| 889 | ), | |
| 890 | ); | |
| 891 | it.live.each(["turn-start", "injection", "large-missed-request"] as const)( | |
| 892 | "recovers %s failure without duplicating history in the same native thread", | |
| 893 | (failure) => | |
| 413 | 894 | Effect.scoped( |
| 414 | 895 | Effect.gen(function* () { |
| 415 | const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); | |
| 896 | const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`); | |
| 416 | 897 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); |
| 417 | 898 | const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]); |
| 418 | const reasoningScenario = scenario.includes("reasoning-change"); | |
| 419 | const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage"); | |
| 420 | const modelScenario = | |
| 421 | scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; | |
| 422 | const failStartOnce = yield* Ref.make(false); | |
| 423 | const failResumeOnce = yield* Ref.make(false); | |
| 899 | const failOnce = yield* Ref.make(true); | |
| 424 | 900 | const generation = yield* Ref.make(0); |
| 425 | const priorImages = scenario.includes("prior-images"); | |
| 426 | const replaceNative = scenario.includes("replacement"); | |
| 427 | const capacityScenario = modelScenario || scenario.includes("reported-capacity"); | |
| 428 | const returning = | |
| 429 | scenario === "large-current-input" || scenario.includes("-change-") || priorImages; | |
| 430 | const targetSelection: ModelSelection = !modelScenario | |
| 431 | ? CLAUDE_MODEL_SELECTION | |
| 432 | : { | |
| 433 | ...CLAUDE_MODEL_SELECTION, | |
| 434 | ...(reasoningScenario | |
| 435 | ? { options: [{ id: "reasoningEffort", value: "low" }] } | |
| 436 | : scenario.includes("option-change") | |
| 437 | ? { options: [{ id: "contextWindow", value: "1m" }] } | |
| 438 | : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), | |
| 439 | }; | |
| 440 | 901 | const registry = ProviderAdapterRegistry.makeLayer([ |
| 441 | 902 | makeTestAdapter({ |
| 442 | 903 | instanceId: CODEX_MODEL_SELECTION.instanceId, |
| ... | ... | @@ -453,94 +914,31 @@ describe("orchestration v2 provider switching", () => { |
| 453 | 914 | modelSelection: CLAUDE_MODEL_SELECTION, |
| 454 | 915 | responseByRunOrdinal: {}, |
| 455 | 916 | capturedTurns, |
| 456 | ...(scenario.includes("retry") || scenario.includes("unsent") | |
| 457 | ? { failStartOnce } | |
| 458 | : {}), | |
| 459 | ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}), | |
| 460 | ...(scenario.includes("reported-capacity") | |
| 461 | ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } | |
| 462 | : {}), | |
| 463 | ...(turnUsageScenario | |
| 464 | ? { | |
| 465 | canReuseContextUsage: canReuseCodexContextUsage, | |
| 466 | tokenUsageByRunOrdinal: { | |
| 467 | 2: { | |
| 468 | usedTokens: replaceNative ? 30_000 : 37_321, | |
| 469 | maxTokens: replaceNative | |
| 470 | ? scenario.includes("small") | |
| 471 | ? 32_000 | |
| 472 | : 64_000 | |
| 473 | : 258_400, | |
| 474 | }, | |
| 475 | }, | |
| 476 | } | |
| 477 | : modelScenario | |
| 478 | ? { | |
| 479 | getModelContextWindow: (selection: ModelSelection) => | |
| 480 | selection.model.endsWith("-large") || | |
| 481 | selection.options?.some( | |
| 482 | (option) => option.id === "contextWindow" && option.value === "1m", | |
| 483 | ) | |
| 484 | ? 1_000_000 | |
| 485 | : 32_000, | |
| 486 | } | |
| 487 | : scenario === "large-current-input" || priorImages | |
| 488 | ? { getModelContextWindow: () => 32_000 } | |
| 489 | : {}), | |
| 490 | ...(scenario.endsWith("-native") || scenario === "large-current-input" | |
| 491 | ? { injectedHistory } | |
| 492 | : {}), | |
| 917 | injectedHistory, | |
| 918 | nativeThreadGeneration: generation, | |
| 919 | getModelContextWindow: () => 32_000, | |
| 920 | ...(failure !== "injection" | |
| 921 | ? { failStartOnce: failOnce } | |
| 922 | : { failInjectionOnce: failOnce }), | |
| 493 | 923 | }), |
| 494 | 924 | ]); |
| 495 | 925 | yield* Effect.gen(function* () { |
| 496 | 926 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 497 | 927 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; |
| 498 | const eventSink = yield* EventSink.EventSinkV2; | |
| 499 | const screenshot: ChatAttachment = { | |
| 500 | type: "image", | |
| 501 | id: "screenshot", | |
| 502 | name: "screenshot.png", | |
| 503 | mimeType: "image/png", | |
| 504 | sizeBytes: 100_000, | |
| 505 | }; | |
| 506 | const screenshots = Array.from( | |
| 507 | { | |
| 508 | length: | |
| 509 | scenario.includes("eight") || capacityScenario | |
| 510 | ? 8 | |
| 511 | : scenario.includes("pair") | |
| 512 | ? 2 | |
| 513 | : 1, | |
| 514 | }, | |
| 515 | (_, index) => ({ ...screenshot, id: `screenshot-${index}` }), | |
| 516 | ); | |
| 517 | const targetOrdinal = returning ? 4 : 2; | |
| 518 | 928 | const dispatch = (ordinal: number, text: string, selection: ModelSelection) => |
| 519 | 929 | orchestrator.dispatch({ |
| 520 | 930 | type: "message.dispatch", |
| 521 | commandId: CommandId.make(`regression:${ordinal}`), | |
| 931 | commandId: CommandId.make(`retry:${ordinal}`), | |
| 522 | 932 | threadId, |
| 523 | messageId: MessageId.make(`regression:${ordinal}`), | |
| 933 | messageId: MessageId.make(`retry:${ordinal}`), | |
| 524 | 934 | createdBy: "user", |
| 525 | 935 | creationSource: "web", |
| 526 | 936 | text, |
| 527 | attachments: | |
| 528 | turnUsageScenario && ordinal === 2 | |
| 529 | ? Array.from({ length: 8 }, (_, index) => ({ | |
| 530 | ...screenshot, | |
| 531 | id: `prior-${index}`, | |
| 532 | })) | |
| 533 | : turnUsageScenario && ordinal >= targetOrdinal | |
| 534 | ? [screenshot, { ...screenshot, id: "current-2" }] | |
| 535 | : scenario.startsWith("screenshot") && ordinal >= targetOrdinal | |
| 536 | ? screenshots | |
| 537 | : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) | |
| 538 | ? [screenshot] | |
| 539 | : [], | |
| 937 | attachments: [], | |
| 540 | 938 | modelSelection: selection, |
| 541 | 939 | dispatchMode: { type: "start_immediately" }, |
| 542 | 940 | }); |
| 543 | const wait = (ordinal: number) => | |
| 941 | const wait = (ordinal: number, status: "failed" | "completed") => | |
| 544 | 942 | orchestrator.streamStoredEvents.pipe( |
| 545 | 943 | Stream.filter( |
| 546 | 944 | ({ event }) => |
| ... | ... | @@ -550,15 +948,23 @@ describe("orchestration v2 provider switching", () => { |
| 550 | 948 | ), |
| 551 | 949 | Stream.runHead, |
| 552 | 950 | Effect.andThen(worker.drain()), |
| 951 | Effect.andThen( | |
| 952 | Effect.gen(function* () { | |
| 953 | assert.equal( | |
| 954 | (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, | |
| 955 | status, | |
| 956 | ); | |
| 957 | }), | |
| 958 | ), | |
| 553 | 959 | ); |
| 554 | 960 | yield* orchestrator.dispatch({ |
| 555 | 961 | type: "thread.create", |
| 556 | commandId: CommandId.make("regression:create"), | |
| 962 | commandId: CommandId.make("retry:create"), | |
| 557 | 963 | threadId, |
| 558 | 964 | projectId, |
| 559 | 965 | createdBy: "user", |
| 560 | 966 | creationSource: "web", |
| 561 | title: "Handoff regression", | |
| 967 | title: "Handoff retry", | |
| 562 | 968 | modelSelection: CODEX_MODEL_SELECTION, |
| 563 | 969 | runtimeMode: "full-access", |
| 564 | 970 | interactionMode: "default", |
| ... | ... | @@ -566,320 +972,71 @@ describe("orchestration v2 provider switching", () => { |
| 566 | 972 | worktreePath: null, |
| 567 | 973 | }); |
| 568 | 974 | yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); |
| 569 | yield* wait(1); | |
| 570 | const write = eventSink.write; | |
| 571 | const spy = | |
| 572 | scenario === "delivery-write-failure" | |
| 573 | ? vi.spyOn(eventSink, "write").mockImplementation((input) => | |
| 574 | input.events.some( | |
| 575 | (event) => | |
| 576 | event.type === "context-handoff.updated" && | |
| 577 | event.payload.delivery?.status === "inline", | |
| 578 | ) | |
| 579 | ? Effect.fail( | |
| 580 | new EventSink.EventSinkWriteError({ | |
| 581 | eventCount: input.events.length, | |
| 582 | cause: "bookkeeping unavailable", | |
| 583 | }), | |
| 584 | ) | |
| 585 | : write(input), | |
| 586 | ) | |
| 587 | : undefined; | |
| 588 | yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); | |
| 589 | const current = scenario.startsWith("compact") | |
| 590 | ? "/compact" | |
| 591 | : capacityScenario && !turnUsageScenario | |
| 592 | ? "x".repeat(70_000) | |
| 593 | : scenario === "large-current-input" | |
| 594 | ? "x".repeat(9_000) | |
| 595 | : "Continue work"; | |
| 596 | // First establish the returning native thread: the current request must | |
| 597 | // not be charged as existing context on the subsequent handoff. | |
| 598 | if (returning) { | |
| 599 | if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); | |
| 600 | yield* dispatch( | |
| 601 | 2, | |
| 602 | turnUsageScenario ? "x".repeat(27_460) : "Establish target", | |
| 603 | CLAUDE_MODEL_SELECTION, | |
| 975 | yield* wait(1, "completed"); | |
| 976 | yield* dispatch( | |
| 977 | 2, | |
| 978 | failure === "large-missed-request" ? "x".repeat(7_000) : "First target request", | |
| 979 | CLAUDE_MODEL_SELECTION, | |
| 980 | ); | |
| 981 | yield* wait(2, "failed"); | |
| 982 | const failed = yield* orchestrator.getThreadProjection(threadId); | |
| 983 | const failedHandoff = failed.contextHandoffs.at(-1)!; | |
| 984 | assert.equal( | |
| 985 | failedHandoff.delivery?.status, | |
| 986 | failure !== "injection" ? "injected" : "pending", | |
| 987 | ); | |
| 988 | const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; | |
| 989 | const retryText = | |
| 990 | failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request"; | |
| 991 | yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION); | |
| 992 | yield* wait(3, "completed"); | |
| 993 | const retried = yield* orchestrator.getThreadProjection(threadId); | |
| 994 | const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; | |
| 995 | assert.equal(lastTurn.text, retryText); | |
| 996 | if (failure !== "injection") { | |
| 997 | const delta = yield* encodeJson( | |
| 998 | (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), | |
| 604 | 999 | ); |
| 605 | yield* wait(2); | |
| 606 | if (modelScenario && !turnUsageScenario) { | |
| 607 | const target = (yield* orchestrator.getThreadProjection( | |
| 608 | threadId, | |
| 609 | )).providerThreads.find( | |
| 610 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 1000 | if (failure === "large-missed-request") { | |
| 1001 | assert.include(delta, "omitted 1 items"); | |
| 1002 | const missedRequest = retried.turnItems.find( | |
| 1003 | (item) => item.type === "user_message" && item.text === "x".repeat(7_000), | |
| 611 | 1004 | )!; |
| 612 | yield* eventSink.write({ | |
| 613 | events: [ | |
| 614 | { | |
| 615 | id: EventId.make("old-model-usage"), | |
| 616 | type: "provider-thread.updated", | |
| 617 | threadId, | |
| 618 | occurredAt: yield* DateTime.now, | |
| 619 | payload: { | |
| 620 | ...target, | |
| 621 | contextUsage: { | |
| 622 | usedTokens: 30_000, | |
| 623 | maxTokens: 32_000, | |
| 624 | autoCompactThreshold: 31_000, | |
| 625 | }, | |
| 626 | }, | |
| 627 | }, | |
| 628 | ], | |
| 629 | }); | |
| 630 | } | |
| 631 | if (scenario.includes("legacy-replacement")) { | |
| 632 | const existing = yield* orchestrator.getThreadProjection(threadId); | |
| 633 | yield* eventSink.write({ | |
| 634 | events: existing.attempts.map( | |
| 635 | ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ | |
| 636 | id: EventId.make(`legacy-attempt:${index}`), | |
| 637 | type: "run-attempt.updated" as const, | |
| 638 | threadId, | |
| 639 | occurredAt: existing.thread.createdAt, | |
| 640 | payload: legacy, | |
| 641 | }), | |
| 642 | ), | |
| 643 | }); | |
| 644 | } | |
| 645 | if (scenario.includes("telemetry")) { | |
| 646 | const target = (yield* orchestrator.getThreadProjection( | |
| 647 | threadId, | |
| 648 | )).providerThreads.find( | |
| 649 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 650 | )!; | |
| 651 | yield* eventSink.write({ | |
| 652 | events: [ | |
| 653 | { | |
| 654 | id: EventId.make("compacted-context-usage"), | |
| 655 | type: "provider-thread.updated", | |
| 656 | threadId, | |
| 657 | occurredAt: yield* DateTime.now, | |
| 658 | payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } }, | |
| 659 | }, | |
| 660 | ], | |
| 661 | }); | |
| 662 | } | |
| 663 | yield* dispatch( | |
| 664 | 3, | |
| 665 | priorImages && !replaceNative | |
| 666 | ? "New source constraint " + "q".repeat(9_000) | |
| 667 | : "New source constraint", | |
| 668 | CODEX_MODEL_SELECTION, | |
| 669 | ); | |
| 670 | yield* wait(3); | |
| 671 | } | |
| 672 | if (replaceNative) { | |
| 673 | const target = (yield* orchestrator.getThreadProjection( | |
| 674 | threadId, | |
| 675 | )).providerThreads.find( | |
| 676 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 677 | )!; | |
| 678 | yield* orchestrator.dispatch({ | |
| 679 | type: "provider-session.detach", | |
| 680 | commandId: CommandId.make("detach-for-native-replacement"), | |
| 681 | threadId, | |
| 682 | providerSessionId: target.providerSessionId!, | |
| 683 | }); | |
| 684 | yield* worker.drain(); | |
| 685 | yield* Ref.set(failResumeOnce, true); | |
| 686 | } | |
| 687 | if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); | |
| 688 | yield* dispatch(targetOrdinal, current, targetSelection); | |
| 689 | yield* wait(targetOrdinal); | |
| 690 | if (scenario.includes("retry")) { | |
| 691 | const failed = yield* orchestrator.getThreadProjection(threadId); | |
| 692 | assert.equal(failed.runs.at(-1)?.status, "failed"); | |
| 693 | const failedUsage = failed.providerThreads.find( | |
| 694 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 695 | )!.contextUsage; | |
| 696 | if (reasoningScenario || scenario.includes("turn-usage")) { | |
| 697 | assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 }); | |
| 698 | } else { | |
| 699 | assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 }); | |
| 700 | } | |
| 701 | if (reasoningScenario) { | |
| 702 | const target = failed.providerThreads.find( | |
| 703 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 704 | )!; | |
| 705 | yield* orchestrator.dispatch({ | |
| 706 | type: "provider-session.detach", | |
| 707 | commandId: CommandId.make("detach-before-reasoning-retry"), | |
| 708 | threadId, | |
| 709 | providerSessionId: target.providerSessionId!, | |
| 710 | }); | |
| 711 | yield* worker.drain(); | |
| 712 | } | |
| 713 | yield* dispatch(targetOrdinal + 1, current, targetSelection); | |
| 714 | yield* wait(targetOrdinal + 1); | |
| 715 | } | |
| 716 | if (reasoningScenario && replaceNative) { | |
| 717 | const replaced = yield* orchestrator.getThreadProjection(threadId); | |
| 718 | assert.equal( | |
| 719 | replaced.runs.at(-1)?.status, | |
| 720 | scenario.includes("small") ? "failed" : "completed", | |
| 721 | ); | |
| 722 | const target = replaced.providerThreads.find( | |
| 723 | (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, | |
| 724 | )!; | |
| 725 | assert.isNull(target.contextUsage); | |
| 1005 | const delivery = retried.contextHandoffs.at(-1)!.delivery!; | |
| 1006 | assert.include(delivery.omittedItemIds ?? [], missedRequest.id); | |
| 1007 | assert.notInclude(delivery.itemIds, missedRequest.id); | |
| 1008 | } else assert.include(delta, "First target request"); | |
| 1009 | assert.notInclude(delta, "Original request with constraints"); | |
| 1010 | assert.notInclude(delta, "Original partial work"); | |
| 1011 | assert.equal(yield* Ref.get(generation), 1); | |
| 1012 | } else { | |
| 726 | 1013 | assert.equal(yield* Ref.get(generation), 2); |
| 727 | assert.equal( | |
| 728 | (yield* Ref.get(capturedTurns)).at(-1)!.driver, | |
| 729 | scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER, | |
| 730 | ); | |
| 731 | return; | |
| 732 | } | |
| 733 | if (replaceNative) { | |
| 734 | if (scenario.includes("legacy-replacement")) { | |
| 735 | const recovered = yield* orchestrator.getThreadProjection(threadId); | |
| 736 | const handoff = recovered.contextHandoffs.at(-1)!; | |
| 737 | const { history: _history, ...legacyHandoff } = handoff; | |
| 738 | yield* eventSink.write({ | |
| 739 | events: [ | |
| 740 | { | |
| 741 | id: EventId.make("legacy-replacement-handoff"), | |
| 742 | type: "context-handoff.updated", | |
| 743 | threadId, | |
| 744 | occurredAt: yield* DateTime.now, | |
| 745 | payload: { | |
| 746 | ...legacyHandoff, | |
| 747 | delivery: { | |
| 748 | nativeThreadId: handoff.delivery!.nativeThreadId, | |
| 749 | status: handoff.delivery!.status, | |
| 750 | itemIds: [], | |
| 751 | }, | |
| 752 | }, | |
| 753 | }, | |
| 754 | ], | |
| 755 | }); | |
| 756 | } | |
| 757 | yield* dispatch( | |
| 758 | targetOrdinal + 1, | |
| 759 | "Continue after native replacement", | |
| 760 | CLAUDE_MODEL_SELECTION, | |
| 761 | ); | |
| 762 | yield* wait(targetOrdinal + 1); | |
| 763 | yield* dispatch( | |
| 764 | targetOrdinal + 2, | |
| 765 | "Another source constraint " + "q".repeat(9_000), | |
| 766 | CODEX_MODEL_SELECTION, | |
| 767 | ); | |
| 768 | yield* wait(targetOrdinal + 2); | |
| 769 | yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION); | |
| 770 | yield* wait(targetOrdinal + 3); | |
| 771 | } | |
| 772 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 773 | assert.equal(projection.runs.at(-1)?.status, "completed"); | |
| 774 | if (priorImages) { | |
| 775 | const handoff = projection.contextHandoffs.at(-1)!; | |
| 776 | const context = scenario.endsWith("-native") | |
| 777 | ? yield* encodeJson(yield* Ref.get(injectedHistory)) | |
| 778 | : (yield* Ref.get(capturedTurns)).at(-1)!.text; | |
| 779 | const shouldFit = | |
| 780 | turnUsageScenario || | |
| 781 | scenario.startsWith("imported") || | |
| 782 | scenario.includes("telemetry") || | |
| 783 | scenario.includes("unsent") || | |
| 784 | replaceNative; | |
| 785 | const sourceText = | |
| 786 | `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000); | |
| 787 | const sourceItem = projection.turnItems.find( | |
| 788 | (item) => item.type === "user_message" && item.text === sourceText, | |
| 789 | )!; | |
| 790 | assert.isDefined(sourceItem); | |
| 791 | if (shouldFit) { | |
| 792 | assert.include(context, sourceText); | |
| 793 | assert.include( | |
| 794 | projection.contextHandoffs | |
| 795 | .filter( | |
| 796 | (record) => | |
| 797 | record.targetRunId === | |
| 798 | (scenario.includes("retry") | |
| 799 | ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id | |
| 800 | : projection.runs.at(-1)!.id), | |
| 801 | ) | |
| 802 | .flatMap((record) => record.delivery?.itemIds ?? []), | |
| 803 | sourceItem.id, | |
| 804 | ); | |
| 805 | } else { | |
| 806 | assert.notInclude(context, "q".repeat(9_000)); | |
| 807 | assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0); | |
| 808 | } | |
| 809 | const latestRun = projection.runs.at(-1)!; | |
| 810 | const latestAttempt = projection.attempts.find( | |
| 811 | (attempt) => attempt.id === latestRun.activeAttemptId, | |
| 812 | )!; | |
| 813 | const target = projection.providerThreads.find( | |
| 814 | (thread) => thread.id === latestAttempt.providerThreadId, | |
| 815 | )!; | |
| 816 | assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); | |
| 817 | if (turnUsageScenario) { | |
| 818 | if (replaceNative) assert.isNull(target.contextUsage); | |
| 819 | else | |
| 820 | assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); | |
| 821 | assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); | |
| 822 | } | |
| 823 | if (replaceNative) assert.equal(yield* Ref.get(generation), 2); | |
| 824 | return; | |
| 825 | } | |
| 826 | const handoff = projection.contextHandoffs.at(-1)!; | |
| 827 | if (scenario.startsWith("screenshot")) { | |
| 828 | assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); | |
| 829 | } | |
| 830 | if (scenario === "compact-fallback" || scenario === "compact-legacy") { | |
| 831 | if (scenario === "compact-legacy") { | |
| 832 | const { history: _history, ...legacyHandoff } = handoff; | |
| 833 | yield* eventSink.write({ | |
| 834 | events: [ | |
| 835 | { | |
| 836 | id: EventId.make("legacy-handoff-shape"), | |
| 837 | type: "context-handoff.updated", | |
| 838 | threadId, | |
| 839 | runId: handoff.targetRunId, | |
| 840 | occurredAt: yield* DateTime.now, | |
| 841 | payload: legacyHandoff, | |
| 842 | }, | |
| 843 | ], | |
| 844 | }); | |
| 845 | } | |
| 846 | assert.isUndefined(handoff.delivery); | |
| 847 | yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); | |
| 848 | yield* wait(3); | |
| 849 | assert.include( | |
| 850 | (yield* Ref.get(capturedTurns)).at(-1)!.text, | |
| 851 | "Original request with constraints", | |
| 852 | ); | |
| 853 | assert.equal( | |
| 854 | (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery | |
| 855 | ?.status, | |
| 856 | "inline", | |
| 857 | ); | |
| 858 | } else if ( | |
| 859 | scenario === "delivery-write-failure" || | |
| 860 | (scenario.startsWith("screenshot") && scenario.endsWith("fallback")) | |
| 861 | ) { | |
| 862 | assert.equal( | |
| 863 | handoff.delivery?.status, | |
| 864 | scenario.startsWith("screenshot") ? "inline" : "pending", | |
| 865 | ); | |
| 866 | assert.include( | |
| 867 | (yield* Ref.get(capturedTurns)).at(-1)!.text, | |
| 868 | "Original request with constraints", | |
| 1014 | assert.notEqual( | |
| 1015 | retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId, | |
| 1016 | failedHandoff.delivery?.nativeThreadId, | |
| 869 | 1017 | ); |
| 870 | } else { | |
| 871 | assert.equal(handoff.delivery?.status, "injected"); | |
| 872 | assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current); | |
| 873 | assert.include( | |
| 874 | yield* encodeJson(yield* Ref.get(injectedHistory)), | |
| 875 | "Original request with constraints", | |
| 1018 | const newHistory = yield* encodeJson( | |
| 1019 | (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), | |
| 876 | 1020 | ); |
| 1021 | assert.include(newHistory, "Original request with constraints"); | |
| 1022 | assert.include(newHistory, "Original partial work"); | |
| 1023 | assert.notInclude(newHistory, "Retry target request"); | |
| 877 | 1024 | } |
| 1025 | const beforeFollowup = yield* Ref.get(injectedHistory); | |
| 1026 | const followup = "z".repeat(6_000); | |
| 1027 | yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION); | |
| 1028 | yield* wait(4, "completed"); | |
| 1029 | assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup); | |
| 1030 | assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup); | |
| 1031 | assert.equal( | |
| 1032 | (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length, | |
| 1033 | retried.contextHandoffs.length, | |
| 1034 | ); | |
| 878 | 1035 | }).pipe( |
| 879 | 1036 | Effect.provide( |
| 880 | 1037 | makeOrchestratorV2ReplayLayerWithRegistry( |
| 881 | 1038 | { |
| 882 | name: `handoff-${scenario}`, | |
| 1039 | name: `handoff-retry-${failure}`, | |
| 883 | 1040 | runtimePolicyOverride: { |
| 884 | 1041 | cwd, |
| 885 | 1042 | approvalPolicy: "never", |
| ... | ... | @@ -892,368 +1049,194 @@ describe("orchestration v2 provider switching", () => { |
| 892 | 1049 | ); |
| 893 | 1050 | }), |
| 894 | 1051 | ), |
| 895 | ); | |
| 896 | } | |
| 897 | for (const failure of ["turn-start", "injection", "large-missed-request"] as const) { | |
| 898 | it.live( | |
| 899 | `recovers ${failure} failure without duplicating history in the same native thread`, | |
| 900 | () => | |
| 901 | Effect.scoped( | |
| 902 | Effect.gen(function* () { | |
| 903 | const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`); | |
| 904 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); | |
| 905 | const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]); | |
| 906 | const failOnce = yield* Ref.make(true); | |
| 907 | const generation = yield* Ref.make(0); | |
| 908 | const registry = ProviderAdapterRegistry.makeLayer([ | |
| 909 | makeTestAdapter({ | |
| 910 | instanceId: CODEX_MODEL_SELECTION.instanceId, | |
| 911 | driver: CODEX_DRIVER, | |
| 912 | capabilities: CodexProviderCapabilitiesV2, | |
| 913 | modelSelection: CODEX_MODEL_SELECTION, | |
| 914 | responseByRunOrdinal: { 1: "Original partial work" }, | |
| 915 | capturedTurns, | |
| 916 | }), | |
| 917 | makeTestAdapter({ | |
| 918 | instanceId: CLAUDE_MODEL_SELECTION.instanceId, | |
| 919 | driver: CLAUDE_DRIVER, | |
| 920 | capabilities: ClaudeProviderCapabilitiesV2, | |
| 921 | modelSelection: CLAUDE_MODEL_SELECTION, | |
| 922 | responseByRunOrdinal: {}, | |
| 923 | capturedTurns, | |
| 924 | injectedHistory, | |
| 925 | nativeThreadGeneration: generation, | |
| 926 | getModelContextWindow: () => 32_000, | |
| 927 | ...(failure !== "injection" | |
| 928 | ? { failStartOnce: failOnce } | |
| 929 | : { failInjectionOnce: failOnce }), | |
| 930 | }), | |
| 931 | ]); | |
| 932 | yield* Effect.gen(function* () { | |
| 933 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 934 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 935 | const dispatch = (ordinal: number, text: string, selection: ModelSelection) => | |
| 936 | orchestrator.dispatch({ | |
| 937 | type: "message.dispatch", | |
| 938 | commandId: CommandId.make(`retry:${ordinal}`), | |
| 939 | threadId, | |
| 940 | messageId: MessageId.make(`retry:${ordinal}`), | |
| 941 | createdBy: "user", | |
| 942 | creationSource: "web", | |
| 943 | text, | |
| 944 | attachments: [], | |
| 945 | modelSelection: selection, | |
| 946 | dispatchMode: { type: "start_immediately" }, | |
| 947 | }); | |
| 948 | const wait = (ordinal: number, status: "failed" | "completed") => | |
| 949 | orchestrator.streamStoredEvents.pipe( | |
| 950 | Stream.filter( | |
| 951 | ({ event }) => | |
| 952 | event.type === "run.updated" && | |
| 953 | event.payload.ordinal === ordinal && | |
| 954 | (event.payload.status === "completed" || event.payload.status === "failed"), | |
| 955 | ), | |
| 956 | Stream.runHead, | |
| 957 | Effect.andThen(worker.drain()), | |
| 958 | Effect.andThen( | |
| 959 | Effect.gen(function* () { | |
| 960 | assert.equal( | |
| 961 | (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, | |
| 962 | status, | |
| 963 | ); | |
| 964 | }), | |
| 965 | ), | |
| 966 | ); | |
| 967 | yield* orchestrator.dispatch({ | |
| 968 | type: "thread.create", | |
| 969 | commandId: CommandId.make("retry:create"), | |
| 970 | threadId, | |
| 971 | projectId, | |
| 972 | createdBy: "user", | |
| 973 | creationSource: "web", | |
| 974 | title: "Handoff retry", | |
| 975 | modelSelection: CODEX_MODEL_SELECTION, | |
| 976 | runtimeMode: "full-access", | |
| 977 | interactionMode: "default", | |
| 978 | branch: null, | |
| 979 | worktreePath: null, | |
| 980 | }); | |
| 981 | yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); | |
| 982 | yield* wait(1, "completed"); | |
| 983 | yield* dispatch( | |
| 984 | 2, | |
| 985 | failure === "large-missed-request" ? "x".repeat(7_000) : "First target request", | |
| 986 | CLAUDE_MODEL_SELECTION, | |
| 987 | ); | |
| 988 | yield* wait(2, "failed"); | |
| 989 | const failed = yield* orchestrator.getThreadProjection(threadId); | |
| 990 | const failedHandoff = failed.contextHandoffs.at(-1)!; | |
| 991 | assert.equal( | |
| 992 | failedHandoff.delivery?.status, | |
| 993 | failure !== "injection" ? "injected" : "pending", | |
| 994 | ); | |
| 995 | const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; | |
| 996 | const retryText = | |
| 997 | failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request"; | |
| 998 | yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION); | |
| 999 | yield* wait(3, "completed"); | |
| 1000 | const retried = yield* orchestrator.getThreadProjection(threadId); | |
| 1001 | const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; | |
| 1002 | assert.equal(lastTurn.text, retryText); | |
| 1003 | if (failure !== "injection") { | |
| 1004 | const delta = yield* encodeJson( | |
| 1005 | (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), | |
| 1006 | ); | |
| 1007 | if (failure === "large-missed-request") { | |
| 1008 | assert.include(delta, "omitted 1 items"); | |
| 1009 | const missedRequest = retried.turnItems.find( | |
| 1010 | (item) => item.type === "user_message" && item.text === "x".repeat(7_000), | |
| 1011 | )!; | |
| 1012 | const delivery = retried.contextHandoffs.at(-1)!.delivery!; | |
| 1013 | assert.include(delivery.omittedItemIds ?? [], missedRequest.id); | |
| 1014 | assert.notInclude(delivery.itemIds, missedRequest.id); | |
| 1015 | } else assert.include(delta, "First target request"); | |
| 1016 | assert.notInclude(delta, "Original request with constraints"); | |
| 1017 | assert.notInclude(delta, "Original partial work"); | |
| 1018 | assert.equal(yield* Ref.get(generation), 1); | |
| 1019 | } else { | |
| 1020 | assert.equal(yield* Ref.get(generation), 2); | |
| 1021 | assert.notEqual( | |
| 1022 | retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId, | |
| 1023 | failedHandoff.delivery?.nativeThreadId, | |
| 1024 | ); | |
| 1025 | const newHistory = yield* encodeJson( | |
| 1026 | (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), | |
| 1027 | ); | |
| 1028 | assert.include(newHistory, "Original request with constraints"); | |
| 1029 | assert.include(newHistory, "Original partial work"); | |
| 1030 | assert.notInclude(newHistory, "Retry target request"); | |
| 1031 | } | |
| 1032 | const beforeFollowup = yield* Ref.get(injectedHistory); | |
| 1033 | const followup = "z".repeat(6_000); | |
| 1034 | yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION); | |
| 1035 | yield* wait(4, "completed"); | |
| 1036 | assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup); | |
| 1037 | assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup); | |
| 1038 | assert.equal( | |
| 1039 | (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length, | |
| 1040 | retried.contextHandoffs.length, | |
| 1041 | ); | |
| 1042 | }).pipe( | |
| 1043 | Effect.provide( | |
| 1044 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 1045 | { | |
| 1046 | name: `handoff-retry-${failure}`, | |
| 1047 | runtimePolicyOverride: { | |
| 1048 | cwd, | |
| 1049 | approvalPolicy: "never", | |
| 1050 | sandboxPolicy: { type: "readOnly" }, | |
| 1051 | }, | |
| 1052 | }, | |
| 1053 | registry, | |
| 1054 | ), | |
| 1055 | ), | |
| 1056 | ); | |
| 1057 | }), | |
| 1058 | ), | |
| 1059 | ); | |
| 1060 | } | |
| 1052 | ); | |
| 1061 | 1053 | |
| 1062 | for (const status of ["failed", "interrupted"] as const) { | |
| 1063 | for (const queued of [false, true]) { | |
| 1064 | for (const returning of [false, true]) { | |
| 1065 | for (const native of [false, true]) { | |
| 1066 | it.live( | |
| 1067 | `hands off ${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, | |
| 1068 | () => | |
| 1069 | Effect.scoped( | |
| 1070 | Effect.gen(function* () { | |
| 1071 | const cwd = yield* checkpointWorkspace( | |
| 1072 | `handoff-${status}-${queued}-${returning}`, | |
| 1073 | ); | |
| 1074 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); | |
| 1075 | const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]); | |
| 1076 | const started = yield* Deferred.make<void>(); | |
| 1077 | const release = yield* Deferred.make<void>(); | |
| 1078 | const sourceOrdinal = returning ? 2 : 1; | |
| 1079 | const sourceSelection = returning | |
| 1080 | ? CLAUDE_MODEL_SELECTION | |
| 1081 | : CODEX_MODEL_SELECTION; | |
| 1082 | const targetSelection = returning | |
| 1083 | ? CODEX_MODEL_SELECTION | |
| 1084 | : CLAUDE_MODEL_SELECTION; | |
| 1085 | const originalPrompt = | |
| 1086 | "Keep the release marker violet and preserve the existing API."; | |
| 1087 | const partialResponse = "I checked the API and found the release configuration."; | |
| 1088 | const registryLayer = ProviderAdapterRegistry.makeLayer( | |
| 1089 | ( | |
| 1090 | [ | |
| 1091 | [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], | |
| 1092 | [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], | |
| 1093 | ] as const | |
| 1094 | ).map(([modelSelection, driver, capabilities]) => | |
| 1095 | makeTestAdapter({ | |
| 1096 | instanceId: modelSelection.instanceId, | |
| 1097 | driver, | |
| 1098 | capabilities, | |
| 1099 | modelSelection, | |
| 1100 | responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, | |
| 1101 | capturedTurns, | |
| 1102 | ...(native && modelSelection.instanceId === targetSelection.instanceId | |
| 1103 | ? { injectedHistory } | |
| 1104 | : {}), | |
| 1105 | ...(modelSelection.instanceId === sourceSelection.instanceId | |
| 1106 | ? { | |
| 1107 | failedRunOrdinals: new Set( | |
| 1108 | status === "failed" ? [sourceOrdinal] : [], | |
| 1109 | ), | |
| 1110 | interruptedRunOrdinals: new Set( | |
| 1111 | status === "interrupted" ? [sourceOrdinal] : [], | |
| 1112 | ), | |
| 1113 | holdRunOrdinal: sourceOrdinal, | |
| 1114 | holdFirstTurn: started, | |
| 1115 | releaseFirstTurn: release, | |
| 1116 | } | |
| 1117 | : {}), | |
| 1118 | }), | |
| 1119 | ), | |
| 1120 | ); | |
| 1121 | yield* Effect.gen(function* () { | |
| 1122 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 1123 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 1124 | const waitForRun = ( | |
| 1125 | ordinal: number, | |
| 1126 | expectedStatus: "completed" | "failed" | "interrupted", | |
| 1127 | ) => | |
| 1128 | orchestrator.streamStoredEvents.pipe( | |
| 1129 | Stream.filter( | |
| 1130 | ({ event }) => | |
| 1131 | event.type === "run.updated" && | |
| 1132 | event.threadId === threadId && | |
| 1133 | event.payload.ordinal === ordinal && | |
| 1134 | event.payload.status === expectedStatus, | |
| 1135 | ), | |
| 1136 | Stream.runHead, | |
| 1137 | Effect.andThen(worker.drain()), | |
| 1138 | ); | |
| 1139 | const dispatch = ( | |
| 1140 | key: string, | |
| 1141 | text: string, | |
| 1142 | modelSelection: ModelSelection, | |
| 1143 | queue = false, | |
| 1144 | ) => | |
| 1145 | orchestrator.dispatch({ | |
| 1146 | type: "message.dispatch", | |
| 1147 | commandId: CommandId.make(`command:handoff:${key}`), | |
| 1148 | threadId, | |
| 1149 | messageId: MessageId.make(`message:handoff:${key}`), | |
| 1150 | createdBy: "user", | |
| 1151 | creationSource: "web", | |
| 1152 | text, | |
| 1153 | attachments: [], | |
| 1154 | modelSelection, | |
| 1155 | dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, | |
| 1156 | }); | |
| 1157 | yield* orchestrator.dispatch({ | |
| 1158 | type: "thread.create", | |
| 1159 | commandId: CommandId.make("command:handoff:create"), | |
| 1160 | threadId, | |
| 1161 | projectId, | |
| 1162 | createdBy: "user", | |
| 1163 | creationSource: "web", | |
| 1164 | title: "Interrupted provider handoff", | |
| 1165 | modelSelection: CODEX_MODEL_SELECTION, | |
| 1166 | runtimeMode: "full-access", | |
| 1167 | interactionMode: "default", | |
| 1168 | branch: null, | |
| 1169 | worktreePath: null, | |
| 1170 | }); | |
| 1171 | if (returning) { | |
| 1172 | yield* dispatch( | |
| 1173 | "initial", | |
| 1174 | "Earlier successful request.", | |
| 1175 | CODEX_MODEL_SELECTION, | |
| 1176 | ); | |
| 1177 | yield* waitForRun(1, "completed"); | |
| 1178 | } | |
| 1179 | yield* dispatch("source", originalPrompt, sourceSelection); | |
| 1180 | yield* Deferred.await(started); | |
| 1181 | if (queued) { | |
| 1182 | yield* dispatch("target", "Continue", targetSelection, true); | |
| 1183 | assert.equal( | |
| 1184 | (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, | |
| 1185 | "queued", | |
| 1186 | ); | |
| 1187 | } | |
| 1188 | yield* Deferred.succeed(release, undefined); | |
| 1189 | yield* waitForRun(sourceOrdinal, status); | |
| 1190 | if (!queued) { | |
| 1191 | yield* dispatch("target", "Continue", targetSelection); | |
| 1192 | } | |
| 1193 | yield* waitForRun(sourceOrdinal + 1, "completed"); | |
| 1194 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 1195 | const targetRun = projection.runs.at(-1)!; | |
| 1196 | const handoff = projection.contextHandoffs.find( | |
| 1197 | (candidate) => candidate.id === targetRun.contextHandoffId, | |
| 1198 | ); | |
| 1199 | assert.isDefined(handoff); | |
| 1200 | assert.equal( | |
| 1201 | handoff?.strategy, | |
| 1202 | returning ? "delta_since_target_last_seen" : "full_thread_summary", | |
| 1203 | ); | |
| 1204 | assert.deepEqual(handoff?.coveredRunOrdinals, { | |
| 1205 | from: sourceOrdinal, | |
| 1206 | to: sourceOrdinal, | |
| 1207 | }); | |
| 1208 | const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; | |
| 1209 | const history = yield* Ref.get(injectedHistory); | |
| 1210 | const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; | |
| 1211 | assert.include(deliveredHistory, originalPrompt); | |
| 1212 | assert.include(deliveredHistory, partialResponse); | |
| 1213 | assert.include(deliveredHistory, `run-status=${status}`); | |
| 1214 | if (native) { | |
| 1215 | assert.equal(delivered.text, "Continue"); | |
| 1216 | assert.notInclude(deliveredHistory, '"text":"Continue"'); | |
| 1217 | assert.include(deliveredHistory, '"role":"assistant"'); | |
| 1218 | assert.include(deliveredHistory, '"role":"user"'); | |
| 1219 | assert.equal(handoff?.delivery?.status, "injected"); | |
| 1220 | } else { | |
| 1221 | assert.include(delivered.text, "User message:\nContinue"); | |
| 1222 | assert.equal(handoff?.delivery?.status, "inline"); | |
| 1223 | } | |
| 1224 | assert.notInclude(deliveredHistory, "Earlier successful request."); | |
| 1225 | // The returning source already has its own failed/interrupted native turn. | |
| 1226 | yield* dispatch("back", "Finish the remaining work", sourceSelection); | |
| 1227 | yield* waitForRun(sourceOrdinal + 2, "completed"); | |
| 1228 | const back = (yield* Ref.get(capturedTurns)).at(-1)!; | |
| 1229 | assert.notInclude(back.text, originalPrompt); | |
| 1230 | assert.notInclude(back.text, partialResponse); | |
| 1231 | }).pipe( | |
| 1232 | Effect.provide( | |
| 1233 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 1234 | { | |
| 1235 | name: `handoff-${status}-${queued}-${returning}`, | |
| 1236 | runtimePolicyOverride: { | |
| 1237 | cwd, | |
| 1238 | approvalPolicy: "never", | |
| 1239 | sandboxPolicy: { | |
| 1240 | type: "readOnly", | |
| 1241 | access: { type: "fullAccess" }, | |
| 1242 | networkAccess: false, | |
| 1243 | }, | |
| 1244 | }, | |
| 1245 | }, | |
| 1246 | registryLayer, | |
| 1247 | ), | |
| 1054 | it.live.each( | |
| 1055 | (["failed", "interrupted"] as const).flatMap((status) => | |
| 1056 | [false, true].flatMap((queued) => | |
| 1057 | [false, true].flatMap((returning) => | |
| 1058 | [false, true].map( | |
| 1059 | (native) => | |
| 1060 | [ | |
| 1061 | `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, | |
| 1062 | { status, queued, returning, native }, | |
| 1063 | ] as const, | |
| 1064 | ), | |
| 1065 | ), | |
| 1066 | ), | |
| 1067 | ), | |
| 1068 | )("hands off %s", ([, { status, queued, returning, native }]) => | |
| 1069 | Effect.scoped( | |
| 1070 | Effect.gen(function* () { | |
| 1071 | const cwd = yield* checkpointWorkspace(`handoff-${status}-${queued}-${returning}`); | |
| 1072 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); | |
| 1073 | const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]); | |
| 1074 | const started = yield* Deferred.make<void>(); | |
| 1075 | const release = yield* Deferred.make<void>(); | |
| 1076 | const sourceOrdinal = returning ? 2 : 1; | |
| 1077 | const sourceSelection = returning ? CLAUDE_MODEL_SELECTION : CODEX_MODEL_SELECTION; | |
| 1078 | const targetSelection = returning ? CODEX_MODEL_SELECTION : CLAUDE_MODEL_SELECTION; | |
| 1079 | const originalPrompt = "Keep the release marker violet and preserve the existing API."; | |
| 1080 | const partialResponse = "I checked the API and found the release configuration."; | |
| 1081 | const registryLayer = ProviderAdapterRegistry.makeLayer( | |
| 1082 | ( | |
| 1083 | [ | |
| 1084 | [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], | |
| 1085 | [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], | |
| 1086 | ] as const | |
| 1087 | ).map(([modelSelection, driver, capabilities]) => | |
| 1088 | makeTestAdapter({ | |
| 1089 | instanceId: modelSelection.instanceId, | |
| 1090 | driver, | |
| 1091 | capabilities, | |
| 1092 | modelSelection, | |
| 1093 | responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, | |
| 1094 | capturedTurns, | |
| 1095 | ...(native && modelSelection.instanceId === targetSelection.instanceId | |
| 1096 | ? { injectedHistory } | |
| 1097 | : {}), | |
| 1098 | ...(modelSelection.instanceId === sourceSelection.instanceId | |
| 1099 | ? { | |
| 1100 | failedRunOrdinals: new Set(status === "failed" ? [sourceOrdinal] : []), | |
| 1101 | interruptedRunOrdinals: new Set( | |
| 1102 | status === "interrupted" ? [sourceOrdinal] : [], | |
| 1248 | 1103 | ), |
| 1249 | ); | |
| 1250 | }), | |
| 1104 | holdRunOrdinal: sourceOrdinal, | |
| 1105 | holdFirstTurn: started, | |
| 1106 | releaseFirstTurn: release, | |
| 1107 | } | |
| 1108 | : {}), | |
| 1109 | }), | |
| 1110 | ), | |
| 1111 | ); | |
| 1112 | yield* Effect.gen(function* () { | |
| 1113 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 1114 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 1115 | const waitForRun = ( | |
| 1116 | ordinal: number, | |
| 1117 | expectedStatus: "completed" | "failed" | "interrupted", | |
| 1118 | ) => | |
| 1119 | orchestrator.streamStoredEvents.pipe( | |
| 1120 | Stream.filter( | |
| 1121 | ({ event }) => | |
| 1122 | event.type === "run.updated" && | |
| 1123 | event.threadId === threadId && | |
| 1124 | event.payload.ordinal === ordinal && | |
| 1125 | event.payload.status === expectedStatus, | |
| 1251 | 1126 | ), |
| 1127 | Stream.runHead, | |
| 1128 | Effect.andThen(worker.drain()), | |
| 1129 | ); | |
| 1130 | const dispatch = ( | |
| 1131 | key: string, | |
| 1132 | text: string, | |
| 1133 | modelSelection: ModelSelection, | |
| 1134 | queue = false, | |
| 1135 | ) => | |
| 1136 | orchestrator.dispatch({ | |
| 1137 | type: "message.dispatch", | |
| 1138 | commandId: CommandId.make(`command:handoff:${key}`), | |
| 1139 | threadId, | |
| 1140 | messageId: MessageId.make(`message:handoff:${key}`), | |
| 1141 | createdBy: "user", | |
| 1142 | creationSource: "web", | |
| 1143 | text, | |
| 1144 | attachments: [], | |
| 1145 | modelSelection, | |
| 1146 | dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, | |
| 1147 | }); | |
| 1148 | yield* orchestrator.dispatch({ | |
| 1149 | type: "thread.create", | |
| 1150 | commandId: CommandId.make("command:handoff:create"), | |
| 1151 | threadId, | |
| 1152 | projectId, | |
| 1153 | createdBy: "user", | |
| 1154 | creationSource: "web", | |
| 1155 | title: "Interrupted provider handoff", | |
| 1156 | modelSelection: CODEX_MODEL_SELECTION, | |
| 1157 | runtimeMode: "full-access", | |
| 1158 | interactionMode: "default", | |
| 1159 | branch: null, | |
| 1160 | worktreePath: null, | |
| 1161 | }); | |
| 1162 | if (returning) { | |
| 1163 | yield* dispatch("initial", "Earlier successful request.", CODEX_MODEL_SELECTION); | |
| 1164 | yield* waitForRun(1, "completed"); | |
| 1165 | } | |
| 1166 | yield* dispatch("source", originalPrompt, sourceSelection); | |
| 1167 | yield* Deferred.await(started); | |
| 1168 | if (queued) { | |
| 1169 | yield* dispatch("target", "Continue", targetSelection, true); | |
| 1170 | assert.equal( | |
| 1171 | (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, | |
| 1172 | "queued", | |
| 1173 | ); | |
| 1174 | } | |
| 1175 | yield* Deferred.succeed(release, undefined); | |
| 1176 | yield* waitForRun(sourceOrdinal, status); | |
| 1177 | if (!queued) { | |
| 1178 | yield* dispatch("target", "Continue", targetSelection); | |
| 1179 | } | |
| 1180 | yield* waitForRun(sourceOrdinal + 1, "completed"); | |
| 1181 | const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 1182 | const targetRun = projection.runs.at(-1)!; | |
| 1183 | const handoff = projection.contextHandoffs.find( | |
| 1184 | (candidate) => candidate.id === targetRun.contextHandoffId, | |
| 1252 | 1185 | ); |
| 1253 | } | |
| 1254 | } | |
| 1255 | } | |
| 1256 | } | |
| 1186 | assert.isDefined(handoff); | |
| 1187 | assert.equal( | |
| 1188 | handoff?.strategy, | |
| 1189 | returning ? "delta_since_target_last_seen" : "full_thread_summary", | |
| 1190 | ); | |
| 1191 | assert.deepEqual(handoff?.coveredRunOrdinals, { | |
| 1192 | from: sourceOrdinal, | |
| 1193 | to: sourceOrdinal, | |
| 1194 | }); | |
| 1195 | const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; | |
| 1196 | const history = yield* Ref.get(injectedHistory); | |
| 1197 | const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; | |
| 1198 | assert.include(deliveredHistory, originalPrompt); | |
| 1199 | assert.include(deliveredHistory, partialResponse); | |
| 1200 | assert.include(deliveredHistory, `run-status=${status}`); | |
| 1201 | if (native) { | |
| 1202 | assert.equal(delivered.text, "Continue"); | |
| 1203 | assert.notInclude(deliveredHistory, '"text":"Continue"'); | |
| 1204 | assert.include(deliveredHistory, '"role":"assistant"'); | |
| 1205 | assert.include(deliveredHistory, '"role":"user"'); | |
| 1206 | assert.equal(handoff?.delivery?.status, "injected"); | |
| 1207 | } else { | |
| 1208 | assert.include(delivered.text, "User message:\nContinue"); | |
| 1209 | assert.equal(handoff?.delivery?.status, "inline"); | |
| 1210 | } | |
| 1211 | assert.notInclude(deliveredHistory, "Earlier successful request."); | |
| 1212 | // The returning source already has its own failed/interrupted native turn. | |
| 1213 | yield* dispatch("back", "Finish the remaining work", sourceSelection); | |
| 1214 | yield* waitForRun(sourceOrdinal + 2, "completed"); | |
| 1215 | const back = (yield* Ref.get(capturedTurns)).at(-1)!; | |
| 1216 | assert.notInclude(back.text, originalPrompt); | |
| 1217 | assert.notInclude(back.text, partialResponse); | |
| 1218 | }).pipe( | |
| 1219 | Effect.provide( | |
| 1220 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 1221 | { | |
| 1222 | name: `handoff-${status}-${queued}-${returning}`, | |
| 1223 | runtimePolicyOverride: { | |
| 1224 | cwd, | |
| 1225 | approvalPolicy: "never", | |
| 1226 | sandboxPolicy: { | |
| 1227 | type: "readOnly", | |
| 1228 | access: { type: "fullAccess" }, | |
| 1229 | networkAccess: false, | |
| 1230 | }, | |
| 1231 | }, | |
| 1232 | }, | |
| 1233 | registryLayer, | |
| 1234 | ), | |
| 1235 | ), | |
| 1236 | ); | |
| 1237 | }), | |
| 1238 | ), | |
| 1239 | ); | |
| 1257 | 1240 | |
| 1258 | 1241 | it.live("checks the queued provider's capability while the current provider stays running", () => |
| 1259 | 1242 | Effect.scoped( |
| ... | ... | @@ -2998,229 +2981,226 @@ describe("orchestration v2 provider switching", () => { |
| 2998 | 2981 | ), |
| 2999 | 2982 | ); |
| 3000 | 2983 | |
| 3001 | for (const failResume of [false, true]) { | |
| 3002 | it.live( | |
| 3003 | `switches providers while consuming a pending cross-provider merge-back (resume failure: ${failResume})`, | |
| 3004 | () => | |
| 3005 | Effect.scoped( | |
| 3006 | Effect.gen(function* () { | |
| 3007 | const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); | |
| 3008 | const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); | |
| 3009 | const firstSourcePrompt = "Remember that the first source marker is amber."; | |
| 3010 | const secondSourcePrompt = "Remember that the second source marker is violet."; | |
| 3011 | const forkPrompt = "Remember that the fork marker is cobalt."; | |
| 3012 | const mergePrompt = "Report all three remembered markers."; | |
| 3013 | const cwd = yield* checkpointWorkspace("cross-provider-merge"); | |
| 3014 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); | |
| 3015 | const registryLayer = ProviderAdapterRegistry.makeLayer([ | |
| 3016 | makeTestAdapter({ | |
| 3017 | instanceId: ProviderInstanceId.make("codex"), | |
| 3018 | driver: CODEX_DRIVER, | |
| 3019 | capabilities: CodexProviderCapabilitiesV2, | |
| 3020 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3021 | responseByRunOrdinal: {}, | |
| 3022 | responseByThreadId: { | |
| 3023 | [sourceThreadId]: { | |
| 3024 | 1: "I will remember amber.", | |
| 3025 | 3: "The markers are amber, violet, and cobalt.", | |
| 3026 | }, | |
| 3027 | [forkThreadId]: { | |
| 3028 | 1: "I will remember cobalt.", | |
| 3029 | }, | |
| 2984 | it.live.each([false, true])( | |
| 2985 | "switches providers while consuming a pending cross-provider merge-back (resume failure: %s)", | |
| 2986 | (failResume) => | |
| 2987 | Effect.scoped( | |
| 2988 | Effect.gen(function* () { | |
| 2989 | const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); | |
| 2990 | const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); | |
| 2991 | const firstSourcePrompt = "Remember that the first source marker is amber."; | |
| 2992 | const secondSourcePrompt = "Remember that the second source marker is violet."; | |
| 2993 | const forkPrompt = "Remember that the fork marker is cobalt."; | |
| 2994 | const mergePrompt = "Report all three remembered markers."; | |
| 2995 | const cwd = yield* checkpointWorkspace("cross-provider-merge"); | |
| 2996 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); | |
| 2997 | const registryLayer = ProviderAdapterRegistry.makeLayer([ | |
| 2998 | makeTestAdapter({ | |
| 2999 | instanceId: ProviderInstanceId.make("codex"), | |
| 3000 | driver: CODEX_DRIVER, | |
| 3001 | capabilities: CodexProviderCapabilitiesV2, | |
| 3002 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3003 | responseByRunOrdinal: {}, | |
| 3004 | responseByThreadId: { | |
| 3005 | [sourceThreadId]: { | |
| 3006 | 1: "I will remember amber.", | |
| 3007 | 3: "The markers are amber, violet, and cobalt.", | |
| 3008 | }, | |
| 3009 | [forkThreadId]: { | |
| 3010 | 1: "I will remember cobalt.", | |
| 3030 | 3011 | }, |
| 3031 | capturedTurns, | |
| 3032 | failResume, | |
| 3033 | }), | |
| 3034 | makeTestAdapter({ | |
| 3035 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 3036 | driver: CLAUDE_DRIVER, | |
| 3037 | capabilities: ClaudeProviderCapabilitiesV2, | |
| 3038 | modelSelection: CLAUDE_MODEL_SELECTION, | |
| 3039 | responseByRunOrdinal: { 2: "I will remember violet." }, | |
| 3040 | capturedTurns, | |
| 3041 | }), | |
| 3042 | ]); | |
| 3043 | const commands = [ | |
| 3044 | { | |
| 3045 | type: "thread.create", | |
| 3046 | createdBy: "user", | |
| 3047 | creationSource: "web", | |
| 3048 | commandId: CommandId.make("command:cross-provider-merge:create"), | |
| 3049 | threadId: sourceThreadId, | |
| 3050 | projectId, | |
| 3051 | title: "Cross-provider merge source", | |
| 3052 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3053 | runtimeMode: "full-access", | |
| 3054 | interactionMode: "default", | |
| 3055 | branch: null, | |
| 3056 | worktreePath: null, | |
| 3057 | }, | |
| 3058 | { | |
| 3059 | type: "message.dispatch", | |
| 3060 | createdBy: "user", | |
| 3061 | creationSource: "web", | |
| 3062 | commandId: CommandId.make("command:cross-provider-merge:first-source"), | |
| 3063 | threadId: sourceThreadId, | |
| 3064 | messageId: MessageId.make("message:cross-provider-merge:first-source"), | |
| 3065 | text: firstSourcePrompt, | |
| 3066 | attachments: [], | |
| 3067 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3068 | dispatchMode: { type: "start_immediately" }, | |
| 3069 | }, | |
| 3070 | { | |
| 3071 | type: "message.dispatch", | |
| 3072 | createdBy: "user", | |
| 3073 | creationSource: "web", | |
| 3074 | commandId: CommandId.make("command:cross-provider-merge:second-source"), | |
| 3075 | threadId: sourceThreadId, | |
| 3076 | messageId: MessageId.make("message:cross-provider-merge:second-source"), | |
| 3077 | text: secondSourcePrompt, | |
| 3078 | attachments: [], | |
| 3079 | modelSelection: CLAUDE_MODEL_SELECTION, | |
| 3080 | dispatchMode: { type: "start_immediately" }, | |
| 3081 | }, | |
| 3082 | { | |
| 3083 | type: "thread.fork", | |
| 3084 | createdBy: "user", | |
| 3085 | creationSource: "web", | |
| 3086 | commandId: CommandId.make("command:cross-provider-merge:fork"), | |
| 3087 | sourceThreadId, | |
| 3088 | targetThreadId: forkThreadId, | |
| 3089 | sourcePoint: { type: "latest_stable" }, | |
| 3090 | title: "Cross-provider merge fork", | |
| 3091 | }, | |
| 3092 | { | |
| 3093 | type: "message.dispatch", | |
| 3094 | createdBy: "user", | |
| 3095 | creationSource: "web", | |
| 3096 | commandId: CommandId.make("command:cross-provider-merge:fork-turn"), | |
| 3097 | threadId: forkThreadId, | |
| 3098 | messageId: MessageId.make("message:cross-provider-merge:fork-turn"), | |
| 3099 | text: forkPrompt, | |
| 3100 | attachments: [], | |
| 3101 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3102 | dispatchMode: { type: "start_immediately" }, | |
| 3103 | }, | |
| 3104 | { | |
| 3105 | type: "thread.merge_back", | |
| 3106 | createdBy: "user", | |
| 3107 | creationSource: "web", | |
| 3108 | commandId: CommandId.make("command:cross-provider-merge:merge"), | |
| 3109 | sourceThreadId: forkThreadId, | |
| 3110 | targetThreadId: sourceThreadId, | |
| 3111 | sourcePoint: { type: "latest_stable" }, | |
| 3112 | }, | |
| 3113 | { | |
| 3114 | type: "message.dispatch", | |
| 3115 | createdBy: "user", | |
| 3116 | creationSource: "web", | |
| 3117 | commandId: CommandId.make("command:cross-provider-merge:consume"), | |
| 3118 | threadId: sourceThreadId, | |
| 3119 | messageId: MessageId.make("message:cross-provider-merge:consume"), | |
| 3120 | text: mergePrompt, | |
| 3121 | attachments: [], | |
| 3122 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3123 | dispatchMode: { type: "start_immediately" }, | |
| 3124 | 3012 | }, |
| 3125 | ] satisfies ReadonlyArray<OrchestrationV2Command>; | |
| 3013 | capturedTurns, | |
| 3014 | failResume, | |
| 3015 | }), | |
| 3016 | makeTestAdapter({ | |
| 3017 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 3018 | driver: CLAUDE_DRIVER, | |
| 3019 | capabilities: ClaudeProviderCapabilitiesV2, | |
| 3020 | modelSelection: CLAUDE_MODEL_SELECTION, | |
| 3021 | responseByRunOrdinal: { 2: "I will remember violet." }, | |
| 3022 | capturedTurns, | |
| 3023 | }), | |
| 3024 | ]); | |
| 3025 | const commands = [ | |
| 3026 | { | |
| 3027 | type: "thread.create", | |
| 3028 | createdBy: "user", | |
| 3029 | creationSource: "web", | |
| 3030 | commandId: CommandId.make("command:cross-provider-merge:create"), | |
| 3031 | threadId: sourceThreadId, | |
| 3032 | projectId, | |
| 3033 | title: "Cross-provider merge source", | |
| 3034 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3035 | runtimeMode: "full-access", | |
| 3036 | interactionMode: "default", | |
| 3037 | branch: null, | |
| 3038 | worktreePath: null, | |
| 3039 | }, | |
| 3040 | { | |
| 3041 | type: "message.dispatch", | |
| 3042 | createdBy: "user", | |
| 3043 | creationSource: "web", | |
| 3044 | commandId: CommandId.make("command:cross-provider-merge:first-source"), | |
| 3045 | threadId: sourceThreadId, | |
| 3046 | messageId: MessageId.make("message:cross-provider-merge:first-source"), | |
| 3047 | text: firstSourcePrompt, | |
| 3048 | attachments: [], | |
| 3049 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3050 | dispatchMode: { type: "start_immediately" }, | |
| 3051 | }, | |
| 3052 | { | |
| 3053 | type: "message.dispatch", | |
| 3054 | createdBy: "user", | |
| 3055 | creationSource: "web", | |
| 3056 | commandId: CommandId.make("command:cross-provider-merge:second-source"), | |
| 3057 | threadId: sourceThreadId, | |
| 3058 | messageId: MessageId.make("message:cross-provider-merge:second-source"), | |
| 3059 | text: secondSourcePrompt, | |
| 3060 | attachments: [], | |
| 3061 | modelSelection: CLAUDE_MODEL_SELECTION, | |
| 3062 | dispatchMode: { type: "start_immediately" }, | |
| 3063 | }, | |
| 3064 | { | |
| 3065 | type: "thread.fork", | |
| 3066 | createdBy: "user", | |
| 3067 | creationSource: "web", | |
| 3068 | commandId: CommandId.make("command:cross-provider-merge:fork"), | |
| 3069 | sourceThreadId, | |
| 3070 | targetThreadId: forkThreadId, | |
| 3071 | sourcePoint: { type: "latest_stable" }, | |
| 3072 | title: "Cross-provider merge fork", | |
| 3073 | }, | |
| 3074 | { | |
| 3075 | type: "message.dispatch", | |
| 3076 | createdBy: "user", | |
| 3077 | creationSource: "web", | |
| 3078 | commandId: CommandId.make("command:cross-provider-merge:fork-turn"), | |
| 3079 | threadId: forkThreadId, | |
| 3080 | messageId: MessageId.make("message:cross-provider-merge:fork-turn"), | |
| 3081 | text: forkPrompt, | |
| 3082 | attachments: [], | |
| 3083 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3084 | dispatchMode: { type: "start_immediately" }, | |
| 3085 | }, | |
| 3086 | { | |
| 3087 | type: "thread.merge_back", | |
| 3088 | createdBy: "user", | |
| 3089 | creationSource: "web", | |
| 3090 | commandId: CommandId.make("command:cross-provider-merge:merge"), | |
| 3091 | sourceThreadId: forkThreadId, | |
| 3092 | targetThreadId: sourceThreadId, | |
| 3093 | sourcePoint: { type: "latest_stable" }, | |
| 3094 | }, | |
| 3095 | { | |
| 3096 | type: "message.dispatch", | |
| 3097 | createdBy: "user", | |
| 3098 | creationSource: "web", | |
| 3099 | commandId: CommandId.make("command:cross-provider-merge:consume"), | |
| 3100 | threadId: sourceThreadId, | |
| 3101 | messageId: MessageId.make("message:cross-provider-merge:consume"), | |
| 3102 | text: mergePrompt, | |
| 3103 | attachments: [], | |
| 3104 | modelSelection: CODEX_MODEL_SELECTION, | |
| 3105 | dispatchMode: { type: "start_immediately" }, | |
| 3106 | }, | |
| 3107 | ] satisfies ReadonlyArray<OrchestrationV2Command>; | |
| 3126 | 3108 | |
| 3127 | const projection = yield* Effect.gen(function* () { | |
| 3128 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 3129 | yield* orchestrator.dispatch(commands[0]!); | |
| 3130 | yield* orchestrator.dispatch(commands[1]!); | |
| 3131 | yield* waitForIdle(sourceThreadId); | |
| 3132 | yield* orchestrator.dispatch(commands[2]!); | |
| 3133 | yield* waitForIdle(sourceThreadId); | |
| 3134 | yield* orchestrator.dispatch(commands[3]!); | |
| 3135 | yield* orchestrator.dispatch(commands[4]!); | |
| 3136 | yield* waitForIdle(forkThreadId); | |
| 3137 | yield* orchestrator.dispatch(commands[5]!); | |
| 3138 | if (failResume) { | |
| 3139 | // A persisted native ref that is not loaded in this process must | |
| 3140 | // exercise resume rather than the session manager's warm cache. | |
| 3141 | const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); | |
| 3142 | const codexThread = beforeResume.providerThreads.find( | |
| 3143 | (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, | |
| 3144 | )!; | |
| 3145 | yield* (yield* EventSink.EventSinkV2).write({ | |
| 3146 | events: [ | |
| 3147 | { | |
| 3148 | id: EventId.make("unloaded-merge-target"), | |
| 3149 | type: "provider-thread.updated", | |
| 3150 | threadId: sourceThreadId, | |
| 3151 | occurredAt: yield* DateTime.now, | |
| 3152 | payload: { | |
| 3153 | ...codexThread, | |
| 3154 | nativeThreadRef: { | |
| 3155 | ...codexThread.nativeThreadRef!, | |
| 3156 | nativeId: "unloaded-native-merge-target", | |
| 3157 | }, | |
| 3158 | }, | |
| 3159 | }, | |
| 3160 | ], | |
| 3161 | }); | |
| 3162 | } | |
| 3163 | yield* orchestrator.dispatch(commands[6]!); | |
| 3164 | return yield* waitForIdle(sourceThreadId); | |
| 3165 | }).pipe( | |
| 3166 | Effect.provide( | |
| 3167 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 3109 | const projection = yield* Effect.gen(function* () { | |
| 3110 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 3111 | yield* orchestrator.dispatch(commands[0]!); | |
| 3112 | yield* orchestrator.dispatch(commands[1]!); | |
| 3113 | yield* waitForIdle(sourceThreadId); | |
| 3114 | yield* orchestrator.dispatch(commands[2]!); | |
| 3115 | yield* waitForIdle(sourceThreadId); | |
| 3116 | yield* orchestrator.dispatch(commands[3]!); | |
| 3117 | yield* orchestrator.dispatch(commands[4]!); | |
| 3118 | yield* waitForIdle(forkThreadId); | |
| 3119 | yield* orchestrator.dispatch(commands[5]!); | |
| 3120 | if (failResume) { | |
| 3121 | // A persisted native ref that is not loaded in this process must | |
| 3122 | // exercise resume rather than the session manager's warm cache. | |
| 3123 | const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); | |
| 3124 | const codexThread = beforeResume.providerThreads.find( | |
| 3125 | (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, | |
| 3126 | )!; | |
| 3127 | yield* (yield* EventSink.EventSinkV2).write({ | |
| 3128 | events: [ | |
| 3168 | 3129 | { |
| 3169 | name: "cross-provider-merge", | |
| 3170 | runtimePolicyOverride: { | |
| 3171 | cwd, | |
| 3172 | approvalPolicy: "never", | |
| 3173 | sandboxPolicy: { | |
| 3174 | type: "readOnly", | |
| 3175 | access: { type: "fullAccess" }, | |
| 3176 | networkAccess: false, | |
| 3130 | id: EventId.make("unloaded-merge-target"), | |
| 3131 | type: "provider-thread.updated", | |
| 3132 | threadId: sourceThreadId, | |
| 3133 | occurredAt: yield* DateTime.now, | |
| 3134 | payload: { | |
| 3135 | ...codexThread, | |
| 3136 | nativeThreadRef: { | |
| 3137 | ...codexThread.nativeThreadRef!, | |
| 3138 | nativeId: "unloaded-native-merge-target", | |
| 3177 | 3139 | }, |
| 3178 | 3140 | }, |
| 3179 | 3141 | }, |
| 3180 | registryLayer, | |
| 3181 | ), | |
| 3142 | ], | |
| 3143 | }); | |
| 3144 | } | |
| 3145 | yield* orchestrator.dispatch(commands[6]!); | |
| 3146 | return yield* waitForIdle(sourceThreadId); | |
| 3147 | }).pipe( | |
| 3148 | Effect.provide( | |
| 3149 | makeOrchestratorV2ReplayLayerWithRegistry( | |
| 3150 | { | |
| 3151 | name: "cross-provider-merge", | |
| 3152 | runtimePolicyOverride: { | |
| 3153 | cwd, | |
| 3154 | approvalPolicy: "never", | |
| 3155 | sandboxPolicy: { | |
| 3156 | type: "readOnly", | |
| 3157 | access: { type: "fullAccess" }, | |
| 3158 | networkAccess: false, | |
| 3159 | }, | |
| 3160 | }, | |
| 3161 | }, | |
| 3162 | registryLayer, | |
| 3182 | 3163 | ), |
| 3164 | ), | |
| 3165 | ); | |
| 3166 | const turns = yield* Ref.get(capturedTurns); | |
| 3167 | const mergedTurn = turns.findLast( | |
| 3168 | (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", | |
| 3169 | ); | |
| 3170 | const mergeTransfer = projection.contextTransfers.find( | |
| 3171 | (transfer) => transfer.type === "merge_back", | |
| 3172 | ); | |
| 3173 | ||
| 3174 | if (failResume) | |
| 3175 | assert.isAtLeast( | |
| 3176 | projection.contextHandoffs.filter( | |
| 3177 | (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, | |
| 3178 | ).length, | |
| 3179 | 2, | |
| 3183 | 3180 | ); |
| 3184 | const turns = yield* Ref.get(capturedTurns); | |
| 3185 | const mergedTurn = turns.findLast( | |
| 3186 | (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", | |
| 3187 | ); | |
| 3188 | const mergeTransfer = projection.contextTransfers.find( | |
| 3189 | (transfer) => transfer.type === "merge_back", | |
| 3181 | assert.isDefined(mergedTurn); | |
| 3182 | if (failResume) | |
| 3183 | assert.notEqual( | |
| 3184 | projection.providerThreads.find((thread) => thread.id === mergedTurn.providerThreadId) | |
| 3185 | ?.nativeThreadRef?.nativeId, | |
| 3186 | "unloaded-native-merge-target", | |
| 3190 | 3187 | ); |
| 3191 | ||
| 3192 | if (failResume) | |
| 3193 | assert.isAtLeast( | |
| 3194 | projection.contextHandoffs.filter( | |
| 3195 | (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, | |
| 3196 | ).length, | |
| 3197 | 2, | |
| 3198 | ); | |
| 3199 | assert.isDefined(mergedTurn); | |
| 3200 | if (failResume) | |
| 3201 | assert.notEqual( | |
| 3202 | projection.providerThreads.find( | |
| 3203 | (thread) => thread.id === mergedTurn.providerThreadId, | |
| 3204 | )?.nativeThreadRef?.nativeId, | |
| 3205 | "unloaded-native-merge-target", | |
| 3206 | ); | |
| 3207 | assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); | |
| 3208 | assert.include(mergedTurn.text, firstSourcePrompt); | |
| 3209 | assert.include(mergedTurn.text, "I will remember amber."); | |
| 3210 | assert.include(mergedTurn.text, secondSourcePrompt); | |
| 3211 | assert.include(mergedTurn.text, "I will remember violet."); | |
| 3212 | assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); | |
| 3213 | assert.include(mergedTurn.text, forkPrompt); | |
| 3214 | assert.include(mergedTurn.text, "I will remember cobalt."); | |
| 3215 | assert.include(mergedTurn.text, mergePrompt); | |
| 3216 | assert.isDefined(mergeTransfer); | |
| 3217 | assert.equal(mergeTransfer.status, "consumed"); | |
| 3218 | assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); | |
| 3219 | assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); | |
| 3220 | }), | |
| 3221 | ), | |
| 3222 | ); | |
| 3223 | } | |
| 3188 | assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); | |
| 3189 | assert.include(mergedTurn.text, firstSourcePrompt); | |
| 3190 | assert.include(mergedTurn.text, "I will remember amber."); | |
| 3191 | assert.include(mergedTurn.text, secondSourcePrompt); | |
| 3192 | assert.include(mergedTurn.text, "I will remember violet."); | |
| 3193 | assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); | |
| 3194 | assert.include(mergedTurn.text, forkPrompt); | |
| 3195 | assert.include(mergedTurn.text, "I will remember cobalt."); | |
| 3196 | assert.include(mergedTurn.text, mergePrompt); | |
| 3197 | assert.isDefined(mergeTransfer); | |
| 3198 | assert.equal(mergeTransfer.status, "consumed"); | |
| 3199 | assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); | |
| 3200 | assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); | |
| 3201 | }), | |
| 3202 | ), | |
| 3203 | ); | |
| 3224 | 3204 | |
| 3225 | 3205 | it.live("routes two custom instances of the same driver independently", () => |
| 3226 | 3206 | Effect.scoped( |
apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts+8-6| ... | ... | @@ -257,8 +257,9 @@ function makeCreateCommand(input: { |
| 257 | 257 | } |
| 258 | 258 | |
| 259 | 259 | describe("orchestration V2 merge-back provider replay", () => { |
| 260 | for (const variant of PROVIDERS) { | |
| 261 | it.effect(`merges one fork delta back into the original ${variant.driver} thread`, () => | |
| 260 | it.effect.each(PROVIDERS)( | |
| 261 | "merges one fork delta back into the original $driver thread", | |
| 262 | (variant) => | |
| 262 | 263 | Effect.gen(function* () { |
| 263 | 264 | const rawTranscript = yield* readTranscript("thread_merge_back_continue", variant.driver); |
| 264 | 265 | const materialized = yield* Effect.gen(function* () { |
| ... | ... | @@ -452,9 +453,11 @@ describe("orchestration V2 merge-back provider replay", () => { |
| 452 | 453 | assert.notInclude(visibleConversationText(source), "Context handoff ("); |
| 453 | 454 | assert.include(visibleConversationText(fork), "merge fork stored"); |
| 454 | 455 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 455 | ); | |
| 456 | ); | |
| 456 | 457 | |
| 457 | it.effect(`merges two sibling fork deltas into the original ${variant.driver} thread`, () => | |
| 458 | it.effect.each(PROVIDERS)( | |
| 459 | "merges two sibling fork deltas into the original $driver thread", | |
| 460 | (variant) => | |
| 458 | 461 | Effect.gen(function* () { |
| 459 | 462 | const rawTranscript = yield* readTranscript("thread_merge_back_siblings", variant.driver); |
| 460 | 463 | const materialized = yield* Effect.gen(function* () { |
| ... | ... | @@ -725,6 +728,5 @@ describe("orchestration V2 merge-back provider replay", () => { |
| 725 | 728 | assert.include(visibleConversationText(secondFork), "second merge sibling stored"); |
| 726 | 729 | assert.notInclude(visibleConversationText(secondFork), "first merge sibling stored"); |
| 727 | 730 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 728 | ); | |
| 729 | } | |
| 731 | ); | |
| 730 | 732 | }); |
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+4-4| ... | ... | @@ -394,8 +394,9 @@ layer("OrchestrationEventStore", (it) => { |
| 394 | 394 | ); |
| 395 | 395 | }); |
| 396 | 396 | |
| 397 | for (const phase of ["high-water", "replay"] as const) { | |
| 398 | it.effect(`bounds application live events while the ${phase} query is blocked`, () => | |
| 397 | it.effect.each(["high-water", "replay"] as const)( | |
| 398 | "bounds application live events while the %s query is blocked", | |
| 399 | (phase) => | |
| 399 | 400 | Effect.scoped( |
| 400 | 401 | Effect.gen(function* () { |
| 401 | 402 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; |
| ... | ... | @@ -464,8 +465,7 @@ for (const phase of ["high-water", "replay"] as const) { |
| 464 | 465 | } |
| 465 | 466 | }), |
| 466 | 467 | ).pipe(Effect.provide(Layer.fresh(TestLayer))), |
| 467 | ); | |
| 468 | } | |
| 468 | ); | |
| 469 | 469 | |
| 470 | 470 | it.effect("releases consumed application replay pages", () => |
| 471 | 471 | Effect.gen(function* () { |
apps/server/src/persistence/reconcileV2PreviewMigration.test.ts+4-4| ... | ... | @@ -60,8 +60,9 @@ describe("V2 preview upgrade", () => { |
| 60 | 60 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), |
| 61 | 61 | ); |
| 62 | 62 | |
| 63 | for (const withIndexes of [false, true]) { | |
| 64 | it.effect(`upgrades preview migration 54 with index cleanup ${withIndexes}`, () => | |
| 63 | it.effect.each([false, true])( | |
| 64 | "upgrades preview migration 54 with index cleanup %s", | |
| 65 | (withIndexes) => | |
| 65 | 66 | Effect.gen(function* () { |
| 66 | 67 | const sql = yield* SqlClient.SqlClient; |
| 67 | 68 | yield* runMigrations({ toMigrationInclusive: 52 }); |
| ... | ... | @@ -89,8 +90,7 @@ describe("V2 preview upgrade", () => { |
| 89 | 90 | }>`PRAGMA table_info(projection_threads)`; |
| 90 | 91 | assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at")); |
| 91 | 92 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), |
| 92 | ); | |
| 93 | } | |
| 93 | ); | |
| 94 | 94 | |
| 95 | 95 | it.effect("rolls back schema and ledger together on failure and can retry", () => |
| 96 | 96 | Effect.gen(function* () { |
apps/server/src/provider/CodexChatGptAuth.test.ts+107-118| ... | ... | @@ -426,36 +426,34 @@ it.effect( |
| 426 | 426 | }), |
| 427 | 427 | ), |
| 428 | 428 | ); |
| 429 | for (const failure of ["identity", "sharing"] as const) { | |
| 430 | it.effect( | |
| 431 | `failed account change preserves the original credentials and registration: ${failure}`, | |
| 432 | () => | |
| 433 | provision( | |
| 434 | Effect.gen(function* () { | |
| 435 | const h = yield* makeHarness; | |
| 436 | yield* h.signIn; | |
| 437 | yield* h.phase("succeeded"); | |
| 438 | const before = h.storedRecords(); | |
| 439 | h.setCallbackClientId("oaiapp_other_account"); | |
| 440 | if (failure === "identity") h.setInvalidNonce(); | |
| 441 | else h.declineSharing(); | |
| 442 | yield* h.changeAccount; | |
| 443 | yield* h.phase("failed"); | |
| 444 | assert.strictEqual( | |
| 445 | h.authorizationRequests[1]!.searchParams.get("client_id"), | |
| 446 | "dynamic_agent_client", | |
| 429 | it.effect.each(["identity", "sharing"] as const)( | |
| 430 | "failed account change preserves the original credentials and registration: %s", | |
| 431 | (failure) => | |
| 432 | provision( | |
| 433 | Effect.gen(function* () { | |
| 434 | const h = yield* makeHarness; | |
| 435 | yield* h.signIn; | |
| 436 | yield* h.phase("succeeded"); | |
| 437 | const before = h.storedRecords(); | |
| 438 | h.setCallbackClientId("oaiapp_other_account"); | |
| 439 | if (failure === "identity") h.setInvalidNonce(); | |
| 440 | else h.declineSharing(); | |
| 441 | yield* h.changeAccount; | |
| 442 | yield* h.phase("failed"); | |
| 443 | assert.strictEqual( | |
| 444 | h.authorizationRequests[1]!.searchParams.get("client_id"), | |
| 445 | "dynamic_agent_client", | |
| 446 | ); | |
| 447 | if (failure === "sharing") { | |
| 448 | assert.deepEqual( | |
| 449 | Option.getOrThrow(yield* h.auth.read), | |
| 450 | before.find((record) => record.accessToken), | |
| 447 | 451 | ); |
| 448 | if (failure === "sharing") { | |
| 449 | assert.deepEqual( | |
| 450 | Option.getOrThrow(yield* h.auth.read), | |
| 451 | before.find((record) => record.accessToken), | |
| 452 | ); | |
| 453 | assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); | |
| 454 | } else assert.deepEqual(h.storedRecords(), before); | |
| 455 | }), | |
| 456 | ), | |
| 457 | ); | |
| 458 | } | |
| 452 | assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); | |
| 453 | } else assert.deepEqual(h.storedRecords(), before); | |
| 454 | }), | |
| 455 | ), | |
| 456 | ); | |
| 459 | 457 | it.effect("retains the callback host and path after controller recreation and token removal", () => |
| 460 | 458 | provision( |
| 461 | 459 | Effect.gen(function* () { |
| ... | ... | @@ -961,48 +959,42 @@ it.effect("rejects mismatched callback state before any token exchange or creden |
| 961 | 959 | }), |
| 962 | 960 | ), |
| 963 | 961 | ); |
| 964 | for (const invalidClaim of ["issuer", "audience", "signature"] as const) { | |
| 965 | it.effect( | |
| 966 | `rejects ID token ${invalidClaim} verification before saving tokens or registration`, | |
| 967 | () => | |
| 968 | provision( | |
| 969 | Effect.gen(function* () { | |
| 970 | const h = yield* makeHarness; | |
| 971 | h.setIdentityFailure(invalidClaim); | |
| 972 | yield* h.signIn; | |
| 973 | assert.include((yield* h.phase("failed")).message!, "could not be verified"); | |
| 974 | assert.strictEqual(h.exchanges.length, 1); | |
| 975 | assert.deepEqual(h.storedRecords(), []); | |
| 976 | assert.isTrue(Option.isNone(yield* h.auth.read)); | |
| 977 | }), | |
| 978 | ), | |
| 979 | ); | |
| 980 | } | |
| 981 | for (const revoked of [false, true]) { | |
| 982 | it.effect( | |
| 983 | `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`, | |
| 984 | () => | |
| 985 | provision( | |
| 986 | Effect.gen(function* () { | |
| 987 | const h = yield* makeHarness; | |
| 988 | yield* h.signIn; | |
| 989 | yield* h.phase("succeeded"); | |
| 990 | if (revoked) yield* h.auth.revoke; | |
| 991 | const before = h.storedRecords(); | |
| 992 | h.setCallbackClientId("oaiapp_untrusted_callback"); | |
| 993 | yield* h.signIn; | |
| 994 | assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); | |
| 995 | assert.strictEqual( | |
| 996 | h.authorizationRequests[1]?.searchParams.get("client_id"), | |
| 997 | "oaiapp_test", | |
| 998 | ); | |
| 999 | assert.strictEqual(h.exchanges.length, 1); | |
| 1000 | assert.deepEqual(h.storedRecords(), before); | |
| 1001 | assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); | |
| 1002 | }), | |
| 1003 | ), | |
| 1004 | ); | |
| 1005 | } | |
| 962 | it.effect.each(["issuer", "audience", "signature"] as const)( | |
| 963 | "rejects ID token %s verification before saving tokens or registration", | |
| 964 | (invalidClaim) => | |
| 965 | provision( | |
| 966 | Effect.gen(function* () { | |
| 967 | const h = yield* makeHarness; | |
| 968 | h.setIdentityFailure(invalidClaim); | |
| 969 | yield* h.signIn; | |
| 970 | assert.include((yield* h.phase("failed")).message!, "could not be verified"); | |
| 971 | assert.strictEqual(h.exchanges.length, 1); | |
| 972 | assert.deepEqual(h.storedRecords(), []); | |
| 973 | assert.isTrue(Option.isNone(yield* h.auth.read)); | |
| 974 | }), | |
| 975 | ), | |
| 976 | ); | |
| 977 | it.effect.each([ | |
| 978 | { revoked: false, label: "without changing the current account" }, | |
| 979 | { revoked: true, label: "after token removal" }, | |
| 980 | ])("rejects conflicting callback client ID on reauthorization $label", ({ revoked }) => | |
| 981 | provision( | |
| 982 | Effect.gen(function* () { | |
| 983 | const h = yield* makeHarness; | |
| 984 | yield* h.signIn; | |
| 985 | yield* h.phase("succeeded"); | |
| 986 | if (revoked) yield* h.auth.revoke; | |
| 987 | const before = h.storedRecords(); | |
| 988 | h.setCallbackClientId("oaiapp_untrusted_callback"); | |
| 989 | yield* h.signIn; | |
| 990 | assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); | |
| 991 | assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); | |
| 992 | assert.strictEqual(h.exchanges.length, 1); | |
| 993 | assert.deepEqual(h.storedRecords(), before); | |
| 994 | assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); | |
| 995 | }), | |
| 996 | ), | |
| 997 | ); | |
| 1006 | 998 | |
| 1007 | 999 | it.effect("returns successful desktop sign-in to the original Welcome step", () => |
| 1008 | 1000 | provision( |
| ... | ... | @@ -1157,26 +1149,25 @@ it.effect( |
| 1157 | 1149 | ), |
| 1158 | 1150 | ); |
| 1159 | 1151 | |
| 1160 | for (const disconnected of [false, true]) { | |
| 1161 | it.effect( | |
| 1162 | `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`, | |
| 1163 | () => | |
| 1164 | provision( | |
| 1165 | Effect.gen(function* () { | |
| 1166 | const h = yield* makeHarness; | |
| 1167 | yield* h.signIn; | |
| 1168 | yield* h.phase("succeeded"); | |
| 1169 | if (disconnected) yield* h.auth.controller.logout(Effect.void); | |
| 1170 | const before = h.storedRecords(); | |
| 1171 | h.setIdentity("another-user", "another@example.test"); | |
| 1172 | yield* h.signIn; | |
| 1173 | assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); | |
| 1174 | assert.deepEqual(h.storedRecords(), before); | |
| 1175 | assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); | |
| 1176 | }), | |
| 1177 | ), | |
| 1178 | ); | |
| 1179 | } | |
| 1152 | it.effect.each([ | |
| 1153 | { disconnected: false, label: "while connected" }, | |
| 1154 | { disconnected: true, label: "after Disconnect" }, | |
| 1155 | ])("rejects a different verified identity during saved-profile reauth $label", ({ disconnected }) => | |
| 1156 | provision( | |
| 1157 | Effect.gen(function* () { | |
| 1158 | const h = yield* makeHarness; | |
| 1159 | yield* h.signIn; | |
| 1160 | yield* h.phase("succeeded"); | |
| 1161 | if (disconnected) yield* h.auth.controller.logout(Effect.void); | |
| 1162 | const before = h.storedRecords(); | |
| 1163 | h.setIdentity("another-user", "another@example.test"); | |
| 1164 | yield* h.signIn; | |
| 1165 | assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); | |
| 1166 | assert.deepEqual(h.storedRecords(), before); | |
| 1167 | assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); | |
| 1168 | }), | |
| 1169 | ), | |
| 1170 | ); | |
| 1180 | 1171 | |
| 1181 | 1172 | it.effect( |
| 1182 | 1173 | "retains both profiles and reuses the original account's client and callback when returning from another account", |
| ... | ... | @@ -1427,7 +1418,7 @@ it.effect( |
| 1427 | 1418 | ), |
| 1428 | 1419 | ); |
| 1429 | 1420 | |
| 1430 | for (const code of [ | |
| 1421 | it.effect.each([ | |
| 1431 | 1422 | "invalid_grant", |
| 1432 | 1423 | "invalid_refresh_token", |
| 1433 | 1424 | "token_expired", |
| ... | ... | @@ -1436,33 +1427,31 @@ for (const code of [ |
| 1436 | 1427 | "refresh_token_reused", |
| 1437 | 1428 | "invalid_client", |
| 1438 | 1429 | "invalid_token", |
| 1439 | ]) { | |
| 1440 | it.effect(`refresh recovery follows the machine-readable code: ${code}`, () => | |
| 1441 | provision( | |
| 1442 | Effect.gen(function* () { | |
| 1443 | const h = yield* makeHarness; | |
| 1444 | yield* h.signIn; | |
| 1445 | yield* h.phase("succeeded"); | |
| 1446 | yield* h.seedExpired; | |
| 1447 | h.setRefreshError(code); | |
| 1448 | yield* Effect.flip(h.auth.access); | |
| 1449 | assert.strictEqual( | |
| 1450 | Option.isNone(yield* h.auth.read), | |
| 1451 | !["invalid_client", "invalid_token"].includes(code), | |
| 1452 | ); | |
| 1453 | assert.isTrue( | |
| 1454 | h | |
| 1455 | .storedRecords() | |
| 1456 | .some((record) => | |
| 1457 | record.profiles?.some( | |
| 1458 | (profile: { clientId: string }) => profile.clientId === "oaiapp_test", | |
| 1459 | ), | |
| 1430 | ])("refresh recovery follows the machine-readable code: %s", (code) => | |
| 1431 | provision( | |
| 1432 | Effect.gen(function* () { | |
| 1433 | const h = yield* makeHarness; | |
| 1434 | yield* h.signIn; | |
| 1435 | yield* h.phase("succeeded"); | |
| 1436 | yield* h.seedExpired; | |
| 1437 | h.setRefreshError(code); | |
| 1438 | yield* Effect.flip(h.auth.access); | |
| 1439 | assert.strictEqual( | |
| 1440 | Option.isNone(yield* h.auth.read), | |
| 1441 | !["invalid_client", "invalid_token"].includes(code), | |
| 1442 | ); | |
| 1443 | assert.isTrue( | |
| 1444 | h | |
| 1445 | .storedRecords() | |
| 1446 | .some((record) => | |
| 1447 | record.profiles?.some( | |
| 1448 | (profile: { clientId: string }) => profile.clientId === "oaiapp_test", | |
| 1460 | 1449 | ), |
| 1461 | ); | |
| 1462 | }), | |
| 1463 | ), | |
| 1464 | ); | |
| 1465 | } | |
| 1450 | ), | |
| 1451 | ); | |
| 1452 | }), | |
| 1453 | ), | |
| 1454 | ); | |
| 1466 | 1455 | |
| 1467 | 1456 | it.effect( |
| 1468 | 1457 | "logout revokes the latest refresh token with the selected client and clears its ID hint", |
apps/server/src/provider/CodexInstallation.test.ts+19-23| ... | ... | @@ -92,8 +92,9 @@ const terminalState = (installation: CodexInstallation.CodexInstallation["Servic |
| 92 | 92 | Effect.map(Option.getOrThrow), |
| 93 | 93 | ); |
| 94 | 94 | |
| 95 | for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) { | |
| 96 | it.effect(`reuses installed Codex ${version} without downloading or taking ownership of it`, () => | |
| 95 | it.effect.each(["0.156.0", "0.156.1", "0.156.2", "0.157.0"])( | |
| 96 | "reuses installed Codex %s without downloading or taking ownership of it", | |
| 97 | (version) => | |
| 97 | 98 | Effect.gen(function* () { |
| 98 | 99 | const h = yield* makeHarness({ local: { version } }); |
| 99 | 100 | 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"]) { |
| 120 | 121 | expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); |
| 121 | 122 | expect((yield* h.installation.state).source).toBe("local"); |
| 122 | 123 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 123 | ); | |
| 124 | } | |
| 125 | for (const local of [ | |
| 124 | ); | |
| 125 | it.effect.each([ | |
| 126 | 126 | { version: "0.128.9" }, |
| 127 | 127 | { version: "0.145.0" }, |
| 128 | 128 | { version: "0.155.1" }, |
| ... | ... | @@ -131,24 +131,20 @@ for (const local of [ |
| 131 | 131 | { version: "unknown" }, |
| 132 | 132 | { version: "0.156.1", appServerFails: true }, |
| 133 | 133 | { version: "0.156.1", versionFails: true }, |
| 134 | ]) { | |
| 135 | it.effect( | |
| 136 | `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`, | |
| 137 | () => | |
| 138 | Effect.gen(function* () { | |
| 139 | const h = yield* makeHarness({ local }); | |
| 140 | expect((yield* h.installation.state).installedVersion).toBeNull(); | |
| 141 | yield* h.installation.start; | |
| 142 | const installed = yield* terminalState(h.installation); | |
| 143 | expect(installed.phase).toBe("succeeded"); | |
| 144 | const executable = yield* h.installation.resolve(); | |
| 145 | expect(executable.source).toBe("managed"); | |
| 146 | expect(installed.executablePath).toBe(executable.executablePath); | |
| 147 | expect(h.downloads()).toBe(1); | |
| 148 | expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); | |
| 149 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 150 | ); | |
| 151 | } | |
| 134 | ])("downloads the pinned release when the local CLI is unsupported or broken: %j", (local) => | |
| 135 | Effect.gen(function* () { | |
| 136 | const h = yield* makeHarness({ local }); | |
| 137 | expect((yield* h.installation.state).installedVersion).toBeNull(); | |
| 138 | yield* h.installation.start; | |
| 139 | const installed = yield* terminalState(h.installation); | |
| 140 | expect(installed.phase).toBe("succeeded"); | |
| 141 | const executable = yield* h.installation.resolve(); | |
| 142 | expect(executable.source).toBe("managed"); | |
| 143 | expect(installed.executablePath).toBe(executable.executablePath); | |
| 144 | expect(h.downloads()).toBe(1); | |
| 145 | expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); | |
| 146 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 147 | ); | |
| 152 | 148 | it.effect("falls back to a managed download when the reused local executable disappears", () => |
| 153 | 149 | Effect.gen(function* () { |
| 154 | 150 | const h = yield* makeHarness({ local: { version: "0.156.1" } }); |
apps/server/src/provider/CodexManagedRuntime.test.ts+164-168| ... | ... | @@ -48,178 +48,174 @@ it.effect("managed home defaults to the global Codex home and honors configured |
| 48 | 48 | assert.equal(configured.effectiveHomePath, "/custom/shadow"); |
| 49 | 49 | }).pipe(Effect.provide(NodeServices.layer)), |
| 50 | 50 | ); |
| 51 | for (const source of ["managed", "local"] as const) | |
| 52 | for (const account of ["primary", "additional"] as const) | |
| 53 | it.effect( | |
| 54 | `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`, | |
| 55 | () => | |
| 56 | Effect.gen(function* () { | |
| 57 | const instanceId = ProviderInstanceId.make( | |
| 58 | account === "primary" ? "codex" : "codex-personal", | |
| 59 | ); | |
| 60 | const fs = yield* FileSystem.FileSystem; | |
| 61 | const path = yield* Path.Path; | |
| 62 | const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); | |
| 63 | yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); | |
| 64 | yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); | |
| 65 | const data = new Map<string, Uint8Array>(); | |
| 66 | const secrets = ServerSecretStore.ServerSecretStore.of({ | |
| 67 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | |
| 68 | set: (key, value) => | |
| 69 | Effect.sync(() => { | |
| 70 | data.set(key, value); | |
| 71 | }), | |
| 72 | remove: (key) => | |
| 51 | it.effect.each( | |
| 52 | (["managed", "local"] as const).flatMap((source) => | |
| 53 | (["primary", "additional"] as const).map((account) => ({ source, account })), | |
| 54 | ), | |
| 55 | )( | |
| 56 | "$source Codex $account account shares home state without routing owned tokens through ambient CLI overrides", | |
| 57 | ({ source, account }) => | |
| 58 | Effect.gen(function* () { | |
| 59 | const instanceId = ProviderInstanceId.make( | |
| 60 | account === "primary" ? "codex" : "codex-personal", | |
| 61 | ); | |
| 62 | const fs = yield* FileSystem.FileSystem; | |
| 63 | const path = yield* Path.Path; | |
| 64 | const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); | |
| 65 | yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); | |
| 66 | yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); | |
| 67 | const data = new Map<string, Uint8Array>(); | |
| 68 | const secrets = ServerSecretStore.ServerSecretStore.of({ | |
| 69 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | |
| 70 | set: (key, value) => | |
| 71 | Effect.sync(() => { | |
| 72 | data.set(key, value); | |
| 73 | }), | |
| 74 | remove: (key) => | |
| 75 | Effect.sync(() => { | |
| 76 | data.delete(key); | |
| 77 | }), | |
| 78 | create: () => Effect.die("unused"), | |
| 79 | getOrCreateRandom: () => Effect.die("unused"), | |
| 80 | }); | |
| 81 | let leases = 0; | |
| 82 | const executable = { | |
| 83 | executablePath: | |
| 84 | source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", | |
| 85 | managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, | |
| 86 | source, | |
| 87 | version: "0.156.1", | |
| 88 | }; | |
| 89 | const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({ | |
| 90 | managedDirectory: "/isolated/tools/codex", | |
| 91 | resolve: () => Effect.succeed(executable), | |
| 92 | acquire: () => | |
| 93 | Effect.gen(function* () { | |
| 94 | leases++; | |
| 95 | yield* Effect.addFinalizer(() => | |
| 73 | 96 | Effect.sync(() => { |
| 74 | data.delete(key); | |
| 97 | leases--; | |
| 75 | 98 | }), |
| 76 | create: () => Effect.die("unused"), | |
| 77 | getOrCreateRandom: () => Effect.die("unused"), | |
| 78 | }); | |
| 79 | let leases = 0; | |
| 80 | const executable = { | |
| 81 | executablePath: | |
| 82 | source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", | |
| 83 | managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, | |
| 84 | source, | |
| 85 | version: "0.156.1", | |
| 86 | }; | |
| 87 | const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({ | |
| 88 | managedDirectory: "/isolated/tools/codex", | |
| 89 | resolve: () => Effect.succeed(executable), | |
| 90 | acquire: () => | |
| 91 | Effect.gen(function* () { | |
| 92 | leases++; | |
| 93 | yield* Effect.addFinalizer(() => | |
| 94 | Effect.sync(() => { | |
| 95 | leases--; | |
| 96 | }), | |
| 97 | ); | |
| 98 | return executable; | |
| 99 | }), | |
| 100 | }); | |
| 101 | yield* Effect.gen(function* () { | |
| 102 | const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); | |
| 103 | const json = yield* encodeJson({ | |
| 104 | clientId: "oaiapp_test", | |
| 105 | accessToken: "dummy-owned-access", | |
| 106 | refreshToken: "dummy-refresh", | |
| 107 | expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, | |
| 108 | earliestRefreshAt: null, | |
| 109 | scopes: ["chatgpt.tokens.use.direct"], | |
| 110 | subject: "test-user", | |
| 111 | email: null, | |
| 112 | }); | |
| 113 | yield* store.set(new TextEncoder().encode(json)); | |
| 114 | const ambient = { | |
| 115 | CODEX_HOME: "/user/.codex", | |
| 116 | OPENAI_API_KEY: "dummy-global-key", | |
| 117 | OPENAI_BASE_URL: "https://user-proxy.test", | |
| 118 | T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", | |
| 119 | PATH: "/usr/bin", | |
| 120 | }; | |
| 121 | const runtime = yield* makeCodexManagedRuntime({ | |
| 122 | instanceId, | |
| 123 | enabled: true, | |
| 124 | config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), | |
| 125 | environment: ambient, | |
| 126 | }); | |
| 127 | yield* Effect.gen(function* () { | |
| 128 | const effective = yield* runtime.resolve; | |
| 129 | assert.strictEqual(leases, 1); | |
| 130 | assert.strictEqual(effective.config.binaryPath, executable.executablePath); | |
| 131 | assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); | |
| 132 | assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); | |
| 133 | if (account === "primary") { | |
| 134 | assert.equal(effective.config.homePath, sharedHome); | |
| 135 | assert.equal(runtime.homeLayout.mode, "direct"); | |
| 136 | } else { | |
| 137 | assert.include(effective.config.homePath, instanceId); | |
| 138 | assert.include(effective.config.homePath, "userdata/providers/codex"); | |
| 139 | assert.equal(runtime.homeLayout.mode, "authOverlay"); | |
| 140 | assert.equal( | |
| 141 | yield* fs.readLink(path.join(effective.config.homePath, "sessions")), | |
| 142 | path.join(sharedHome, "sessions"), | |
| 143 | ); | |
| 144 | assert.equal( | |
| 145 | yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), | |
| 146 | path.join(sharedHome, "config.toml"), | |
| 147 | ); | |
| 148 | assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); | |
| 149 | } | |
| 150 | assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); | |
| 151 | assert.isUndefined(effective.environment.OPENAI_API_KEY); | |
| 152 | assert.isUndefined(effective.environment.OPENAI_BASE_URL); | |
| 153 | assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); | |
| 154 | const args = codexAppServerArgs(effective.config.launchArgs); | |
| 155 | assert.include( | |
| 156 | args, | |
| 157 | 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', | |
| 158 | ); | |
| 159 | assert.include( | |
| 160 | args, | |
| 161 | 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', | |
| 162 | ); | |
| 163 | assert.include(args, "features.api_key_model_discovery=true"); | |
| 164 | assert.notInclude(effective.config.launchArgs, "model_catalog_json"); | |
| 165 | assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); | |
| 166 | assert.include( | |
| 167 | args, | |
| 168 | "model_providers.openai_token_sharing.supports_websockets=false", | |
| 169 | ); | |
| 170 | assert.include( | |
| 171 | args, | |
| 172 | "model_providers.openai_token_sharing.requires_openai_auth=false", | |
| 173 | ); | |
| 174 | assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); | |
| 175 | assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); | |
| 176 | }).pipe(Effect.scoped); | |
| 177 | assert.strictEqual(leases, 0); | |
| 178 | yield* runtime.auth.controller.logout(Effect.void); | |
| 99 | ); | |
| 100 | return executable; | |
| 101 | }), | |
| 102 | }); | |
| 103 | yield* Effect.gen(function* () { | |
| 104 | const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); | |
| 105 | const json = yield* encodeJson({ | |
| 106 | clientId: "oaiapp_test", | |
| 107 | accessToken: "dummy-owned-access", | |
| 108 | refreshToken: "dummy-refresh", | |
| 109 | expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, | |
| 110 | earliestRefreshAt: null, | |
| 111 | scopes: ["chatgpt.tokens.use.direct"], | |
| 112 | subject: "test-user", | |
| 113 | email: null, | |
| 114 | }); | |
| 115 | yield* store.set(new TextEncoder().encode(json)); | |
| 116 | const ambient = { | |
| 117 | CODEX_HOME: "/user/.codex", | |
| 118 | OPENAI_API_KEY: "dummy-global-key", | |
| 119 | OPENAI_BASE_URL: "https://user-proxy.test", | |
| 120 | T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", | |
| 121 | PATH: "/usr/bin", | |
| 122 | }; | |
| 123 | const runtime = yield* makeCodexManagedRuntime({ | |
| 124 | instanceId, | |
| 125 | enabled: true, | |
| 126 | config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), | |
| 127 | environment: ambient, | |
| 128 | }); | |
| 129 | yield* Effect.gen(function* () { | |
| 130 | const effective = yield* runtime.resolve; | |
| 131 | assert.strictEqual(leases, 1); | |
| 132 | assert.strictEqual(effective.config.binaryPath, executable.executablePath); | |
| 133 | assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); | |
| 134 | assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); | |
| 135 | if (account === "primary") { | |
| 136 | assert.equal(effective.config.homePath, sharedHome); | |
| 137 | assert.equal(runtime.homeLayout.mode, "direct"); | |
| 138 | } else { | |
| 139 | assert.include(effective.config.homePath, instanceId); | |
| 140 | assert.include(effective.config.homePath, "userdata/providers/codex"); | |
| 141 | assert.equal(runtime.homeLayout.mode, "authOverlay"); | |
| 179 | 142 | assert.equal( |
| 180 | yield* fs.readFileString(path.join(sharedHome, "auth.json")), | |
| 181 | "native-auth-unchanged", | |
| 143 | yield* fs.readLink(path.join(effective.config.homePath, "sessions")), | |
| 144 | path.join(sharedHome, "sessions"), | |
| 182 | 145 | ); |
| 183 | }).pipe( | |
| 184 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), | |
| 185 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { | |
| 186 | getEnvironmentId: Effect.succeed( | |
| 187 | EnvironmentId.make("00000000-0000-4000-8000-000000000001"), | |
| 188 | ), | |
| 189 | }), | |
| 190 | Effect.provide(installerLayer), | |
| 191 | Effect.provideService( | |
| 192 | HttpClient.HttpClient, | |
| 193 | HttpClient.make((request) => | |
| 194 | Effect.sync(() => { | |
| 195 | assert.isTrue( | |
| 196 | [ | |
| 197 | "https://auth.openai.com/.well-known/openid-configuration", | |
| 198 | "https://auth.openai.com/revoke", | |
| 199 | ].includes(request.url), | |
| 200 | ); | |
| 201 | return HttpClientResponse.fromWeb( | |
| 202 | request, | |
| 203 | request.url.endsWith("/revoke") | |
| 204 | ? new Response(null, { status: 200 }) | |
| 205 | : Response.json({ | |
| 206 | issuer: "https://auth.openai.com", | |
| 207 | authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", | |
| 208 | token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", | |
| 209 | jwks_uri: "https://auth.openai.com/jwks", | |
| 210 | revocation_endpoint: "https://auth.openai.com/revoke", | |
| 211 | }), | |
| 212 | ); | |
| 213 | }), | |
| 214 | ), | |
| 215 | ), | |
| 146 | assert.equal( | |
| 147 | yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), | |
| 148 | path.join(sharedHome, "config.toml"), | |
| 149 | ); | |
| 150 | assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); | |
| 151 | } | |
| 152 | assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); | |
| 153 | assert.isUndefined(effective.environment.OPENAI_API_KEY); | |
| 154 | assert.isUndefined(effective.environment.OPENAI_BASE_URL); | |
| 155 | assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); | |
| 156 | const args = codexAppServerArgs(effective.config.launchArgs); | |
| 157 | assert.include( | |
| 158 | args, | |
| 159 | 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', | |
| 160 | ); | |
| 161 | assert.include( | |
| 162 | args, | |
| 163 | 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', | |
| 216 | 164 | ); |
| 217 | }).pipe( | |
| 218 | Effect.scoped, | |
| 219 | Effect.provide( | |
| 220 | ServerConfig.layerTest(process.cwd(), { | |
| 221 | prefix: "t3-managed-runtime-", | |
| 222 | }).pipe(Layer.provideMerge(NodeServices.layer)), | |
| 165 | assert.include(args, "features.api_key_model_discovery=true"); | |
| 166 | assert.notInclude(effective.config.launchArgs, "model_catalog_json"); | |
| 167 | assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); | |
| 168 | assert.include(args, "model_providers.openai_token_sharing.supports_websockets=false"); | |
| 169 | assert.include(args, "model_providers.openai_token_sharing.requires_openai_auth=false"); | |
| 170 | assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); | |
| 171 | assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); | |
| 172 | }).pipe(Effect.scoped); | |
| 173 | assert.strictEqual(leases, 0); | |
| 174 | yield* runtime.auth.controller.logout(Effect.void); | |
| 175 | assert.equal( | |
| 176 | yield* fs.readFileString(path.join(sharedHome, "auth.json")), | |
| 177 | "native-auth-unchanged", | |
| 178 | ); | |
| 179 | }).pipe( | |
| 180 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), | |
| 181 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { | |
| 182 | getEnvironmentId: Effect.succeed( | |
| 183 | EnvironmentId.make("00000000-0000-4000-8000-000000000001"), | |
| 184 | ), | |
| 185 | }), | |
| 186 | Effect.provide(installerLayer), | |
| 187 | Effect.provideService( | |
| 188 | HttpClient.HttpClient, | |
| 189 | HttpClient.make((request) => | |
| 190 | Effect.sync(() => { | |
| 191 | assert.isTrue( | |
| 192 | [ | |
| 193 | "https://auth.openai.com/.well-known/openid-configuration", | |
| 194 | "https://auth.openai.com/revoke", | |
| 195 | ].includes(request.url), | |
| 196 | ); | |
| 197 | return HttpClientResponse.fromWeb( | |
| 198 | request, | |
| 199 | request.url.endsWith("/revoke") | |
| 200 | ? new Response(null, { status: 200 }) | |
| 201 | : Response.json({ | |
| 202 | issuer: "https://auth.openai.com", | |
| 203 | authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", | |
| 204 | token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", | |
| 205 | jwks_uri: "https://auth.openai.com/jwks", | |
| 206 | revocation_endpoint: "https://auth.openai.com/revoke", | |
| 207 | }), | |
| 208 | ); | |
| 209 | }), | |
| 223 | 210 | ), |
| 224 | 211 | ), |
| 225 | ); | |
| 212 | ); | |
| 213 | }).pipe( | |
| 214 | Effect.scoped, | |
| 215 | Effect.provide( | |
| 216 | ServerConfig.layerTest(process.cwd(), { | |
| 217 | prefix: "t3-managed-runtime-", | |
| 218 | }).pipe(Layer.provideMerge(NodeServices.layer)), | |
| 219 | ), | |
| 220 | ), | |
| 221 | ); |
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+4-4| ... | ... | @@ -33,8 +33,9 @@ const mockRuntimeOptions = { |
| 33 | 33 | } satisfies AcpSessionRuntime.AcpSessionRuntimeOptions; |
| 34 | 34 | |
| 35 | 35 | describe("AcpSessionRuntime", () => { |
| 36 | for (const setupMethod of ["session/new", "session/resume"] as const) { | |
| 37 | it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () => | |
| 36 | it.effect.each(["session/new", "session/resume"] as const)( | |
| 37 | "buffers root metadata while %s startup is still pending", | |
| 38 | (setupMethod) => | |
| 38 | 39 | Effect.gen(function* () { |
| 39 | 40 | const setupReplied = yield* Deferred.make<void>(); |
| 40 | 41 | const allowStartup = yield* Deferred.make<void>(); |
| ... | ... | @@ -91,8 +92,7 @@ describe("AcpSessionRuntime", () => { |
| 91 | 92 | (yield* runtime.getConfigOptions).find((option) => option.category === "model"), |
| 92 | 93 | ).toMatchObject({ currentValue: "gpt-5.4" }); |
| 93 | 94 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 94 | ); | |
| 95 | } | |
| 95 | ); | |
| 96 | 96 | |
| 97 | 97 | it.effect("publishes model changes returned by a config request and live notifications", () => |
| 98 | 98 | Effect.gen(function* () { |
apps/server/src/pullRequest/PullRequestService.test.ts+145-149| ... | ... | @@ -2287,7 +2287,7 @@ it.effect("routes Azure reads and writes through the requested organization's ch |
| 2287 | 2287 | }), |
| 2288 | 2288 | ); |
| 2289 | 2289 | |
| 2290 | for (const checkout of [ | |
| 2290 | it.effect.each([ | |
| 2291 | 2291 | { |
| 2292 | 2292 | host: "ssh.dev.azure.com", |
| 2293 | 2293 | repository: "v3/org-b/project/web", |
| ... | ... | @@ -2303,57 +2303,55 @@ for (const checkout of [ |
| 2303 | 2303 | repository: "DefaultCollection/project/_git/web", |
| 2304 | 2304 | remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", |
| 2305 | 2305 | }, |
| 2306 | ]) { | |
| 2307 | it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () => | |
| 2308 | Effect.gen(function* () { | |
| 2309 | const seen: string[] = []; | |
| 2310 | const target = project({ | |
| 2311 | id: "target", | |
| 2312 | title: "target", | |
| 2313 | workspaceRoot: "/target", | |
| 2314 | provider: "azure-devops", | |
| 2315 | ...checkout, | |
| 2316 | }); | |
| 2317 | const service = yield* makeService({ | |
| 2318 | projects: [ | |
| 2319 | ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => | |
| 2320 | project({ | |
| 2321 | id: repository, | |
| 2322 | title: repository, | |
| 2323 | workspaceRoot: `/${repository}`, | |
| 2324 | provider: "azure-devops", | |
| 2325 | host: "dev.azure.com", | |
| 2326 | repository, | |
| 2327 | }), | |
| 2328 | ), | |
| 2329 | target, | |
| 2330 | ], | |
| 2331 | providers: [ | |
| 2332 | fakeProvider("azure-devops", { | |
| 2333 | getChangeRequestSummary: (input) => | |
| 2334 | Effect.sync(() => { | |
| 2335 | seen.push(`read ${input.cwd} ${input.repository}`); | |
| 2336 | return changeRequest(7, "2026-07-02T00:00:00Z"); | |
| 2337 | }), | |
| 2338 | runAction: (input) => | |
| 2339 | Effect.sync(() => { | |
| 2340 | seen.push(`write ${input.cwd} ${input.repository}`); | |
| 2341 | }), | |
| 2306 | ])("routes Azure URL reads and writes through a $host checkout", (checkout) => | |
| 2307 | Effect.gen(function* () { | |
| 2308 | const seen: string[] = []; | |
| 2309 | const target = project({ | |
| 2310 | id: "target", | |
| 2311 | title: "target", | |
| 2312 | workspaceRoot: "/target", | |
| 2313 | provider: "azure-devops", | |
| 2314 | ...checkout, | |
| 2315 | }); | |
| 2316 | const service = yield* makeService({ | |
| 2317 | projects: [ | |
| 2318 | ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => | |
| 2319 | project({ | |
| 2320 | id: repository, | |
| 2321 | title: repository, | |
| 2322 | workspaceRoot: `/${repository}`, | |
| 2323 | provider: "azure-devops", | |
| 2324 | host: "dev.azure.com", | |
| 2325 | repository, | |
| 2342 | 2326 | }), |
| 2343 | ], | |
| 2344 | }); | |
| 2345 | const reference = { | |
| 2346 | projectId: "org-a/project/_git/web" as ProjectId, | |
| 2347 | host: "dev.azure.com", | |
| 2348 | repository: "org-b/project/_git/web", | |
| 2349 | number: 7, | |
| 2350 | }; | |
| 2351 | yield* service.summary(reference, { recoverTransientFailure: false }); | |
| 2352 | yield* service.runAction({ ...reference, action: "merge" }); | |
| 2353 | assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); | |
| 2354 | }), | |
| 2355 | ); | |
| 2356 | } | |
| 2327 | ), | |
| 2328 | target, | |
| 2329 | ], | |
| 2330 | providers: [ | |
| 2331 | fakeProvider("azure-devops", { | |
| 2332 | getChangeRequestSummary: (input) => | |
| 2333 | Effect.sync(() => { | |
| 2334 | seen.push(`read ${input.cwd} ${input.repository}`); | |
| 2335 | return changeRequest(7, "2026-07-02T00:00:00Z"); | |
| 2336 | }), | |
| 2337 | runAction: (input) => | |
| 2338 | Effect.sync(() => { | |
| 2339 | seen.push(`write ${input.cwd} ${input.repository}`); | |
| 2340 | }), | |
| 2341 | }), | |
| 2342 | ], | |
| 2343 | }); | |
| 2344 | const reference = { | |
| 2345 | projectId: "org-a/project/_git/web" as ProjectId, | |
| 2346 | host: "dev.azure.com", | |
| 2347 | repository: "org-b/project/_git/web", | |
| 2348 | number: 7, | |
| 2349 | }; | |
| 2350 | yield* service.summary(reference, { recoverTransientFailure: false }); | |
| 2351 | yield* service.runAction({ ...reference, action: "merge" }); | |
| 2352 | assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); | |
| 2353 | }), | |
| 2354 | ); | |
| 2357 | 2355 | |
| 2358 | 2356 | it.effect("refuses Azure cross-organization reads and writes without its checkout", () => |
| 2359 | 2357 | Effect.gen(function* () { |
| ... | ... | @@ -5042,104 +5040,102 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho |
| 5042 | 5040 | }), |
| 5043 | 5041 | ); |
| 5044 | 5042 | |
| 5045 | for (const crossHost of [false, true]) { | |
| 5046 | it.effect( | |
| 5047 | `authorizes stack rebases and refreshes sibling layers (cross-host: ${crossHost})`, | |
| 5048 | () => | |
| 5049 | Effect.gen(function* () { | |
| 5050 | let taken = 0; | |
| 5051 | let summaryReads = 0; | |
| 5052 | let mutationFails = false; | |
| 5053 | let stackRebase = true; | |
| 5054 | let stackActions = true; | |
| 5055 | const capabilities = { | |
| 5056 | diff: true, | |
| 5057 | comment: true, | |
| 5058 | actions: ["update-branch"] as const, | |
| 5059 | mergeMethods: ["merge"] as const, | |
| 5060 | updateMethods: ["rebase"] as const, | |
| 5061 | get stackActions() { | |
| 5062 | return stackActions; | |
| 5063 | }, | |
| 5064 | search: true, | |
| 5065 | reactions: true, | |
| 5066 | review: FULL_REVIEW, | |
| 5067 | reviewers: FULL_REVIEWERS, | |
| 5068 | }; | |
| 5069 | const service = yield* makeService({ | |
| 5070 | projects: [ | |
| 5071 | project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), | |
| 5072 | project({ | |
| 5073 | id: "p2", | |
| 5074 | title: "enterprise", | |
| 5075 | workspaceRoot: "/b", | |
| 5076 | repository: "acme/web", | |
| 5077 | host: "enterprise.test", | |
| 5078 | }), | |
| 5079 | ], | |
| 5080 | providers: [ | |
| 5081 | fakeProvider("github", { | |
| 5082 | capabilities, | |
| 5083 | getViewerPermissions: () => | |
| 5084 | Effect.succeed({ | |
| 5085 | actions: [], | |
| 5086 | stackRebase, | |
| 5087 | comment: true, | |
| 5088 | resolve: false, | |
| 5089 | verdicts: [], | |
| 5090 | requestReviewers: false, | |
| 5091 | }), | |
| 5092 | getChangeRequestSummary: () => | |
| 5093 | Effect.sync(() => { | |
| 5094 | summaryReads++; | |
| 5095 | return changeRequest(8, "2026-07-01T00:00:00Z"); | |
| 5096 | }), | |
| 5097 | runAction: () => | |
| 5098 | Effect.gen(function* () { | |
| 5099 | taken++; | |
| 5100 | if (mutationFails) return yield* requestFailed; | |
| 5101 | }), | |
| 5102 | }), | |
| 5103 | ], | |
| 5104 | }); | |
| 5105 | const input = { | |
| 5106 | ...(crossHost ? { host: "enterprise.test" } : {}), | |
| 5107 | projectId: "p1" as ProjectId, | |
| 5108 | repository: "acme/web", | |
| 5109 | number: 3, | |
| 5110 | action: "update-branch" as const, | |
| 5111 | updateMethod: "rebase" as const, | |
| 5112 | stackNumber: 50, | |
| 5113 | expectedStackHeads: [{ number: 3, headSha: "ccc" }], | |
| 5114 | }; | |
| 5115 | yield* service.runAction(input); | |
| 5116 | assert.strictEqual(taken, 1); | |
| 5117 | const unrelated = { ...input, number: 8 }; | |
| 5118 | yield* service.summary(unrelated); | |
| 5119 | assert.strictEqual(summaryReads, 1); | |
| 5120 | stackRebase = false; | |
| 5121 | assert.strictEqual( | |
| 5122 | (yield* Effect.flip(service.runAction(input)))._tag, | |
| 5123 | "PullRequestOperationError", | |
| 5124 | ); | |
| 5125 | stackRebase = true; | |
| 5126 | stackActions = false; | |
| 5127 | assert.strictEqual( | |
| 5128 | (yield* Effect.flip(service.runAction(input)))._tag, | |
| 5129 | "PullRequestOperationError", | |
| 5130 | ); | |
| 5131 | assert.strictEqual(taken, 1); | |
| 5132 | yield* service.summary(unrelated); | |
| 5133 | assert.strictEqual(summaryReads, 1); | |
| 5134 | stackActions = true; | |
| 5135 | mutationFails = true; | |
| 5136 | yield* Effect.flip(service.runAction(input)); | |
| 5137 | assert.strictEqual(taken, 2); | |
| 5138 | yield* service.summary(unrelated); | |
| 5139 | assert.strictEqual(summaryReads, 2); | |
| 5140 | }), | |
| 5141 | ); | |
| 5142 | } | |
| 5043 | it.effect.each([false, true])( | |
| 5044 | "authorizes stack rebases and refreshes sibling layers (cross-host: %s)", | |
| 5045 | (crossHost) => | |
| 5046 | Effect.gen(function* () { | |
| 5047 | let taken = 0; | |
| 5048 | let summaryReads = 0; | |
| 5049 | let mutationFails = false; | |
| 5050 | let stackRebase = true; | |
| 5051 | let stackActions = true; | |
| 5052 | const capabilities = { | |
| 5053 | diff: true, | |
| 5054 | comment: true, | |
| 5055 | actions: ["update-branch"] as const, | |
| 5056 | mergeMethods: ["merge"] as const, | |
| 5057 | updateMethods: ["rebase"] as const, | |
| 5058 | get stackActions() { | |
| 5059 | return stackActions; | |
| 5060 | }, | |
| 5061 | search: true, | |
| 5062 | reactions: true, | |
| 5063 | review: FULL_REVIEW, | |
| 5064 | reviewers: FULL_REVIEWERS, | |
| 5065 | }; | |
| 5066 | const service = yield* makeService({ | |
| 5067 | projects: [ | |
| 5068 | project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), | |
| 5069 | project({ | |
| 5070 | id: "p2", | |
| 5071 | title: "enterprise", | |
| 5072 | workspaceRoot: "/b", | |
| 5073 | repository: "acme/web", | |
| 5074 | host: "enterprise.test", | |
| 5075 | }), | |
| 5076 | ], | |
| 5077 | providers: [ | |
| 5078 | fakeProvider("github", { | |
| 5079 | capabilities, | |
| 5080 | getViewerPermissions: () => | |
| 5081 | Effect.succeed({ | |
| 5082 | actions: [], | |
| 5083 | stackRebase, | |
| 5084 | comment: true, | |
| 5085 | resolve: false, | |
| 5086 | verdicts: [], | |
| 5087 | requestReviewers: false, | |
| 5088 | }), | |
| 5089 | getChangeRequestSummary: () => | |
| 5090 | Effect.sync(() => { | |
| 5091 | summaryReads++; | |
| 5092 | return changeRequest(8, "2026-07-01T00:00:00Z"); | |
| 5093 | }), | |
| 5094 | runAction: () => | |
| 5095 | Effect.gen(function* () { | |
| 5096 | taken++; | |
| 5097 | if (mutationFails) return yield* requestFailed; | |
| 5098 | }), | |
| 5099 | }), | |
| 5100 | ], | |
| 5101 | }); | |
| 5102 | const input = { | |
| 5103 | ...(crossHost ? { host: "enterprise.test" } : {}), | |
| 5104 | projectId: "p1" as ProjectId, | |
| 5105 | repository: "acme/web", | |
| 5106 | number: 3, | |
| 5107 | action: "update-branch" as const, | |
| 5108 | updateMethod: "rebase" as const, | |
| 5109 | stackNumber: 50, | |
| 5110 | expectedStackHeads: [{ number: 3, headSha: "ccc" }], | |
| 5111 | }; | |
| 5112 | yield* service.runAction(input); | |
| 5113 | assert.strictEqual(taken, 1); | |
| 5114 | const unrelated = { ...input, number: 8 }; | |
| 5115 | yield* service.summary(unrelated); | |
| 5116 | assert.strictEqual(summaryReads, 1); | |
| 5117 | stackRebase = false; | |
| 5118 | assert.strictEqual( | |
| 5119 | (yield* Effect.flip(service.runAction(input)))._tag, | |
| 5120 | "PullRequestOperationError", | |
| 5121 | ); | |
| 5122 | stackRebase = true; | |
| 5123 | stackActions = false; | |
| 5124 | assert.strictEqual( | |
| 5125 | (yield* Effect.flip(service.runAction(input)))._tag, | |
| 5126 | "PullRequestOperationError", | |
| 5127 | ); | |
| 5128 | assert.strictEqual(taken, 1); | |
| 5129 | yield* service.summary(unrelated); | |
| 5130 | assert.strictEqual(summaryReads, 1); | |
| 5131 | stackActions = true; | |
| 5132 | mutationFails = true; | |
| 5133 | yield* Effect.flip(service.runAction(input)); | |
| 5134 | assert.strictEqual(taken, 2); | |
| 5135 | yield* service.summary(unrelated); | |
| 5136 | assert.strictEqual(summaryReads, 2); | |
| 5137 | }), | |
| 5138 | ); | |
| 5143 | 5139 | |
| 5144 | 5140 | it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => |
| 5145 | 5141 | Effect.gen(function* () { |
apps/server/src/serverSettings.test.ts+75-74| ... | ... | @@ -1245,77 +1245,78 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1245 | 1245 | }).pipe(Effect.provide(settingsLayer)); |
| 1246 | 1246 | }); |
| 1247 | 1247 | |
| 1248 | for (const { label, variable, expected, duplicate } of [ | |
| 1249 | { | |
| 1250 | label: "preserves an inline secret on a redacted settings save", | |
| 1251 | variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, | |
| 1252 | expected: "inline-test-token", | |
| 1253 | }, | |
| 1254 | { | |
| 1255 | label: "preserves the effective last inline secret when names are duplicated", | |
| 1256 | variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, | |
| 1257 | expected: "last-inline-test-token", | |
| 1258 | duplicate: true, | |
| 1259 | }, | |
| 1260 | { | |
| 1261 | label: "replaces an inline secret with an explicit value", | |
| 1262 | variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true }, | |
| 1263 | expected: "replacement-test-token", | |
| 1264 | }, | |
| 1265 | { | |
| 1266 | label: "clears an inline secret with an explicit empty value", | |
| 1267 | variable: { name: "API_TOKEN", value: "", sensitive: true }, | |
| 1268 | expected: "", | |
| 1269 | }, | |
| 1270 | ]) { | |
| 1271 | it.effect(label, () => | |
| 1272 | Effect.gen(function* () { | |
| 1273 | const instanceId = ProviderInstanceId.make("codex_personal"); | |
| 1274 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1275 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 1276 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1277 | yield* fileSystem.writeFileString( | |
| 1278 | serverConfig.settingsPath, | |
| 1279 | duplicate | |
| 1280 | ? '{"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":{}}}}' | |
| 1281 | : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}', | |
| 1282 | ); | |
| 1283 | const initial = yield* serverSettings.getSettings; | |
| 1284 | assert.equal( | |
| 1285 | initial.providerInstances[instanceId]?.environment?.[0]?.value, | |
| 1286 | "inline-test-token", | |
| 1287 | ); | |
| 1248 | it.effect.each( | |
| 1249 | [ | |
| 1250 | { | |
| 1251 | label: "preserves an inline secret on a redacted settings save", | |
| 1252 | variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, | |
| 1253 | expected: "inline-test-token", | |
| 1254 | }, | |
| 1255 | { | |
| 1256 | label: "preserves the effective last inline secret when names are duplicated", | |
| 1257 | variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, | |
| 1258 | expected: "last-inline-test-token", | |
| 1259 | duplicate: true, | |
| 1260 | }, | |
| 1261 | { | |
| 1262 | label: "replaces an inline secret with an explicit value", | |
| 1263 | variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true }, | |
| 1264 | expected: "replacement-test-token", | |
| 1265 | }, | |
| 1266 | { | |
| 1267 | label: "clears an inline secret with an explicit empty value", | |
| 1268 | variable: { name: "API_TOKEN", value: "", sensitive: true }, | |
| 1269 | expected: "", | |
| 1270 | }, | |
| 1271 | ].map((testCase) => [testCase.label, testCase] as const), | |
| 1272 | )("%s", ([, { variable, expected, duplicate }]) => | |
| 1273 | Effect.gen(function* () { | |
| 1274 | const instanceId = ProviderInstanceId.make("codex_personal"); | |
| 1275 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1276 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 1277 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1278 | yield* fileSystem.writeFileString( | |
| 1279 | serverConfig.settingsPath, | |
| 1280 | duplicate | |
| 1281 | ? '{"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":{}}}}' | |
| 1282 | : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}', | |
| 1283 | ); | |
| 1284 | const initial = yield* serverSettings.getSettings; | |
| 1285 | assert.equal( | |
| 1286 | initial.providerInstances[instanceId]?.environment?.[0]?.value, | |
| 1287 | "inline-test-token", | |
| 1288 | ); | |
| 1288 | 1289 | |
| 1289 | const next = yield* serverSettings.updateSettings({ | |
| 1290 | providerInstances: { | |
| 1291 | [instanceId]: { | |
| 1292 | driver: ProviderDriverKind.make("codex"), | |
| 1293 | displayName: "Renamed provider", | |
| 1294 | environment: duplicate ? [variable, variable] : [variable], | |
| 1295 | config: {}, | |
| 1296 | }, | |
| 1290 | const next = yield* serverSettings.updateSettings({ | |
| 1291 | providerInstances: { | |
| 1292 | [instanceId]: { | |
| 1293 | driver: ProviderDriverKind.make("codex"), | |
| 1294 | displayName: "Renamed provider", | |
| 1295 | environment: duplicate ? [variable, variable] : [variable], | |
| 1296 | config: {}, | |
| 1297 | 1297 | }, |
| 1298 | }); | |
| 1299 | assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected); | |
| 1300 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | |
| 1301 | assert.notInclude(raw, "inline-test-token"); | |
| 1302 | assert.notInclude(raw, "replacement-test-token"); | |
| 1303 | ||
| 1304 | const reloaded = yield* Effect.gen(function* () { | |
| 1305 | const fresh = yield* ServerSettingsModule.ServerSettingsService; | |
| 1306 | return yield* fresh.getSettings; | |
| 1307 | }).pipe( | |
| 1308 | Effect.provide( | |
| 1309 | Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), | |
| 1310 | ), | |
| 1311 | ); | |
| 1312 | assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected); | |
| 1313 | }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 1314 | ); | |
| 1315 | } | |
| 1298 | }, | |
| 1299 | }); | |
| 1300 | assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected); | |
| 1301 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | |
| 1302 | assert.notInclude(raw, "inline-test-token"); | |
| 1303 | assert.notInclude(raw, "replacement-test-token"); | |
| 1304 | ||
| 1305 | const reloaded = yield* Effect.gen(function* () { | |
| 1306 | const fresh = yield* ServerSettingsModule.ServerSettingsService; | |
| 1307 | return yield* fresh.getSettings; | |
| 1308 | }).pipe( | |
| 1309 | Effect.provide( | |
| 1310 | Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), | |
| 1311 | ), | |
| 1312 | ); | |
| 1313 | assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected); | |
| 1314 | }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 1315 | ); | |
| 1316 | 1316 | |
| 1317 | for (const sensitiveLast of [true, false]) { | |
| 1318 | it.effect(`preserves duplicate secret operation order (sensitive last: ${sensitiveLast})`, () => | |
| 1317 | it.effect.each([true, false])( | |
| 1318 | "preserves duplicate secret operation order (sensitive last: %s)", | |
| 1319 | (sensitiveLast) => | |
| 1319 | 1320 | Effect.gen(function* () { |
| 1320 | 1321 | const service = yield* ServerSettingsModule.ServerSettingsService; |
| 1321 | 1322 | const instanceId = ProviderInstanceId.make("codex_duplicate"); |
| ... | ... | @@ -1339,8 +1340,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1339 | 1340 | sensitiveLast ? "secret-last" : "", |
| 1340 | 1341 | ); |
| 1341 | 1342 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 1342 | ); | |
| 1343 | } | |
| 1343 | ); | |
| 1344 | 1344 | |
| 1345 | 1345 | it.effect("stores sensitive provider instance environment values outside settings.json", () => |
| 1346 | 1346 | Effect.gen(function* () { |
| ... | ... | @@ -1630,8 +1630,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1630 | 1630 | }), |
| 1631 | 1631 | ); |
| 1632 | 1632 | |
| 1633 | for (const failure of ["response materialization", "partially committed write"] as const) { | |
| 1634 | it.effect(`rolls back provider secret changes after ${failure} fails`, () => { | |
| 1633 | it.effect.each(["response materialization", "partially committed write"] as const)( | |
| 1634 | "rolls back provider secret changes after %s fails", | |
| 1635 | (failure) => { | |
| 1635 | 1636 | const textDecoder = new TextDecoder(); |
| 1636 | 1637 | const secrets = new Map<string, Uint8Array>(); |
| 1637 | 1638 | let rejectNewSecret = false; |
| ... | ... | @@ -1734,8 +1735,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1734 | 1735 | "sk-kept", |
| 1735 | 1736 | ); |
| 1736 | 1737 | }).pipe(Effect.provide(settingsLayer)); |
| 1737 | }); | |
| 1738 | } | |
| 1738 | }, | |
| 1739 | ); | |
| 1739 | 1740 | |
| 1740 | 1741 | it.effect("folds legacy project overrides into projectSettingsOverrides once", () => |
| 1741 | 1742 | Effect.gen(function* () { |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+8-8| ... | ... | @@ -408,8 +408,9 @@ it("reports an update hint instead of unauthenticated when gh predates --json", |
| 408 | 408 | ); |
| 409 | 409 | }); |
| 410 | 410 | |
| 411 | for (const kind of ["pull", "issues"]) { | |
| 412 | it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () => | |
| 411 | it.effect.each(["pull", "issues"])( | |
| 412 | "resolves %s subjects on the linked host without using the checkout", | |
| 413 | (kind) => | |
| 413 | 414 | Effect.gen(function* () { |
| 414 | 415 | const provider = yield* makeProvider({ |
| 415 | 416 | execute: (input) => { |
| ... | ... | @@ -449,11 +450,11 @@ for (const kind of ["pull", "issues"]) { |
| 449 | 450 | undefined, |
| 450 | 451 | ); |
| 451 | 452 | }), |
| 452 | ); | |
| 453 | } | |
| 453 | ); | |
| 454 | 454 | |
| 455 | for (const stage of ["read", "decode"] as const) { | |
| 456 | it.effect(`retains the ${stage} failure without exposing its raw contents`, () => | |
| 455 | it.effect.each(["read", "decode"] as const)( | |
| 456 | "retains the %s failure without exposing its raw contents", | |
| 457 | (stage) => | |
| 457 | 458 | Effect.gen(function* () { |
| 458 | 459 | const cause = new GitHubCli.GitHubCliCommandError({ |
| 459 | 460 | command: "gh", |
| ... | ... | @@ -484,5 +485,4 @@ for (const stage of ["read", "decode"] as const) { |
| 484 | 485 | if (stage === "read") assert.strictEqual(error.cause, cause); |
| 485 | 486 | else assert.propertyVal(error.cause, "_tag", "SchemaError"); |
| 486 | 487 | }), |
| 487 | ); | |
| 488 | } | |
| 488 | ); |
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+8-8| ... | ... | @@ -227,8 +227,9 @@ selfhosted |
| 227 | 227 | ); |
| 228 | 228 | }); |
| 229 | 229 | |
| 230 | for (const kind of ["merge_requests", "issues"]) { | |
| 231 | it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () => | |
| 230 | it.effect.each(["merge_requests", "issues"])( | |
| 231 | "resolves %s subjects on the linked host without using the checkout", | |
| 232 | (kind) => | |
| 232 | 233 | Effect.gen(function* () { |
| 233 | 234 | const provider = yield* makeProvider({ |
| 234 | 235 | execute: (input) => { |
| ... | ... | @@ -269,11 +270,11 @@ for (const kind of ["merge_requests", "issues"]) { |
| 269 | 270 | undefined, |
| 270 | 271 | ); |
| 271 | 272 | }), |
| 272 | ); | |
| 273 | } | |
| 273 | ); | |
| 274 | 274 | |
| 275 | for (const stage of ["read", "decode"] as const) { | |
| 276 | it.effect(`retains the ${stage} failure without exposing its raw contents`, () => | |
| 275 | it.effect.each(["read", "decode"] as const)( | |
| 276 | "retains the %s failure without exposing its raw contents", | |
| 277 | (stage) => | |
| 277 | 278 | Effect.gen(function* () { |
| 278 | 279 | const cause = new GitLabCli.GitLabCliCommandError({ |
| 279 | 280 | command: "glab", |
| ... | ... | @@ -305,5 +306,4 @@ for (const stage of ["read", "decode"] as const) { |
| 305 | 306 | if (stage === "read") assert.strictEqual(error.cause, cause); |
| 306 | 307 | else assert.propertyVal(error.cause, "_tag", "SchemaError"); |
| 307 | 308 | }), |
| 308 | ); | |
| 309 | } | |
| 309 | ); |
apps/server/src/terminal/Manager.test.ts+4-4| ... | ... | @@ -1461,8 +1461,9 @@ it.layer( |
| 1461 | 1461 | }), |
| 1462 | 1462 | ); |
| 1463 | 1463 | |
| 1464 | for (const source of ["current", "legacy"] as const) { | |
| 1465 | it.effect(`reads only a Unicode-safe tail from oversized ${source} history`, () => | |
| 1464 | it.effect.each(["current", "legacy"] as const)( | |
| 1465 | "reads only a Unicode-safe tail from oversized %s history", | |
| 1466 | (source) => | |
| 1466 | 1467 | Effect.gen(function* () { |
| 1467 | 1468 | const fs = yield* FileSystem.FileSystem; |
| 1468 | 1469 | const path = yield* Path.Path; |
| ... | ... | @@ -1513,8 +1514,7 @@ it.layer( |
| 1513 | 1514 | yield* manager.close({ threadId: "thread-1" }); |
| 1514 | 1515 | expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré"); |
| 1515 | 1516 | }), |
| 1516 | ); | |
| 1517 | } | |
| 1517 | ); | |
| 1518 | 1518 | |
| 1519 | 1519 | it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => |
| 1520 | 1520 | Effect.gen(function* () { |
apps/server/src/terminal/NodePtyAdapter.test.ts+16-16| ... | ... | @@ -110,8 +110,9 @@ it.effect("waits for the Windows PID without requiring output", () => |
| 110 | 110 | }).pipe(Effect.provide(testLayer)), |
| 111 | 111 | ); |
| 112 | 112 | |
| 113 | for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { | |
| 114 | it.effect(`fails Windows startup on ${failure} and cleans up`, () => | |
| 113 | it.effect.each(["exit", "close", "error", "invalid-pid"] as const)( | |
| 114 | "fails Windows startup on %s and cleans up", | |
| 115 | (failure) => | |
| 115 | 116 | Effect.gen(function* () { |
| 116 | 117 | const { nativeProcess, subscribed } = preparePendingProcess(); |
| 117 | 118 | const adapter = yield* PtyAdapter.PtyAdapter; |
| ... | ... | @@ -129,8 +130,7 @@ for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { |
| 129 | 130 | assert.equal(nativeProcess.events.listenerCount("exit"), 0); |
| 130 | 131 | assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); |
| 131 | 132 | }).pipe(Effect.provide(testLayer)), |
| 132 | ); | |
| 133 | } | |
| 133 | ); | |
| 134 | 134 | |
| 135 | 135 | it.effect("cancels the Windows connection without waiting for output", () => |
| 136 | 136 | Effect.gen(function* () { |
| ... | ... | @@ -160,8 +160,9 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", () |
| 160 | 160 | }).pipe(Effect.provide(testLayer)), |
| 161 | 161 | ); |
| 162 | 162 | |
| 163 | for (const platform of ["win32", "linux", "darwin"] as const) { | |
| 164 | it.effect(`terminates through node-pty using ${platform} semantics`, () => | |
| 163 | it.effect.each(["win32", "linux", "darwin"] as const)( | |
| 164 | "terminates through node-pty using %s semantics", | |
| 165 | (platform) => | |
| 165 | 166 | Effect.gen(function* () { |
| 166 | 167 | const adapter = yield* PtyAdapter.PtyAdapter; |
| 167 | 168 | const process = yield* adapter.spawn({ |
| ... | ... | @@ -189,8 +190,7 @@ for (const platform of ["win32", "linux", "darwin"] as const) { |
| 189 | 190 | : [["SIGTERM"], ["SIGKILL"], [undefined]], |
| 190 | 191 | ); |
| 191 | 192 | }).pipe(Effect.provide(makeTestLayer(platform))), |
| 192 | ); | |
| 193 | } | |
| 193 | ); | |
| 194 | 194 | |
| 195 | 195 | it.effect("spawns through the public adapter with the provided host references", () => |
| 196 | 196 | Effect.gen(function* () { |
| ... | ... | @@ -279,8 +279,9 @@ it.effect("reports native module load failures as structured startup defects", ( |
| 279 | 279 | ), |
| 280 | 280 | ); |
| 281 | 281 | |
| 282 | for (const budget of [2048, 8]) { | |
| 283 | it.effect(`preserves an exit during readiness handoff with scheduler budget ${budget}`, () => | |
| 282 | it.effect.each([2048, 8])( | |
| 283 | "preserves an exit during readiness handoff with scheduler budget %s", | |
| 284 | (budget) => | |
| 284 | 285 | Effect.gen(function* () { |
| 285 | 286 | const { nativeProcess, subscribed } = preparePendingProcess(); |
| 286 | 287 | const adapter = yield* PtyAdapter.PtyAdapter; |
| ... | ... | @@ -300,8 +301,7 @@ for (const budget of [2048, 8]) { |
| 300 | 301 | yield* Fiber.join(fiber); |
| 301 | 302 | assert.equal(exits.length, 1); |
| 302 | 303 | }).pipe(Effect.provide(testLayer)), |
| 303 | ); | |
| 304 | } | |
| 304 | ); | |
| 305 | 305 | |
| 306 | 306 | it.effect("replays an exit to late subscribers and respects unsubscription", () => |
| 307 | 307 | Effect.gen(function* () { |
| ... | ... | @@ -320,8 +320,9 @@ it.effect("replays an exit to late subscribers and respects unsubscription", () |
| 320 | 320 | }).pipe(Effect.provide(testLayer)), |
| 321 | 321 | ); |
| 322 | 322 | |
| 323 | for (const failure of ["spawn", "interrupt"] as const) { | |
| 324 | it.effect(`logs cleanup failures without replacing ${failure}`, () => | |
| 323 | it.effect.each(["spawn", "interrupt"] as const)( | |
| 324 | "logs cleanup failures without replacing %s", | |
| 325 | (failure) => | |
| 325 | 326 | Effect.gen(function* () { |
| 326 | 327 | const { nativeProcess, subscribed } = preparePendingProcess(); |
| 327 | 328 | const killError = new Error("native kill failed"); |
| ... | ... | @@ -361,5 +362,4 @@ for (const failure of ["spawn", "interrupt"] as const) { |
| 361 | 362 | assert.equal(nativeProcess.events.listenerCount("exit"), 0); |
| 362 | 363 | assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); |
| 363 | 364 | }).pipe(Effect.provide(testLayer)), |
| 364 | ); | |
| 365 | } | |
| 365 | ); |
apps/server/src/terminal/OutputProtocol.test.ts+56-58| ... | ... | @@ -11,69 +11,67 @@ import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/u |
| 11 | 11 | import { withTerminalOutputWindow } from "./OutputProtocol.ts"; |
| 12 | 12 | |
| 13 | 13 | describe("terminal output window", () => { |
| 14 | for (const { tag, size, limit } of [ | |
| 14 | it.effect.each([ | |
| 15 | 15 | { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, |
| 16 | 16 | { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, |
| 17 | 17 | { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, |
| 18 | 18 | { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, |
| 19 | ]) { | |
| 20 | it.effect(`limits ${tag} with ${size}-byte values to ${limit} pending chunks`, () => | |
| 21 | Effect.gen(function* () { | |
| 22 | const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); | |
| 23 | const output = yield* Queue.unbounded<string>(); | |
| 24 | const responses = yield* Queue.unbounded<RpcMessage.FromServerEncoded>(); | |
| 25 | const receive = yield* Deferred.make<Parameters<RpcServer.Protocol["Service"]["run"]>[0]>(); | |
| 26 | const protocol = yield* RpcServer.Protocol.make((write) => | |
| 27 | Effect.gen(function* () { | |
| 28 | yield* Deferred.succeed(receive, write); | |
| 29 | const serialization = yield* RpcSerialization.RpcSerialization; | |
| 30 | return { | |
| 31 | disconnects: yield* Queue.unbounded<number>(), | |
| 32 | send: (_clientId, response) => Queue.offer(responses, response), | |
| 33 | end: () => Effect.void, | |
| 34 | clientIds: Effect.succeed(new Set([0])), | |
| 35 | initialMessage: Effect.succeedNone, | |
| 36 | supportsAck: true, | |
| 37 | supportsTransferables: false, | |
| 38 | supportsSpanPropagation: false, | |
| 39 | supportsNotifications: true, | |
| 40 | codecFor: serialization.codecFor, | |
| 41 | }; | |
| 42 | }), | |
| 43 | ); | |
| 44 | yield* RpcServer.make(group).pipe( | |
| 45 | Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), | |
| 46 | Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), | |
| 47 | Effect.forkScoped, | |
| 48 | ); | |
| 49 | const write = yield* Deferred.await(receive); | |
| 50 | yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); | |
| 19 | ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) => | |
| 20 | Effect.gen(function* () { | |
| 21 | const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); | |
| 22 | const output = yield* Queue.unbounded<string>(); | |
| 23 | const responses = yield* Queue.unbounded<RpcMessage.FromServerEncoded>(); | |
| 24 | const receive = yield* Deferred.make<Parameters<RpcServer.Protocol["Service"]["run"]>[0]>(); | |
| 25 | const protocol = yield* RpcServer.Protocol.make((write) => | |
| 26 | Effect.gen(function* () { | |
| 27 | yield* Deferred.succeed(receive, write); | |
| 28 | const serialization = yield* RpcSerialization.RpcSerialization; | |
| 29 | return { | |
| 30 | disconnects: yield* Queue.unbounded<number>(), | |
| 31 | send: (_clientId, response) => Queue.offer(responses, response), | |
| 32 | end: () => Effect.void, | |
| 33 | clientIds: Effect.succeed(new Set([0])), | |
| 34 | initialMessage: Effect.succeedNone, | |
| 35 | supportsAck: true, | |
| 36 | supportsTransferables: false, | |
| 37 | supportsSpanPropagation: false, | |
| 38 | supportsNotifications: true, | |
| 39 | codecFor: serialization.codecFor, | |
| 40 | }; | |
| 41 | }), | |
| 42 | ); | |
| 43 | yield* RpcServer.make(group).pipe( | |
| 44 | Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), | |
| 45 | Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), | |
| 46 | Effect.forkScoped, | |
| 47 | ); | |
| 48 | const write = yield* Deferred.await(receive); | |
| 49 | yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); | |
| 51 | 50 | |
| 52 | for (let index = 0; index < limit; index++) { | |
| 53 | const value = String(index).repeat(size); | |
| 54 | yield* Queue.offer(output, value); | |
| 55 | yield* TestClock.adjust(0); | |
| 56 | assert.equal(yield* Queue.size(responses), 1); | |
| 57 | assert.deepEqual(yield* Queue.take(responses), { | |
| 58 | _tag: "Chunk", | |
| 59 | requestId: "1", | |
| 60 | values: [value], | |
| 61 | }); | |
| 62 | } | |
| 63 | yield* Queue.offer(output, "i".repeat(size)); | |
| 51 | for (let index = 0; index < limit; index++) { | |
| 52 | const value = String(index).repeat(size); | |
| 53 | yield* Queue.offer(output, value); | |
| 64 | 54 | yield* TestClock.adjust(0); |
| 65 | assert.equal(yield* Queue.size(responses), 0); | |
| 66 | yield* write(0, { _tag: "Ack", requestId: "1" }); | |
| 67 | const resumed = yield* Queue.take(responses); | |
| 68 | assert.equal(resumed._tag, "Chunk"); | |
| 69 | if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); | |
| 55 | assert.equal(yield* Queue.size(responses), 1); | |
| 56 | assert.deepEqual(yield* Queue.take(responses), { | |
| 57 | _tag: "Chunk", | |
| 58 | requestId: "1", | |
| 59 | values: [value], | |
| 60 | }); | |
| 61 | } | |
| 62 | yield* Queue.offer(output, "i".repeat(size)); | |
| 63 | yield* TestClock.adjust(0); | |
| 64 | assert.equal(yield* Queue.size(responses), 0); | |
| 65 | yield* write(0, { _tag: "Ack", requestId: "1" }); | |
| 66 | const resumed = yield* Queue.take(responses); | |
| 67 | assert.equal(resumed._tag, "Chunk"); | |
| 68 | if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); | |
| 70 | 69 | |
| 71 | yield* Queue.offer(output, "j"); | |
| 72 | yield* TestClock.adjust(0); | |
| 73 | assert.equal(yield* Queue.size(responses), 0); | |
| 74 | yield* write(0, { _tag: "Interrupt", requestId: "1" }); | |
| 75 | assert.equal((yield* Queue.take(responses))._tag, "Exit"); | |
| 76 | }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), | |
| 77 | ); | |
| 78 | } | |
| 70 | yield* Queue.offer(output, "j"); | |
| 71 | yield* TestClock.adjust(0); | |
| 72 | assert.equal(yield* Queue.size(responses), 0); | |
| 73 | yield* write(0, { _tag: "Interrupt", requestId: "1" }); | |
| 74 | assert.equal((yield* Queue.take(responses))._tag, "Exit"); | |
| 75 | }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), | |
| 76 | ); | |
| 79 | 77 | }); |
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+75-77| ... | ... | @@ -461,69 +461,69 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 461 | 461 | }), |
| 462 | 462 | ); |
| 463 | 463 | |
| 464 | for (const verbose of [false, true]) { | |
| 465 | it.effect(`unwraps a JSON title in ${verbose ? "verbose" : "normal"} Claude output`, () => { | |
| 466 | const result = { | |
| 467 | type: "result", | |
| 468 | structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' }, | |
| 469 | }; | |
| 470 | return withFakeClaudeEnv( | |
| 471 | { output: JSON.stringify(verbose ? [result] : result) }, | |
| 472 | (textGeneration) => | |
| 473 | Effect.gen(function* () { | |
| 474 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 475 | cwd: process.cwd(), | |
| 476 | message: "Refresh ev-stg APP ASG instances", | |
| 477 | modelSelection: { | |
| 478 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 479 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 480 | }, | |
| 481 | }); | |
| 464 | it.effect.each([ | |
| 465 | { verbose: false, label: "normal" }, | |
| 466 | { verbose: true, label: "verbose" }, | |
| 467 | ])("unwraps a JSON title in $label Claude output", ({ verbose }) => { | |
| 468 | const result = { | |
| 469 | type: "result", | |
| 470 | structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' }, | |
| 471 | }; | |
| 472 | return withFakeClaudeEnv( | |
| 473 | { output: JSON.stringify(verbose ? [result] : result) }, | |
| 474 | (textGeneration) => | |
| 475 | Effect.gen(function* () { | |
| 476 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 477 | cwd: process.cwd(), | |
| 478 | message: "Refresh ev-stg APP ASG instances", | |
| 479 | modelSelection: { | |
| 480 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 481 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 482 | }, | |
| 483 | }); | |
| 482 | 484 | |
| 483 | expect(generated.title).toBe("Refresh ev-stg APP ASG instances"); | |
| 484 | }), | |
| 485 | ); | |
| 486 | }); | |
| 487 | } | |
| 485 | expect(generated.title).toBe("Refresh ev-stg APP ASG instances"); | |
| 486 | }), | |
| 487 | ); | |
| 488 | }); | |
| 488 | 489 | |
| 489 | for (const previousTitle of [undefined, "Old thread title"]) { | |
| 490 | it.effect( | |
| 491 | `reads the result from verbose Claude output when ${previousTitle ? "regenerating" : "generating"} a title`, | |
| 492 | () => | |
| 493 | withFakeClaudeEnv( | |
| 490 | it.effect.each([ | |
| 491 | { previousTitle: undefined, label: "generating" }, | |
| 492 | { previousTitle: "Old thread title", label: "regenerating" }, | |
| 493 | ])("reads the result from verbose Claude output when $label a title", ({ previousTitle }) => | |
| 494 | withFakeClaudeEnv( | |
| 495 | { | |
| 496 | output: JSON.stringify([ | |
| 497 | { type: "system", subtype: "init" }, | |
| 498 | { type: "assistant", message: { content: [] } }, | |
| 499 | { type: "user", message: { content: [] } }, | |
| 500 | { type: "rate_limit_event" }, | |
| 494 | 501 | { |
| 495 | output: JSON.stringify([ | |
| 496 | { type: "system", subtype: "init" }, | |
| 497 | { type: "assistant", message: { content: [] } }, | |
| 498 | { type: "user", message: { content: [] } }, | |
| 499 | { type: "rate_limit_event" }, | |
| 500 | { | |
| 501 | type: "result", | |
| 502 | subtype: "success", | |
| 503 | result: '{"title":"Refresh ev-stg APP ASG Instances"}', | |
| 504 | structured_output: { title: "Refresh ev-stg APP ASG Instances" }, | |
| 505 | }, | |
| 506 | ]), | |
| 502 | type: "result", | |
| 503 | subtype: "success", | |
| 504 | result: '{"title":"Refresh ev-stg APP ASG Instances"}', | |
| 505 | structured_output: { title: "Refresh ev-stg APP ASG Instances" }, | |
| 507 | 506 | }, |
| 508 | (textGeneration) => | |
| 509 | Effect.gen(function* () { | |
| 510 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 511 | cwd: process.cwd(), | |
| 512 | message: "Refresh ev-stg APP ASG instances", | |
| 513 | previousTitle, | |
| 514 | modelSelection: { | |
| 515 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 516 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 517 | }, | |
| 518 | }); | |
| 519 | ||
| 520 | expect(generated.title).toBe("Refresh ev-stg APP ASG Instances"); | |
| 521 | }), | |
| 522 | ), | |
| 523 | ); | |
| 524 | } | |
| 507 | ]), | |
| 508 | }, | |
| 509 | (textGeneration) => | |
| 510 | Effect.gen(function* () { | |
| 511 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 512 | cwd: process.cwd(), | |
| 513 | message: "Refresh ev-stg APP ASG instances", | |
| 514 | previousTitle, | |
| 515 | modelSelection: { | |
| 516 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 517 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 518 | }, | |
| 519 | }); | |
| 525 | 520 | |
| 526 | for (const [name, output] of [ | |
| 521 | expect(generated.title).toBe("Refresh ev-stg APP ASG Instances"); | |
| 522 | }), | |
| 523 | ), | |
| 524 | ); | |
| 525 | ||
| 526 | it.effect.each([ | |
| 527 | 527 | ["empty message array", []], |
| 528 | 528 | ["missing result", [{ type: "assistant", structured_output: { title: "Not a result" } }]], |
| 529 | 529 | ["invalid title", [{ type: "result", structured_output: { title: 42 } }]], |
| ... | ... | @@ -534,26 +534,24 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 534 | 534 | { type: "result", subtype: "error_max_structured_output_retries" }, |
| 535 | 535 | ], |
| 536 | 536 | ], |
| 537 | ] as const) { | |
| 538 | it.effect(`rejects verbose Claude output with ${name}`, () => | |
| 539 | withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) => | |
| 540 | Effect.gen(function* () { | |
| 541 | const error = yield* Effect.flip( | |
| 542 | textGeneration.generateThreadTitle({ | |
| 543 | cwd: process.cwd(), | |
| 544 | message: "Name this thread", | |
| 545 | modelSelection: { | |
| 546 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 547 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 548 | }, | |
| 549 | }), | |
| 550 | ); | |
| 537 | ] as const)("rejects verbose Claude output with %s", ([, output]) => | |
| 538 | withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) => | |
| 539 | Effect.gen(function* () { | |
| 540 | const error = yield* Effect.flip( | |
| 541 | textGeneration.generateThreadTitle({ | |
| 542 | cwd: process.cwd(), | |
| 543 | message: "Name this thread", | |
| 544 | modelSelection: { | |
| 545 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 546 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 547 | }, | |
| 548 | }), | |
| 549 | ); | |
| 551 | 550 | |
| 552 | expect(error._tag).toBe("TextGenerationError"); | |
| 553 | }), | |
| 554 | ), | |
| 555 | ); | |
| 556 | } | |
| 551 | expect(error._tag).toBe("TextGenerationError"); | |
| 552 | }), | |
| 553 | ), | |
| 554 | ); | |
| 557 | 555 | |
| 558 | 556 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => |
| 559 | 557 | withFakeClaudeEnv( |
apps/server/src/textGeneration/CodexTextGeneration.test.ts+27-29| ... | ... | @@ -170,8 +170,9 @@ function withFakeCodexEnv<A, E, R>( |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | 172 | it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { |
| 173 | for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) { | |
| 174 | it.effect(`dispatches the qualified live model for ${selectedModel}`, () => | |
| 173 | it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])( | |
| 174 | "dispatches the qualified live model for %s", | |
| 175 | (selectedModel) => | |
| 175 | 176 | withFakeCodexEnv( |
| 176 | 177 | { |
| 177 | 178 | output: JSON.stringify({ title: "Bedrock title" }), |
| ... | ... | @@ -189,8 +190,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { |
| 189 | 190 | expect(result.title).toBe("Bedrock title"); |
| 190 | 191 | }), |
| 191 | 192 | ), |
| 192 | ); | |
| 193 | } | |
| 193 | ); | |
| 194 | 194 | it.effect("generates and sanitizes commit messages without branch by default", () => |
| 195 | 195 | withFakeCodexEnv( |
| 196 | 196 | { |
| ... | ... | @@ -406,7 +406,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { |
| 406 | 406 | ), |
| 407 | 407 | ); |
| 408 | 408 | |
| 409 | for (const example of [ | |
| 409 | it.effect.each([ | |
| 410 | 410 | { |
| 411 | 411 | mode: "static", |
| 412 | 412 | output: "Add Search", |
| ... | ... | @@ -425,30 +425,28 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { |
| 425 | 425 | expected: "Julius/ABC-123.v2", |
| 426 | 426 | instruction: "Preserve the issue ID and capitalization.", |
| 427 | 427 | }, |
| 428 | ] as const) { | |
| 429 | it.effect(`generates a branch using ${example.mode} naming`, () => | |
| 430 | withFakeCodexEnv( | |
| 431 | { | |
| 432 | output: JSON.stringify({ branch: example.output }), | |
| 433 | stdinMustContain: example.instruction, | |
| 434 | }, | |
| 435 | (textGeneration) => | |
| 436 | Effect.gen(function* () { | |
| 437 | const generated = yield* textGeneration.generateBranchName({ | |
| 438 | cwd: process.cwd(), | |
| 439 | message: "Add search", | |
| 440 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 441 | naming: { | |
| 442 | mode: example.mode, | |
| 443 | prefix: "team/", | |
| 444 | instructions: "Preserve the issue ID and capitalization.", | |
| 445 | }, | |
| 446 | }); | |
| 447 | expect(generated.branch).toBe(example.expected); | |
| 448 | }), | |
| 449 | ), | |
| 450 | ); | |
| 451 | } | |
| 428 | ] as const)("generates a branch using $mode naming", (example) => | |
| 429 | withFakeCodexEnv( | |
| 430 | { | |
| 431 | output: JSON.stringify({ branch: example.output }), | |
| 432 | stdinMustContain: example.instruction, | |
| 433 | }, | |
| 434 | (textGeneration) => | |
| 435 | Effect.gen(function* () { | |
| 436 | const generated = yield* textGeneration.generateBranchName({ | |
| 437 | cwd: process.cwd(), | |
| 438 | message: "Add search", | |
| 439 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 440 | naming: { | |
| 441 | mode: example.mode, | |
| 442 | prefix: "team/", | |
| 443 | instructions: "Preserve the issue ID and capitalization.", | |
| 444 | }, | |
| 445 | }); | |
| 446 | expect(generated.branch).toBe(example.expected); | |
| 447 | }), | |
| 448 | ), | |
| 449 | ); | |
| 452 | 450 | |
| 453 | 451 | it.effect("generates branch names even when the ambient scope is already closed", () => |
| 454 | 452 | withFakeCodexEnv( |
apps/server/src/textGeneration/CursorTextGeneration.test.ts+8-8| ... | ... | @@ -253,8 +253,9 @@ describe("CursorTextGeneration", () => { |
| 253 | 253 | }).pipe(Effect.provide(fsLayer)), |
| 254 | 254 | ); |
| 255 | 255 | |
| 256 | for (const status of ["error", "cancelled"] as const) { | |
| 257 | it.effect(`rejects a ${status} Cursor SDK run that includes valid title JSON`, () => | |
| 256 | it.effect.each(["error", "cancelled"] as const)( | |
| 257 | "rejects a %s Cursor SDK run that includes valid title JSON", | |
| 258 | (status) => | |
| 258 | 259 | Effect.gen(function* () { |
| 259 | 260 | const promptResult = { |
| 260 | 261 | id: "run-cursor-partial-title-test", |
| ... | ... | @@ -284,8 +285,7 @@ describe("CursorTextGeneration", () => { |
| 284 | 285 | ); |
| 285 | 286 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 286 | 287 | }).pipe(Effect.provide(fsLayer)), |
| 287 | ); | |
| 288 | } | |
| 288 | ); | |
| 289 | 289 | |
| 290 | 290 | it.effect("fails closed when ambient sandbox policy can expand write access", () => |
| 291 | 291 | Effect.gen(function* () { |
| ... | ... | @@ -329,8 +329,9 @@ describe("CursorTextGeneration", () => { |
| 329 | 329 | }).pipe(Effect.provide(fsLayer), Effect.scoped), |
| 330 | 330 | ); |
| 331 | 331 | |
| 332 | for (const phase of ["create", "send"] as const) { | |
| 333 | it.effect(`times out pending ${phase} and releases its late SDK resource`, () => | |
| 332 | it.effect.each(["create", "send"] as const)( | |
| 333 | "times out pending %s and releases its late SDK resource", | |
| 334 | (phase) => | |
| 334 | 335 | Effect.gen(function* () { |
| 335 | 336 | let started!: () => void; |
| 336 | 337 | const called = new Promise<void>((resolve) => { |
| ... | ... | @@ -388,8 +389,7 @@ describe("CursorTextGeneration", () => { |
| 388 | 389 | expect(wait).not.toHaveBeenCalled(); |
| 389 | 390 | if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); |
| 390 | 391 | }).pipe(Effect.provide(fsLayer), Effect.scoped), |
| 391 | ); | |
| 392 | } | |
| 392 | ); | |
| 393 | 393 | |
| 394 | 394 | it.effect("requires CURSOR_API_KEY before calling the SDK", () => |
| 395 | 395 | Effect.gen(function* () { |
apps/server/src/usage/UsageService.test.ts+83-82| ... | ... | @@ -121,95 +121,96 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | 123 | describe("UsageService", () => { |
| 124 | for (const explicitDefault of [true, false]) { | |
| 125 | it.live( | |
| 126 | `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`, | |
| 127 | () => | |
| 128 | Effect.gen(function* () { | |
| 129 | const { home, settings } = yield* setup; | |
| 130 | const summary = yield* Effect.gen(function* () { | |
| 131 | for (const [id, output] of [ | |
| 132 | ["codex", 17], | |
| 133 | ["codex-personal", 23], | |
| 134 | ] as const) { | |
| 135 | const sessions = NodePath.join(home, "shared-codex", "sessions"); | |
| 136 | yield* Effect.promise(async () => { | |
| 137 | await NodeFSP.mkdir(sessions, { recursive: true }); | |
| 138 | await NodeFSP.writeFile( | |
| 139 | NodePath.join(sessions, `${id}-rollout.jsonl`), | |
| 140 | [ | |
| 141 | { type: "session_meta", payload: { id } }, | |
| 142 | { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, | |
| 143 | { | |
| 144 | type: "event_msg", | |
| 145 | timestamp: "2026-08-01T10:00:00Z", | |
| 146 | payload: { | |
| 147 | type: "token_count", | |
| 148 | info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, | |
| 149 | }, | |
| 124 | it.live.each([ | |
| 125 | { explicitDefault: true, label: "explicit" }, | |
| 126 | { explicitDefault: false, label: "legacy" }, | |
| 127 | ])( | |
| 128 | "reads shared managed $label default and disabled extra account history once", | |
| 129 | ({ explicitDefault }) => | |
| 130 | Effect.gen(function* () { | |
| 131 | const { home, settings } = yield* setup; | |
| 132 | const summary = yield* Effect.gen(function* () { | |
| 133 | for (const [id, output] of [ | |
| 134 | ["codex", 17], | |
| 135 | ["codex-personal", 23], | |
| 136 | ] as const) { | |
| 137 | const sessions = NodePath.join(home, "shared-codex", "sessions"); | |
| 138 | yield* Effect.promise(async () => { | |
| 139 | await NodeFSP.mkdir(sessions, { recursive: true }); | |
| 140 | await NodeFSP.writeFile( | |
| 141 | NodePath.join(sessions, `${id}-rollout.jsonl`), | |
| 142 | [ | |
| 143 | { type: "session_meta", payload: { id } }, | |
| 144 | { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, | |
| 145 | { | |
| 146 | type: "event_msg", | |
| 147 | timestamp: "2026-08-01T10:00:00Z", | |
| 148 | payload: { | |
| 149 | type: "token_count", | |
| 150 | info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, | |
| 150 | 151 | }, |
| 151 | ] | |
| 152 | .map((line) => encodeUnknownJsonString(line)) | |
| 153 | .join("\n") + "\n", | |
| 154 | ); | |
| 155 | }); | |
| 156 | } | |
| 157 | const service = yield* UsageService.make; | |
| 158 | return yield* service.readSummary(WINDOW); | |
| 159 | }).pipe( | |
| 160 | Effect.provide( | |
| 161 | serviceLayers({ | |
| 162 | prefix: "usage-managed-accounts", | |
| 163 | home, | |
| 164 | settings: { | |
| 165 | ...settings, | |
| 166 | providers: { | |
| 167 | ...settings.providers, | |
| 168 | codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, | |
| 169 | 152 | }, |
| 170 | providerInstances: { | |
| 171 | ...(explicitDefault | |
| 172 | ? { | |
| 173 | [ProviderInstanceId.make("codex")]: { | |
| 174 | driver: ProviderDriverKind.make("codex"), | |
| 175 | config: { | |
| 176 | setupMode: "managed", | |
| 177 | homePath: NodePath.join(home, "shared-codex"), | |
| 178 | }, | |
| 153 | ] | |
| 154 | .map((line) => encodeUnknownJsonString(line)) | |
| 155 | .join("\n") + "\n", | |
| 156 | ); | |
| 157 | }); | |
| 158 | } | |
| 159 | const service = yield* UsageService.make; | |
| 160 | return yield* service.readSummary(WINDOW); | |
| 161 | }).pipe( | |
| 162 | Effect.provide( | |
| 163 | serviceLayers({ | |
| 164 | prefix: "usage-managed-accounts", | |
| 165 | home, | |
| 166 | settings: { | |
| 167 | ...settings, | |
| 168 | providers: { | |
| 169 | ...settings.providers, | |
| 170 | codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, | |
| 171 | }, | |
| 172 | providerInstances: { | |
| 173 | ...(explicitDefault | |
| 174 | ? { | |
| 175 | [ProviderInstanceId.make("codex")]: { | |
| 176 | driver: ProviderDriverKind.make("codex"), | |
| 177 | config: { | |
| 178 | setupMode: "managed", | |
| 179 | homePath: NodePath.join(home, "shared-codex"), | |
| 179 | 180 | }, |
| 180 | } | |
| 181 | : {}), | |
| 182 | [ProviderInstanceId.make("codex-personal")]: { | |
| 183 | driver: ProviderDriverKind.make("codex"), | |
| 184 | enabled: false, | |
| 185 | config: { | |
| 186 | setupMode: "managed", | |
| 187 | homePath: NodePath.join(home, "shared-codex"), | |
| 188 | shadowHomePath: NodePath.join(home, "personal-shadow"), | |
| 189 | }, | |
| 190 | environment: [ | |
| 191 | { | |
| 192 | name: "CODEX_HOME", | |
| 193 | value: NodePath.join(home, "ignored-environment"), | |
| 194 | sensitive: false, | |
| 195 | 181 | }, |
| 196 | ], | |
| 182 | } | |
| 183 | : {}), | |
| 184 | [ProviderInstanceId.make("codex-personal")]: { | |
| 185 | driver: ProviderDriverKind.make("codex"), | |
| 186 | enabled: false, | |
| 187 | config: { | |
| 188 | setupMode: "managed", | |
| 189 | homePath: NodePath.join(home, "shared-codex"), | |
| 190 | shadowHomePath: NodePath.join(home, "personal-shadow"), | |
| 197 | 191 | }, |
| 192 | environment: [ | |
| 193 | { | |
| 194 | name: "CODEX_HOME", | |
| 195 | value: NodePath.join(home, "ignored-environment"), | |
| 196 | sensitive: false, | |
| 197 | }, | |
| 198 | ], | |
| 198 | 199 | }, |
| 199 | 200 | }, |
| 200 | }), | |
| 201 | ), | |
| 202 | ); | |
| 203 | assert.strictEqual(totalOutputTokens(summary), 40); | |
| 204 | assert.strictEqual( | |
| 205 | summary.sources.filter( | |
| 206 | (source) => source.fingerprint.provider === "codex" && source.status === "ok", | |
| 207 | ).length, | |
| 208 | 1, | |
| 209 | ); | |
| 210 | }).pipe(Effect.scoped), | |
| 211 | ); | |
| 212 | } | |
| 201 | }, | |
| 202 | }), | |
| 203 | ), | |
| 204 | ); | |
| 205 | assert.strictEqual(totalOutputTokens(summary), 40); | |
| 206 | assert.strictEqual( | |
| 207 | summary.sources.filter( | |
| 208 | (source) => source.fingerprint.provider === "codex" && source.status === "ok", | |
| 209 | ).length, | |
| 210 | 1, | |
| 211 | ); | |
| 212 | }).pipe(Effect.scoped), | |
| 213 | ); | |
| 213 | 214 | it.live("omits Cursor account usage when no file login is saved", () => |
| 214 | 215 | Effect.gen(function* () { |
| 215 | 216 | const { settings, home } = yield* setup; |
apps/server/src/usage/cliproxyApi.test.ts+11-13| ... | ... | @@ -234,22 +234,20 @@ describe("CLIProxyAPI built-in management API", () => { |
| 234 | 234 | }), |
| 235 | 235 | ); |
| 236 | 236 | |
| 237 | for (const [code, outcome] of [ | |
| 237 | it.effect.each([ | |
| 238 | 238 | ["nothing_to_reset", "nothingToReset"], |
| 239 | 239 | ["no_credit", "noCredit"], |
| 240 | 240 | ["already_redeemed", "alreadyRedeemed"], |
| 241 | ] as const) { | |
| 242 | it.effect(`reports ${code} accurately`, () => | |
| 243 | Effect.gen(function* () { | |
| 244 | const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); | |
| 245 | const api = yield* test.api; | |
| 246 | expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); | |
| 247 | expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( | |
| 248 | code === "already_redeemed", | |
| 249 | ); | |
| 250 | }), | |
| 251 | ); | |
| 252 | } | |
| 241 | ] as const)("reports %s accurately", ([code, outcome]) => | |
| 242 | Effect.gen(function* () { | |
| 243 | const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); | |
| 244 | const api = yield* test.api; | |
| 245 | expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); | |
| 246 | expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( | |
| 247 | code === "already_redeemed", | |
| 248 | ); | |
| 249 | }), | |
| 250 | ); | |
| 253 | 251 | |
| 254 | 252 | it.effect("reports redemption success even if cooldown clearing fails", () => |
| 255 | 253 | Effect.gen(function* () { |
apps/server/src/vcs/GitVcsDriver.test.ts+358-394| ... | ... | @@ -392,8 +392,9 @@ it.effect.each([ |
| 392 | 392 | }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), |
| 393 | 393 | ); |
| 394 | 394 | |
| 395 | for (const blockedPhase of ["discovery", "probe", "retry"] as const) { | |
| 396 | it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () => | |
| 395 | it.effect.each(["discovery", "probe", "retry"] as const)( | |
| 396 | "checkpoint recovery has one deadline including %s", | |
| 397 | (blockedPhase) => | |
| 397 | 398 | Effect.gen(function* () { |
| 398 | 399 | const fs = yield* FileSystem.FileSystem; |
| 399 | 400 | const path = yield* Path.Path; |
| ... | ... | @@ -478,8 +479,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) { |
| 478 | 479 | assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); |
| 479 | 480 | assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); |
| 480 | 481 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| 481 | ); | |
| 482 | } | |
| 482 | ); | |
| 483 | 483 | |
| 484 | 484 | it.effect("checkpoint recovery preserves interruption and removes the private index", () => |
| 485 | 485 | Effect.gen(function* () { |
| ... | ... | @@ -548,116 +548,103 @@ it.effect("checkpoint capture does not rerun clean filters for unchanged indexed |
| 548 | 548 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| 549 | 549 | ); |
| 550 | 550 | |
| 551 | for (const nested of [false, true]) { | |
| 552 | for (const indexState of [ | |
| 553 | "sparse", | |
| 554 | "flags", | |
| 555 | "manual-skip", | |
| 556 | "missing", | |
| 557 | "non-cone-missing", | |
| 558 | ] as const) { | |
| 559 | it.effect( | |
| 560 | `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`, | |
| 561 | () => | |
| 562 | Effect.gen(function* () { | |
| 563 | const fs = yield* FileSystem.FileSystem; | |
| 564 | const path = yield* Path.Path; | |
| 565 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 566 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); | |
| 567 | const { git } = yield* makeCheckpointFixture(driver, cwd); | |
| 568 | const write = Effect.fn(function* (name: string, contents: string) { | |
| 569 | yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); | |
| 570 | yield* fs.writeFileString(path.join(cwd, name), contents); | |
| 571 | }); | |
| 572 | for (const name of [ | |
| 573 | "scope/in/edit", | |
| 574 | "scope/in/delete", | |
| 575 | "scope/out/deep/absent", | |
| 576 | "scope/out/present", | |
| 577 | "elsewhere/file", | |
| 578 | ]) { | |
| 579 | yield* write(name, "original\n"); | |
| 580 | } | |
| 581 | yield* git(["add", "."]); | |
| 582 | yield* git(["commit", "-m", "sparse fixture"]); | |
| 583 | yield* git([ | |
| 584 | "sparse-checkout", | |
| 585 | "set", | |
| 586 | "--cone", | |
| 587 | "--sparse-index", | |
| 588 | "scope/in", | |
| 589 | "elsewhere", | |
| 590 | ]); | |
| 591 | if (indexState === "non-cone-missing") | |
| 592 | yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); | |
| 593 | yield* write("scope/in/edit", "staged\n"); | |
| 594 | yield* write("elsewhere/file", "staged outside\n"); | |
| 595 | yield* git(["add", "."]); | |
| 596 | if (indexState === "flags") | |
| 597 | yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); | |
| 598 | if (indexState === "manual-skip") | |
| 599 | yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); | |
| 600 | yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); | |
| 601 | yield* write("scope/in/edit", "working\n"); | |
| 602 | yield* write("scope/out/present", "modified skipped\n"); | |
| 603 | yield* write("scope/out/new file", "new outside cone\n"); | |
| 604 | yield* write("elsewhere/file", "working outside\n"); | |
| 605 | yield* fs.remove(path.join(cwd, "scope/in/delete")); | |
| 606 | const indexPath = path.join(cwd, ".git/index"); | |
| 607 | if (indexState.endsWith("missing")) yield* fs.remove(indexPath); | |
| 608 | const originalIndex = yield* fs | |
| 609 | .readFile(indexPath) | |
| 610 | .pipe(Effect.orElseSucceed(() => null)); | |
| 611 | const captureCwd = nested ? path.join(cwd, "scope") : cwd; | |
| 612 | for (const turn of [1, 2]) { | |
| 613 | const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); | |
| 614 | if (turn === 2) { | |
| 615 | yield* write("scope/in/edit", "second\n"); | |
| 616 | yield* fs.remove(path.join(cwd, "scope/out/new file")); | |
| 617 | yield* write("scope/out/second", "second addition\n"); | |
| 618 | } | |
| 619 | const capture = driver.checkpoints.captureCheckpoint({ | |
| 620 | cwd: captureCwd, | |
| 621 | checkpointRef: ref, | |
| 622 | }); | |
| 623 | if (indexState === "non-cone-missing") { | |
| 624 | assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); | |
| 625 | assert.isFalse( | |
| 626 | yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), | |
| 627 | ); | |
| 628 | assert.isFalse(yield* fs.exists(indexPath)); | |
| 629 | break; | |
| 630 | } | |
| 631 | yield* capture; | |
| 632 | for (const [name, content] of [ | |
| 633 | ["scope/out/deep/absent", "original\n"], | |
| 634 | ["scope/out/present", "modified skipped\n"], | |
| 635 | ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], | |
| 636 | ["elsewhere/file", nested ? "original\n" : "working outside\n"], | |
| 637 | [ | |
| 638 | turn === 1 ? "scope/out/new file" : "scope/out/second", | |
| 639 | turn === 1 ? "new outside cone\n" : "second addition\n", | |
| 640 | ], | |
| 641 | ]) { | |
| 642 | assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); | |
| 643 | } | |
| 644 | const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); | |
| 645 | assert.notInclude(files, "scope/in/delete"); | |
| 646 | if (turn === 2) assert.notInclude(files, "scope/out/new file"); | |
| 647 | assert.deepEqual( | |
| 648 | yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), | |
| 649 | originalIndex, | |
| 650 | ); | |
| 651 | assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); | |
| 652 | assert.strictEqual( | |
| 653 | yield* fs.readFileString(path.join(cwd, "elsewhere/file")), | |
| 654 | "working outside\n", | |
| 655 | ); | |
| 656 | } | |
| 657 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 658 | ); | |
| 659 | } | |
| 660 | } | |
| 551 | it.effect.each( | |
| 552 | [false, true].flatMap((nested) => | |
| 553 | (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( | |
| 554 | (indexState) => ({ nested, indexState }), | |
| 555 | ), | |
| 556 | ), | |
| 557 | )( | |
| 558 | "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", | |
| 559 | ({ nested, indexState }) => | |
| 560 | Effect.gen(function* () { | |
| 561 | const fs = yield* FileSystem.FileSystem; | |
| 562 | const path = yield* Path.Path; | |
| 563 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 564 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); | |
| 565 | const { git } = yield* makeCheckpointFixture(driver, cwd); | |
| 566 | const write = Effect.fn(function* (name: string, contents: string) { | |
| 567 | yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); | |
| 568 | yield* fs.writeFileString(path.join(cwd, name), contents); | |
| 569 | }); | |
| 570 | for (const name of [ | |
| 571 | "scope/in/edit", | |
| 572 | "scope/in/delete", | |
| 573 | "scope/out/deep/absent", | |
| 574 | "scope/out/present", | |
| 575 | "elsewhere/file", | |
| 576 | ]) { | |
| 577 | yield* write(name, "original\n"); | |
| 578 | } | |
| 579 | yield* git(["add", "."]); | |
| 580 | yield* git(["commit", "-m", "sparse fixture"]); | |
| 581 | yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); | |
| 582 | if (indexState === "non-cone-missing") | |
| 583 | yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); | |
| 584 | yield* write("scope/in/edit", "staged\n"); | |
| 585 | yield* write("elsewhere/file", "staged outside\n"); | |
| 586 | yield* git(["add", "."]); | |
| 587 | if (indexState === "flags") | |
| 588 | yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); | |
| 589 | if (indexState === "manual-skip") | |
| 590 | yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); | |
| 591 | yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); | |
| 592 | yield* write("scope/in/edit", "working\n"); | |
| 593 | yield* write("scope/out/present", "modified skipped\n"); | |
| 594 | yield* write("scope/out/new file", "new outside cone\n"); | |
| 595 | yield* write("elsewhere/file", "working outside\n"); | |
| 596 | yield* fs.remove(path.join(cwd, "scope/in/delete")); | |
| 597 | const indexPath = path.join(cwd, ".git/index"); | |
| 598 | if (indexState.endsWith("missing")) yield* fs.remove(indexPath); | |
| 599 | const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); | |
| 600 | const captureCwd = nested ? path.join(cwd, "scope") : cwd; | |
| 601 | for (const turn of [1, 2]) { | |
| 602 | const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); | |
| 603 | if (turn === 2) { | |
| 604 | yield* write("scope/in/edit", "second\n"); | |
| 605 | yield* fs.remove(path.join(cwd, "scope/out/new file")); | |
| 606 | yield* write("scope/out/second", "second addition\n"); | |
| 607 | } | |
| 608 | const capture = driver.checkpoints.captureCheckpoint({ | |
| 609 | cwd: captureCwd, | |
| 610 | checkpointRef: ref, | |
| 611 | }); | |
| 612 | if (indexState === "non-cone-missing") { | |
| 613 | assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); | |
| 614 | assert.isFalse( | |
| 615 | yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), | |
| 616 | ); | |
| 617 | assert.isFalse(yield* fs.exists(indexPath)); | |
| 618 | break; | |
| 619 | } | |
| 620 | yield* capture; | |
| 621 | for (const [name, content] of [ | |
| 622 | ["scope/out/deep/absent", "original\n"], | |
| 623 | ["scope/out/present", "modified skipped\n"], | |
| 624 | ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], | |
| 625 | ["elsewhere/file", nested ? "original\n" : "working outside\n"], | |
| 626 | [ | |
| 627 | turn === 1 ? "scope/out/new file" : "scope/out/second", | |
| 628 | turn === 1 ? "new outside cone\n" : "second addition\n", | |
| 629 | ], | |
| 630 | ]) { | |
| 631 | assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); | |
| 632 | } | |
| 633 | const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); | |
| 634 | assert.notInclude(files, "scope/in/delete"); | |
| 635 | if (turn === 2) assert.notInclude(files, "scope/out/new file"); | |
| 636 | assert.deepEqual( | |
| 637 | yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), | |
| 638 | originalIndex, | |
| 639 | ); | |
| 640 | assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); | |
| 641 | assert.strictEqual( | |
| 642 | yield* fs.readFileString(path.join(cwd, "elsewhere/file")), | |
| 643 | "working outside\n", | |
| 644 | ); | |
| 645 | } | |
| 646 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 647 | ); | |
| 661 | 648 | |
| 662 | 649 | it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () => |
| 663 | 650 | Effect.gen(function* () { |
| ... | ... | @@ -699,206 +686,186 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse" |
| 699 | 686 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| 700 | 687 | ); |
| 701 | 688 | |
| 702 | for (const indexMode of ["normal", "flags", "sparse"] as const) { | |
| 703 | it.effect( | |
| 704 | `checkpoint index inspection handles entries beyond the output cap (index=${indexMode})`, | |
| 705 | () => | |
| 706 | Effect.gen(function* () { | |
| 707 | const fs = yield* FileSystem.FileSystem; | |
| 708 | const path = yield* Path.Path; | |
| 709 | const liveProcess = yield* VcsProcess.VcsProcess; | |
| 710 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 711 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); | |
| 712 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); | |
| 713 | yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); | |
| 714 | yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); | |
| 715 | yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); | |
| 716 | yield* fs.makeDirectory(path.join(cwd, "excluded")); | |
| 717 | yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); | |
| 718 | yield* fs.writeFileString( | |
| 719 | path.join(cwd, ".git/filter.cjs"), | |
| 720 | 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', | |
| 721 | ); | |
| 722 | yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); | |
| 723 | yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); | |
| 724 | yield* git(["add", "."]); | |
| 725 | yield* git(["commit", "-m", "inspection fixture"]); | |
| 726 | if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); | |
| 727 | if (indexMode === "sparse") | |
| 728 | yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); | |
| 729 | yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); | |
| 730 | yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); | |
| 731 | const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); | |
| 732 | const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( | |
| 733 | Effect.provideService(VcsProcess.VcsProcess, { | |
| 734 | run: (input) => | |
| 735 | liveProcess.run( | |
| 736 | input.args.includes("ls-files") | |
| 737 | ? { | |
| 738 | ...input, | |
| 739 | maxOutputBytes: 8, | |
| 740 | onStdoutChunk: (chunk) => { | |
| 741 | for (let i = 0; i < chunk.length; i++) | |
| 742 | input.onStdoutChunk?.(chunk.subarray(i, i + 1)); | |
| 743 | }, | |
| 744 | } | |
| 745 | : input, | |
| 746 | ), | |
| 747 | }), | |
| 748 | ); | |
| 749 | yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); | |
| 750 | assert.strictEqual( | |
| 751 | (yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, | |
| 752 | "modified\n", | |
| 753 | ); | |
| 754 | if (indexMode !== "flags") | |
| 755 | assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); | |
| 756 | assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); | |
| 757 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 758 | ); | |
| 759 | } | |
| 689 | it.effect.each(["normal", "flags", "sparse"] as const)( | |
| 690 | "checkpoint index inspection handles entries beyond the output cap (index=%s)", | |
| 691 | (indexMode) => | |
| 692 | Effect.gen(function* () { | |
| 693 | const fs = yield* FileSystem.FileSystem; | |
| 694 | const path = yield* Path.Path; | |
| 695 | const liveProcess = yield* VcsProcess.VcsProcess; | |
| 696 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 697 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); | |
| 698 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); | |
| 699 | yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); | |
| 700 | yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); | |
| 701 | yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); | |
| 702 | yield* fs.makeDirectory(path.join(cwd, "excluded")); | |
| 703 | yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); | |
| 704 | yield* fs.writeFileString( | |
| 705 | path.join(cwd, ".git/filter.cjs"), | |
| 706 | 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', | |
| 707 | ); | |
| 708 | yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); | |
| 709 | yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); | |
| 710 | yield* git(["add", "."]); | |
| 711 | yield* git(["commit", "-m", "inspection fixture"]); | |
| 712 | if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); | |
| 713 | if (indexMode === "sparse") | |
| 714 | yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); | |
| 715 | yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); | |
| 716 | yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); | |
| 717 | const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); | |
| 718 | const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( | |
| 719 | Effect.provideService(VcsProcess.VcsProcess, { | |
| 720 | run: (input) => | |
| 721 | liveProcess.run( | |
| 722 | input.args.includes("ls-files") | |
| 723 | ? { | |
| 724 | ...input, | |
| 725 | maxOutputBytes: 8, | |
| 726 | onStdoutChunk: (chunk) => { | |
| 727 | for (let i = 0; i < chunk.length; i++) | |
| 728 | input.onStdoutChunk?.(chunk.subarray(i, i + 1)); | |
| 729 | }, | |
| 730 | } | |
| 731 | : input, | |
| 732 | ), | |
| 733 | }), | |
| 734 | ); | |
| 735 | yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); | |
| 736 | assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n"); | |
| 737 | if (indexMode !== "flags") | |
| 738 | assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); | |
| 739 | assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); | |
| 740 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 741 | ); | |
| 760 | 742 | |
| 761 | for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) { | |
| 762 | it.effect( | |
| 763 | `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`, | |
| 764 | () => | |
| 765 | Effect.gen(function* () { | |
| 766 | const fileSystem = yield* FileSystem.FileSystem; | |
| 767 | const path = yield* Path.Path; | |
| 768 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 769 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); | |
| 770 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); | |
| 771 | const filePath = path.join(cwd, "file.txt"); | |
| 772 | const indexPath = path.join(cwd, ".git", "index"); | |
| 773 | yield* git(["config", "core.trustctime", "false"]); | |
| 774 | yield* fileSystem.writeFileString(filePath, "before\n"); | |
| 775 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 776 | yield* git(["add", "file.txt"]); | |
| 777 | yield* git(["commit", "-m", "record racy file"]); | |
| 778 | yield* fileSystem.utimes(indexPath, timestamp, timestamp); | |
| 779 | const originalIndex = yield* fileSystem.readFile(indexPath); | |
| 780 | const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; | |
| 781 | yield* fileSystem.writeFileString(filePath, "after!\n"); | |
| 782 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 743 | it.effect.each([1_700_000_000, 1_700_000_000.9999])( | |
| 744 | "checkpoint capture preserves same-size edits with racy index timestamps (%s)", | |
| 745 | (timestamp) => | |
| 746 | Effect.gen(function* () { | |
| 747 | const fileSystem = yield* FileSystem.FileSystem; | |
| 748 | const path = yield* Path.Path; | |
| 749 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 750 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); | |
| 751 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); | |
| 752 | const filePath = path.join(cwd, "file.txt"); | |
| 753 | const indexPath = path.join(cwd, ".git", "index"); | |
| 754 | yield* git(["config", "core.trustctime", "false"]); | |
| 755 | yield* fileSystem.writeFileString(filePath, "before\n"); | |
| 756 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 757 | yield* git(["add", "file.txt"]); | |
| 758 | yield* git(["commit", "-m", "record racy file"]); | |
| 759 | yield* fileSystem.utimes(indexPath, timestamp, timestamp); | |
| 760 | const originalIndex = yield* fileSystem.readFile(indexPath); | |
| 761 | const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; | |
| 762 | yield* fileSystem.writeFileString(filePath, "after!\n"); | |
| 763 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 783 | 764 | |
| 784 | yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); | |
| 765 | yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); | |
| 785 | 766 | |
| 786 | assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); | |
| 787 | assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); | |
| 788 | assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); | |
| 789 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 790 | ); | |
| 791 | } | |
| 792 | for (const nested of [false, true]) { | |
| 793 | for (const indexState of [ | |
| 794 | "sparse", | |
| 795 | "flags", | |
| 796 | "manual-skip", | |
| 797 | "missing", | |
| 798 | "non-cone-missing", | |
| 799 | ] as const) { | |
| 800 | it.effect( | |
| 801 | `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`, | |
| 802 | () => | |
| 803 | Effect.gen(function* () { | |
| 804 | const fs = yield* FileSystem.FileSystem; | |
| 805 | const path = yield* Path.Path; | |
| 806 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 807 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); | |
| 808 | const { git } = yield* makeCheckpointFixture(driver, cwd); | |
| 809 | const write = Effect.fn(function* (name: string, contents: string) { | |
| 810 | yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); | |
| 811 | yield* fs.writeFileString(path.join(cwd, name), contents); | |
| 812 | }); | |
| 813 | for (const name of [ | |
| 814 | "scope/in/edit", | |
| 815 | "scope/in/delete", | |
| 816 | "scope/out/deep/absent", | |
| 817 | "scope/out/present", | |
| 818 | "elsewhere/file", | |
| 819 | ]) { | |
| 820 | yield* write(name, "original\n"); | |
| 821 | } | |
| 822 | yield* git(["add", "."]); | |
| 823 | yield* git(["commit", "-m", "sparse fixture"]); | |
| 824 | yield* git([ | |
| 825 | "sparse-checkout", | |
| 826 | "set", | |
| 827 | "--cone", | |
| 828 | "--sparse-index", | |
| 829 | "scope/in", | |
| 830 | "elsewhere", | |
| 831 | ]); | |
| 832 | if (indexState === "non-cone-missing") | |
| 833 | yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); | |
| 834 | yield* write("scope/in/edit", "staged\n"); | |
| 835 | yield* write("elsewhere/file", "staged outside\n"); | |
| 836 | yield* git(["add", "."]); | |
| 837 | if (indexState === "flags") | |
| 838 | yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); | |
| 839 | if (indexState === "manual-skip") | |
| 840 | yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); | |
| 841 | yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); | |
| 842 | yield* write("scope/in/edit", "working\n"); | |
| 843 | yield* write("scope/out/present", "modified skipped\n"); | |
| 844 | yield* write("scope/out/new file", "new outside cone\n"); | |
| 845 | yield* write("elsewhere/file", "working outside\n"); | |
| 846 | yield* fs.remove(path.join(cwd, "scope/in/delete")); | |
| 847 | const indexPath = path.join(cwd, ".git/index"); | |
| 848 | if (indexState.endsWith("missing")) yield* fs.remove(indexPath); | |
| 849 | const originalIndex = yield* fs | |
| 850 | .readFile(indexPath) | |
| 851 | .pipe(Effect.orElseSucceed(() => null)); | |
| 852 | const captureCwd = nested ? path.join(cwd, "scope") : cwd; | |
| 853 | for (const turn of [1, 2]) { | |
| 854 | const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); | |
| 855 | if (turn === 2) { | |
| 856 | yield* write("scope/in/edit", "second\n"); | |
| 857 | yield* fs.remove(path.join(cwd, "scope/out/new file")); | |
| 858 | yield* write("scope/out/second", "second addition\n"); | |
| 859 | } | |
| 860 | const capture = driver.checkpoints.captureCheckpoint({ | |
| 861 | cwd: captureCwd, | |
| 862 | checkpointRef: ref, | |
| 863 | }); | |
| 864 | if (indexState === "non-cone-missing") { | |
| 865 | assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); | |
| 866 | assert.isFalse( | |
| 867 | yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), | |
| 868 | ); | |
| 869 | assert.isFalse(yield* fs.exists(indexPath)); | |
| 870 | break; | |
| 871 | } | |
| 872 | yield* capture; | |
| 873 | for (const [name, content] of [ | |
| 874 | ["scope/out/deep/absent", "original\n"], | |
| 875 | ["scope/out/present", "modified skipped\n"], | |
| 876 | ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], | |
| 877 | ["elsewhere/file", nested ? "original\n" : "working outside\n"], | |
| 878 | [ | |
| 879 | turn === 1 ? "scope/out/new file" : "scope/out/second", | |
| 880 | turn === 1 ? "new outside cone\n" : "second addition\n", | |
| 881 | ], | |
| 882 | ]) { | |
| 883 | assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); | |
| 884 | } | |
| 885 | const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); | |
| 886 | assert.notInclude(files, "scope/in/delete"); | |
| 887 | if (turn === 2) assert.notInclude(files, "scope/out/new file"); | |
| 888 | assert.deepEqual( | |
| 889 | yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), | |
| 890 | originalIndex, | |
| 891 | ); | |
| 892 | assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); | |
| 893 | assert.strictEqual( | |
| 894 | yield* fs.readFileString(path.join(cwd, "elsewhere/file")), | |
| 895 | "working outside\n", | |
| 896 | ); | |
| 897 | } | |
| 898 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 899 | ); | |
| 900 | } | |
| 901 | } | |
| 767 | assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); | |
| 768 | assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); | |
| 769 | assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); | |
| 770 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 771 | ); | |
| 772 | it.effect.each( | |
| 773 | [false, true].flatMap((nested) => | |
| 774 | (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( | |
| 775 | (indexState) => ({ nested, indexState }), | |
| 776 | ), | |
| 777 | ), | |
| 778 | )( | |
| 779 | "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", | |
| 780 | ({ nested, indexState }) => | |
| 781 | Effect.gen(function* () { | |
| 782 | const fs = yield* FileSystem.FileSystem; | |
| 783 | const path = yield* Path.Path; | |
| 784 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 785 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); | |
| 786 | const { git } = yield* makeCheckpointFixture(driver, cwd); | |
| 787 | const write = Effect.fn(function* (name: string, contents: string) { | |
| 788 | yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); | |
| 789 | yield* fs.writeFileString(path.join(cwd, name), contents); | |
| 790 | }); | |
| 791 | for (const name of [ | |
| 792 | "scope/in/edit", | |
| 793 | "scope/in/delete", | |
| 794 | "scope/out/deep/absent", | |
| 795 | "scope/out/present", | |
| 796 | "elsewhere/file", | |
| 797 | ]) { | |
| 798 | yield* write(name, "original\n"); | |
| 799 | } | |
| 800 | yield* git(["add", "."]); | |
| 801 | yield* git(["commit", "-m", "sparse fixture"]); | |
| 802 | yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); | |
| 803 | if (indexState === "non-cone-missing") | |
| 804 | yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); | |
| 805 | yield* write("scope/in/edit", "staged\n"); | |
| 806 | yield* write("elsewhere/file", "staged outside\n"); | |
| 807 | yield* git(["add", "."]); | |
| 808 | if (indexState === "flags") | |
| 809 | yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); | |
| 810 | if (indexState === "manual-skip") | |
| 811 | yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); | |
| 812 | yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); | |
| 813 | yield* write("scope/in/edit", "working\n"); | |
| 814 | yield* write("scope/out/present", "modified skipped\n"); | |
| 815 | yield* write("scope/out/new file", "new outside cone\n"); | |
| 816 | yield* write("elsewhere/file", "working outside\n"); | |
| 817 | yield* fs.remove(path.join(cwd, "scope/in/delete")); | |
| 818 | const indexPath = path.join(cwd, ".git/index"); | |
| 819 | if (indexState.endsWith("missing")) yield* fs.remove(indexPath); | |
| 820 | const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); | |
| 821 | const captureCwd = nested ? path.join(cwd, "scope") : cwd; | |
| 822 | for (const turn of [1, 2]) { | |
| 823 | const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); | |
| 824 | if (turn === 2) { | |
| 825 | yield* write("scope/in/edit", "second\n"); | |
| 826 | yield* fs.remove(path.join(cwd, "scope/out/new file")); | |
| 827 | yield* write("scope/out/second", "second addition\n"); | |
| 828 | } | |
| 829 | const capture = driver.checkpoints.captureCheckpoint({ | |
| 830 | cwd: captureCwd, | |
| 831 | checkpointRef: ref, | |
| 832 | }); | |
| 833 | if (indexState === "non-cone-missing") { | |
| 834 | assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); | |
| 835 | assert.isFalse( | |
| 836 | yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), | |
| 837 | ); | |
| 838 | assert.isFalse(yield* fs.exists(indexPath)); | |
| 839 | break; | |
| 840 | } | |
| 841 | yield* capture; | |
| 842 | for (const [name, content] of [ | |
| 843 | ["scope/out/deep/absent", "original\n"], | |
| 844 | ["scope/out/present", "modified skipped\n"], | |
| 845 | ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], | |
| 846 | ["elsewhere/file", nested ? "original\n" : "working outside\n"], | |
| 847 | [ | |
| 848 | turn === 1 ? "scope/out/new file" : "scope/out/second", | |
| 849 | turn === 1 ? "new outside cone\n" : "second addition\n", | |
| 850 | ], | |
| 851 | ]) { | |
| 852 | assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); | |
| 853 | } | |
| 854 | const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); | |
| 855 | assert.notInclude(files, "scope/in/delete"); | |
| 856 | if (turn === 2) assert.notInclude(files, "scope/out/new file"); | |
| 857 | assert.deepEqual( | |
| 858 | yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), | |
| 859 | originalIndex, | |
| 860 | ); | |
| 861 | assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); | |
| 862 | assert.strictEqual( | |
| 863 | yield* fs.readFileString(path.join(cwd, "elsewhere/file")), | |
| 864 | "working outside\n", | |
| 865 | ); | |
| 866 | } | |
| 867 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 868 | ); | |
| 902 | 869 | |
| 903 | 870 | it.effect("checkpoint capture preserves racy edits made after resetting the index", () => |
| 904 | 871 | Effect.gen(function* () { |
| ... | ... | @@ -943,89 +910,87 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde |
| 943 | 910 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| 944 | 911 | ); |
| 945 | 912 | |
| 946 | for (const nested of [false, true]) { | |
| 947 | for (const indexMode of ["normal", "flags", "split"] as const) { | |
| 948 | it.effect( | |
| 949 | `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`, | |
| 950 | () => | |
| 951 | Effect.gen(function* () { | |
| 952 | const fileSystem = yield* FileSystem.FileSystem; | |
| 953 | const path = yield* Path.Path; | |
| 954 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 955 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); | |
| 956 | const { git } = yield* makeCheckpointFixture(driver, cwd); | |
| 957 | const write = (name: string, contents: string) => | |
| 958 | fileSystem.writeFileString(path.join(cwd, name), contents); | |
| 959 | yield* fileSystem.makeDirectory(path.join(cwd, "scope")); | |
| 960 | for (const name of [ | |
| 961 | "scope/staged", | |
| 962 | "scope/deleted", | |
| 963 | "scope/assumed", | |
| 964 | "scope/skipped", | |
| 965 | "outside", | |
| 966 | ]) { | |
| 967 | yield* write(name, "original\n"); | |
| 968 | } | |
| 969 | yield* git(["add", "."]); | |
| 970 | yield* git(["commit", "-m", "initial scoped files"]); | |
| 971 | yield* write("scope/staged", "staged\n"); | |
| 972 | yield* write("scope/new-deleted", "staged then deleted\n"); | |
| 973 | yield* write("outside", "staged outside\n"); | |
| 974 | yield* git(["add", "."]); | |
| 975 | if (indexMode === "flags") { | |
| 976 | yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); | |
| 977 | yield* git(["update-index", "--skip-worktree", "scope/skipped"]); | |
| 978 | } | |
| 979 | if (indexMode === "split") { | |
| 980 | yield* git(["update-index", "--split-index"]); | |
| 981 | } | |
| 982 | const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); | |
| 983 | for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { | |
| 984 | yield* write(name, "working\n"); | |
| 985 | } | |
| 986 | yield* write("scope/new", "first\n"); | |
| 987 | yield* fileSystem.remove(path.join(cwd, "scope/deleted")); | |
| 988 | yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); | |
| 989 | const captureCwd = nested ? path.join(cwd, "scope") : cwd; | |
| 990 | const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); | |
| 991 | const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); | |
| 992 | yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); | |
| 993 | for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { | |
| 994 | assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); | |
| 995 | } | |
| 996 | assert.strictEqual( | |
| 997 | (yield* git(["show", `${first}:outside`])).stdout, | |
| 998 | nested ? "original\n" : "working\n", | |
| 999 | ); | |
| 1000 | const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); | |
| 1001 | assert.notInclude(files, "scope/deleted"); | |
| 1002 | assert.notInclude(files, "scope/new-deleted"); | |
| 1003 | assert.include(files, "scope/new"); | |
| 913 | it.effect.each( | |
| 914 | [false, true].flatMap((nested) => | |
| 915 | (["normal", "flags", "split"] as const).map((indexMode) => ({ nested, indexMode })), | |
| 916 | ), | |
| 917 | )( | |
| 918 | "checkpoint index reuse preserves two turns (nested=$nested, index=$indexMode)", | |
| 919 | ({ nested, indexMode }) => | |
| 920 | Effect.gen(function* () { | |
| 921 | const fileSystem = yield* FileSystem.FileSystem; | |
| 922 | const path = yield* Path.Path; | |
| 923 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | |
| 924 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); | |
| 925 | const { git } = yield* makeCheckpointFixture(driver, cwd); | |
| 926 | const write = (name: string, contents: string) => | |
| 927 | fileSystem.writeFileString(path.join(cwd, name), contents); | |
| 928 | yield* fileSystem.makeDirectory(path.join(cwd, "scope")); | |
| 929 | for (const name of [ | |
| 930 | "scope/staged", | |
| 931 | "scope/deleted", | |
| 932 | "scope/assumed", | |
| 933 | "scope/skipped", | |
| 934 | "outside", | |
| 935 | ]) { | |
| 936 | yield* write(name, "original\n"); | |
| 937 | } | |
| 938 | yield* git(["add", "."]); | |
| 939 | yield* git(["commit", "-m", "initial scoped files"]); | |
| 940 | yield* write("scope/staged", "staged\n"); | |
| 941 | yield* write("scope/new-deleted", "staged then deleted\n"); | |
| 942 | yield* write("outside", "staged outside\n"); | |
| 943 | yield* git(["add", "."]); | |
| 944 | if (indexMode === "flags") { | |
| 945 | yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); | |
| 946 | yield* git(["update-index", "--skip-worktree", "scope/skipped"]); | |
| 947 | } | |
| 948 | if (indexMode === "split") { | |
| 949 | yield* git(["update-index", "--split-index"]); | |
| 950 | } | |
| 951 | const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); | |
| 952 | for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { | |
| 953 | yield* write(name, "working\n"); | |
| 954 | } | |
| 955 | yield* write("scope/new", "first\n"); | |
| 956 | yield* fileSystem.remove(path.join(cwd, "scope/deleted")); | |
| 957 | yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); | |
| 958 | const captureCwd = nested ? path.join(cwd, "scope") : cwd; | |
| 959 | const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); | |
| 960 | const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); | |
| 961 | yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); | |
| 962 | for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { | |
| 963 | assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); | |
| 964 | } | |
| 965 | assert.strictEqual( | |
| 966 | (yield* git(["show", `${first}:outside`])).stdout, | |
| 967 | nested ? "original\n" : "working\n", | |
| 968 | ); | |
| 969 | const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); | |
| 970 | assert.notInclude(files, "scope/deleted"); | |
| 971 | assert.notInclude(files, "scope/new-deleted"); | |
| 972 | assert.include(files, "scope/new"); | |
| 1004 | 973 | |
| 1005 | yield* write("scope/staged", "second\n"); | |
| 1006 | yield* fileSystem.remove(path.join(cwd, "scope/new")); | |
| 1007 | yield* write("scope/second", "added in second turn\n"); | |
| 1008 | yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); | |
| 1009 | assert.strictEqual( | |
| 1010 | (yield* git(["diff", "--name-only", first, second])).stdout, | |
| 1011 | "scope/new\nscope/second\nscope/staged\n", | |
| 1012 | ); | |
| 1013 | assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); | |
| 1014 | assert.strictEqual( | |
| 1015 | (yield* git(["show", `${second}:scope/second`])).stdout, | |
| 1016 | "added in second turn\n", | |
| 1017 | ); | |
| 1018 | assert.deepEqual( | |
| 1019 | yield* fileSystem.readFile(path.join(cwd, ".git", "index")), | |
| 1020 | originalIndex, | |
| 1021 | ); | |
| 1022 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 1023 | ); | |
| 1024 | } | |
| 1025 | } | |
| 974 | yield* write("scope/staged", "second\n"); | |
| 975 | yield* fileSystem.remove(path.join(cwd, "scope/new")); | |
| 976 | yield* write("scope/second", "added in second turn\n"); | |
| 977 | yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); | |
| 978 | assert.strictEqual( | |
| 979 | (yield* git(["diff", "--name-only", first, second])).stdout, | |
| 980 | "scope/new\nscope/second\nscope/staged\n", | |
| 981 | ); | |
| 982 | assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); | |
| 983 | assert.strictEqual( | |
| 984 | (yield* git(["show", `${second}:scope/second`])).stdout, | |
| 985 | "added in second turn\n", | |
| 986 | ); | |
| 987 | assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); | |
| 988 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), | |
| 989 | ); | |
| 1026 | 990 | |
| 1027 | for (const indexState of ["missing", "invalid"] as const) { | |
| 1028 | it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () => | |
| 991 | it.effect.each(["missing", "invalid"] as const)( | |
| 992 | "checkpoint capture falls back when the user index is %s", | |
| 993 | (indexState) => | |
| 1029 | 994 | Effect.gen(function* () { |
| 1030 | 995 | const fileSystem = yield* FileSystem.FileSystem; |
| 1031 | 996 | const path = yield* Path.Path; |
| ... | ... | @@ -1048,8 +1013,7 @@ for (const indexState of ["missing", "invalid"] as const) { |
| 1048 | 1013 | assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index"); |
| 1049 | 1014 | } |
| 1050 | 1015 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| 1051 | ); | |
| 1052 | } | |
| 1016 | ); | |
| 1053 | 1017 | |
| 1054 | 1018 | it.effect("restores empty checkpoints without changing paths outside the workspace", () => |
| 1055 | 1019 | Effect.gen(function* () { |
apps/server/src/vcs/GitVcsDriverCore.test.ts+134-140| ... | ... | @@ -254,50 +254,48 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( |
| 254 | 254 | }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), |
| 255 | 255 | ); |
| 256 | 256 | |
| 257 | for (const location of ["root", "nested", "worktree"] as const) { | |
| 258 | it.effect( | |
| 259 | `skips clean filters while the ${location} index is locked and resumes after unlock`, | |
| 260 | () => | |
| 261 | Effect.gen(function* () { | |
| 262 | const driver = yield* GitVcsDriver.GitVcsDriver; | |
| 263 | const fs = yield* FileSystem.FileSystem; | |
| 264 | const path = yield* Path.Path; | |
| 265 | const repository = yield* makeTmpDir(); | |
| 266 | yield* initRepoWithCommit(repository); | |
| 267 | const cwd = location === "worktree" ? yield* makeTmpDir() : repository; | |
| 268 | if (location === "worktree") { | |
| 269 | yield* git(repository, ["worktree", "add", "--detach", cwd]); | |
| 270 | } | |
| 271 | yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]); | |
| 272 | yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n"); | |
| 273 | yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n"); | |
| 274 | yield* writeTextFile(cwd, "asset.bin", "original\n"); | |
| 275 | yield* git(cwd, ["add", "."]); | |
| 276 | yield* git(cwd, ["commit", "-m", "filtered asset"]); | |
| 277 | NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1); | |
| 278 | const runsPath = path.join(cwd, ".filter-runs"); | |
| 279 | yield* fs.remove(runsPath, { force: true }); | |
| 280 | const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); | |
| 281 | const lockPath = `${path.resolve(cwd, indexPath)}.lock`; | |
| 282 | yield* fs.writeFileString(lockPath, ""); | |
| 283 | const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd; | |
| 284 | yield* fs.makeDirectory(statusCwd, { recursive: true }); | |
| 285 | ||
| 286 | for (let poll = 0; poll < 3; poll++) { | |
| 287 | const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); | |
| 288 | assert.isTrue(Result.isFailure(result)); | |
| 289 | if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); | |
| 290 | } | |
| 291 | assert.isFalse(yield* fs.exists(runsPath)); | |
| 292 | assert.isTrue(yield* fs.exists(lockPath)); | |
| 293 | ||
| 294 | yield* fs.remove(lockPath); | |
| 295 | const status = yield* driver.statusDetailsLocal(statusCwd); | |
| 296 | assert.isFalse(status.hasWorkingTreeChanges); | |
| 297 | assert.include(yield* fs.readFileString(runsPath), "clean"); | |
| 298 | }).pipe(Effect.provide(TestLayer)), | |
| 299 | ); | |
| 300 | } | |
| 257 | it.effect.each(["root", "nested", "worktree"] as const)( | |
| 258 | "skips clean filters while the %s index is locked and resumes after unlock", | |
| 259 | (location) => | |
| 260 | Effect.gen(function* () { | |
| 261 | const driver = yield* GitVcsDriver.GitVcsDriver; | |
| 262 | const fs = yield* FileSystem.FileSystem; | |
| 263 | const path = yield* Path.Path; | |
| 264 | const repository = yield* makeTmpDir(); | |
| 265 | yield* initRepoWithCommit(repository); | |
| 266 | const cwd = location === "worktree" ? yield* makeTmpDir() : repository; | |
| 267 | if (location === "worktree") { | |
| 268 | yield* git(repository, ["worktree", "add", "--detach", cwd]); | |
| 269 | } | |
| 270 | yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]); | |
| 271 | yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n"); | |
| 272 | yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n"); | |
| 273 | yield* writeTextFile(cwd, "asset.bin", "original\n"); | |
| 274 | yield* git(cwd, ["add", "."]); | |
| 275 | yield* git(cwd, ["commit", "-m", "filtered asset"]); | |
| 276 | NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1); | |
| 277 | const runsPath = path.join(cwd, ".filter-runs"); | |
| 278 | yield* fs.remove(runsPath, { force: true }); | |
| 279 | const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); | |
| 280 | const lockPath = `${path.resolve(cwd, indexPath)}.lock`; | |
| 281 | yield* fs.writeFileString(lockPath, ""); | |
| 282 | const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd; | |
| 283 | yield* fs.makeDirectory(statusCwd, { recursive: true }); | |
| 284 | ||
| 285 | for (let poll = 0; poll < 3; poll++) { | |
| 286 | const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); | |
| 287 | assert.isTrue(Result.isFailure(result)); | |
| 288 | if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); | |
| 289 | } | |
| 290 | assert.isFalse(yield* fs.exists(runsPath)); | |
| 291 | assert.isTrue(yield* fs.exists(lockPath)); | |
| 292 | ||
| 293 | yield* fs.remove(lockPath); | |
| 294 | const status = yield* driver.statusDetailsLocal(statusCwd); | |
| 295 | assert.isFalse(status.hasWorkingTreeChanges); | |
| 296 | assert.include(yield* fs.readFileString(runsPath), "clean"); | |
| 297 | }).pipe(Effect.provide(TestLayer)), | |
| 298 | ); | |
| 301 | 299 | |
| 302 | 300 | it.effect("uses stable diagnostics for every parsed non-repository command", () => { |
| 303 | 301 | const commands: Array<{ readonly args: ReadonlyArray<string>; readonly lcAll?: string }> = []; |
| ... | ... | @@ -822,7 +820,7 @@ it.effect("backs off and logs failed fetch attempts across linked worktrees", () |
| 822 | 820 | ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), |
| 823 | 821 | ); |
| 824 | 822 | |
| 825 | for (const scenario of [ | |
| 823 | it.effect.each([ | |
| 826 | 824 | { |
| 827 | 825 | name: "HTTPS credentials", |
| 828 | 826 | stderr: "fatal: Authentication failed for", |
| ... | ... | @@ -885,53 +883,51 @@ for (const scenario of [ |
| 885 | 883 | stderr: "fatal: unexpected remote failure", |
| 886 | 884 | expected: "git fetch origin failed", |
| 887 | 885 | }, |
| 888 | ] as const) { | |
| 889 | it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () => | |
| 890 | Effect.gen(function* () { | |
| 891 | const secret = "secret-fetch-token"; | |
| 892 | const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; | |
| 893 | const attempts = yield* Ref.make(0); | |
| 894 | const spawner = ChildProcessSpawner.make((command) => | |
| 895 | Effect.gen(function* () { | |
| 896 | if (!ChildProcess.isStandardCommand(command)) | |
| 897 | return yield* Effect.die("expected Git command"); | |
| 898 | if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); | |
| 899 | assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); | |
| 900 | assert.equal(command.options.env?.LC_ALL, "C"); | |
| 901 | assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); | |
| 902 | yield* Ref.update(attempts, (count) => count + 1); | |
| 903 | return ChildProcessSpawner.makeHandle({ | |
| 904 | pid: ChildProcessSpawner.ProcessId(1), | |
| 905 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), | |
| 906 | isRunning: Effect.succeed(false), | |
| 907 | kill: () => Effect.void, | |
| 908 | unref: Effect.succeed(Effect.void), | |
| 909 | stdin: Sink.drain, | |
| 910 | stdout: Stream.encodeText(Stream.make(secret)), | |
| 911 | stderr: Stream.encodeText(Stream.make(stderr)), | |
| 912 | all: Stream.empty, | |
| 913 | getInputFd: () => Sink.drain, | |
| 914 | getOutputFd: () => Stream.empty, | |
| 915 | }); | |
| 916 | }), | |
| 917 | ); | |
| 918 | const driver = yield* makeGitVcsDriverCore().pipe( | |
| 919 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 920 | ); | |
| 921 | const cwd = yield* makeTmpDir(); | |
| 922 | const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); | |
| 923 | assert.include(error.detail, scenario.expected); | |
| 924 | assert.equal(error.exitCode, 128); | |
| 925 | assert.equal(error.stderrLength, stderr.length); | |
| 926 | assert.equal(error.stdoutLength, secret.length); | |
| 927 | assert.notInclude(error.message, secret); | |
| 928 | assert.notInclude(yield* encodeGitCommandError(error), secret); | |
| 929 | assert.notProperty(error, "stderr"); | |
| 930 | assert.notProperty(error, "args"); | |
| 931 | assert.equal(yield* Ref.get(attempts), 1); | |
| 932 | }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), | |
| 933 | ); | |
| 934 | } | |
| 886 | ] as const)("reports $name during fetch without retaining remote output", (scenario) => | |
| 887 | Effect.gen(function* () { | |
| 888 | const secret = "secret-fetch-token"; | |
| 889 | const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; | |
| 890 | const attempts = yield* Ref.make(0); | |
| 891 | const spawner = ChildProcessSpawner.make((command) => | |
| 892 | Effect.gen(function* () { | |
| 893 | if (!ChildProcess.isStandardCommand(command)) | |
| 894 | return yield* Effect.die("expected Git command"); | |
| 895 | if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); | |
| 896 | assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); | |
| 897 | assert.equal(command.options.env?.LC_ALL, "C"); | |
| 898 | assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); | |
| 899 | yield* Ref.update(attempts, (count) => count + 1); | |
| 900 | return ChildProcessSpawner.makeHandle({ | |
| 901 | pid: ChildProcessSpawner.ProcessId(1), | |
| 902 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), | |
| 903 | isRunning: Effect.succeed(false), | |
| 904 | kill: () => Effect.void, | |
| 905 | unref: Effect.succeed(Effect.void), | |
| 906 | stdin: Sink.drain, | |
| 907 | stdout: Stream.encodeText(Stream.make(secret)), | |
| 908 | stderr: Stream.encodeText(Stream.make(stderr)), | |
| 909 | all: Stream.empty, | |
| 910 | getInputFd: () => Sink.drain, | |
| 911 | getOutputFd: () => Stream.empty, | |
| 912 | }); | |
| 913 | }), | |
| 914 | ); | |
| 915 | const driver = yield* makeGitVcsDriverCore().pipe( | |
| 916 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 917 | ); | |
| 918 | const cwd = yield* makeTmpDir(); | |
| 919 | const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); | |
| 920 | assert.include(error.detail, scenario.expected); | |
| 921 | assert.equal(error.exitCode, 128); | |
| 922 | assert.equal(error.stderrLength, stderr.length); | |
| 923 | assert.equal(error.stdoutLength, secret.length); | |
| 924 | assert.notInclude(error.message, secret); | |
| 925 | assert.notInclude(yield* encodeGitCommandError(error), secret); | |
| 926 | assert.notProperty(error, "stderr"); | |
| 927 | assert.notProperty(error, "args"); | |
| 928 | assert.equal(yield* Ref.get(attempts), 1); | |
| 929 | }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), | |
| 930 | ); | |
| 935 | 931 | |
| 936 | 932 | it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 937 | 933 | describe("process environment", () => { |
| ... | ... | @@ -1449,8 +1445,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1449 | 1445 | }), |
| 1450 | 1446 | ); |
| 1451 | 1447 | |
| 1452 | for (const splitIndex of [false, true]) { | |
| 1453 | it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => | |
| 1448 | it.effect.each([false, true])( | |
| 1449 | "keeps the preceding second cached in review previews (split: %s)", | |
| 1450 | (splitIndex) => | |
| 1454 | 1451 | Effect.gen(function* () { |
| 1455 | 1452 | const cwd = yield* makeTmpDir(); |
| 1456 | 1453 | yield* initRepoWithCommit(cwd); |
| ... | ... | @@ -1484,53 +1481,50 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1484 | 1481 | assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); |
| 1485 | 1482 | assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); |
| 1486 | 1483 | }), |
| 1487 | ); | |
| 1488 | } | |
| 1484 | ); | |
| 1489 | 1485 | |
| 1490 | for (const [timestamp, splitIndex] of [ | |
| 1486 | it.effect.each([ | |
| 1491 | 1487 | [1_700_000_000, false], |
| 1492 | 1488 | [1_700_000_000.9999, false], |
| 1493 | 1489 | [1_700_000_000, true], |
| 1494 | 1490 | [1_700_000_000.9999, true], |
| 1495 | ] as const) { | |
| 1496 | it.effect( | |
| 1497 | `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`, | |
| 1498 | () => | |
| 1499 | Effect.gen(function* () { | |
| 1500 | const cwd = yield* makeTmpDir(); | |
| 1501 | yield* initRepoWithCommit(cwd); | |
| 1502 | const driver = yield* GitVcsDriver.GitVcsDriver; | |
| 1503 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1504 | const path = yield* Path.Path; | |
| 1505 | const filePath = path.join(cwd, "tracked.txt"); | |
| 1506 | const indexPath = path.join(cwd, ".git", "index"); | |
| 1507 | // Reproduce a same-timestamp edit without relying on filesystem clock resolution. | |
| 1508 | yield* git(cwd, ["config", "core.trustctime", "false"]); | |
| 1509 | yield* writeTextFile(cwd, "tracked.txt", "before\n"); | |
| 1510 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 1511 | yield* git(cwd, ["add", "tracked.txt"]); | |
| 1512 | yield* git(cwd, ["commit", "-m", "record racy file"]); | |
| 1513 | if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); | |
| 1514 | yield* fileSystem.utimes(indexPath, timestamp, timestamp); | |
| 1515 | const originalIndex = yield* fileSystem.readFile(indexPath); | |
| 1516 | const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; | |
| 1517 | yield* writeTextFile(cwd, "tracked.txt", "after!\n"); | |
| 1518 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 1519 | yield* writeTextFile(cwd, "untracked.txt", "new\n"); | |
| 1520 | ||
| 1521 | const preview = yield* driver.getReviewDiffPreview({ cwd }); | |
| 1522 | const dirty = preview.sources.find((source) => source.kind === "working-tree")!; | |
| 1523 | assert.deepStrictEqual(dirty.files, [ | |
| 1524 | { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, | |
| 1525 | { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, | |
| 1526 | ]); | |
| 1527 | assert.include(dirty.diff, "-before"); | |
| 1528 | assert.include(dirty.diff, "+after!"); | |
| 1529 | assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); | |
| 1530 | assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); | |
| 1531 | }), | |
| 1532 | ); | |
| 1533 | } | |
| 1491 | ] as const)( | |
| 1492 | "preserves same-size edits with a racy review index (%s, split: %s)", | |
| 1493 | ([timestamp, splitIndex]) => | |
| 1494 | Effect.gen(function* () { | |
| 1495 | const cwd = yield* makeTmpDir(); | |
| 1496 | yield* initRepoWithCommit(cwd); | |
| 1497 | const driver = yield* GitVcsDriver.GitVcsDriver; | |
| 1498 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1499 | const path = yield* Path.Path; | |
| 1500 | const filePath = path.join(cwd, "tracked.txt"); | |
| 1501 | const indexPath = path.join(cwd, ".git", "index"); | |
| 1502 | // Reproduce a same-timestamp edit without relying on filesystem clock resolution. | |
| 1503 | yield* git(cwd, ["config", "core.trustctime", "false"]); | |
| 1504 | yield* writeTextFile(cwd, "tracked.txt", "before\n"); | |
| 1505 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 1506 | yield* git(cwd, ["add", "tracked.txt"]); | |
| 1507 | yield* git(cwd, ["commit", "-m", "record racy file"]); | |
| 1508 | if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); | |
| 1509 | yield* fileSystem.utimes(indexPath, timestamp, timestamp); | |
| 1510 | const originalIndex = yield* fileSystem.readFile(indexPath); | |
| 1511 | const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; | |
| 1512 | yield* writeTextFile(cwd, "tracked.txt", "after!\n"); | |
| 1513 | yield* fileSystem.utimes(filePath, timestamp, timestamp); | |
| 1514 | yield* writeTextFile(cwd, "untracked.txt", "new\n"); | |
| 1515 | ||
| 1516 | const preview = yield* driver.getReviewDiffPreview({ cwd }); | |
| 1517 | const dirty = preview.sources.find((source) => source.kind === "working-tree")!; | |
| 1518 | assert.deepStrictEqual(dirty.files, [ | |
| 1519 | { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, | |
| 1520 | { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, | |
| 1521 | ]); | |
| 1522 | assert.include(dirty.diff, "-before"); | |
| 1523 | assert.include(dirty.diff, "+after!"); | |
| 1524 | assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); | |
| 1525 | assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); | |
| 1526 | }), | |
| 1527 | ); | |
| 1534 | 1528 | |
| 1535 | 1529 | it.effect("keeps complete stats for files beyond the combined patch limit", () => |
| 1536 | 1530 | Effect.gen(function* () { |
| ... | ... | @@ -2940,8 +2934,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2940 | 2934 | }); |
| 2941 | 2935 | |
| 2942 | 2936 | describe("remote operations", () => { |
| 2943 | for (const failure of ["offline", "auth", "timeout"] as const) { | |
| 2944 | it.effect(`does not retry a scoped fetch after ${failure}`, () => | |
| 2937 | it.effect.each(["offline", "auth", "timeout"] as const)( | |
| 2938 | "does not retry a scoped fetch after %s", | |
| 2939 | (failure) => | |
| 2945 | 2940 | Effect.gen(function* () { |
| 2946 | 2941 | const cwd = yield* makeTmpDir(); |
| 2947 | 2942 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; |
| ... | ... | @@ -2996,8 +2991,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2996 | 2991 | ); |
| 2997 | 2992 | } |
| 2998 | 2993 | }), |
| 2999 | ); | |
| 3000 | } | |
| 2994 | ); | |
| 3001 | 2995 | |
| 3002 | 2996 | it.effect("creates a worktree from the latest fetched remote commit", () => |
| 3003 | 2997 | Effect.gen(function* () { |
apps/web/src/components/chat/queuedMessageEdit.test.ts+18-17| ... | ... | @@ -42,24 +42,25 @@ describe("queued message file edits", () => { |
| 42 | 42 | useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); |
| 43 | 43 | }); |
| 44 | 44 | |
| 45 | for (const images of [[], [image]]) { | |
| 46 | it(`preserves a generic file in a ${images.length === 0 ? "file-only" : "mixed"} save`, async () => { | |
| 47 | const attachments = await prepareQueuedEditAttachments({ | |
| 48 | existingAttachments: [], | |
| 49 | images, | |
| 50 | files: [file], | |
| 51 | uploadFiles: async () => [uploadedFile], | |
| 52 | readImage: async () => "data:image/png;base64,aW1hZ2U=", | |
| 53 | }); | |
| 54 | expect(attachments.at(-1)).toEqual(uploadedFile); | |
| 55 | expect(attachments.length).toBe(images.length + 1); | |
| 56 | if (images.length > 0) | |
| 57 | expect(attachments[0]).toMatchObject({ | |
| 58 | type: "image", | |
| 59 | dataUrl: "data:image/png;base64,aW1hZ2U=", | |
| 60 | }); | |
| 45 | it.each([ | |
| 46 | { images: [], label: "file-only" }, | |
| 47 | { images: [image], label: "mixed" }, | |
| 48 | ])("preserves a generic file in a $label save", async ({ images }) => { | |
| 49 | const attachments = await prepareQueuedEditAttachments({ | |
| 50 | existingAttachments: [], | |
| 51 | images, | |
| 52 | files: [file], | |
| 53 | uploadFiles: async () => [uploadedFile], | |
| 54 | readImage: async () => "data:image/png;base64,aW1hZ2U=", | |
| 61 | 55 | }); |
| 62 | } | |
| 56 | expect(attachments.at(-1)).toEqual(uploadedFile); | |
| 57 | expect(attachments.length).toBe(images.length + 1); | |
| 58 | if (images.length > 0) | |
| 59 | expect(attachments[0]).toMatchObject({ | |
| 60 | type: "image", | |
| 61 | dataUrl: "data:image/png;base64,aW1hZ2U=", | |
| 62 | }); | |
| 63 | }); | |
| 63 | 64 | |
| 64 | 65 | it("retains saved attachments alongside newly uploaded files", async () => { |
| 65 | 66 | const saved = { ...uploadedFile, id: "saved:earlier" }; |
apps/web/src/keybindings.test.ts+10-8| ... | ... | @@ -1279,8 +1279,9 @@ describe("composer and pull request shortcuts", () => { |
| 1279 | 1279 | ["Enter", "thread.steerQueuedMessage"], |
| 1280 | 1280 | ] as const; |
| 1281 | 1281 | |
| 1282 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | |
| 1283 | it(`separates queued steering and background start on ${platform}`, () => { | |
| 1282 | it.each(["MacIntel", "Win32", "Linux"])( | |
| 1283 | "separates queued steering and background start on %s", | |
| 1284 | (platform) => { | |
| 1284 | 1285 | const modifier = { |
| 1285 | 1286 | metaKey: platform === "MacIntel", |
| 1286 | 1287 | ctrlKey: platform !== "MacIntel", |
| ... | ... | @@ -1301,8 +1302,8 @@ describe("composer and pull request shortcuts", () => { |
| 1301 | 1302 | }), |
| 1302 | 1303 | "composer.sendBackground", |
| 1303 | 1304 | ); |
| 1304 | }); | |
| 1305 | } | |
| 1305 | }, | |
| 1306 | ); | |
| 1306 | 1307 | |
| 1307 | 1308 | for (const platform of ["MacIntel", "Win32", "Linux"]) { |
| 1308 | 1309 | it.each(shortcuts)( |
| ... | ... | @@ -1331,8 +1332,9 @@ describe("composer and pull request shortcuts", () => { |
| 1331 | 1332 | ); |
| 1332 | 1333 | } |
| 1333 | 1334 | |
| 1334 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | |
| 1335 | it(`edits the last queued message with Alt+ArrowUp from the composer on ${platform}`, () => { | |
| 1335 | it.each(["MacIntel", "Win32", "Linux"])( | |
| 1336 | "edits the last queued message with Alt+ArrowUp from the composer on %s", | |
| 1337 | (platform) => { | |
| 1336 | 1338 | const input = event({ key: "ArrowUp", altKey: true }); |
| 1337 | 1339 | assert.strictEqual( |
| 1338 | 1340 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { |
| ... | ... | @@ -1347,8 +1349,8 @@ describe("composer and pull request shortcuts", () => { |
| 1347 | 1349 | context: { composerFocus: false }, |
| 1348 | 1350 | }), |
| 1349 | 1351 | ); |
| 1350 | }); | |
| 1351 | } | |
| 1352 | }, | |
| 1353 | ); | |
| 1352 | 1354 | |
| 1353 | 1355 | for (const platform of ["MacIntel", "Win32", "Linux"]) { |
| 1354 | 1356 | it.each([ |
infra/relay/src/agentActivity/ApnsClient.test.ts+74-74| ... | ... | @@ -366,79 +366,79 @@ describe("ApnsClient", () => { |
| 366 | 366 | }).pipe(Effect.provide(layer)); |
| 367 | 367 | }); |
| 368 | 368 | |
| 369 | for (const requestKind of ["live-activity", "push-notification"] as const) { | |
| 370 | for (const stage of ["send", "read-response"] as const) { | |
| 371 | it.effect(`aborts a stalled ${requestKind} ${stage} after ten seconds`, () => | |
| 372 | Effect.gen(function* () { | |
| 373 | const started = yield* Deferred.make<void>(); | |
| 374 | const signals: AbortSignal[] = []; | |
| 375 | const stalledHttpClient = HttpClient.make((request, _url, signal) => { | |
| 376 | signals.push(signal); | |
| 377 | const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); | |
| 378 | if (stage === "send") return stall; | |
| 379 | const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | |
| 380 | Object.defineProperty(response, "text", { value: stall }); | |
| 381 | return Effect.succeed(response); | |
| 382 | }); | |
| 383 | const layer = ApnsClient.layer.pipe( | |
| 384 | Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), | |
| 385 | Layer.provide( | |
| 386 | Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { | |
| 387 | getJwt: () => Effect.succeed("test-jwt"), | |
| 388 | }), | |
| 389 | ), | |
| 390 | ); | |
| 391 | const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); | |
| 392 | const credentials = { | |
| 393 | teamId: "team-timeout", | |
| 394 | keyId: "key-timeout", | |
| 395 | privateKey: Redacted.make("unused-test-key"), | |
| 396 | bundleId: "com.t3tools.test", | |
| 397 | environment: "sandbox", | |
| 398 | } satisfies ApnsCredentials; | |
| 399 | const send = | |
| 400 | requestKind === "live-activity" | |
| 401 | ? apns.sendLiveActivityRequest({ | |
| 402 | credentials, | |
| 403 | issuedAtUnixSeconds: 123, | |
| 404 | request: apns.makeLiveActivityRequest({ | |
| 405 | event: "update", | |
| 406 | token: "long-push-token", | |
| 407 | state, | |
| 408 | nowEpochSeconds: 123, | |
| 409 | nowIso: DateTime.formatIso(now), | |
| 410 | }), | |
| 411 | }) | |
| 412 | : apns.sendPushNotificationRequest({ | |
| 413 | credentials, | |
| 414 | issuedAtUnixSeconds: 123, | |
| 415 | request: apns.makePushNotificationRequest({ | |
| 416 | token: "long-push-token", | |
| 417 | notification: { | |
| 418 | title: "Thread", | |
| 419 | body: "Done", | |
| 420 | environmentId: "env", | |
| 421 | threadId: "thread", | |
| 422 | deepLink: "/", | |
| 423 | }, | |
| 424 | }), | |
| 425 | }); | |
| 426 | const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); | |
| 427 | yield* Deferred.await(started); | |
| 428 | yield* TestClock.adjust("10 seconds"); | |
| 429 | expect(signals[0]?.aborted).toBe(true); | |
| 430 | const error = yield* Fiber.join(fiber); | |
| 431 | expect(error).toMatchObject({ | |
| 432 | _tag: "ApnsHttpRequestError", | |
| 433 | requestKind, | |
| 434 | event: requestKind === "live-activity" ? "update" : null, | |
| 435 | stage, | |
| 436 | status: stage === "read-response" ? 200 : null, | |
| 437 | tokenSuffix: "sh-token", | |
| 438 | cause: { _tag: "TimeoutError" }, | |
| 439 | }); | |
| 440 | }), | |
| 369 | it.effect.each( | |
| 370 | (["live-activity", "push-notification"] as const).flatMap((requestKind) => | |
| 371 | (["send", "read-response"] as const).map((stage) => ({ requestKind, stage })), | |
| 372 | ), | |
| 373 | )("aborts a stalled $requestKind $stage after ten seconds", ({ requestKind, stage }) => | |
| 374 | Effect.gen(function* () { | |
| 375 | const started = yield* Deferred.make<void>(); | |
| 376 | const signals: AbortSignal[] = []; | |
| 377 | const stalledHttpClient = HttpClient.make((request, _url, signal) => { | |
| 378 | signals.push(signal); | |
| 379 | const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); | |
| 380 | if (stage === "send") return stall; | |
| 381 | const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | |
| 382 | Object.defineProperty(response, "text", { value: stall }); | |
| 383 | return Effect.succeed(response); | |
| 384 | }); | |
| 385 | const layer = ApnsClient.layer.pipe( | |
| 386 | Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), | |
| 387 | Layer.provide( | |
| 388 | Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { | |
| 389 | getJwt: () => Effect.succeed("test-jwt"), | |
| 390 | }), | |
| 391 | ), | |
| 441 | 392 | ); |
| 442 | } | |
| 443 | } | |
| 393 | const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); | |
| 394 | const credentials = { | |
| 395 | teamId: "team-timeout", | |
| 396 | keyId: "key-timeout", | |
| 397 | privateKey: Redacted.make("unused-test-key"), | |
| 398 | bundleId: "com.t3tools.test", | |
| 399 | environment: "sandbox", | |
| 400 | } satisfies ApnsCredentials; | |
| 401 | const send = | |
| 402 | requestKind === "live-activity" | |
| 403 | ? apns.sendLiveActivityRequest({ | |
| 404 | credentials, | |
| 405 | issuedAtUnixSeconds: 123, | |
| 406 | request: apns.makeLiveActivityRequest({ | |
| 407 | event: "update", | |
| 408 | token: "long-push-token", | |
| 409 | state, | |
| 410 | nowEpochSeconds: 123, | |
| 411 | nowIso: DateTime.formatIso(now), | |
| 412 | }), | |
| 413 | }) | |
| 414 | : apns.sendPushNotificationRequest({ | |
| 415 | credentials, | |
| 416 | issuedAtUnixSeconds: 123, | |
| 417 | request: apns.makePushNotificationRequest({ | |
| 418 | token: "long-push-token", | |
| 419 | notification: { | |
| 420 | title: "Thread", | |
| 421 | body: "Done", | |
| 422 | environmentId: "env", | |
| 423 | threadId: "thread", | |
| 424 | deepLink: "/", | |
| 425 | }, | |
| 426 | }), | |
| 427 | }); | |
| 428 | const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); | |
| 429 | yield* Deferred.await(started); | |
| 430 | yield* TestClock.adjust("10 seconds"); | |
| 431 | expect(signals[0]?.aborted).toBe(true); | |
| 432 | const error = yield* Fiber.join(fiber); | |
| 433 | expect(error).toMatchObject({ | |
| 434 | _tag: "ApnsHttpRequestError", | |
| 435 | requestKind, | |
| 436 | event: requestKind === "live-activity" ? "update" : null, | |
| 437 | stage, | |
| 438 | status: stage === "read-response" ? 200 : null, | |
| 439 | tokenSuffix: "sh-token", | |
| 440 | cause: { _tag: "TimeoutError" }, | |
| 441 | }); | |
| 442 | }), | |
| 443 | ); | |
| 444 | 444 | }); |
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+74-73| ... | ... | @@ -1909,8 +1909,9 @@ describe("live activity alert decisions", () => { |
| 1909 | 1909 | }); |
| 1910 | 1910 | |
| 1911 | 1911 | describe("queued iOS alert policy", () => { |
| 1912 | for (const scenario of ["enabled", "muted", "late"] as const) { | |
| 1913 | it.effect(`checks the current policy for a ${scenario} completion`, () => { | |
| 1912 | it.effect.each(["enabled", "muted", "late"] as const)( | |
| 1913 | "checks the current policy for a %s completion", | |
| 1914 | (scenario) => { | |
| 1914 | 1915 | let sent = 0; |
| 1915 | 1916 | const completed = { ...state, phase: "completed" as const }; |
| 1916 | 1917 | const prefs = JSON.parse(enabledPreferences); |
| ... | ... | @@ -1957,8 +1958,8 @@ describe("queued iOS alert policy", () => { |
| 1957 | 1958 | }), |
| 1958 | 1959 | ), |
| 1959 | 1960 | ); |
| 1960 | }); | |
| 1961 | } | |
| 1961 | }, | |
| 1962 | ); | |
| 1962 | 1963 | }); |
| 1963 | 1964 | |
| 1964 | 1965 | describe("fast completion delivery", () => { |
| ... | ... | @@ -2019,74 +2020,74 @@ describe("fast completion delivery", () => { |
| 2019 | 2020 | }); |
| 2020 | 2021 | |
| 2021 | 2022 | describe("signed APNs registration metadata", () => { |
| 2022 | for (const kind of ["live_activity_update", "push_notification"] as const) { | |
| 2023 | for (const changed of ["bundle", "environment", "legacy"] as const) { | |
| 2024 | it.effect(`routes ${kind} using current registration with ${changed} job metadata`, () => { | |
| 2025 | const attempts: DeliveryAttempts.DeliveryAttemptInput[] = []; | |
| 2026 | const requests: HttpClientRequest.HttpClientRequest[] = []; | |
| 2027 | const payload = makeApnsDeliveryJobPayload({ | |
| 2028 | kind, | |
| 2029 | userId: target.user_id, | |
| 2030 | deviceId: target.device_id, | |
| 2031 | token: "unchanged-token", | |
| 2032 | ...(changed === "legacy" | |
| 2033 | ? {} | |
| 2034 | : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }), | |
| 2035 | aggregate: kind === "live_activity_update" ? aggregate : null, | |
| 2036 | ...(kind === "push_notification" | |
| 2037 | ? { | |
| 2038 | notification: { | |
| 2039 | title: "Thread", | |
| 2040 | body: "Input: Project", | |
| 2041 | environmentId: "env", | |
| 2042 | threadId: "thread", | |
| 2043 | deepLink: "/", | |
| 2044 | }, | |
| 2045 | } | |
| 2046 | : {}), | |
| 2047 | createdAt: "1970-01-01T00:00:00.000Z", | |
| 2048 | expiresAt: "1970-01-01T00:10:00.000Z", | |
| 2049 | jobId: `metadata-${kind}-${changed}`, | |
| 2050 | }); | |
| 2051 | const signed = signApnsDeliveryJob({ | |
| 2052 | secret: config.apnsDeliveryJobSigningSecret, | |
| 2053 | payload, | |
| 2054 | }); | |
| 2055 | return Effect.gen(function* () { | |
| 2056 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | |
| 2057 | const result = yield* deliveries.processSignedJob(signed); | |
| 2058 | expect(result.ok).toBe(true); | |
| 2059 | expect(requests).toHaveLength(1); | |
| 2060 | expect(requests[0]?.url).toBe( | |
| 2061 | `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`, | |
| 2062 | ); | |
| 2063 | expect(requests[0]?.headers["apns-topic"]).toBe( | |
| 2064 | `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`, | |
| 2065 | ); | |
| 2066 | }).pipe( | |
| 2067 | Effect.provide( | |
| 2068 | makeLayer({ | |
| 2069 | attempts, | |
| 2070 | config: signingConfig, | |
| 2071 | currentTargets: [ | |
| 2072 | { | |
| 2073 | ...target, | |
| 2074 | push_token: "unchanged-token", | |
| 2075 | activity_push_token: "unchanged-token", | |
| 2076 | bundle_id: | |
| 2077 | changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev", | |
| 2078 | aps_environment: changed === "environment" ? "production" : "sandbox", | |
| 2079 | }, | |
| 2080 | ], | |
| 2081 | execute: (request) => | |
| 2082 | Effect.sync(() => { | |
| 2083 | requests.push(request); | |
| 2084 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | |
| 2085 | }), | |
| 2023 | it.effect.each( | |
| 2024 | (["live_activity_update", "push_notification"] as const).flatMap((kind) => | |
| 2025 | (["bundle", "environment", "legacy"] as const).map((changed) => ({ kind, changed })), | |
| 2026 | ), | |
| 2027 | )("routes $kind using current registration with $changed job metadata", ({ kind, changed }) => { | |
| 2028 | const attempts: DeliveryAttempts.DeliveryAttemptInput[] = []; | |
| 2029 | const requests: HttpClientRequest.HttpClientRequest[] = []; | |
| 2030 | const payload = makeApnsDeliveryJobPayload({ | |
| 2031 | kind, | |
| 2032 | userId: target.user_id, | |
| 2033 | deviceId: target.device_id, | |
| 2034 | token: "unchanged-token", | |
| 2035 | ...(changed === "legacy" | |
| 2036 | ? {} | |
| 2037 | : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }), | |
| 2038 | aggregate: kind === "live_activity_update" ? aggregate : null, | |
| 2039 | ...(kind === "push_notification" | |
| 2040 | ? { | |
| 2041 | notification: { | |
| 2042 | title: "Thread", | |
| 2043 | body: "Input: Project", | |
| 2044 | environmentId: "env", | |
| 2045 | threadId: "thread", | |
| 2046 | deepLink: "/", | |
| 2047 | }, | |
| 2048 | } | |
| 2049 | : {}), | |
| 2050 | createdAt: "1970-01-01T00:00:00.000Z", | |
| 2051 | expiresAt: "1970-01-01T00:10:00.000Z", | |
| 2052 | jobId: `metadata-${kind}-${changed}`, | |
| 2053 | }); | |
| 2054 | const signed = signApnsDeliveryJob({ | |
| 2055 | secret: config.apnsDeliveryJobSigningSecret, | |
| 2056 | payload, | |
| 2057 | }); | |
| 2058 | return Effect.gen(function* () { | |
| 2059 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | |
| 2060 | const result = yield* deliveries.processSignedJob(signed); | |
| 2061 | expect(result.ok).toBe(true); | |
| 2062 | expect(requests).toHaveLength(1); | |
| 2063 | expect(requests[0]?.url).toBe( | |
| 2064 | `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`, | |
| 2065 | ); | |
| 2066 | expect(requests[0]?.headers["apns-topic"]).toBe( | |
| 2067 | `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`, | |
| 2068 | ); | |
| 2069 | }).pipe( | |
| 2070 | Effect.provide( | |
| 2071 | makeLayer({ | |
| 2072 | attempts, | |
| 2073 | config: signingConfig, | |
| 2074 | currentTargets: [ | |
| 2075 | { | |
| 2076 | ...target, | |
| 2077 | push_token: "unchanged-token", | |
| 2078 | activity_push_token: "unchanged-token", | |
| 2079 | bundle_id: | |
| 2080 | changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev", | |
| 2081 | aps_environment: changed === "environment" ? "production" : "sandbox", | |
| 2082 | }, | |
| 2083 | ], | |
| 2084 | execute: (request) => | |
| 2085 | Effect.sync(() => { | |
| 2086 | requests.push(request); | |
| 2087 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | |
| 2086 | 2088 | }), |
| 2087 | ), | |
| 2088 | ); | |
| 2089 | }); | |
| 2090 | } | |
| 2091 | } | |
| 2089 | }), | |
| 2090 | ), | |
| 2091 | ); | |
| 2092 | }); | |
| 2092 | 2093 | }); |
infra/relay/src/agentActivity/FcmDeliveries.test.ts+75-73| ... | ... | @@ -215,36 +215,39 @@ describe("Android delivery routing", () => { |
| 215 | 215 | deepLink: "/threads/env/second-thread", |
| 216 | 216 | }; |
| 217 | 217 | |
| 218 | for (const [firstPhase, secondPhase, title, active] of [ | |
| 219 | ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], | |
| 220 | ["completed", "failed", "2 agents finished", "false"], | |
| 221 | ] as const) { | |
| 222 | it.effect( | |
| 223 | `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`, | |
| 224 | () => { | |
| 225 | const h = harness(); | |
| 226 | h.current.otherStates = [secondState]; | |
| 227 | return Effect.gen(function* () { | |
| 228 | const delivery = yield* FcmDeliveries.FcmDeliveries; | |
| 229 | yield* delivery.process(h.job); | |
| 230 | h.current.state = { ...state, phase: firstPhase }; | |
| 231 | h.current.otherStates = [{ ...secondState, phase: secondPhase }]; | |
| 232 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 233 | yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); | |
| 234 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 235 | const alerts = h.sent.filter((message) => message.alert); | |
| 236 | expect(alerts).toHaveLength(1); | |
| 237 | expect(alerts[0]?.data).toMatchObject({ | |
| 238 | alert_title: title, | |
| 239 | alert_body: "Fix notifications, Second thread", | |
| 240 | alert_path: "/", | |
| 241 | active, | |
| 242 | }); | |
| 243 | expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); | |
| 244 | }).pipe(Effect.provide(h.layer)); | |
| 245 | }, | |
| 246 | ); | |
| 247 | } | |
| 218 | it.effect.each([ | |
| 219 | { | |
| 220 | firstPhase: "waiting_for_approval", | |
| 221 | secondPhase: "waiting_for_input", | |
| 222 | title: "2 agents need attention", | |
| 223 | active: "true", | |
| 224 | }, | |
| 225 | { firstPhase: "completed", secondPhase: "failed", title: "2 agents finished", active: "false" }, | |
| 226 | ] as const)( | |
| 227 | "routes grouped $firstPhase and $secondPhase to the overview once across their queued jobs", | |
| 228 | ({ firstPhase, secondPhase, title, active }) => { | |
| 229 | const h = harness(); | |
| 230 | h.current.otherStates = [secondState]; | |
| 231 | return Effect.gen(function* () { | |
| 232 | const delivery = yield* FcmDeliveries.FcmDeliveries; | |
| 233 | yield* delivery.process(h.job); | |
| 234 | h.current.state = { ...state, phase: firstPhase }; | |
| 235 | h.current.otherStates = [{ ...secondState, phase: secondPhase }]; | |
| 236 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 237 | yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); | |
| 238 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 239 | const alerts = h.sent.filter((message) => message.alert); | |
| 240 | expect(alerts).toHaveLength(1); | |
| 241 | expect(alerts[0]?.data).toMatchObject({ | |
| 242 | alert_title: title, | |
| 243 | alert_body: "Fix notifications, Second thread", | |
| 244 | alert_path: "/", | |
| 245 | active, | |
| 246 | }); | |
| 247 | expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); | |
| 248 | }).pipe(Effect.provide(h.layer)); | |
| 249 | }, | |
| 250 | ); | |
| 248 | 251 | |
| 249 | 252 | it.effect("filters disabled event types before counting a group", () => { |
| 250 | 253 | const h = harness(); |
| ... | ... | @@ -294,13 +297,9 @@ describe("Android delivery routing", () => { |
| 294 | 297 | }).pipe(Effect.provide(h.layer)); |
| 295 | 298 | }); |
| 296 | 299 | |
| 297 | for (const phase of [ | |
| 298 | "completed", | |
| 299 | "waiting_for_approval", | |
| 300 | "waiting_for_input", | |
| 301 | "failed", | |
| 302 | ] as const) { | |
| 303 | it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => { | |
| 300 | it.effect.each(["completed", "waiting_for_approval", "waiting_for_input", "failed"] as const)( | |
| 301 | "deleting one thread preserves another thread's %s alert", | |
| 302 | (phase) => { | |
| 304 | 303 | const h = harness(); |
| 305 | 304 | h.current.otherStates = [secondState]; |
| 306 | 305 | return Effect.gen(function* () { |
| ... | ... | @@ -316,8 +315,8 @@ describe("Android delivery routing", () => { |
| 316 | 315 | yield* delivery.process({ ...h.job, state: h.current.state }); |
| 317 | 316 | expect(h.sent.filter((message) => message.alert)).toHaveLength(1); |
| 318 | 317 | }).pipe(Effect.provide(h.layer)); |
| 319 | }); | |
| 320 | } | |
| 318 | }, | |
| 319 | ); | |
| 321 | 320 | |
| 322 | 321 | it.effect("registration replay establishes a baseline without alerting", () => { |
| 323 | 322 | const h = harness(); |
| ... | ... | @@ -333,8 +332,9 @@ describe("Android delivery routing", () => { |
| 333 | 332 | }).pipe(Effect.provide(h.layer)); |
| 334 | 333 | }); |
| 335 | 334 | |
| 336 | for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) { | |
| 337 | it.effect(`excludes ${restriction} when forming cross-environment groups`, () => { | |
| 335 | it.effect.each(["mutedEnvironments", "revokedEnvironments"] as const)( | |
| 336 | "excludes %s when forming cross-environment groups", | |
| 337 | (restriction) => { | |
| 338 | 338 | const h = harness(); |
| 339 | 339 | const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; |
| 340 | 340 | h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); |
| ... | ... | @@ -349,8 +349,8 @@ describe("Android delivery routing", () => { |
| 349 | 349 | alert_body: "Approval: Project", |
| 350 | 350 | }); |
| 351 | 351 | }).pipe(Effect.provide(h.layer)); |
| 352 | }); | |
| 353 | } | |
| 352 | }, | |
| 353 | ); | |
| 354 | 354 | |
| 355 | 355 | it("gives a group a stable retry identity independent of row order", () => { |
| 356 | 356 | const other = { |
| ... | ... | @@ -409,13 +409,14 @@ describe("Android delivery routing", () => { |
| 409 | 409 | ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" }); |
| 410 | 410 | }); |
| 411 | 411 | |
| 412 | for (const [phase, body, preference] of [ | |
| 413 | ["waiting_for_approval", "Approval: Project", "notifyOnApproval"], | |
| 414 | ["waiting_for_input", "Input: Project", "notifyOnInput"], | |
| 415 | ["completed", "Done: Project", "notifyOnCompletion"], | |
| 416 | ["failed", "Failed: Project", "notifyOnFailure"], | |
| 417 | ] as const) { | |
| 418 | it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => { | |
| 412 | it.effect.each([ | |
| 413 | { phase: "waiting_for_approval", body: "Approval: Project", preference: "notifyOnApproval" }, | |
| 414 | { phase: "waiting_for_input", body: "Input: Project", preference: "notifyOnInput" }, | |
| 415 | { phase: "completed", body: "Done: Project", preference: "notifyOnCompletion" }, | |
| 416 | { phase: "failed", body: "Failed: Project", preference: "notifyOnFailure" }, | |
| 417 | ] as const)( | |
| 418 | "uses iOS alert wording for $phase and honors its preference", | |
| 419 | ({ phase, body, preference }) => { | |
| 419 | 420 | const h = harness(); |
| 420 | 421 | h.current.state = { ...state, phase }; |
| 421 | 422 | return Effect.gen(function* () { |
| ... | ... | @@ -433,8 +434,8 @@ describe("Android delivery routing", () => { |
| 433 | 434 | yield* delivery.process({ ...h.job, state: h.current.state }); |
| 434 | 435 | expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true); |
| 435 | 436 | }).pipe(Effect.provide(h.layer)); |
| 436 | }); | |
| 437 | } | |
| 437 | }, | |
| 438 | ); | |
| 438 | 439 | |
| 439 | 440 | it("trims and truncates alert text like iOS", () => { |
| 440 | 441 | expect( |
| ... | ... | @@ -572,23 +573,23 @@ describe("Android delivery routing", () => { |
| 572 | 573 | expect(h.sent[1]?.data.alert_id).toBeUndefined(); |
| 573 | 574 | }).pipe(Effect.provide(h.layer)); |
| 574 | 575 | }); |
| 575 | for (const ongoing of [true, false]) { | |
| 576 | for (const phase of ["completed", "failed"] as const) { | |
| 577 | it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => { | |
| 578 | const h = harness(); | |
| 579 | h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; | |
| 580 | h.current.target.preferences_json = encodeJson({ | |
| 581 | ...preferences, | |
| 582 | liveActivitiesEnabled: ongoing, | |
| 583 | }); | |
| 584 | return Effect.gen(function* () { | |
| 585 | const delivery = yield* FcmDeliveries.FcmDeliveries; | |
| 586 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 587 | expect(h.sent.every((message) => !message.alert)).toBe(true); | |
| 588 | }).pipe(Effect.provide(h.layer)); | |
| 589 | }); | |
| 590 | } | |
| 591 | } | |
| 576 | it.effect.each( | |
| 577 | [true, false].flatMap((ongoing) => | |
| 578 | (["completed", "failed"] as const).map((phase) => ({ ongoing, phase })), | |
| 579 | ), | |
| 580 | )("does not alert a stale $phase without a baseline (ongoing=$ongoing)", ({ ongoing, phase }) => { | |
| 581 | const h = harness(); | |
| 582 | h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; | |
| 583 | h.current.target.preferences_json = encodeJson({ | |
| 584 | ...preferences, | |
| 585 | liveActivitiesEnabled: ongoing, | |
| 586 | }); | |
| 587 | return Effect.gen(function* () { | |
| 588 | const delivery = yield* FcmDeliveries.FcmDeliveries; | |
| 589 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 590 | expect(h.sent.every((message) => !message.alert)).toBe(true); | |
| 591 | }).pipe(Effect.provide(h.layer)); | |
| 592 | }); | |
| 592 | 593 | |
| 593 | 594 | it.effect( |
| 594 | 595 | "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", () = |
| 821 | 822 | }); |
| 822 | 823 | |
| 823 | 824 | describe("FCM queue message isolation", () => { |
| 824 | for (const failure of ["invalid-job", "fcm-rejection"] as const) { | |
| 825 | it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => { | |
| 825 | it.effect.each(["invalid-job", "fcm-rejection"] as const)( | |
| 826 | "retries only the %s message and delivers the rest of its batch", | |
| 827 | (failure) => { | |
| 826 | 828 | const h = harness(); |
| 827 | 829 | const outcomes = new Map<string, "ack" | "retry">(); |
| 828 | 830 | const message = (id: string, body: unknown): Cloudflare.Queues.Message<unknown> => ({ |
| ... | ... | @@ -863,6 +865,6 @@ describe("FCM queue message isolation", () => { |
| 863 | 865 | expect(h.sent).toHaveLength(1); |
| 864 | 866 | expect(h.marked).toHaveLength(1); |
| 865 | 867 | }).pipe(Effect.provide(h.layer)); |
| 866 | }); | |
| 867 | } | |
| 868 | }, | |
| 869 | ); | |
| 868 | 870 | }); |
oxlint-plugin-t3code/index.ts+2| ... | ... | @@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 7 | 7 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; |
| 8 | 8 | import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; |
| 9 | 9 | import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; |
| 10 | import noTestInLoop from "./rules/no-test-in-loop.ts"; | |
| 10 | 11 | |
| 11 | 12 | export default definePlugin({ |
| 12 | 13 | meta: { |
| ... | ... | @@ -20,5 +21,6 @@ export default definePlugin({ |
| 20 | 21 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, |
| 21 | 22 | "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, |
| 22 | 23 | "no-native-title-tooltip": noNativeTitleTooltip, |
| 24 | "no-test-in-loop": noTestInLoop, | |
| 23 | 25 | }, |
| 24 | 26 | }); |
oxlint-plugin-t3code/rules/no-test-in-loop.test.ts created+114| ... | ... | @@ -0,0 +1,114 @@ |
| 1 | import { assert, describe } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; | |
| 4 | ||
| 5 | const rule = createOxlintRuleHarness("t3code/no-test-in-loop", { | |
| 6 | filename: "fixture.test.ts", | |
| 7 | }); | |
| 8 | ||
| 9 | describe("t3code/no-test-in-loop", () => { | |
| 10 | rule.valid( | |
| 11 | "allows it.each and it.effect.each", | |
| 12 | ` | |
| 13 | import { it } from "@effect/vitest"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | ||
| 16 | it.each([1, 2])("handles %s", (value) => {}); | |
| 17 | it.effect.each([1, 2])("handles %s", (value) => Effect.succeed(value)); | |
| 18 | `, | |
| 19 | ); | |
| 20 | ||
| 21 | rule.valid( | |
| 22 | "allows loops inside a test body", | |
| 23 | ` | |
| 24 | import { it } from "@effect/vitest"; | |
| 25 | ||
| 26 | it("checks every value", () => { | |
| 27 | for (const value of [1, 2]) { | |
| 28 | if (value < 0) throw new Error("negative"); | |
| 29 | } | |
| 30 | }); | |
| 31 | `, | |
| 32 | ); | |
| 33 | ||
| 34 | rule.valid( | |
| 35 | "ignores node:test files, which have no .each", | |
| 36 | ` | |
| 37 | import { test } from "node:test"; | |
| 38 | ||
| 39 | for (const value of [1, 2]) { | |
| 40 | test(\`handles \${value}\`, () => {}); | |
| 41 | } | |
| 42 | `, | |
| 43 | ); | |
| 44 | ||
| 45 | rule.invalid( | |
| 46 | "reports it inside a for...of loop", | |
| 47 | ` | |
| 48 | import { it } from "@effect/vitest"; | |
| 49 | ||
| 50 | for (const value of [1, 2]) { | |
| 51 | it(\`handles \${value}\`, () => {}); | |
| 52 | } | |
| 53 | `, | |
| 54 | (output) => { | |
| 55 | assert.match(output, /Use it\.each\(cases\)/); | |
| 56 | }, | |
| 57 | ); | |
| 58 | ||
| 59 | rule.invalid( | |
| 60 | "reports it.effect inside a for loop nested in describe", | |
| 61 | ` | |
| 62 | import { describe, it } from "@effect/vitest"; | |
| 63 | import * as Effect from "effect/Effect"; | |
| 64 | ||
| 65 | describe("cases", () => { | |
| 66 | for (let index = 0; index < 2; index++) { | |
| 67 | it.effect(\`handles \${index}\`, () => Effect.void); | |
| 68 | } | |
| 69 | }); | |
| 70 | `, | |
| 71 | (output) => { | |
| 72 | assert.match(output, /Use it\.effect\.each\(cases\)/); | |
| 73 | }, | |
| 74 | ); | |
| 75 | ||
| 76 | rule.invalid( | |
| 77 | "reports test inside a for...in loop", | |
| 78 | ` | |
| 79 | import { test } from "vitest"; | |
| 80 | ||
| 81 | for (const key in { a: 1 }) { | |
| 82 | test(key, () => {}); | |
| 83 | } | |
| 84 | `, | |
| 85 | ); | |
| 86 | ||
| 87 | rule.invalid( | |
| 88 | "reports a describe inside a loop once, not the tests inside it", | |
| 89 | ` | |
| 90 | import { describe, it } from "vitest"; | |
| 91 | ||
| 92 | for (const bundle of ["a.js", "b.js"]) { | |
| 93 | describe(bundle, () => { | |
| 94 | it("first", () => {}); | |
| 95 | it("second", () => {}); | |
| 96 | }); | |
| 97 | } | |
| 98 | `, | |
| 99 | (output) => { | |
| 100 | assert.match(output, /Use describe\.each\(cases\)/); | |
| 101 | assert.equal(output.match(/no-test-in-loop/g)?.length, 1); | |
| 102 | }, | |
| 103 | ); | |
| 104 | }); | |
| 105 | ||
| 106 | const productionRule = createOxlintRuleHarness("t3code/no-test-in-loop"); | |
| 107 | ||
| 108 | productionRule.valid( | |
| 109 | "ignores non-test files", | |
| 110 | ` | |
| 111 | const it = (_name: string) => {}; | |
| 112 | for (const value of ["a"]) it(value); | |
| 113 | `, | |
| 114 | ); |
oxlint-plugin-t3code/rules/no-test-in-loop.ts created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | import { defineRule, type ESTree } from "@oxlint/plugins"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | ||
| 4 | import { getPropertyName, unwrapExpression } from "../utils.ts"; | |
| 5 | ||
| 6 | const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; | |
| 7 | // node:test has no `.each`, so files written against it keep their loops. | |
| 8 | const NODE_TEST_IMPORT_PATTERN = /["']node:test["']/u; | |
| 9 | // Calls that declare a test or a group of tests; all of them expose `.each`. | |
| 10 | const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]); | |
| 11 | // Modifiers whose tester also exposes `.each` (@effect/vitest's `it.effect` and `it.live`). | |
| 12 | const EACH_CAPABLE_MODIFIERS = new Set(["effect", "live"]); | |
| 13 | const LOOPS = new Set(["ForStatement", "ForInStatement", "ForOfStatement"]); | |
| 14 | const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]); | |
| 15 | ||
| 16 | /** Returns the reported name for `it(…)`, `describe(…)`, `it.effect(…)`, and friends. */ | |
| 17 | const testCallName = (callee: unknown): Option.Option<string> => { | |
| 18 | const expression = unwrapExpression(callee); | |
| 19 | if (Option.isNone(expression)) return Option.none(); | |
| 20 | ||
| 21 | if (expression.value.type === "Identifier") { | |
| 22 | return TEST_FUNCTIONS.has(expression.value.name) | |
| 23 | ? Option.some(expression.value.name) | |
| 24 | : Option.none(); | |
| 25 | } | |
| 26 | ||
| 27 | if (expression.value.type !== "MemberExpression") return Option.none(); | |
| 28 | const object = unwrapExpression(expression.value.object); | |
| 29 | const property = getPropertyName(expression.value.property); | |
| 30 | if (Option.isNone(object) || Option.isNone(property)) return Option.none(); | |
| 31 | if (!EACH_CAPABLE_MODIFIERS.has(property.value)) return Option.none(); | |
| 32 | if (object.value.type !== "Identifier" || !TEST_FUNCTIONS.has(object.value.name)) { | |
| 33 | return Option.none(); | |
| 34 | } | |
| 35 | return Option.some(`${object.value.name}.${property.value}`); | |
| 36 | }; | |
| 37 | ||
| 38 | /** | |
| 39 | * Reports a test declaration that runs once per loop iteration. A loop around a | |
| 40 | * `describe` is reported on the `describe`, so the tests inside it stay quiet. | |
| 41 | */ | |
| 42 | const isInsideLoop = (node: ESTree.Node): boolean => { | |
| 43 | let current = node.parent; | |
| 44 | while (current) { | |
| 45 | if (LOOPS.has(current.type)) return true; | |
| 46 | // Anything inside a function (a describe body, a helper, a test body) runs | |
| 47 | // once per call of that function, not once per iteration. | |
| 48 | if (FUNCTIONS.has(current.type)) return false; | |
| 49 | current = current.parent; | |
| 50 | } | |
| 51 | return false; | |
| 52 | }; | |
| 53 | ||
| 54 | export default defineRule({ | |
| 55 | meta: { | |
| 56 | type: "suggestion", | |
| 57 | docs: { | |
| 58 | description: | |
| 59 | "Disallow declaring tests inside a for loop; use it.each / it.effect.each / describe.each instead.", | |
| 60 | }, | |
| 61 | }, | |
| 62 | create(context) { | |
| 63 | if (!TEST_FILE_PATTERN.test(context.filename)) return {}; | |
| 64 | if (NODE_TEST_IMPORT_PATTERN.test(context.sourceCode.text)) return {}; | |
| 65 | ||
| 66 | return { | |
| 67 | CallExpression(node) { | |
| 68 | const name = testCallName(node.callee); | |
| 69 | if (Option.isNone(name)) return; | |
| 70 | if (!isInsideLoop(node)) return; | |
| 71 | ||
| 72 | context.report({ | |
| 73 | node: node.callee, | |
| 74 | message: `Do not call ${name.value}(…) inside a for loop. Use ${name.value}.each(cases)(name, …) instead.`, | |
| 75 | }); | |
| 76 | }, | |
| 77 | }; | |
| 78 | }, | |
| 79 | }); |
packages/client-runtime/src/connection/resolver.test.ts+4-4| ... | ... | @@ -444,8 +444,9 @@ describe("ConnectionResolver", () => { |
| 444 | 444 | }), |
| 445 | 445 | ); |
| 446 | 446 | |
| 447 | for (const scenario of ["unchanged", "changed", "revocation-failed"] as const) { | |
| 448 | it.effect(`handles ${scenario} SSH routing consent before saving or authorizing`, () => | |
| 447 | it.effect.each(["unchanged", "changed", "revocation-failed"] as const)( | |
| 448 | "handles %s SSH routing consent before saving or authorizing", | |
| 449 | (scenario) => | |
| 449 | 450 | Effect.gen(function* () { |
| 450 | 451 | const calls: string[] = []; |
| 451 | 452 | const target = new SshConnectionTarget({ |
| ... | ... | @@ -537,8 +538,7 @@ describe("ConnectionResolver", () => { |
| 537 | 538 | } |
| 538 | 539 | expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write"); |
| 539 | 540 | }), |
| 540 | ); | |
| 541 | } | |
| 541 | ); | |
| 542 | 542 | |
| 543 | 543 | it.effect("preserves relay authorization failure classification and trace details", () => |
| 544 | 544 | Effect.gen(function* () { |
packages/client-runtime/src/operations/commands.test.ts+67-69| ... | ... | @@ -502,83 +502,81 @@ describe("V2 environment commands", () => { |
| 502 | 502 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 503 | 503 | ); |
| 504 | 504 | |
| 505 | for (const status of [ | |
| 505 | it.effect.each([ | |
| 506 | 506 | "waiting", |
| 507 | 507 | "completed", |
| 508 | 508 | "failed", |
| 509 | 509 | "interrupted", |
| 510 | 510 | "cancelled", |
| 511 | 511 | "rolled_back", |
| 512 | ] as const) { | |
| 513 | it.effect(`dispatches Stop for ${status} runs with background commands except rollback`, () => | |
| 514 | Effect.gen(function* () { | |
| 515 | const waitingRunId = RunId.make("run-waiting"); | |
| 516 | const projection: OrchestrationV2ThreadProjection = { | |
| 517 | ...v2Projection, | |
| 518 | runs: [ | |
| 519 | { | |
| 520 | id: waitingRunId, | |
| 521 | threadId: v2ThreadId, | |
| 522 | ordinal: 1, | |
| 523 | providerInstanceId: v2Projection.thread.providerInstanceId, | |
| 524 | modelSelection: v2Projection.thread.modelSelection, | |
| 525 | providerThreadId: null, | |
| 526 | userMessageId: MessageId.make("message-waiting"), | |
| 527 | rootNodeId: null, | |
| 528 | activeAttemptId: null, | |
| 529 | status, | |
| 530 | requestedAt: v2Now, | |
| 531 | startedAt: v2Now, | |
| 532 | completedAt: null, | |
| 533 | checkpointId: null, | |
| 534 | contextHandoffId: null, | |
| 535 | }, | |
| 536 | ], | |
| 537 | turnItems: [ | |
| 538 | { | |
| 539 | id: TurnItemId.make("background-command"), | |
| 540 | threadId: v2ThreadId, | |
| 541 | runId: waitingRunId, | |
| 542 | nodeId: null, | |
| 543 | providerThreadId: null, | |
| 544 | providerTurnId: null, | |
| 545 | nativeItemRef: null, | |
| 546 | parentItemId: null, | |
| 547 | ordinal: 1, | |
| 548 | status: "running", | |
| 549 | title: null, | |
| 550 | startedAt: v2Now, | |
| 551 | completedAt: null, | |
| 552 | updatedAt: v2Now, | |
| 553 | type: "command_execution", | |
| 554 | input: "vp run dev", | |
| 555 | }, | |
| 556 | ], | |
| 557 | }; | |
| 558 | const commands: OrchestrationV2Command[] = []; | |
| 559 | const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); | |
| 512 | ] as const)("dispatches Stop for %s runs with background commands except rollback", (status) => | |
| 513 | Effect.gen(function* () { | |
| 514 | const waitingRunId = RunId.make("run-waiting"); | |
| 515 | const projection: OrchestrationV2ThreadProjection = { | |
| 516 | ...v2Projection, | |
| 517 | runs: [ | |
| 518 | { | |
| 519 | id: waitingRunId, | |
| 520 | threadId: v2ThreadId, | |
| 521 | ordinal: 1, | |
| 522 | providerInstanceId: v2Projection.thread.providerInstanceId, | |
| 523 | modelSelection: v2Projection.thread.modelSelection, | |
| 524 | providerThreadId: null, | |
| 525 | userMessageId: MessageId.make("message-waiting"), | |
| 526 | rootNodeId: null, | |
| 527 | activeAttemptId: null, | |
| 528 | status, | |
| 529 | requestedAt: v2Now, | |
| 530 | startedAt: v2Now, | |
| 531 | completedAt: null, | |
| 532 | checkpointId: null, | |
| 533 | contextHandoffId: null, | |
| 534 | }, | |
| 535 | ], | |
| 536 | turnItems: [ | |
| 537 | { | |
| 538 | id: TurnItemId.make("background-command"), | |
| 539 | threadId: v2ThreadId, | |
| 540 | runId: waitingRunId, | |
| 541 | nodeId: null, | |
| 542 | providerThreadId: null, | |
| 543 | providerTurnId: null, | |
| 544 | nativeItemRef: null, | |
| 545 | parentItemId: null, | |
| 546 | ordinal: 1, | |
| 547 | status: "running", | |
| 548 | title: null, | |
| 549 | startedAt: v2Now, | |
| 550 | completedAt: null, | |
| 551 | updatedAt: v2Now, | |
| 552 | type: "command_execution", | |
| 553 | input: "vp run dev", | |
| 554 | }, | |
| 555 | ], | |
| 556 | }; | |
| 557 | const commands: OrchestrationV2Command[] = []; | |
| 558 | const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); | |
| 560 | 559 | |
| 561 | const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( | |
| 562 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 563 | ); | |
| 560 | const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( | |
| 561 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 562 | ); | |
| 564 | 563 | |
| 565 | expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 }); | |
| 566 | expect(commands).toEqual( | |
| 567 | status === "rolled_back" | |
| 568 | ? [] | |
| 569 | : [ | |
| 570 | { | |
| 571 | type: "run.interrupt", | |
| 572 | commandId: expect.any(String), | |
| 573 | threadId: v2ThreadId, | |
| 574 | runId: waitingRunId, | |
| 575 | holdQueue: true, | |
| 576 | }, | |
| 577 | ], | |
| 578 | ); | |
| 579 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | |
| 580 | ); | |
| 581 | } | |
| 564 | expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 }); | |
| 565 | expect(commands).toEqual( | |
| 566 | status === "rolled_back" | |
| 567 | ? [] | |
| 568 | : [ | |
| 569 | { | |
| 570 | type: "run.interrupt", | |
| 571 | commandId: expect.any(String), | |
| 572 | threadId: v2ThreadId, | |
| 573 | runId: waitingRunId, | |
| 574 | holdQueue: true, | |
| 575 | }, | |
| 576 | ], | |
| 577 | ); | |
| 578 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | |
| 579 | ); | |
| 582 | 580 | |
| 583 | 581 | it.effect( |
| 584 | 582 | "dispatches V2-native relationship and queue commands without compatibility shaping", |
packages/client-runtime/src/rpc/session.test.ts+77-80| ... | ... | @@ -429,59 +429,55 @@ describe("RpcSessionFactory", () => { |
| 429 | 429 | ), |
| 430 | 430 | ); |
| 431 | 431 | |
| 432 | for (const options of [ | |
| 432 | it.effect.each([ | |
| 433 | 433 | { environmentThemes: true }, |
| 434 | 434 | { usageLimitSources: true }, |
| 435 | 435 | { environmentThemes: true, usageLimitSources: true }, |
| 436 | ]) { | |
| 437 | it.effect( | |
| 438 | `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`, | |
| 439 | () => | |
| 440 | Effect.scoped( | |
| 441 | Effect.gen(function* () { | |
| 442 | const { factory, sockets } = yield* makeFactory(options); | |
| 443 | const session = yield* factory.connect(PREPARED); | |
| 444 | const readyFiber = yield* Effect.forkChild(session.ready); | |
| 445 | const socket = yield* awaitSocket(sockets); | |
| 446 | socket.open(); | |
| 447 | yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); | |
| 448 | yield* Fiber.join(readyFiber); | |
| 449 | ||
| 450 | const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); | |
| 451 | expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); | |
| 452 | expect( | |
| 453 | socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest), | |
| 454 | ).toHaveLength(1); | |
| 455 | ||
| 456 | const fallbackFiber = yield* session | |
| 457 | .subscribeServerConfig({}) | |
| 458 | .pipe(Stream.runHead, Effect.forkChild); | |
| 459 | const fallbackRequest = yield* awaitRequest(socket, 1); | |
| 460 | expect(fallbackRequest).toMatchObject({ | |
| 461 | tag: WS_METHODS.subscribeServerConfig, | |
| 462 | payload: {}, | |
| 463 | }); | |
| 464 | socket.serverMessage( | |
| 465 | encodeJson({ | |
| 466 | _tag: "Chunk", | |
| 467 | requestId: fallbackRequest.id, | |
| 468 | values: [ | |
| 469 | { | |
| 470 | version: 1, | |
| 471 | type: "snapshot", | |
| 472 | config: ENCODED_THEME_SERVER_CONFIG, | |
| 473 | }, | |
| 474 | ], | |
| 475 | }), | |
| 476 | ); | |
| 477 | expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ | |
| 478 | _tag: "Some", | |
| 479 | value: { type: "snapshot" }, | |
| 480 | }); | |
| 436 | ])("shares only a config subscription with the same opt-ins: %j", (options) => | |
| 437 | Effect.scoped( | |
| 438 | Effect.gen(function* () { | |
| 439 | const { factory, sockets } = yield* makeFactory(options); | |
| 440 | const session = yield* factory.connect(PREPARED); | |
| 441 | const readyFiber = yield* Effect.forkChild(session.ready); | |
| 442 | const socket = yield* awaitSocket(sockets); | |
| 443 | socket.open(); | |
| 444 | yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); | |
| 445 | yield* Fiber.join(readyFiber); | |
| 446 | ||
| 447 | const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); | |
| 448 | expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); | |
| 449 | expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( | |
| 450 | 1, | |
| 451 | ); | |
| 452 | ||
| 453 | const fallbackFiber = yield* session | |
| 454 | .subscribeServerConfig({}) | |
| 455 | .pipe(Stream.runHead, Effect.forkChild); | |
| 456 | const fallbackRequest = yield* awaitRequest(socket, 1); | |
| 457 | expect(fallbackRequest).toMatchObject({ | |
| 458 | tag: WS_METHODS.subscribeServerConfig, | |
| 459 | payload: {}, | |
| 460 | }); | |
| 461 | socket.serverMessage( | |
| 462 | encodeJson({ | |
| 463 | _tag: "Chunk", | |
| 464 | requestId: fallbackRequest.id, | |
| 465 | values: [ | |
| 466 | { | |
| 467 | version: 1, | |
| 468 | type: "snapshot", | |
| 469 | config: ENCODED_THEME_SERVER_CONFIG, | |
| 470 | }, | |
| 471 | ], | |
| 481 | 472 | }), |
| 482 | ), | |
| 483 | ); | |
| 484 | } | |
| 473 | ); | |
| 474 | expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ | |
| 475 | _tag: "Some", | |
| 476 | value: { type: "snapshot" }, | |
| 477 | }); | |
| 478 | }), | |
| 479 | ), | |
| 480 | ); | |
| 485 | 481 | |
| 486 | 482 | it.effect.each([ |
| 487 | 483 | { usageLimitSources: true }, |
| ... | ... | @@ -1191,37 +1187,38 @@ describe("RpcSessionFactory", () => { |
| 1191 | 1187 | ), |
| 1192 | 1188 | ); |
| 1193 | 1189 | |
| 1194 | for (const relay of [false, true]) { | |
| 1195 | it.effect(`fails readiness when the ${relay ? "relay" : "direct"} websocket never opens`, () => | |
| 1196 | Effect.gen(function* () { | |
| 1197 | const { factory, sockets } = yield* makeFactory(); | |
| 1190 | it.effect.each([ | |
| 1191 | { relay: false, label: "direct" }, | |
| 1192 | { relay: true, label: "relay" }, | |
| 1193 | ])("fails readiness when the $label websocket never opens", ({ relay }) => | |
| 1194 | Effect.gen(function* () { | |
| 1195 | const { factory, sockets } = yield* makeFactory(); | |
| 1198 | 1196 | |
| 1199 | const error = yield* Effect.scoped( | |
| 1200 | Effect.gen(function* () { | |
| 1201 | const session = yield* factory.connect({ | |
| 1202 | ...PREPARED, | |
| 1203 | target: relay | |
| 1204 | ? new RelayConnectionTarget({ | |
| 1205 | environmentId: TARGET.environmentId, | |
| 1206 | label: TARGET.label, | |
| 1207 | }) | |
| 1208 | : TARGET, | |
| 1209 | }); | |
| 1210 | const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); | |
| 1211 | yield* awaitSocket(sockets); | |
| 1212 | ||
| 1213 | yield* TestClock.adjust("15 seconds"); | |
| 1214 | return yield* Fiber.join(readyFiber); | |
| 1215 | }), | |
| 1216 | ); | |
| 1197 | const error = yield* Effect.scoped( | |
| 1198 | Effect.gen(function* () { | |
| 1199 | const session = yield* factory.connect({ | |
| 1200 | ...PREPARED, | |
| 1201 | target: relay | |
| 1202 | ? new RelayConnectionTarget({ | |
| 1203 | environmentId: TARGET.environmentId, | |
| 1204 | label: TARGET.label, | |
| 1205 | }) | |
| 1206 | : TARGET, | |
| 1207 | }); | |
| 1208 | const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); | |
| 1209 | yield* awaitSocket(sockets); | |
| 1217 | 1210 | |
| 1218 | expect(error).toBeInstanceOf(ConnectionTransientError); | |
| 1219 | expect(error).toMatchObject({ | |
| 1220 | reason: "transport", | |
| 1221 | message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`, | |
| 1222 | }); | |
| 1223 | expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); | |
| 1224 | }).pipe(Effect.provide(TestClock.layer())), | |
| 1225 | ); | |
| 1226 | } | |
| 1211 | yield* TestClock.adjust("15 seconds"); | |
| 1212 | return yield* Fiber.join(readyFiber); | |
| 1213 | }), | |
| 1214 | ); | |
| 1215 | ||
| 1216 | expect(error).toBeInstanceOf(ConnectionTransientError); | |
| 1217 | expect(error).toMatchObject({ | |
| 1218 | reason: "transport", | |
| 1219 | message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`, | |
| 1220 | }); | |
| 1221 | expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); | |
| 1222 | }).pipe(Effect.provide(TestClock.layer())), | |
| 1223 | ); | |
| 1227 | 1224 | }); |
packages/client-runtime/src/state/assets.test.ts+103-105| ... | ... | @@ -60,7 +60,7 @@ describe("asset collection keys", () => { |
| 60 | 60 | }); |
| 61 | 61 | |
| 62 | 62 | describe("createAssetEnvironmentAtoms", () => { |
| 63 | for (const scenario of [ | |
| 63 | it.effect.each([ | |
| 64 | 64 | { name: "missing video", path: "/tmp/clip.mp4", fallback: true }, |
| 65 | 65 | { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true }, |
| 66 | 66 | { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true }, |
| ... | ... | @@ -73,113 +73,111 @@ describe("createAssetEnvironmentAtoms", () => { |
| 73 | 73 | { name: "same environment", path: "/tmp/clip.mp4", primary: "same" }, |
| 74 | 74 | { name: "non-media", path: "/tmp/report.html" }, |
| 75 | 75 | { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" }, |
| 76 | ]) { | |
| 77 | it.effect(`uses the correct environment for ${scenario.name}`, () => | |
| 78 | Effect.gen(function* () { | |
| 79 | const remoteId = EnvironmentId.make("remote"); | |
| 80 | const localId = EnvironmentId.make("local"); | |
| 81 | const resource = { | |
| 82 | _tag: "media-file" as const, | |
| 83 | threadId: ThreadId.make("foreign-thread"), | |
| 84 | path: scenario.path, | |
| 85 | }; | |
| 86 | const error = | |
| 87 | scenario.error === "auth" | |
| 88 | ? new EnvironmentAuthorizationError({ | |
| 89 | message: "denied", | |
| 90 | requiredScope: "orchestration:read", | |
| 91 | }) | |
| 92 | : scenario.error === "inspection" | |
| 93 | ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") }) | |
| 94 | : scenario.error === "context" | |
| 95 | ? new AssetWorkspaceContextNotFoundError({ resource }) | |
| 96 | : new AssetWorkspaceAssetNotFoundError({ resource }); | |
| 97 | const calls: EnvironmentId[] = []; | |
| 98 | const supervisors = new Map< | |
| 99 | EnvironmentId, | |
| 100 | EnvironmentSupervisor.EnvironmentSupervisor["Service"] | |
| 101 | >(); | |
| 102 | for (const environmentId of [remoteId, localId]) { | |
| 103 | const client = { | |
| 104 | [WS_METHODS.assetsCreateUrl]: () => { | |
| 105 | calls.push(environmentId); | |
| 106 | return environmentId === remoteId && !scenario.success | |
| 107 | ? Effect.fail(error) | |
| 108 | : Effect.succeed({ | |
| 109 | relativeUrl: `/api/assets/${environmentId}/media`, | |
| 110 | expiresAt: 999999, | |
| 111 | }); | |
| 112 | }, | |
| 113 | } as unknown as WsRpcProtocolClient; | |
| 114 | const session = { client } as RpcSession; | |
| 115 | supervisors.set( | |
| 116 | environmentId, | |
| 117 | EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 118 | target: new PrimaryConnectionTarget({ | |
| 119 | environmentId, | |
| 120 | label: environmentId, | |
| 121 | httpBaseUrl: `https://${environmentId}.test`, | |
| 122 | wsBaseUrl: `wss://${environmentId}.test`, | |
| 123 | }), | |
| 124 | state: yield* SubscriptionRef.make<SupervisorConnectionState>({ | |
| 125 | ...AVAILABLE_CONNECTION_STATE, | |
| 126 | phase: "connected" as const, | |
| 127 | }), | |
| 128 | session: yield* SubscriptionRef.make(Option.some(session)), | |
| 129 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | |
| 130 | connect: Effect.void, | |
| 131 | disconnect: Effect.void, | |
| 132 | retryNow: Effect.void, | |
| 76 | ])("uses the correct environment for $name", (scenario) => | |
| 77 | Effect.gen(function* () { | |
| 78 | const remoteId = EnvironmentId.make("remote"); | |
| 79 | const localId = EnvironmentId.make("local"); | |
| 80 | const resource = { | |
| 81 | _tag: "media-file" as const, | |
| 82 | threadId: ThreadId.make("foreign-thread"), | |
| 83 | path: scenario.path, | |
| 84 | }; | |
| 85 | const error = | |
| 86 | scenario.error === "auth" | |
| 87 | ? new EnvironmentAuthorizationError({ | |
| 88 | message: "denied", | |
| 89 | requiredScope: "orchestration:read", | |
| 90 | }) | |
| 91 | : scenario.error === "inspection" | |
| 92 | ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") }) | |
| 93 | : scenario.error === "context" | |
| 94 | ? new AssetWorkspaceContextNotFoundError({ resource }) | |
| 95 | : new AssetWorkspaceAssetNotFoundError({ resource }); | |
| 96 | const calls: EnvironmentId[] = []; | |
| 97 | const supervisors = new Map< | |
| 98 | EnvironmentId, | |
| 99 | EnvironmentSupervisor.EnvironmentSupervisor["Service"] | |
| 100 | >(); | |
| 101 | for (const environmentId of [remoteId, localId]) { | |
| 102 | const client = { | |
| 103 | [WS_METHODS.assetsCreateUrl]: () => { | |
| 104 | calls.push(environmentId); | |
| 105 | return environmentId === remoteId && !scenario.success | |
| 106 | ? Effect.fail(error) | |
| 107 | : Effect.succeed({ | |
| 108 | relativeUrl: `/api/assets/${environmentId}/media`, | |
| 109 | expiresAt: 999999, | |
| 110 | }); | |
| 111 | }, | |
| 112 | } as unknown as WsRpcProtocolClient; | |
| 113 | const session = { client } as RpcSession; | |
| 114 | supervisors.set( | |
| 115 | environmentId, | |
| 116 | EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 117 | target: new PrimaryConnectionTarget({ | |
| 118 | environmentId, | |
| 119 | label: environmentId, | |
| 120 | httpBaseUrl: `https://${environmentId}.test`, | |
| 121 | wsBaseUrl: `wss://${environmentId}.test`, | |
| 122 | }), | |
| 123 | state: yield* SubscriptionRef.make<SupervisorConnectionState>({ | |
| 124 | ...AVAILABLE_CONNECTION_STATE, | |
| 125 | phase: "connected" as const, | |
| 133 | 126 | }), |
| 134 | ); | |
| 135 | } | |
| 136 | const environments = EnvironmentRegistry.EnvironmentRegistry.of({ | |
| 137 | run: (id, effect) => | |
| 138 | Effect.provideService( | |
| 139 | effect, | |
| 140 | EnvironmentSupervisor.EnvironmentSupervisor, | |
| 141 | supervisors.get(id)!, | |
| 142 | ), | |
| 143 | followStream: (id, stream) => | |
| 144 | Stream.provideService( | |
| 145 | stream, | |
| 146 | EnvironmentSupervisor.EnvironmentSupervisor, | |
| 147 | supervisors.get(id)!, | |
| 148 | ), | |
| 149 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]); | |
| 150 | const registry = AtomRegistry.make(); | |
| 151 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | |
| 152 | const localTarget = { | |
| 153 | environmentId: scenario.primary === "same" ? remoteId : localId, | |
| 154 | httpBaseUrl: "https://local.test", | |
| 155 | }; | |
| 156 | const localEnvironment = Atom.make<typeof localTarget | null>( | |
| 157 | scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget, | |
| 127 | session: yield* SubscriptionRef.make(Option.some(session)), | |
| 128 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | |
| 129 | connect: Effect.void, | |
| 130 | disconnect: Effect.void, | |
| 131 | retryNow: Effect.void, | |
| 132 | }), | |
| 158 | 133 | ); |
| 159 | const assets = createAssetEnvironmentAtoms( | |
| 160 | Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)), | |
| 161 | localEnvironment, | |
| 134 | } | |
| 135 | const environments = EnvironmentRegistry.EnvironmentRegistry.of({ | |
| 136 | run: (id, effect) => | |
| 137 | Effect.provideService( | |
| 138 | effect, | |
| 139 | EnvironmentSupervisor.EnvironmentSupervisor, | |
| 140 | supervisors.get(id)!, | |
| 141 | ), | |
| 142 | followStream: (id, stream) => | |
| 143 | Stream.provideService( | |
| 144 | stream, | |
| 145 | EnvironmentSupervisor.EnvironmentSupervisor, | |
| 146 | supervisors.get(id)!, | |
| 147 | ), | |
| 148 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]); | |
| 149 | const registry = AtomRegistry.make(); | |
| 150 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | |
| 151 | const localTarget = { | |
| 152 | environmentId: scenario.primary === "same" ? remoteId : localId, | |
| 153 | httpBaseUrl: "https://local.test", | |
| 154 | }; | |
| 155 | const localEnvironment = Atom.make<typeof localTarget | null>( | |
| 156 | scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget, | |
| 157 | ); | |
| 158 | const assets = createAssetEnvironmentAtoms( | |
| 159 | Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)), | |
| 160 | localEnvironment, | |
| 161 | ); | |
| 162 | const query = assets.createUrl({ environmentId: remoteId, input: { resource } }); | |
| 163 | const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); | |
| 164 | if (scenario.fallback || scenario.success) { | |
| 165 | expect((yield* result).relativeUrl).toBe( | |
| 166 | scenario.fallback | |
| 167 | ? "https://local.test/api/assets/local/media" | |
| 168 | : "/api/assets/remote/media", | |
| 162 | 169 | ); |
| 163 | const query = assets.createUrl({ environmentId: remoteId, input: { resource } }); | |
| 164 | const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); | |
| 165 | if (scenario.fallback || scenario.success) { | |
| 166 | expect((yield* result).relativeUrl).toBe( | |
| 167 | scenario.fallback | |
| 168 | ? "https://local.test/api/assets/local/media" | |
| 169 | : "/api/assets/remote/media", | |
| 170 | ); | |
| 171 | } else { | |
| 172 | expect(yield* Effect.flip(result)).toEqual(error); | |
| 173 | } | |
| 174 | expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]); | |
| 175 | if (scenario.primary === "reconnecting") { | |
| 176 | registry.set(localEnvironment, localTarget); | |
| 177 | expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media"); | |
| 178 | expect(calls).toEqual([remoteId, remoteId, localId]); | |
| 179 | } | |
| 180 | }).pipe(Effect.scoped), | |
| 181 | ); | |
| 182 | } | |
| 170 | } else { | |
| 171 | expect(yield* Effect.flip(result)).toEqual(error); | |
| 172 | } | |
| 173 | expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]); | |
| 174 | if (scenario.primary === "reconnecting") { | |
| 175 | registry.set(localEnvironment, localTarget); | |
| 176 | expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media"); | |
| 177 | expect(calls).toEqual([remoteId, remoteId, localId]); | |
| 178 | } | |
| 179 | }).pipe(Effect.scoped), | |
| 180 | ); | |
| 183 | 181 | |
| 184 | 182 | it("keys asset URL queries by environment and resource", () => { |
| 185 | 183 | const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< |
packages/client-runtime/src/state/pullRequests.test.ts+200-196| ... | ... | @@ -236,8 +236,9 @@ for (const scenario of [ |
| 236 | 236 | ); |
| 237 | 237 | } |
| 238 | 238 | |
| 239 | for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as const) { | |
| 240 | it.effect(`routes ${provider} viewed marks to their storage environment`, () => | |
| 239 | it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)( | |
| 240 | "routes %s viewed marks to their storage environment", | |
| 241 | (provider) => | |
| 241 | 242 | Effect.scoped( |
| 242 | 243 | Effect.gen(function* () { |
| 243 | 244 | const reference = { |
| ... | ... | @@ -311,8 +312,7 @@ for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as cons |
| 311 | 312 | } |
| 312 | 313 | }), |
| 313 | 314 | ), |
| 314 | ); | |
| 315 | } | |
| 315 | ); | |
| 316 | 316 | |
| 317 | 317 | const TARGET = new PrimaryConnectionTarget({ |
| 318 | 318 | environmentId: EnvironmentId.make("environment-1"), |
| ... | ... | @@ -420,8 +420,9 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( |
| 420 | 420 | return { runtime, atoms, registry, environmentRegistry, supervisor }; |
| 421 | 421 | }); |
| 422 | 422 | |
| 423 | for (const permission of ["default", "origin-off", "destination-off", "read-only"] as const) { | |
| 424 | it.effect(`does not probe another environment with ${permission} routing permission`, () => | |
| 423 | it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( | |
| 424 | "does not probe another environment with %s routing permission", | |
| 425 | (permission) => | |
| 425 | 426 | Effect.scoped( |
| 426 | 427 | Effect.gen(function* () { |
| 427 | 428 | const calls: string[] = []; |
| ... | ... | @@ -480,125 +481,128 @@ for (const permission of ["default", "origin-off", "destination-off", "read-only |
| 480 | 481 | ); |
| 481 | 482 | }), |
| 482 | 483 | ), |
| 483 | ); | |
| 484 | } | |
| 484 | ); | |
| 485 | 485 | |
| 486 | for (const side of ["origin", "destination"] as const) { | |
| 487 | for (const stored of ["matching", "changed", "missing", "unavailable", "failed"] as const) { | |
| 488 | it.effect(`checks the current ${side} SSH profile before routing with ${stored} storage`, () => | |
| 489 | Effect.scoped( | |
| 490 | Effect.gen(function* () { | |
| 491 | const calls: string[] = []; | |
| 492 | const identity = { | |
| 493 | host: "github.com", | |
| 494 | provider: "github", | |
| 495 | viewer: "maria", | |
| 496 | accountId: "123", | |
| 497 | }; | |
| 498 | const client = { | |
| 499 | [WS_METHODS.pullRequestsRouting]: () => | |
| 500 | Effect.sync(() => { | |
| 501 | calls.push("source-probe"); | |
| 502 | return identity; | |
| 503 | }), | |
| 504 | [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) => | |
| 505 | Effect.gen(function* () { | |
| 506 | if (input.expectedAccountId !== undefined) { | |
| 507 | return yield* new PullRequestOperationError({ | |
| 508 | operation: "routeIdentity", | |
| 509 | detail: "Source guard refused.", | |
| 510 | }); | |
| 511 | } | |
| 512 | calls.push("source-write"); | |
| 513 | }), | |
| 514 | [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, | |
| 515 | } as unknown as WsRpcProtocolClient; | |
| 516 | const alternate = { | |
| 517 | [WS_METHODS.pullRequestsRoutingIdentity]: () => | |
| 518 | Effect.sync(() => { | |
| 519 | calls.push("alternate-probe"); | |
| 520 | return identity; | |
| 521 | }), | |
| 522 | [WS_METHODS.pullRequestsRunAction]: () => | |
| 523 | Effect.sync(() => { | |
| 524 | calls.push("alternate-write"); | |
| 525 | }), | |
| 526 | [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, | |
| 527 | } as unknown as WsRpcProtocolClient; | |
| 528 | const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate); | |
| 529 | const environmentId = | |
| 530 | side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment"); | |
| 531 | const profile = new SshConnectionProfile({ | |
| 532 | connectionId: "ssh-1", | |
| 486 | it.effect.each( | |
| 487 | (["origin", "destination"] as const).flatMap((side) => | |
| 488 | (["matching", "changed", "missing", "unavailable", "failed"] as const).map((stored) => ({ | |
| 489 | side, | |
| 490 | stored, | |
| 491 | })), | |
| 492 | ), | |
| 493 | )("checks the current $side SSH profile before routing with $stored storage", ({ side, stored }) => | |
| 494 | Effect.scoped( | |
| 495 | Effect.gen(function* () { | |
| 496 | const calls: string[] = []; | |
| 497 | const identity = { | |
| 498 | host: "github.com", | |
| 499 | provider: "github", | |
| 500 | viewer: "maria", | |
| 501 | accountId: "123", | |
| 502 | }; | |
| 503 | const client = { | |
| 504 | [WS_METHODS.pullRequestsRouting]: () => | |
| 505 | Effect.sync(() => { | |
| 506 | calls.push("source-probe"); | |
| 507 | return identity; | |
| 508 | }), | |
| 509 | [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) => | |
| 510 | Effect.gen(function* () { | |
| 511 | if (input.expectedAccountId !== undefined) { | |
| 512 | return yield* new PullRequestOperationError({ | |
| 513 | operation: "routeIdentity", | |
| 514 | detail: "Source guard refused.", | |
| 515 | }); | |
| 516 | } | |
| 517 | calls.push("source-write"); | |
| 518 | }), | |
| 519 | [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, | |
| 520 | } as unknown as WsRpcProtocolClient; | |
| 521 | const alternate = { | |
| 522 | [WS_METHODS.pullRequestsRoutingIdentity]: () => | |
| 523 | Effect.sync(() => { | |
| 524 | calls.push("alternate-probe"); | |
| 525 | return identity; | |
| 526 | }), | |
| 527 | [WS_METHODS.pullRequestsRunAction]: () => | |
| 528 | Effect.sync(() => { | |
| 529 | calls.push("alternate-write"); | |
| 530 | }), | |
| 531 | [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, | |
| 532 | } as unknown as WsRpcProtocolClient; | |
| 533 | const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate); | |
| 534 | const environmentId = | |
| 535 | side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment"); | |
| 536 | const profile = new SshConnectionProfile({ | |
| 537 | connectionId: "ssh-1", | |
| 538 | environmentId, | |
| 539 | label: "SSH", | |
| 540 | target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 }, | |
| 541 | }); | |
| 542 | yield* SubscriptionRef.update(environmentRegistry.entries, (entries) => | |
| 543 | new Map(entries).set(environmentId, { | |
| 544 | target: new SshConnectionTarget({ | |
| 533 | 545 | environmentId, |
| 546 | connectionId: profile.connectionId, | |
| 534 | 547 | label: "SSH", |
| 535 | target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 }, | |
| 536 | }); | |
| 537 | yield* SubscriptionRef.update(environmentRegistry.entries, (entries) => | |
| 538 | new Map(entries).set(environmentId, { | |
| 539 | target: new SshConnectionTarget({ | |
| 540 | environmentId, | |
| 541 | connectionId: profile.connectionId, | |
| 542 | label: "SSH", | |
| 548 | }), | |
| 549 | profile: Option.some(profile), | |
| 550 | enabled: true, | |
| 551 | }), | |
| 552 | ); | |
| 553 | const read = Effect.suspend(() => | |
| 554 | stored === "failed" | |
| 555 | ? Effect.fail( | |
| 556 | new ConnectionTransientError({ | |
| 557 | reason: "remote-unavailable", | |
| 558 | detail: "Profile storage unavailable.", | |
| 543 | 559 | }), |
| 544 | profile: Option.some(profile), | |
| 545 | enabled: true, | |
| 560 | ) | |
| 561 | : Effect.succeed( | |
| 562 | stored === "missing" | |
| 563 | ? Option.none() | |
| 564 | : Option.some( | |
| 565 | stored === "changed" | |
| 566 | ? new SshConnectionProfile({ | |
| 567 | ...profile, | |
| 568 | target: { ...profile.target, hostname: "replacement.example.test" }, | |
| 569 | }) | |
| 570 | : profile, | |
| 571 | ), | |
| 572 | ), | |
| 573 | ); | |
| 574 | const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { | |
| 575 | projectId: ProjectId.make("project-1"), | |
| 576 | repository: "private/repo", | |
| 577 | number: 7, | |
| 578 | action: "merge", | |
| 579 | }).pipe( | |
| 580 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | |
| 581 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 582 | // This also represents a user enabling the stale catalog entry after re-resolution. | |
| 583 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | |
| 584 | ); | |
| 585 | yield* stored === "unavailable" | |
| 586 | ? route | |
| 587 | : route.pipe( | |
| 588 | Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, { | |
| 589 | get: () => read, | |
| 590 | put: () => Effect.die("unused"), | |
| 591 | remove: () => Effect.die("unused"), | |
| 546 | 592 | }), |
| 547 | 593 | ); |
| 548 | const read = Effect.suspend(() => | |
| 549 | stored === "failed" | |
| 550 | ? Effect.fail( | |
| 551 | new ConnectionTransientError({ | |
| 552 | reason: "remote-unavailable", | |
| 553 | detail: "Profile storage unavailable.", | |
| 554 | }), | |
| 555 | ) | |
| 556 | : Effect.succeed( | |
| 557 | stored === "missing" | |
| 558 | ? Option.none() | |
| 559 | : Option.some( | |
| 560 | stored === "changed" | |
| 561 | ? new SshConnectionProfile({ | |
| 562 | ...profile, | |
| 563 | target: { ...profile.target, hostname: "replacement.example.test" }, | |
| 564 | }) | |
| 565 | : profile, | |
| 566 | ), | |
| 567 | ), | |
| 568 | ); | |
| 569 | const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { | |
| 570 | projectId: ProjectId.make("project-1"), | |
| 571 | repository: "private/repo", | |
| 572 | number: 7, | |
| 573 | action: "merge", | |
| 574 | }).pipe( | |
| 575 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | |
| 576 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 577 | // This also represents a user enabling the stale catalog entry after re-resolution. | |
| 578 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | |
| 579 | ); | |
| 580 | yield* stored === "unavailable" | |
| 581 | ? route | |
| 582 | : route.pipe( | |
| 583 | Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, { | |
| 584 | get: () => read, | |
| 585 | put: () => Effect.die("unused"), | |
| 586 | remove: () => Effect.die("unused"), | |
| 587 | }), | |
| 588 | ); | |
| 589 | expect(calls).toEqual( | |
| 590 | stored === "matching" | |
| 591 | ? ["source-probe", "alternate-probe", "alternate-write"] | |
| 592 | : ["source-write"], | |
| 593 | ); | |
| 594 | }), | |
| 595 | ), | |
| 596 | ); | |
| 597 | } | |
| 598 | } | |
| 594 | expect(calls).toEqual( | |
| 595 | stored === "matching" | |
| 596 | ? ["source-probe", "alternate-probe", "alternate-write"] | |
| 597 | : ["source-write"], | |
| 598 | ); | |
| 599 | }), | |
| 600 | ), | |
| 601 | ); | |
| 599 | 602 | |
| 600 | for (const probe of ["origin", "alternate"] as const) { | |
| 601 | it.live(`bounds a stalled ${probe} metadata probe without repeating a strict source read`, () => | |
| 603 | it.live.each(["origin", "alternate"] as const)( | |
| 604 | "bounds a stalled %s metadata probe without repeating a strict source read", | |
| 605 | (probe) => | |
| 602 | 606 | Effect.scoped( |
| 603 | 607 | Effect.gen(function* () { |
| 604 | 608 | let sourceReads = 0; |
| ... | ... | @@ -638,81 +642,81 @@ for (const probe of ["origin", "alternate"] as const) { |
| 638 | 642 | expect(sourceReads).toBe(1); |
| 639 | 643 | }), |
| 640 | 644 | ), |
| 641 | ); | |
| 642 | } | |
| 645 | ); | |
| 643 | 646 | |
| 644 | for (const source of ["pending", "pending-local", "failed-local", "failed", "offline"] as const) { | |
| 645 | it.effect( | |
| 646 | source === "offline" | |
| 647 | ? "returns held source data only after both fresh paths fail" | |
| 648 | : `uses one shared reader with a ${source} source`, | |
| 649 | () => | |
| 650 | Effect.scoped( | |
| 651 | Effect.gen(function* () { | |
| 652 | const started = yield* Deferred.make<void>(); | |
| 653 | const calls: string[] = []; | |
| 654 | const clientFor = (local: boolean) => | |
| 655 | ({ | |
| 656 | [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: | |
| 657 | () => | |
| 658 | Effect.succeed({ | |
| 659 | host: "github.com", | |
| 660 | provider: "github", | |
| 661 | viewer: "maria-rcks", | |
| 662 | accountId: "123", | |
| 663 | }), | |
| 664 | [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => | |
| 665 | Effect.gen(function* () { | |
| 666 | calls.push(local ? "local" : input.allowStale === false ? "origin" : "held"); | |
| 667 | if (source === "offline" && !local && input.allowStale === undefined) { | |
| 668 | return { state: "open" }; | |
| 669 | } | |
| 670 | expect(input.allowStale).toBe(false); | |
| 671 | if (local && source !== "offline") return null; | |
| 672 | if (source !== "pending" && source !== "pending-local") | |
| 673 | return yield* new PullRequestOperationError({ | |
| 674 | operation: "summary", | |
| 675 | detail: "github unreachable", | |
| 676 | }); | |
| 677 | yield* Deferred.succeed(started, undefined); | |
| 678 | return yield* Effect.never; | |
| 679 | }), | |
| 680 | }) as unknown as WsRpcProtocolClient; | |
| 681 | const { environmentRegistry, supervisor } = yield* makeTestRuntime( | |
| 682 | clientFor(false), | |
| 683 | clientFor(true), | |
| 684 | source === "failed-local" || source === "pending-local", | |
| 685 | ); | |
| 686 | const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { | |
| 687 | projectId: ProjectId.make("project-1"), | |
| 688 | repository: "acme/web", | |
| 689 | number: 7, | |
| 690 | }).pipe( | |
| 691 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | |
| 692 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | |
| 693 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 694 | ); | |
| 695 | const fiber = yield* request.pipe(Effect.forkChild); | |
| 696 | if (source === "pending-local") { | |
| 697 | yield* Deferred.await(started); | |
| 698 | yield* TestClock.adjust("30 seconds"); | |
| 699 | } | |
| 700 | const result = yield* Fiber.join(fiber); | |
| 701 | if (source === "offline") { | |
| 702 | expect(result).toEqual({ state: "open" }); | |
| 703 | expect(calls).toEqual(["local", "origin", "held"]); | |
| 704 | } else { | |
| 705 | expect(result).toBeNull(); | |
| 706 | expect(calls).toEqual( | |
| 707 | source === "failed-local" || source === "pending-local" | |
| 708 | ? ["origin", "local"] | |
| 709 | : ["local"], | |
| 710 | ); | |
| 711 | } | |
| 712 | }), | |
| 713 | ), | |
| 714 | ); | |
| 715 | } | |
| 647 | it.effect.each( | |
| 648 | (["pending", "pending-local", "failed-local", "failed", "offline"] as const).map( | |
| 649 | (source) => | |
| 650 | [ | |
| 651 | source === "offline" | |
| 652 | ? "returns held source data only after both fresh paths fail" | |
| 653 | : `uses one shared reader with a ${source} source`, | |
| 654 | source, | |
| 655 | ] as const, | |
| 656 | ), | |
| 657 | )("%s", ([, source]) => | |
| 658 | Effect.scoped( | |
| 659 | Effect.gen(function* () { | |
| 660 | const started = yield* Deferred.make<void>(); | |
| 661 | const calls: string[] = []; | |
| 662 | const clientFor = (local: boolean) => | |
| 663 | ({ | |
| 664 | [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => | |
| 665 | Effect.succeed({ | |
| 666 | host: "github.com", | |
| 667 | provider: "github", | |
| 668 | viewer: "maria-rcks", | |
| 669 | accountId: "123", | |
| 670 | }), | |
| 671 | [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => | |
| 672 | Effect.gen(function* () { | |
| 673 | calls.push(local ? "local" : input.allowStale === false ? "origin" : "held"); | |
| 674 | if (source === "offline" && !local && input.allowStale === undefined) { | |
| 675 | return { state: "open" }; | |
| 676 | } | |
| 677 | expect(input.allowStale).toBe(false); | |
| 678 | if (local && source !== "offline") return null; | |
| 679 | if (source !== "pending" && source !== "pending-local") | |
| 680 | return yield* new PullRequestOperationError({ | |
| 681 | operation: "summary", | |
| 682 | detail: "github unreachable", | |
| 683 | }); | |
| 684 | yield* Deferred.succeed(started, undefined); | |
| 685 | return yield* Effect.never; | |
| 686 | }), | |
| 687 | }) as unknown as WsRpcProtocolClient; | |
| 688 | const { environmentRegistry, supervisor } = yield* makeTestRuntime( | |
| 689 | clientFor(false), | |
| 690 | clientFor(true), | |
| 691 | source === "failed-local" || source === "pending-local", | |
| 692 | ); | |
| 693 | const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { | |
| 694 | projectId: ProjectId.make("project-1"), | |
| 695 | repository: "acme/web", | |
| 696 | number: 7, | |
| 697 | }).pipe( | |
| 698 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | |
| 699 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | |
| 700 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 701 | ); | |
| 702 | const fiber = yield* request.pipe(Effect.forkChild); | |
| 703 | if (source === "pending-local") { | |
| 704 | yield* Deferred.await(started); | |
| 705 | yield* TestClock.adjust("30 seconds"); | |
| 706 | } | |
| 707 | const result = yield* Fiber.join(fiber); | |
| 708 | if (source === "offline") { | |
| 709 | expect(result).toEqual({ state: "open" }); | |
| 710 | expect(calls).toEqual(["local", "origin", "held"]); | |
| 711 | } else { | |
| 712 | expect(result).toBeNull(); | |
| 713 | expect(calls).toEqual( | |
| 714 | source === "failed-local" || source === "pending-local" ? ["origin", "local"] : ["local"], | |
| 715 | ); | |
| 716 | } | |
| 717 | }), | |
| 718 | ), | |
| 719 | ); | |
| 716 | 720 | |
| 717 | 721 | it.live("keeps source workspace metadata when an alternate answers a detail read", () => |
| 718 | 722 | Effect.scoped( |
| ... | ... | @@ -1191,8 +1195,9 @@ it.effect("refreshes checks without refreshing full detail", () => |
| 1191 | 1195 | ), |
| 1192 | 1196 | ); |
| 1193 | 1197 | |
| 1194 | for (const oldAlternate of [false, true]) { | |
| 1195 | it.effect(`routes checks through one reader with old alternate: ${oldAlternate}`, () => | |
| 1198 | it.effect.each([false, true])( | |
| 1199 | "routes checks through one reader with old alternate: %s", | |
| 1200 | (oldAlternate) => | |
| 1196 | 1201 | Effect.scoped( |
| 1197 | 1202 | Effect.gen(function* () { |
| 1198 | 1203 | const calls: string[] = []; |
| ... | ... | @@ -1236,8 +1241,7 @@ for (const oldAlternate of [false, true]) { |
| 1236 | 1241 | expect(calls).toEqual(oldAlternate ? ["origin"] : ["local"]); |
| 1237 | 1242 | }), |
| 1238 | 1243 | ), |
| 1239 | ); | |
| 1240 | } | |
| 1244 | ); | |
| 1241 | 1245 | |
| 1242 | 1246 | it.effect("keeps live detail reads separate from reads that allow stale data", () => |
| 1243 | 1247 | Effect.scoped( |
packages/client-runtime/src/state/threadCommands.test.ts+47-45| ... | ... | @@ -137,8 +137,9 @@ describe("remote thread lifecycle commands", () => { |
| 137 | 137 | ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }], |
| 138 | 138 | ] as const; |
| 139 | 139 | |
| 140 | for (const [action, input, expected] of actions) { | |
| 141 | it.effect(`shows ${action} before a delayed remote reply and rolls back a rejection`, () => | |
| 140 | it.effect.each(actions)( | |
| 141 | "shows %s before a delayed remote reply and rolls back a rejection", | |
| 142 | ([action, input, expected]) => | |
| 142 | 143 | Effect.gen(function* () { |
| 143 | 144 | const h = yield* makeHarness(); |
| 144 | 145 | const source = h.snapshotAtom(ENVIRONMENT_ID); |
| ... | ... | @@ -179,8 +180,7 @@ describe("remote thread lifecycle commands", () => { |
| 179 | 180 | expect((yield* Effect.promise(() => result))._tag).toBe("Failure"); |
| 180 | 181 | expect(h.registry.get(h.visibleAtom)).toBe(initial); |
| 181 | 182 | }), |
| 182 | ); | |
| 183 | } | |
| 183 | ); | |
| 184 | 184 | |
| 185 | 185 | it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () => |
| 186 | 186 | Effect.gen(function* () { |
| ... | ... | @@ -290,44 +290,45 @@ describe("remote thread lifecycle commands", () => { |
| 290 | 290 | }), |
| 291 | 291 | ); |
| 292 | 292 | |
| 293 | for (const action of ["settle", "snooze"] as const) { | |
| 294 | it.effect(`restores a confirmed ${action} when a queued undo fails`, () => | |
| 295 | Effect.gen(function* () { | |
| 296 | const h = yield* makeHarness(); | |
| 297 | const parked = | |
| 298 | action === "settle" ? { settledOverride: "settled" as const } : { snoozedUntil: FUTURE }; | |
| 299 | const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null }; | |
| 300 | const result = h.commands[action].run(h.registry, { | |
| 301 | environmentId: ENVIRONMENT_ID, | |
| 302 | input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, | |
| 303 | }); | |
| 304 | const first = yield* Queue.take(h.requests); | |
| 305 | const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, { | |
| 306 | environmentId: ENVIRONMENT_ID, | |
| 307 | input: { threadId: THREAD_ID, reason: "user" }, | |
| 308 | }); | |
| 309 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | |
| 310 | yield* Deferred.succeed(first.reply, { sequence: 2 }); | |
| 311 | expect((yield* Effect.promise(() => result))._tag).toBe("Success"); | |
| 312 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | |
| 313 | const confirmed = { | |
| 314 | ...SNAPSHOT, | |
| 315 | snapshotSequence: 2, | |
| 316 | threads: [{ ...SNAPSHOT.threads[0]!, ...parked }], | |
| 317 | }; | |
| 318 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); | |
| 319 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | |
| 320 | const second = yield* Queue.take(h.requests); | |
| 321 | expect(second.command.type).toBe( | |
| 322 | action === "settle" ? "thread.unsettle" : "thread.unsnooze", | |
| 323 | ); | |
| 324 | yield* Deferred.fail(second.reply, new Error("Undo rejected")); | |
| 325 | expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); | |
| 326 | expect(h.registry.get(h.visibleAtom)).toBe(confirmed); | |
| 327 | }), | |
| 328 | ); | |
| 293 | const undoableActions = ["settle", "snooze"] as const; | |
| 329 | 294 | |
| 330 | it.effect(`preserves a newer approval when the ${action} reply arrives after the shell`, () => | |
| 295 | it.effect.each(undoableActions)("restores a confirmed %s when a queued undo fails", (action) => | |
| 296 | Effect.gen(function* () { | |
| 297 | const h = yield* makeHarness(); | |
| 298 | const parked = | |
| 299 | action === "settle" ? { settledOverride: "settled" as const } : { snoozedUntil: FUTURE }; | |
| 300 | const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null }; | |
| 301 | const result = h.commands[action].run(h.registry, { | |
| 302 | environmentId: ENVIRONMENT_ID, | |
| 303 | input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, | |
| 304 | }); | |
| 305 | const first = yield* Queue.take(h.requests); | |
| 306 | const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, { | |
| 307 | environmentId: ENVIRONMENT_ID, | |
| 308 | input: { threadId: THREAD_ID, reason: "user" }, | |
| 309 | }); | |
| 310 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | |
| 311 | yield* Deferred.succeed(first.reply, { sequence: 2 }); | |
| 312 | expect((yield* Effect.promise(() => result))._tag).toBe("Success"); | |
| 313 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | |
| 314 | const confirmed = { | |
| 315 | ...SNAPSHOT, | |
| 316 | snapshotSequence: 2, | |
| 317 | threads: [{ ...SNAPSHOT.threads[0]!, ...parked }], | |
| 318 | }; | |
| 319 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); | |
| 320 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | |
| 321 | const second = yield* Queue.take(h.requests); | |
| 322 | expect(second.command.type).toBe(action === "settle" ? "thread.unsettle" : "thread.unsnooze"); | |
| 323 | yield* Deferred.fail(second.reply, new Error("Undo rejected")); | |
| 324 | expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); | |
| 325 | expect(h.registry.get(h.visibleAtom)).toBe(confirmed); | |
| 326 | }), | |
| 327 | ); | |
| 328 | ||
| 329 | it.effect.each(undoableActions)( | |
| 330 | "preserves a newer approval when the %s reply arrives after the shell", | |
| 331 | (action) => | |
| 331 | 332 | Effect.gen(function* () { |
| 332 | 333 | const h = yield* makeHarness(); |
| 333 | 334 | const result = h.commands[action].run(h.registry, { |
| ... | ... | @@ -346,9 +347,11 @@ describe("remote thread lifecycle commands", () => { |
| 346 | 347 | expect((yield* Effect.promise(() => result))._tag).toBe("Success"); |
| 347 | 348 | expect(h.registry.get(h.visibleAtom)).toBe(newer); |
| 348 | 349 | }), |
| 349 | ); | |
| 350 | ); | |
| 350 | 351 | |
| 351 | it.effect(`shows an accepted ${action} while the shell still has an old input request`, () => | |
| 352 | it.effect.each(undoableActions)( | |
| 353 | "shows an accepted %s while the shell still has an old input request", | |
| 354 | (action) => | |
| 352 | 355 | Effect.gen(function* () { |
| 353 | 356 | const h = yield* makeHarness(); |
| 354 | 357 | const stale = { |
| ... | ... | @@ -370,6 +373,5 @@ describe("remote thread lifecycle commands", () => { |
| 370 | 373 | expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull(); |
| 371 | 374 | expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale); |
| 372 | 375 | }), |
| 373 | ); | |
| 374 | } | |
| 376 | ); | |
| 375 | 377 | }); |
packages/client-runtime/src/state/threads-sync.test.ts+12-12| ... | ... | @@ -315,8 +315,9 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { |
| 315 | 315 | }; |
| 316 | 316 | |
| 317 | 317 | describe("EnvironmentThreads", () => { |
| 318 | for (const source of ["disk", "HTTP"] as const) { | |
| 319 | it.effect(`does not rewrite an unchanged ${source} snapshot on navigation or warm return`, () => | |
| 318 | it.effect.each(["disk", "HTTP"] as const)( | |
| 319 | "does not rewrite an unchanged %s snapshot on navigation or warm return", | |
| 320 | (source) => | |
| 320 | 321 | Effect.gen(function* () { |
| 321 | 322 | const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = { |
| 322 | 323 | snapshot: undefined, |
| ... | ... | @@ -350,8 +351,7 @@ describe("EnvironmentThreads", () => { |
| 350 | 351 | ); |
| 351 | 352 | expect(yield* Ref.get(nextSaved)).toEqual([]); |
| 352 | 353 | }), |
| 353 | ); | |
| 354 | } | |
| 354 | ); | |
| 355 | 355 | |
| 356 | 356 | it.effect("persists a complete bounded HTTP window only once", () => |
| 357 | 357 | Effect.gen(function* () { |
| ... | ... | @@ -1012,8 +1012,9 @@ describe("EnvironmentThreads", () => { |
| 1012 | 1012 | }), |
| 1013 | 1013 | ); |
| 1014 | 1014 | |
| 1015 | for (const cacheKind of ["disk", "retained"] as const) { | |
| 1016 | it.effect(`retains paging support through a complete bounded ${cacheKind} cache`, () => | |
| 1015 | it.effect.each(["disk", "retained"] as const)( | |
| 1016 | "retains paging support through a complete bounded %s cache", | |
| 1017 | (cacheKind) => | |
| 1017 | 1018 | Effect.gen(function* () { |
| 1018 | 1019 | const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = { |
| 1019 | 1020 | snapshot: undefined, |
| ... | ... | @@ -1071,11 +1072,11 @@ describe("EnvironmentThreads", () => { |
| 1071 | 1072 | expect(yield* Ref.get(warm.lastSubscribeAfterSequence)).toBe(5); |
| 1072 | 1073 | expect(yield* Ref.get(warm.lastAcceptBoundedSnapshot)).toBe(true); |
| 1073 | 1074 | }), |
| 1074 | ); | |
| 1075 | } | |
| 1075 | ); | |
| 1076 | 1076 | |
| 1077 | for (const historyPaging of ["no-http", "no-controller"] as const) { | |
| 1078 | it.effect(`does not negotiate bounded fallbacks with ${historyPaging}`, () => | |
| 1077 | it.effect.each(["no-http", "no-controller"] as const)( | |
| 1078 | "does not negotiate bounded fallbacks with %s", | |
| 1079 | (historyPaging) => | |
| 1079 | 1080 | Effect.gen(function* () { |
| 1080 | 1081 | for (const source of ["cache", "http"] as const) { |
| 1081 | 1082 | const history = { |
| ... | ... | @@ -1105,8 +1106,7 @@ describe("EnvironmentThreads", () => { |
| 1105 | 1106 | expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBeUndefined(); |
| 1106 | 1107 | } |
| 1107 | 1108 | }), |
| 1108 | ); | |
| 1109 | } | |
| 1109 | ); | |
| 1110 | 1110 | |
| 1111 | 1111 | it.effect("socket snapshot clears progressive history meta left from a bounded window", () => |
| 1112 | 1112 | Effect.gen(function* () { |
packages/contracts/src/providerInstance.test.ts+30-32| ... | ... | @@ -21,39 +21,37 @@ describe("provider slug validation (shared by driver + instance ids)", () => { |
| 21 | 21 | { schemaName: "ProviderDriverKind", decode: decodeProviderDriverKind }, |
| 22 | 22 | ] as const; |
| 23 | 23 | |
| 24 | for (const { schemaName, decode } of cases) { | |
| 25 | describe(schemaName, () => { | |
| 26 | it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])( | |
| 27 | "accepts %s", | |
| 28 | (id) => { | |
| 29 | expect(decode(id)).toBe(id); | |
| 30 | }, | |
| 31 | ); | |
| 32 | ||
| 33 | it.each([ | |
| 34 | ["empty string", ""], | |
| 35 | ["leading digit", "1codex"], | |
| 36 | ["leading dash", "-codex"], | |
| 37 | ["leading underscore", "_codex"], | |
| 38 | ["whitespace inside", "codex personal"], | |
| 39 | ["dot inside", "codex.personal"], | |
| 40 | ["slash inside", "codex/personal"], | |
| 41 | ])("rejects %s", (_label, value) => { | |
| 42 | expect(() => decode(value)).toThrow(); | |
| 43 | }); | |
| 44 | ||
| 45 | it("trims surrounding whitespace before validating", () => { | |
| 46 | expect(decode(" codex_work ")).toBe("codex_work"); | |
| 47 | }); | |
| 48 | ||
| 49 | it("rejects ids longer than 64 characters", () => { | |
| 50 | const tooLong = "a".repeat(65); | |
| 51 | expect(() => decode(tooLong)).toThrow(); | |
| 52 | const justRight = "a".repeat(64); | |
| 53 | expect(decode(justRight)).toBe(justRight); | |
| 54 | }); | |
| 24 | describe.each(cases)("$schemaName", ({ decode }) => { | |
| 25 | it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])( | |
| 26 | "accepts %s", | |
| 27 | (id) => { | |
| 28 | expect(decode(id)).toBe(id); | |
| 29 | }, | |
| 30 | ); | |
| 31 | ||
| 32 | it.each([ | |
| 33 | ["empty string", ""], | |
| 34 | ["leading digit", "1codex"], | |
| 35 | ["leading dash", "-codex"], | |
| 36 | ["leading underscore", "_codex"], | |
| 37 | ["whitespace inside", "codex personal"], | |
| 38 | ["dot inside", "codex.personal"], | |
| 39 | ["slash inside", "codex/personal"], | |
| 40 | ])("rejects %s", (_label, value) => { | |
| 41 | expect(() => decode(value)).toThrow(); | |
| 42 | }); | |
| 43 | ||
| 44 | it("trims surrounding whitespace before validating", () => { | |
| 45 | expect(decode(" codex_work ")).toBe("codex_work"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("rejects ids longer than 64 characters", () => { | |
| 49 | const tooLong = "a".repeat(65); | |
| 50 | expect(() => decode(tooLong)).toThrow(); | |
| 51 | const justRight = "a".repeat(64); | |
| 52 | expect(decode(justRight)).toBe(justRight); | |
| 55 | 53 | }); |
| 56 | } | |
| 54 | }); | |
| 57 | 55 | }); |
| 58 | 56 | |
| 59 | 57 | describe("ProviderInstanceRef", () => { |
packages/effect-acp/src/protocol.test.ts+4-4| ... | ... | @@ -1088,8 +1088,9 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { |
| 1088 | 1088 | }), |
| 1089 | 1089 | ); |
| 1090 | 1090 | |
| 1091 | for (const operation of ["request", "notification"] as const) { | |
| 1092 | it.effect(`rejects a ${operation} if the connection ends while its logger is running`, () => | |
| 1091 | it.effect.each(["request", "notification"] as const)( | |
| 1092 | "rejects a %s if the connection ends while its logger is running", | |
| 1093 | (operation) => | |
| 1093 | 1094 | Effect.gen(function* () { |
| 1094 | 1095 | const { stdio, input, output } = yield* makeInMemoryStdio(); |
| 1095 | 1096 | const writeStarted = yield* Deferred.make<void>(); |
| ... | ... | @@ -1127,6 +1128,5 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { |
| 1127 | 1128 | assert.strictEqual(failure, error); |
| 1128 | 1129 | assert.equal(yield* Queue.size(output), 0); |
| 1129 | 1130 | }), |
| 1130 | ); | |
| 1131 | } | |
| 1131 | ); | |
| 1132 | 1132 | }); |
packages/shared/src/gitPatchPath.test.ts+7-9| ... | ... | @@ -46,15 +46,13 @@ describe("a name written into a header and read back out", () => { |
| 46 | 46 | `every\t\n\r"\\${BELL}.txt`, |
| 47 | 47 | ]; |
| 48 | 48 | |
| 49 | for (const name of names) { | |
| 50 | it(`is the name that went in: ${JSON.stringify(name)}`, () => { | |
| 51 | const written = quoteGitPatchPath(name); | |
| 52 | expect(unquoteGitPatchPath(written)).toBe(name); | |
| 53 | // A parser that takes the quotes off itself, as the clients' one does, gets there too. | |
| 54 | const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; | |
| 55 | expect(unquoteGitPatchPath(unwrapped)).toBe(name); | |
| 56 | }); | |
| 57 | } | |
| 49 | it.each(names)("is the name that went in: %j", (name) => { | |
| 50 | const written = quoteGitPatchPath(name); | |
| 51 | expect(unquoteGitPatchPath(written)).toBe(name); | |
| 52 | // A parser that takes the quotes off itself, as the clients' one does, gets there too. | |
| 53 | const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; | |
| 54 | expect(unquoteGitPatchPath(unwrapped)).toBe(name); | |
| 55 | }); | |
| 58 | 56 | |
| 59 | 57 | it("carries the whole name past the first thing a header stops at", () => { |
| 60 | 58 | const written = quoteGitPatchPath("tab\tfile.txt"); |
scripts/build-desktop-artifact.test.ts+28-28| ... | ... | @@ -1269,8 +1269,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1269 | 1269 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), |
| 1270 | 1270 | ); |
| 1271 | 1271 | |
| 1272 | for (const targetArch of ["x64", "arm64"] as const) { | |
| 1273 | it.effect(`accepts an embedded archive with the Linux ${targetArch} node-pty prebuild`, () => | |
| 1272 | it.effect.each(["x64", "arm64"] as const)( | |
| 1273 | "accepts an embedded archive with the Linux %s node-pty prebuild", | |
| 1274 | (targetArch) => | |
| 1274 | 1275 | Effect.scoped( |
| 1275 | 1276 | Effect.gen(function* () { |
| 1276 | 1277 | const fixture = yield* makeWindowsPayloadFixture({ |
| ... | ... | @@ -1290,33 +1291,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1290 | 1291 | assert.equal(result.packagedAppDir, fixture.packagedAppDir); |
| 1291 | 1292 | }), |
| 1292 | 1293 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), |
| 1293 | ); | |
| 1294 | ); | |
| 1294 | 1295 | |
| 1295 | it.effect( | |
| 1296 | `rejects a node-pty prebuild for the wrong architecture in a Linux ${targetArch} archive`, | |
| 1297 | () => | |
| 1298 | Effect.scoped( | |
| 1299 | Effect.gen(function* () { | |
| 1300 | const fixture = yield* makeWindowsPayloadFixture({ | |
| 1301 | copyUnpackedNatives: true, | |
| 1302 | wslRuntime: "valid", | |
| 1303 | targetArch, | |
| 1304 | ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", | |
| 1305 | }); | |
| 1306 | const error = yield* validateWindowsPackagedPayload({ | |
| 1307 | stageDistDir: fixture.stageDistDir, | |
| 1308 | appExecutableName: fixture.appExecutableName, | |
| 1309 | targetArch, | |
| 1310 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1311 | expectWslRuntime: true, | |
| 1312 | }).pipe(Effect.flip); | |
| 1313 | ||
| 1314 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | |
| 1315 | assert.equal(error.reason, "wsl-runtime-invalid"); | |
| 1316 | }), | |
| 1317 | ), | |
| 1318 | ); | |
| 1319 | } | |
| 1296 | it.effect.each(["x64", "arm64"] as const)( | |
| 1297 | "rejects a node-pty prebuild for the wrong architecture in a Linux %s archive", | |
| 1298 | (targetArch) => | |
| 1299 | Effect.scoped( | |
| 1300 | Effect.gen(function* () { | |
| 1301 | const fixture = yield* makeWindowsPayloadFixture({ | |
| 1302 | copyUnpackedNatives: true, | |
| 1303 | wslRuntime: "valid", | |
| 1304 | targetArch, | |
| 1305 | ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", | |
| 1306 | }); | |
| 1307 | const error = yield* validateWindowsPackagedPayload({ | |
| 1308 | stageDistDir: fixture.stageDistDir, | |
| 1309 | appExecutableName: fixture.appExecutableName, | |
| 1310 | targetArch, | |
| 1311 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1312 | expectWslRuntime: true, | |
| 1313 | }).pipe(Effect.flip); | |
| 1314 | ||
| 1315 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | |
| 1316 | assert.equal(error.reason, "wsl-runtime-invalid"); | |
| 1317 | }), | |
| 1318 | ), | |
| 1319 | ); | |
| 1320 | 1320 | |
| 1321 | 1321 | it.effect("rejects an embedded archive built for a different release version", () => |
| 1322 | 1322 | Effect.scoped( |
scripts/dev-runner.test.ts+22-24| ... | ... | @@ -414,8 +414,9 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 414 | 414 | // Browser dev is single-origin: Vite proxies the backend, and the client |
| 415 | 415 | // resolves it from window.location.origin. Baking a localhost URL here is |
| 416 | 416 | // what breaks sharing a dev server to another device. |
| 417 | for (const mode of ["dev", "dev:web"] as const) { | |
| 418 | it.effect(`leaves the client backend URLs unset in ${mode} mode`, () => | |
| 417 | it.effect.each(["dev", "dev:web"] as const)( | |
| 418 | "leaves the client backend URLs unset in %s mode", | |
| 419 | (mode) => | |
| 419 | 420 | Effect.gen(function* () { |
| 420 | 421 | const env = yield* createDevRunnerEnv({ |
| 421 | 422 | mode, |
| ... | ... | @@ -442,8 +443,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 442 | 443 | // the intent has to be stated positively. |
| 443 | 444 | assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1"); |
| 444 | 445 | }), |
| 445 | ); | |
| 446 | } | |
| 446 | ); | |
| 447 | 447 | |
| 448 | 448 | // Desktop pins the renderer at loopback deliberately; an ambient marker |
| 449 | 449 | // must not make Vite discard those URLs. |
| ... | ... | @@ -492,27 +492,25 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 492 | 492 | // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An |
| 493 | 493 | // inherited one would survive into browser dev and point HMR at the wrong |
| 494 | 494 | // interface — invisible over a shared origin, since the page still loads. |
| 495 | for (const mode of ["dev", "dev:web"] as const) { | |
| 496 | it.effect(`drops an inherited HOST in ${mode} mode`, () => | |
| 497 | Effect.gen(function* () { | |
| 498 | const env = yield* createDevRunnerEnv({ | |
| 499 | mode, | |
| 500 | baseEnv: { HOST: "0.0.0.0" }, | |
| 501 | serverOffset: 0, | |
| 502 | webOffset: 0, | |
| 503 | t3Home: undefined, | |
| 504 | browser: undefined, | |
| 505 | autoBootstrapProjectFromCwd: undefined, | |
| 506 | logWebSocketEvents: undefined, | |
| 507 | host: undefined, | |
| 508 | port: undefined, | |
| 509 | devUrl: undefined, | |
| 510 | }); | |
| 495 | it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) => | |
| 496 | Effect.gen(function* () { | |
| 497 | const env = yield* createDevRunnerEnv({ | |
| 498 | mode, | |
| 499 | baseEnv: { HOST: "0.0.0.0" }, | |
| 500 | serverOffset: 0, | |
| 501 | webOffset: 0, | |
| 502 | t3Home: undefined, | |
| 503 | browser: undefined, | |
| 504 | autoBootstrapProjectFromCwd: undefined, | |
| 505 | logWebSocketEvents: undefined, | |
| 506 | host: undefined, | |
| 507 | port: undefined, | |
| 508 | devUrl: undefined, | |
| 509 | }); | |
| 511 | 510 | |
| 512 | assert.equal(env.HOST, undefined); | |
| 513 | }), | |
| 514 | ); | |
| 515 | } | |
| 511 | assert.equal(env.HOST, undefined); | |
| 512 | }), | |
| 513 | ); | |
| 516 | 514 | |
| 517 | 515 | // --host configures the *backend* (T3CODE_HOST). It must not become Vite's |
| 518 | 516 | // bind address by way of an inherited HOST that happens to agree with it. |
scripts/legend-list-initial-reveal.test.ts+5-4| ... | ... | @@ -78,8 +78,9 @@ function createList(bundle: string) { |
| 78 | 78 | }; |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | for (const bundle of ["react-native.js", "react-native.mjs"]) { | |
| 82 | describe(`initial inset end reveal (${bundle})`, () => { | |
| 81 | describe.each(["react-native.js", "react-native.mjs"])( | |
| 82 | "initial inset end reveal (%s)", | |
| 83 | (bundle) => { | |
| 83 | 84 | it("waits for the native offset instead of the optimistic scroll target", () => { |
| 84 | 85 | const list = createList(bundle); |
| 85 | 86 | list.start(); |
| ... | ... | @@ -210,5 +211,5 @@ for (const bundle of ["react-native.js", "react-native.mjs"]) { |
| 210 | 211 | empty.complete(); |
| 211 | 212 | expect(empty.ready()).toBe(true); |
| 212 | 213 | }); |
| 213 | }); | |
| 214 | } | |
| 214 | }, | |
| 215 | ); |
vite.config.ts+1| ... | ... | @@ -167,6 +167,7 @@ export default defineConfig({ |
| 167 | 167 | "t3code/no-inline-schema-compile": "warn", |
| 168 | 168 | "t3code/no-manual-effect-runtime-in-tests": "error", |
| 169 | 169 | "t3code/no-native-title-tooltip": "error", |
| 170 | "t3code/no-test-in-loop": "error", | |
| 170 | 171 | "t3code/namespace-node-imports": "error", |
| 171 | 172 | }, |
| 172 | 173 | overrides: [ |