authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-02 16:07:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 16:07:50-07:00
logfd7ee2c30a7eeef910c99e7bc92e944526555db7
tree58372fc5149b9aeba3435ddd1dabb4fe57db12b2
parenta8927712f8b3338fa3c47a0c657b6e11d7e9aaec
signature Signed by PGP key B5690EEEBB952194

chore(lint): forbid declaring tests inside for loops (#14921)

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,7 +78,7 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => {
78 expect(result.stderr.length).toBe(0);78 expect(result.stderr.length).toBe(0);
79 });79 });
8080
81 for (const [scenario, code] of [81 it.each([
82 ["missing", 2],82 ["missing", 2],
83 ["empty", 2],83 ["empty", 2],
84 ["locked", 3],84 ["locked", 3],
...@@ -86,13 +86,11 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => {...@@ -86,13 +86,11 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => {
86 ["denied", 3],86 ["denied", 3],
87 ["unavailable", 4],87 ["unavailable", 4],
88 ["unloaded", 4],88 ["unloaded", 4],
89 ]) {89 ])("reports %s without emitting a secret", (scenario, code) => {
90 it(`reports ${scenario} without emitting a secret`, () => {90 const result = run([scenario]);
91 const result = run([scenario]);91 expect(result.status).toBe(code);
92 expect(result.status).toBe(code);92 expect(result.stdout.length).toBe(0);
93 expect(result.stdout.length).toBe(0);93 });
94 });
95 }
96 it("rejects invalid arguments before accessing the keyring", () => {94 it("rejects invalid arguments before accessing the keyring", () => {
97 for (const args of [[], [""], ["chrome", "extra"]]) {95 for (const args of [[], [""], ["chrome", "extra"]]) {
98 const result = run(args);96 const result = run(args);
apps/desktop/src/app/DesktopClerk.test.ts+4-4
...@@ -303,8 +303,9 @@ it.effect(...@@ -303,8 +303,9 @@ it.effect(
303 },303 },
304);304);
305305
306for (const entry of ["startup", "open-url"] as const) {306it.effect.each(["startup", "open-url"] as const)(
307 it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () =>307 "receives hosted web sign-in through the desktop %s handler",
308 (entry) =>
308 Effect.gen(function* () {309 Effect.gen(function* () {
309 storageMock.mockReturnValue(storageAdapter);310 storageMock.mockReturnValue(storageAdapter);
310 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });311 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
...@@ -382,5 +383,4 @@ for (const entry of ["startup", "open-url"] as const) {...@@ -382,5 +383,4 @@ for (const entry of ["startup", "open-url"] as const) {
382 ),383 ),
383 );384 );
384 }).pipe(Effect.scoped),385 }).pipe(Effect.scoped),
385 );386);
386}
apps/desktop/src/app/DesktopLifecycle.test.ts+5-4
...@@ -101,8 +101,9 @@ function makeDesktopWindowLayer(...@@ -101,8 +101,9 @@ function makeDesktopWindowLayer(
101}101}
102102
103describe("DesktopLifecycle", () => {103describe("DesktopLifecycle", () => {
104 for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {104 it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>)(
105 it.effect(`lets the updater's quit event proceed on ${platform}`, () => {105 "lets the updater's quit event proceed on %s",
106 (platform) => {
106 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();107 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
107 let windowsDestroyed = false;108 let windowsDestroyed = false;
108 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {109 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
...@@ -152,8 +153,8 @@ describe("DesktopLifecycle", () => {...@@ -152,8 +153,8 @@ describe("DesktopLifecycle", () => {
152 assert.isTrue(yield* Ref.get(state.quitting));153 assert.isTrue(yield* Ref.get(state.quitting));
153 }),154 }),
154 ).pipe(Effect.provide(layer));155 ).pipe(Effect.provide(layer));
155 });156 },
156 }157 );
157158
158 it.effect("destroys windows before waiting for backend shutdown", () =>159 it.effect("destroys windows before waiting for backend shutdown", () =>
159 Effect.gen(function* () {160 Effect.gen(function* () {
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+45-47
...@@ -81,54 +81,52 @@ describe("DesktopPreReadyPlatform", () => {...@@ -81,54 +81,52 @@ describe("DesktopPreReadyPlatform", () => {
81 );81 );
82 });82 });
8383
84 for (const previousEntry of [undefined, 'Exec="/Applications/deleted-previous.AppImage" %U']) {84 it.effect.each([
85 it.effect(85 { previousEntry: undefined, label: "missing" },
86 `prepares a ${previousEntry ? "stale" : "missing"} Linux desktop entry before startup yields`,86 { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" },
87 () => {87 ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => {
88 vi.stubEnv("VITE_DEV_SERVER_URL", "");88 vi.stubEnv("VITE_DEV_SERVER_URL", "");
89 vi.stubEnv("XDG_DATA_HOME", "/xdg");89 vi.stubEnv("XDG_DATA_HOME", "/xdg");
90 vi.stubEnv("APPIMAGE", "/Applications/current.AppImage");90 vi.stubEnv("APPIMAGE", "/Applications/current.AppImage");
91 getSwitchValueMock.mockReturnValue("");91 getSwitchValueMock.mockReturnValue("");
92 let desktopName = "t3code.desktop";92 let desktopName = "t3code.desktop";
93 let desktopEntry = previousEntry;93 let desktopEntry = previousEntry;
94 let iconInstalled = false;94 let iconInstalled = false;
95 copyFileSyncMock.mockImplementation((_source: string, destination: string) => {95 copyFileSyncMock.mockImplementation((_source: string, destination: string) => {
96 iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png";96 iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png";
97 });97 });
98 setDesktopNameMock.mockImplementation((name: string) => {98 setDesktopNameMock.mockImplementation((name: string) => {
99 desktopName = name;99 desktopName = name;
100 });100 });
101 writeFileSyncMock.mockImplementation((path: string, contents: string) => {101 writeFileSyncMock.mockImplementation((path: string, contents: string) => {
102 if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents;102 if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents;
103 });103 });
104104
105 return Effect.scoped(105 return Effect.scoped(
106 Effect.gen(function* () {106 Effect.gen(function* () {
107 const portalIdentity = Promise.resolve().then(() => ({107 const portalIdentity = Promise.resolve().then(() => ({
108 desktopName,108 desktopName,
109 desktopEntry,109 desktopEntry,
110 iconInstalled,110 iconInstalled,
111 }));111 }));
112 yield* Layer.build(112 yield* Layer.build(
113 DesktopPreReadyPlatform.layer.pipe(113 DesktopPreReadyPlatform.layer.pipe(
114 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),114 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
115 ),115 ),
116 );116 );
117 const identity = yield* Effect.promise(() => portalIdentity);117 const identity = yield* Effect.promise(() => portalIdentity);
118 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");118 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");
119 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');119 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');
120 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");120 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");
121 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");121 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");
122 assert.include(122 assert.include(
123 identity.desktopEntry ?? "",123 identity.desktopEntry ?? "",
124 "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png",124 "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png",
125 );125 );
126 assert.isTrue(identity.iconInstalled);126 assert.isTrue(identity.iconInstalled);
127 }),127 }),
128 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));128 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
129 },129 });
130 );
131 }
132130
133 it.effect("keeps startup available when the early desktop entry cannot be written", () => {131 it.effect("keeps startup available when the early desktop entry cannot be written", () => {
134 getSwitchValueMock.mockReturnValue("");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,39 +37,37 @@ it.effect("identifies a failed source read and preserves its cause", () => {
37 );37 );
38});38});
3939
40for (const sourceName of ["t3code", "T3 Code (Alpha)"]) {40it.effect.each(["t3code", "T3 Code (Alpha)"])(
41 it.effect(41 "preserves Windows credential keys from %s without copying browser databases",
42 `preserves Windows credential keys from ${sourceName} without copying browser databases`,42 (sourceName) =>
43 () =>43 Effect.gen(function* () {
44 Effect.gen(function* () {44 const fs = yield* FileSystem.FileSystem;
45 const fs = yield* FileSystem.FileSystem;45 const path = yield* Path.Path;
46 const path = yield* Path.Path;46 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" });
47 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" });47 const source = path.join(directory, sourceName);
48 const source = path.join(directory, sourceName);48 const destination = path.join(directory, "t3code-v2");
49 const destination = path.join(directory, "t3code-v2");49 const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}';
50 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(directory, "T3 Code (Alpha)"), { recursive: true });51 yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true });
52 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, "Local State"), state);53 yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database");
54 yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database");54 yield* resolveUserDataPath({
55 yield* resolveUserDataPath({55 appDataDirectory: directory,
56 appDataDirectory: directory,56 isDevelopment: false,
57 isDevelopment: false,57 platform: "win32",
58 platform: "win32",58 });
59 });59 assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state);
60 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.equal(yield* fs.readFileString(path.join(source, "Local State")), state);61 assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB")));
62 assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB")));62 yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state");
63 yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state");63 yield* resolveUserDataPath({
64 yield* resolveUserDataPath({64 appDataDirectory: directory,
65 appDataDirectory: directory,65 isDevelopment: false,
66 isDevelopment: false,66 platform: "win32",
67 platform: "win32",67 });
68 });68 assert.equal(
69 assert.equal(69 yield* fs.readFileString(path.join(destination, "Local State")),
70 yield* fs.readFileString(path.join(destination, "Local State")),70 "existing V2 state",
71 "existing V2 state",71 );
72 );72 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
73 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),73);
74 );
75}
apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts+36-38
...@@ -386,45 +386,43 @@ describe("parseFirefoxProfiles", () => {...@@ -386,45 +386,43 @@ describe("parseFirefoxProfiles", () => {
386 }),386 }),
387 );387 );
388388
389 for (const [platform, root] of [389 it.effect.each([
390 ["Linux", "/home/user/.mozilla/firefox"],390 { platform: "Linux", root: "/home/user/.mozilla/firefox" },
391 ["macOS", "/Users/user/Library/Application Support/Firefox"],391 { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" },
392 ] as const) {392 ])("validates relative and absolute $platform profile paths", ({ root }) =>
393 it.effect(`validates relative and absolute ${platform} profile paths`, () =>393 Effect.gen(function* () {
394 Effect.gen(function* () {394 const parsed = yield* parsePosixFirefoxProfiles(
395 const parsed = yield* parsePosixFirefoxProfiles(395 [
396 [396 "[Profile0]",
397 "[Profile0]",397 "Name=Relative",
398 "Name=Relative",398 "IsRelative=1",
399 "IsRelative=1",399 "Path=Profiles/relative.default",
400 "Path=Profiles/relative.default",400 "[Profile1]",
401 "[Profile1]",401 "Name=Custom",
402 "Name=Custom",402 "IsRelative=0",
403 "IsRelative=0",403 "Path=/mnt/custom/firefox-profile",
404 "Path=/mnt/custom/firefox-profile",404 "[Profile2]",
405 "[Profile2]",405 "IsRelative=1",
406 "IsRelative=1",406 "Path=../../escape",
407 "Path=../../escape",407 "[Profile3]",
408 "[Profile3]",408 "IsRelative=1",
409 "IsRelative=1",409 "Path=/absolute-marked-relative",
410 "Path=/absolute-marked-relative",410 "[Profile4]",
411 "[Profile4]",411 "IsRelative=0",
412 "IsRelative=0",412 "Path=relative-marked-absolute",
413 "Path=relative-marked-absolute",413 "[Profile5]",
414 "[Profile5]",414 "IsRelative=1",
415 "IsRelative=1",415 "Path=Profiles/nul\u0000escape",
416 "Path=Profiles/nul\u0000escape",416 ].join("\n"),
417 ].join("\n"),417 root,
418 root,418 );
419 );
420419
421 expect(parsed).toEqual([420 expect(parsed).toEqual([
422 { directory: "Profiles/relative.default", name: "Relative" },421 { directory: "Profiles/relative.default", name: "Relative" },
423 { directory: "/mnt/custom/firefox-profile", name: "Custom" },422 { directory: "/mnt/custom/firefox-profile", name: "Custom" },
424 ]);423 ]);
425 }),424 }),
426 );425 );
427 }
428426
429 it.effect("uses Windows path rules for relative and absolute profiles", () =>427 it.effect("uses Windows path rules for relative and absolute profiles", () =>
430 Effect.gen(function* () {428 Effect.gen(function* () {
apps/desktop/src/preview/BrowserImport/Sources.test.ts+8-8
...@@ -763,8 +763,9 @@ describe("listSourceProfiles Firefox fallback", () => {...@@ -763,8 +763,9 @@ describe("listSourceProfiles Firefox fallback", () => {
763 { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") },763 { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") },
764 ];764 ];
765765
766 for (const { platform, profileDirectory } of cases) {766 it.effect.each(cases)(
767 it.effect(`scans the ${platform} profile location and excludes stale entries`, () =>767 "scans the $platform profile location and excludes stale entries",
768 ({ platform, profileDirectory }) =>
768 run(769 run(
769 Effect.gen(function* () {770 Effect.gen(function* () {
770 const fileSystem = yield* FileSystem.FileSystem;771 const fileSystem = yield* FileSystem.FileSystem;
...@@ -800,8 +801,7 @@ describe("listSourceProfiles Firefox fallback", () => {...@@ -800,8 +801,7 @@ describe("listSourceProfiles Firefox fallback", () => {
800 ]);801 ]);
801 }),802 }),
802 ),803 ),
803 );804 );
804 }
805805
806 it.effect("scans for profiles when profiles.ini declares only ones without cookies", () =>806 it.effect("scans for profiles when profiles.ini declares only ones without cookies", () =>
807 run(807 run(
...@@ -1166,8 +1166,9 @@ describe("Safari profiles", () => {...@@ -1166,8 +1166,9 @@ describe("Safari profiles", () => {
1166 ),1166 ),
1167 );1167 );
11681168
1169 for (const metadataState of ["missing", "corrupt"] as const) {1169 it.effect.each(["missing", "corrupt"] as const)(
1170 it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () =>1170 "recovers separate cookie stores when metadata is %s",
1171 (metadataState) =>
1171 run(1172 run(
1172 Effect.gen(function* () {1173 Effect.gen(function* () {
1173 const { context, store, metadata } = yield* fixture();1174 const { context, store, metadata } = yield* fixture();
...@@ -1181,8 +1182,7 @@ describe("Safari profiles", () => {...@@ -1181,8 +1182,7 @@ describe("Safari profiles", () => {
1181 assert.isTrue(yield* isSourceInstalled(safari, context));1182 assert.isTrue(yield* isSourceInstalled(safari, context));
1182 }),1183 }),
1183 ),1184 ),
1184 );1185 );
1185 }
11861186
1187 it.effect("keeps Safari without profiles available", () =>1187 it.effect("keeps Safari without profiles available", () =>
1188 run(1188 run(
apps/desktop/src/settings/DesktopClientSettings.test.ts+67-71
...@@ -149,58 +149,56 @@ describe("DesktopClientSettings", () => {...@@ -149,58 +149,56 @@ describe("DesktopClientSettings", () => {
149 ),149 ),
150 );150 );
151151
152 for (const failure of [152 it.effect.each([
153 { label: "permission", reason: "PermissionDenied" },153 { label: "permission", reason: "PermissionDenied" },
154 { label: "I/O", reason: "Unknown" },154 { label: "I/O", reason: "Unknown" },
155 ] as const) {155 ] as const)("preserves saved preferences across $label read failures and retries", (failure) =>
156 it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () =>156 withClientSettings(
157 withClientSettings(157 Effect.gen(function* () {
158 Effect.gen(function* () {158 const environment = yield* DesktopEnvironment.DesktopEnvironment;
159 const environment = yield* DesktopEnvironment.DesktopEnvironment;159 const fileSystem = yield* FileSystem.FileSystem;
160 const fileSystem = yield* FileSystem.FileSystem;160 const settings = yield* DesktopClientSettings.DesktopClientSettings;
161 const settings = yield* DesktopClientSettings.DesktopClientSettings;161 const savedSettings = {
162 const savedSettings = {162 ...clientSettings,
163 ...clientSettings,163 onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
164 onboardingCompletedAt: "2026-09-05T12:00:00.000Z",164 };
165 };165 yield* settings.set(savedSettings);
166 yield* settings.set(savedSettings);166 const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
167 const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);167 const cause = PlatformError.systemError({
168 const cause = PlatformError.systemError({168 _tag: failure.reason,
169 _tag: failure.reason,169 module: "FileSystem",
170 module: "FileSystem",170 method: "readFileString",
171 method: "readFileString",171 pathOrDescriptor: environment.clientSettingsPath,
172 pathOrDescriptor: environment.clientSettingsPath,172 });
173 });173 let failRead = true;
174 let failRead = true;174 const retryableSettings = yield* DesktopClientSettings.make.pipe(
175 const retryableSettings = yield* DesktopClientSettings.make.pipe(175 Effect.provideService(
176 Effect.provideService(176 FileSystem.FileSystem,
177 FileSystem.FileSystem,177 FileSystem.FileSystem.of({
178 FileSystem.FileSystem.of({178 ...fileSystem,
179 ...fileSystem,179 readFileString: (path) =>
180 readFileString: (path) =>180 Effect.suspend(() =>
181 Effect.suspend(() =>181 failRead ? Effect.fail(cause) : fileSystem.readFileString(path),
182 failRead ? Effect.fail(cause) : fileSystem.readFileString(path),182 ),
183 ),183 }),
184 }),184 ),
185 ),185 );
186 );
187186
188 const error = yield* retryableSettings.get.pipe(Effect.flip);187 const error = yield* retryableSettings.get.pipe(Effect.flip);
189 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);188 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
190 assert.equal(error.operation, "read-file");189 assert.equal(error.operation, "read-file");
191 assert.equal(error.path, environment.clientSettingsPath);190 assert.equal(error.path, environment.clientSettingsPath);
192 assert.strictEqual(error.cause, cause);191 assert.strictEqual(error.cause, cause);
193 assert.equal(192 assert.equal(
194 yield* fileSystem.readFileString(environment.clientSettingsPath),193 yield* fileSystem.readFileString(environment.clientSettingsPath),
195 savedContents,194 savedContents,
196 );195 );
197196
198 failRead = false;197 failRead = false;
199 assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));198 assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));
200 }),199 }),
201 ),200 ),
202 );201 );
203 }
204202
205 it.effect("reports the failed client settings write operation and path", () =>203 it.effect("reports the failed client settings write operation and path", () =>
206 withClientSettings(204 withClientSettings(
...@@ -288,31 +286,29 @@ describe("DesktopClientSettings", () => {...@@ -288,31 +286,29 @@ describe("DesktopClientSettings", () => {
288 ),286 ),
289 );287 );
290288
291 for (const document of [289 it.effect.each([
292 { label: "malformed JSON", contents: "{not-json" },290 { label: "malformed JSON", contents: "{not-json" },
293 { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' },291 { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' },
294 { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' },292 { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' },
295 ]) {293 ])("reports $label without treating the settings file as absent", (document) =>
296 it.effect(`reports ${document.label} without treating the settings file as absent`, () =>294 withClientSettings(
297 withClientSettings(295 Effect.gen(function* () {
298 Effect.gen(function* () {296 const environment = yield* DesktopEnvironment.DesktopEnvironment;
299 const environment = yield* DesktopEnvironment.DesktopEnvironment;297 const fileSystem = yield* FileSystem.FileSystem;
300 const fileSystem = yield* FileSystem.FileSystem;298 const settings = yield* DesktopClientSettings.DesktopClientSettings;
301 const settings = yield* DesktopClientSettings.DesktopClientSettings;299 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
302 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });300 yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents);
303 yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents);
304301
305 const error = yield* settings.get.pipe(Effect.flip);302 const error = yield* settings.get.pipe(Effect.flip);
306 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);303 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
307 assert.equal(error.operation, "decode-document");304 assert.equal(error.operation, "decode-document");
308 assert.equal(error.path, environment.clientSettingsPath);305 assert.equal(error.path, environment.clientSettingsPath);
309 assert.instanceOf(error.cause, Schema.SchemaError);306 assert.instanceOf(error.cause, Schema.SchemaError);
310 assert.equal(307 assert.equal(
311 yield* fileSystem.readFileString(environment.clientSettingsPath),308 yield* fileSystem.readFileString(environment.clientSettingsPath),
312 document.contents,309 document.contents,
313 );310 );
314 }),311 }),
315 ),312 ),
316 );313 );
317 }
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,8 +3873,9 @@ it.effect("waits to apply settings while permissions are pending", () => {
3873 ).pipe(Effect.provide(layer));3873 ).pipe(Effect.provide(layer));
3874});3874});
38753875
3876for (const fails of [false, true]) {3876it.effect.each([false, true])(
3877 it.effect(`tests macOS capture without publishing it and cleans up, failure=${fails}`, () => {3877 "tests macOS capture without publishing it and cleans up, failure=%s",
3878 (fails) => {
3878 const active = {3879 const active = {
3879 platform: "macos",3880 platform: "macos",
3880 id: 42,3881 id: 42,
...@@ -3915,8 +3916,8 @@ for (const fails of [false, true]) {...@@ -3915,8 +3916,8 @@ for (const fails of [false, true]) {
3915 }),3916 }),
3916 ),3917 ),
3917 );3918 );
3918 });3919 },
3919}3920);
39203921
3921it.effect("rejects macOS test capture on other platforms", () =>3922it.effect("rejects macOS test capture on other platforms", () =>
3922 Effect.scoped(3923 Effect.scoped(
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+48-50
...@@ -949,59 +949,57 @@ describe("makeRelayDeviceRegistrationRequest", () => {...@@ -949,59 +949,57 @@ describe("makeRelayDeviceRegistrationRequest", () => {
949 await new Promise((resolve) => setTimeout(resolve, 0));949 await new Promise((resolve) => setTimeout(resolve, 0));
950 expect(widgetMocks.start).toHaveBeenCalledTimes(1);950 expect(widgetMocks.start).toHaveBeenCalledTimes(1);
951 });951 });
952 for (const os of ["ios", "android"] as const) {952 it.effect.each(["ios", "android"] as const)(
953 it.effect(953 "does not enable %s notifications when a token rotates after permission is revoked",
954 `does not enable ${os} notifications when a token rotates after permission is revoked`,954 (os) => {
955 () => {955 vi.spyOn(Platform, "OS", "get").mockReturnValue(os);
956 vi.spyOn(Platform, "OS", "get").mockReturnValue(os);956 vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);
957 vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);957 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
958 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({958 type: os,
959 type: os,959 data: "initial",
960 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[] = [];983 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
963 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {984 granted: false,
964 const request = new Request(input, init);985 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
965 if (request.url.endsWith("/v1/client/dpop-token")) {986 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
966 return Response.json({987 listener({ type: os, data: "rotated" });
967 access_token: "dpop",988 yield* runBackgroundOperations();
968 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",989 expect(registrations.at(-1)).toMatchObject({
969 token_type: "DPoP",990 preferences: { notificationsEnabled: false },
970 expires_in: 300,
971 scope: "mobile:registration",
972 });
973 }
974 registrations.push(await request.json());
975 return Response.json({ ok: true });
976 });991 });
977 Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };992 expect(registrations.at(-1)).not.toHaveProperty("pushToken");
978 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");993 }).pipe(
979 return Effect.gen(function* () {994 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
980 yield* runBackgroundOperations();995 Effect.provide(
981 expect(registrations.at(-1)).toMatchObject({996 managedRelayClientLayer("https://permission-relay.example.test").pipe(
982 preferences: { notificationsEnabled: true },997 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)),
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 ),
1000 ),998 ),
1001 );999 ),
1002 },1000 );
1003 );1001 },
1004 }1002 );
1005 it.effect("preserves relay rejection errors with React Native response headers", () => {1003 it.effect("preserves relay rejection errors with React Native response headers", () => {
1006 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");1004 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1007 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({1005 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
apps/server/src/cli/app.test.ts+4-4
...@@ -256,8 +256,9 @@ describe("t3 app", () => {...@@ -256,8 +256,9 @@ describe("t3 app", () => {
256 ),256 ),
257 );257 );
258258
259 for (const responseKind of ["failure", "invalid"] as const) {259 it.effect.each(["failure", "invalid"] as const)(
260 it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () =>260 "never falls back after the default desktop sends a %s response",
261 (responseKind) =>
261 withTempDirectory("t3-app-response-test-", (root) =>262 withTempDirectory("t3-app-response-test-", (root) =>
262 Effect.gen(function* () {263 Effect.gen(function* () {
263 vi.mocked(NodeOS.homedir).mockReturnValue(root);264 vi.mocked(NodeOS.homedir).mockReturnValue(root);
...@@ -302,6 +303,5 @@ describe("t3 app", () => {...@@ -302,6 +303,5 @@ describe("t3 app", () => {
302 }303 }
303 }).pipe(Effect.scoped),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,29 +361,39 @@ describe("device discovery after server restart", () => {
361 );361 );
362});362});
363363
364for (const [diagnostic, reason, message] of [364it.effect.each([
365 ["Insufficient disk space at /private/user/path", "disk_space", "not enough free disk space"],365 {
366 ["Timed out spawning /private/user/command", "timeout", "did not become ready in time"],366 diagnostic: "Insufficient disk space at /private/user/path",
367 ["Unexpected failure: secret-token", "launch_failed", "could not start"],367 reason: "disk_space",
368] as const) {368 message: "not enough free disk space",
369 it.effect(`normalizes boot failure: ${reason}`, () =>369 },
370 Effect.gen(function* () {370 {
371 const { service } = yield* fixture(Effect.void, diagnostic);371 diagnostic: "Timed out spawning /private/user/command",
372 yield* service.configure({ enabled: true });372 reason: "timeout",
373 const error = yield* service373 message: "did not become ready in time",
374 .open({374 },
375 threadId: ThreadId.make("boot-failure"),375 {
376 deviceId: "Pixel_API_35",376 diagnostic: "Unexpected failure: secret-token",
377 platform: "android",377 reason: "launch_failed",
378 })378 message: "could not start",
379 .pipe(Effect.flip);379 },
380 expect(error._tag).toBe("DeviceBootError");380] as const)("normalizes boot failure: $reason", ({ diagnostic, message }) =>
381 expect(error.message).toContain(message);381 Effect.gen(function* () {
382 expect(error.message).not.toContain(diagnostic);382 const { service } = yield* fixture(Effect.void, diagnostic);
383 expect((yield* service.state).bootingDevices).toEqual([]);383 yield* service.configure({ enabled: true });
384 }).pipe(Effect.scoped),384 const error = yield* service
385 );385 .open({
386}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);
387397
388it.effect("keeps shutdown successful when subsequent discovery fails", () =>398it.effect("keeps shutdown successful when subsequent discovery fails", () =>
389 Effect.gen(function* () {399 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+8-8
...@@ -604,8 +604,9 @@ function makeTurnInput(input: {...@@ -604,8 +604,9 @@ function makeTurnInput(input: {
604}604}
605605
606describe("AcpAdapterV2", () => {606describe("AcpAdapterV2", () => {
607 for (const outcome of ["failed", "recovered", "completed", "cancelled"] as const) {607 it.live.each(["failed", "recovered", "completed", "cancelled"] as const)(
608 it.live(`projects Mistral retry notices and their ${outcome} outcome`, () =>608 "projects Mistral retry notices and their %s outcome",
609 (outcome) =>
609 Effect.gen(function* () {610 Effect.gen(function* () {
610 const path = yield* Path.Path;611 const path = yield* Path.Path;
611 const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`);612 const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`);
...@@ -692,8 +693,7 @@ describe("AcpAdapterV2", () => {...@@ -692,8 +693,7 @@ describe("AcpAdapterV2", () => {
692 assert.equal(retries.at(-1)?.title, "Provider recovered");693 assert.equal(retries.at(-1)?.title, "Provider recovered");
693 }694 }
694 }).pipe(Effect.provide(testLayer), Effect.scoped),695 }).pipe(Effect.provide(testLayer), Effect.scoped),
695 );696 );
696 }
697697
698 it("preserves legacy ids and scopes v2 ids by provider instance", () => {698 it("preserves legacy ids and scopes v2 ids by provider instance", () => {
699 const instanceId = ProviderInstanceId.make("acp-identity-test");699 const instanceId = ProviderInstanceId.make("acp-identity-test");
...@@ -2914,8 +2914,9 @@ describe("AcpAdapterV2", () => {...@@ -2914,8 +2914,9 @@ describe("AcpAdapterV2", () => {
2914 }).pipe(Effect.provide(testLayer)),2914 }).pipe(Effect.provide(testLayer)),
2915 );2915 );
29162916
2917 for (const model of ["grok-build", "composer-2"]) {2917 it.effect.each(["grok-build", "composer-2"])(
2918 it.effect(`Grok configures the native session for ${model}`, () =>2918 "Grok configures the native session for %s",
2919 (model) =>
2919 Effect.gen(function* () {2920 Effect.gen(function* () {
2920 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;2921 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2921 const fileSystem = yield* FileSystem.FileSystem;2922 const fileSystem = yield* FileSystem.FileSystem;
...@@ -2968,8 +2969,7 @@ describe("AcpAdapterV2", () => {...@@ -2968,8 +2969,7 @@ describe("AcpAdapterV2", () => {
2968 model === "grok-build" ? 0 : 1,2969 model === "grok-build" ? 0 : 1,
2969 );2970 );
2970 }).pipe(Effect.provide(testLayer), Effect.scoped),2971 }).pipe(Effect.provide(testLayer), Effect.scoped),
2971 );2972 );
2972 }
29732973
2974 it.live("Grok reapplies an explicit return to the session's setup-time model", () =>2974 it.live("Grok reapplies an explicit return to the session's setup-time model", () =>
2975 Effect.gen(function* () {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,78 +2252,78 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2252 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),2252 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
2253 );2253 );
22542254
2255 for (const terminalReason of ["aborted_tools", "aborted_streaming"] as const) {2255 it.effect.each(
2256 for (const steered of [true, false]) {2256 (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) =>
2257 it.effect(`handles ${terminalReason} with active steering=${steered}`, () =>2257 [true, false].map((steered) => ({ terminalReason, steered })),
2258 Effect.scoped(2258 ),
2259 Effect.gen(function* () {2259 )("handles $terminalReason with active steering=$steered", ({ terminalReason, steered }) =>
2260 const harness = yield* makeWakeHarness;2260 Effect.scoped(
2261 const idAllocator = yield* IdAllocator.IdAllocatorV2;2261 Effect.gen(function* () {
2262 const attemptId = RunAttemptId.make("attempt-steering-abort");2262 const harness = yield* makeWakeHarness;
2263 const input = makeClaudeTestTurnInput({2263 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2264 threadId: harness.threadId,2264 const attemptId = RunAttemptId.make("attempt-steering-abort");
2265 providerThread: harness.providerThread,2265 const input = makeClaudeTestTurnInput({
2266 now: yield* DateTime.now,2266 threadId: harness.threadId,
2267 attemptId,2267 providerThread: harness.providerThread,
2268 text: "Audit the settings pages.",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 attachments: [],2288 attachments: [],
2270 });2289 },
2271 yield* harness.runtime.startTurn(input);2290 });
2272 if (steered) {2291 assert.equal(harness.offeredMessages[1]?.priority, "now");
2273 yield* harness.runtime.steerTurn({2292 }
2274 threadId: harness.threadId,2293 yield* Queue.offer(
2275 runId: input.runId,2294 harness.sdkMessages,
2276 providerThread: harness.providerThread,2295 makeResultFrame({
2277 providerTurnId: idAllocator.derive.providerTurn({2296 uuid: "00000000-0000-4000-8000-000000000901",
2278 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,2297 result: "",
2279 nativeTurnId: `turn:${attemptId}`,2298 terminalReason,
2280 }),2299 }),
2281 message: {2300 );
2282 createdBy: "user",2301 if (steered) {
2283 creationSource: "web",2302 yield* Queue.offer(harness.sdkMessages, wakeAssistant);
2284 messageId: MessageId.make("message-steering-abort"),2303 yield* Queue.offer(
2285 text: "Include the hierarchy mock.",2304 harness.sdkMessages,
2286 attachments: [],2305 makeResultFrame({
2287 },2306 uuid: "00000000-0000-4000-8000-000000000902",
2288 });2307 result: "Audit finished after the steer.",
2289 assert.equal(harness.offeredMessages[1]?.priority, "now");2308 }),
2290 }2309 );
2291 yield* Queue.offer(2310 }
2292 harness.sdkMessages,2311 const terminal = yield* Queue.take(harness.terminalReceipts);
2293 makeResultFrame({2312 assert.equal(terminal.status, steered ? "completed" : "interrupted");
2294 uuid: "00000000-0000-4000-8000-000000000901",2313 if (steered) {
2295 result: "",2314 assert.isTrue(
2296 terminalReason,2315 harness.events.some(
2297 }),2316 (event) =>
2298 );2317 event.type === "turn_item.updated" &&
2299 if (steered) {2318 event.turnItem.type === "assistant_message" &&
2300 yield* Queue.offer(harness.sdkMessages, wakeAssistant);2319 event.turnItem.text === WAKE_ASSISTANT_TEXT,
2301 yield* Queue.offer(2320 ),
2302 harness.sdkMessages,2321 );
2303 makeResultFrame({2322 }
2304 uuid: "00000000-0000-4000-8000-000000000902",2323 assert.lengthOf(harness.terminalEvents(), 1);
2305 result: "Audit finished after the steer.",2324 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
2306 }),2325 ),
2307 );2326 );
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 }
23272327
2328 it.effect("announces usage-limit pauses once per window and again on a new turn", () =>2328 it.effect("announces usage-limit pauses once per window and again on a new turn", () =>
2329 Effect.gen(function* () {2329 Effect.gen(function* () {
...@@ -3187,7 +3187,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3187,7 +3187,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3187 ),3187 ),
3188 );3188 );
31893189
3190 for (const terminalReason of [3190 it.effect.each([
3191 "api_error",3191 "api_error",
3192 "malformed_tool_use_exhausted",3192 "malformed_tool_use_exhausted",
3193 "budget_exhausted",3193 "budget_exhausted",
...@@ -3200,56 +3200,54 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3200,56 +3200,54 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3200 "image_error",3200 "image_error",
3201 "model_error",3201 "model_error",
3202 "overloaded_status",3202 "overloaded_status",
3203 ] as const) {3203 ] as const)("fails a success-shaped Claude result with %s", (terminalReason) =>
3204 it.effect(`fails a success-shaped Claude result with ${terminalReason}`, () =>3204 Effect.scoped(
3205 Effect.scoped(3205 Effect.gen(function* () {
3206 Effect.gen(function* () {3206 const harness = yield* makeWakeHarness;
3207 const harness = yield* makeWakeHarness;3207 const now = yield* DateTime.now;
3208 const now = yield* DateTime.now;3208 yield* harness.runtime.startTurn(
3209 yield* harness.runtime.startTurn(3209 makeClaudeTestTurnInput({
3210 makeClaudeTestTurnInput({3210 threadId: harness.threadId,
3211 threadId: harness.threadId,3211 providerThread: harness.providerThread,
3212 providerThread: harness.providerThread,3212 now,
3213 now,3213 attemptId: RunAttemptId.make("attempt-structured-terminal-failure"),
3214 attemptId: RunAttemptId.make("attempt-structured-terminal-failure"),3214 text: "Complete the task.",
3215 text: "Complete the task.",3215 attachments: [],
3216 attachments: [],3216 }),
3217 }),3217 );
3218 );3218 yield* Queue.offer(
3219 yield* Queue.offer(3219 harness.sdkMessages,
3220 harness.sdkMessages,3220 makeResultFrame({
3221 makeResultFrame({3221 uuid: "00000000-0000-4000-8000-000000000205",
3222 uuid: "00000000-0000-4000-8000-000000000205",3222 result: "Provider failure details.",
3223 result: "Provider failure details.",3223 isError: false,
3224 isError: false,3224 ...(terminalReason === "overloaded_status"
3225 ...(terminalReason === "overloaded_status"3225 ? { apiErrorStatus: 529 }
3226 ? { apiErrorStatus: 529 }3226 : { terminalReason }),
3227 : { terminalReason }),3227 }),
3228 }),3228 );
3229 );3229 const terminal = yield* Queue.take(harness.terminalReceipts);
3230 const terminal = yield* Queue.take(harness.terminalReceipts);3230 assert.equal(terminal.status, "failed");
3231 assert.equal(terminal.status, "failed");3231 if (terminal.status !== "failed") return;
3232 if (terminal.status !== "failed") return;3232 assert.isNotEmpty(terminal.failure.message);
3233 assert.isNotEmpty(terminal.failure.message);3233 assert.equal(
3234 assert.equal(3234 terminal.failure.class,
3235 terminal.failure.class,3235 terminalReason === "blocking_limit" ? "usage_limit" : "provider_error",
3236 terminalReason === "blocking_limit" ? "usage_limit" : "provider_error",3236 );
3237 );3237 assert.isFalse(
3238 assert.isFalse(3238 harness.events.some(
3239 harness.events.some(3239 (event) =>
3240 (event) =>3240 event.type === "message.updated" &&
3241 event.type === "message.updated" &&3241 event.message.text === "Provider failure details.",
3242 event.message.text === "Provider failure details.",3242 ),
3243 ),3243 );
3244 );3244 assert.equal(
3245 assert.equal(3245 terminal.failure.code,
3246 terminal.failure.code,3246 terminalReason === "overloaded_status" ? "api_error_529" : terminalReason,
3247 terminalReason === "overloaded_status" ? "api_error_529" : terminalReason,3247 );
3248 );3248 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
3249 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),3249 ),
3250 ),3250 );
3251 );
3252 }
32533251
3254 const providerThreadRosterEvents = (events: ReadonlyArray<ProviderAdapterV2Event>) =>3252 const providerThreadRosterEvents = (events: ReadonlyArray<ProviderAdapterV2Event>) =>
3255 events.filter(3253 events.filter(
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+317-316
...@@ -1728,8 +1728,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -1728,8 +1728,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
1728 };1728 };
1729 });1729 });
17301730
1731 for (const response of ["supported", "unsupported", "invalid"] as const) {1731 it.effect.each(["supported", "unsupported", "invalid"] as const)(
1732 it.effect(`delivers native history with ${response} app-server protocol`, () =>1732 "delivers native history with %s app-server protocol",
1733 (response) =>
1733 Effect.gen(function* () {1734 Effect.gen(function* () {
1734 const nativeThreadId = `inject-${response}`;1735 const nativeThreadId = `inject-${response}`;
1735 const prompt = "Only the current request";1736 const prompt = "Only the current request";
...@@ -1830,8 +1831,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -1830,8 +1831,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
1830 assert.equal(requests.filter((method) => method === "turn/start").length, 1);1831 assert.equal(requests.filter((method) => method === "turn/start").length, 1);
1831 assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start"));1832 assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start"));
1832 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),1833 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
1833 );1834 );
1834 }
18351835
1836 it.effect("identifies sessions to Codex with the same client info as main", () =>1836 it.effect("identifies sessions to Codex with the same client info as main", () =>
1837 Effect.gen(function* () {1837 Effect.gen(function* () {
...@@ -2926,8 +2926,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2926,8 +2926,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2926 ),2926 ),
2927 );2927 );
29282928
2929 for (const terminalStatus of ["completed", "interrupted"] as const) {2929 it.effect.each(["completed", "interrupted"] as const)(
2930 it.effect(`retains Codex reasoning parts when the turn is ${terminalStatus}`, () =>2930 "retains Codex reasoning parts when the turn is %s",
2931 (terminalStatus) =>
2931 Effect.scoped(2932 Effect.scoped(
2932 Effect.gen(function* () {2933 Effect.gen(function* () {
2933 const scenario = `codex-reasoning-${terminalStatus}`;2934 const scenario = `codex-reasoning-${terminalStatus}`;
...@@ -3087,8 +3088,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3087,8 +3088,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3087 assert.deepEqual(assistantMessages(harness.events), []);3088 assert.deepEqual(assistantMessages(harness.events), []);
3088 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),3089 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
3089 ),3090 ),
3090 );3091 );
3091 }
30923092
3093 const finalAnswerTranscript = (3093 const finalAnswerTranscript = (
3094 scenario: string,3094 scenario: string,
...@@ -3714,9 +3714,10 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3714,9 +3714,10 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3714 ),3714 ),
3715 );3715 );
37163716
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 const stillRunning = terminated === "still_running";3719 const stillRunning = terminated === "still_running";
3719 const transcript = makeCodexReplayTranscript({3720 return makeCodexReplayTranscript({
3720 scenario: `codex-bg-stop-${terminated}`,3721 scenario: `codex-bg-stop-${terminated}`,
3721 entries: [3722 entries: [
3722 ...backgroundExecTranscript.entries.slice(0, -1),3723 ...backgroundExecTranscript.entries.slice(0, -1),
...@@ -3796,9 +3797,14 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3796,9 +3797,14 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3796 backgroundExecTranscript.entries.at(-1)!,3797 backgroundExecTranscript.entries.at(-1)!,
3797 ],3798 ],
3798 });3799 });
3800 };
37993801
3800 it.effect(`stops a command after root completion when termination returns ${terminated}`, () =>3802 it.effect.each(backgroundStopCases)(
3801 Effect.scoped(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 Effect.gen(function* () {3808 Effect.gen(function* () {
3803 const stopped = yield* Deferred.make<void>();3809 const stopped = yield* Deferred.make<void>();
3804 const harness = yield* makeCodexReplayHarness(transcript, (event) =>3810 const harness = yield* makeCodexReplayHarness(transcript, (event) =>
...@@ -3852,140 +3858,137 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3852,140 +3858,137 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3852 assert.equal(harness.terminalEvents()[0]?.status, "completed");3858 assert.equal(harness.terminalEvents()[0]?.status, "completed");
3853 assert.lengthOf(harness.continuationRequests, 0);3859 assert.lengthOf(harness.continuationRequests, 0);
3854 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),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 }3862 },
3988 }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 });
39893992
3990 // The app-server exits after the root turn, before the command's own3993 // The app-server exits after the root turn, before the command's own
3991 // item/completed (Codex always sends one, so only a lost notification or a3994 // item/completed (Codex always sends one, so only a lost notification or a
...@@ -5506,7 +5509,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -5506,7 +5509,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
5506 ),5509 ),
5507 );5510 );
55085511
5509 for (const scenario of [5512 it.effect.each([
5510 {5513 {
5511 name: "usage",5514 name: "usage",
5512 code: "usageLimitExceeded",5515 code: "usageLimitExceeded",
...@@ -5557,192 +5560,190 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -5557,192 +5560,190 @@ describe("CodexAdapterV2 post-settle continuation", () => {
5557 expectedClass: "usage_limit",5560 expectedClass: "usage_limit",
5558 },5561 },
5559 { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" },5562 { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" },
5560 ] as const) {5563 ] as const)("classifies Codex terminal failures from $name evidence", (scenario) =>
5561 it.effect(`classifies Codex terminal failures from ${scenario.name} evidence`, () =>5564 Effect.scoped(
5562 Effect.scoped(5565 Effect.gen(function* () {
5563 Effect.gen(function* () {5566 const nativeThreadId = `native-limit-${scenario.name}`;
5564 const nativeThreadId = `native-limit-${scenario.name}`;5567 const nativeTurnId = `turn-limit-${scenario.name}`;
5565 const nativeTurnId = `turn-limit-${scenario.name}`;5568 const message = "Provider stopped this request.";
5566 const message = "Provider stopped this request.";5569 const resetAt = "2033-05-19T07:20:00.000Z";
5567 const resetAt = "2033-05-19T07:20:00.000Z";5570 const snapshot = {
5568 const snapshot = {5571 type: "emit_inbound" as const,
5569 type: "emit_inbound" as const,5572 label: "account/rateLimits/updated",
5570 label: "account/rateLimits/updated",5573 frame: {
5571 frame: {5574 method: "account/rateLimits/updated",
5572 method: "account/rateLimits/updated",5575 params: {
5573 params: {5576 rateLimits: {
5574 rateLimits: {5577 limitId: "codex",
5575 limitId: "codex",5578 primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 },
5576 primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 },
5577 },
5578 },5579 },
5579 },5580 },
5580 };5581 },
5581 const transcript = makeCodexReplayTranscript({5582 };
5582 scenario: `codex-limit-${scenario.name}`,5583 const transcript = makeCodexReplayTranscript({
5583 entries: [5584 scenario: `codex-limit-${scenario.name}`,
5584 ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }),5585 entries: [
5585 ...(scenario.name === "known-reset" || scenario.name === "deferred-reset"5586 ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }),
5586 ? [snapshot]5587 ...(scenario.name === "known-reset" || scenario.name === "deferred-reset"
5587 : []),5588 ? [snapshot]
5588 ...(scenario.name === "deferred-reset"5589 : []),
5589 ? [5590 ...(scenario.name === "deferred-reset"
5590 {5591 ? [
5591 type: "emit_inbound" as const,5592 {
5592 label: "item/completed/subAgentActivity-started",5593 type: "emit_inbound" as const,
5593 frame: {5594 label: "item/completed/subAgentActivity-started",
5594 method: "item/completed",5595 frame: {
5595 params: {5596 method: "item/completed",
5596 threadId: nativeThreadId,5597 params: {
5597 turnId: nativeTurnId,5598 threadId: nativeThreadId,
5598 item: {5599 turnId: nativeTurnId,
5599 type: "subAgentActivity",5600 item: {
5600 id: "limit-child-spawn",5601 type: "subAgentActivity",
5601 kind: "started",5602 id: "limit-child-spawn",
5602 agentThreadId: "native-limit-child",5603 kind: "started",
5603 agentPath: "/root/limit_child",5604 agentThreadId: "native-limit-child",
5604 },5605 agentPath: "/root/limit_child",
5605 },5606 },
5606 },5607 },
5607 },5608 },
5608 {5609 },
5609 type: "emit_inbound" as const,5610 {
5610 label: "turn/started/child",5611 type: "emit_inbound" as const,
5611 frame: {5612 label: "turn/started/child",
5612 method: "turn/started",5613 frame: {
5613 params: {5614 method: "turn/started",
5614 threadId: "native-limit-child",5615 params: {
5615 turn: makeCodexReplayTurn({5616 threadId: "native-limit-child",
5616 id: "limit-child-turn",5617 turn: makeCodexReplayTurn({
5617 status: "inProgress",5618 id: "limit-child-turn",
5618 }),5619 status: "inProgress",
5619 },5620 }),
5620 },5621 },
5621 },5622 },
5622 ]5623 },
5623 : []),5624 ]
5624 ...(scenario.notification5625 : []),
5625 ? [5626 ...(scenario.notification
5626 {5627 ? [
5627 type: "emit_inbound" as const,5628 {
5628 label: "error",5629 type: "emit_inbound" as const,
5629 frame: {5630 label: "error",
5630 method: "error",5631 frame: {
5631 params: {5632 method: "error",
5632 threadId: nativeThreadId,5633 params: {
5633 turnId: nativeTurnId,5634 threadId: nativeThreadId,
5634 willRetry: scenario.name === "retry",5635 turnId: nativeTurnId,
5635 error: {5636 willRetry: scenario.name === "retry",
5636 message,5637 error: {
5637 codexErrorInfo: scenario.code,5638 message,
5638 additionalDetails:5639 codexErrorInfo: scenario.code,
5639 scenario.name === "matching-details"5640 additionalDetails:
5640 ? "Detailed provider allowance explanation."5641 scenario.name === "matching-details"
5641 : null,5642 ? "Detailed provider allowance explanation."
5642 },5643 : null,
5643 },5644 },
5644 },5645 },
5645 },5646 },
5646 ]5647 },
5647 : []),5648 ]
5648 {5649 : []),
5649 type: "emit_inbound",5650 {
5650 label: "turn/completed",5651 type: "emit_inbound",
5651 frame: {5652 label: "turn/completed",
5652 method: "turn/completed",5653 frame: {
5653 params: {5654 method: "turn/completed",
5654 threadId: nativeThreadId,5655 params: {
5655 turn: {5656 threadId: nativeThreadId,
5656 ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }),5657 turn: {
5657 error: {5658 ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }),
5658 message: scenario.name === "replacement" ? "A different failure." : message,5659 error: {
5659 ...(scenario.notification && scenario.name !== "matching-details"5660 message: scenario.name === "replacement" ? "A different failure." : message,
5660 ? {}5661 ...(scenario.notification && scenario.name !== "matching-details"
5661 : { codexErrorInfo: scenario.code }),5662 ? {}
5662 },5663 : { codexErrorInfo: scenario.code }),
5663 },5664 },
5664 },5665 },
5665 },5666 },
5666 },5667 },
5667 ...(scenario.name === "late-reset" ? [snapshot] : []),5668 },
5668 ...(scenario.name === "deferred-reset"5669 ...(scenario.name === "late-reset" ? [snapshot] : []),
5669 ? [5670 ...(scenario.name === "deferred-reset"
5670 {5671 ? [
5671 ...snapshot,5672 {
5672 frame: {5673 ...snapshot,
5673 method: "account/rateLimits/updated",5674 frame: {
5674 params: {5675 method: "account/rateLimits/updated",
5675 rateLimits: {5676 params: {
5676 limitId: "codex",5677 rateLimits: {
5677 primary: {5678 limitId: "codex",
5678 usedPercent: 100,5679 primary: {
5679 resetsAt: 2000200000,5680 usedPercent: 100,
5680 windowDurationMins: 300,5681 resetsAt: 2000200000,
5681 },5682 windowDurationMins: 300,
5682 },5683 },
5683 },5684 },
5684 },5685 },
5685 },5686 },
5686 {5687 },
5687 type: "emit_inbound" as const,5688 {
5688 label: "turn/completed/child",5689 type: "emit_inbound" as const,
5689 frame: {5690 label: "turn/completed/child",
5690 method: "turn/completed",5691 frame: {
5691 params: {5692 method: "turn/completed",
5692 threadId: "native-limit-child",5693 params: {
5693 turn: makeCodexReplayTurn({5694 threadId: "native-limit-child",
5694 id: "limit-child-turn",5695 turn: makeCodexReplayTurn({
5695 status: "completed",5696 id: "limit-child-turn",
5696 }),5697 status: "completed",
5697 },5698 }),
5698 },5699 },
5699 },5700 },
5700 ]5701 },
5701 : []),5702 ]
5702 ],5703 : []),
5703 });5704 ],
5704 const resetReceipt = yield* Deferred.make<void>();5705 });
5705 const harness = yield* makeCodexReplayHarness(transcript, (event) =>5706 const resetReceipt = yield* Deferred.make<void>();
5706 event.type === "turn_item.updated" &&5707 const harness = yield* makeCodexReplayHarness(transcript, (event) =>
5707 event.turnItem.type === "error" &&5708 event.type === "turn_item.updated" &&
5708 event.turnItem.failure.resetAt === resetAt5709 event.turnItem.type === "error" &&
5709 ? Deferred.succeed(resetReceipt, undefined)5710 event.turnItem.failure.resetAt === resetAt
5710 : Effect.void,5711 ? Deferred.succeed(resetReceipt, undefined)
5711 );5712 : Effect.void,
5712 yield* harness.runtime.startTurn(5713 );
5713 makeCodexTestTurnInput({5714 yield* harness.runtime.startTurn(
5714 threadId: harness.threadId,5715 makeCodexTestTurnInput({
5715 providerThread: harness.providerThread,5716 threadId: harness.threadId,
5716 now: yield* DateTime.now,5717 providerThread: harness.providerThread,
5717 text: "Continue.",5718 now: yield* DateTime.now,
5718 attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`),5719 text: "Continue.",
5719 }),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;5741 assert.isDefined(item);
5722 const terminal = harness.terminalEvents()[0];5742 }
5723 assert.equal(terminal?.status, "failed");5743 if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1);
5724 if (terminal?.status !== "failed") return;5744 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
5725 assert.equal(terminal.failure.class, scenario.expectedClass);5745 ),
5726 assert.equal(terminal.threadDisposition, "reusable");5746 );
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 }
57465747
5747 const FAILED_SCENARIO = "codex-failed-mid-command";5748 const FAILED_SCENARIO = "codex-failed-mid-command";
5748 const FAILED_NATIVE_THREAD = "native-codex-failed-thread";5749 const FAILED_NATIVE_THREAD = "native-codex-failed-thread";
...@@ -6511,7 +6512,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6511,7 +6512,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6511 ),6512 ),
6512 );6513 );
65136514
6514 for (const [nativeStatus, expectedStatus] of [6515 it.effect.each([
6515 ["pendingInit", "pending"],6516 ["pendingInit", "pending"],
6516 ["running", "running"],6517 ["running", "running"],
6517 ["interrupted", "interrupted"],6518 ["interrupted", "interrupted"],
...@@ -6523,8 +6524,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6523,8 +6524,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6523 ["late-activity-completed", "completed"],6524 ["late-activity-completed", "completed"],
6524 ["stale-running", "completed"],6525 ["stale-running", "completed"],
6525 ["duplicate-completed", "completed"],6526 ["duplicate-completed", "completed"],
6526 ] as const) {6527 ] as const)(
6527 it.effect(`normalizes subagent ${nativeStatus} without losing its lifecycle`, () =>6528 "normalizes subagent %s without losing its lifecycle",
6529 ([nativeStatus, expectedStatus]) =>
6528 Effect.scoped(6530 Effect.scoped(
6529 Effect.gen(function* () {6531 Effect.gen(function* () {
6530 const marker = yield* Deferred.make<void>();6532 const marker = yield* Deferred.make<void>();
...@@ -6670,8 +6672,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6670,8 +6672,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6670 }6672 }
6671 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),6673 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
6672 ),6674 ),
6673 );6675 );
6674 }
66756676
6676 const codexReplayThreadResult = (input: {6677 const codexReplayThreadResult = (input: {
6677 readonly nativeThreadId: string;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,12 +39,13 @@ import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAge
39const decodeCursorSettings = Schema.decodeEffect(CursorSettings);39const decodeCursorSettings = Schema.decodeEffect(CursorSettings);
4040
41describe("CursorAdapterV2", () => {41describe("CursorAdapterV2", () => {
42 for (const { status, model } of [42 it.effect.each([
43 { status: "finished", model: undefined },43 { status: "finished", model: undefined },
44 { status: "cancelled", model: "claude-opus-4-6" },44 { status: "cancelled", model: "claude-opus-4-6" },
45 { status: "error", model: "custom-fable" },45 { status: "error", model: "custom-fable" },
46 ] as const) {46 ] as const)(
47 it.effect(`settles missing task completions when the Cursor run is ${status}`, () =>47 "settles missing task completions when the Cursor run is $status",
48 ({ status, model }) =>
48 Effect.gen(function* () {49 Effect.gen(function* () {
49 const fileSystem = yield* FileSystem.FileSystem;50 const fileSystem = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;51 const path = yield* Path.Path;
...@@ -185,8 +186,7 @@ describe("CursorAdapterV2", () => {...@@ -185,8 +186,7 @@ describe("CursorAdapterV2", () => {
185 );186 );
186 assert.isNotNull(rows.at(-1)?.subagent.completedAt);187 assert.isNotNull(rows.at(-1)?.subagent.completedAt);
187 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),188 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
188 );189 );
189 }
190190
191 it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () =>191 it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () =>
192 Effect.gen(function* () {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,17 +371,19 @@ describe("Grok launch permission mode", () => {
371 ...override,371 ...override,
372 });372 });
373373
374 for (const [runtimeMode, args] of [374 it.effect.each(
375 ["approval-required", ["--permission-mode", "default", "agent", "stdio"]],375 (
376 ["auto", ["--permission-mode", "auto", "agent", "stdio"]],376 [
377 ["full-access", ["agent", "--always-approve", "stdio"]],377 ["approval-required", ["--permission-mode", "default", "agent", "stdio"]],
378 ] as const) {378 ["auto", ["--permission-mode", "auto", "agent", "stdio"]],
379 it.effect(`launches ${runtimeMode} threads with ${args.join(" ")}`, () =>379 ["full-access", ["agent", "--always-approve", "stdio"]],
380 Effect.gen(function* () {380 ] as const
381 assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]);381 ).map(([runtimeMode, args]) => [runtimeMode, args.join(" "), args] as const),
382 }),382 )("launches %s threads with %s", ([runtimeMode, , args]) =>
383 );383 Effect.gen(function* () {
384 }384 assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]);
385 }),
386 );
385387
386 it.effect("launches a thread stored as Auto-accept edits asking", () =>388 it.effect("launches a thread stored as Auto-accept edits asking", () =>
387 Effect.gen(function* () {389 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+158-161
...@@ -3090,81 +3090,78 @@ describe("OpenCode2 adapter", () => {...@@ -3090,81 +3090,78 @@ describe("OpenCode2 adapter", () => {
3090 }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)),3090 }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)),
3091 );3091 );
30923092
3093 for (const running of [true, false]) {3093 it.effect.each([
3094 it.effect(3094 ["refuses a rollback while a timed-out Stop's run is still going", true],
3095 running3095 ["rolls back once a timed-out Stop's run has left the server", false],
3096 ? "refuses a rollback while a timed-out Stop's run is still going"3096 ] as const)("%s", ([, running]) =>
3097 : "rolls back once a timed-out Stop's run has left the server",3097 Effect.gen(function* () {
3098 () =>3098 const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`;
3099 Effect.gen(function* () {3099 const { runtime, thread } = yield* resumed([
3100 const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`;3100 ...stopTimedOut,
3101 const { runtime, thread } = yield* resumed([3101 // The server says whether the stopped run still goes.
3102 ...stopTimedOut,3102 out("session.active"),
3103 // The server says whether the stopped run still goes.3103 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
3104 out("session.active"),3104 // Gone: the cut is made. Running: nothing is read or cut meanwhile.
3105 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),3105 ...(running
3106 // Gone: the cut is made. Running: nothing is read or cut meanwhile.3106 ? []
3107 ...(running3107 : [
3108 ? []3108 out("message.list", "<any>"),
3109 : [3109 reply("message.list", {
3110 out("message.list", "<any>"),3110 data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }],
3111 reply("message.list", {3111 cursor: {},
3112 data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }],3112 }),
3113 cursor: {},3113 out("session.revert.stage", {
3114 }),3114 sessionID: SESSION,
3115 out("session.revert.stage", {3115 messageID: prompt,
3116 sessionID: SESSION,3116 files: false,
3117 messageID: prompt,3117 }),
3118 files: false,3118 replyData("session.revert.stage", { messageID: prompt, files: [] }),
3119 }),3119 out("session.revert.commit", { sessionID: SESSION }),
3120 replyData("session.revert.stage", { messageID: prompt, files: [] }),3120 reply("session.revert.commit", null),
3121 out("session.revert.commit", { sessionID: SESSION }),3121 out("message.list", "<any>"),
3122 reply("session.revert.commit", null),3122 reply("message.list", { data: [], cursor: {} }),
3123 out("message.list", "<any>"),3123 ]),
3124 reply("message.list", { data: [], cursor: {} }),3124 ]);
3125 ]),3125 yield* stopFirstTurn(runtime, thread);
3126 ]);3126 const now = yield* DateTime.now;
3127 yield* stopFirstTurn(runtime, thread);3127 const first = {
3128 const now = yield* DateTime.now;3128 id: yield* providerTurnId,
3129 const first = {3129 providerThreadId: thread.id,
3130 id: yield* providerTurnId,3130 nodeId: NodeId.make("node:opencode2-adapter"),
3131 providerThreadId: thread.id,3131 runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"),
3132 nodeId: NodeId.make("node:opencode2-adapter"),3132 nativeTurnRef: {
3133 runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"),3133 driver: OPENCODE_PROVIDER,
3134 nativeTurnRef: {3134 nativeId: prompt,
3135 driver: OPENCODE_PROVIDER,3135 strength: "weak" as const,
3136 nativeId: prompt,3136 },
3137 strength: "weak" as const,3137 ordinal: 1,
3138 },3138 status: "interrupted" as const,
3139 ordinal: 1,3139 startedAt: now,
3140 status: "interrupted" as const,3140 completedAt: now,
3141 startedAt: now,3141 };
3142 completedAt: now,3142 const rollback = yield* runtime
3143 };3143 .rollbackThread({
3144 const rollback = yield* runtime3144 providerThread: thread,
3145 .rollbackThread({3145 target: {
3146 providerThread: thread,3146 type: "thread_start",
3147 target: {3147 checkpointId: CheckpointId.make("checkpoint:start"),
3148 type: "thread_start",3148 appRunOrdinal: 0,
3149 checkpointId: CheckpointId.make("checkpoint:start"),3149 },
3150 appRunOrdinal: 0,3150 providerThreadTurns: [first],
3151 },3151 })
3152 providerThreadTurns: [first],3152 .pipe(Effect.exit);
3153 })3153 if (running) {
3154 .pipe(Effect.exit);3154 assert.isTrue(Exit.isFailure(rollback));
3155 if (running) {3155 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
3156 assert.isTrue(Exit.isFailure(rollback));3156 assert.equal(
3157 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;3157 (error as { _tag?: string } | undefined)?._tag,
3158 assert.equal(3158 "ProviderAdapterProtocolError",
3159 (error as { _tag?: string } | undefined)?._tag,3159 );
3160 "ProviderAdapterProtocolError",3160 } else {
3161 );3161 assert.isTrue(Exit.isSuccess(rollback));
3162 } else {3162 }
3163 assert.isTrue(Exit.isSuccess(rollback));3163 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
3164 }3164 );
3165 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
3166 );
3167 }
31683165
3169 it.effect("takes back a stranded steer again when its first cancel failed", () =>3166 it.effect("takes back a stranded steer again when its first cancel failed", () =>
3170 Effect.gen(function* () {3167 Effect.gen(function* () {
...@@ -3376,92 +3373,92 @@ describe("OpenCode2 adapter", () => {...@@ -3376,92 +3373,92 @@ describe("OpenCode2 adapter", () => {
3376 }).pipe(Effect.scoped),3373 }).pipe(Effect.scoped),
3377 );3374 );
33783375
3379 for (const running of [true, false]) {3376 it.effect.each([
3380 it.effect(3377 ["refuses to roll back a session not loaded yet while the server runs it", true],
3381 running3378 [
3382 ? "refuses to roll back a session not loaded yet while the server runs it"3379 "keeps the recorded turns before the target when rolling back a session not loaded yet",
3383 : "keeps the recorded turns before the target when rolling back a session not loaded yet",3380 false,
3384 () =>3381 ],
3385 Effect.gen(function* () {3382 ] as const)("%s", ([, running]) =>
3386 const first = `msg_t3_turn_${SESSION}:attempt:first`;3383 Effect.gen(function* () {
3387 const second = `msg_t3_turn_${SESSION}:attempt:second`;3384 const first = `msg_t3_turn_${SESSION}:attempt:first`;
3388 // A runtime that never loaded the session, as after a T3 restart3385 const second = `msg_t3_turn_${SESSION}:attempt:second`;
3389 // against a server that kept running.3386 // A runtime that never loaded the session, as after a T3 restart
3390 const runtime = yield* openCode2ReplayRuntime([3387 // against a server that kept running.
3391 ...opening,3388 const runtime = yield* openCode2ReplayRuntime([
3392 out("session.active"),3389 ...opening,
3393 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),3390 out("session.active"),
3394 ...(running3391 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
3395 ? []3392 ...(running
3396 : [3393 ? []
3397 out("session.get", { sessionID: SESSION }),3394 : [
3398 replyData("session.get", sessionInfo()),3395 out("session.get", { sessionID: SESSION }),
3399 ...noOpenRequests,3396 replyData("session.get", sessionInfo()),
3400 out("message.list", "<any>"),3397 ...noOpenRequests,
3401 reply("message.list", {3398 out("message.list", "<any>"),
3402 data: [3399 reply("message.list", {
3403 { id: second, time: { created: 2 }, text: "second", type: "user" },3400 data: [
3404 { id: first, time: { created: 1 }, text: "first", type: "user" },3401 { id: second, time: { created: 2 }, text: "second", type: "user" },
3405 ],3402 { id: first, time: { created: 1 }, text: "first", type: "user" },
3406 cursor: {},3403 ],
3407 }),3404 cursor: {},
3408 out("session.revert.stage", {3405 }),
3409 sessionID: SESSION,3406 out("session.revert.stage", {
3410 messageID: second,3407 sessionID: SESSION,
3411 files: false,3408 messageID: second,
3412 }),3409 files: false,
3413 replyData("session.revert.stage", { messageID: second, files: [] }),3410 }),
3414 out("session.revert.commit", { sessionID: SESSION }),3411 replyData("session.revert.stage", { messageID: second, files: [] }),
3415 reply("session.revert.commit", null),3412 out("session.revert.commit", { sessionID: SESSION }),
3416 out("message.list", "<any>"),3413 reply("session.revert.commit", null),
3417 reply("message.list", {3414 out("message.list", "<any>"),
3418 data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }],3415 reply("message.list", {
3419 cursor: {},3416 data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }],
3420 }),3417 cursor: {},
3421 ]),3418 }),
3422 ]);3419 ]),
3423 const thread = providerThread(yield* DateTime.now);3420 ]);
3424 const now = yield* DateTime.now;3421 const thread = providerThread(yield* DateTime.now);
3425 const recorded = (key: string, ordinal: number, nativeId: string) => ({3422 const now = yield* DateTime.now;
3426 id: ProviderTurnId.make(`provider-turn:${key}`),3423 const recorded = (key: string, ordinal: number, nativeId: string) => ({
3427 providerThreadId: thread.id,3424 id: ProviderTurnId.make(`provider-turn:${key}`),
3428 nodeId: NodeId.make(`node:${key}`),3425 providerThreadId: thread.id,
3429 runAttemptId: RunAttemptId.make(`attempt:${key}`),3426 nodeId: NodeId.make(`node:${key}`),
3430 nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const },3427 runAttemptId: RunAttemptId.make(`attempt:${key}`),
3431 ordinal,3428 nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const },
3432 status: "completed" as const,3429 ordinal,
3433 startedAt: now,3430 status: "completed" as const,
3434 completedAt: now,3431 startedAt: now,
3435 });3432 completedAt: now,
3436 const kept = recorded("first", 1, first);3433 });
3437 const rollback = yield* runtime3434 const kept = recorded("first", 1, first);
3438 .rollbackThread({3435 const rollback = yield* runtime
3439 providerThread: thread,3436 .rollbackThread({
3440 target: {3437 providerThread: thread,
3441 type: "provider_turn",3438 target: {
3442 checkpointId: CheckpointId.make("checkpoint:first"),3439 type: "provider_turn",
3443 appRunOrdinal: 1,3440 checkpointId: CheckpointId.make("checkpoint:first"),
3444 providerTurn: kept,3441 appRunOrdinal: 1,
3445 },3442 providerTurn: kept,
3446 providerThreadTurns: [kept, recorded("second", 2, second)],3443 },
3447 })3444 providerThreadTurns: [kept, recorded("second", 2, second)],
3448 .pipe(Effect.exit);3445 })
3449 if (running) {3446 .pipe(Effect.exit);
3450 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;3447 if (running) {
3451 assert.equal(3448 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
3452 (error as { _tag?: string } | undefined)?._tag,3449 assert.equal(
3453 "ProviderAdapterProtocolError",3450 (error as { _tag?: string } | undefined)?._tag,
3454 );3451 "ProviderAdapterProtocolError",
3455 } else {3452 );
3456 assert.isTrue(Exit.isSuccess(rollback));3453 } else {
3457 assert.deepEqual(3454 assert.isTrue(Exit.isSuccess(rollback));
3458 Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [],3455 assert.deepEqual(
3459 [kept.id],3456 Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [],
3460 );3457 [kept.id],
3461 }3458 );
3462 }).pipe(Effect.scoped),3459 }
3463 );3460 }).pipe(Effect.scoped),
3464 }3461 );
34653462
3466 it.effect("refuses to fork a session while OpenCode runs a follow-up on it", () =>3463 it.effect("refuses to fork a session while OpenCode runs a follow-up on it", () =>
3467 Effect.gen(function* () {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,8 +237,9 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct
237});237});
238238
239describe("OpenCodeAdapterV2", () => {239describe("OpenCodeAdapterV2", () => {
240 for (const ending of ["completed", "failed", "unresolved", "unavailable", "reconnect"] as const) {240 it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)(
241 it.effect(`normalizes OpenCode step usage for ${ending} turns`, () =>241 "normalizes OpenCode step usage for %s turns",
242 (ending) =>
242 Effect.gen(function* () {243 Effect.gen(function* () {
243 const nativeEvents = asyncEventStream();244 const nativeEvents = asyncEventStream();
244 let promptId = "";245 let promptId = "";
...@@ -375,11 +376,11 @@ describe("OpenCodeAdapterV2", () => {...@@ -375,11 +376,11 @@ describe("OpenCodeAdapterV2", () => {
375 },376 },
376 );377 );
377 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),378 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
378 );379 );
379 }
380380
381 for (const kind of ["permission", "question"] as const) {381 it.effect.each(["permission", "question"] as const)(
382 it.effect(`cancels an undelivered ${kind} reply at its deadline`, () =>382 "cancels an undelivered %s reply at its deadline",
383 (kind) =>
383 Effect.gen(function* () {384 Effect.gen(function* () {
384 const nativeEvents = asyncEventStream();385 const nativeEvents = asyncEventStream();
385 const called = promiseGate<void>();386 const called = promiseGate<void>();
...@@ -453,8 +454,7 @@ describe("OpenCodeAdapterV2", () => {...@@ -453,8 +454,7 @@ describe("OpenCodeAdapterV2", () => {
453 // Failed delivery leaves the request available for an explicit retry.454 // Failed delivery leaves the request available for an explicit retry.
454 yield* harness.runtime.respondToRuntimeRequest(response);455 yield* harness.runtime.respondToRuntimeRequest(response);
455 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),456 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
456 );457 );
457 }
458458
459 it.effect("aborts external root and descendants before closing the event stream", () =>459 it.effect("aborts external root and descendants before closing the event stream", () =>
460 Effect.gen(function* () {460 Effect.gen(function* () {
...@@ -494,8 +494,9 @@ describe("OpenCodeAdapterV2", () => {...@@ -494,8 +494,9 @@ describe("OpenCodeAdapterV2", () => {
494 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),494 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
495 );495 );
496496
497 for (const failure of ["enumeration", "abort", "not-found", "timeout"] as const) {497 it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)(
498 it.effect(`reports descendant cleanup ${failure}`, () =>498 "reports descendant cleanup %s",
499 (failure) =>
499 Effect.gen(function* () {500 Effect.gen(function* () {
500 const nativeEvents = asyncEventStream();501 const nativeEvents = asyncEventStream();
501 const called = promiseGate<void>();502 const called = promiseGate<void>();
...@@ -545,8 +546,7 @@ describe("OpenCodeAdapterV2", () => {...@@ -545,8 +546,7 @@ describe("OpenCodeAdapterV2", () => {
545 assert.equal(Exit.isSuccess(result), failure === "not-found");546 assert.equal(Exit.isSuccess(result), failure === "not-found");
546 if (failure === "timeout") assert.isTrue(childSignal?.aborted);547 if (failure === "timeout") assert.isTrue(childSignal?.aborted);
547 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),548 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
548 );549 );
549 }
550550
551 it.effect(551 it.effect(
552 "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads",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,8 +589,9 @@ describe("PiAdapterV2", () => {
589 }).pipe(Effect.scoped, Effect.provide(testLayer)),589 }).pipe(Effect.scoped, Effect.provide(testLayer)),
590 );590 );
591591
592 for (const invalidReplacement of ["veto", "same identity"] as const) {592 it.effect.each(["veto", "same identity"] as const)(
593 it.effect(`rejects a replacement with ${invalidReplacement}`, () =>593 "rejects a replacement with %s",
594 (invalidReplacement) =>
594 Effect.gen(function* () {595 Effect.gen(function* () {
595 const fake = yield* makeFakePi;596 const fake = yield* makeFakePi;
596 const { runtime } = yield* openRuntime(fake);597 const { runtime } = yield* openRuntime(fake);
...@@ -617,8 +618,7 @@ describe("PiAdapterV2", () => {...@@ -617,8 +618,7 @@ describe("PiAdapterV2", () => {
617 yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip);618 yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip);
618 assert.isFalse(fake.allRequests().some((request) => request.type === "prompt"));619 assert.isFalse(fake.allRequests().some((request) => request.type === "prompt"));
619 }).pipe(Effect.scoped, Effect.provide(testLayer)),620 }).pipe(Effect.scoped, Effect.provide(testLayer)),
620 );621 );
621 }
622622
623 it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () =>623 it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () =>
624 Effect.gen(function* () {624 Effect.gen(function* () {
...@@ -983,90 +983,85 @@ describe("PiAdapterV2", () => {...@@ -983,90 +983,85 @@ describe("PiAdapterV2", () => {
983 }).pipe(Effect.scoped, Effect.provide(testLayer)),983 }).pipe(Effect.scoped, Effect.provide(testLayer)),
984 );984 );
985985
986 for (const historical of [false, true]) {986 it.effect.each([
987 it.effect(987 { historical: false, label: "the latest turn" },
988 `natively forks ${historical ? "a historical turn" : "the latest turn"} into an independent session`,988 { historical: true, label: "a historical turn" },
989 () =>989 ])("natively forks $label into an independent session", ({ historical }) =>
990 Effect.gen(function* () {990 Effect.gen(function* () {
991 const fake = yield* makeFakePi;991 const fake = yield* makeFakePi;
992 const forkFake = yield* makeFakePi;992 const forkFake = yield* makeFakePi;
993 const forkFile = "/fake/forked.jsonl";993 const forkFile = "/fake/forked.jsonl";
994 const { runtime, takeEvent } = yield* openRuntime(994 const { runtime, takeEvent } = yield* openRuntime(
995 fake,995 fake,
996 "default",996 "default",
997 THREAD_ID,997 THREAD_ID,
998 SESSION_ID,998 SESSION_ID,
999 forkFake,999 forkFake,
1000 );1000 );
1001 const source = yield* runtime.ensureThread({1001 const source = yield* runtime.ensureThread({
1002 threadId: THREAD_ID,1002 threadId: THREAD_ID,
1003 modelSelection: modelSelection("default"),1003 modelSelection: modelSelection("default"),
1004 runtimePolicy,1004 runtimePolicy,
1005 });1005 });
1006 const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({1006 const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({
1007 id: ProviderTurnId.make(`turn-${ordinal}`),1007 id: ProviderTurnId.make(`turn-${ordinal}`),
1008 providerThreadId: source.id,1008 providerThreadId: source.id,
1009 nodeId: NodeId.make(`node-${ordinal}`),1009 nodeId: NodeId.make(`node-${ordinal}`),
1010 runAttemptId: null,1010 runAttemptId: null,
1011 nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" },1011 nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" },
1012 ordinal,1012 ordinal,
1013 status: "completed",1013 status: "completed",
1014 startedAt: null,1014 startedAt: null,
1015 completedAt: null,1015 completedAt: null,
1016 });1016 });
1017 forkFake.queueState({ sessionFile: forkFile });1017 forkFake.queueState({ sessionFile: forkFile });
1018 fake.queueState({ sessionFile: forkFile });1018 fake.queueState({ sessionFile: forkFile });
1019 const target = ThreadId.make("fork-target");1019 const target = ThreadId.make("fork-target");
1020 const forked = yield* runtime.forkThread({1020 const forked = yield* runtime.forkThread({
1021 sourceProviderThread: source,1021 sourceProviderThread: source,
1022 sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)],1022 sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)],
1023 providerTurnId: turn(1).id,1023 providerTurnId: turn(1).id,
1024 targetThreadId: target,1024 targetThreadId: target,
1025 });1025 });
1026 assert.equal(forked.appThreadId, target);1026 assert.equal(forked.appThreadId, target);
1027 assert.equal(forked.nativeThreadRef?.nativeId, forkFile);1027 assert.equal(forked.nativeThreadRef?.nativeId, forkFile);
1028 assert.notEqual(forked.id, source.id);1028 assert.notEqual(forked.id, source.id);
1029 assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE);1029 assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE);
1030 const args = forkFake.lastSpawn().args;1030 const args = forkFake.lastSpawn().args;
1031 assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE);1031 assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE);
1032 assert.include(args, "--no-extensions");1032 assert.include(args, "--no-extensions");
1033 assert.include(args, "--no-tools");1033 assert.include(args, "--no-tools");
1034 assert.notInclude(args, "--no-session");1034 assert.notInclude(args, "--no-session");
1035 assert.deepEqual(1035 assert.deepEqual(
1036 forkFake1036 forkFake
1037 .allRequests()1037 .allRequests()
1038 .filter((request) => request.type === "fork")1038 .filter((request) => request.type === "fork")
1039 .map((request) => request.entryId),1039 .map((request) => request.entryId),
1040 historical ? ["u2"] : [],1040 historical ? ["u2"] : [],
1041 );1041 );
1042 assert.isFalse(1042 assert.isFalse(
1043 fake1043 fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"),
1044 .allRequests()1044 );
1045 .some((request) => request.type === "fork" || request.type === "clone"),1045 // ProviderTurnStartService adopts the fork into its pending row.
1046 );1046 const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") };
1047 // ProviderTurnStartService adopts the fork into its pending row.1047 yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target);
1048 const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") };1048 yield* fake.emit({ type: "agent_start" });
1049 yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target);1049 yield* fake.emit({ type: "agent_settled" });
1050 yield* fake.emit({ type: "agent_start" });1050 const updated = yield* takeEvent(
1051 yield* fake.emit({ type: "agent_settled" });1051 (event) =>
1052 const updated = yield* takeEvent(1052 event.type === "provider_thread.updated" && event.providerThread.appThreadId === target,
1053 (event) =>1053 );
1054 event.type === "provider_thread.updated" &&1054 assert.isTrue(
1055 event.providerThread.appThreadId === target,1055 updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id,
1056 );1056 );
1057 assert.isTrue(1057 yield* takeEvent((event) => event.type === "turn.terminal");
1058 updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id,1058 yield* runtime.resumeThread({ providerThread: adopted });
1059 );1059 assert.equal(
1060 yield* takeEvent((event) => event.type === "turn.terminal");1060 fake.allRequests().findLast((request) => request.type === "switch_session")?.sessionPath,
1061 yield* runtime.resumeThread({ providerThread: adopted });1061 forkFile,
1062 assert.equal(1062 );
1063 fake.allRequests().findLast((request) => request.type === "switch_session")1063 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1064 ?.sessionPath,1064 );
1065 forkFile,
1066 );
1067 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1068 );
1069 }
10701065
1071 it.effect("observes official subagent results without inventing child threads", () =>1066 it.effect("observes official subagent results without inventing child threads", () =>
1072 Effect.gen(function* () {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,8 +28,9 @@ async function loadRequestHook(): Promise<RequestHook> {
28}28}
2929
30describe("Pi upstream output-budget workaround", () => {30describe("Pi upstream output-budget workaround", () => {
31 for (const key of ["max_tokens", "max_completion_tokens"]) {31 it.each(["max_tokens", "max_completion_tokens"])(
32 it(`caps ${key} without changing the conversation or tools`, async () => {32 "caps %s without changing the conversation or tools",
33 async (key) => {
33 const hook = await loadRequestHook();34 const hook = await loadRequestHook();
34 const payload = {35 const payload = {
35 model: "moonshotai/kimi-k2.6",36 model: "moonshotai/kimi-k2.6",
...@@ -43,8 +44,8 @@ describe("Pi upstream output-budget workaround", () => {...@@ -43,8 +44,8 @@ describe("Pi upstream output-budget workaround", () => {
43 assert.strictEqual(result?.tools, payload.tools);44 assert.strictEqual(result?.tools, payload.tools);
44 assert.equal(result?.model, payload.model);45 assert.equal(result?.model, payload.model);
45 assert.equal(payload[key], 231_969);46 assert.equal(payload[key], 231_969);
46 });47 },
47 }48 );
4849
49 it("preserves smaller budgets and other providers' payloads", async () => {50 it("preserves smaller budgets and other providers' payloads", async () => {
50 const hook = await loadRequestHook();51 const hook = await loadRequestHook();
apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts+44-48
...@@ -413,55 +413,51 @@ describe("handoff budget", () => {...@@ -413,55 +413,51 @@ describe("handoff budget", () => {
413});413});
414414
415describe("handoff delivery", () => {415describe("handoff delivery", () => {
416 for (const native of [true, false]) {416 it.effect.each([
417 it.effect(417 { native: true, label: "injected" },
418 `records omitted recovery coverage separately from ${native ? "injected" : "inline"} text`,418 { native: false, label: "inline" },
419 () =>419 ])("records omitted recovery coverage separately from $label text", ({ native }) =>
420 Effect.gen(function* () {420 Effect.gen(function* () {
421 const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation");421 const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation");
422 const oversized = message("item:oversized", "user", "x".repeat(20_000));422 const oversized = message("item:oversized", "user", "x".repeat(20_000));
423 let durable: OrchestrationV2ContextHandoff = {423 let durable: OrchestrationV2ContextHandoff = {
424 ...handoff,424 ...handoff,
425 history: {425 history: {
426 ...handoff.history!,426 ...handoff.history!,
427 messages: [...messages, oversized],427 messages: [...messages, oversized],
428 omittedItems: 1,428 omittedItems: 1,
429 omittedItemIds: [omittedBeforeDelivery],429 omittedItemIds: [omittedBeforeDelivery],
430 },430 },
431 };431 };
432 const result = yield* deliverContextHandoffs({432 const result = yield* deliverContextHandoffs({
433 handoffs: [durable],433 handoffs: [durable],
434 providerThread,434 providerThread,
435 budget: 16_000,435 budget: 16_000,
436 alreadyDeliveredItemIds: new Set(),436 alreadyDeliveredItemIds: new Set(),
437 inject: (value) => {437 inject: (value) => {
438 assert.include(value.context, "omitted 2 items");438 assert.include(value.context, "omitted 2 items");
439 assert.notInclude(439 assert.notInclude(
440 value.messages.map((item) => item.itemId),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,
459 oversized.itemId,441 oversized.itemId,
460 ]);442 );
461 assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery);443 return Effect.succeed(native);
462 }),444 },
463 );445 persist: (value) =>
464 }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 it.effect("loads the history budget only when a handoff needs delivery", () =>461 it.effect("loads the history budget only when a handoff needs delivery", () =>
466 Effect.gen(function* () {462 Effect.gen(function* () {
467 let reads = 0;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,8 +433,9 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => {
433 ),433 ),
434 );434 );
435435
436 for (const phase of ["high-water", "replay"] as const) {436 it.effect.each(["high-water", "replay"] as const)(
437 it.effect(`bounds live events while the V2 ${phase} query is blocked`, () =>437 "bounds live events while the V2 %s query is blocked",
438 (phase) =>
438 Effect.scoped(439 Effect.scoped(
439 Effect.gen(function* () {440 Effect.gen(function* () {
440 const sink = yield* EventSink.EventSinkV2;441 const sink = yield* EventSink.EventSinkV2;
...@@ -486,8 +487,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => {...@@ -486,8 +487,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => {
486 }487 }
487 }),488 }),
488 ),489 ),
489 );490 );
490 }
491491
492 it.effect(492 it.effect(
493 "keeps internal streams subscribed while replay is blocked beyond the RPC buffer cap",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,68 +2661,66 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => {
2661 }),2661 }),
2662 );2662 );
26632663
2664 for (const replayRequest of [2664 it.effect.each([
2665 { type: "terminal.cleanup" },2665 { type: "terminal.cleanup" },
2666 { type: "provider-runtime.continue", sourceRunId: RunId.make("run:restart-replay") },2666 { type: "provider-runtime.continue", sourceRunId: RunId.make("run:restart-replay") },
2667 ] as const) {2667 ] as const)(
2668 it.effect(2668 "retires live provider effects and requeues $type after process loss",
2669 `retires live provider effects and requeues ${replayRequest.type} after process loss`,2669 (replayRequest) =>
2670 () =>2670 Effect.gen(function* () {
2671 Effect.gen(function* () {2671 const outbox = yield* EffectOutbox.EffectOutboxV2;
2672 const outbox = yield* EffectOutbox.EffectOutboxV2;2672 const commandId = CommandId.make(
2673 const commandId = CommandId.make(2673 `command:foundation-reclaim-running:${replayRequest.type}`,
2674 `command:foundation-reclaim-running:${replayRequest.type}`,2674 );
2675 );2675 yield* outbox.enqueue([
2676 yield* outbox.enqueue([2676 {
2677 {2677 id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`,
2678 id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`,2678 commandId,
2679 commandId,2679 threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`),
2680 threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`),2680 request: {
2681 request: {2681 type: "provider-turn.start",
2682 type: "provider-turn.start",2682 runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`),
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,
2691 },2683 },
2692 ]);2684 },
2693 assert.isTrue(2685 {
2694 Option.isSome(2686 id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`,
2695 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),2687 commandId,
2696 ),2688 threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`),
2697 );2689 request: replayRequest,
2698 assert.isTrue(2690 },
2699 Option.isSome(2691 ]);
2700 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),2692 assert.isTrue(
2701 ),2693 Option.isSome(
2702 );2694 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),
2703 assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, {2695 ),
2704 cancelled: 1,2696 );
2705 requeued: 1,2697 assert.isTrue(
2706 });2698 Option.isSome(
2707 const cancelled = yield* outbox.get(2699 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),
2708 `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`,2700 ),
2709 );2701 );
2710 assert.isTrue(Option.isSome(cancelled));2702 assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, {
2711 if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled");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");
27122711
2713 const reclaimed = yield* outbox.claimNext({2712 const reclaimed = yield* outbox.claimNext({
2714 workerId: "recovery-worker",2713 workerId: "recovery-worker",
2715 leaseDurationMs: 30_000,2714 leaseDurationMs: 30_000,
2716 });2715 });
2717 assert.isTrue(Option.isSome(reclaimed));2716 assert.isTrue(Option.isSome(reclaimed));
2718 if (Option.isSome(reclaimed)) {2717 if (Option.isSome(reclaimed)) {
2719 assert.equal(reclaimed.value.request.type, replayRequest.type);2718 assert.equal(reclaimed.value.request.type, replayRequest.type);
2720 assert.equal(reclaimed.value.attemptCount, 2);2719 assert.equal(reclaimed.value.attemptCount, 2);
2721 yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" });2720 yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" });
2722 }2721 }
2723 }),2722 }),
2724 );2723 );
2725 }
27262724
2727 it.effect("atomically cancels stale runs and their process-bound effects", () =>2725 it.effect("atomically cancels stale runs and their process-bound effects", () =>
2728 Effect.gen(function* () {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,8 +78,9 @@ it.effect("stops draining a slow subscriber when its unacknowledged tail fills",
78type Event = { readonly sequence: number; readonly text: string; readonly threadId?: string };78type Event = { readonly sequence: number; readonly text: string; readonly threadId?: string };
7979
80describe("replayAndBufferLiveEvents", () => {80describe("replayAndBufferLiveEvents", () => {
81 for (const phase of ["high-water", "replay"] as const) {81 it.effect.each(["high-water", "replay"] as const)(
82 it.effect(`unsubscribes and cancels a blocked ${phase} read on live overflow`, () =>82 "unsubscribes and cancels a blocked %s read on live overflow",
83 (phase) =>
83 Effect.scoped(84 Effect.scoped(
84 Effect.gen(function* () {85 Effect.gen(function* () {
85 const pubsub = yield* PubSub.unbounded<Event>();86 const pubsub = yield* PubSub.unbounded<Event>();
...@@ -121,11 +122,11 @@ describe("replayAndBufferLiveEvents", () => {...@@ -121,11 +122,11 @@ describe("replayAndBufferLiveEvents", () => {
121 expect(yield* PubSub.size(pubsub)).toBe(0);122 expect(yield* PubSub.size(pubsub)).toBe(0);
122 }),123 }),
123 ),124 ),
124 );125 );
125 }
126126
127 for (const limit of ["items", "bytes"] as const) {127 it.effect.each(["items", "bytes"] as const)(
128 it.effect(`counts an unacknowledged replay batch toward the live ${limit} limit`, () =>128 "counts an unacknowledged replay batch toward the live %s limit",
129 (limit) =>
129 Effect.scoped(130 Effect.scoped(
130 Effect.gen(function* () {131 Effect.gen(function* () {
131 const pubsub = yield* PubSub.unbounded<Event>();132 const pubsub = yield* PubSub.unbounded<Event>();
...@@ -158,8 +159,7 @@ describe("replayAndBufferLiveEvents", () => {...@@ -158,8 +159,7 @@ describe("replayAndBufferLiveEvents", () => {
158 if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError");159 if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError");
159 }),160 }),
160 ),161 ),
161 );162 );
162 }
163163
164 it.effect("lets a fast reader replay a database page larger than both buffer limits", () =>164 it.effect("lets a fast reader replay a database page larger than both buffer limits", () =>
165 Effect.scoped(165 Effect.scoped(
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts+53-55
...@@ -332,61 +332,59 @@ const runScenario = (input: {...@@ -332,61 +332,59 @@ const runScenario = (input: {
332 });332 });
333333
334describe("OpenCode 2 through the orchestrator", () => {334describe("OpenCode 2 through the orchestrator", () => {
335 for (const via of ["message", "thread settings"] as const) {335 it.effect.each(["message", "thread settings"] as const)(
336 it.effect(336 "switches the session's model before the next prompt when changed from the %s",
337 `switches the session's model before the next prompt when changed from the ${via}`,337 (via) =>
338 () =>338 Effect.gen(function* () {
339 Effect.gen(function* () {339 const name = `opencode2-model-switch-${via.replace(" ", "-")}`;
340 const name = `opencode2-model-switch-${via.replace(" ", "-")}`;340 const cwd = yield* checkpointWorkspace(name);
341 const cwd = yield* checkpointWorkspace(name);341 const thread = threadCommands({ name, worktreePath: cwd });
342 const thread = threadCommands({ name, worktreePath: cwd });342 const projection = yield* runScenario({
343 const projection = yield* runScenario({343 name,
344 name,344 threadId: thread.threadId,
345 threadId: thread.threadId,345 entries: [
346 entries: [346 ...createdSession(cwd, name),
347 ...createdSession(cwd, name),347 ...instructionsWritten,
348 ...instructionsWritten,348 ...answeredPrompt("FIRST"),
349 ...answeredPrompt("FIRST"),349 // The next turn resumes the session at its new selection.
350 // The next turn resumes the session at its new selection.350 out("session.get", { sessionID: SESSION }),
351 out("session.get", { sessionID: SESSION }),351 reply("session.get", sessionInfo(cwd, t3Rules(name))),
352 reply("session.get", sessionInfo(cwd, t3Rules(name))),352 out("session.switchModel", {
353 out("session.switchModel", {353 sessionID: SESSION,
354 sessionID: SESSION,354 model: { providerID: "opencode", id: "mimo-v2.6-flash-free" },
355 model: { providerID: "opencode", id: "mimo-v2.6-flash-free" },355 }),
356 }),356 reply("session.switchModel", null),
357 reply("session.switchModel", null),357 // The instructions name the model, so they are written again.
358 // The instructions name the model, so they are written again.358 ...instructionsWritten,
359 ...instructionsWritten,359 ...answeredPrompt("SECOND"),
360 ...answeredPrompt("SECOND"),360 ],
361 ],361 commands: [
362 commands: [362 thread.create,
363 thread.create,363 thread.message("first"),
364 thread.message("first"),364 ...(via === "thread settings"
365 ...(via === "thread settings"365 ? [
366 ? [366 {
367 {367 type: "thread.model-selection.set",
368 type: "thread.model-selection.set",368 commandId: thread.command("model"),
369 commandId: thread.command("model"),369 threadId: thread.threadId,
370 threadId: thread.threadId,370 modelSelection: mimo,
371 modelSelection: mimo,371 } satisfies OrchestrationV2Command,
372 } satisfies OrchestrationV2Command,372 ]
373 ]373 : []),
374 : []),374 thread.message("second", mimo),
375 thread.message("second", mimo),375 ],
376 ],376 });
377 });377 assert.deepEqual(
378 assert.deepEqual(378 projection.runs.map((run) => [run.status, run.modelSelection.model]),
379 projection.runs.map((run) => [run.status, run.modelSelection.model]),379 [
380 [380 ["completed", bigPickle.model],
381 ["completed", bigPickle.model],381 ["completed", mimo.model],
382 ["completed", mimo.model],382 ],
383 ],383 );
384 );384 // One native session carried both turns.
385 // One native session carried both turns.385 assert.lengthOf(projection.providerThreads, 1);
386 assert.lengthOf(projection.providerThreads, 1);386 }).pipe(Effect.scoped),
387 }).pipe(Effect.scoped),387 );
388 );
389 }
390388
391 it.effect("moves the session to the thread's new worktree before the next prompt", () =>389 it.effect("moves the session to the thread's new worktree before the next prompt", () =>
392 Effect.gen(function* () {390 Effect.gen(function* () {
apps/server/src/orchestration-v2/ProjectCommands.test.ts+5-4
...@@ -128,16 +128,17 @@ describe("planProjectCommand", () => {...@@ -128,16 +128,17 @@ describe("planProjectCommand", () => {
128 assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode);128 assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode);
129 });129 });
130130
131 for (const id of ["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)]) {131 it.each(["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}`, () => {132 "rejects a new script ID that cannot have a shortcut: %s",
133 (id) => {
133 const failure = failureOf(update({ scripts: [script("lint"), script(id)] }));134 const failure = failureOf(update({ scripts: [script("lint"), script(id)] }));
134 assert.equal(failure._tag, "ProjectCommandInvariantError");135 assert.equal(failure._tag, "ProjectCommandInvariantError");
135 assert.include(failure.message, "Script ID");136 assert.include(failure.message, "Script ID");
136 assert.include(failure.message, "24");137 assert.include(failure.message, "24");
137 // The detail is persisted in the rejected receipt, so it omits the raw ID.138 // The detail is persisted in the rejected receipt, so it omits the raw ID.
138 assert.notInclude(failure.message, `'${id}'`);139 assert.notInclude(failure.message, `'${id}'`);
139 });140 },
140 }141 );
141142
142 it("accepts a script ID at the shortcut length limit", () => {143 it("accepts a script ID at the shortcut length limit", () => {
143 const scripts = [script("a".repeat(24))];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,12 +186,17 @@ function fixtureEvents(now: DateTime.Utc): ReadonlyArray<OrchestrationV2DomainEv
186 ];186 ];
187}187}
188188
189for (const storage of ["sqlite", "memory"] as const) {189const storageCases = (["sqlite", "memory"] as const).map((storage) => ({
190 const storeLayer =190 storage,
191 storeLayer:
191 storage === "sqlite"192 storage === "sqlite"
192 ? ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory))193 ? ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory))
193 : ProjectionStore.layerMemory;194 : ProjectionStore.layerMemory,
194 it.effect(`${storage}: finds the active root turn without an attempt reverse link`, () =>195}));
196
197it.effect.each(storageCases)(
198 "$storage: finds the active root turn without an attempt reverse link",
199 ({ storeLayer }) =>
195 Effect.gen(function* () {200 Effect.gen(function* () {
196 const store = yield* ProjectionStore.ProjectionStoreV2;201 const store = yield* ProjectionStore.ProjectionStoreV2;
197 const now = yield* DateTime.now;202 const now = yield* DateTime.now;
...@@ -210,8 +215,10 @@ for (const storage of ["sqlite", "memory"] as const) {...@@ -210,8 +215,10 @@ for (const storage of ["sqlite", "memory"] as const) {
210 });215 });
211 assert.isUndefined((yield* store.getRunningTurnContext(threadId)).providerTurn);216 assert.isUndefined((yield* store.getRunningTurnContext(threadId)).providerTurn);
212 }).pipe(Effect.provide(storeLayer)),217 }).pipe(Effect.provide(storeLayer)),
213 );218);
214 it.effect(`${storage}: controls and replies read only their exact durable targets`, () =>219it.effect.each(storageCases)(
220 "$storage: controls and replies read only their exact durable targets",
221 ({ storage, storeLayer }) =>
215 Effect.gen(function* () {222 Effect.gen(function* () {
216 const store = yield* ProjectionStore.ProjectionStoreV2;223 const store = yield* ProjectionStore.ProjectionStoreV2;
217 const now = yield* DateTime.now;224 const now = yield* DateTime.now;
...@@ -344,5 +351,4 @@ for (const storage of ["sqlite", "memory"] as const) {...@@ -344,5 +351,4 @@ for (const storage of ["sqlite", "memory"] as const) {
344 ),351 ),
345 );352 );
346 }).pipe(Effect.provide(Layer.merge(storeLayer, SqlitePersistenceMemory))),353 }).pipe(Effect.provide(Layer.merge(storeLayer, SqlitePersistenceMemory))),
347 );354);
348}
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+166-173
...@@ -140,159 +140,157 @@ const createItem = Effect.fn(function* (...@@ -140,159 +140,157 @@ const createItem = Effect.fn(function* (
140 });140 });
141});141});
142142
143for (const [name, testLayer] of [143it.effect.each([
144 ["sql", SqlLayer],144 ["sql", SqlLayer],
145 ["memory", ProjectionStore.layerMemory],145 ["memory", ProjectionStore.layerMemory],
146] as const) {146] as const)(
147 it.effect(147 "%s: discovers settlement work with the same activity and background semantics as the shell",
148 `${name}: discovers settlement work with the same activity and background semantics as the shell`,148 ([, testLayer]) =>
149 () =>149 Effect.gen(function* () {
150 Effect.gen(function* () {150 const store = yield* ProjectionStore.ProjectionStoreV2;
151 const store = yield* ProjectionStore.ProjectionStoreV2;151 const idle = yield* createThread("idle");
152 const idle = yield* createThread("idle");152 const completed = yield* createThread("completed");
153 const completed = yield* createThread("completed");153 yield* createRun(completed);
154 yield* createRun(completed);154 for (const [name, overrides] of [
155 for (const [name, overrides] of [155 ["archived", { archivedAt: old }],
156 ["archived", { archivedAt: old }],156 ["deleted", { deletedAt: old }],
157 ["deleted", { deletedAt: old }],157 ["settled", { settledOverride: "settled" }],
158 ["settled", { settledOverride: "settled" }],158 ["unsettled", { settledOverride: "active" }],
159 ["unsettled", { settledOverride: "active" }],159 ["pinned", { pinnedAt: old }],
160 ["pinned", { pinnedAt: old }],160 ["auto-settle-disabled", { autoSettleDisabledAt: old }],
161 ["auto-settle-disabled", { autoSettleDisabledAt: old }],161 ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) {
162 ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) {162 yield* createRun(yield* createThread(name, overrides));
163 yield* createRun(yield* createThread(name, overrides));163 }
164 }164 for (const status of ["preparing", "starting", "running", "waiting"] as const) {
165 for (const status of ["preparing", "starting", "running", "waiting"] as const) {165 yield* createRun(yield* createThread(status), status);
166 yield* createRun(yield* createThread(status), status);166 }
167 }167 const queued = yield* createThread("queued");
168 const queued = yield* createThread("queued");168 yield* createRun(queued, "queued");
169 yield* createRun(queued, "queued");169 const blocked = yield* createThread("blocked");
170 const blocked = yield* createThread("blocked");170 yield* store.apply({
171 yield* store.apply({171 id: EventId.make("event:settlement:request"),
172 id: EventId.make("event:settlement:request"),172 type: "runtime-request.updated",
173 type: "runtime-request.updated",173 threadId: blocked,
174 threadId: blocked,174 occurredAt: old,
175 occurredAt: old,175 payload: {
176 payload: {176 id: RuntimeRequestId.make("request:settlement"),
177 id: RuntimeRequestId.make("request:settlement"),177 nodeId: NodeId.make("node:settlement"),
178 nodeId: NodeId.make("node:settlement"),178 providerTurnId: null,
179 providerTurnId: null,179 nativeRequestRef: null,
180 nativeRequestRef: null,180 kind: "user_input",
181 kind: "user_input",181 status: "pending",
182 status: "pending",182 responseCapability: { type: "not_resumable", reason: "Process stopped" },
183 responseCapability: { type: "not_resumable", reason: "Process stopped" },183 createdAt: old,
184 createdAt: old,184 resolvedAt: null,
185 resolvedAt: null,185 },
186 },186 });
187 });187 const recent = yield* createThread("recent-message");
188 const recent = yield* createThread("recent-message");188 yield* createRun(recent);
189 yield* createRun(recent);189 yield* store.apply({
190 yield* store.apply({190 id: EventId.make("event:settlement:message"),
191 id: EventId.make("event:settlement:message"),191 type: "message.updated",
192 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 threadId: recent,198 threadId: recent,
194 occurredAt: now,199 runId: null,
195 payload: {200 nodeId: null,
196 createdBy: "user",201 role: "user",
197 creationSource: "web",202 text: "Next turn",
198 id: MessageId.make("message:settlement:recent"),203 attachments: [],
199 threadId: recent,204 streaming: false,
200 runId: null,205 createdAt: now,
201 nodeId: null,206 updatedAt: now,
202 role: "user",207 },
203 text: "Next turn",208 });
204 attachments: [],209 yield* createRun(
205 streaming: false,210 yield* createThread("snoozed", {
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", {
217 snoozedUntil: DateTime.add(now, { days: 1 }),211 snoozedUntil: DateTime.add(now, { days: 1 }),
218 snoozedAt: DateTime.subtract(old, { days: 1 }),212 snoozedAt: now,
219 });213 }),
220 yield* createRun(woke);214 );
221 const background = yield* createThread("idle-background");215 const woke = yield* createThread("woke", {
222 yield* createItem(background, yield* createRun(background), "idle");216 snoozedUntil: DateTime.add(now, { days: 1 }),
223 const persistent = yield* createThread("persistent-monitor");217 snoozedAt: DateTime.subtract(old, { days: 1 }),
224 yield* createItem(persistent, yield* createRun(persistent), "running", true);218 });
225 const rolledBack = yield* createThread("rolled-back-background");219 yield* createRun(woke);
226 yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running");220 const background = yield* createThread("idle-background");
227 yield* createRun(rolledBack, "completed", 2);221 yield* createItem(background, yield* createRun(background), "idle");
228 const roster = yield* createThread("provider-roster");222 const persistent = yield* createThread("persistent-monitor");
229 yield* createRun(roster);223 yield* createItem(persistent, yield* createRun(persistent), "running", true);
230 yield* store.apply({224 const rolledBack = yield* createThread("rolled-back-background");
231 id: EventId.make("event:settlement:roster"),225 yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running");
232 type: "provider-thread.updated",226 yield* createRun(rolledBack, "completed", 2);
233 threadId: roster,227 const roster = yield* createThread("provider-roster");
234 occurredAt: old,228 yield* createRun(roster);
235 payload: {229 yield* store.apply({
236 id: ProviderThreadId.make("provider-thread:settlement"),230 id: EventId.make("event:settlement:roster"),
237 appThreadId: roster,231 type: "provider-thread.updated",
238 ownerNodeId: null,232 threadId: roster,
239 driver: ProviderDriverKind.make("codex"),233 occurredAt: old,
240 providerInstanceId,234 payload: {
241 providerSessionId: null,235 id: ProviderThreadId.make("provider-thread:settlement"),
242 nativeThreadRef: null,236 appThreadId: roster,
243 nativeConversationHeadRef: null,237 ownerNodeId: null,
244 status: "idle",238 driver: ProviderDriverKind.make("codex"),
245 firstRunOrdinal: null,239 providerInstanceId,
246 lastRunOrdinal: null,240 providerSessionId: null,
247 handoffIds: [],241 nativeThreadRef: null,
248 forkedFrom: null,242 nativeConversationHeadRef: null,
249 createdAt: old,243 status: "idle",
250 updatedAt: old,244 firstRunOrdinal: null,
251 pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }],245 lastRunOrdinal: null,
252 },246 handoffIds: [],
253 });247 forkedFrom: null,
254 const candidates = yield* store.getSettlementCandidates();248 createdAt: old,
255 const shell = yield* store.getShellSnapshot({ location: "active" });249 updatedAt: old,
256 const eligible = candidates.filter((thread) =>250 pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }],
257 isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)),251 },
258 );252 });
259 assert.deepEqual(253 const candidates = yield* store.getSettlementCandidates();
260 new Set(eligible.map((thread) => thread.id)),254 const shell = yield* store.getShellSnapshot({ location: "active" });
261 new Set([idle, completed, queued, woke, background, persistent, rolledBack]),255 const eligible = candidates.filter((thread) =>
262 );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 assert.deepEqual(279 assert.deepEqual(
264 new Set(eligible.map((thread) => thread.id)),280 resolveAutoSettlementAt({ ...settings, thread: candidate }),
265 new Set(281 resolveAutoSettlementAt({ ...settings, thread: expected }),
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",
292 );282 );
293 }).pipe(Effect.provide(testLayer)),283 }
294 );284 assert.equal(
295}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);
296294
297const pullRequestLink = (number: number) => ({295const pullRequestLink = (number: number) => ({
298 host: "github.com",296 host: "github.com",
...@@ -305,11 +303,12 @@ const pullRequestLink = (number: number) => ({...@@ -305,11 +303,12 @@ const pullRequestLink = (number: number) => ({
305 stack: null,303 stack: null,
306});304});
307305
308for (const [name, testLayer] of [306it.effect.each([
309 ["sql", SqlLayer],307 ["sql", SqlLayer],
310 ["memory", ProjectionStore.layerMemory],308 ["memory", ProjectionStore.layerMemory],
311] as const) {309] as const)(
312 it.effect(`${name}: lists only active threads with pull request links, oldest first`, () =>310 "%s: lists only active threads with pull request links, oldest first",
311 ([, testLayer]) =>
313 Effect.gen(function* () {312 Effect.gen(function* () {
314 const store = yield* ProjectionStore.ProjectionStoreV2;313 const store = yield* ProjectionStore.ProjectionStoreV2;
315 yield* createThread("no-links");314 yield* createThread("no-links");
...@@ -337,33 +336,27 @@ for (const [name, testLayer] of [...@@ -337,33 +336,27 @@ for (const [name, testLayer] of [
337 ],336 ],
338 );337 );
339 }).pipe(Effect.provide(testLayer)),338 }).pipe(Effect.provide(testLayer)),
340 );339);
341}
342340
343for (const [name, testLayer] of [341it.effect.each([
344 ["sql", SqlLayer],342 ["sql", SqlLayer],
345 ["memory", ProjectionStore.layerMemory],343 ["memory", ProjectionStore.layerMemory],
346] as const) {344] as const)("%s: an unsettled-only shell read skips settled threads", ([, testLayer]) =>
347 it.effect(`${name}: an unsettled-only shell read skips settled threads`, () =>345 Effect.gen(function* () {
348 Effect.gen(function* () {346 const store = yield* ProjectionStore.ProjectionStoreV2;
349 const store = yield* ProjectionStore.ProjectionStoreV2;347 const open = yield* createThread("unsettled-open");
350 const open = yield* createThread("unsettled-open");348 const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" });
351 const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" });349 yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old });
352 yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old });350 yield* createThread("unsettled-auto", { settledAt: old });
353 yield* createThread("unsettled-auto", { settledAt: old });351 yield* createThread("unsettled-archived", { archivedAt: old });
354 yield* createThread("unsettled-archived", { archivedAt: old });
355352
356 const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true });353 const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true });
357 assert.deepEqual(354 assert.deepEqual(new Set(shell.threads.map((thread) => thread.id)), new Set([open, reopened]));
358 new Set(shell.threads.map((thread) => thread.id)),355 assert.equal(shell.archivedThreads.length, 0);
359 new Set([open, reopened]),356 const all = yield* store.getShellSnapshot({ location: "active" });
360 );357 assert.equal(all.threads.length, 4);
361 assert.equal(shell.archivedThreads.length, 0);358 }).pipe(Effect.provide(testLayer)),
362 const all = yield* store.getShellSnapshot({ location: "active" });359);
363 assert.equal(all.threads.length, 4);
364 }).pipe(Effect.provide(testLayer)),
365 );
366}
367360
368it.effect(361it.effect(
369 "reads settlement candidates and thread metadata without loading historical or archived payloads",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,8 +724,9 @@ layer("ProviderEventIngestorV2", (it) => {
724 }),724 }),
725 );725 );
726726
727 for (const terminal of ["completed", "interrupted", "failed", "cancelled", "control"] as const) {727 it.effect.each(["completed", "interrupted", "failed", "cancelled", "control"] as const)(
728 it.effect(`dismisses only native questions when a provider turn ends with ${terminal}`, () =>728 "dismisses only native questions when a provider turn ends with %s",
729 (terminal) =>
729 Effect.gen(function* () {730 Effect.gen(function* () {
730 const now = yield* DateTime.now;731 const now = yield* DateTime.now;
731 const eventSink = yield* EventSink.EventSinkV2;732 const eventSink = yield* EventSink.EventSinkV2;
...@@ -909,8 +910,7 @@ layer("ProviderEventIngestorV2", (it) => {...@@ -909,8 +910,7 @@ layer("ProviderEventIngestorV2", (it) => {
909 const repeated = yield* ingestor.ingestNormalized(input);910 const repeated = yield* ingestor.ingestNormalized(input);
910 assert.isFalse(repeated.some((entry) => entry.event.type === "runtime-request.updated"));911 assert.isFalse(repeated.some((entry) => entry.event.type === "runtime-request.updated"));
911 }),912 }),
912 );913 );
913 }
914914
915 it.effect(915 it.effect(
916 "preserves an answer committed after terminal normalization reads a pending question",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,8 +3007,9 @@ it.effect(
3007 }),3007 }),
3008);3008);
30093009
3010for (const workspaceState of ["missing", "file"] as const) {3010it.effect.each(["missing", "file"] as const)(
3011 it.effect(`rejects a ${workspaceState} workspace before opening a provider session`, () =>3011 "rejects a %s workspace before opening a provider session",
3012 (workspaceState) =>
3012 Effect.gen(function* () {3013 Effect.gen(function* () {
3013 const fileSystem = yield* FileSystem.FileSystem;3014 const fileSystem = yield* FileSystem.FileSystem;
3014 const root = yield* fileSystem.makeTempDirectoryScoped();3015 const root = yield* fileSystem.makeTempDirectoryScoped();
...@@ -3053,8 +3054,7 @@ for (const workspaceState of ["missing", "file"] as const) {...@@ -3053,8 +3054,7 @@ for (const workspaceState of ["missing", "file"] as const) {
3053 );3054 );
3054 }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 })));3055 }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 })));
3055 }).pipe(Effect.provide(NodeServices.layer)),3056 }).pipe(Effect.provide(NodeServices.layer)),
3056 );3057);
3057}
30583058
3059it.effect(3059it.effect(
3060 "rejects a deleted workspace before reusing a live session without changing its state",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,32 +120,30 @@ it.effect(
120 ),120 ),
121);121);
122122
123for (const deadStatus of ["stopped", "error"] as const) {123it.effect.each(["stopped", "error"] as const)(
124 it.effect(124 "restarts and releases the live session when a newer %s session exists",
125 `restarts and releases the live session when a newer ${deadStatus} session exists`,125 (deadStatus) =>
126 () =>126 Effect.gen(function* () {
127 Effect.gen(function* () {127 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
128 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;128 const thread = projection();
129 const thread = projection();129 const result = yield* service.plan({
130 const result = yield* service.plan({130 projection: {
131 projection: {131 ...thread,
132 ...thread,132 providerSessions: [
133 providerSessions: [133 ...thread.providerSessions,
134 ...thread.providerSessions,134 deadSessionRecord("dead_session", deadStatus),
135 deadSessionRecord("dead_session", deadStatus),135 ],
136 ],136 },
137 },137 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
138 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },138 });
139 });139 assert.equal(result.transition.type, "restart_and_resume");
140 assert.equal(result.transition.type, "restart_and_resume");140 assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]);
141 assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]);141 }).pipe(
142 }).pipe(142 Effect.provide(
143 Effect.provide(143 testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
144 testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
145 ),
146 ),144 ),
147 );145 ),
148}146);
149147
150it.effect("releases the newest live session, not the newest record overall", () =>148it.effect("releases the newest live session, not the newest record overall", () =>
151 Effect.gen(function* () {149 Effect.gen(function* () {
...@@ -275,32 +273,29 @@ it.effect("hands off from a native provider thread after its session detaches",...@@ -275,32 +273,29 @@ it.effect("hands off from a native provider thread after its session detaches",
275 ),273 ),
276);274);
277275
278for (const deadStatus of ["stopped", "error"] as const) {276it.effect.each(["stopped", "error"] as const)(
279 it.effect(277 "applies a model change on next turn when a %s session negotiated model switching",
280 `applies a model change on next turn when a ${deadStatus} session negotiated model switching`,278 (deadStatus) =>
281 () =>279 Effect.gen(function* () {
282 Effect.gen(function* () {280 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
283 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;281 const result = yield* service.plan({
284 const result = yield* service.plan({282 projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2),
285 projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2),283 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
286 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },284 });
287 });285 // Static capabilities report no in-session switch, but the dead
288 // Static capabilities report no in-session switch, but the dead286 // record's negotiated capabilities describe the provider: without
289 // record's negotiated capabilities describe the provider: without287 // them the ACP classification rejects the selection instead of
290 // them the ACP classification rejects the selection instead of288 // reopening with the requested model on the next run.
291 // reopening with the requested model on the next run.289 assert.equal(result.transition.type, "switch_model_in_session");
292 assert.equal(result.transition.type, "switch_model_in_session");290 assert.deepEqual(result.releaseProviderSessionIds, []);
293 assert.deepEqual(result.releaseProviderSessionIds, []);291 }).pipe(
294 }).pipe(292 Effect.provide(
295 Effect.provide(293 testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) =>
296 testLayer(294 Effect.succeed(acpSelectionTransition(input)),
297 { [currentInstanceId]: { continuationKey: "codex:account:primary" } },
298 (input) => Effect.succeed(acpSelectionTransition(input)),
299 ),
300 ),295 ),
301 ),296 ),
302 );297 ),
303}298);
304299
305it.effect("rejects a model change the dead record never negotiated support for", () =>300it.effect("rejects a model change the dead record never negotiated support for", () =>
306 Effect.gen(function* () {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,67 +259,65 @@ it.effect("does not continue a failed run that lost background work", () =>
259 }),259 }),
260);260);
261261
262for (const [enabled, projectOverride] of [262it.effect.each([
263 [false, undefined],263 [false, undefined],
264 [true, undefined],264 [true, undefined],
265 [false, true],265 [false, true],
266 [true, false],266 [true, false],
267] as const) {267] as const)(
268 it.effect(268 "atomically records restart intent with cancellation when opt-in is %s and project override is %s",
269 `atomically records restart intent with cancellation when opt-in is ${enabled} and project override is ${projectOverride}`,269 ([enabled, projectOverride]) =>
270 () =>270 Effect.gen(function* () {
271 Effect.gen(function* () {271 let committed: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | undefined;
272 let committed: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | undefined;272 const recovery = yield* ProviderRuntimeRecovery.make.pipe(
273 const recovery = yield* ProviderRuntimeRecovery.make.pipe(273 Effect.provide(
274 Effect.provide(274 Layer.mergeAll(
275 Layer.mergeAll(275 ServerSettings.layerTest({
276 ServerSettings.layerTest({276 continueThreadsAfterServerUpdate: enabled,
277 continueThreadsAfterServerUpdate: enabled,277 projectSettingsOverrides:
278 projectSettingsOverrides:278 projectOverride === undefined
279 projectOverride === undefined279 ? {}
280 ? {}280 : {
281 : {281 [ProjectId.make("restart-project")]: {
282 [ProjectId.make("restart-project")]: {282 continueThreadsAfterServerUpdate: projectOverride,
283 continueThreadsAfterServerUpdate: projectOverride,
284 },
285 },283 },
286 }),284 },
287 Layer.mock(ProjectionStore.ProjectionStoreV2)({285 }),
288 getRecoveryThreadIds: () => Effect.succeed([threadId]),286 Layer.mock(ProjectionStore.ProjectionStoreV2)({
289 getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()),287 getRecoveryThreadIds: () => Effect.succeed([threadId]),
290 }),288 getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()),
291 Layer.mock(EventSink.EventSinkV2)({289 }),
292 commitCommand: (input) => {290 Layer.mock(EventSink.EventSinkV2)({
293 committed = input;291 commitCommand: (input) => {
294 return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never);292 committed = input;
295 },293 return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never);
296 }),294 },
297 IdAllocator.layer,295 }),
298 Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({296 IdAllocator.layer,
299 runRecoveryOnce: Effect.succeed(false),297 Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({
300 }),298 runRecoveryOnce: Effect.succeed(false),
301 Layer.mock(EffectOutbox.EffectOutboxV2)({299 }),
302 reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }),300 Layer.mock(EffectOutbox.EffectOutboxV2)({
303 }),301 reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }),
304 ),302 }),
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",
312 ),303 ),
313 );304 ),
314 assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0);305 );
315 if (projectOverride ?? enabled)306 yield* recovery.reconcile("startup");
316 assert.deepEqual(committed!.effects[0]?.request, {307 assert.isDefined(committed);
317 type: "provider-runtime.continue",308 assert.isTrue(
318 sourceRunId: runId,309 committed!.events.some(
319 });310 (event) => event.type === "run.updated" && event.payload.status === "cancelled",
320 }),311 ),
321 );312 );
322}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);
323321
324it.effect("does not duplicate delivery and yields to newer user work or opt-out", () =>322it.effect("does not duplicate delivery and yields to newer user work or opt-out", () =>
325 Effect.gen(function* () {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,8 +862,9 @@ it.effect("starts the provider when checkpoint baseline capture fails", () =>
862 }),862 }),
863);863);
864864
865for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard"] as const) {865it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)(
866 it.effect(`handles ${scenario} before the provider turn starts`, () =>866 "handles %s before the provider turn starts",
867 (scenario) =>
867 Effect.gen(function* () {868 Effect.gen(function* () {
868 const threadId = ThreadId.make("thread:run-execution-settings-failure");869 const threadId = ThreadId.make("thread:run-execution-settings-failure");
869 const runId = RunId.make("run:run-execution-settings-failure");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,8 +1031,7 @@ for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard
1030 assert.equal(errorItem.payload.failure.message, "Run preparation failed.");1031 assert.equal(errorItem.payload.failure.message, "Run preparation failed.");
1031 }1032 }
1032 }),1033 }),
1033 );1034);
1034}
10351035
1036it.effect("keeps ingesting owned child events after the root turn terminalizes", () =>1036it.effect("keeps ingesting owned child events after the root turn terminalizes", () =>
1037 Effect.gen(function* () {1037 Effect.gen(function* () {
...@@ -3149,8 +3149,9 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp...@@ -3149,8 +3149,9 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp
3149 }),3149 }),
3150);3150);
31513151
3152for (const status of ["completed", "interrupted", "cancelled", "failed"] as const) {3152it.effect.each(["completed", "interrupted", "cancelled", "failed"] as const)(
3153 it.effect(`refreshes pull requests after the current root run ${status}`, () =>3153 "refreshes pull requests after the current root run %s",
3154 (status) =>
3154 Effect.gen(function* () {3155 Effect.gen(function* () {
3155 const { observed } = yield* captureRootRunTermination({3156 const { observed } = yield* captureRootRunTermination({
3156 key: `pull-request-refresh:${status}`,3157 key: `pull-request-refresh:${status}`,
...@@ -3162,8 +3163,7 @@ for (const status of ["completed", "interrupted", "cancelled", "failed"] as cons...@@ -3162,8 +3163,7 @@ for (const status of ["completed", "interrupted", "cancelled", "failed"] as cons
3162 "pull-requests-refreshed",3163 "pull-requests-refreshed",
3163 ]);3164 ]);
3164 }),3165 }),
3165 );3166);
3166}
31673167
3168it.effect("does not refresh pull requests for auxiliary or stale provider terminals", () =>3168it.effect("does not refresh pull requests for auxiliary or stale provider terminals", () =>
3169 Effect.gen(function* () {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,80 +50,82 @@ it.effect("refreshes workspace after checkpoint capture without reading history"
50 }).pipe(Effect.provide(layer));50 }).pipe(Effect.provide(layer));
51});51});
5252
53for (const scenario of [53it.effect.each(
54 {54 (
55 label: "discovers a new PR for the completed run's branch",55 [
56 branch: "feature",56 {
57 checkedOut: "feature",57 label: "discovers a new PR for the completed run's branch",
58 activeRun: null,58 branch: "feature",
59 expected: ["/repo"],59 checkedOut: "feature",
60 },60 activeRun: null,
61 {61 expected: ["/repo"],
62 label: "leaves the default branch's PR cache alone",62 },
63 branch: "main",63 {
64 checkedOut: "main",64 label: "leaves the default branch's PR cache alone",
65 activeRun: null,65 branch: "main",
66 expected: [],66 checkedOut: "main",
67 },67 activeRun: null,
68 {68 expected: [],
69 label: "does not refresh another thread's checkout",69 },
70 branch: "feature",70 {
71 checkedOut: "other",71 label: "does not refresh another thread's checkout",
72 activeRun: null,72 branch: "feature",
73 expected: [],73 checkedOut: "other",
74 },74 activeRun: null,
75 {75 expected: [],
76 label: "does not refresh during a newer active run",76 },
77 branch: "feature",77 {
78 checkedOut: "feature",78 label: "does not refresh during a newer active run",
79 activeRun: "newer-run",79 branch: "feature",
80 expected: [],80 checkedOut: "feature",
81 },81 activeRun: "newer-run",
82] as const) {82 expected: [],
83 it.effect(scenario.label, () => {83 },
84 const refreshed: string[] = [];84 ] as const
85 const threadId = ThreadId.make("thread-pr-refresh");85 ).map((scenario) => [scenario.label, scenario] as const),
86 const runId = RunId.make("completed-run");86)("%s", ([, scenario]) => {
87 const layer = RunFinalization.observerLive.pipe(87 const refreshed: string[] = [];
88 Layer.provide(88 const threadId = ThreadId.make("thread-pr-refresh");
89 Layer.mergeAll(89 const runId = RunId.make("completed-run");
90 Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),90 const layer = RunFinalization.observerLive.pipe(
91 Layer.mock(PullRequestService.PullRequestService)({91 Layer.provide(
92 refreshAfterTurn: () => Effect.void,92 Layer.mergeAll(
93 }),93 Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
94 Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({94 Layer.mock(PullRequestService.PullRequestService)({
95 refreshLocalStatus: () =>95 refreshAfterTurn: () => Effect.void,
96 Effect.succeed({96 }),
97 isRepo: true,97 Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
98 hasPrimaryRemote: true,98 refreshLocalStatus: () =>
99 isDefaultRef: scenario.checkedOut === "main",99 Effect.succeed({
100 refName: scenario.checkedOut,100 isRepo: true,
101 hasWorkingTreeChanges: false,101 hasPrimaryRemote: true,
102 workingTree: { files: [], insertions: 0, deletions: 0 },102 isDefaultRef: scenario.checkedOut === "main",
103 }),103 refName: scenario.checkedOut,
104 refreshStatus: () =>104 hasWorkingTreeChanges: false,
105 Effect.die("turn completion must preserve known PRs and lookup backoff"),105 workingTree: { files: [], insertions: 0, deletions: 0 },
106 refreshPullRequestStatus: (cwd) =>106 }),
107 Effect.sync(() => {107 refreshStatus: () =>
108 refreshed.push(cwd);108 Effect.die("turn completion must preserve known PRs and lookup backoff"),
109 return null;109 refreshPullRequestStatus: (cwd) =>
110 }),110 Effect.sync(() => {
111 }),111 refreshed.push(cwd);
112 Layer.mock(ProjectionStore.ProjectionStoreV2)({112 return null;
113 getThreadShell: () =>113 }),
114 Effect.succeed({114 }),
115 id: threadId,115 Layer.mock(ProjectionStore.ProjectionStoreV2)({
116 branch: scenario.branch,116 getThreadShell: () =>
117 activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun),117 Effect.succeed({
118 } as OrchestrationV2ThreadShell),118 id: threadId,
119 }),119 branch: scenario.branch,
120 ),120 activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun),
121 } as OrchestrationV2ThreadShell),
122 }),
121 ),123 ),
122 );124 ),
123 return Effect.gen(function* () {125 );
124 const observer = yield* RunFinalization.RunFinalizationObserver;126 return Effect.gen(function* () {
125 yield* observer.refresh({ cwd: "/repo", threadId, runId });127 const observer = yield* RunFinalization.RunFinalizationObserver;
126 assert.deepEqual(refreshed, [...scenario.expected]);128 yield* observer.refresh({ cwd: "/repo", threadId, runId });
127 }).pipe(Effect.provide(layer));129 assert.deepEqual(refreshed, [...scenario.expected]);
128 });130 }).pipe(Effect.provide(layer));
129}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,176 +537,174 @@ it.live("restarts selection as a new attempt and retries after old-session clean
537 ),537 ),
538);538);
539539
540for (const deadStatus of ["stopped", "error"] as const) {540it.live.each(["stopped", "error"] as const)(
541 it.live(541 "restarts the live session on a model change when a newer %s session record exists",
542 `restarts the live session on a model change when a newer ${deadStatus} session record exists`,542 (deadStatus) =>
543 () =>543 Effect.scoped(
544 Effect.scoped(544 Effect.gen(function* () {
545 Effect.gen(function* () {545 const name = `selection-restart-dead-${deadStatus}`;
546 const name = `selection-restart-dead-${deadStatus}`;546 const cwd = yield* checkpointWorkspace(name);
547 const cwd = yield* checkpointWorkspace(name);547 const threadId = ThreadId.make(`thread:${name}`);
548 const threadId = ThreadId.make(`thread:${name}`);548 const state = yield* Ref.make<RestartAdapterState>({
549 const state = yield* Ref.make<RestartAdapterState>({549 activeTurn: null,
550 activeTurn: null,550 opened: [],
551 opened: [],551 started: [],
552 started: [],552 closedSessionCount: 0,
553 closedSessionCount: 0,553 // The dead record is seeded directly, so the adapter's one-shot
554 // The dead record is seeded directly, so the adapter's one-shot554 // simulated replacement-open failure is skipped.
555 // simulated replacement-open failure is skipped.555 failedReplacementOpen: true,
556 failedReplacementOpen: true,556 });
557 });557 const registry = ProviderAdapterRegistry.makeSingleLayer(
558 const registry = ProviderAdapterRegistry.makeSingleLayer(558 makeRestartAdapter(state, exclusiveCapabilities),
559 makeRestartAdapter(state, exclusiveCapabilities),559 );
560 );
561560
562 const result = yield* Effect.gen(function* () {561 const result = yield* Effect.gen(function* () {
563 const orchestrator = yield* Orchestrator.OrchestratorV2;562 const orchestrator = yield* Orchestrator.OrchestratorV2;
564 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;563 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
565 const eventSink = yield* EventSink.EventSinkV2;564 const eventSink = yield* EventSink.EventSinkV2;
566 const dispatch = (step: string, modelSelection: ModelSelection) =>565 const dispatch = (step: string, modelSelection: ModelSelection) =>
567 Effect.gen(function* () {566 Effect.gen(function* () {
568 const terminal = yield* orchestrator.streamDomainEvents.pipe(567 const terminal = yield* orchestrator.streamDomainEvents.pipe(
569 Stream.filter(568 Stream.filter(
570 (event) => event.type === "run.updated" && event.payload.status === "completed",569 (event) => event.type === "run.updated" && event.payload.status === "completed",
571 ),570 ),
572 Stream.take(1),571 Stream.take(1),
573 Stream.runDrain,572 Stream.runDrain,
574 Effect.forkScoped,573 Effect.forkScoped,
575 );574 );
576 yield* orchestrator.dispatch({575 yield* orchestrator.dispatch({
577 type: "message.dispatch",576 type: "message.dispatch",
578 createdBy: "user",577 createdBy: "user",
579 creationSource: "web",578 creationSource: "web",
580 commandId: CommandId.make(`${name}:${step}`),579 commandId: CommandId.make(`${name}:${step}`),
581 threadId,580 threadId,
582 messageId: MessageId.make(`${name}:${step}`),581 messageId: MessageId.make(`${name}:${step}`),
583 text: step,582 text: step,
584 attachments: [],583 attachments: [],
585 modelSelection,584 modelSelection,
586 dispatchMode: { type: "start_immediately" },585 dispatchMode: { type: "start_immediately" },
587 });
588 yield* worker.drain();
589 yield* Fiber.join(terminal);
590 yield* worker.drain();
591 return yield* orchestrator.getThreadProjection(threadId);
592 });586 });
593 yield* orchestrator.dispatch({587 yield* worker.drain();
594 type: "thread.create",588 yield* Fiber.join(terminal);
595 createdBy: "user",589 yield* worker.drain();
596 creationSource: "web",590 return yield* orchestrator.getThreadProjection(threadId);
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,
606 });591 });
607 const first = yield* dispatch("first", seedSelection);592 yield* orchestrator.dispatch({
608 const liveSession = first.providerSessions.find(593 type: "thread.create",
609 (session) => session.status !== "stopped" && session.status !== "error",594 createdBy: "user",
610 );595 creationSource: "web",
611 assert.isDefined(liveSession);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);
612611
613 // Dead session records stay bound in the projection until612 // Dead session records stay bound in the projection until
614 // detachment. A stale stopped/error record written after the613 // detachment. A stale stopped/error record written after the
615 // live session attached must not hide it.614 // live session attached must not hide it.
616 const deadAt = yield* DateTime.now;615 const deadAt = yield* DateTime.now;
617 const deadSession: OrchestrationV2ProviderSession = {616 const deadSession: OrchestrationV2ProviderSession = {
618 id: ProviderSessionId.make(`session:${name}:dead`),617 id: ProviderSessionId.make(`session:${name}:dead`),
619 driver,618 driver,
620 providerInstanceId,619 providerInstanceId,
621 status: "ready",620 status: "ready",
622 cwd,621 cwd,
623 model: seedSelection.model,622 model: seedSelection.model,
624 capabilities: exclusiveCapabilities,623 capabilities: exclusiveCapabilities,
625 createdAt: deadAt,624 createdAt: deadAt,
626 updatedAt: deadAt,625 updatedAt: deadAt,
627 lastError: null,626 lastError: null,
628 };627 };
629 yield* eventSink.write({628 yield* eventSink.write({
630 events: [629 events: [
631 {630 {
632 id: EventId.make(`event:${name}:dead-attached`),631 id: EventId.make(`event:${name}:dead-attached`),
633 type: "provider-session.attached",632 type: "provider-session.attached",
634 threadId,633 threadId,
635 driver,634 driver,
636 providerInstanceId,635 providerInstanceId,
637 occurredAt: deadAt,636 occurredAt: deadAt,
638 payload: deadSession,637 payload: deadSession,
639 },638 },
640 {639 {
641 id: EventId.make(`event:${name}:dead-updated`),640 id: EventId.make(`event:${name}:dead-updated`),
642 type: "provider-session.updated",641 type: "provider-session.updated",
643 threadId,642 threadId,
644 driver,643 driver,
645 providerInstanceId,644 providerInstanceId,
646 occurredAt: deadAt,645 occurredAt: deadAt,
647 payload: {646 payload: {
648 ...deadSession,647 ...deadSession,
649 status: deadStatus,648 status: deadStatus,
650 updatedAt: deadAt,649 updatedAt: deadAt,
651 lastError: deadStatus === "error" ? "Simulated session failure." : null,650 lastError: deadStatus === "error" ? "Simulated session failure." : null,
652 },
653 },651 },
654 ],652 },
655 });653 ],
654 });
656655
657 const switchCommandId = CommandId.make(`${name}:switch`);656 const switchCommandId = CommandId.make(`${name}:switch`);
658 yield* orchestrator.dispatch({657 yield* orchestrator.dispatch({
659 type: "thread.model-selection.set",658 type: "thread.model-selection.set",
660 commandId: switchCommandId,659 commandId: switchCommandId,
661 threadId,660 threadId,
662 modelSelection: replacementSelection,661 modelSelection: replacementSelection,
663 });662 });
664 yield* worker.drain();663 yield* worker.drain();
665 const storedSwitchEvents = yield* eventSink664 const storedSwitchEvents = yield* eventSink
666 .readByCommandId({ commandId: switchCommandId })665 .readByCommandId({ commandId: switchCommandId })
667 .pipe(Stream.runCollect);666 .pipe(Stream.runCollect);
668 const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) =>667 const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) =>
669 stored.event.type === "provider-session.detached"668 stored.event.type === "provider-session.detached"
670 ? [stored.event.payload.providerSessionId]669 ? [stored.event.payload.providerSessionId]
671 : [],670 : [],
672 );671 );
673672
674 const second = yield* dispatch("second", replacementSelection);673 const second = yield* dispatch("second", replacementSelection);
675 return {674 return {
676 projection: second,675 projection: second,
677 captured: yield* Ref.get(state),676 captured: yield* Ref.get(state),
678 liveSessionId: liveSession.id,677 liveSessionId: liveSession.id,
679 detachedSessionIds,678 detachedSessionIds,
680 };679 };
681 }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry)));680 }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry)));
682681
683 const { projection, captured } = result;682 const { projection, captured } = result;
684 assert.lengthOf(projection.runs, 2);683 assert.lengthOf(projection.runs, 2);
685 assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model);684 assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model);
686 // The exact released session is the older live one, never the newer685 // The exact released session is the older live one, never the newer
687 // dead record.686 // dead record.
688 assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]);687 assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]);
689 assert.equal(captured.closedSessionCount, 1);688 assert.equal(captured.closedSessionCount, 1);
690 assert.deepEqual(689 assert.deepEqual(
691 captured.opened.map((open) => open.model),690 captured.opened.map((open) => open.model),
692 [seedSelection.model, replacementSelection.model],691 [seedSelection.model, replacementSelection.model],
693 );692 );
694 assert.deepEqual(693 assert.deepEqual(
695 captured.started.map((turn) => turn.model),694 captured.started.map((turn) => turn.model),
696 [seedSelection.model, replacementSelection.model],695 [seedSelection.model, replacementSelection.model],
697 );696 );
698 const servingSession = projection.providerSessions.find(697 const servingSession = projection.providerSessions.find(
699 (session) =>698 (session) =>
700 session.id ===699 session.id ===
701 projection.providerThreads.find(700 projection.providerThreads.find(
702 (providerThread) => providerThread.id === projection.thread.activeProviderThreadId,701 (providerThread) => providerThread.id === projection.thread.activeProviderThreadId,
703 )?.providerSessionId,702 )?.providerSessionId,
704 );703 );
705 assert.equal(servingSession?.model, replacementSelection.model);704 assert.equal(servingSession?.model, replacementSelection.model);
706 }),705 }),
707 ),706 ),
708 );707);
709}
710708
711it.live("detaches the old provider session after an active provider handoff", () =>709it.live("detaches the old provider session after an active provider handoff", () =>
712 Effect.scoped(710 Effect.scoped(
...@@ -822,8 +820,9 @@ it.live("detaches the old provider session after an active provider handoff", ()...@@ -822,8 +820,9 @@ it.live("detaches the old provider session after an active provider handoff", ()
822 ),820 ),
823);821);
824822
825for (const mode of ["active", "idle", "selection-command", "pooled", "separate-home"] as const) {823it.live.each(["active", "idle", "selection-command", "pooled", "separate-home"] as const)(
826 it.live(`preserves native history only for compatible account switches (${mode})`, () =>824 "preserves native history only for compatible account switches (%s)",
825 (mode) =>
827 Effect.scoped(826 Effect.scoped(
828 Effect.gen(function* () {827 Effect.gen(function* () {
829 const name = `shared-home-${mode}`;828 const name = `shared-home-${mode}`;
...@@ -994,5 +993,4 @@ for (const mode of ["active", "idle", "selection-command", "pooled", "separate-h...@@ -994,5 +993,4 @@ for (const mode of ["active", "idle", "selection-command", "pooled", "separate-h
994 }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry)));993 }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry)));
995 }),994 }),
996 ),995 ),
997 );996);
998}
apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts+354-352
...@@ -37,371 +37,373 @@ const driver = ProviderDriverKind.make("codex");...@@ -37,371 +37,373 @@ const driver = ProviderDriverKind.make("codex");
37const instanceId = ProviderInstanceId.make("codex");37const instanceId = ProviderInstanceId.make("codex");
38const modelSelection = { instanceId, model: "test-model" };38const modelSelection = { instanceId, model: "test-model" };
3939
40for (const mailbox of [false, true]) {40it.effect.each(
41 for (const timing of [41 [false, true]
42 "before delivery",42 .flatMap((mailbox) =>
43 "during delivery",43 (
44 "before dispatch",44 [
45 "after delivery",45 "before delivery",
46 "without native steering",46 "during delivery",
47 "settled only",47 "before dispatch",
48 ] as const) {48 "after delivery",
49 if (!mailbox && (timing === "without native steering" || timing === "settled only")) continue;49 "without native steering",
50 it.effect(50 "settled only",
51 `delivers ${mailbox ? "mailbox notification" : "steering"} when completion wins ${timing}`,51 ] as const
52 () =>52 ).map((timing) => ({
53 Effect.scoped(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 Effect.gen(function* () {84 Effect.gen(function* () {
55 const cwd = yield* checkpointWorkspace(85 const now = yield* DateTime.now;
56 `steering-completion-${timing.replaceAll(" ", "-")}`,86 return {
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 = {
71 instanceId,87 instanceId,
72 driver,88 driver,
73 getCapabilities: () => Effect.succeed(capabilities),89 providerSessionId: input.providerSessionId,
74 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),90 providerSession: {
75 openSession: (input) =>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 Effect.gen(function* () {123 Effect.gen(function* () {
77 const now = yield* DateTime.now;124 started.push(turn);
78 return {125 yield* Queue.offer(events, {
79 instanceId,126 type: "provider_turn.updated",
80 driver,127 driver,
81 providerSessionId: input.providerSessionId,128 providerTurn: {
82 providerSession: {129 id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`),
83 id: input.providerSessionId,130 providerThreadId: turn.providerThread.id,
84 driver,131 nodeId: turn.rootNodeId,
85 providerInstanceId: instanceId,132 runAttemptId: turn.attemptId,
86 status: "ready",133 nativeTurnRef: {
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}`),
98 driver,134 driver,
99 providerInstanceId: instanceId,135 nativeId: `native:${turn.attemptId}`,
100 providerSessionId: input.providerSessionId,136 strength: "strong",
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 },
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();144 }),
345 assert.equal(steerCalls, 1);145 steerTurn: (turn) =>
346 yield* orchestrator.dispatch({146 Effect.gen(function* () {
347 type: "delegated_task.completion-delivery.acknowledge",147 steerCalls += 1;
348 commandId: CommandId.make("read-result"),148 if (timing === "after delivery") return;
349 parentThreadId: threadId,149 yield* Deferred.succeed(steerEntered, undefined);
350 taskId,150 yield* Deferred.await(rejectSteer);
351 observedByRunId: first.runId,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);157 }),
354 assert.equal(158 interruptTurn: () => Effect.void,
355 acknowledged.subagents[0]?.completionDelivery?.state,159 respondToRuntimeRequest: () => Effect.void,
356 "acknowledged",160 readThreadSnapshot: () => Effect.die("unused"),
357 );161 rollbackThread: () => Effect.die("unused"),
358 }162 forkThread: () => Effect.die("unused"),
359 return;163 };
360 }164 }),
361 assert.equal(started.length, 2);165 };
362 assert.equal(started[1]?.message.messageId, messageId);166 yield* Effect.gen(function* () {
363 if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId));167 const orchestrator = yield* Orchestrator.OrchestratorV2;
364 else assert.equal(started[1]?.message.text, "fix the popover");168 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
365 assert.deepEqual(started[1]?.message.attachments, [169 const threadId = ThreadId.make("thread:steering-completion");
366 {170 const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) =>
367 type: "image",171 orchestrator.streamDomainEvents.pipe(
368 id: "steering-screenshot",172 Stream.filter(predicate),
369 name: "image.png",173 Stream.take(1),
370 mimeType: "image/png",174 Stream.runDrain,
371 sizeBytes: 10,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 ]);262 },
374 assert.equal(steerCalls, timing === "during delivery" ? 1 : 0);263 ],
375 const final = yield* orchestrator.getThreadProjection(threadId);264 });
376 assert.equal(final.messages.filter((message) => message.id === messageId).length, 1);265 }
377 assert.equal(266 const dispatchSteer = orchestrator.dispatch({
378 final.messages.find((message) => message.id === messageId)?.runId,267 type: "message.dispatch",
379 started[1]?.runId,268 commandId: CommandId.make("steer"),
380 );269 threadId,
381 assert.equal(270 messageId,
382 final.turnItems.filter((item) =>271 text: "fix the popover",
383 mailbox272 attachments: [
384 ? item.type === "notification"273 {
385 : item.type === "user_message" && item.messageId === messageId,274 type: "image",
386 ).length,275 id: "steering-screenshot",
387 1,276 name: "image.png",
388 );277 mimeType: "image/png",
389 yield* worker.drain();278 sizeBytes: 10,
390 assert.equal(started.length, 2);279 },
391 }).pipe(280 ],
392 Effect.provide(281 dispatchMode: mailbox
393 makeOrchestratorV2ReplayLayerWithRegistry(282 ? { type: "queue_after_active" }
394 { name: `steering-completion-${timing}` },283 : { type: "steer_active", targetRunId: first.runId },
395 ProviderAdapterRegistry.makeSingleLayer(adapter),284 createdBy: mailbox ? "agent" : "user",
396 { runEffectWorker: false },285 creationSource: mailbox ? "server" : "web",
397 ),286 ...(mailbox
398 ),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 );
403 }404 }),
404}405 ),
406);
405407
406// Claude and Pi steer live but cannot interrupt-and-restart, so a changed408// Claude and Pi steer live but cannot interrupt-and-restart, so a changed
407// selection waits for the next turn as the thread's saved selection.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,7 +25,7 @@ import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts";
25import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";25import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
26import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts";26import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts";
2727
28for (const driverName of ["codex", "claudeAgent"] as const) {28const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => {
29 const driver = ProviderDriverKind.make(driverName);29 const driver = ProviderDriverKind.make(driverName);
30 const instanceId = ProviderInstanceId.make(driver);30 const instanceId = ProviderInstanceId.make(driver);
31 const modelSelection = { instanceId, model: "test-model" };31 const modelSelection = { instanceId, model: "test-model" };
...@@ -45,199 +45,207 @@ for (const driverName of ["codex", "claudeAgent"] as const) {...@@ -45,199 +45,207 @@ for (const driverName of ["codex", "claudeAgent"] as const) {
45 { runEffectWorker: false },45 { runEffectWorker: false },
46 );46 );
4747
48 for (const status of ["failed", "interrupted", "cancelled"] as const) {48 return (["failed", "interrupted", "cancelled"] as const).map((status) => ({
49 it.effect(`bounds ${driver} context when continuing a fork of a ${status} run`, () =>49 driver,
50 Effect.gen(function* () {50 status,
51 const orchestrator = yield* Orchestrator.OrchestratorV2;51 instanceId,
52 const eventSink = yield* EventSink.EventSinkV2;52 modelSelection,
53 const now = yield* DateTime.now;53 layer,
54 const sourceThreadId = ThreadId.make("fork-boundary-source");54 }));
55 const targetThreadId = ThreadId.make("fork-boundary-target");55});
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");
6156
62 yield* orchestrator.dispatch({57it.effect.each(forkCases)(
63 type: "thread.create",58 "bounds $driver context when continuing a fork of a $status run",
64 commandId: CommandId.make("create-source"),59 ({ driver, status, instanceId, modelSelection, layer }) =>
65 threadId: sourceThreadId,60 Effect.gen(function* () {
66 projectId: ProjectId.make("fork-boundary-project"),61 const orchestrator = yield* Orchestrator.OrchestratorV2;
67 title: "Fork boundary source",62 const eventSink = yield* EventSink.EventSinkV2;
68 modelSelection,63 const now = yield* DateTime.now;
69 runtimeMode: "full-access",64 const sourceThreadId = ThreadId.make("fork-boundary-source");
70 interactionMode: "default",65 const targetThreadId = ThreadId.make("fork-boundary-target");
71 branch: null,66 const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread");
72 worktreePath: null,67 const sourceRunId = RunId.make("fork-boundary-source-run");
73 createdBy: "user",68 const attemptId = RunAttemptId.make("interrupted-source-attempt");
74 creationSource: "web",69 const providerTurnId = ProviderTurnId.make("interrupted-source-turn");
75 });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 yield* eventSink.write({116 yield* eventSink.write({
77 events: [117 events: [
78 {118 {
79 id: EventId.make("source-provider-thread"),119 id: EventId.make("source-attempt"),
80 type: "provider-thread.updated",120 type: "run-attempt.created",
81 threadId: sourceThreadId,121 threadId: sourceThreadId,
122 runId: sourceRunId,
82 occurredAt: now,123 occurredAt: now,
83 payload: {124 payload: {
84 id: providerThreadId,125 id: attemptId,
85 driver,126 runId: sourceRunId,
127 attemptOrdinal: 1,
128 rootNodeId,
86 providerInstanceId: instanceId,129 providerInstanceId: instanceId,
87 providerSessionId: null,130 providerThreadId,
88 appThreadId: sourceThreadId,131 providerTurnId,
89 ownerNodeId: null,132 reason: "initial",
90 nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" },133 status,
91 nativeConversationHeadRef: null,134 startedAt: now,
92 status: "idle",135 completedAt: now,
93 firstRunOrdinal: 1,136 },
94 lastRunOrdinal: 2,137 },
95 handoffIds: [],138 {
96 forkedFrom: null,139 id: EventId.make("source-provider-turn"),
97 createdAt: now,140 type: "provider-turn.updated",
98 updatedAt: now,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 interruption157 }
104 // can have a turn but no native assistant cursor.158 for (const ordinal of [1, 2]) {
105 if (status === "interrupted") {159 const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run");
106 yield* eventSink.write({160 const messageId = MessageId.make(`source-message-${ordinal}`);
107 events: [161 yield* eventSink.write({
108 {162 events: [
109 id: EventId.make("source-attempt"),163 {
110 type: "run-attempt.created",164 id: EventId.make(`run-${ordinal}`),
111 threadId: sourceThreadId,165 type: "run.created",
112 runId: sourceRunId,166 threadId: sourceThreadId,
113 occurredAt: now,167 runId,
114 payload: {168 occurredAt: now,
115 id: attemptId,169 payload: {
116 runId: sourceRunId,170 id: runId,
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",
156 threadId: sourceThreadId,171 threadId: sourceThreadId,
157 runId,172 ordinal,
158 occurredAt: now,173 providerInstanceId: instanceId,
159 payload: {174 modelSelection,
160 id: runId,175 providerThreadId,
161 threadId: sourceThreadId,176 userMessageId: messageId,
162 ordinal,177 rootNodeId: null,
163 providerInstanceId: instanceId,178 activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null,
164 modelSelection,179 status: ordinal === 1 ? status : "completed",
165 providerThreadId,180 queuePosition: null,
166 userMessageId: messageId,181 requestedAt: now,
167 rootNodeId: null,182 startedAt: now,
168 activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null,183 completedAt: now,
169 status: ordinal === 1 ? status : "completed",184 checkpointId: null,
170 queuePosition: null,185 contextHandoffId: null,
171 requestedAt: now,
172 startedAt: now,
173 completedAt: now,
174 checkpointId: null,
175 contextHandoffId: null,
176 },
177 },186 },
178 {187 },
179 id: EventId.make(`item-${ordinal}`),188 {
180 type: "turn-item.updated",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 threadId: sourceThreadId,196 threadId: sourceThreadId,
182 runId,197 runId,
183 occurredAt: now,198 nodeId: null,
184 payload: {199 providerThreadId,
185 id: TurnItemId.make(`item-${ordinal}`),200 providerTurnId: null,
186 threadId: sourceThreadId,201 nativeItemRef: null,
187 runId,202 parentItemId: null,
188 nodeId: null,203 ordinal,
189 providerThreadId,204 status: "completed",
190 providerTurnId: null,205 title: null,
191 nativeItemRef: null,206 startedAt: now,
192 parentItemId: null,207 completedAt: now,
193 ordinal,208 updatedAt: now,
194 status: "completed",209 type: "user_message",
195 title: null,210 createdBy: "user",
196 startedAt: now,211 creationSource: "web",
197 completedAt: now,212 inputIntent: "turn_start",
198 updatedAt: now,213 messageId,
199 type: "user_message",214 text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER",
200 createdBy: "user",215 attachments: [],
201 creationSource: "web",
202 inputIntent: "turn_start",
203 messageId,
204 text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER",
205 attachments: [],
206 },
207 },216 },
208 ],217 },
209 });218 ],
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",
231 });219 });
232 const target = yield* orchestrator.getThreadProjection(targetThreadId);220 }
233 assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context");221 yield* orchestrator.dispatch({
234 assert.lengthOf(target.contextHandoffs, 1);222 type: "thread.fork",
235 const handoff = target.contextHandoffs[0]!;223 commandId: CommandId.make("fork-source"),
236 const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? "";224 sourceThreadId,
237 assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER");225 targetThreadId,
238 assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER");226 sourcePoint: { type: "run", runId: sourceRunId },
239 assert.isNull(target.providerThreads[0]?.forkedFrom);227 createdBy: "user",
240 }).pipe(Effect.provide(layer)),228 creationSource: "web",
241 );229 });
242 }230 yield* orchestrator.dispatch({
243}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,64 +271,64 @@ function waitUntil<E, R>(predicate: () => Effect.Effect<boolean, E, R>): Effect.
271 });271 });
272}272}
273273
274for (const target of ["new", "existing"] as const) {274it.effect.each(
275 for (const createdBy of ["user", "agent"] as const) {275 (["new", "existing"] as const).flatMap((target) =>
276 it.effect(276 (["user", "agent"] as const).map((createdBy) => ({ target, createdBy })),
277 `attributes ${createdBy}-configured automations in ${target} threads without changing their prompt`,277 ),
278 () => {278)(
279 const harness = makeHarness();279 "attributes $createdBy-configured automations in $target threads without changing their prompt",
280 const scheduledTasks = ScheduledTasks.layer.pipe(280 ({ target, createdBy }) => {
281 Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)),281 const harness = makeHarness();
282 );282 const scheduledTasks = ScheduledTasks.layer.pipe(
283 return Effect.gen(function* () {283 Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)),
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 },
329 );284 );
330 }285 return Effect.gen(function* () {
331}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);
332332
333it.effect("retains automation and sender attribution while a message waits in the queue", () => {333it.effect("retains automation and sender attribution while a message waits in the queue", () => {
334 const harness = makeHarness({ runSetup: () => Effect.never });334 const harness = makeHarness({ runSetup: () => Effect.never });
...@@ -603,8 +603,9 @@ it.effect(...@@ -603,8 +603,9 @@ it.effect(
603 }),603 }),
604);604);
605605
606for (const nativeCommand of [" /COMPACT ", "/logout"]) {606it.effect.each([" /COMPACT ", "/logout"])(
607 it.effect(`uses the first conversation message for a title after ${nativeCommand}`, () =>607 "uses the first conversation message for a title after %s",
608 (nativeCommand) =>
608 Effect.gen(function* () {609 Effect.gen(function* () {
609 const harness = makeHarness();610 const harness = makeHarness();
610 yield* Effect.gen(function* () {611 yield* Effect.gen(function* () {
...@@ -653,8 +654,7 @@ for (const nativeCommand of [" /COMPACT ", "/logout"]) {...@@ -653,8 +654,7 @@ for (const nativeCommand of [" /COMPACT ", "/logout"]) {
653 );654 );
654 }).pipe(Effect.provide(harness.layer));655 }).pipe(Effect.provide(harness.layer));
655 }),656 }),
656 );657);
657}
658658
659it.effect("keeps native maintenance commands out of steering and restart messages", () =>659it.effect("keeps native maintenance commands out of steering and restart messages", () =>
660 Effect.gen(function* () {660 Effect.gen(function* () {
...@@ -1293,47 +1293,45 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails...@@ -1293,47 +1293,45 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails
1293 }).pipe(Effect.provide(harness.layer));1293 }).pipe(Effect.provide(harness.layer));
1294});1294});
12951295
1296for (const failurePoint of ["worktree", "setup"] as const) {1296it.effect.each(["worktree", "setup"] as const)(
1297 it.effect(1297 "%s failure keeps the thread and message visible and emits failure items",
1298 `${failurePoint} failure keeps the thread and message visible and emits failure items`,1298 (failurePoint) =>
1299 () =>1299 Effect.gen(function* () {
1300 Effect.gen(function* () {1300 const failure = new Error(`${failurePoint} failed`);
1301 const failure = new Error(`${failurePoint} failed`);1301 const harness = makeHarness(
1302 const harness = makeHarness(1302 failurePoint === "worktree"
1303 failurePoint === "worktree"1303 ? { createWorktree: () => Effect.fail(failure as never) }
1304 ? { createWorktree: () => Effect.fail(failure as never) }1304 : { runSetup: () => Effect.fail(failure as never) },
1305 : { 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* () {1321 const projection = yield* threads.getThreadProjection(launched.threadId);
1308 const launches = yield* ThreadLaunch.ThreadLaunchService;1322 assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`);
1309 const threads = yield* ThreadManagement.ThreadManagementService;1323 assert.equal(projection.runs[0]?.status, "failed");
1310 const input = launchInput({1324 assert.equal(
1311 command: `command:launch:${failurePoint}-failure`,1325 projection.turnItems.find((item) => item.type === "command_execution")?.status,
1312 thread: `thread:launch:${failurePoint}-failure`,1326 "failed",
1313 message: `Fail during ${failurePoint}`,1327 );
1314 workspace: { type: "worktree", baseRef: "main" },1328 assert.match(
1315 });1329 projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "",
1316 const launched = yield* launches.launch(input);1330 new RegExp(`${failurePoint} failed`, "u"),
1317 yield* waitUntil(() =>1331 );
1318 threads1332 }).pipe(Effect.provide(harness.layer));
1319 .getThreadProjection(launched.threadId)1333 }),
1320 .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")),1334);
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}
13371335
1338it.effect("replays a server-allocated launch", () =>1336it.effect("replays a server-allocated launch", () =>
1339 Effect.gen(function* () {1337 Effect.gen(function* () {
...@@ -1989,61 +1987,59 @@ it.effect("cancels tracked setup before provider work is released", () =>...@@ -1989,61 +1987,59 @@ it.effect("cancels tracked setup before provider work is released", () =>
1989 }),1987 }),
1990);1988);
19911989
1992for (const exitCode of [0, 1]) {1990it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) =>
1993 it.effect(`releases an async setup before its completion with exit ${exitCode}`, () =>1991 Effect.gen(function* () {
1994 Effect.gen(function* () {1992 const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>();
1995 const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>();1993 const harness = makeHarness({
1996 const harness = makeHarness({1994 runSetup: () =>
1997 runSetup: () =>1995 Effect.succeed({
1998 Effect.succeed({1996 status: "started" as const,
1999 status: "started" as const,1997 async: true,
2000 async: true,1998 scriptId: "setup",
2001 scriptId: "setup",1999 scriptName: "Setup",
2002 scriptName: "Setup",2000 scriptCommand: "vp install",
2003 scriptCommand: "vp install",2001 terminalId: "setup",
2004 terminalId: "setup",2002 cwd: "/repo-worktrees/feature",
2005 cwd: "/repo-worktrees/feature",2003 completion: Deferred.await(completion),
2006 completion: Deferred.await(completion),2004 }),
2007 }),2005 });
2008 });2006 yield* Effect.gen(function* () {
2009 yield* Effect.gen(function* () {2007 const launches = yield* ThreadLaunch.ThreadLaunchService;
2010 const launches = yield* ThreadLaunch.ThreadLaunchService;2008 const threads = yield* ThreadManagement.ThreadManagementService;
2011 const threads = yield* ThreadManagement.ThreadManagementService;2009 const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
2012 const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;2010 const launched = yield* launches.launch(
2013 const launched = yield* launches.launch(2011 launchInput({
2014 launchInput({2012 command: `command:launch:async-${exitCode}`,
2015 command: `command:launch:async-${exitCode}`,2013 thread: `thread:launch:async-${exitCode}`,
2016 thread: `thread:launch:async-${exitCode}`,2014 message: "Start during setup",
2017 message: "Start during setup",2015 workspace: { type: "worktree", baseRef: "main" },
2018 workspace: { type: "worktree", baseRef: "main" },2016 }),
2019 }),2017 );
2020 );2018 yield* tracker.stream(launched.threadId).pipe(
2021 yield* tracker.stream(launched.threadId).pipe(2019 Stream.filter(
2022 Stream.filter(2020 (snapshot) =>
2023 (snapshot) =>2021 snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") ===
2024 snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") ===2022 true,
2025 true,2023 ),
2026 ),2024 Stream.runHead,
2027 Stream.runHead,2025 );
2028 );2026 const running = yield* threads.getThreadProjection(launched.threadId);
2029 const running = yield* threads.getThreadProjection(launched.threadId);2027 assert.equal(running.runs[0]?.status, "starting");
2030 assert.equal(running.runs[0]?.status, "starting");2028 assert.equal((yield* tracker.get(launched.threadId))?.phase, "running");
2031 assert.equal((yield* tracker.get(launched.threadId))?.phase, "running");2029 yield* Deferred.succeed(completion, { exitCode, durationMs: 1 });
2032 yield* Deferred.succeed(completion, { exitCode, durationMs: 1 });2030 yield* tracker.stream(launched.threadId).pipe(
2033 yield* tracker.stream(launched.threadId).pipe(2031 Stream.filter((snapshot) => snapshot?.phase === "done"),
2034 Stream.filter((snapshot) => snapshot?.phase === "done"),2032 Stream.runHead,
2035 Stream.runHead,2033 );
2036 );2034 const settled = yield* tracker.get(launched.threadId);
2037 const settled = yield* tracker.get(launched.threadId);2035 assert.equal(
2038 assert.equal(2036 settled?.stages.find((stage) => stage.id === "setup-script")?.status,
2039 settled?.stages.find((stage) => stage.id === "setup-script")?.status,2037 exitCode === 0 ? "done" : "failed",
2040 exitCode === 0 ? "done" : "failed",2038 );
2041 );2039 assert.equal(
2042 assert.equal(2040 (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status,
2043 (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status,2041 "starting",
2044 "starting",2042 );
2045 );2043 }).pipe(Effect.provide(harness.layer));
2046 }).pipe(Effect.provide(harness.layer));2044 }),
2047 }),2045);
2048 );
2049}
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,7 +344,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
344 }).pipe(Effect.provide(testLayer));344 }).pipe(Effect.provide(testLayer));
345});345});
346346
347for (const scenario of [347it.effect.each([
348 { finalStatus: "completed" as const, timedOut: false },348 { finalStatus: "completed" as const, timedOut: false },
349 { finalStatus: "failed" as const, timedOut: false },349 { finalStatus: "failed" as const, timedOut: false },
350 { finalStatus: "cancelled" as const, timedOut: false },350 { finalStatus: "cancelled" as const, timedOut: false },
...@@ -352,72 +352,70 @@ for (const scenario of [...@@ -352,72 +352,70 @@ for (const scenario of [
352 { finalStatus: "rolled_back" as const, timedOut: false },352 { finalStatus: "rolled_back" as const, timedOut: false },
353 { finalStatus: "running" as const, timedOut: true },353 { finalStatus: "running" as const, timedOut: true },
354 { finalStatus: "missing" as const },354 { finalStatus: "missing" as const },
355]) {355])("waitForThread timeout final read when selected run is $finalStatus", (scenario) =>
356 it.effect(`waitForThread timeout final read when selected run is ${scenario.finalStatus}`, () =>356 Effect.gen(function* () {
357 Effect.gen(function* () {357 const projectId = ProjectId.make("project:thread-management:wait-timeout");
358 const projectId = ProjectId.make("project:thread-management:wait-timeout");358 const threadId = ThreadId.make("thread:thread-management:wait-timeout");
359 const threadId = ThreadId.make("thread:thread-management:wait-timeout");359 const runId = RunId.make("run:thread-management:wait-timeout");
360 const runId = RunId.make("run:thread-management:wait-timeout");360 const loopRead = yield* Deferred.make<void>();
361 const loopRead = yield* Deferred.make<void>();361 let reads = 0;
362 let reads = 0;362 const projection = (status: OrchestrationV2Run["status"] | "missing") =>
363 const projection = (status: OrchestrationV2Run["status"] | "missing") =>363 ({
364 ({364 thread: { id: threadId, projectId, deletedAt: null },
365 thread: { id: threadId, projectId, deletedAt: null },365 runs: status === "missing" ? [] : [{ id: runId, status }],
366 runs: status === "missing" ? [] : [{ id: runId, status }],366 }) as unknown as OrchestrationV2ThreadProjection;
367 }) as unknown as OrchestrationV2ThreadProjection;367 const testLayer = ThreadManagementService.layer.pipe(
368 const testLayer = ThreadManagementService.layer.pipe(368 Layer.provide(
369 Layer.provide(369 Layer.mock(Orchestrator.OrchestratorV2)({
370 Layer.mock(Orchestrator.OrchestratorV2)({370 getThreadRecords: () =>
371 getThreadRecords: () =>371 Effect.gen(function* () {
372 Effect.gen(function* () {372 reads += 1;
373 reads += 1;373 if (reads === 1) {
374 if (reads === 1) {374 return projection("running");
375 return projection("running");375 }
376 }376 if (reads === 2) {
377 if (reads === 2) {377 // Park inside the wait loop so the timeout path runs while a
378 // Park inside the wait loop so the timeout path runs while a378 // final projection read can still observe a terminal run.
379 // final projection read can still observe a terminal run.379 yield* Deferred.succeed(loopRead, undefined);
380 yield* Deferred.succeed(loopRead, undefined);380 return yield* Effect.never;
381 return yield* Effect.never;381 }
382 }382 return projection(scenario.finalStatus);
383 return projection(scenario.finalStatus);383 }),
384 }),384 }),
385 }),385 ),
386 ),386 );
387 );387 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
388 const service = yield* ThreadManagementService.ThreadManagementService.pipe(388 Effect.provide(testLayer),
389 Effect.provide(testLayer),389 );
390 );390 const fiber = yield* service
391 const fiber = yield* service391 .waitForThread({
392 .waitForThread({392 projectId,
393 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 threadId,414 threadId,
395 runId,415 timedOut: scenario.timedOut,
396 timeoutMs: 1,416 run: { id: runId, status: scenario.finalStatus },
397 })417 },
398 .pipe(Effect.result, Effect.forkChild);418 });
399 yield* Deferred.await(loopRead);419 }
400 yield* TestClock.adjust(Duration.millis(1));420 }),
401 const result = yield* Fiber.join(fiber);421);
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}
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts+4-4
...@@ -438,8 +438,9 @@ describe("ThreadTitleRegenerationService", () => {...@@ -438,8 +438,9 @@ describe("ThreadTitleRegenerationService", () => {
438 );438 );
439});439});
440440
441for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) {441it.effect.each(["success", "exhausted", "stale", "interrupted"] as const)(
442 it.effect(`initial title retry: ${outcome}`, () =>442 "initial title retry: %s",
443 (outcome) =>
443 Effect.gen(function* () {444 Effect.gen(function* () {
444 const attempted = yield* Deferred.make<void>();445 const attempted = yield* Deferred.make<void>();
445 let attempts = 0;446 let attempts = 0;
...@@ -502,5 +503,4 @@ for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const)...@@ -502,5 +503,4 @@ for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const)
502 else assert.isNotOk(projection.thread.titleRegeneration);503 else assert.isNotOk(projection.thread.titleRegeneration);
503 }).pipe(Effect.provide(harness.layer));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,210 +2599,206 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
2599 }),2599 }),
2600 );2600 );
26012601
2602 for (const automatic of [false, true]) {2602 it.effect.each([false, true])(
2603 it.effect(2603 "promotes only one queued run after each terminal run (notification: %s)",
2604 `promotes only one queued run after each terminal run (notification: ${automatic})`,2604 (automatic) =>
2605 () =>2605 Effect.gen(function* () {
2606 Effect.gen(function* () {2606 const orchestrator = yield* Orchestrator.OrchestratorV2;
2607 const orchestrator = yield* Orchestrator.OrchestratorV2;2607 const eventSink = yield* EventSink.EventSinkV2;
2608 const eventSink = yield* EventSink.EventSinkV2;2608 const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`);
2609 const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`);
26102609
2611 yield* orchestrator.dispatch({2610 yield* orchestrator.dispatch({
2612 type: "thread.create",2611 type: "thread.create",
2613 createdBy: "user",2612 createdBy: "user",
2614 creationSource: "web",2613 creationSource: "web",
2615 commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`),2614 commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`),
2616 threadId,2615 threadId,
2617 projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`),2616 projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`),
2618 title: "Serialized queue",2617 title: "Serialized queue",
2619 modelSelection,2618 modelSelection,
2620 runtimeMode: "full-access",2619 runtimeMode: "full-access",
2621 interactionMode: "default",2620 interactionMode: "default",
2622 branch: null,2621 branch: null,
2623 worktreePath: process.cwd(),2622 worktreePath: process.cwd(),
2624 });2623 });
2625 yield* orchestrator.dispatch({2624 yield* orchestrator.dispatch({
2626 type: "message.dispatch",2625 type: "message.dispatch",
2627 createdBy: "user",2626 createdBy: "user",
2628 creationSource: "web",2627 creationSource: "web",
2629 commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`),2628 commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`),
2630 threadId,2629 threadId,
2631 messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`),2630 messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`),
2632 text: "Active",2631 text: "Active",
2633 attachments: [],2632 attachments: [],
2634 modelSelection,2633 modelSelection,
2635 dispatchMode: { type: "start_immediately" },2634 dispatchMode: { type: "start_immediately" },
2636 });2635 });
2637 yield* orchestrator.dispatch({2636 yield* orchestrator.dispatch({
2638 type: "message.dispatch",2637 type: "message.dispatch",
2639 createdBy: automatic ? "agent" : "user",2638 createdBy: automatic ? "agent" : "user",
2640 creationSource: automatic ? "provider" : "web",2639 creationSource: automatic ? "provider" : "web",
2641 ...(automatic2640 ...(automatic
2642 ? {2641 ? {
2643 notification: {2642 notification: {
2644 source: { kind: "monitor" as const },2643 source: { kind: "monitor" as const },
2645 outcome: "updated" as const,2644 outcome: "updated" as const,
2646 summary: "Monitor updated",2645 summary: "Monitor updated",
2647 detail: "Build is green",2646 detail: "Build is green",
2648 },2647 },
2649 }2648 }
2650 : {}),2649 : {}),
2651 commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`),2650 commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`),
2652 threadId,2651 threadId,
2653 messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`),2652 messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`),
2654 text: "First queued",2653 text: "First queued",
2655 attachments: [],2654 attachments: [],
2656 modelSelection,2655 modelSelection,
2657 dispatchMode: { type: "queue_after_active" },2656 dispatchMode: { type: "queue_after_active" },
2658 });2657 });
2659 yield* orchestrator.dispatch({2658 yield* orchestrator.dispatch({
2660 type: "message.dispatch",2659 type: "message.dispatch",
2661 createdBy: "user",2660 createdBy: "user",
2662 creationSource: "web",2661 creationSource: "web",
2663 commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`),2662 commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`),
2664 threadId,2663 threadId,
2665 messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`),2664 messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`),
2666 text: "Second queued",2665 text: "Second queued",
2667 attachments: [],2666 attachments: [],
2668 modelSelection,2667 modelSelection,
2669 dispatchMode: { type: "queue_after_active" },2668 dispatchMode: { type: "queue_after_active" },
2670 });2669 });
26712670
2672 const before = yield* orchestrator.getThreadProjection(threadId);2671 const before = yield* orchestrator.getThreadProjection(threadId);
2673 const activeRun = before.runs.find((run) => run.status === "starting");2672 const activeRun = before.runs.find((run) => run.status === "starting");
2674 const queuedRuns = before.runs2673 const queuedRuns = before.runs
2675 .filter((run) => run.status === "queued")2674 .filter((run) => run.status === "queued")
2676 .toSorted((left, right) => left.ordinal - right.ordinal);2675 .toSorted((left, right) => left.ordinal - right.ordinal);
2677 const firstQueuedRun = queuedRuns[0];2676 const firstQueuedRun = queuedRuns[0];
2678 const secondQueuedRun = queuedRuns[1];2677 const secondQueuedRun = queuedRuns[1];
2679 assert.isDefined(activeRun);2678 assert.isDefined(activeRun);
2680 assert.isDefined(firstQueuedRun);2679 assert.isDefined(firstQueuedRun);
2681 assert.isDefined(secondQueuedRun);2680 assert.isDefined(secondQueuedRun);
2682 assert.isFalse(2681 assert.isFalse(
2683 before.turnItems.some(2682 before.turnItems.some(
2684 (item) =>2683 (item) =>
2685 item.type === "user_message" &&2684 item.type === "user_message" &&
2686 (item.messageId === firstQueuedRun.userMessageId ||2685 (item.messageId === firstQueuedRun.userMessageId ||
2687 item.messageId === secondQueuedRun.userMessageId),2686 item.messageId === secondQueuedRun.userMessageId),
2688 ),2687 ),
2689 "queued messages must not exist as turn items before dispatch",2688 "queued messages must not exist as turn items before dispatch",
2690 );2689 );
26912690
2692 const promotedRunIds = yield* Queue.unbounded<RunId>();2691 const promotedRunIds = yield* Queue.unbounded<RunId>();
2693 const afterSequence = yield* orchestrator.getThreadEventSequence(threadId);2692 const afterSequence = yield* orchestrator.getThreadEventSequence(threadId);
2694 yield* eventSink.stream({ threadId, afterSequence }).pipe(2693 yield* eventSink.stream({ threadId, afterSequence }).pipe(
2695 Stream.runForEach((stored) =>2694 Stream.runForEach((stored) =>
2696 stored.event.type === "run.updated" && stored.event.payload.status === "starting"2695 stored.event.type === "run.updated" && stored.event.payload.status === "starting"
2697 ? Queue.offer(promotedRunIds, stored.event.payload.id)2696 ? Queue.offer(promotedRunIds, stored.event.payload.id)
2698 : Effect.void,2697 : Effect.void,
2699 ),2698 ),
2700 Effect.forkScoped,2699 Effect.forkScoped,
2701 );2700 );
2702 yield* Effect.yieldNow;2701 yield* Effect.yieldNow;
27032702
2704 const activeCompletedAt = yield* DateTime.now;2703 const activeCompletedAt = yield* DateTime.now;
2705 yield* eventSink.write({2704 yield* eventSink.write({
2706 events: [2705 events: [
2707 {2706 {
2708 id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`),2707 id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`),
2709 type: "run.updated",2708 type: "run.updated",
2710 threadId,2709 threadId,
2711 runId: activeRun.id,2710 runId: activeRun.id,
2712 ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }),2711 ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }),
2713 providerInstanceId: activeRun.providerInstanceId,2712 providerInstanceId: activeRun.providerInstanceId,
2714 occurredAt: activeCompletedAt,2713 occurredAt: activeCompletedAt,
2715 payload: {2714 payload: {
2716 ...activeRun,2715 ...activeRun,
2717 status: "completed",2716 status: "completed",
2718 completedAt: activeCompletedAt,2717 completedAt: activeCompletedAt,
2719 },
2720 },2718 },
2721 ],2719 },
2722 });2720 ],
2721 });
27232722
2724 assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id);2723 assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id);
2725 const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId);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 assert.equal(2741 assert.equal(
2727 afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status,2742 afterFirstPromotion.messages.find(
2728 "starting",2743 (message) => message.id === firstQueuedRun.userMessageId,
2744 )?.text,
2745 "First queued",
2729 );2746 );
2730 assert.equal(2747 assert.equal(
2731 afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status,2748 afterFirstPromotion.messages.find(
2732 "queued",2749 (message) => message.id === firstQueuedRun.userMessageId,
2750 )?.notification?.summary,
2751 "Monitor updated",
2733 );2752 );
2734 const promotedMessageItem = afterFirstPromotion.turnItems.find(2753 assert.isFalse(
2735 (item) =>2754 afterFirstPromotion.turnItems.some(
2736 item.runId === firstQueuedRun.id &&2755 (item) =>
2737 (item.type === "user_message" || item.type === "notification"),2756 item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId,
2738 );2757 ),
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),
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 );
27682767
2769 const promotedFirst = afterFirstPromotion.runs.find(2768 const promotedFirst = afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id);
2770 (run) => run.id === firstQueuedRun.id,2769 assert.isDefined(promotedFirst);
2771 );2770 const firstCompletedAt = yield* DateTime.now;
2772 assert.isDefined(promotedFirst);2771 yield* eventSink.write({
2773 const firstCompletedAt = yield* DateTime.now;2772 events: [
2774 yield* eventSink.write({2773 {
2775 events: [2774 id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`),
2776 {2775 type: "run.updated",
2777 id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`),2776 threadId,
2778 type: "run.updated",2777 runId: promotedFirst.id,
2779 threadId,2778 ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }),
2780 runId: promotedFirst.id,2779 providerInstanceId: promotedFirst.providerInstanceId,
2781 ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }),2780 occurredAt: firstCompletedAt,
2782 providerInstanceId: promotedFirst.providerInstanceId,2781 payload: {
2783 occurredAt: firstCompletedAt,2782 ...promotedFirst,
2784 payload: {2783 status: "completed",
2785 ...promotedFirst,2784 completedAt: firstCompletedAt,
2786 status: "completed",
2787 completedAt: firstCompletedAt,
2788 },
2789 },2785 },
2790 ],2786 },
2791 });2787 ],
2788 });
27922789
2793 assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id);2790 assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id);
2794 const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId);2791 const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId);
2795 assert.equal(2792 assert.equal(
2796 afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status,2793 afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status,
2797 "completed",2794 "completed",
2798 );2795 );
2799 assert.equal(2796 assert.equal(
2800 afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status,2797 afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status,
2801 "starting",2798 "starting",
2802 );2799 );
2803 }),2800 }),
2804 );2801 );
2805 }
28062802
2807 it.effect.each(["usage_limit", "provider_error"] as const)(2803 it.effect.each(["usage_limit", "provider_error"] as const)(
2808 "handles a queued message after a %s failure",2804 "handles a queued message after a %s failure",
...@@ -2993,112 +2989,106 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2993,112 +2989,106 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
2993 }),2989 }),
2994 );2990 );
29952991
2996 for (const trigger of ["startup", "shutdown"] as const) {2992 it.effect.each(["startup", "shutdown"] as const)(
2997 it.effect(2993 "preserves and holds queued messages across %s until explicitly resumed",
2998 `preserves and holds queued messages across ${trigger} until explicitly resumed`,2994 (trigger) =>
2999 () =>2995 Effect.gen(function* () {
3000 Effect.gen(function* () {2996 const orchestrator = yield* Orchestrator.OrchestratorV2;
3001 const orchestrator = yield* Orchestrator.OrchestratorV2;2997 const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService;
3002 const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService;2998 const threadId = ThreadId.make(`queue-hold-${trigger}`);
3003 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 yield* orchestrator.dispatch({3014 yield* orchestrator.dispatch({
3005 type: "thread.create",3015 type: "message.dispatch",
3006 createdBy: "user",3016 createdBy: "user",
3007 creationSource: "web",3017 creationSource: "web",
3008 commandId: CommandId.make(`${threadId}:create`),3018 commandId: CommandId.make(`${threadId}:message:${index}`),
3009 threadId,3019 threadId,
3010 projectId: ProjectId.make(`${threadId}:project`),3020 messageId: MessageId.make(`${threadId}:message:${index}`),
3011 title: "Recover queue",3021 text,
3022 attachments: [],
3012 modelSelection,3023 modelSelection,
3013 runtimeMode: "full-access",3024 dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" },
3014 interactionMode: "default",
3015 branch: null,
3016 worktreePath: process.cwd(),
3017 });3025 });
3018 for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) {3026 }
3019 yield* orchestrator.dispatch({3027 const before = yield* orchestrator.getThreadProjection(threadId);
3020 type: "message.dispatch",3028 const queued = before.runs.filter((run) => run.status === "queued");
3021 createdBy: "user",3029 assert.equal(queued.length, 2);
3022 creationSource: "web",3030 yield* recovery.reconcile(trigger);
3023 commandId: CommandId.make(`${threadId}:message:${index}`),3031 // A second boot must preserve the hold, even when only queued work remains.
3024 threadId,3032 yield* recovery.reconcile("startup");
3025 messageId: MessageId.make(`${threadId}:message:${index}`),3033 const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
3026 text,3034 assert.isTrue((yield* maintenance.rebuild).valid);
3027 attachments: [],3035 assert.equal(yield* orchestrator.resumeQueuedRuns, 0);
3028 modelSelection,3036 const held = yield* orchestrator.getThreadProjection(threadId);
3029 dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" },3037 assert.deepEqual(
3030 });3038 held.runs.map((run) => run.status),
3031 }3039 ["cancelled", "queued", "queued"],
3032 const before = yield* orchestrator.getThreadProjection(threadId);3040 );
3033 const queued = before.runs.filter((run) => run.status === "queued");3041 for (const run of 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);
3042 assert.deepEqual(3042 assert.deepEqual(
3043 held.runs.map((run) => run.status),3043 held.runs.find((row) => row.id === run.id),
3044 ["cancelled", "queued", "queued"],3044 { ...run, queueHeld: true },
3045 );3045 );
3046 for (const run of queued) {3046 assert.deepEqual(
3047 assert.deepEqual(3047 held.messages.find((row) => row.id === run.userMessageId),
3048 held.runs.find((row) => row.id === run.id),3048 before.messages.find((row) => row.id === run.userMessageId),
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",
3093 );3049 );
3094 assert.equal(3050 assert.equal(
3095 resumed.runs.length,3051 held.attempts.find((row) => row.id === run.activeAttemptId)?.status,
3096 3,3052 "pending",
3097 "resume retries must not duplicate messages or runs",
3098 );3053 );
3099 }),3054 assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending");
3100 );3055 }
3101 }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 );
31023092
3103 it.effect("edits and removes queued runs", () =>3093 it.effect("edits and removes queued runs", () =>
3104 Effect.gen(function* () {3094 Effect.gen(function* () {
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts+44-39
...@@ -221,19 +221,19 @@ function runFixtureProviderWithRegisteredHarness(input: {...@@ -221,19 +221,19 @@ function runFixtureProviderWithRegisteredHarness(input: {
221}221}
222222
223describe("orchestrator replay fixtures", () => {223describe("orchestrator replay fixtures", () => {
224 for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) {224 it.effect.each(
225 for (const provider of fixture.providers) {225 ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) =>
226 it.effect(226 fixture.providers.map(
227 `runs ${fixture.name}/${provider.driver} through OrchestratorV2 using deterministic replay`,227 (provider) => [fixture.name, provider.driver, fixture, provider] as const,
228 () =>228 ),
229 runFixtureProviderWithRegisteredHarness({229 ),
230 fixtureName: fixture.name,230 )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) =>
231 buildInput: fixture.buildInput,231 runFixtureProviderWithRegisteredHarness({
232 driver: provider,232 fixtureName: fixture.name,
233 }),233 buildInput: fixture.buildInput,
234 );234 driver: provider,
235 }235 }),
236 }236 );
237237
238 const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find(238 const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find(
239 (fixture) => fixture.name === "message_steering",239 (fixture) => fixture.name === "message_steering",
...@@ -254,33 +254,38 @@ describe("orchestrator replay fixtures", () => {...@@ -254,33 +254,38 @@ describe("orchestrator replay fixtures", () => {
254 // A later OpenCode may change an execution start's shape; the client then254 // A later OpenCode may change an execution start's shape; the client then
255 // reads it as `unreadable.execution.started`. A subagent's turn and a255 // reads it as `unreadable.execution.started`. A subagent's turn and a
256 // background follow-up must still start from it.256 // background follow-up must still start from it.
257 for (const [fixtureName, label] of [257 it.effect.each(
258 ["opencode2_subagent", "session.execution.started.2"],258 (
259 ["opencode2_background", "session.execution.started.3"],259 [
260 ] as const) {260 ["opencode2_subagent", "session.execution.started.2"],
261 const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find(261 ["opencode2_background", "session.execution.started.3"],
262 (candidate) => candidate.name === fixtureName,262 ] as const
263 );263 ).flatMap(([fixtureName, label]) => {
264 const provider = fixture?.providers[0];264 const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find(
265 if (fixture === undefined || provider === undefined) continue;265 (candidate) => candidate.name === fixtureName,
266 it.effect(266 );
267 `runs ${fixtureName} when ${label} is an execution start this build cannot decode`,267 const provider = fixture?.providers[0];
268 () =>268 return fixture === undefined || provider === undefined
269 runFixtureProviderWithRegisteredHarness({269 ? []
270 fixtureName,270 : [[fixtureName, label, fixture, provider] as const];
271 buildInput: fixture.buildInput,271 }),
272 driver: provider,272 )(
273 transformTranscript: (transcript) => ({273 "runs %s when %s is an execution start this build cannot decode",
274 ...transcript,274 ([fixtureName, label, fixture, provider]) =>
275 entries: transcript.entries.map((entry) =>275 runFixtureProviderWithRegisteredHarness({
276 entry.type === "emit_inbound" && entry.label === label276 fixtureName,
277 ? undecodableEvent(entry)277 buildInput: fixture.buildInput,
278 : entry,278 driver: provider,
279 ),279 transformTranscript: (transcript) => ({
280 }),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 );287 }),
283 }288 );
284});289});
285290
286/** The same event with an envelope this build cannot decode, as a newer OpenCode may send. */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,7 +374,7 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* (
374});374});
375375
376describe("orchestration v2 provider switching", () => {376describe("orchestration v2 provider switching", () => {
377 for (const scenario of [377 it.live.each([
378 "compact-native",378 "compact-native",
379 "compact-fallback",379 "compact-fallback",
380 "compact-legacy",380 "compact-legacy",
...@@ -408,35 +408,496 @@ describe("orchestration v2 provider switching", () => {...@@ -408,35 +408,496 @@ describe("orchestration v2 provider switching", () => {
408 "prior-images-replacement-native",408 "prior-images-replacement-native",
409 "prior-images-legacy-replacement-native",409 "prior-images-legacy-replacement-native",
410 "delivery-write-failure",410 "delivery-write-failure",
411 ] as const) {411 ] as const)("preserves handoffs through %s", (scenario) =>
412 it.live(`preserves handoffs through ${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 Effect.scoped(894 Effect.scoped(
414 Effect.gen(function* () {895 Effect.gen(function* () {
415 const cwd = yield* checkpointWorkspace(`handoff-${scenario}`);896 const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`);
416 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);897 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
417 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);898 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
418 const reasoningScenario = scenario.includes("reasoning-change");899 const failOnce = yield* Ref.make(true);
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);
424 const generation = yield* Ref.make(0);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 const registry = ProviderAdapterRegistry.makeLayer([901 const registry = ProviderAdapterRegistry.makeLayer([
441 makeTestAdapter({902 makeTestAdapter({
442 instanceId: CODEX_MODEL_SELECTION.instanceId,903 instanceId: CODEX_MODEL_SELECTION.instanceId,
...@@ -453,94 +914,31 @@ describe("orchestration v2 provider switching", () => {...@@ -453,94 +914,31 @@ describe("orchestration v2 provider switching", () => {
453 modelSelection: CLAUDE_MODEL_SELECTION,914 modelSelection: CLAUDE_MODEL_SELECTION,
454 responseByRunOrdinal: {},915 responseByRunOrdinal: {},
455 capturedTurns,916 capturedTurns,
456 ...(scenario.includes("retry") || scenario.includes("unsent")917 injectedHistory,
457 ? { failStartOnce }918 nativeThreadGeneration: generation,
458 : {}),919 getModelContextWindow: () => 32_000,
459 ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}),920 ...(failure !== "injection"
460 ...(scenario.includes("reported-capacity")921 ? { failStartOnce: failOnce }
461 ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } }922 : { failInjectionOnce: failOnce }),
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 : {}),
493 }),923 }),
494 ]);924 ]);
495 yield* Effect.gen(function* () {925 yield* Effect.gen(function* () {
496 const orchestrator = yield* Orchestrator.OrchestratorV2;926 const orchestrator = yield* Orchestrator.OrchestratorV2;
497 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;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 const dispatch = (ordinal: number, text: string, selection: ModelSelection) =>928 const dispatch = (ordinal: number, text: string, selection: ModelSelection) =>
519 orchestrator.dispatch({929 orchestrator.dispatch({
520 type: "message.dispatch",930 type: "message.dispatch",
521 commandId: CommandId.make(`regression:${ordinal}`),931 commandId: CommandId.make(`retry:${ordinal}`),
522 threadId,932 threadId,
523 messageId: MessageId.make(`regression:${ordinal}`),933 messageId: MessageId.make(`retry:${ordinal}`),
524 createdBy: "user",934 createdBy: "user",
525 creationSource: "web",935 creationSource: "web",
526 text,936 text,
527 attachments:937 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 : [],
540 modelSelection: selection,938 modelSelection: selection,
541 dispatchMode: { type: "start_immediately" },939 dispatchMode: { type: "start_immediately" },
542 });940 });
543 const wait = (ordinal: number) =>941 const wait = (ordinal: number, status: "failed" | "completed") =>
544 orchestrator.streamStoredEvents.pipe(942 orchestrator.streamStoredEvents.pipe(
545 Stream.filter(943 Stream.filter(
546 ({ event }) =>944 ({ event }) =>
...@@ -550,15 +948,23 @@ describe("orchestration v2 provider switching", () => {...@@ -550,15 +948,23 @@ describe("orchestration v2 provider switching", () => {
550 ),948 ),
551 Stream.runHead,949 Stream.runHead,
552 Effect.andThen(worker.drain()),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 yield* orchestrator.dispatch({960 yield* orchestrator.dispatch({
555 type: "thread.create",961 type: "thread.create",
556 commandId: CommandId.make("regression:create"),962 commandId: CommandId.make("retry:create"),
557 threadId,963 threadId,
558 projectId,964 projectId,
559 createdBy: "user",965 createdBy: "user",
560 creationSource: "web",966 creationSource: "web",
561 title: "Handoff regression",967 title: "Handoff retry",
562 modelSelection: CODEX_MODEL_SELECTION,968 modelSelection: CODEX_MODEL_SELECTION,
563 runtimeMode: "full-access",969 runtimeMode: "full-access",
564 interactionMode: "default",970 interactionMode: "default",
...@@ -566,320 +972,71 @@ describe("orchestration v2 provider switching", () => {...@@ -566,320 +972,71 @@ describe("orchestration v2 provider switching", () => {
566 worktreePath: null,972 worktreePath: null,
567 });973 });
568 yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION);974 yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION);
569 yield* wait(1);975 yield* wait(1, "completed");
570 const write = eventSink.write;976 yield* dispatch(
571 const spy =977 2,
572 scenario === "delivery-write-failure"978 failure === "large-missed-request" ? "x".repeat(7_000) : "First target request",
573 ? vi.spyOn(eventSink, "write").mockImplementation((input) =>979 CLAUDE_MODEL_SELECTION,
574 input.events.some(980 );
575 (event) =>981 yield* wait(2, "failed");
576 event.type === "context-handoff.updated" &&982 const failed = yield* orchestrator.getThreadProjection(threadId);
577 event.payload.delivery?.status === "inline",983 const failedHandoff = failed.contextHandoffs.at(-1)!;
578 )984 assert.equal(
579 ? Effect.fail(985 failedHandoff.delivery?.status,
580 new EventSink.EventSinkWriteError({986 failure !== "injection" ? "injected" : "pending",
581 eventCount: input.events.length,987 );
582 cause: "bookkeeping unavailable",988 const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length;
583 }),989 const retryText =
584 )990 failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request";
585 : write(input),991 yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION);
586 )992 yield* wait(3, "completed");
587 : undefined;993 const retried = yield* orchestrator.getThreadProjection(threadId);
588 yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore()));994 const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!;
589 const current = scenario.startsWith("compact")995 assert.equal(lastTurn.text, retryText);
590 ? "/compact"996 if (failure !== "injection") {
591 : capacityScenario && !turnUsageScenario997 const delta = yield* encodeJson(
592 ? "x".repeat(70_000)998 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
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,
604 );999 );
605 yield* wait(2);1000 if (failure === "large-missed-request") {
606 if (modelScenario && !turnUsageScenario) {1001 assert.include(delta, "omitted 1 items");
607 const target = (yield* orchestrator.getThreadProjection(1002 const missedRequest = retried.turnItems.find(
608 threadId,1003 (item) => item.type === "user_message" && item.text === "x".repeat(7_000),
609 )).providerThreads.find(
610 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
611 )!;1004 )!;
612 yield* eventSink.write({1005 const delivery = retried.contextHandoffs.at(-1)!.delivery!;
613 events: [1006 assert.include(delivery.omittedItemIds ?? [], missedRequest.id);
614 {1007 assert.notInclude(delivery.itemIds, missedRequest.id);
615 id: EventId.make("old-model-usage"),1008 } else assert.include(delta, "First target request");
616 type: "provider-thread.updated",1009 assert.notInclude(delta, "Original request with constraints");
617 threadId,1010 assert.notInclude(delta, "Original partial work");
618 occurredAt: yield* DateTime.now,1011 assert.equal(yield* Ref.get(generation), 1);
619 payload: {1012 } else {
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);
726 assert.equal(yield* Ref.get(generation), 2);1013 assert.equal(yield* Ref.get(generation), 2);
727 assert.equal(1014 assert.notEqual(
728 (yield* Ref.get(capturedTurns)).at(-1)!.driver,1015 retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId,
729 scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER,1016 failedHandoff.delivery?.nativeThreadId,
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",
869 );1017 );
870 } else {1018 const newHistory = yield* encodeJson(
871 assert.equal(handoff.delivery?.status, "injected");1019 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
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",
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 }).pipe(1035 }).pipe(
879 Effect.provide(1036 Effect.provide(
880 makeOrchestratorV2ReplayLayerWithRegistry(1037 makeOrchestratorV2ReplayLayerWithRegistry(
881 {1038 {
882 name: `handoff-${scenario}`,1039 name: `handoff-retry-${failure}`,
883 runtimePolicyOverride: {1040 runtimePolicyOverride: {
884 cwd,1041 cwd,
885 approvalPolicy: "never",1042 approvalPolicy: "never",
...@@ -892,368 +1049,194 @@ describe("orchestration v2 provider switching", () => {...@@ -892,368 +1049,194 @@ describe("orchestration v2 provider switching", () => {
892 );1049 );
893 }),1050 }),
894 ),1051 ),
895 );1052 );
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 }
10611053
1062 for (const status of ["failed", "interrupted"] as const) {1054 it.live.each(
1063 for (const queued of [false, true]) {1055 (["failed", "interrupted"] as const).flatMap((status) =>
1064 for (const returning of [false, true]) {1056 [false, true].flatMap((queued) =>
1065 for (const native of [false, true]) {1057 [false, true].flatMap((returning) =>
1066 it.live(1058 [false, true].map(
1067 `hands off ${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`,1059 (native) =>
1068 () =>1060 [
1069 Effect.scoped(1061 `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`,
1070 Effect.gen(function* () {1062 { status, queued, returning, native },
1071 const cwd = yield* checkpointWorkspace(1063 ] as const,
1072 `handoff-${status}-${queued}-${returning}`,1064 ),
1073 );1065 ),
1074 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);1066 ),
1075 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);1067 ),
1076 const started = yield* Deferred.make<void>();1068 )("hands off %s", ([, { status, queued, returning, native }]) =>
1077 const release = yield* Deferred.make<void>();1069 Effect.scoped(
1078 const sourceOrdinal = returning ? 2 : 1;1070 Effect.gen(function* () {
1079 const sourceSelection = returning1071 const cwd = yield* checkpointWorkspace(`handoff-${status}-${queued}-${returning}`);
1080 ? CLAUDE_MODEL_SELECTION1072 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1081 : CODEX_MODEL_SELECTION;1073 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
1082 const targetSelection = returning1074 const started = yield* Deferred.make<void>();
1083 ? CODEX_MODEL_SELECTION1075 const release = yield* Deferred.make<void>();
1084 : CLAUDE_MODEL_SELECTION;1076 const sourceOrdinal = returning ? 2 : 1;
1085 const originalPrompt =1077 const sourceSelection = returning ? CLAUDE_MODEL_SELECTION : CODEX_MODEL_SELECTION;
1086 "Keep the release marker violet and preserve the existing API.";1078 const targetSelection = returning ? CODEX_MODEL_SELECTION : CLAUDE_MODEL_SELECTION;
1087 const partialResponse = "I checked the API and found the release configuration.";1079 const originalPrompt = "Keep the release marker violet and preserve the existing API.";
1088 const registryLayer = ProviderAdapterRegistry.makeLayer(1080 const partialResponse = "I checked the API and found the release configuration.";
1089 (1081 const registryLayer = ProviderAdapterRegistry.makeLayer(
1090 [1082 (
1091 [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2],1083 [
1092 [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2],1084 [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2],
1093 ] as const1085 [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2],
1094 ).map(([modelSelection, driver, capabilities]) =>1086 ] as const
1095 makeTestAdapter({1087 ).map(([modelSelection, driver, capabilities]) =>
1096 instanceId: modelSelection.instanceId,1088 makeTestAdapter({
1097 driver,1089 instanceId: modelSelection.instanceId,
1098 capabilities,1090 driver,
1099 modelSelection,1091 capabilities,
1100 responseByRunOrdinal: { [sourceOrdinal]: partialResponse },1092 modelSelection,
1101 capturedTurns,1093 responseByRunOrdinal: { [sourceOrdinal]: partialResponse },
1102 ...(native && modelSelection.instanceId === targetSelection.instanceId1094 capturedTurns,
1103 ? { injectedHistory }1095 ...(native && modelSelection.instanceId === targetSelection.instanceId
1104 : {}),1096 ? { injectedHistory }
1105 ...(modelSelection.instanceId === sourceSelection.instanceId1097 : {}),
1106 ? {1098 ...(modelSelection.instanceId === sourceSelection.instanceId
1107 failedRunOrdinals: new Set(1099 ? {
1108 status === "failed" ? [sourceOrdinal] : [],1100 failedRunOrdinals: new Set(status === "failed" ? [sourceOrdinal] : []),
1109 ),1101 interruptedRunOrdinals: new Set(
1110 interruptedRunOrdinals: new Set(1102 status === "interrupted" ? [sourceOrdinal] : [],
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 ),
1248 ),1103 ),
1249 );1104 holdRunOrdinal: sourceOrdinal,
1250 }),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 }1186 assert.isDefined(handoff);
1254 }1187 assert.equal(
1255 }1188 handoff?.strategy,
1256 }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 );
12571240
1258 it.live("checks the queued provider's capability while the current provider stays running", () =>1241 it.live("checks the queued provider's capability while the current provider stays running", () =>
1259 Effect.scoped(1242 Effect.scoped(
...@@ -2998,229 +2981,226 @@ describe("orchestration v2 provider switching", () => {...@@ -2998,229 +2981,226 @@ describe("orchestration v2 provider switching", () => {
2998 ),2981 ),
2999 );2982 );
30002983
3001 for (const failResume of [false, true]) {2984 it.live.each([false, true])(
3002 it.live(2985 "switches providers while consuming a pending cross-provider merge-back (resume failure: %s)",
3003 `switches providers while consuming a pending cross-provider merge-back (resume failure: ${failResume})`,2986 (failResume) =>
3004 () =>2987 Effect.scoped(
3005 Effect.scoped(2988 Effect.gen(function* () {
3006 Effect.gen(function* () {2989 const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source");
3007 const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source");2990 const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork");
3008 const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork");2991 const firstSourcePrompt = "Remember that the first source marker is amber.";
3009 const firstSourcePrompt = "Remember that the first source marker is amber.";2992 const secondSourcePrompt = "Remember that the second source marker is violet.";
3010 const secondSourcePrompt = "Remember that the second source marker is violet.";2993 const forkPrompt = "Remember that the fork marker is cobalt.";
3011 const forkPrompt = "Remember that the fork marker is cobalt.";2994 const mergePrompt = "Report all three remembered markers.";
3012 const mergePrompt = "Report all three remembered markers.";2995 const cwd = yield* checkpointWorkspace("cross-provider-merge");
3013 const cwd = yield* checkpointWorkspace("cross-provider-merge");2996 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
3014 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);2997 const registryLayer = ProviderAdapterRegistry.makeLayer([
3015 const registryLayer = ProviderAdapterRegistry.makeLayer([2998 makeTestAdapter({
3016 makeTestAdapter({2999 instanceId: ProviderInstanceId.make("codex"),
3017 instanceId: ProviderInstanceId.make("codex"),3000 driver: CODEX_DRIVER,
3018 driver: CODEX_DRIVER,3001 capabilities: CodexProviderCapabilitiesV2,
3019 capabilities: CodexProviderCapabilitiesV2,3002 modelSelection: CODEX_MODEL_SELECTION,
3020 modelSelection: CODEX_MODEL_SELECTION,3003 responseByRunOrdinal: {},
3021 responseByRunOrdinal: {},3004 responseByThreadId: {
3022 responseByThreadId: {3005 [sourceThreadId]: {
3023 [sourceThreadId]: {3006 1: "I will remember amber.",
3024 1: "I will remember amber.",3007 3: "The markers are amber, violet, and cobalt.",
3025 3: "The markers are amber, violet, and cobalt.",3008 },
3026 },3009 [forkThreadId]: {
3027 [forkThreadId]: {3010 1: "I will remember cobalt.",
3028 1: "I will remember cobalt.",
3029 },
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>;
31263108
3127 const projection = yield* Effect.gen(function* () {3109 const projection = yield* Effect.gen(function* () {
3128 const orchestrator = yield* Orchestrator.OrchestratorV2;3110 const orchestrator = yield* Orchestrator.OrchestratorV2;
3129 yield* orchestrator.dispatch(commands[0]!);3111 yield* orchestrator.dispatch(commands[0]!);
3130 yield* orchestrator.dispatch(commands[1]!);3112 yield* orchestrator.dispatch(commands[1]!);
3131 yield* waitForIdle(sourceThreadId);3113 yield* waitForIdle(sourceThreadId);
3132 yield* orchestrator.dispatch(commands[2]!);3114 yield* orchestrator.dispatch(commands[2]!);
3133 yield* waitForIdle(sourceThreadId);3115 yield* waitForIdle(sourceThreadId);
3134 yield* orchestrator.dispatch(commands[3]!);3116 yield* orchestrator.dispatch(commands[3]!);
3135 yield* orchestrator.dispatch(commands[4]!);3117 yield* orchestrator.dispatch(commands[4]!);
3136 yield* waitForIdle(forkThreadId);3118 yield* waitForIdle(forkThreadId);
3137 yield* orchestrator.dispatch(commands[5]!);3119 yield* orchestrator.dispatch(commands[5]!);
3138 if (failResume) {3120 if (failResume) {
3139 // A persisted native ref that is not loaded in this process must3121 // A persisted native ref that is not loaded in this process must
3140 // exercise resume rather than the session manager's warm cache.3122 // exercise resume rather than the session manager's warm cache.
3141 const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId);3123 const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId);
3142 const codexThread = beforeResume.providerThreads.find(3124 const codexThread = beforeResume.providerThreads.find(
3143 (thread) => thread.id === beforeResume.runs[0]?.providerThreadId,3125 (thread) => thread.id === beforeResume.runs[0]?.providerThreadId,
3144 )!;3126 )!;
3145 yield* (yield* EventSink.EventSinkV2).write({3127 yield* (yield* EventSink.EventSinkV2).write({
3146 events: [3128 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(
3168 {3129 {
3169 name: "cross-provider-merge",3130 id: EventId.make("unloaded-merge-target"),
3170 runtimePolicyOverride: {3131 type: "provider-thread.updated",
3171 cwd,3132 threadId: sourceThreadId,
3172 approvalPolicy: "never",3133 occurredAt: yield* DateTime.now,
3173 sandboxPolicy: {3134 payload: {
3174 type: "readOnly",3135 ...codexThread,
3175 access: { type: "fullAccess" },3136 nativeThreadRef: {
3176 networkAccess: false,3137 ...codexThread.nativeThreadRef!,
3138 nativeId: "unloaded-native-merge-target",
3177 },3139 },
3178 },3140 },
3179 },3141 },
3180 registryLayer,3142 ],
3181 ),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);3181 assert.isDefined(mergedTurn);
3185 const mergedTurn = turns.findLast(3182 if (failResume)
3186 (turn) => turn.threadId === sourceThreadId && turn.driver === "codex",3183 assert.notEqual(
3187 );3184 projection.providerThreads.find((thread) => thread.id === mergedTurn.providerThreadId)
3188 const mergeTransfer = projection.contextTransfers.find(3185 ?.nativeThreadRef?.nativeId,
3189 (transfer) => transfer.type === "merge_back",3186 "unloaded-native-merge-target",
3190 );3187 );
31913188 assert.include(mergedTurn.text, "Context handoff (full_thread_summary):");
3192 if (failResume)3189 assert.include(mergedTurn.text, firstSourcePrompt);
3193 assert.isAtLeast(3190 assert.include(mergedTurn.text, "I will remember amber.");
3194 projection.contextHandoffs.filter(3191 assert.include(mergedTurn.text, secondSourcePrompt);
3195 (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id,3192 assert.include(mergedTurn.text, "I will remember violet.");
3196 ).length,3193 assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):");
3197 2,3194 assert.include(mergedTurn.text, forkPrompt);
3198 );3195 assert.include(mergedTurn.text, "I will remember cobalt.");
3199 assert.isDefined(mergedTurn);3196 assert.include(mergedTurn.text, mergePrompt);
3200 if (failResume)3197 assert.isDefined(mergeTransfer);
3201 assert.notEqual(3198 assert.equal(mergeTransfer.status, "consumed");
3202 projection.providerThreads.find(3199 assert.equal(mergeTransfer.targetProviderInstanceId, "codex");
3203 (thread) => thread.id === mergedTurn.providerThreadId,3200 assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context");
3204 )?.nativeThreadRef?.nativeId,3201 }),
3205 "unloaded-native-merge-target",3202 ),
3206 );3203 );
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 }
32243204
3225 it.live("routes two custom instances of the same driver independently", () =>3205 it.live("routes two custom instances of the same driver independently", () =>
3226 Effect.scoped(3206 Effect.scoped(
apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts+8-6
...@@ -257,8 +257,9 @@ function makeCreateCommand(input: {...@@ -257,8 +257,9 @@ function makeCreateCommand(input: {
257}257}
258258
259describe("orchestration V2 merge-back provider replay", () => {259describe("orchestration V2 merge-back provider replay", () => {
260 for (const variant of PROVIDERS) {260 it.effect.each(PROVIDERS)(
261 it.effect(`merges one fork delta back into the original ${variant.driver} thread`, () =>261 "merges one fork delta back into the original $driver thread",
262 (variant) =>
262 Effect.gen(function* () {263 Effect.gen(function* () {
263 const rawTranscript = yield* readTranscript("thread_merge_back_continue", variant.driver);264 const rawTranscript = yield* readTranscript("thread_merge_back_continue", variant.driver);
264 const materialized = yield* Effect.gen(function* () {265 const materialized = yield* Effect.gen(function* () {
...@@ -452,9 +453,11 @@ describe("orchestration V2 merge-back provider replay", () => {...@@ -452,9 +453,11 @@ describe("orchestration V2 merge-back provider replay", () => {
452 assert.notInclude(visibleConversationText(source), "Context handoff (");453 assert.notInclude(visibleConversationText(source), "Context handoff (");
453 assert.include(visibleConversationText(fork), "merge fork stored");454 assert.include(visibleConversationText(fork), "merge fork stored");
454 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),455 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
455 );456 );
456457
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 Effect.gen(function* () {461 Effect.gen(function* () {
459 const rawTranscript = yield* readTranscript("thread_merge_back_siblings", variant.driver);462 const rawTranscript = yield* readTranscript("thread_merge_back_siblings", variant.driver);
460 const materialized = yield* Effect.gen(function* () {463 const materialized = yield* Effect.gen(function* () {
...@@ -725,6 +728,5 @@ describe("orchestration V2 merge-back provider replay", () => {...@@ -725,6 +728,5 @@ describe("orchestration V2 merge-back provider replay", () => {
725 assert.include(visibleConversationText(secondFork), "second merge sibling stored");728 assert.include(visibleConversationText(secondFork), "second merge sibling stored");
726 assert.notInclude(visibleConversationText(secondFork), "first merge sibling stored");729 assert.notInclude(visibleConversationText(secondFork), "first merge sibling stored");
727 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),730 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
728 );731 );
729 }
730});732});
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+4-4
...@@ -394,8 +394,9 @@ layer("OrchestrationEventStore", (it) => {...@@ -394,8 +394,9 @@ layer("OrchestrationEventStore", (it) => {
394 );394 );
395});395});
396396
397for (const phase of ["high-water", "replay"] as const) {397it.effect.each(["high-water", "replay"] as const)(
398 it.effect(`bounds application live events while the ${phase} query is blocked`, () =>398 "bounds application live events while the %s query is blocked",
399 (phase) =>
399 Effect.scoped(400 Effect.scoped(
400 Effect.gen(function* () {401 Effect.gen(function* () {
401 const store = yield* OrchestrationEventStore.OrchestrationEventStore;402 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
...@@ -464,8 +465,7 @@ for (const phase of ["high-water", "replay"] as const) {...@@ -464,8 +465,7 @@ for (const phase of ["high-water", "replay"] as const) {
464 }465 }
465 }),466 }),
466 ).pipe(Effect.provide(Layer.fresh(TestLayer))),467 ).pipe(Effect.provide(Layer.fresh(TestLayer))),
467 );468);
468}
469469
470it.effect("releases consumed application replay pages", () =>470it.effect("releases consumed application replay pages", () =>
471 Effect.gen(function* () {471 Effect.gen(function* () {
apps/server/src/persistence/reconcileV2PreviewMigration.test.ts+4-4
...@@ -60,8 +60,9 @@ describe("V2 preview upgrade", () => {...@@ -60,8 +60,9 @@ describe("V2 preview upgrade", () => {
60 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),60 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
61 );61 );
6262
63 for (const withIndexes of [false, true]) {63 it.effect.each([false, true])(
64 it.effect(`upgrades preview migration 54 with index cleanup ${withIndexes}`, () =>64 "upgrades preview migration 54 with index cleanup %s",
65 (withIndexes) =>
65 Effect.gen(function* () {66 Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;67 const sql = yield* SqlClient.SqlClient;
67 yield* runMigrations({ toMigrationInclusive: 52 });68 yield* runMigrations({ toMigrationInclusive: 52 });
...@@ -89,8 +90,7 @@ describe("V2 preview upgrade", () => {...@@ -89,8 +90,7 @@ describe("V2 preview upgrade", () => {
89 }>`PRAGMA table_info(projection_threads)`;90 }>`PRAGMA table_info(projection_threads)`;
90 assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at"));91 assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at"));
91 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),92 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
92 );93 );
93 }
9494
95 it.effect("rolls back schema and ledger together on failure and can retry", () =>95 it.effect("rolls back schema and ledger together on failure and can retry", () =>
96 Effect.gen(function* () {96 Effect.gen(function* () {
apps/server/src/provider/CodexChatGptAuth.test.ts+107-118
...@@ -426,36 +426,34 @@ it.effect(...@@ -426,36 +426,34 @@ it.effect(
426 }),426 }),
427 ),427 ),
428);428);
429for (const failure of ["identity", "sharing"] as const) {429it.effect.each(["identity", "sharing"] as const)(
430 it.effect(430 "failed account change preserves the original credentials and registration: %s",
431 `failed account change preserves the original credentials and registration: ${failure}`,431 (failure) =>
432 () =>432 provision(
433 provision(433 Effect.gen(function* () {
434 Effect.gen(function* () {434 const h = yield* makeHarness;
435 const h = yield* makeHarness;435 yield* h.signIn;
436 yield* h.signIn;436 yield* h.phase("succeeded");
437 yield* h.phase("succeeded");437 const before = h.storedRecords();
438 const before = h.storedRecords();438 h.setCallbackClientId("oaiapp_other_account");
439 h.setCallbackClientId("oaiapp_other_account");439 if (failure === "identity") h.setInvalidNonce();
440 if (failure === "identity") h.setInvalidNonce();440 else h.declineSharing();
441 else h.declineSharing();441 yield* h.changeAccount;
442 yield* h.changeAccount;442 yield* h.phase("failed");
443 yield* h.phase("failed");443 assert.strictEqual(
444 assert.strictEqual(444 h.authorizationRequests[1]!.searchParams.get("client_id"),
445 h.authorizationRequests[1]!.searchParams.get("client_id"),445 "dynamic_agent_client",
446 "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") {452 assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2);
449 assert.deepEqual(453 } else assert.deepEqual(h.storedRecords(), before);
450 Option.getOrThrow(yield* h.auth.read),454 }),
451 before.find((record) => record.accessToken),455 ),
452 );456);
453 assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2);
454 } else assert.deepEqual(h.storedRecords(), before);
455 }),
456 ),
457 );
458}
459it.effect("retains the callback host and path after controller recreation and token removal", () =>457it.effect("retains the callback host and path after controller recreation and token removal", () =>
460 provision(458 provision(
461 Effect.gen(function* () {459 Effect.gen(function* () {
...@@ -961,48 +959,42 @@ it.effect("rejects mismatched callback state before any token exchange or creden...@@ -961,48 +959,42 @@ it.effect("rejects mismatched callback state before any token exchange or creden
961 }),959 }),
962 ),960 ),
963);961);
964for (const invalidClaim of ["issuer", "audience", "signature"] as const) {962it.effect.each(["issuer", "audience", "signature"] as const)(
965 it.effect(963 "rejects ID token %s verification before saving tokens or registration",
966 `rejects ID token ${invalidClaim} verification before saving tokens or registration`,964 (invalidClaim) =>
967 () =>965 provision(
968 provision(966 Effect.gen(function* () {
969 Effect.gen(function* () {967 const h = yield* makeHarness;
970 const h = yield* makeHarness;968 h.setIdentityFailure(invalidClaim);
971 h.setIdentityFailure(invalidClaim);969 yield* h.signIn;
972 yield* h.signIn;970 assert.include((yield* h.phase("failed")).message!, "could not be verified");
973 assert.include((yield* h.phase("failed")).message!, "could not be verified");971 assert.strictEqual(h.exchanges.length, 1);
974 assert.strictEqual(h.exchanges.length, 1);972 assert.deepEqual(h.storedRecords(), []);
975 assert.deepEqual(h.storedRecords(), []);973 assert.isTrue(Option.isNone(yield* h.auth.read));
976 assert.isTrue(Option.isNone(yield* h.auth.read));974 }),
977 }),975 ),
978 ),976);
979 );977it.effect.each([
980}978 { revoked: false, label: "without changing the current account" },
981for (const revoked of [false, true]) {979 { revoked: true, label: "after token removal" },
982 it.effect(980])("rejects conflicting callback client ID on reauthorization $label", ({ revoked }) =>
983 `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`,981 provision(
984 () =>982 Effect.gen(function* () {
985 provision(983 const h = yield* makeHarness;
986 Effect.gen(function* () {984 yield* h.signIn;
987 const h = yield* makeHarness;985 yield* h.phase("succeeded");
988 yield* h.signIn;986 if (revoked) yield* h.auth.revoke;
989 yield* h.phase("succeeded");987 const before = h.storedRecords();
990 if (revoked) yield* h.auth.revoke;988 h.setCallbackClientId("oaiapp_untrusted_callback");
991 const before = h.storedRecords();989 yield* h.signIn;
992 h.setCallbackClientId("oaiapp_untrusted_callback");990 assert.include((yield* h.phase("failed")).message!, "registration is incomplete");
993 yield* h.signIn;991 assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test");
994 assert.include((yield* h.phase("failed")).message!, "registration is incomplete");992 assert.strictEqual(h.exchanges.length, 1);
995 assert.strictEqual(993 assert.deepEqual(h.storedRecords(), before);
996 h.authorizationRequests[1]?.searchParams.get("client_id"),994 assert.strictEqual(Option.isNone(yield* h.auth.read), revoked);
997 "oaiapp_test",995 }),
998 );996 ),
999 assert.strictEqual(h.exchanges.length, 1);997);
1000 assert.deepEqual(h.storedRecords(), before);
1001 assert.strictEqual(Option.isNone(yield* h.auth.read), revoked);
1002 }),
1003 ),
1004 );
1005}
1006998
1007it.effect("returns successful desktop sign-in to the original Welcome step", () =>999it.effect("returns successful desktop sign-in to the original Welcome step", () =>
1008 provision(1000 provision(
...@@ -1157,26 +1149,25 @@ it.effect(...@@ -1157,26 +1149,25 @@ it.effect(
1157 ),1149 ),
1158);1150);
11591151
1160for (const disconnected of [false, true]) {1152it.effect.each([
1161 it.effect(1153 { disconnected: false, label: "while connected" },
1162 `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`,1154 { disconnected: true, label: "after Disconnect" },
1163 () =>1155])("rejects a different verified identity during saved-profile reauth $label", ({ disconnected }) =>
1164 provision(1156 provision(
1165 Effect.gen(function* () {1157 Effect.gen(function* () {
1166 const h = yield* makeHarness;1158 const h = yield* makeHarness;
1167 yield* h.signIn;1159 yield* h.signIn;
1168 yield* h.phase("succeeded");1160 yield* h.phase("succeeded");
1169 if (disconnected) yield* h.auth.controller.logout(Effect.void);1161 if (disconnected) yield* h.auth.controller.logout(Effect.void);
1170 const before = h.storedRecords();1162 const before = h.storedRecords();
1171 h.setIdentity("another-user", "another@example.test");1163 h.setIdentity("another-user", "another@example.test");
1172 yield* h.signIn;1164 yield* h.signIn;
1173 assert.include((yield* h.phase("failed")).message!, "different ChatGPT account");1165 assert.include((yield* h.phase("failed")).message!, "different ChatGPT account");
1174 assert.deepEqual(h.storedRecords(), before);1166 assert.deepEqual(h.storedRecords(), before);
1175 assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected);1167 assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected);
1176 }),1168 }),
1177 ),1169 ),
1178 );1170);
1179}
11801171
1181it.effect(1172it.effect(
1182 "retains both profiles and reuses the original account's client and callback when returning from another account",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,7 +1418,7 @@ it.effect(
1427 ),1418 ),
1428);1419);
14291420
1430for (const code of [1421it.effect.each([
1431 "invalid_grant",1422 "invalid_grant",
1432 "invalid_refresh_token",1423 "invalid_refresh_token",
1433 "token_expired",1424 "token_expired",
...@@ -1436,33 +1427,31 @@ for (const code of [...@@ -1436,33 +1427,31 @@ for (const code of [
1436 "refresh_token_reused",1427 "refresh_token_reused",
1437 "invalid_client",1428 "invalid_client",
1438 "invalid_token",1429 "invalid_token",
1439]) {1430])("refresh recovery follows the machine-readable code: %s", (code) =>
1440 it.effect(`refresh recovery follows the machine-readable code: ${code}`, () =>1431 provision(
1441 provision(1432 Effect.gen(function* () {
1442 Effect.gen(function* () {1433 const h = yield* makeHarness;
1443 const h = yield* makeHarness;1434 yield* h.signIn;
1444 yield* h.signIn;1435 yield* h.phase("succeeded");
1445 yield* h.phase("succeeded");1436 yield* h.seedExpired;
1446 yield* h.seedExpired;1437 h.setRefreshError(code);
1447 h.setRefreshError(code);1438 yield* Effect.flip(h.auth.access);
1448 yield* Effect.flip(h.auth.access);1439 assert.strictEqual(
1449 assert.strictEqual(1440 Option.isNone(yield* h.auth.read),
1450 Option.isNone(yield* h.auth.read),1441 !["invalid_client", "invalid_token"].includes(code),
1451 !["invalid_client", "invalid_token"].includes(code),1442 );
1452 );1443 assert.isTrue(
1453 assert.isTrue(1444 h
1454 h1445 .storedRecords()
1455 .storedRecords()1446 .some((record) =>
1456 .some((record) =>1447 record.profiles?.some(
1457 record.profiles?.some(1448 (profile: { clientId: string }) => profile.clientId === "oaiapp_test",
1458 (profile: { clientId: string }) => profile.clientId === "oaiapp_test",
1459 ),
1460 ),1449 ),
1461 );1450 ),
1462 }),1451 );
1463 ),1452 }),
1464 );1453 ),
1465}1454);
14661455
1467it.effect(1456it.effect(
1468 "logout revokes the latest refresh token with the selected client and clears its ID hint",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,8 +92,9 @@ const terminalState = (installation: CodexInstallation.CodexInstallation["Servic
92 Effect.map(Option.getOrThrow),92 Effect.map(Option.getOrThrow),
93 );93 );
9494
95for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) {95it.effect.each(["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`, () =>96 "reuses installed Codex %s without downloading or taking ownership of it",
97 (version) =>
97 Effect.gen(function* () {98 Effect.gen(function* () {
98 const h = yield* makeHarness({ local: { version } });99 const h = yield* makeHarness({ local: { version } });
99 expect(yield* h.installation.start).toMatchObject({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,9 +121,8 @@ for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) {
120 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);121 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
121 expect((yield* h.installation.state).source).toBe("local");122 expect((yield* h.installation.state).source).toBe("local");
122 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),123 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
123 );124);
124}125it.effect.each([
125for (const local of [
126 { version: "0.128.9" },126 { version: "0.128.9" },
127 { version: "0.145.0" },127 { version: "0.145.0" },
128 { version: "0.155.1" },128 { version: "0.155.1" },
...@@ -131,24 +131,20 @@ for (const local of [...@@ -131,24 +131,20 @@ for (const local of [
131 { version: "unknown" },131 { version: "unknown" },
132 { version: "0.156.1", appServerFails: true },132 { version: "0.156.1", appServerFails: true },
133 { version: "0.156.1", versionFails: true },133 { version: "0.156.1", versionFails: true },
134]) {134])("downloads the pinned release when the local CLI is unsupported or broken: %j", (local) =>
135 it.effect(135 Effect.gen(function* () {
136 `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`,136 const h = yield* makeHarness({ local });
137 () =>137 expect((yield* h.installation.state).installedVersion).toBeNull();
138 Effect.gen(function* () {138 yield* h.installation.start;
139 const h = yield* makeHarness({ local });139 const installed = yield* terminalState(h.installation);
140 expect((yield* h.installation.state).installedVersion).toBeNull();140 expect(installed.phase).toBe("succeeded");
141 yield* h.installation.start;141 const executable = yield* h.installation.resolve();
142 const installed = yield* terminalState(h.installation);142 expect(executable.source).toBe("managed");
143 expect(installed.phase).toBe("succeeded");143 expect(installed.executablePath).toBe(executable.executablePath);
144 const executable = yield* h.installation.resolve();144 expect(h.downloads()).toBe(1);
145 expect(executable.source).toBe("managed");145 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
146 expect(installed.executablePath).toBe(executable.executablePath);146 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
147 expect(h.downloads()).toBe(1);147);
148 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
149 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
150 );
151}
152it.effect("falls back to a managed download when the reused local executable disappears", () =>148it.effect("falls back to a managed download when the reused local executable disappears", () =>
153 Effect.gen(function* () {149 Effect.gen(function* () {
154 const h = yield* makeHarness({ local: { version: "0.156.1" } });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,178 +48,174 @@ it.effect("managed home defaults to the global Codex home and honors configured
48 assert.equal(configured.effectiveHomePath, "/custom/shadow");48 assert.equal(configured.effectiveHomePath, "/custom/shadow");
49 }).pipe(Effect.provide(NodeServices.layer)),49 }).pipe(Effect.provide(NodeServices.layer)),
50);50);
51for (const source of ["managed", "local"] as const)51it.effect.each(
52 for (const account of ["primary", "additional"] as const)52 (["managed", "local"] as const).flatMap((source) =>
53 it.effect(53 (["primary", "additional"] as const).map((account) => ({ source, account })),
54 `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`,54 ),
55 () =>55)(
56 Effect.gen(function* () {56 "$source Codex $account account shares home state without routing owned tokens through ambient CLI overrides",
57 const instanceId = ProviderInstanceId.make(57 ({ source, account }) =>
58 account === "primary" ? "codex" : "codex-personal",58 Effect.gen(function* () {
59 );59 const instanceId = ProviderInstanceId.make(
60 const fs = yield* FileSystem.FileSystem;60 account === "primary" ? "codex" : "codex-personal",
61 const path = yield* Path.Path;61 );
62 const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" });62 const fs = yield* FileSystem.FileSystem;
63 yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged");63 const path = yield* Path.Path;
64 yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n");64 const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" });
65 const data = new Map<string, Uint8Array>();65 yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged");
66 const secrets = ServerSecretStore.ServerSecretStore.of({66 yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n");
67 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),67 const data = new Map<string, Uint8Array>();
68 set: (key, value) =>68 const secrets = ServerSecretStore.ServerSecretStore.of({
69 Effect.sync(() => {69 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
70 data.set(key, value);70 set: (key, value) =>
71 }),71 Effect.sync(() => {
72 remove: (key) =>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 Effect.sync(() => {96 Effect.sync(() => {
74 data.delete(key);97 leases--;
75 }),98 }),
76 create: () => Effect.die("unused"),99 );
77 getOrCreateRandom: () => Effect.die("unused"),100 return executable;
78 });101 }),
79 let leases = 0;102 });
80 const executable = {103 yield* Effect.gen(function* () {
81 executablePath:104 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);
82 source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex",105 const json = yield* encodeJson({
83 managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null,106 clientId: "oaiapp_test",
84 source,107 accessToken: "dummy-owned-access",
85 version: "0.156.1",108 refreshToken: "dummy-refresh",
86 };109 expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000,
87 const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({110 earliestRefreshAt: null,
88 managedDirectory: "/isolated/tools/codex",111 scopes: ["chatgpt.tokens.use.direct"],
89 resolve: () => Effect.succeed(executable),112 subject: "test-user",
90 acquire: () =>113 email: null,
91 Effect.gen(function* () {114 });
92 leases++;115 yield* store.set(new TextEncoder().encode(json));
93 yield* Effect.addFinalizer(() =>116 const ambient = {
94 Effect.sync(() => {117 CODEX_HOME: "/user/.codex",
95 leases--;118 OPENAI_API_KEY: "dummy-global-key",
96 }),119 OPENAI_BASE_URL: "https://user-proxy.test",
97 );120 T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy",
98 return executable;121 PATH: "/usr/bin",
99 }),122 };
100 });123 const runtime = yield* makeCodexManagedRuntime({
101 yield* Effect.gen(function* () {124 instanceId,
102 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);125 enabled: true,
103 const json = yield* encodeJson({126 config: decodeSettings({ setupMode: "managed", homePath: sharedHome }),
104 clientId: "oaiapp_test",127 environment: ambient,
105 accessToken: "dummy-owned-access",128 });
106 refreshToken: "dummy-refresh",129 yield* Effect.gen(function* () {
107 expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000,130 const effective = yield* runtime.resolve;
108 earliestRefreshAt: null,131 assert.strictEqual(leases, 1);
109 scopes: ["chatgpt.tokens.use.direct"],132 assert.strictEqual(effective.config.binaryPath, executable.executablePath);
110 subject: "test-user",133 assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME);
111 email: null,134 assert.equal(runtime.homeLayout.sharedHomePath, sharedHome);
112 });135 if (account === "primary") {
113 yield* store.set(new TextEncoder().encode(json));136 assert.equal(effective.config.homePath, sharedHome);
114 const ambient = {137 assert.equal(runtime.homeLayout.mode, "direct");
115 CODEX_HOME: "/user/.codex",138 } else {
116 OPENAI_API_KEY: "dummy-global-key",139 assert.include(effective.config.homePath, instanceId);
117 OPENAI_BASE_URL: "https://user-proxy.test",140 assert.include(effective.config.homePath, "userdata/providers/codex");
118 T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy",141 assert.equal(runtime.homeLayout.mode, "authOverlay");
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);
179 assert.equal(142 assert.equal(
180 yield* fs.readFileString(path.join(sharedHome, "auth.json")),143 yield* fs.readLink(path.join(effective.config.homePath, "sessions")),
181 "native-auth-unchanged",144 path.join(sharedHome, "sessions"),
182 );145 );
183 }).pipe(146 assert.equal(
184 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),147 yield* fs.readLink(path.join(effective.config.homePath, "config.toml")),
185 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, {148 path.join(sharedHome, "config.toml"),
186 getEnvironmentId: Effect.succeed(149 );
187 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),150 assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json")));
188 ),151 }
189 }),152 assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access");
190 Effect.provide(installerLayer),153 assert.isUndefined(effective.environment.OPENAI_API_KEY);
191 Effect.provideService(154 assert.isUndefined(effective.environment.OPENAI_BASE_URL);
192 HttpClient.HttpClient,155 assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS);
193 HttpClient.make((request) =>156 const args = codexAppServerArgs(effective.config.launchArgs);
194 Effect.sync(() => {157 assert.include(
195 assert.isTrue(158 args,
196 [159 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"',
197 "https://auth.openai.com/.well-known/openid-configuration",160 );
198 "https://auth.openai.com/revoke",161 assert.include(
199 ].includes(request.url),162 args,
200 );163 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"',
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 ),
216 );164 );
217 }).pipe(165 assert.include(args, "features.api_key_model_discovery=true");
218 Effect.scoped,166 assert.notInclude(effective.config.launchArgs, "model_catalog_json");
219 Effect.provide(167 assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test");
220 ServerConfig.layerTest(process.cwd(), {168 assert.include(args, "model_providers.openai_token_sharing.supports_websockets=false");
221 prefix: "t3-managed-runtime-",169 assert.include(args, "model_providers.openai_token_sharing.requires_openai_auth=false");
222 }).pipe(Layer.provideMerge(NodeServices.layer)),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,8 +33,9 @@ const mockRuntimeOptions = {
33} satisfies AcpSessionRuntime.AcpSessionRuntimeOptions;33} satisfies AcpSessionRuntime.AcpSessionRuntimeOptions;
3434
35describe("AcpSessionRuntime", () => {35describe("AcpSessionRuntime", () => {
36 for (const setupMethod of ["session/new", "session/resume"] as const) {36 it.effect.each(["session/new", "session/resume"] as const)(
37 it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () =>37 "buffers root metadata while %s startup is still pending",
38 (setupMethod) =>
38 Effect.gen(function* () {39 Effect.gen(function* () {
39 const setupReplied = yield* Deferred.make<void>();40 const setupReplied = yield* Deferred.make<void>();
40 const allowStartup = yield* Deferred.make<void>();41 const allowStartup = yield* Deferred.make<void>();
...@@ -91,8 +92,7 @@ describe("AcpSessionRuntime", () => {...@@ -91,8 +92,7 @@ describe("AcpSessionRuntime", () => {
91 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),92 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),
92 ).toMatchObject({ currentValue: "gpt-5.4" });93 ).toMatchObject({ currentValue: "gpt-5.4" });
93 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),94 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
94 );95 );
95 }
9696
97 it.effect("publishes model changes returned by a config request and live notifications", () =>97 it.effect("publishes model changes returned by a config request and live notifications", () =>
98 Effect.gen(function* () {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,7 +2287,7 @@ it.effect("routes Azure reads and writes through the requested organization's ch
2287 }),2287 }),
2288);2288);
22892289
2290for (const checkout of [2290it.effect.each([
2291 {2291 {
2292 host: "ssh.dev.azure.com",2292 host: "ssh.dev.azure.com",
2293 repository: "v3/org-b/project/web",2293 repository: "v3/org-b/project/web",
...@@ -2303,57 +2303,55 @@ for (const checkout of [...@@ -2303,57 +2303,55 @@ for (const checkout of [
2303 repository: "DefaultCollection/project/_git/web",2303 repository: "DefaultCollection/project/_git/web",
2304 remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web",2304 remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web",
2305 },2305 },
2306]) {2306])("routes Azure URL reads and writes through a $host checkout", (checkout) =>
2307 it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () =>2307 Effect.gen(function* () {
2308 Effect.gen(function* () {2308 const seen: string[] = [];
2309 const seen: string[] = [];2309 const target = project({
2310 const target = project({2310 id: "target",
2311 id: "target",2311 title: "target",
2312 title: "target",2312 workspaceRoot: "/target",
2313 workspaceRoot: "/target",2313 provider: "azure-devops",
2314 provider: "azure-devops",2314 ...checkout,
2315 ...checkout,2315 });
2316 });2316 const service = yield* makeService({
2317 const service = yield* makeService({2317 projects: [
2318 projects: [2318 ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>
2319 ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>2319 project({
2320 project({2320 id: repository,
2321 id: repository,2321 title: repository,
2322 title: repository,2322 workspaceRoot: `/${repository}`,
2323 workspaceRoot: `/${repository}`,2323 provider: "azure-devops",
2324 provider: "azure-devops",2324 host: "dev.azure.com",
2325 host: "dev.azure.com",2325 repository,
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 }),
2342 }),2326 }),
2343 ],2327 ),
2344 });2328 target,
2345 const reference = {2329 ],
2346 projectId: "org-a/project/_git/web" as ProjectId,2330 providers: [
2347 host: "dev.azure.com",2331 fakeProvider("azure-devops", {
2348 repository: "org-b/project/_git/web",2332 getChangeRequestSummary: (input) =>
2349 number: 7,2333 Effect.sync(() => {
2350 };2334 seen.push(`read ${input.cwd} ${input.repository}`);
2351 yield* service.summary(reference, { recoverTransientFailure: false });2335 return changeRequest(7, "2026-07-02T00:00:00Z");
2352 yield* service.runAction({ ...reference, action: "merge" });2336 }),
2353 assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]);2337 runAction: (input) =>
2354 }),2338 Effect.sync(() => {
2355 );2339 seen.push(`write ${input.cwd} ${input.repository}`);
2356}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);
23572355
2358it.effect("refuses Azure cross-organization reads and writes without its checkout", () =>2356it.effect("refuses Azure cross-organization reads and writes without its checkout", () =>
2359 Effect.gen(function* () {2357 Effect.gen(function* () {
...@@ -5042,104 +5040,102 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho...@@ -5042,104 +5040,102 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho
5042 }),5040 }),
5043);5041);
50445042
5045for (const crossHost of [false, true]) {5043it.effect.each([false, true])(
5046 it.effect(5044 "authorizes stack rebases and refreshes sibling layers (cross-host: %s)",
5047 `authorizes stack rebases and refreshes sibling layers (cross-host: ${crossHost})`,5045 (crossHost) =>
5048 () =>5046 Effect.gen(function* () {
5049 Effect.gen(function* () {5047 let taken = 0;
5050 let taken = 0;5048 let summaryReads = 0;
5051 let summaryReads = 0;5049 let mutationFails = false;
5052 let mutationFails = false;5050 let stackRebase = true;
5053 let stackRebase = true;5051 let stackActions = true;
5054 let stackActions = true;5052 const capabilities = {
5055 const capabilities = {5053 diff: true,
5056 diff: true,5054 comment: true,
5057 comment: true,5055 actions: ["update-branch"] as const,
5058 actions: ["update-branch"] as const,5056 mergeMethods: ["merge"] as const,
5059 mergeMethods: ["merge"] as const,5057 updateMethods: ["rebase"] as const,
5060 updateMethods: ["rebase"] as const,5058 get stackActions() {
5061 get stackActions() {5059 return stackActions;
5062 return stackActions;5060 },
5063 },5061 search: true,
5064 search: true,5062 reactions: true,
5065 reactions: true,5063 review: FULL_REVIEW,
5066 review: FULL_REVIEW,5064 reviewers: FULL_REVIEWERS,
5067 reviewers: FULL_REVIEWERS,5065 };
5068 };5066 const service = yield* makeService({
5069 const service = yield* makeService({5067 projects: [
5070 projects: [5068 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5071 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),5069 project({
5072 project({5070 id: "p2",
5073 id: "p2",5071 title: "enterprise",
5074 title: "enterprise",5072 workspaceRoot: "/b",
5075 workspaceRoot: "/b",5073 repository: "acme/web",
5076 repository: "acme/web",5074 host: "enterprise.test",
5077 host: "enterprise.test",5075 }),
5078 }),5076 ],
5079 ],5077 providers: [
5080 providers: [5078 fakeProvider("github", {
5081 fakeProvider("github", {5079 capabilities,
5082 capabilities,5080 getViewerPermissions: () =>
5083 getViewerPermissions: () =>5081 Effect.succeed({
5084 Effect.succeed({5082 actions: [],
5085 actions: [],5083 stackRebase,
5086 stackRebase,5084 comment: true,
5087 comment: true,5085 resolve: false,
5088 resolve: false,5086 verdicts: [],
5089 verdicts: [],5087 requestReviewers: false,
5090 requestReviewers: false,5088 }),
5091 }),5089 getChangeRequestSummary: () =>
5092 getChangeRequestSummary: () =>5090 Effect.sync(() => {
5093 Effect.sync(() => {5091 summaryReads++;
5094 summaryReads++;5092 return changeRequest(8, "2026-07-01T00:00:00Z");
5095 return changeRequest(8, "2026-07-01T00:00:00Z");5093 }),
5096 }),5094 runAction: () =>
5097 runAction: () =>5095 Effect.gen(function* () {
5098 Effect.gen(function* () {5096 taken++;
5099 taken++;5097 if (mutationFails) return yield* requestFailed;
5100 if (mutationFails) return yield* requestFailed;5098 }),
5101 }),5099 }),
5102 }),5100 ],
5103 ],5101 });
5104 });5102 const input = {
5105 const input = {5103 ...(crossHost ? { host: "enterprise.test" } : {}),
5106 ...(crossHost ? { host: "enterprise.test" } : {}),5104 projectId: "p1" as ProjectId,
5107 projectId: "p1" as ProjectId,5105 repository: "acme/web",
5108 repository: "acme/web",5106 number: 3,
5109 number: 3,5107 action: "update-branch" as const,
5110 action: "update-branch" as const,5108 updateMethod: "rebase" as const,
5111 updateMethod: "rebase" as const,5109 stackNumber: 50,
5112 stackNumber: 50,5110 expectedStackHeads: [{ number: 3, headSha: "ccc" }],
5113 expectedStackHeads: [{ number: 3, headSha: "ccc" }],5111 };
5114 };5112 yield* service.runAction(input);
5115 yield* service.runAction(input);5113 assert.strictEqual(taken, 1);
5116 assert.strictEqual(taken, 1);5114 const unrelated = { ...input, number: 8 };
5117 const unrelated = { ...input, number: 8 };5115 yield* service.summary(unrelated);
5118 yield* service.summary(unrelated);5116 assert.strictEqual(summaryReads, 1);
5119 assert.strictEqual(summaryReads, 1);5117 stackRebase = false;
5120 stackRebase = false;5118 assert.strictEqual(
5121 assert.strictEqual(5119 (yield* Effect.flip(service.runAction(input)))._tag,
5122 (yield* Effect.flip(service.runAction(input)))._tag,5120 "PullRequestOperationError",
5123 "PullRequestOperationError",5121 );
5124 );5122 stackRebase = true;
5125 stackRebase = true;5123 stackActions = false;
5126 stackActions = false;5124 assert.strictEqual(
5127 assert.strictEqual(5125 (yield* Effect.flip(service.runAction(input)))._tag,
5128 (yield* Effect.flip(service.runAction(input)))._tag,5126 "PullRequestOperationError",
5129 "PullRequestOperationError",5127 );
5130 );5128 assert.strictEqual(taken, 1);
5131 assert.strictEqual(taken, 1);5129 yield* service.summary(unrelated);
5132 yield* service.summary(unrelated);5130 assert.strictEqual(summaryReads, 1);
5133 assert.strictEqual(summaryReads, 1);5131 stackActions = true;
5134 stackActions = true;5132 mutationFails = true;
5135 mutationFails = true;5133 yield* Effect.flip(service.runAction(input));
5136 yield* Effect.flip(service.runAction(input));5134 assert.strictEqual(taken, 2);
5137 assert.strictEqual(taken, 2);5135 yield* service.summary(unrelated);
5138 yield* service.summary(unrelated);5136 assert.strictEqual(summaryReads, 2);
5139 assert.strictEqual(summaryReads, 2);5137 }),
5140 }),5138);
5141 );
5142}
51435139
5144it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>5140it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>
5145 Effect.gen(function* () {5141 Effect.gen(function* () {
apps/server/src/serverSettings.test.ts+75-74
...@@ -1245,77 +1245,78 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1245,77 +1245,78 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1245 }).pipe(Effect.provide(settingsLayer));1245 }).pipe(Effect.provide(settingsLayer));
1246 });1246 });
12471247
1248 for (const { label, variable, expected, duplicate } of [1248 it.effect.each(
1249 {1249 [
1250 label: "preserves an inline secret on a redacted settings save",1250 {
1251 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },1251 label: "preserves an inline secret on a redacted settings save",
1252 expected: "inline-test-token",1252 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1253 },1253 expected: "inline-test-token",
1254 {1254 },
1255 label: "preserves the effective last inline secret when names are duplicated",1255 {
1256 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },1256 label: "preserves the effective last inline secret when names are duplicated",
1257 expected: "last-inline-test-token",1257 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1258 duplicate: true,1258 expected: "last-inline-test-token",
1259 },1259 duplicate: true,
1260 {1260 },
1261 label: "replaces an inline secret with an explicit value",1261 {
1262 variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true },1262 label: "replaces an inline secret with an explicit value",
1263 expected: "replacement-test-token",1263 variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true },
1264 },1264 expected: "replacement-test-token",
1265 {1265 },
1266 label: "clears an inline secret with an explicit empty value",1266 {
1267 variable: { name: "API_TOKEN", value: "", sensitive: true },1267 label: "clears an inline secret with an explicit empty value",
1268 expected: "",1268 variable: { name: "API_TOKEN", value: "", sensitive: true },
1269 },1269 expected: "",
1270 ]) {1270 },
1271 it.effect(label, () =>1271 ].map((testCase) => [testCase.label, testCase] as const),
1272 Effect.gen(function* () {1272 )("%s", ([, { variable, expected, duplicate }]) =>
1273 const instanceId = ProviderInstanceId.make("codex_personal");1273 Effect.gen(function* () {
1274 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1274 const instanceId = ProviderInstanceId.make("codex_personal");
1275 const serverConfig = yield* ServerConfig.ServerConfig;1275 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1276 const fileSystem = yield* FileSystem.FileSystem;1276 const serverConfig = yield* ServerConfig.ServerConfig;
1277 yield* fileSystem.writeFileString(1277 const fileSystem = yield* FileSystem.FileSystem;
1278 serverConfig.settingsPath,1278 yield* fileSystem.writeFileString(
1279 duplicate1279 serverConfig.settingsPath,
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":{}}}}'1280 duplicate
1281 : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}',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 );1282 : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}',
1283 const initial = yield* serverSettings.getSettings;1283 );
1284 assert.equal(1284 const initial = yield* serverSettings.getSettings;
1285 initial.providerInstances[instanceId]?.environment?.[0]?.value,1285 assert.equal(
1286 "inline-test-token",1286 initial.providerInstances[instanceId]?.environment?.[0]?.value,
1287 );1287 "inline-test-token",
1288 );
12881289
1289 const next = yield* serverSettings.updateSettings({1290 const next = yield* serverSettings.updateSettings({
1290 providerInstances: {1291 providerInstances: {
1291 [instanceId]: {1292 [instanceId]: {
1292 driver: ProviderDriverKind.make("codex"),1293 driver: ProviderDriverKind.make("codex"),
1293 displayName: "Renamed provider",1294 displayName: "Renamed provider",
1294 environment: duplicate ? [variable, variable] : [variable],1295 environment: duplicate ? [variable, variable] : [variable],
1295 config: {},1296 config: {},
1296 },
1297 },1297 },
1298 });1298 },
1299 assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected);1299 });
1300 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);1300 assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1301 assert.notInclude(raw, "inline-test-token");1301 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1302 assert.notInclude(raw, "replacement-test-token");1302 assert.notInclude(raw, "inline-test-token");
13031303 assert.notInclude(raw, "replacement-test-token");
1304 const reloaded = yield* Effect.gen(function* () {1304
1305 const fresh = yield* ServerSettingsModule.ServerSettingsService;1305 const reloaded = yield* Effect.gen(function* () {
1306 return yield* fresh.getSettings;1306 const fresh = yield* ServerSettingsModule.ServerSettingsService;
1307 }).pipe(1307 return yield* fresh.getSettings;
1308 Effect.provide(1308 }).pipe(
1309 Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),1309 Effect.provide(
1310 ),1310 Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),
1311 );1311 ),
1312 assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected);1312 );
1313 }).pipe(Effect.provide(makeServerSettingsLayer())),1313 assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1314 );1314 }).pipe(Effect.provide(makeServerSettingsLayer())),
1315 }1315 );
13161316
1317 for (const sensitiveLast of [true, false]) {1317 it.effect.each([true, false])(
1318 it.effect(`preserves duplicate secret operation order (sensitive last: ${sensitiveLast})`, () =>1318 "preserves duplicate secret operation order (sensitive last: %s)",
1319 (sensitiveLast) =>
1319 Effect.gen(function* () {1320 Effect.gen(function* () {
1320 const service = yield* ServerSettingsModule.ServerSettingsService;1321 const service = yield* ServerSettingsModule.ServerSettingsService;
1321 const instanceId = ProviderInstanceId.make("codex_duplicate");1322 const instanceId = ProviderInstanceId.make("codex_duplicate");
...@@ -1339,8 +1340,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1339,8 +1340,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1339 sensitiveLast ? "secret-last" : "",1340 sensitiveLast ? "secret-last" : "",
1340 );1341 );
1341 }).pipe(Effect.provide(makeServerSettingsLayer())),1342 }).pipe(Effect.provide(makeServerSettingsLayer())),
1342 );1343 );
1343 }
13441344
1345 it.effect("stores sensitive provider instance environment values outside settings.json", () =>1345 it.effect("stores sensitive provider instance environment values outside settings.json", () =>
1346 Effect.gen(function* () {1346 Effect.gen(function* () {
...@@ -1630,8 +1630,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1630,8 +1630,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1630 }),1630 }),
1631 );1631 );
16321632
1633 for (const failure of ["response materialization", "partially committed write"] as const) {1633 it.effect.each(["response materialization", "partially committed write"] as const)(
1634 it.effect(`rolls back provider secret changes after ${failure} fails`, () => {1634 "rolls back provider secret changes after %s fails",
1635 (failure) => {
1635 const textDecoder = new TextDecoder();1636 const textDecoder = new TextDecoder();
1636 const secrets = new Map<string, Uint8Array>();1637 const secrets = new Map<string, Uint8Array>();
1637 let rejectNewSecret = false;1638 let rejectNewSecret = false;
...@@ -1734,8 +1735,8 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1734,8 +1735,8 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1734 "sk-kept",1735 "sk-kept",
1735 );1736 );
1736 }).pipe(Effect.provide(settingsLayer));1737 }).pipe(Effect.provide(settingsLayer));
1737 });1738 },
1738 }1739 );
17391740
1740 it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>1741 it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>
1741 Effect.gen(function* () {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,8 +408,9 @@ it("reports an update hint instead of unauthenticated when gh predates --json",
408 );408 );
409});409});
410410
411for (const kind of ["pull", "issues"]) {411it.effect.each(["pull", "issues"])(
412 it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () =>412 "resolves %s subjects on the linked host without using the checkout",
413 (kind) =>
413 Effect.gen(function* () {414 Effect.gen(function* () {
414 const provider = yield* makeProvider({415 const provider = yield* makeProvider({
415 execute: (input) => {416 execute: (input) => {
...@@ -449,11 +450,11 @@ for (const kind of ["pull", "issues"]) {...@@ -449,11 +450,11 @@ for (const kind of ["pull", "issues"]) {
449 undefined,450 undefined,
450 );451 );
451 }),452 }),
452 );453);
453}
454454
455for (const stage of ["read", "decode"] as const) {455it.effect.each(["read", "decode"] as const)(
456 it.effect(`retains the ${stage} failure without exposing its raw contents`, () =>456 "retains the %s failure without exposing its raw contents",
457 (stage) =>
457 Effect.gen(function* () {458 Effect.gen(function* () {
458 const cause = new GitHubCli.GitHubCliCommandError({459 const cause = new GitHubCli.GitHubCliCommandError({
459 command: "gh",460 command: "gh",
...@@ -484,5 +485,4 @@ for (const stage of ["read", "decode"] as const) {...@@ -484,5 +485,4 @@ for (const stage of ["read", "decode"] as const) {
484 if (stage === "read") assert.strictEqual(error.cause, cause);485 if (stage === "read") assert.strictEqual(error.cause, cause);
485 else assert.propertyVal(error.cause, "_tag", "SchemaError");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,8 +227,9 @@ selfhosted
227 );227 );
228});228});
229229
230for (const kind of ["merge_requests", "issues"]) {230it.effect.each(["merge_requests", "issues"])(
231 it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () =>231 "resolves %s subjects on the linked host without using the checkout",
232 (kind) =>
232 Effect.gen(function* () {233 Effect.gen(function* () {
233 const provider = yield* makeProvider({234 const provider = yield* makeProvider({
234 execute: (input) => {235 execute: (input) => {
...@@ -269,11 +270,11 @@ for (const kind of ["merge_requests", "issues"]) {...@@ -269,11 +270,11 @@ for (const kind of ["merge_requests", "issues"]) {
269 undefined,270 undefined,
270 );271 );
271 }),272 }),
272 );273);
273}
274274
275for (const stage of ["read", "decode"] as const) {275it.effect.each(["read", "decode"] as const)(
276 it.effect(`retains the ${stage} failure without exposing its raw contents`, () =>276 "retains the %s failure without exposing its raw contents",
277 (stage) =>
277 Effect.gen(function* () {278 Effect.gen(function* () {
278 const cause = new GitLabCli.GitLabCliCommandError({279 const cause = new GitLabCli.GitLabCliCommandError({
279 command: "glab",280 command: "glab",
...@@ -305,5 +306,4 @@ for (const stage of ["read", "decode"] as const) {...@@ -305,5 +306,4 @@ for (const stage of ["read", "decode"] as const) {
305 if (stage === "read") assert.strictEqual(error.cause, cause);306 if (stage === "read") assert.strictEqual(error.cause, cause);
306 else assert.propertyVal(error.cause, "_tag", "SchemaError");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,8 +1461,9 @@ it.layer(
1461 }),1461 }),
1462 );1462 );
14631463
1464 for (const source of ["current", "legacy"] as const) {1464 it.effect.each(["current", "legacy"] as const)(
1465 it.effect(`reads only a Unicode-safe tail from oversized ${source} history`, () =>1465 "reads only a Unicode-safe tail from oversized %s history",
1466 (source) =>
1466 Effect.gen(function* () {1467 Effect.gen(function* () {
1467 const fs = yield* FileSystem.FileSystem;1468 const fs = yield* FileSystem.FileSystem;
1468 const path = yield* Path.Path;1469 const path = yield* Path.Path;
...@@ -1513,8 +1514,7 @@ it.layer(...@@ -1513,8 +1514,7 @@ it.layer(
1513 yield* manager.close({ threadId: "thread-1" });1514 yield* manager.close({ threadId: "thread-1" });
1514 expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré");1515 expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré");
1515 }),1516 }),
1516 );1517 );
1517 }
15181518
1519 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>1519 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
1520 Effect.gen(function* () {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,8 +110,9 @@ it.effect("waits for the Windows PID without requiring output", () =>
110 }).pipe(Effect.provide(testLayer)),110 }).pipe(Effect.provide(testLayer)),
111);111);
112112
113for (const failure of ["exit", "close", "error", "invalid-pid"] as const) {113it.effect.each(["exit", "close", "error", "invalid-pid"] as const)(
114 it.effect(`fails Windows startup on ${failure} and cleans up`, () =>114 "fails Windows startup on %s and cleans up",
115 (failure) =>
115 Effect.gen(function* () {116 Effect.gen(function* () {
116 const { nativeProcess, subscribed } = preparePendingProcess();117 const { nativeProcess, subscribed } = preparePendingProcess();
117 const adapter = yield* PtyAdapter.PtyAdapter;118 const adapter = yield* PtyAdapter.PtyAdapter;
...@@ -129,8 +130,7 @@ for (const failure of ["exit", "close", "error", "invalid-pid"] as const) {...@@ -129,8 +130,7 @@ for (const failure of ["exit", "close", "error", "invalid-pid"] as const) {
129 assert.equal(nativeProcess.events.listenerCount("exit"), 0);130 assert.equal(nativeProcess.events.listenerCount("exit"), 0);
130 assert.equal(nativeProcess._agent.kill.mock.calls.length, 1);131 assert.equal(nativeProcess._agent.kill.mock.calls.length, 1);
131 }).pipe(Effect.provide(testLayer)),132 }).pipe(Effect.provide(testLayer)),
132 );133);
133}
134134
135it.effect("cancels the Windows connection without waiting for output", () =>135it.effect("cancels the Windows connection without waiting for output", () =>
136 Effect.gen(function* () {136 Effect.gen(function* () {
...@@ -160,8 +160,9 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", ()...@@ -160,8 +160,9 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", ()
160 }).pipe(Effect.provide(testLayer)),160 }).pipe(Effect.provide(testLayer)),
161);161);
162162
163for (const platform of ["win32", "linux", "darwin"] as const) {163it.effect.each(["win32", "linux", "darwin"] as const)(
164 it.effect(`terminates through node-pty using ${platform} semantics`, () =>164 "terminates through node-pty using %s semantics",
165 (platform) =>
165 Effect.gen(function* () {166 Effect.gen(function* () {
166 const adapter = yield* PtyAdapter.PtyAdapter;167 const adapter = yield* PtyAdapter.PtyAdapter;
167 const process = yield* adapter.spawn({168 const process = yield* adapter.spawn({
...@@ -189,8 +190,7 @@ for (const platform of ["win32", "linux", "darwin"] as const) {...@@ -189,8 +190,7 @@ for (const platform of ["win32", "linux", "darwin"] as const) {
189 : [["SIGTERM"], ["SIGKILL"], [undefined]],190 : [["SIGTERM"], ["SIGKILL"], [undefined]],
190 );191 );
191 }).pipe(Effect.provide(makeTestLayer(platform))),192 }).pipe(Effect.provide(makeTestLayer(platform))),
192 );193);
193}
194194
195it.effect("spawns through the public adapter with the provided host references", () =>195it.effect("spawns through the public adapter with the provided host references", () =>
196 Effect.gen(function* () {196 Effect.gen(function* () {
...@@ -279,8 +279,9 @@ it.effect("reports native module load failures as structured startup defects", (...@@ -279,8 +279,9 @@ it.effect("reports native module load failures as structured startup defects", (
279 ),279 ),
280);280);
281281
282for (const budget of [2048, 8]) {282it.effect.each([2048, 8])(
283 it.effect(`preserves an exit during readiness handoff with scheduler budget ${budget}`, () =>283 "preserves an exit during readiness handoff with scheduler budget %s",
284 (budget) =>
284 Effect.gen(function* () {285 Effect.gen(function* () {
285 const { nativeProcess, subscribed } = preparePendingProcess();286 const { nativeProcess, subscribed } = preparePendingProcess();
286 const adapter = yield* PtyAdapter.PtyAdapter;287 const adapter = yield* PtyAdapter.PtyAdapter;
...@@ -300,8 +301,7 @@ for (const budget of [2048, 8]) {...@@ -300,8 +301,7 @@ for (const budget of [2048, 8]) {
300 yield* Fiber.join(fiber);301 yield* Fiber.join(fiber);
301 assert.equal(exits.length, 1);302 assert.equal(exits.length, 1);
302 }).pipe(Effect.provide(testLayer)),303 }).pipe(Effect.provide(testLayer)),
303 );304);
304}
305305
306it.effect("replays an exit to late subscribers and respects unsubscription", () =>306it.effect("replays an exit to late subscribers and respects unsubscription", () =>
307 Effect.gen(function* () {307 Effect.gen(function* () {
...@@ -320,8 +320,9 @@ it.effect("replays an exit to late subscribers and respects unsubscription", ()...@@ -320,8 +320,9 @@ it.effect("replays an exit to late subscribers and respects unsubscription", ()
320 }).pipe(Effect.provide(testLayer)),320 }).pipe(Effect.provide(testLayer)),
321);321);
322322
323for (const failure of ["spawn", "interrupt"] as const) {323it.effect.each(["spawn", "interrupt"] as const)(
324 it.effect(`logs cleanup failures without replacing ${failure}`, () =>324 "logs cleanup failures without replacing %s",
325 (failure) =>
325 Effect.gen(function* () {326 Effect.gen(function* () {
326 const { nativeProcess, subscribed } = preparePendingProcess();327 const { nativeProcess, subscribed } = preparePendingProcess();
327 const killError = new Error("native kill failed");328 const killError = new Error("native kill failed");
...@@ -361,5 +362,4 @@ for (const failure of ["spawn", "interrupt"] as const) {...@@ -361,5 +362,4 @@ for (const failure of ["spawn", "interrupt"] as const) {
361 assert.equal(nativeProcess.events.listenerCount("exit"), 0);362 assert.equal(nativeProcess.events.listenerCount("exit"), 0);
362 assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0);363 assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0);
363 }).pipe(Effect.provide(testLayer)),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,69 +11,67 @@ import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/u
11import { withTerminalOutputWindow } from "./OutputProtocol.ts";11import { withTerminalOutputWindow } from "./OutputProtocol.ts";
1212
13describe("terminal output window", () => {13describe("terminal output window", () => {
14 for (const { tag, size, limit } of [14 it.effect.each([
15 { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 },15 { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 },
16 { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 },16 { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 },
17 { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 },17 { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 },
18 { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 },18 { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 },
19 ]) {19 ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) =>
20 it.effect(`limits ${tag} with ${size}-byte values to ${limit} pending chunks`, () =>20 Effect.gen(function* () {
21 Effect.gen(function* () {21 const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true }));
22 const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true }));22 const output = yield* Queue.unbounded<string>();
23 const output = yield* Queue.unbounded<string>();23 const responses = yield* Queue.unbounded<RpcMessage.FromServerEncoded>();
24 const responses = yield* Queue.unbounded<RpcMessage.FromServerEncoded>();24 const receive = yield* Deferred.make<Parameters<RpcServer.Protocol["Service"]["run"]>[0]>();
25 const receive = yield* Deferred.make<Parameters<RpcServer.Protocol["Service"]["run"]>[0]>();25 const protocol = yield* RpcServer.Protocol.make((write) =>
26 const protocol = yield* RpcServer.Protocol.make((write) =>26 Effect.gen(function* () {
27 Effect.gen(function* () {27 yield* Deferred.succeed(receive, write);
28 yield* Deferred.succeed(receive, write);28 const serialization = yield* RpcSerialization.RpcSerialization;
29 const serialization = yield* RpcSerialization.RpcSerialization;29 return {
30 return {30 disconnects: yield* Queue.unbounded<number>(),
31 disconnects: yield* Queue.unbounded<number>(),31 send: (_clientId, response) => Queue.offer(responses, response),
32 send: (_clientId, response) => Queue.offer(responses, response),32 end: () => Effect.void,
33 end: () => Effect.void,33 clientIds: Effect.succeed(new Set([0])),
34 clientIds: Effect.succeed(new Set([0])),34 initialMessage: Effect.succeedNone,
35 initialMessage: Effect.succeedNone,35 supportsAck: true,
36 supportsAck: true,36 supportsTransferables: false,
37 supportsTransferables: false,37 supportsSpanPropagation: false,
38 supportsSpanPropagation: false,38 supportsNotifications: true,
39 supportsNotifications: true,39 codecFor: serialization.codecFor,
40 codecFor: serialization.codecFor,40 };
41 };41 }),
42 }),42 );
43 );43 yield* RpcServer.make(group).pipe(
44 yield* RpcServer.make(group).pipe(44 Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))),
45 Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))),45 Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),
46 Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),46 Effect.forkScoped,
47 Effect.forkScoped,47 );
48 );48 const write = yield* Deferred.await(receive);
49 const write = yield* Deferred.await(receive);49 yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] });
50 yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] });
5150
52 for (let index = 0; index < limit; index++) {51 for (let index = 0; index < limit; index++) {
53 const value = String(index).repeat(size);52 const value = String(index).repeat(size);
54 yield* Queue.offer(output, value);53 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));
64 yield* TestClock.adjust(0);54 yield* TestClock.adjust(0);
65 assert.equal(yield* Queue.size(responses), 0);55 assert.equal(yield* Queue.size(responses), 1);
66 yield* write(0, { _tag: "Ack", requestId: "1" });56 assert.deepEqual(yield* Queue.take(responses), {
67 const resumed = yield* Queue.take(responses);57 _tag: "Chunk",
68 assert.equal(resumed._tag, "Chunk");58 requestId: "1",
69 if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]);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)]);
7069
71 yield* Queue.offer(output, "j");70 yield* Queue.offer(output, "j");
72 yield* TestClock.adjust(0);71 yield* TestClock.adjust(0);
73 assert.equal(yield* Queue.size(responses), 0);72 assert.equal(yield* Queue.size(responses), 0);
74 yield* write(0, { _tag: "Interrupt", requestId: "1" });73 yield* write(0, { _tag: "Interrupt", requestId: "1" });
75 assert.equal((yield* Queue.take(responses))._tag, "Exit");74 assert.equal((yield* Queue.take(responses))._tag, "Exit");
76 }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),75 }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),
77 );76 );
78 }
79});77});
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+75-77
...@@ -461,69 +461,69 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -461,69 +461,69 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
461 }),461 }),
462 );462 );
463463
464 for (const verbose of [false, true]) {464 it.effect.each([
465 it.effect(`unwraps a JSON title in ${verbose ? "verbose" : "normal"} Claude output`, () => {465 { verbose: false, label: "normal" },
466 const result = {466 { verbose: true, label: "verbose" },
467 type: "result",467 ])("unwraps a JSON title in $label Claude output", ({ verbose }) => {
468 structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' },468 const result = {
469 };469 type: "result",
470 return withFakeClaudeEnv(470 structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' },
471 { output: JSON.stringify(verbose ? [result] : result) },471 };
472 (textGeneration) =>472 return withFakeClaudeEnv(
473 Effect.gen(function* () {473 { output: JSON.stringify(verbose ? [result] : result) },
474 const generated = yield* textGeneration.generateThreadTitle({474 (textGeneration) =>
475 cwd: process.cwd(),475 Effect.gen(function* () {
476 message: "Refresh ev-stg APP ASG instances",476 const generated = yield* textGeneration.generateThreadTitle({
477 modelSelection: {477 cwd: process.cwd(),
478 instanceId: ProviderInstanceId.make("claudeAgent"),478 message: "Refresh ev-stg APP ASG instances",
479 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,479 modelSelection: {
480 },480 instanceId: ProviderInstanceId.make("claudeAgent"),
481 });481 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
482 },
483 });
482484
483 expect(generated.title).toBe("Refresh ev-stg APP ASG instances");485 expect(generated.title).toBe("Refresh ev-stg APP ASG instances");
484 }),486 }),
485 );487 );
486 });488 });
487 }
488489
489 for (const previousTitle of [undefined, "Old thread title"]) {490 it.effect.each([
490 it.effect(491 { previousTitle: undefined, label: "generating" },
491 `reads the result from verbose Claude output when ${previousTitle ? "regenerating" : "generating"} a title`,492 { previousTitle: "Old thread title", label: "regenerating" },
492 () =>493 ])("reads the result from verbose Claude output when $label a title", ({ previousTitle }) =>
493 withFakeClaudeEnv(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([502 type: "result",
496 { type: "system", subtype: "init" },503 subtype: "success",
497 { type: "assistant", message: { content: [] } },504 result: '{"title":"Refresh ev-stg APP ASG Instances"}',
498 { type: "user", message: { content: [] } },505 structured_output: { title: "Refresh ev-stg APP ASG Instances" },
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 ]),
507 },506 },
508 (textGeneration) =>507 ]),
509 Effect.gen(function* () {508 },
510 const generated = yield* textGeneration.generateThreadTitle({509 (textGeneration) =>
511 cwd: process.cwd(),510 Effect.gen(function* () {
512 message: "Refresh ev-stg APP ASG instances",511 const generated = yield* textGeneration.generateThreadTitle({
513 previousTitle,512 cwd: process.cwd(),
514 modelSelection: {513 message: "Refresh ev-stg APP ASG instances",
515 instanceId: ProviderInstanceId.make("claudeAgent"),514 previousTitle,
516 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,515 modelSelection: {
517 },516 instanceId: ProviderInstanceId.make("claudeAgent"),
518 });517 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
519518 },
520 expect(generated.title).toBe("Refresh ev-stg APP ASG Instances");519 });
521 }),
522 ),
523 );
524 }
525520
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 ["empty message array", []],527 ["empty message array", []],
528 ["missing result", [{ type: "assistant", structured_output: { title: "Not a result" } }]],528 ["missing result", [{ type: "assistant", structured_output: { title: "Not a result" } }]],
529 ["invalid title", [{ type: "result", structured_output: { title: 42 } }]],529 ["invalid title", [{ type: "result", structured_output: { title: 42 } }]],
...@@ -534,26 +534,24 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -534,26 +534,24 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
534 { type: "result", subtype: "error_max_structured_output_retries" },534 { type: "result", subtype: "error_max_structured_output_retries" },
535 ],535 ],
536 ],536 ],
537 ] as const) {537 ] as const)("rejects verbose Claude output with %s", ([, output]) =>
538 it.effect(`rejects verbose Claude output with ${name}`, () =>538 withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) =>
539 withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) =>539 Effect.gen(function* () {
540 Effect.gen(function* () {540 const error = yield* Effect.flip(
541 const error = yield* Effect.flip(541 textGeneration.generateThreadTitle({
542 textGeneration.generateThreadTitle({542 cwd: process.cwd(),
543 cwd: process.cwd(),543 message: "Name this thread",
544 message: "Name this thread",544 modelSelection: {
545 modelSelection: {545 instanceId: ProviderInstanceId.make("claudeAgent"),
546 instanceId: ProviderInstanceId.make("claudeAgent"),546 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
547 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,547 },
548 },548 }),
549 }),549 );
550 );
551550
552 expect(error._tag).toBe("TextGenerationError");551 expect(error._tag).toBe("TextGenerationError");
553 }),552 }),
554 ),553 ),
555 );554 );
556 }
557555
558 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>556 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
559 withFakeClaudeEnv(557 withFakeClaudeEnv(
apps/server/src/textGeneration/CodexTextGeneration.test.ts+27-29
...@@ -170,8 +170,9 @@ function withFakeCodexEnv<A, E, R>(...@@ -170,8 +170,9 @@ function withFakeCodexEnv<A, E, R>(
170}170}
171171
172it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {172it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
173 for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) {173 it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])(
174 it.effect(`dispatches the qualified live model for ${selectedModel}`, () =>174 "dispatches the qualified live model for %s",
175 (selectedModel) =>
175 withFakeCodexEnv(176 withFakeCodexEnv(
176 {177 {
177 output: JSON.stringify({ title: "Bedrock title" }),178 output: JSON.stringify({ title: "Bedrock title" }),
...@@ -189,8 +190,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -189,8 +190,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
189 expect(result.title).toBe("Bedrock title");190 expect(result.title).toBe("Bedrock title");
190 }),191 }),
191 ),192 ),
192 );193 );
193 }
194 it.effect("generates and sanitizes commit messages without branch by default", () =>194 it.effect("generates and sanitizes commit messages without branch by default", () =>
195 withFakeCodexEnv(195 withFakeCodexEnv(
196 {196 {
...@@ -406,7 +406,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -406,7 +406,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
406 ),406 ),
407 );407 );
408408
409 for (const example of [409 it.effect.each([
410 {410 {
411 mode: "static",411 mode: "static",
412 output: "Add Search",412 output: "Add Search",
...@@ -425,30 +425,28 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -425,30 +425,28 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
425 expected: "Julius/ABC-123.v2",425 expected: "Julius/ABC-123.v2",
426 instruction: "Preserve the issue ID and capitalization.",426 instruction: "Preserve the issue ID and capitalization.",
427 },427 },
428 ] as const) {428 ] as const)("generates a branch using $mode naming", (example) =>
429 it.effect(`generates a branch using ${example.mode} naming`, () =>429 withFakeCodexEnv(
430 withFakeCodexEnv(430 {
431 {431 output: JSON.stringify({ branch: example.output }),
432 output: JSON.stringify({ branch: example.output }),432 stdinMustContain: example.instruction,
433 stdinMustContain: example.instruction,433 },
434 },434 (textGeneration) =>
435 (textGeneration) =>435 Effect.gen(function* () {
436 Effect.gen(function* () {436 const generated = yield* textGeneration.generateBranchName({
437 const generated = yield* textGeneration.generateBranchName({437 cwd: process.cwd(),
438 cwd: process.cwd(),438 message: "Add search",
439 message: "Add search",439 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
440 modelSelection: DEFAULT_TEST_MODEL_SELECTION,440 naming: {
441 naming: {441 mode: example.mode,
442 mode: example.mode,442 prefix: "team/",
443 prefix: "team/",443 instructions: "Preserve the issue ID and capitalization.",
444 instructions: "Preserve the issue ID and capitalization.",444 },
445 },445 });
446 });446 expect(generated.branch).toBe(example.expected);
447 expect(generated.branch).toBe(example.expected);447 }),
448 }),448 ),
449 ),449 );
450 );
451 }
452450
453 it.effect("generates branch names even when the ambient scope is already closed", () =>451 it.effect("generates branch names even when the ambient scope is already closed", () =>
454 withFakeCodexEnv(452 withFakeCodexEnv(
apps/server/src/textGeneration/CursorTextGeneration.test.ts+8-8
...@@ -253,8 +253,9 @@ describe("CursorTextGeneration", () => {...@@ -253,8 +253,9 @@ describe("CursorTextGeneration", () => {
253 }).pipe(Effect.provide(fsLayer)),253 }).pipe(Effect.provide(fsLayer)),
254 );254 );
255255
256 for (const status of ["error", "cancelled"] as const) {256 it.effect.each(["error", "cancelled"] as const)(
257 it.effect(`rejects a ${status} Cursor SDK run that includes valid title JSON`, () =>257 "rejects a %s Cursor SDK run that includes valid title JSON",
258 (status) =>
258 Effect.gen(function* () {259 Effect.gen(function* () {
259 const promptResult = {260 const promptResult = {
260 id: "run-cursor-partial-title-test",261 id: "run-cursor-partial-title-test",
...@@ -284,8 +285,7 @@ describe("CursorTextGeneration", () => {...@@ -284,8 +285,7 @@ describe("CursorTextGeneration", () => {
284 );285 );
285 expect(cursorSdkMock.close).toHaveBeenCalledOnce();286 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
286 }).pipe(Effect.provide(fsLayer)),287 }).pipe(Effect.provide(fsLayer)),
287 );288 );
288 }
289289
290 it.effect("fails closed when ambient sandbox policy can expand write access", () =>290 it.effect("fails closed when ambient sandbox policy can expand write access", () =>
291 Effect.gen(function* () {291 Effect.gen(function* () {
...@@ -329,8 +329,9 @@ describe("CursorTextGeneration", () => {...@@ -329,8 +329,9 @@ describe("CursorTextGeneration", () => {
329 }).pipe(Effect.provide(fsLayer), Effect.scoped),329 }).pipe(Effect.provide(fsLayer), Effect.scoped),
330 );330 );
331331
332 for (const phase of ["create", "send"] as const) {332 it.effect.each(["create", "send"] as const)(
333 it.effect(`times out pending ${phase} and releases its late SDK resource`, () =>333 "times out pending %s and releases its late SDK resource",
334 (phase) =>
334 Effect.gen(function* () {335 Effect.gen(function* () {
335 let started!: () => void;336 let started!: () => void;
336 const called = new Promise<void>((resolve) => {337 const called = new Promise<void>((resolve) => {
...@@ -388,8 +389,7 @@ describe("CursorTextGeneration", () => {...@@ -388,8 +389,7 @@ describe("CursorTextGeneration", () => {
388 expect(wait).not.toHaveBeenCalled();389 expect(wait).not.toHaveBeenCalled();
389 if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();390 if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
390 }).pipe(Effect.provide(fsLayer), Effect.scoped),391 }).pipe(Effect.provide(fsLayer), Effect.scoped),
391 );392 );
392 }
393393
394 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>394 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>
395 Effect.gen(function* () {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,95 +121,96 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
121}121}
122122
123describe("UsageService", () => {123describe("UsageService", () => {
124 for (const explicitDefault of [true, false]) {124 it.live.each([
125 it.live(125 { explicitDefault: true, label: "explicit" },
126 `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`,126 { explicitDefault: false, label: "legacy" },
127 () =>127 ])(
128 Effect.gen(function* () {128 "reads shared managed $label default and disabled extra account history once",
129 const { home, settings } = yield* setup;129 ({ explicitDefault }) =>
130 const summary = yield* Effect.gen(function* () {130 Effect.gen(function* () {
131 for (const [id, output] of [131 const { home, settings } = yield* setup;
132 ["codex", 17],132 const summary = yield* Effect.gen(function* () {
133 ["codex-personal", 23],133 for (const [id, output] of [
134 ] as const) {134 ["codex", 17],
135 const sessions = NodePath.join(home, "shared-codex", "sessions");135 ["codex-personal", 23],
136 yield* Effect.promise(async () => {136 ] as const) {
137 await NodeFSP.mkdir(sessions, { recursive: true });137 const sessions = NodePath.join(home, "shared-codex", "sessions");
138 await NodeFSP.writeFile(138 yield* Effect.promise(async () => {
139 NodePath.join(sessions, `${id}-rollout.jsonl`),139 await NodeFSP.mkdir(sessions, { recursive: true });
140 [140 await NodeFSP.writeFile(
141 { type: "session_meta", payload: { id } },141 NodePath.join(sessions, `${id}-rollout.jsonl`),
142 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },142 [
143 {143 { type: "session_meta", payload: { id } },
144 type: "event_msg",144 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },
145 timestamp: "2026-08-01T10:00:00Z",145 {
146 payload: {146 type: "event_msg",
147 type: "token_count",147 timestamp: "2026-08-01T10:00:00Z",
148 info: { last_token_usage: { input_tokens: 10, output_tokens: output } },148 payload: {
149 },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: {153 ]
171 ...(explicitDefault154 .map((line) => encodeUnknownJsonString(line))
172 ? {155 .join("\n") + "\n",
173 [ProviderInstanceId.make("codex")]: {156 );
174 driver: ProviderDriverKind.make("codex"),157 });
175 config: {158 }
176 setupMode: "managed",159 const service = yield* UsageService.make;
177 homePath: NodePath.join(home, "shared-codex"),160 return yield* service.readSummary(WINDOW);
178 },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 },
201 ),202 }),
202 );203 ),
203 assert.strictEqual(totalOutputTokens(summary), 40);204 );
204 assert.strictEqual(205 assert.strictEqual(totalOutputTokens(summary), 40);
205 summary.sources.filter(206 assert.strictEqual(
206 (source) => source.fingerprint.provider === "codex" && source.status === "ok",207 summary.sources.filter(
207 ).length,208 (source) => source.fingerprint.provider === "codex" && source.status === "ok",
208 1,209 ).length,
209 );210 1,
210 }).pipe(Effect.scoped),211 );
211 );212 }).pipe(Effect.scoped),
212 }213 );
213 it.live("omits Cursor account usage when no file login is saved", () =>214 it.live("omits Cursor account usage when no file login is saved", () =>
214 Effect.gen(function* () {215 Effect.gen(function* () {
215 const { settings, home } = yield* setup;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,22 +234,20 @@ describe("CLIProxyAPI built-in management API", () => {
234 }),234 }),
235 );235 );
236236
237 for (const [code, outcome] of [237 it.effect.each([
238 ["nothing_to_reset", "nothingToReset"],238 ["nothing_to_reset", "nothingToReset"],
239 ["no_credit", "noCredit"],239 ["no_credit", "noCredit"],
240 ["already_redeemed", "alreadyRedeemed"],240 ["already_redeemed", "alreadyRedeemed"],
241 ] as const) {241 ] as const)("reports %s accurately", ([code, outcome]) =>
242 it.effect(`reports ${code} accurately`, () =>242 Effect.gen(function* () {
243 Effect.gen(function* () {243 const test = fixture({ upstream: () => ({ status: 200, body: { code } }) });
244 const test = fixture({ upstream: () => ({ status: 200, body: { code } }) });244 const api = yield* test.api;
245 const api = yield* test.api;245 expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome });
246 expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome });246 expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(
247 expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(247 code === "already_redeemed",
248 code === "already_redeemed",248 );
249 );249 }),
250 }),250 );
251 );
252 }
253251
254 it.effect("reports redemption success even if cooldown clearing fails", () =>252 it.effect("reports redemption success even if cooldown clearing fails", () =>
255 Effect.gen(function* () {253 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriver.test.ts+358-394
...@@ -392,8 +392,9 @@ it.effect.each([...@@ -392,8 +392,9 @@ it.effect.each([
392 }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)),392 }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)),
393);393);
394394
395for (const blockedPhase of ["discovery", "probe", "retry"] as const) {395it.effect.each(["discovery", "probe", "retry"] as const)(
396 it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () =>396 "checkpoint recovery has one deadline including %s",
397 (blockedPhase) =>
397 Effect.gen(function* () {398 Effect.gen(function* () {
398 const fs = yield* FileSystem.FileSystem;399 const fs = yield* FileSystem.FileSystem;
399 const path = yield* Path.Path;400 const path = yield* Path.Path;
...@@ -478,8 +479,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) {...@@ -478,8 +479,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) {
478 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));479 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
479 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);480 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
480 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),481 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
481 );482);
482}
483483
484it.effect("checkpoint recovery preserves interruption and removes the private index", () =>484it.effect("checkpoint recovery preserves interruption and removes the private index", () =>
485 Effect.gen(function* () {485 Effect.gen(function* () {
...@@ -548,116 +548,103 @@ it.effect("checkpoint capture does not rerun clean filters for unchanged indexed...@@ -548,116 +548,103 @@ it.effect("checkpoint capture does not rerun clean filters for unchanged indexed
548 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),548 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
549);549);
550550
551for (const nested of [false, true]) {551it.effect.each(
552 for (const indexState of [552 [false, true].flatMap((nested) =>
553 "sparse",553 (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map(
554 "flags",554 (indexState) => ({ nested, indexState }),
555 "manual-skip",555 ),
556 "missing",556 ),
557 "non-cone-missing",557)(
558 ] as const) {558 "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)",
559 it.effect(559 ({ nested, indexState }) =>
560 `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`,560 Effect.gen(function* () {
561 () =>561 const fs = yield* FileSystem.FileSystem;
562 Effect.gen(function* () {562 const path = yield* Path.Path;
563 const fs = yield* FileSystem.FileSystem;563 const driver = yield* GitVcsDriver.makeVcsDriverShape();
564 const path = yield* Path.Path;564 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
565 const driver = yield* GitVcsDriver.makeVcsDriverShape();565 const { git } = yield* makeCheckpointFixture(driver, cwd);
566 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });566 const write = Effect.fn(function* (name: string, contents: string) {
567 const { git } = yield* makeCheckpointFixture(driver, cwd);567 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
568 const write = Effect.fn(function* (name: string, contents: string) {568 yield* fs.writeFileString(path.join(cwd, name), contents);
569 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });569 });
570 yield* fs.writeFileString(path.join(cwd, name), contents);570 for (const name of [
571 });571 "scope/in/edit",
572 for (const name of [572 "scope/in/delete",
573 "scope/in/edit",573 "scope/out/deep/absent",
574 "scope/in/delete",574 "scope/out/present",
575 "scope/out/deep/absent",575 "elsewhere/file",
576 "scope/out/present",576 ]) {
577 "elsewhere/file",577 yield* write(name, "original\n");
578 ]) {578 }
579 yield* write(name, "original\n");579 yield* git(["add", "."]);
580 }580 yield* git(["commit", "-m", "sparse fixture"]);
581 yield* git(["add", "."]);581 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]);
582 yield* git(["commit", "-m", "sparse fixture"]);582 if (indexState === "non-cone-missing")
583 yield* git([583 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
584 "sparse-checkout",584 yield* write("scope/in/edit", "staged\n");
585 "set",585 yield* write("elsewhere/file", "staged outside\n");
586 "--cone",586 yield* git(["add", "."]);
587 "--sparse-index",587 if (indexState === "flags")
588 "scope/in",588 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
589 "elsewhere",589 if (indexState === "manual-skip")
590 ]);590 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
591 if (indexState === "non-cone-missing")591 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
592 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);592 yield* write("scope/in/edit", "working\n");
593 yield* write("scope/in/edit", "staged\n");593 yield* write("scope/out/present", "modified skipped\n");
594 yield* write("elsewhere/file", "staged outside\n");594 yield* write("scope/out/new file", "new outside cone\n");
595 yield* git(["add", "."]);595 yield* write("elsewhere/file", "working outside\n");
596 if (indexState === "flags")596 yield* fs.remove(path.join(cwd, "scope/in/delete"));
597 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);597 const indexPath = path.join(cwd, ".git/index");
598 if (indexState === "manual-skip")598 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
599 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);599 const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null));
600 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);600 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
601 yield* write("scope/in/edit", "working\n");601 for (const turn of [1, 2]) {
602 yield* write("scope/out/present", "modified skipped\n");602 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
603 yield* write("scope/out/new file", "new outside cone\n");603 if (turn === 2) {
604 yield* write("elsewhere/file", "working outside\n");604 yield* write("scope/in/edit", "second\n");
605 yield* fs.remove(path.join(cwd, "scope/in/delete"));605 yield* fs.remove(path.join(cwd, "scope/out/new file"));
606 const indexPath = path.join(cwd, ".git/index");606 yield* write("scope/out/second", "second addition\n");
607 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);607 }
608 const originalIndex = yield* fs608 const capture = driver.checkpoints.captureCheckpoint({
609 .readFile(indexPath)609 cwd: captureCwd,
610 .pipe(Effect.orElseSucceed(() => null));610 checkpointRef: ref,
611 const captureCwd = nested ? path.join(cwd, "scope") : cwd;611 });
612 for (const turn of [1, 2]) {612 if (indexState === "non-cone-missing") {
613 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);613 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
614 if (turn === 2) {614 assert.isFalse(
615 yield* write("scope/in/edit", "second\n");615 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
616 yield* fs.remove(path.join(cwd, "scope/out/new file"));616 );
617 yield* write("scope/out/second", "second addition\n");617 assert.isFalse(yield* fs.exists(indexPath));
618 }618 break;
619 const capture = driver.checkpoints.captureCheckpoint({619 }
620 cwd: captureCwd,620 yield* capture;
621 checkpointRef: ref,621 for (const [name, content] of [
622 });622 ["scope/out/deep/absent", "original\n"],
623 if (indexState === "non-cone-missing") {623 ["scope/out/present", "modified skipped\n"],
624 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");624 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
625 assert.isFalse(625 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
626 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),626 [
627 );627 turn === 1 ? "scope/out/new file" : "scope/out/second",
628 assert.isFalse(yield* fs.exists(indexPath));628 turn === 1 ? "new outside cone\n" : "second addition\n",
629 break;629 ],
630 }630 ]) {
631 yield* capture;631 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
632 for (const [name, content] of [632 }
633 ["scope/out/deep/absent", "original\n"],633 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
634 ["scope/out/present", "modified skipped\n"],634 assert.notInclude(files, "scope/in/delete");
635 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],635 if (turn === 2) assert.notInclude(files, "scope/out/new file");
636 ["elsewhere/file", nested ? "original\n" : "working outside\n"],636 assert.deepEqual(
637 [637 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
638 turn === 1 ? "scope/out/new file" : "scope/out/second",638 originalIndex,
639 turn === 1 ? "new outside cone\n" : "second addition\n",639 );
640 ],640 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
641 ]) {641 assert.strictEqual(
642 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);642 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
643 }643 "working outside\n",
644 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");644 );
645 assert.notInclude(files, "scope/in/delete");645 }
646 if (turn === 2) assert.notInclude(files, "scope/out/new file");646 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
647 assert.deepEqual(647);
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}
661648
662it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () =>649it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () =>
663 Effect.gen(function* () {650 Effect.gen(function* () {
...@@ -699,206 +686,186 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse"...@@ -699,206 +686,186 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse"
699 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),686 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
700);687);
701688
702for (const indexMode of ["normal", "flags", "sparse"] as const) {689it.effect.each(["normal", "flags", "sparse"] as const)(
703 it.effect(690 "checkpoint index inspection handles entries beyond the output cap (index=%s)",
704 `checkpoint index inspection handles entries beyond the output cap (index=${indexMode})`,691 (indexMode) =>
705 () =>692 Effect.gen(function* () {
706 Effect.gen(function* () {693 const fs = yield* FileSystem.FileSystem;
707 const fs = yield* FileSystem.FileSystem;694 const path = yield* Path.Path;
708 const path = yield* Path.Path;695 const liveProcess = yield* VcsProcess.VcsProcess;
709 const liveProcess = yield* VcsProcess.VcsProcess;696 const driver = yield* GitVcsDriver.makeVcsDriverShape();
710 const driver = yield* GitVcsDriver.makeVcsDriverShape();697 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" });
711 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" });698 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
712 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);699 yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n");
713 yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n");700 yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n");
714 yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n");701 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n");
715 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n");702 yield* fs.makeDirectory(path.join(cwd, "excluded"));
716 yield* fs.makeDirectory(path.join(cwd, "excluded"));703 yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n");
717 yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n");704 yield* fs.writeFileString(
718 yield* fs.writeFileString(705 path.join(cwd, ".git/filter.cjs"),
719 path.join(cwd, ".git/filter.cjs"),706 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);',
720 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);',707 );
721 );708 yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]);
722 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);
723 yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000);710 yield* git(["add", "."]);
724 yield* git(["add", "."]);711 yield* git(["commit", "-m", "inspection fixture"]);
725 yield* git(["commit", "-m", "inspection fixture"]);712 if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]);
726 if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]);713 if (indexMode === "sparse")
727 if (indexMode === "sparse")714 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]);
728 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]);715 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n");
729 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n");716 yield* fs.writeFileString(path.join(cwd, ".git/reads"), "");
730 yield* fs.writeFileString(path.join(cwd, ".git/reads"), "");717 const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index"));
731 const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index"));718 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
732 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(719 Effect.provideService(VcsProcess.VcsProcess, {
733 Effect.provideService(VcsProcess.VcsProcess, {720 run: (input) =>
734 run: (input) =>721 liveProcess.run(
735 liveProcess.run(722 input.args.includes("ls-files")
736 input.args.includes("ls-files")723 ? {
737 ? {724 ...input,
738 ...input,725 maxOutputBytes: 8,
739 maxOutputBytes: 8,726 onStdoutChunk: (chunk) => {
740 onStdoutChunk: (chunk) => {727 for (let i = 0; i < chunk.length; i++)
741 for (let i = 0; i < chunk.length; i++)728 input.onStdoutChunk?.(chunk.subarray(i, i + 1));
742 input.onStdoutChunk?.(chunk.subarray(i, i + 1));729 },
743 },730 }
744 }731 : input,
745 : input,732 ),
746 ),733 }),
747 }),734 );
748 );735 yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
749 yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef });736 assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n");
750 assert.strictEqual(737 if (indexMode !== "flags")
751 (yield* git(["show", `${checkpointRef}:z-skipped`])).stdout,738 assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), "");
752 "modified\n",739 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex);
753 );740 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
754 if (indexMode !== "flags")741);
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}
760742
761for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) {743it.effect.each([1_700_000_000, 1_700_000_000.9999])(
762 it.effect(744 "checkpoint capture preserves same-size edits with racy index timestamps (%s)",
763 `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`,745 (timestamp) =>
764 () =>746 Effect.gen(function* () {
765 Effect.gen(function* () {747 const fileSystem = yield* FileSystem.FileSystem;
766 const fileSystem = yield* FileSystem.FileSystem;748 const path = yield* Path.Path;
767 const path = yield* Path.Path;749 const driver = yield* GitVcsDriver.makeVcsDriverShape();
768 const driver = yield* GitVcsDriver.makeVcsDriverShape();750 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" });
769 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" });751 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
770 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);752 const filePath = path.join(cwd, "file.txt");
771 const filePath = path.join(cwd, "file.txt");753 const indexPath = path.join(cwd, ".git", "index");
772 const indexPath = path.join(cwd, ".git", "index");754 yield* git(["config", "core.trustctime", "false"]);
773 yield* git(["config", "core.trustctime", "false"]);755 yield* fileSystem.writeFileString(filePath, "before\n");
774 yield* fileSystem.writeFileString(filePath, "before\n");756 yield* fileSystem.utimes(filePath, timestamp, timestamp);
775 yield* fileSystem.utimes(filePath, timestamp, timestamp);757 yield* git(["add", "file.txt"]);
776 yield* git(["add", "file.txt"]);758 yield* git(["commit", "-m", "record racy file"]);
777 yield* git(["commit", "-m", "record racy file"]);759 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
778 yield* fileSystem.utimes(indexPath, timestamp, timestamp);760 const originalIndex = yield* fileSystem.readFile(indexPath);
779 const originalIndex = yield* fileSystem.readFile(indexPath);761 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
780 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;762 yield* fileSystem.writeFileString(filePath, "after!\n");
781 yield* fileSystem.writeFileString(filePath, "after!\n");763 yield* fileSystem.utimes(filePath, timestamp, timestamp);
782 yield* fileSystem.utimes(filePath, timestamp, timestamp);
783764
784 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });765 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
785766
786 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n");767 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n");
787 assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex);768 assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex);
788 assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);769 assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
789 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),770 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
790 );771);
791}772it.effect.each(
792for (const nested of [false, true]) {773 [false, true].flatMap((nested) =>
793 for (const indexState of [774 (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map(
794 "sparse",775 (indexState) => ({ nested, indexState }),
795 "flags",776 ),
796 "manual-skip",777 ),
797 "missing",778)(
798 "non-cone-missing",779 "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)",
799 ] as const) {780 ({ nested, indexState }) =>
800 it.effect(781 Effect.gen(function* () {
801 `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`,782 const fs = yield* FileSystem.FileSystem;
802 () =>783 const path = yield* Path.Path;
803 Effect.gen(function* () {784 const driver = yield* GitVcsDriver.makeVcsDriverShape();
804 const fs = yield* FileSystem.FileSystem;785 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
805 const path = yield* Path.Path;786 const { git } = yield* makeCheckpointFixture(driver, cwd);
806 const driver = yield* GitVcsDriver.makeVcsDriverShape();787 const write = Effect.fn(function* (name: string, contents: string) {
807 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });788 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
808 const { git } = yield* makeCheckpointFixture(driver, cwd);789 yield* fs.writeFileString(path.join(cwd, name), contents);
809 const write = Effect.fn(function* (name: string, contents: string) {790 });
810 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });791 for (const name of [
811 yield* fs.writeFileString(path.join(cwd, name), contents);792 "scope/in/edit",
812 });793 "scope/in/delete",
813 for (const name of [794 "scope/out/deep/absent",
814 "scope/in/edit",795 "scope/out/present",
815 "scope/in/delete",796 "elsewhere/file",
816 "scope/out/deep/absent",797 ]) {
817 "scope/out/present",798 yield* write(name, "original\n");
818 "elsewhere/file",799 }
819 ]) {800 yield* git(["add", "."]);
820 yield* write(name, "original\n");801 yield* git(["commit", "-m", "sparse fixture"]);
821 }802 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]);
822 yield* git(["add", "."]);803 if (indexState === "non-cone-missing")
823 yield* git(["commit", "-m", "sparse fixture"]);804 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
824 yield* git([805 yield* write("scope/in/edit", "staged\n");
825 "sparse-checkout",806 yield* write("elsewhere/file", "staged outside\n");
826 "set",807 yield* git(["add", "."]);
827 "--cone",808 if (indexState === "flags")
828 "--sparse-index",809 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
829 "scope/in",810 if (indexState === "manual-skip")
830 "elsewhere",811 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
831 ]);812 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
832 if (indexState === "non-cone-missing")813 yield* write("scope/in/edit", "working\n");
833 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);814 yield* write("scope/out/present", "modified skipped\n");
834 yield* write("scope/in/edit", "staged\n");815 yield* write("scope/out/new file", "new outside cone\n");
835 yield* write("elsewhere/file", "staged outside\n");816 yield* write("elsewhere/file", "working outside\n");
836 yield* git(["add", "."]);817 yield* fs.remove(path.join(cwd, "scope/in/delete"));
837 if (indexState === "flags")818 const indexPath = path.join(cwd, ".git/index");
838 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);819 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
839 if (indexState === "manual-skip")820 const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null));
840 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);821 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
841 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);822 for (const turn of [1, 2]) {
842 yield* write("scope/in/edit", "working\n");823 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
843 yield* write("scope/out/present", "modified skipped\n");824 if (turn === 2) {
844 yield* write("scope/out/new file", "new outside cone\n");825 yield* write("scope/in/edit", "second\n");
845 yield* write("elsewhere/file", "working outside\n");826 yield* fs.remove(path.join(cwd, "scope/out/new file"));
846 yield* fs.remove(path.join(cwd, "scope/in/delete"));827 yield* write("scope/out/second", "second addition\n");
847 const indexPath = path.join(cwd, ".git/index");828 }
848 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);829 const capture = driver.checkpoints.captureCheckpoint({
849 const originalIndex = yield* fs830 cwd: captureCwd,
850 .readFile(indexPath)831 checkpointRef: ref,
851 .pipe(Effect.orElseSucceed(() => null));832 });
852 const captureCwd = nested ? path.join(cwd, "scope") : cwd;833 if (indexState === "non-cone-missing") {
853 for (const turn of [1, 2]) {834 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
854 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);835 assert.isFalse(
855 if (turn === 2) {836 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
856 yield* write("scope/in/edit", "second\n");837 );
857 yield* fs.remove(path.join(cwd, "scope/out/new file"));838 assert.isFalse(yield* fs.exists(indexPath));
858 yield* write("scope/out/second", "second addition\n");839 break;
859 }840 }
860 const capture = driver.checkpoints.captureCheckpoint({841 yield* capture;
861 cwd: captureCwd,842 for (const [name, content] of [
862 checkpointRef: ref,843 ["scope/out/deep/absent", "original\n"],
863 });844 ["scope/out/present", "modified skipped\n"],
864 if (indexState === "non-cone-missing") {845 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
865 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");846 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
866 assert.isFalse(847 [
867 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),848 turn === 1 ? "scope/out/new file" : "scope/out/second",
868 );849 turn === 1 ? "new outside cone\n" : "second addition\n",
869 assert.isFalse(yield* fs.exists(indexPath));850 ],
870 break;851 ]) {
871 }852 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
872 yield* capture;853 }
873 for (const [name, content] of [854 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
874 ["scope/out/deep/absent", "original\n"],855 assert.notInclude(files, "scope/in/delete");
875 ["scope/out/present", "modified skipped\n"],856 if (turn === 2) assert.notInclude(files, "scope/out/new file");
876 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],857 assert.deepEqual(
877 ["elsewhere/file", nested ? "original\n" : "working outside\n"],858 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
878 [859 originalIndex,
879 turn === 1 ? "scope/out/new file" : "scope/out/second",860 );
880 turn === 1 ? "new outside cone\n" : "second addition\n",861 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
881 ],862 assert.strictEqual(
882 ]) {863 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
883 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);864 "working outside\n",
884 }865 );
885 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");866 }
886 assert.notInclude(files, "scope/in/delete");867 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
887 if (turn === 2) assert.notInclude(files, "scope/out/new file");868);
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}
902869
903it.effect("checkpoint capture preserves racy edits made after resetting the index", () =>870it.effect("checkpoint capture preserves racy edits made after resetting the index", () =>
904 Effect.gen(function* () {871 Effect.gen(function* () {
...@@ -943,89 +910,87 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde...@@ -943,89 +910,87 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde
943 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),910 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
944);911);
945912
946for (const nested of [false, true]) {913it.effect.each(
947 for (const indexMode of ["normal", "flags", "split"] as const) {914 [false, true].flatMap((nested) =>
948 it.effect(915 (["normal", "flags", "split"] as const).map((indexMode) => ({ nested, indexMode })),
949 `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`,916 ),
950 () =>917)(
951 Effect.gen(function* () {918 "checkpoint index reuse preserves two turns (nested=$nested, index=$indexMode)",
952 const fileSystem = yield* FileSystem.FileSystem;919 ({ nested, indexMode }) =>
953 const path = yield* Path.Path;920 Effect.gen(function* () {
954 const driver = yield* GitVcsDriver.makeVcsDriverShape();921 const fileSystem = yield* FileSystem.FileSystem;
955 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" });922 const path = yield* Path.Path;
956 const { git } = yield* makeCheckpointFixture(driver, cwd);923 const driver = yield* GitVcsDriver.makeVcsDriverShape();
957 const write = (name: string, contents: string) =>924 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" });
958 fileSystem.writeFileString(path.join(cwd, name), contents);925 const { git } = yield* makeCheckpointFixture(driver, cwd);
959 yield* fileSystem.makeDirectory(path.join(cwd, "scope"));926 const write = (name: string, contents: string) =>
960 for (const name of [927 fileSystem.writeFileString(path.join(cwd, name), contents);
961 "scope/staged",928 yield* fileSystem.makeDirectory(path.join(cwd, "scope"));
962 "scope/deleted",929 for (const name of [
963 "scope/assumed",930 "scope/staged",
964 "scope/skipped",931 "scope/deleted",
965 "outside",932 "scope/assumed",
966 ]) {933 "scope/skipped",
967 yield* write(name, "original\n");934 "outside",
968 }935 ]) {
969 yield* git(["add", "."]);936 yield* write(name, "original\n");
970 yield* git(["commit", "-m", "initial scoped files"]);937 }
971 yield* write("scope/staged", "staged\n");938 yield* git(["add", "."]);
972 yield* write("scope/new-deleted", "staged then deleted\n");939 yield* git(["commit", "-m", "initial scoped files"]);
973 yield* write("outside", "staged outside\n");940 yield* write("scope/staged", "staged\n");
974 yield* git(["add", "."]);941 yield* write("scope/new-deleted", "staged then deleted\n");
975 if (indexMode === "flags") {942 yield* write("outside", "staged outside\n");
976 yield* git(["update-index", "--assume-unchanged", "scope/assumed"]);943 yield* git(["add", "."]);
977 yield* git(["update-index", "--skip-worktree", "scope/skipped"]);944 if (indexMode === "flags") {
978 }945 yield* git(["update-index", "--assume-unchanged", "scope/assumed"]);
979 if (indexMode === "split") {946 yield* git(["update-index", "--skip-worktree", "scope/skipped"]);
980 yield* git(["update-index", "--split-index"]);947 }
981 }948 if (indexMode === "split") {
982 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));949 yield* git(["update-index", "--split-index"]);
983 for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) {950 }
984 yield* write(name, "working\n");951 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));
985 }952 for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) {
986 yield* write("scope/new", "first\n");953 yield* write(name, "working\n");
987 yield* fileSystem.remove(path.join(cwd, "scope/deleted"));954 }
988 yield* fileSystem.remove(path.join(cwd, "scope/new-deleted"));955 yield* write("scope/new", "first\n");
989 const captureCwd = nested ? path.join(cwd, "scope") : cwd;956 yield* fileSystem.remove(path.join(cwd, "scope/deleted"));
990 const first = CheckpointRef.make("refs/t3/checkpoints/turns/1");957 yield* fileSystem.remove(path.join(cwd, "scope/new-deleted"));
991 const second = CheckpointRef.make("refs/t3/checkpoints/turns/2");958 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
992 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first });959 const first = CheckpointRef.make("refs/t3/checkpoints/turns/1");
993 for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) {960 const second = CheckpointRef.make("refs/t3/checkpoints/turns/2");
994 assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n");961 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first });
995 }962 for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) {
996 assert.strictEqual(963 assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n");
997 (yield* git(["show", `${first}:outside`])).stdout,964 }
998 nested ? "original\n" : "working\n",965 assert.strictEqual(
999 );966 (yield* git(["show", `${first}:outside`])).stdout,
1000 const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n");967 nested ? "original\n" : "working\n",
1001 assert.notInclude(files, "scope/deleted");968 );
1002 assert.notInclude(files, "scope/new-deleted");969 const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n");
1003 assert.include(files, "scope/new");970 assert.notInclude(files, "scope/deleted");
971 assert.notInclude(files, "scope/new-deleted");
972 assert.include(files, "scope/new");
1004973
1005 yield* write("scope/staged", "second\n");974 yield* write("scope/staged", "second\n");
1006 yield* fileSystem.remove(path.join(cwd, "scope/new"));975 yield* fileSystem.remove(path.join(cwd, "scope/new"));
1007 yield* write("scope/second", "added in second turn\n");976 yield* write("scope/second", "added in second turn\n");
1008 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second });977 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second });
1009 assert.strictEqual(978 assert.strictEqual(
1010 (yield* git(["diff", "--name-only", first, second])).stdout,979 (yield* git(["diff", "--name-only", first, second])).stdout,
1011 "scope/new\nscope/second\nscope/staged\n",980 "scope/new\nscope/second\nscope/staged\n",
1012 );981 );
1013 assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n");982 assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n");
1014 assert.strictEqual(983 assert.strictEqual(
1015 (yield* git(["show", `${second}:scope/second`])).stdout,984 (yield* git(["show", `${second}:scope/second`])).stdout,
1016 "added in second turn\n",985 "added in second turn\n",
1017 );986 );
1018 assert.deepEqual(987 assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex);
1019 yield* fileSystem.readFile(path.join(cwd, ".git", "index")),988 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
1020 originalIndex,989);
1021 );
1022 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
1023 );
1024 }
1025}
1026990
1027for (const indexState of ["missing", "invalid"] as const) {991it.effect.each(["missing", "invalid"] as const)(
1028 it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () =>992 "checkpoint capture falls back when the user index is %s",
993 (indexState) =>
1029 Effect.gen(function* () {994 Effect.gen(function* () {
1030 const fileSystem = yield* FileSystem.FileSystem;995 const fileSystem = yield* FileSystem.FileSystem;
1031 const path = yield* Path.Path;996 const path = yield* Path.Path;
...@@ -1048,8 +1013,7 @@ for (const indexState of ["missing", "invalid"] as const) {...@@ -1048,8 +1013,7 @@ for (const indexState of ["missing", "invalid"] as const) {
1048 assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index");1013 assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index");
1049 }1014 }
1050 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),1015 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
1051 );1016);
1052}
10531017
1054it.effect("restores empty checkpoints without changing paths outside the workspace", () =>1018it.effect("restores empty checkpoints without changing paths outside the workspace", () =>
1055 Effect.gen(function* () {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,50 +254,48 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])(
254 }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),254 }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
255);255);
256256
257for (const location of ["root", "nested", "worktree"] as const) {257it.effect.each(["root", "nested", "worktree"] as const)(
258 it.effect(258 "skips clean filters while the %s index is locked and resumes after unlock",
259 `skips clean filters while the ${location} index is locked and resumes after unlock`,259 (location) =>
260 () =>260 Effect.gen(function* () {
261 Effect.gen(function* () {261 const driver = yield* GitVcsDriver.GitVcsDriver;
262 const driver = yield* GitVcsDriver.GitVcsDriver;262 const fs = yield* FileSystem.FileSystem;
263 const fs = yield* FileSystem.FileSystem;263 const path = yield* Path.Path;
264 const path = yield* Path.Path;264 const repository = yield* makeTmpDir();
265 const repository = yield* makeTmpDir();265 yield* initRepoWithCommit(repository);
266 yield* initRepoWithCommit(repository);266 const cwd = location === "worktree" ? yield* makeTmpDir() : repository;
267 const cwd = location === "worktree" ? yield* makeTmpDir() : repository;267 if (location === "worktree") {
268 if (location === "worktree") {268 yield* git(repository, ["worktree", "add", "--detach", cwd]);
269 yield* git(repository, ["worktree", "add", "--detach", cwd]);269 }
270 }270 yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]);
271 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, ".gitattributes", "asset.bin filter=probe\n");272 yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n");
273 yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n");273 yield* writeTextFile(cwd, "asset.bin", "original\n");
274 yield* writeTextFile(cwd, "asset.bin", "original\n");274 yield* git(cwd, ["add", "."]);
275 yield* git(cwd, ["add", "."]);275 yield* git(cwd, ["commit", "-m", "filtered asset"]);
276 yield* git(cwd, ["commit", "-m", "filtered asset"]);276 NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1);
277 NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1);277 const runsPath = path.join(cwd, ".filter-runs");
278 const runsPath = path.join(cwd, ".filter-runs");278 yield* fs.remove(runsPath, { force: true });
279 yield* fs.remove(runsPath, { force: true });279 const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]);
280 const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]);280 const lockPath = `${path.resolve(cwd, indexPath)}.lock`;
281 const lockPath = `${path.resolve(cwd, indexPath)}.lock`;281 yield* fs.writeFileString(lockPath, "");
282 yield* fs.writeFileString(lockPath, "");282 const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd;
283 const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd;283 yield* fs.makeDirectory(statusCwd, { recursive: true });
284 yield* fs.makeDirectory(statusCwd, { recursive: true });284
285285 for (let poll = 0; poll < 3; poll++) {
286 for (let poll = 0; poll < 3; poll++) {286 const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result);
287 const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result);287 assert.isTrue(Result.isFailure(result));
288 assert.isTrue(Result.isFailure(result));288 if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked");
289 if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked");289 }
290 }290 assert.isFalse(yield* fs.exists(runsPath));
291 assert.isFalse(yield* fs.exists(runsPath));291 assert.isTrue(yield* fs.exists(lockPath));
292 assert.isTrue(yield* fs.exists(lockPath));292
293293 yield* fs.remove(lockPath);
294 yield* fs.remove(lockPath);294 const status = yield* driver.statusDetailsLocal(statusCwd);
295 const status = yield* driver.statusDetailsLocal(statusCwd);295 assert.isFalse(status.hasWorkingTreeChanges);
296 assert.isFalse(status.hasWorkingTreeChanges);296 assert.include(yield* fs.readFileString(runsPath), "clean");
297 assert.include(yield* fs.readFileString(runsPath), "clean");297 }).pipe(Effect.provide(TestLayer)),
298 }).pipe(Effect.provide(TestLayer)),298);
299 );
300}
301299
302it.effect("uses stable diagnostics for every parsed non-repository command", () => {300it.effect("uses stable diagnostics for every parsed non-repository command", () => {
303 const commands: Array<{ readonly args: ReadonlyArray<string>; readonly lcAll?: string }> = [];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,7 +820,7 @@ it.effect("backs off and logs failed fetch attempts across linked worktrees", ()
822 ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),820 ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
823);821);
824822
825for (const scenario of [823it.effect.each([
826 {824 {
827 name: "HTTPS credentials",825 name: "HTTPS credentials",
828 stderr: "fatal: Authentication failed for",826 stderr: "fatal: Authentication failed for",
...@@ -885,53 +883,51 @@ for (const scenario of [...@@ -885,53 +883,51 @@ for (const scenario of [
885 stderr: "fatal: unexpected remote failure",883 stderr: "fatal: unexpected remote failure",
886 expected: "git fetch origin failed",884 expected: "git fetch origin failed",
887 },885 },
888] as const) {886] as const)("reports $name during fetch without retaining remote output", (scenario) =>
889 it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () =>887 Effect.gen(function* () {
890 Effect.gen(function* () {888 const secret = "secret-fetch-token";
891 const secret = "secret-fetch-token";889 const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`;
892 const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`;890 const attempts = yield* Ref.make(0);
893 const attempts = yield* Ref.make(0);891 const spawner = ChildProcessSpawner.make((command) =>
894 const spawner = ChildProcessSpawner.make((command) =>892 Effect.gen(function* () {
895 Effect.gen(function* () {893 if (!ChildProcess.isStandardCommand(command))
896 if (!ChildProcess.isStandardCommand(command))894 return yield* Effect.die("expected Git command");
897 return yield* Effect.die("expected Git command");895 if (command.args[0] !== "fetch") return makeNonRepositoryHandle();
898 if (command.args[0] !== "fetch") return makeNonRepositoryHandle();896 assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]);
899 assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]);897 assert.equal(command.options.env?.LC_ALL, "C");
900 assert.equal(command.options.env?.LC_ALL, "C");898 assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0");
901 assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0");899 yield* Ref.update(attempts, (count) => count + 1);
902 yield* Ref.update(attempts, (count) => count + 1);900 return ChildProcessSpawner.makeHandle({
903 return ChildProcessSpawner.makeHandle({901 pid: ChildProcessSpawner.ProcessId(1),
904 pid: ChildProcessSpawner.ProcessId(1),902 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
905 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),903 isRunning: Effect.succeed(false),
906 isRunning: Effect.succeed(false),904 kill: () => Effect.void,
907 kill: () => Effect.void,905 unref: Effect.succeed(Effect.void),
908 unref: Effect.succeed(Effect.void),906 stdin: Sink.drain,
909 stdin: Sink.drain,907 stdout: Stream.encodeText(Stream.make(secret)),
910 stdout: Stream.encodeText(Stream.make(secret)),908 stderr: Stream.encodeText(Stream.make(stderr)),
911 stderr: Stream.encodeText(Stream.make(stderr)),909 all: Stream.empty,
912 all: Stream.empty,910 getInputFd: () => Sink.drain,
913 getInputFd: () => Sink.drain,911 getOutputFd: () => Stream.empty,
914 getOutputFd: () => Stream.empty,912 });
915 });913 }),
916 }),914 );
917 );915 const driver = yield* makeGitVcsDriverCore().pipe(
918 const driver = yield* makeGitVcsDriverCore().pipe(916 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
919 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),917 );
920 );918 const cwd = yield* makeTmpDir();
921 const cwd = yield* makeTmpDir();919 const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip);
922 const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip);920 assert.include(error.detail, scenario.expected);
923 assert.include(error.detail, scenario.expected);921 assert.equal(error.exitCode, 128);
924 assert.equal(error.exitCode, 128);922 assert.equal(error.stderrLength, stderr.length);
925 assert.equal(error.stderrLength, stderr.length);923 assert.equal(error.stdoutLength, secret.length);
926 assert.equal(error.stdoutLength, secret.length);924 assert.notInclude(error.message, secret);
927 assert.notInclude(error.message, secret);925 assert.notInclude(yield* encodeGitCommandError(error), secret);
928 assert.notInclude(yield* encodeGitCommandError(error), secret);926 assert.notProperty(error, "stderr");
929 assert.notProperty(error, "stderr");927 assert.notProperty(error, "args");
930 assert.notProperty(error, "args");928 assert.equal(yield* Ref.get(attempts), 1);
931 assert.equal(yield* Ref.get(attempts), 1);929 }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
932 }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),930);
933 );
934}
935931
936it.layer(TestLayer)("GitVcsDriver core integration", (it) => {932it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
937 describe("process environment", () => {933 describe("process environment", () => {
...@@ -1449,8 +1445,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1449,8 +1445,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1449 }),1445 }),
1450 );1446 );
14511447
1452 for (const splitIndex of [false, true]) {1448 it.effect.each([false, true])(
1453 it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () =>1449 "keeps the preceding second cached in review previews (split: %s)",
1450 (splitIndex) =>
1454 Effect.gen(function* () {1451 Effect.gen(function* () {
1455 const cwd = yield* makeTmpDir();1452 const cwd = yield* makeTmpDir();
1456 yield* initRepoWithCommit(cwd);1453 yield* initRepoWithCommit(cwd);
...@@ -1484,53 +1481,50 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1484,53 +1481,50 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1484 assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex);1481 assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex);
1485 assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime);1482 assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime);
1486 }),1483 }),
1487 );1484 );
1488 }
14891485
1490 for (const [timestamp, splitIndex] of [1486 it.effect.each([
1491 [1_700_000_000, false],1487 [1_700_000_000, false],
1492 [1_700_000_000.9999, false],1488 [1_700_000_000.9999, false],
1493 [1_700_000_000, true],1489 [1_700_000_000, true],
1494 [1_700_000_000.9999, true],1490 [1_700_000_000.9999, true],
1495 ] as const) {1491 ] as const)(
1496 it.effect(1492 "preserves same-size edits with a racy review index (%s, split: %s)",
1497 `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`,1493 ([timestamp, splitIndex]) =>
1498 () =>1494 Effect.gen(function* () {
1499 Effect.gen(function* () {1495 const cwd = yield* makeTmpDir();
1500 const cwd = yield* makeTmpDir();1496 yield* initRepoWithCommit(cwd);
1501 yield* initRepoWithCommit(cwd);1497 const driver = yield* GitVcsDriver.GitVcsDriver;
1502 const driver = yield* GitVcsDriver.GitVcsDriver;1498 const fileSystem = yield* FileSystem.FileSystem;
1503 const fileSystem = yield* FileSystem.FileSystem;1499 const path = yield* Path.Path;
1504 const path = yield* Path.Path;1500 const filePath = path.join(cwd, "tracked.txt");
1505 const filePath = path.join(cwd, "tracked.txt");1501 const indexPath = path.join(cwd, ".git", "index");
1506 const indexPath = path.join(cwd, ".git", "index");1502 // Reproduce a same-timestamp edit without relying on filesystem clock resolution.
1507 // Reproduce a same-timestamp edit without relying on filesystem clock resolution.1503 yield* git(cwd, ["config", "core.trustctime", "false"]);
1508 yield* git(cwd, ["config", "core.trustctime", "false"]);1504 yield* writeTextFile(cwd, "tracked.txt", "before\n");
1509 yield* writeTextFile(cwd, "tracked.txt", "before\n");1505 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1510 yield* fileSystem.utimes(filePath, timestamp, timestamp);1506 yield* git(cwd, ["add", "tracked.txt"]);
1511 yield* git(cwd, ["add", "tracked.txt"]);1507 yield* git(cwd, ["commit", "-m", "record racy file"]);
1512 yield* git(cwd, ["commit", "-m", "record racy file"]);1508 if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]);
1513 if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]);1509 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
1514 yield* fileSystem.utimes(indexPath, timestamp, timestamp);1510 const originalIndex = yield* fileSystem.readFile(indexPath);
1515 const originalIndex = yield* fileSystem.readFile(indexPath);1511 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
1516 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;1512 yield* writeTextFile(cwd, "tracked.txt", "after!\n");
1517 yield* writeTextFile(cwd, "tracked.txt", "after!\n");1513 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1518 yield* fileSystem.utimes(filePath, timestamp, timestamp);1514 yield* writeTextFile(cwd, "untracked.txt", "new\n");
1519 yield* writeTextFile(cwd, "untracked.txt", "new\n");1515
15201516 const preview = yield* driver.getReviewDiffPreview({ cwd });
1521 const preview = yield* driver.getReviewDiffPreview({ cwd });1517 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
1522 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;1518 assert.deepStrictEqual(dirty.files, [
1523 assert.deepStrictEqual(dirty.files, [1519 { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 },
1524 { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 },1520 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
1525 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },1521 ]);
1526 ]);1522 assert.include(dirty.diff, "-before");
1527 assert.include(dirty.diff, "-before");1523 assert.include(dirty.diff, "+after!");
1528 assert.include(dirty.diff, "+after!");1524 assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex);
1529 assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex);1525 assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
1530 assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);1526 }),
1531 }),1527 );
1532 );
1533 }
15341528
1535 it.effect("keeps complete stats for files beyond the combined patch limit", () =>1529 it.effect("keeps complete stats for files beyond the combined patch limit", () =>
1536 Effect.gen(function* () {1530 Effect.gen(function* () {
...@@ -2940,8 +2934,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -2940,8 +2934,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
2940 });2934 });
29412935
2942 describe("remote operations", () => {2936 describe("remote operations", () => {
2943 for (const failure of ["offline", "auth", "timeout"] as const) {2937 it.effect.each(["offline", "auth", "timeout"] as const)(
2944 it.effect(`does not retry a scoped fetch after ${failure}`, () =>2938 "does not retry a scoped fetch after %s",
2939 (failure) =>
2945 Effect.gen(function* () {2940 Effect.gen(function* () {
2946 const cwd = yield* makeTmpDir();2941 const cwd = yield* makeTmpDir();
2947 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;2942 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
...@@ -2996,8 +2991,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -2996,8 +2991,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
2996 );2991 );
2997 }2992 }
2998 }),2993 }),
2999 );2994 );
3000 }
30012995
3002 it.effect("creates a worktree from the latest fetched remote commit", () =>2996 it.effect("creates a worktree from the latest fetched remote commit", () =>
3003 Effect.gen(function* () {2997 Effect.gen(function* () {
apps/web/src/components/chat/queuedMessageEdit.test.ts+18-17
...@@ -42,24 +42,25 @@ describe("queued message file edits", () => {...@@ -42,24 +42,25 @@ describe("queued message file edits", () => {
42 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });42 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
43 });43 });
4444
45 for (const images of [[], [image]]) {45 it.each([
46 it(`preserves a generic file in a ${images.length === 0 ? "file-only" : "mixed"} save`, async () => {46 { images: [], label: "file-only" },
47 const attachments = await prepareQueuedEditAttachments({47 { images: [image], label: "mixed" },
48 existingAttachments: [],48 ])("preserves a generic file in a $label save", async ({ images }) => {
49 images,49 const attachments = await prepareQueuedEditAttachments({
50 files: [file],50 existingAttachments: [],
51 uploadFiles: async () => [uploadedFile],51 images,
52 readImage: async () => "data:image/png;base64,aW1hZ2U=",52 files: [file],
53 });53 uploadFiles: async () => [uploadedFile],
54 expect(attachments.at(-1)).toEqual(uploadedFile);54 readImage: async () => "data:image/png;base64,aW1hZ2U=",
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 });
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 });
6364
64 it("retains saved attachments alongside newly uploaded files", async () => {65 it("retains saved attachments alongside newly uploaded files", async () => {
65 const saved = { ...uploadedFile, id: "saved:earlier" };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,8 +1279,9 @@ describe("composer and pull request shortcuts", () => {
1279 ["Enter", "thread.steerQueuedMessage"],1279 ["Enter", "thread.steerQueuedMessage"],
1280 ] as const;1280 ] as const;
12811281
1282 for (const platform of ["MacIntel", "Win32", "Linux"]) {1282 it.each(["MacIntel", "Win32", "Linux"])(
1283 it(`separates queued steering and background start on ${platform}`, () => {1283 "separates queued steering and background start on %s",
1284 (platform) => {
1284 const modifier = {1285 const modifier = {
1285 metaKey: platform === "MacIntel",1286 metaKey: platform === "MacIntel",
1286 ctrlKey: platform !== "MacIntel",1287 ctrlKey: platform !== "MacIntel",
...@@ -1301,8 +1302,8 @@ describe("composer and pull request shortcuts", () => {...@@ -1301,8 +1302,8 @@ describe("composer and pull request shortcuts", () => {
1301 }),1302 }),
1302 "composer.sendBackground",1303 "composer.sendBackground",
1303 );1304 );
1304 });1305 },
1305 }1306 );
13061307
1307 for (const platform of ["MacIntel", "Win32", "Linux"]) {1308 for (const platform of ["MacIntel", "Win32", "Linux"]) {
1308 it.each(shortcuts)(1309 it.each(shortcuts)(
...@@ -1331,8 +1332,9 @@ describe("composer and pull request shortcuts", () => {...@@ -1331,8 +1332,9 @@ describe("composer and pull request shortcuts", () => {
1331 );1332 );
1332 }1333 }
13331334
1334 for (const platform of ["MacIntel", "Win32", "Linux"]) {1335 it.each(["MacIntel", "Win32", "Linux"])(
1335 it(`edits the last queued message with Alt+ArrowUp from the composer on ${platform}`, () => {1336 "edits the last queued message with Alt+ArrowUp from the composer on %s",
1337 (platform) => {
1336 const input = event({ key: "ArrowUp", altKey: true });1338 const input = event({ key: "ArrowUp", altKey: true });
1337 assert.strictEqual(1339 assert.strictEqual(
1338 resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, {1340 resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, {
...@@ -1347,8 +1349,8 @@ describe("composer and pull request shortcuts", () => {...@@ -1347,8 +1349,8 @@ describe("composer and pull request shortcuts", () => {
1347 context: { composerFocus: false },1349 context: { composerFocus: false },
1348 }),1350 }),
1349 );1351 );
1350 });1352 },
1351 }1353 );
13521354
1353 for (const platform of ["MacIntel", "Win32", "Linux"]) {1355 for (const platform of ["MacIntel", "Win32", "Linux"]) {
1354 it.each([1356 it.each([
infra/relay/src/agentActivity/ApnsClient.test.ts+74-74
...@@ -366,79 +366,79 @@ describe("ApnsClient", () => {...@@ -366,79 +366,79 @@ describe("ApnsClient", () => {
366 }).pipe(Effect.provide(layer));366 }).pipe(Effect.provide(layer));
367 });367 });
368368
369 for (const requestKind of ["live-activity", "push-notification"] as const) {369 it.effect.each(
370 for (const stage of ["send", "read-response"] as const) {370 (["live-activity", "push-notification"] as const).flatMap((requestKind) =>
371 it.effect(`aborts a stalled ${requestKind} ${stage} after ten seconds`, () =>371 (["send", "read-response"] as const).map((stage) => ({ requestKind, stage })),
372 Effect.gen(function* () {372 ),
373 const started = yield* Deferred.make<void>();373 )("aborts a stalled $requestKind $stage after ten seconds", ({ requestKind, stage }) =>
374 const signals: AbortSignal[] = [];374 Effect.gen(function* () {
375 const stalledHttpClient = HttpClient.make((request, _url, signal) => {375 const started = yield* Deferred.make<void>();
376 signals.push(signal);376 const signals: AbortSignal[] = [];
377 const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));377 const stalledHttpClient = HttpClient.make((request, _url, signal) => {
378 if (stage === "send") return stall;378 signals.push(signal);
379 const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));379 const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));
380 Object.defineProperty(response, "text", { value: stall });380 if (stage === "send") return stall;
381 return Effect.succeed(response);381 const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
382 });382 Object.defineProperty(response, "text", { value: stall });
383 const layer = ApnsClient.layer.pipe(383 return Effect.succeed(response);
384 Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)),384 });
385 Layer.provide(385 const layer = ApnsClient.layer.pipe(
386 Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, {386 Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)),
387 getJwt: () => Effect.succeed("test-jwt"),387 Layer.provide(
388 }),388 Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, {
389 ),389 getJwt: () => Effect.succeed("test-jwt"),
390 );390 }),
391 const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer));391 ),
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 }),
441 );392 );
442 }393 const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer));
443 }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,8 +1909,9 @@ describe("live activity alert decisions", () => {
1909});1909});
19101910
1911describe("queued iOS alert policy", () => {1911describe("queued iOS alert policy", () => {
1912 for (const scenario of ["enabled", "muted", "late"] as const) {1912 it.effect.each(["enabled", "muted", "late"] as const)(
1913 it.effect(`checks the current policy for a ${scenario} completion`, () => {1913 "checks the current policy for a %s completion",
1914 (scenario) => {
1914 let sent = 0;1915 let sent = 0;
1915 const completed = { ...state, phase: "completed" as const };1916 const completed = { ...state, phase: "completed" as const };
1916 const prefs = JSON.parse(enabledPreferences);1917 const prefs = JSON.parse(enabledPreferences);
...@@ -1957,8 +1958,8 @@ describe("queued iOS alert policy", () => {...@@ -1957,8 +1958,8 @@ describe("queued iOS alert policy", () => {
1957 }),1958 }),
1958 ),1959 ),
1959 );1960 );
1960 });1961 },
1961 }1962 );
1962});1963});
19631964
1964describe("fast completion delivery", () => {1965describe("fast completion delivery", () => {
...@@ -2019,74 +2020,74 @@ describe("fast completion delivery", () => {...@@ -2019,74 +2020,74 @@ describe("fast completion delivery", () => {
2019});2020});
20202021
2021describe("signed APNs registration metadata", () => {2022describe("signed APNs registration metadata", () => {
2022 for (const kind of ["live_activity_update", "push_notification"] as const) {2023 it.effect.each(
2023 for (const changed of ["bundle", "environment", "legacy"] as const) {2024 (["live_activity_update", "push_notification"] as const).flatMap((kind) =>
2024 it.effect(`routes ${kind} using current registration with ${changed} job metadata`, () => {2025 (["bundle", "environment", "legacy"] as const).map((changed) => ({ kind, changed })),
2025 const attempts: DeliveryAttempts.DeliveryAttemptInput[] = [];2026 ),
2026 const requests: HttpClientRequest.HttpClientRequest[] = [];2027 )("routes $kind using current registration with $changed job metadata", ({ kind, changed }) => {
2027 const payload = makeApnsDeliveryJobPayload({2028 const attempts: DeliveryAttempts.DeliveryAttemptInput[] = [];
2028 kind,2029 const requests: HttpClientRequest.HttpClientRequest[] = [];
2029 userId: target.user_id,2030 const payload = makeApnsDeliveryJobPayload({
2030 deviceId: target.device_id,2031 kind,
2031 token: "unchanged-token",2032 userId: target.user_id,
2032 ...(changed === "legacy"2033 deviceId: target.device_id,
2033 ? {}2034 token: "unchanged-token",
2034 : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }),2035 ...(changed === "legacy"
2035 aggregate: kind === "live_activity_update" ? aggregate : null,2036 ? {}
2036 ...(kind === "push_notification"2037 : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }),
2037 ? {2038 aggregate: kind === "live_activity_update" ? aggregate : null,
2038 notification: {2039 ...(kind === "push_notification"
2039 title: "Thread",2040 ? {
2040 body: "Input: Project",2041 notification: {
2041 environmentId: "env",2042 title: "Thread",
2042 threadId: "thread",2043 body: "Input: Project",
2043 deepLink: "/",2044 environmentId: "env",
2044 },2045 threadId: "thread",
2045 }2046 deepLink: "/",
2046 : {}),2047 },
2047 createdAt: "1970-01-01T00:00:00.000Z",2048 }
2048 expiresAt: "1970-01-01T00:10:00.000Z",2049 : {}),
2049 jobId: `metadata-${kind}-${changed}`,2050 createdAt: "1970-01-01T00:00:00.000Z",
2050 });2051 expiresAt: "1970-01-01T00:10:00.000Z",
2051 const signed = signApnsDeliveryJob({2052 jobId: `metadata-${kind}-${changed}`,
2052 secret: config.apnsDeliveryJobSigningSecret,2053 });
2053 payload,2054 const signed = signApnsDeliveryJob({
2054 });2055 secret: config.apnsDeliveryJobSigningSecret,
2055 return Effect.gen(function* () {2056 payload,
2056 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;2057 });
2057 const result = yield* deliveries.processSignedJob(signed);2058 return Effect.gen(function* () {
2058 expect(result.ok).toBe(true);2059 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
2059 expect(requests).toHaveLength(1);2060 const result = yield* deliveries.processSignedJob(signed);
2060 expect(requests[0]?.url).toBe(2061 expect(result.ok).toBe(true);
2061 `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`,2062 expect(requests).toHaveLength(1);
2062 );2063 expect(requests[0]?.url).toBe(
2063 expect(requests[0]?.headers["apns-topic"]).toBe(2064 `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`,
2064 `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`,2065 );
2065 );2066 expect(requests[0]?.headers["apns-topic"]).toBe(
2066 }).pipe(2067 `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`,
2067 Effect.provide(2068 );
2068 makeLayer({2069 }).pipe(
2069 attempts,2070 Effect.provide(
2070 config: signingConfig,2071 makeLayer({
2071 currentTargets: [2072 attempts,
2072 {2073 config: signingConfig,
2073 ...target,2074 currentTargets: [
2074 push_token: "unchanged-token",2075 {
2075 activity_push_token: "unchanged-token",2076 ...target,
2076 bundle_id:2077 push_token: "unchanged-token",
2077 changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev",2078 activity_push_token: "unchanged-token",
2078 aps_environment: changed === "environment" ? "production" : "sandbox",2079 bundle_id:
2079 },2080 changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev",
2080 ],2081 aps_environment: changed === "environment" ? "production" : "sandbox",
2081 execute: (request) =>2082 },
2082 Effect.sync(() => {2083 ],
2083 requests.push(request);2084 execute: (request) =>
2084 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));2085 Effect.sync(() => {
2085 }),2086 requests.push(request);
2087 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
2086 }),2088 }),
2087 ),2089 }),
2088 );2090 ),
2089 });2091 );
2090 }2092 });
2091 }
2092});2093});
infra/relay/src/agentActivity/FcmDeliveries.test.ts+75-73
...@@ -215,36 +215,39 @@ describe("Android delivery routing", () => {...@@ -215,36 +215,39 @@ describe("Android delivery routing", () => {
215 deepLink: "/threads/env/second-thread",215 deepLink: "/threads/env/second-thread",
216 };216 };
217217
218 for (const [firstPhase, secondPhase, title, active] of [218 it.effect.each([
219 ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"],219 {
220 ["completed", "failed", "2 agents finished", "false"],220 firstPhase: "waiting_for_approval",
221 ] as const) {221 secondPhase: "waiting_for_input",
222 it.effect(222 title: "2 agents need attention",
223 `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`,223 active: "true",
224 () => {224 },
225 const h = harness();225 { firstPhase: "completed", secondPhase: "failed", title: "2 agents finished", active: "false" },
226 h.current.otherStates = [secondState];226 ] as const)(
227 return Effect.gen(function* () {227 "routes grouped $firstPhase and $secondPhase to the overview once across their queued jobs",
228 const delivery = yield* FcmDeliveries.FcmDeliveries;228 ({ firstPhase, secondPhase, title, active }) => {
229 yield* delivery.process(h.job);229 const h = harness();
230 h.current.state = { ...state, phase: firstPhase };230 h.current.otherStates = [secondState];
231 h.current.otherStates = [{ ...secondState, phase: secondPhase }];231 return Effect.gen(function* () {
232 yield* delivery.process({ ...h.job, state: h.current.state });232 const delivery = yield* FcmDeliveries.FcmDeliveries;
233 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });233 yield* delivery.process(h.job);
234 yield* delivery.process({ ...h.job, state: h.current.state });234 h.current.state = { ...state, phase: firstPhase };
235 const alerts = h.sent.filter((message) => message.alert);235 h.current.otherStates = [{ ...secondState, phase: secondPhase }];
236 expect(alerts).toHaveLength(1);236 yield* delivery.process({ ...h.job, state: h.current.state });
237 expect(alerts[0]?.data).toMatchObject({237 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });
238 alert_title: title,238 yield* delivery.process({ ...h.job, state: h.current.state });
239 alert_body: "Fix notifications, Second thread",239 const alerts = h.sent.filter((message) => message.alert);
240 alert_path: "/",240 expect(alerts).toHaveLength(1);
241 active,241 expect(alerts[0]?.data).toMatchObject({
242 });242 alert_title: title,
243 expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2);243 alert_body: "Fix notifications, Second thread",
244 }).pipe(Effect.provide(h.layer));244 alert_path: "/",
245 },245 active,
246 );246 });
247 }247 expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2);
248 }).pipe(Effect.provide(h.layer));
249 },
250 );
248251
249 it.effect("filters disabled event types before counting a group", () => {252 it.effect("filters disabled event types before counting a group", () => {
250 const h = harness();253 const h = harness();
...@@ -294,13 +297,9 @@ describe("Android delivery routing", () => {...@@ -294,13 +297,9 @@ describe("Android delivery routing", () => {
294 }).pipe(Effect.provide(h.layer));297 }).pipe(Effect.provide(h.layer));
295 });298 });
296299
297 for (const phase of [300 it.effect.each(["completed", "waiting_for_approval", "waiting_for_input", "failed"] as const)(
298 "completed",301 "deleting one thread preserves another thread's %s alert",
299 "waiting_for_approval",302 (phase) => {
300 "waiting_for_input",
301 "failed",
302 ] as const) {
303 it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => {
304 const h = harness();303 const h = harness();
305 h.current.otherStates = [secondState];304 h.current.otherStates = [secondState];
306 return Effect.gen(function* () {305 return Effect.gen(function* () {
...@@ -316,8 +315,8 @@ describe("Android delivery routing", () => {...@@ -316,8 +315,8 @@ describe("Android delivery routing", () => {
316 yield* delivery.process({ ...h.job, state: h.current.state });315 yield* delivery.process({ ...h.job, state: h.current.state });
317 expect(h.sent.filter((message) => message.alert)).toHaveLength(1);316 expect(h.sent.filter((message) => message.alert)).toHaveLength(1);
318 }).pipe(Effect.provide(h.layer));317 }).pipe(Effect.provide(h.layer));
319 });318 },
320 }319 );
321320
322 it.effect("registration replay establishes a baseline without alerting", () => {321 it.effect("registration replay establishes a baseline without alerting", () => {
323 const h = harness();322 const h = harness();
...@@ -333,8 +332,9 @@ describe("Android delivery routing", () => {...@@ -333,8 +332,9 @@ describe("Android delivery routing", () => {
333 }).pipe(Effect.provide(h.layer));332 }).pipe(Effect.provide(h.layer));
334 });333 });
335334
336 for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) {335 it.effect.each(["mutedEnvironments", "revokedEnvironments"] as const)(
337 it.effect(`excludes ${restriction} when forming cross-environment groups`, () => {336 "excludes %s when forming cross-environment groups",
337 (restriction) => {
338 const h = harness();338 const h = harness();
339 const other = { ...secondState, environmentId: EnvironmentId.make("other-env") };339 const other = { ...secondState, environmentId: EnvironmentId.make("other-env") };
340 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other]));340 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other]));
...@@ -349,8 +349,8 @@ describe("Android delivery routing", () => {...@@ -349,8 +349,8 @@ describe("Android delivery routing", () => {
349 alert_body: "Approval: Project",349 alert_body: "Approval: Project",
350 });350 });
351 }).pipe(Effect.provide(h.layer));351 }).pipe(Effect.provide(h.layer));
352 });352 },
353 }353 );
354354
355 it("gives a group a stable retry identity independent of row order", () => {355 it("gives a group a stable retry identity independent of row order", () => {
356 const other = {356 const other = {
...@@ -409,13 +409,14 @@ describe("Android delivery routing", () => {...@@ -409,13 +409,14 @@ describe("Android delivery routing", () => {
409 ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" });409 ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" });
410 });410 });
411411
412 for (const [phase, body, preference] of [412 it.effect.each([
413 ["waiting_for_approval", "Approval: Project", "notifyOnApproval"],413 { phase: "waiting_for_approval", body: "Approval: Project", preference: "notifyOnApproval" },
414 ["waiting_for_input", "Input: Project", "notifyOnInput"],414 { phase: "waiting_for_input", body: "Input: Project", preference: "notifyOnInput" },
415 ["completed", "Done: Project", "notifyOnCompletion"],415 { phase: "completed", body: "Done: Project", preference: "notifyOnCompletion" },
416 ["failed", "Failed: Project", "notifyOnFailure"],416 { phase: "failed", body: "Failed: Project", preference: "notifyOnFailure" },
417 ] as const) {417 ] as const)(
418 it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => {418 "uses iOS alert wording for $phase and honors its preference",
419 ({ phase, body, preference }) => {
419 const h = harness();420 const h = harness();
420 h.current.state = { ...state, phase };421 h.current.state = { ...state, phase };
421 return Effect.gen(function* () {422 return Effect.gen(function* () {
...@@ -433,8 +434,8 @@ describe("Android delivery routing", () => {...@@ -433,8 +434,8 @@ describe("Android delivery routing", () => {
433 yield* delivery.process({ ...h.job, state: h.current.state });434 yield* delivery.process({ ...h.job, state: h.current.state });
434 expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true);435 expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true);
435 }).pipe(Effect.provide(h.layer));436 }).pipe(Effect.provide(h.layer));
436 });437 },
437 }438 );
438439
439 it("trims and truncates alert text like iOS", () => {440 it("trims and truncates alert text like iOS", () => {
440 expect(441 expect(
...@@ -572,23 +573,23 @@ describe("Android delivery routing", () => {...@@ -572,23 +573,23 @@ describe("Android delivery routing", () => {
572 expect(h.sent[1]?.data.alert_id).toBeUndefined();573 expect(h.sent[1]?.data.alert_id).toBeUndefined();
573 }).pipe(Effect.provide(h.layer));574 }).pipe(Effect.provide(h.layer));
574 });575 });
575 for (const ongoing of [true, false]) {576 it.effect.each(
576 for (const phase of ["completed", "failed"] as const) {577 [true, false].flatMap((ongoing) =>
577 it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => {578 (["completed", "failed"] as const).map((phase) => ({ ongoing, phase })),
578 const h = harness();579 ),
579 h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" };580 )("does not alert a stale $phase without a baseline (ongoing=$ongoing)", ({ ongoing, phase }) => {
580 h.current.target.preferences_json = encodeJson({581 const h = harness();
581 ...preferences,582 h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" };
582 liveActivitiesEnabled: ongoing,583 h.current.target.preferences_json = encodeJson({
583 });584 ...preferences,
584 return Effect.gen(function* () {585 liveActivitiesEnabled: ongoing,
585 const delivery = yield* FcmDeliveries.FcmDeliveries;586 });
586 yield* delivery.process({ ...h.job, state: h.current.state });587 return Effect.gen(function* () {
587 expect(h.sent.every((message) => !message.alert)).toBe(true);588 const delivery = yield* FcmDeliveries.FcmDeliveries;
588 }).pipe(Effect.provide(h.layer));589 yield* delivery.process({ ...h.job, state: h.current.state });
589 });590 expect(h.sent.every((message) => !message.alert)).toBe(true);
590 }591 }).pipe(Effect.provide(h.layer));
591 }592 });
592593
593 it.effect(594 it.effect(
594 "retains finished results without extending expiry on replay and clears expired cards",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,8 +822,9 @@ it("stops reducing five-character row fields and fits the remaining alert", () =
821});822});
822823
823describe("FCM queue message isolation", () => {824describe("FCM queue message isolation", () => {
824 for (const failure of ["invalid-job", "fcm-rejection"] as const) {825 it.effect.each(["invalid-job", "fcm-rejection"] as const)(
825 it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => {826 "retries only the %s message and delivers the rest of its batch",
827 (failure) => {
826 const h = harness();828 const h = harness();
827 const outcomes = new Map<string, "ack" | "retry">();829 const outcomes = new Map<string, "ack" | "retry">();
828 const message = (id: string, body: unknown): Cloudflare.Queues.Message<unknown> => ({830 const message = (id: string, body: unknown): Cloudflare.Queues.Message<unknown> => ({
...@@ -863,6 +865,6 @@ describe("FCM queue message isolation", () => {...@@ -863,6 +865,6 @@ describe("FCM queue message isolation", () => {
863 expect(h.sent).toHaveLength(1);865 expect(h.sent).toHaveLength(1);
864 expect(h.marked).toHaveLength(1);866 expect(h.marked).toHaveLength(1);
865 }).pipe(Effect.provide(h.layer));867 }).pipe(Effect.provide(h.layer));
866 });868 },
867 }869 );
868});870});
oxlint-plugin-t3code/index.ts+2
...@@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";...@@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
7import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";7import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
8import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";8import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noTestInLoop from "./rules/no-test-in-loop.ts";
1011
11export default definePlugin({12export default definePlugin({
12 meta: {13 meta: {
...@@ -20,5 +21,6 @@ export default definePlugin({...@@ -20,5 +21,6 @@ export default definePlugin({
20 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,21 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
21 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,22 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
22 "no-native-title-tooltip": noNativeTitleTooltip,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 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-test-in-loop", {
6 filename: "fixture.test.ts",
7});
8
9describe("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
106const productionRule = createOxlintRuleHarness("t3code/no-test-in-loop");
107
108productionRule.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 @@
1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, unwrapExpression } from "../utils.ts";
5
6const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
7// node:test has no `.each`, so files written against it keep their loops.
8const NODE_TEST_IMPORT_PATTERN = /["']node:test["']/u;
9// Calls that declare a test or a group of tests; all of them expose `.each`.
10const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]);
11// Modifiers whose tester also exposes `.each` (@effect/vitest's `it.effect` and `it.live`).
12const EACH_CAPABLE_MODIFIERS = new Set(["effect", "live"]);
13const LOOPS = new Set(["ForStatement", "ForInStatement", "ForOfStatement"]);
14const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]);
15
16/** Returns the reported name for `it(…)`, `describe(…)`, `it.effect(…)`, and friends. */
17const 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 */
42const 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
54export 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,8 +444,9 @@ describe("ConnectionResolver", () => {
444 }),444 }),
445 );445 );
446446
447 for (const scenario of ["unchanged", "changed", "revocation-failed"] as const) {447 it.effect.each(["unchanged", "changed", "revocation-failed"] as const)(
448 it.effect(`handles ${scenario} SSH routing consent before saving or authorizing`, () =>448 "handles %s SSH routing consent before saving or authorizing",
449 (scenario) =>
449 Effect.gen(function* () {450 Effect.gen(function* () {
450 const calls: string[] = [];451 const calls: string[] = [];
451 const target = new SshConnectionTarget({452 const target = new SshConnectionTarget({
...@@ -537,8 +538,7 @@ describe("ConnectionResolver", () => {...@@ -537,8 +538,7 @@ describe("ConnectionResolver", () => {
537 }538 }
538 expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write");539 expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write");
539 }),540 }),
540 );541 );
541 }
542542
543 it.effect("preserves relay authorization failure classification and trace details", () =>543 it.effect("preserves relay authorization failure classification and trace details", () =>
544 Effect.gen(function* () {544 Effect.gen(function* () {
packages/client-runtime/src/operations/commands.test.ts+67-69
...@@ -502,83 +502,81 @@ describe("V2 environment commands", () => {...@@ -502,83 +502,81 @@ describe("V2 environment commands", () => {
502 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),502 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
503 );503 );
504504
505 for (const status of [505 it.effect.each([
506 "waiting",506 "waiting",
507 "completed",507 "completed",
508 "failed",508 "failed",
509 "interrupted",509 "interrupted",
510 "cancelled",510 "cancelled",
511 "rolled_back",511 "rolled_back",
512 ] as const) {512 ] as const)("dispatches Stop for %s runs with background commands except rollback", (status) =>
513 it.effect(`dispatches Stop for ${status} runs with background commands except rollback`, () =>513 Effect.gen(function* () {
514 Effect.gen(function* () {514 const waitingRunId = RunId.make("run-waiting");
515 const waitingRunId = RunId.make("run-waiting");515 const projection: OrchestrationV2ThreadProjection = {
516 const projection: OrchestrationV2ThreadProjection = {516 ...v2Projection,
517 ...v2Projection,517 runs: [
518 runs: [518 {
519 {519 id: waitingRunId,
520 id: waitingRunId,520 threadId: v2ThreadId,
521 threadId: v2ThreadId,521 ordinal: 1,
522 ordinal: 1,522 providerInstanceId: v2Projection.thread.providerInstanceId,
523 providerInstanceId: v2Projection.thread.providerInstanceId,523 modelSelection: v2Projection.thread.modelSelection,
524 modelSelection: v2Projection.thread.modelSelection,524 providerThreadId: null,
525 providerThreadId: null,525 userMessageId: MessageId.make("message-waiting"),
526 userMessageId: MessageId.make("message-waiting"),526 rootNodeId: null,
527 rootNodeId: null,527 activeAttemptId: null,
528 activeAttemptId: null,528 status,
529 status,529 requestedAt: v2Now,
530 requestedAt: v2Now,530 startedAt: v2Now,
531 startedAt: v2Now,531 completedAt: null,
532 completedAt: null,532 checkpointId: null,
533 checkpointId: null,533 contextHandoffId: null,
534 contextHandoffId: null,534 },
535 },535 ],
536 ],536 turnItems: [
537 turnItems: [537 {
538 {538 id: TurnItemId.make("background-command"),
539 id: TurnItemId.make("background-command"),539 threadId: v2ThreadId,
540 threadId: v2ThreadId,540 runId: waitingRunId,
541 runId: waitingRunId,541 nodeId: null,
542 nodeId: null,542 providerThreadId: null,
543 providerThreadId: null,543 providerTurnId: null,
544 providerTurnId: null,544 nativeItemRef: null,
545 nativeItemRef: null,545 parentItemId: null,
546 parentItemId: null,546 ordinal: 1,
547 ordinal: 1,547 status: "running",
548 status: "running",548 title: null,
549 title: null,549 startedAt: v2Now,
550 startedAt: v2Now,550 completedAt: null,
551 completedAt: null,551 updatedAt: v2Now,
552 updatedAt: v2Now,552 type: "command_execution",
553 type: "command_execution",553 input: "vp run dev",
554 input: "vp run dev",554 },
555 },555 ],
556 ],556 };
557 };557 const commands: OrchestrationV2Command[] = [];
558 const commands: OrchestrationV2Command[] = [];558 const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
559 const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
560559
561 const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe(560 const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe(
562 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),561 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
563 );562 );
564563
565 expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 });564 expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 });
566 expect(commands).toEqual(565 expect(commands).toEqual(
567 status === "rolled_back"566 status === "rolled_back"
568 ? []567 ? []
569 : [568 : [
570 {569 {
571 type: "run.interrupt",570 type: "run.interrupt",
572 commandId: expect.any(String),571 commandId: expect.any(String),
573 threadId: v2ThreadId,572 threadId: v2ThreadId,
574 runId: waitingRunId,573 runId: waitingRunId,
575 holdQueue: true,574 holdQueue: true,
576 },575 },
577 ],576 ],
578 );577 );
579 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),578 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
580 );579 );
581 }
582580
583 it.effect(581 it.effect(
584 "dispatches V2-native relationship and queue commands without compatibility shaping",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,59 +429,55 @@ describe("RpcSessionFactory", () => {
429 ),429 ),
430 );430 );
431431
432 for (const options of [432 it.effect.each([
433 { environmentThemes: true },433 { environmentThemes: true },
434 { usageLimitSources: true },434 { usageLimitSources: true },
435 { environmentThemes: true, usageLimitSources: true },435 { environmentThemes: true, usageLimitSources: true },
436 ]) {436 ])("shares only a config subscription with the same opt-ins: %j", (options) =>
437 it.effect(437 Effect.scoped(
438 `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`,438 Effect.gen(function* () {
439 () =>439 const { factory, sockets } = yield* makeFactory(options);
440 Effect.scoped(440 const session = yield* factory.connect(PREPARED);
441 Effect.gen(function* () {441 const readyFiber = yield* Effect.forkChild(session.ready);
442 const { factory, sockets } = yield* makeFactory(options);442 const socket = yield* awaitSocket(sockets);
443 const session = yield* factory.connect(PREPARED);443 socket.open();
444 const readyFiber = yield* Effect.forkChild(session.ready);444 yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options);
445 const socket = yield* awaitSocket(sockets);445 yield* Fiber.join(readyFiber);
446 socket.open();446
447 yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options);447 const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead);
448 yield* Fiber.join(readyFiber);448 expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } });
449449 expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength(
450 const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead);450 1,
451 expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } });451 );
452 expect(452
453 socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest),453 const fallbackFiber = yield* session
454 ).toHaveLength(1);454 .subscribeServerConfig({})
455455 .pipe(Stream.runHead, Effect.forkChild);
456 const fallbackFiber = yield* session456 const fallbackRequest = yield* awaitRequest(socket, 1);
457 .subscribeServerConfig({})457 expect(fallbackRequest).toMatchObject({
458 .pipe(Stream.runHead, Effect.forkChild);458 tag: WS_METHODS.subscribeServerConfig,
459 const fallbackRequest = yield* awaitRequest(socket, 1);459 payload: {},
460 expect(fallbackRequest).toMatchObject({460 });
461 tag: WS_METHODS.subscribeServerConfig,461 socket.serverMessage(
462 payload: {},462 encodeJson({
463 });463 _tag: "Chunk",
464 socket.serverMessage(464 requestId: fallbackRequest.id,
465 encodeJson({465 values: [
466 _tag: "Chunk",466 {
467 requestId: fallbackRequest.id,467 version: 1,
468 values: [468 type: "snapshot",
469 {469 config: ENCODED_THEME_SERVER_CONFIG,
470 version: 1,470 },
471 type: "snapshot",471 ],
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 });
481 }),472 }),
482 ),473 );
483 );474 expect(yield* Fiber.join(fallbackFiber)).toMatchObject({
484 }475 _tag: "Some",
476 value: { type: "snapshot" },
477 });
478 }),
479 ),
480 );
485481
486 it.effect.each([482 it.effect.each([
487 { usageLimitSources: true },483 { usageLimitSources: true },
...@@ -1191,37 +1187,38 @@ describe("RpcSessionFactory", () => {...@@ -1191,37 +1187,38 @@ describe("RpcSessionFactory", () => {
1191 ),1187 ),
1192 );1188 );
11931189
1194 for (const relay of [false, true]) {1190 it.effect.each([
1195 it.effect(`fails readiness when the ${relay ? "relay" : "direct"} websocket never opens`, () =>1191 { relay: false, label: "direct" },
1196 Effect.gen(function* () {1192 { relay: true, label: "relay" },
1197 const { factory, sockets } = yield* makeFactory();1193 ])("fails readiness when the $label websocket never opens", ({ relay }) =>
1194 Effect.gen(function* () {
1195 const { factory, sockets } = yield* makeFactory();
11981196
1199 const error = yield* Effect.scoped(1197 const error = yield* Effect.scoped(
1200 Effect.gen(function* () {1198 Effect.gen(function* () {
1201 const session = yield* factory.connect({1199 const session = yield* factory.connect({
1202 ...PREPARED,1200 ...PREPARED,
1203 target: relay1201 target: relay
1204 ? new RelayConnectionTarget({1202 ? new RelayConnectionTarget({
1205 environmentId: TARGET.environmentId,1203 environmentId: TARGET.environmentId,
1206 label: TARGET.label,1204 label: TARGET.label,
1207 })1205 })
1208 : TARGET,1206 : TARGET,
1209 });1207 });
1210 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));1208 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));
1211 yield* awaitSocket(sockets);1209 yield* awaitSocket(sockets);
1212
1213 yield* TestClock.adjust("15 seconds");
1214 return yield* Fiber.join(readyFiber);
1215 }),
1216 );
12171210
1218 expect(error).toBeInstanceOf(ConnectionTransientError);1211 yield* TestClock.adjust("15 seconds");
1219 expect(error).toMatchObject({1212 return yield* Fiber.join(readyFiber);
1220 reason: "transport",1213 }),
1221 message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`,1214 );
1222 });1215
1223 expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED);1216 expect(error).toBeInstanceOf(ConnectionTransientError);
1224 }).pipe(Effect.provide(TestClock.layer())),1217 expect(error).toMatchObject({
1225 );1218 reason: "transport",
1226 }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,7 +60,7 @@ describe("asset collection keys", () => {
60});60});
6161
62describe("createAssetEnvironmentAtoms", () => {62describe("createAssetEnvironmentAtoms", () => {
63 for (const scenario of [63 it.effect.each([
64 { name: "missing video", path: "/tmp/clip.mp4", fallback: true },64 { name: "missing video", path: "/tmp/clip.mp4", fallback: true },
65 { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true },65 { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true },
66 { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true },66 { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true },
...@@ -73,113 +73,111 @@ describe("createAssetEnvironmentAtoms", () => {...@@ -73,113 +73,111 @@ describe("createAssetEnvironmentAtoms", () => {
73 { name: "same environment", path: "/tmp/clip.mp4", primary: "same" },73 { name: "same environment", path: "/tmp/clip.mp4", primary: "same" },
74 { name: "non-media", path: "/tmp/report.html" },74 { name: "non-media", path: "/tmp/report.html" },
75 { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" },75 { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" },
76 ]) {76 ])("uses the correct environment for $name", (scenario) =>
77 it.effect(`uses the correct environment for ${scenario.name}`, () =>77 Effect.gen(function* () {
78 Effect.gen(function* () {78 const remoteId = EnvironmentId.make("remote");
79 const remoteId = EnvironmentId.make("remote");79 const localId = EnvironmentId.make("local");
80 const localId = EnvironmentId.make("local");80 const resource = {
81 const resource = {81 _tag: "media-file" as const,
82 _tag: "media-file" as const,82 threadId: ThreadId.make("foreign-thread"),
83 threadId: ThreadId.make("foreign-thread"),83 path: scenario.path,
84 path: scenario.path,84 };
85 };85 const error =
86 const error =86 scenario.error === "auth"
87 scenario.error === "auth"87 ? new EnvironmentAuthorizationError({
88 ? new EnvironmentAuthorizationError({88 message: "denied",
89 message: "denied",89 requiredScope: "orchestration:read",
90 requiredScope: "orchestration:read",90 })
91 })91 : scenario.error === "inspection"
92 : scenario.error === "inspection"92 ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") })
93 ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") })93 : scenario.error === "context"
94 : scenario.error === "context"94 ? new AssetWorkspaceContextNotFoundError({ resource })
95 ? new AssetWorkspaceContextNotFoundError({ resource })95 : new AssetWorkspaceAssetNotFoundError({ resource });
96 : new AssetWorkspaceAssetNotFoundError({ resource });96 const calls: EnvironmentId[] = [];
97 const calls: EnvironmentId[] = [];97 const supervisors = new Map<
98 const supervisors = new Map<98 EnvironmentId,
99 EnvironmentId,99 EnvironmentSupervisor.EnvironmentSupervisor["Service"]
100 EnvironmentSupervisor.EnvironmentSupervisor["Service"]100 >();
101 >();101 for (const environmentId of [remoteId, localId]) {
102 for (const environmentId of [remoteId, localId]) {102 const client = {
103 const client = {103 [WS_METHODS.assetsCreateUrl]: () => {
104 [WS_METHODS.assetsCreateUrl]: () => {104 calls.push(environmentId);
105 calls.push(environmentId);105 return environmentId === remoteId && !scenario.success
106 return environmentId === remoteId && !scenario.success106 ? Effect.fail(error)
107 ? Effect.fail(error)107 : Effect.succeed({
108 : Effect.succeed({108 relativeUrl: `/api/assets/${environmentId}/media`,
109 relativeUrl: `/api/assets/${environmentId}/media`,109 expiresAt: 999999,
110 expiresAt: 999999,110 });
111 });111 },
112 },112 } as unknown as WsRpcProtocolClient;
113 } as unknown as WsRpcProtocolClient;113 const session = { client } as RpcSession;
114 const session = { client } as RpcSession;114 supervisors.set(
115 supervisors.set(115 environmentId,
116 environmentId,116 EnvironmentSupervisor.EnvironmentSupervisor.of({
117 EnvironmentSupervisor.EnvironmentSupervisor.of({117 target: new PrimaryConnectionTarget({
118 target: new PrimaryConnectionTarget({118 environmentId,
119 environmentId,119 label: environmentId,
120 label: environmentId,120 httpBaseUrl: `https://${environmentId}.test`,
121 httpBaseUrl: `https://${environmentId}.test`,121 wsBaseUrl: `wss://${environmentId}.test`,
122 wsBaseUrl: `wss://${environmentId}.test`,122 }),
123 }),123 state: yield* SubscriptionRef.make<SupervisorConnectionState>({
124 state: yield* SubscriptionRef.make<SupervisorConnectionState>({124 ...AVAILABLE_CONNECTION_STATE,
125 ...AVAILABLE_CONNECTION_STATE,125 phase: "connected" as const,
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,
133 }),126 }),
134 );127 session: yield* SubscriptionRef.make(Option.some(session)),
135 }128 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
136 const environments = EnvironmentRegistry.EnvironmentRegistry.of({129 connect: Effect.void,
137 run: (id, effect) =>130 disconnect: Effect.void,
138 Effect.provideService(131 retryNow: Effect.void,
139 effect,132 }),
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,
158 );133 );
159 const assets = createAssetEnvironmentAtoms(134 }
160 Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)),135 const environments = EnvironmentRegistry.EnvironmentRegistry.of({
161 localEnvironment,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 } });170 } else {
164 const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true });171 expect(yield* Effect.flip(result)).toEqual(error);
165 if (scenario.fallback || scenario.success) {172 }
166 expect((yield* result).relativeUrl).toBe(173 expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]);
167 scenario.fallback174 if (scenario.primary === "reconnecting") {
168 ? "https://local.test/api/assets/local/media"175 registry.set(localEnvironment, localTarget);
169 : "/api/assets/remote/media",176 expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media");
170 );177 expect(calls).toEqual([remoteId, remoteId, localId]);
171 } else {178 }
172 expect(yield* Effect.flip(result)).toEqual(error);179 }).pipe(Effect.scoped),
173 }180 );
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 }
183181
184 it("keys asset URL queries by environment and resource", () => {182 it("keys asset URL queries by environment and resource", () => {
185 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<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,8 +236,9 @@ for (const scenario of [
236 );236 );
237}237}
238238
239for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as const) {239it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)(
240 it.effect(`routes ${provider} viewed marks to their storage environment`, () =>240 "routes %s viewed marks to their storage environment",
241 (provider) =>
241 Effect.scoped(242 Effect.scoped(
242 Effect.gen(function* () {243 Effect.gen(function* () {
243 const reference = {244 const reference = {
...@@ -311,8 +312,7 @@ for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as cons...@@ -311,8 +312,7 @@ for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as cons
311 }312 }
312 }),313 }),
313 ),314 ),
314 );315);
315}
316316
317const TARGET = new PrimaryConnectionTarget({317const TARGET = new PrimaryConnectionTarget({
318 environmentId: EnvironmentId.make("environment-1"),318 environmentId: EnvironmentId.make("environment-1"),
...@@ -420,8 +420,9 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (...@@ -420,8 +420,9 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (
420 return { runtime, atoms, registry, environmentRegistry, supervisor };420 return { runtime, atoms, registry, environmentRegistry, supervisor };
421});421});
422422
423for (const permission of ["default", "origin-off", "destination-off", "read-only"] as const) {423it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)(
424 it.effect(`does not probe another environment with ${permission} routing permission`, () =>424 "does not probe another environment with %s routing permission",
425 (permission) =>
425 Effect.scoped(426 Effect.scoped(
426 Effect.gen(function* () {427 Effect.gen(function* () {
427 const calls: string[] = [];428 const calls: string[] = [];
...@@ -480,125 +481,128 @@ for (const permission of ["default", "origin-off", "destination-off", "read-only...@@ -480,125 +481,128 @@ for (const permission of ["default", "origin-off", "destination-off", "read-only
480 );481 );
481 }),482 }),
482 ),483 ),
483 );484);
484}
485485
486for (const side of ["origin", "destination"] as const) {486it.effect.each(
487 for (const stored of ["matching", "changed", "missing", "unavailable", "failed"] as const) {487 (["origin", "destination"] as const).flatMap((side) =>
488 it.effect(`checks the current ${side} SSH profile before routing with ${stored} storage`, () =>488 (["matching", "changed", "missing", "unavailable", "failed"] as const).map((stored) => ({
489 Effect.scoped(489 side,
490 Effect.gen(function* () {490 stored,
491 const calls: string[] = [];491 })),
492 const identity = {492 ),
493 host: "github.com",493)("checks the current $side SSH profile before routing with $stored storage", ({ side, stored }) =>
494 provider: "github",494 Effect.scoped(
495 viewer: "maria",495 Effect.gen(function* () {
496 accountId: "123",496 const calls: string[] = [];
497 };497 const identity = {
498 const client = {498 host: "github.com",
499 [WS_METHODS.pullRequestsRouting]: () =>499 provider: "github",
500 Effect.sync(() => {500 viewer: "maria",
501 calls.push("source-probe");501 accountId: "123",
502 return identity;502 };
503 }),503 const client = {
504 [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) =>504 [WS_METHODS.pullRequestsRouting]: () =>
505 Effect.gen(function* () {505 Effect.sync(() => {
506 if (input.expectedAccountId !== undefined) {506 calls.push("source-probe");
507 return yield* new PullRequestOperationError({507 return identity;
508 operation: "routeIdentity",508 }),
509 detail: "Source guard refused.",509 [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) =>
510 });510 Effect.gen(function* () {
511 }511 if (input.expectedAccountId !== undefined) {
512 calls.push("source-write");512 return yield* new PullRequestOperationError({
513 }),513 operation: "routeIdentity",
514 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,514 detail: "Source guard refused.",
515 } as unknown as WsRpcProtocolClient;515 });
516 const alternate = {516 }
517 [WS_METHODS.pullRequestsRoutingIdentity]: () =>517 calls.push("source-write");
518 Effect.sync(() => {518 }),
519 calls.push("alternate-probe");519 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
520 return identity;520 } as unknown as WsRpcProtocolClient;
521 }),521 const alternate = {
522 [WS_METHODS.pullRequestsRunAction]: () =>522 [WS_METHODS.pullRequestsRoutingIdentity]: () =>
523 Effect.sync(() => {523 Effect.sync(() => {
524 calls.push("alternate-write");524 calls.push("alternate-probe");
525 }),525 return identity;
526 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,526 }),
527 } as unknown as WsRpcProtocolClient;527 [WS_METHODS.pullRequestsRunAction]: () =>
528 const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate);528 Effect.sync(() => {
529 const environmentId =529 calls.push("alternate-write");
530 side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment");530 }),
531 const profile = new SshConnectionProfile({531 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
532 connectionId: "ssh-1",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 environmentId,545 environmentId,
546 connectionId: profile.connectionId,
534 label: "SSH",547 label: "SSH",
535 target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 },548 }),
536 });549 profile: Option.some(profile),
537 yield* SubscriptionRef.update(environmentRegistry.entries, (entries) =>550 enabled: true,
538 new Map(entries).set(environmentId, {551 }),
539 target: new SshConnectionTarget({552 );
540 environmentId,553 const read = Effect.suspend(() =>
541 connectionId: profile.connectionId,554 stored === "failed"
542 label: "SSH",555 ? Effect.fail(
556 new ConnectionTransientError({
557 reason: "remote-unavailable",
558 detail: "Profile storage unavailable.",
543 }),559 }),
544 profile: Option.some(profile),560 )
545 enabled: true,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(() =>594 expect(calls).toEqual(
549 stored === "failed"595 stored === "matching"
550 ? Effect.fail(596 ? ["source-probe", "alternate-probe", "alternate-write"]
551 new ConnectionTransientError({597 : ["source-write"],
552 reason: "remote-unavailable",598 );
553 detail: "Profile storage unavailable.",599 }),
554 }),600 ),
555 )601);
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}
599602
600for (const probe of ["origin", "alternate"] as const) {603it.live.each(["origin", "alternate"] as const)(
601 it.live(`bounds a stalled ${probe} metadata probe without repeating a strict source read`, () =>604 "bounds a stalled %s metadata probe without repeating a strict source read",
605 (probe) =>
602 Effect.scoped(606 Effect.scoped(
603 Effect.gen(function* () {607 Effect.gen(function* () {
604 let sourceReads = 0;608 let sourceReads = 0;
...@@ -638,81 +642,81 @@ for (const probe of ["origin", "alternate"] as const) {...@@ -638,81 +642,81 @@ for (const probe of ["origin", "alternate"] as const) {
638 expect(sourceReads).toBe(1);642 expect(sourceReads).toBe(1);
639 }),643 }),
640 ),644 ),
641 );645);
642}
643646
644for (const source of ["pending", "pending-local", "failed-local", "failed", "offline"] as const) {647it.effect.each(
645 it.effect(648 (["pending", "pending-local", "failed-local", "failed", "offline"] as const).map(
646 source === "offline"649 (source) =>
647 ? "returns held source data only after both fresh paths fail"650 [
648 : `uses one shared reader with a ${source} source`,651 source === "offline"
649 () =>652 ? "returns held source data only after both fresh paths fail"
650 Effect.scoped(653 : `uses one shared reader with a ${source} source`,
651 Effect.gen(function* () {654 source,
652 const started = yield* Deferred.make<void>();655 ] as const,
653 const calls: string[] = [];656 ),
654 const clientFor = (local: boolean) =>657)("%s", ([, source]) =>
655 ({658 Effect.scoped(
656 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]:659 Effect.gen(function* () {
657 () =>660 const started = yield* Deferred.make<void>();
658 Effect.succeed({661 const calls: string[] = [];
659 host: "github.com",662 const clientFor = (local: boolean) =>
660 provider: "github",663 ({
661 viewer: "maria-rcks",664 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () =>
662 accountId: "123",665 Effect.succeed({
663 }),666 host: "github.com",
664 [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) =>667 provider: "github",
665 Effect.gen(function* () {668 viewer: "maria-rcks",
666 calls.push(local ? "local" : input.allowStale === false ? "origin" : "held");669 accountId: "123",
667 if (source === "offline" && !local && input.allowStale === undefined) {670 }),
668 return { state: "open" };671 [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) =>
669 }672 Effect.gen(function* () {
670 expect(input.allowStale).toBe(false);673 calls.push(local ? "local" : input.allowStale === false ? "origin" : "held");
671 if (local && source !== "offline") return null;674 if (source === "offline" && !local && input.allowStale === undefined) {
672 if (source !== "pending" && source !== "pending-local")675 return { state: "open" };
673 return yield* new PullRequestOperationError({676 }
674 operation: "summary",677 expect(input.allowStale).toBe(false);
675 detail: "github unreachable",678 if (local && source !== "offline") return null;
676 });679 if (source !== "pending" && source !== "pending-local")
677 yield* Deferred.succeed(started, undefined);680 return yield* new PullRequestOperationError({
678 return yield* Effect.never;681 operation: "summary",
679 }),682 detail: "github unreachable",
680 }) as unknown as WsRpcProtocolClient;683 });
681 const { environmentRegistry, supervisor } = yield* makeTestRuntime(684 yield* Deferred.succeed(started, undefined);
682 clientFor(false),685 return yield* Effect.never;
683 clientFor(true),686 }),
684 source === "failed-local" || source === "pending-local",687 }) as unknown as WsRpcProtocolClient;
685 );688 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
686 const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {689 clientFor(false),
687 projectId: ProjectId.make("project-1"),690 clientFor(true),
688 repository: "acme/web",691 source === "failed-local" || source === "pending-local",
689 number: 7,692 );
690 }).pipe(693 const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {
691 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),694 projectId: ProjectId.make("project-1"),
692 Effect.provideService(GitHubRoutingPermissions, trustedRouting),695 repository: "acme/web",
693 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),696 number: 7,
694 );697 }).pipe(
695 const fiber = yield* request.pipe(Effect.forkChild);698 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
696 if (source === "pending-local") {699 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
697 yield* Deferred.await(started);700 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
698 yield* TestClock.adjust("30 seconds");701 );
699 }702 const fiber = yield* request.pipe(Effect.forkChild);
700 const result = yield* Fiber.join(fiber);703 if (source === "pending-local") {
701 if (source === "offline") {704 yield* Deferred.await(started);
702 expect(result).toEqual({ state: "open" });705 yield* TestClock.adjust("30 seconds");
703 expect(calls).toEqual(["local", "origin", "held"]);706 }
704 } else {707 const result = yield* Fiber.join(fiber);
705 expect(result).toBeNull();708 if (source === "offline") {
706 expect(calls).toEqual(709 expect(result).toEqual({ state: "open" });
707 source === "failed-local" || source === "pending-local"710 expect(calls).toEqual(["local", "origin", "held"]);
708 ? ["origin", "local"]711 } else {
709 : ["local"],712 expect(result).toBeNull();
710 );713 expect(calls).toEqual(
711 }714 source === "failed-local" || source === "pending-local" ? ["origin", "local"] : ["local"],
712 }),715 );
713 ),716 }
714 );717 }),
715}718 ),
719);
716720
717it.live("keeps source workspace metadata when an alternate answers a detail read", () =>721it.live("keeps source workspace metadata when an alternate answers a detail read", () =>
718 Effect.scoped(722 Effect.scoped(
...@@ -1191,8 +1195,9 @@ it.effect("refreshes checks without refreshing full detail", () =>...@@ -1191,8 +1195,9 @@ it.effect("refreshes checks without refreshing full detail", () =>
1191 ),1195 ),
1192);1196);
11931197
1194for (const oldAlternate of [false, true]) {1198it.effect.each([false, true])(
1195 it.effect(`routes checks through one reader with old alternate: ${oldAlternate}`, () =>1199 "routes checks through one reader with old alternate: %s",
1200 (oldAlternate) =>
1196 Effect.scoped(1201 Effect.scoped(
1197 Effect.gen(function* () {1202 Effect.gen(function* () {
1198 const calls: string[] = [];1203 const calls: string[] = [];
...@@ -1236,8 +1241,7 @@ for (const oldAlternate of [false, true]) {...@@ -1236,8 +1241,7 @@ for (const oldAlternate of [false, true]) {
1236 expect(calls).toEqual(oldAlternate ? ["origin"] : ["local"]);1241 expect(calls).toEqual(oldAlternate ? ["origin"] : ["local"]);
1237 }),1242 }),
1238 ),1243 ),
1239 );1244);
1240}
12411245
1242it.effect("keeps live detail reads separate from reads that allow stale data", () =>1246it.effect("keeps live detail reads separate from reads that allow stale data", () =>
1243 Effect.scoped(1247 Effect.scoped(
packages/client-runtime/src/state/threadCommands.test.ts+47-45
...@@ -137,8 +137,9 @@ describe("remote thread lifecycle commands", () => {...@@ -137,8 +137,9 @@ describe("remote thread lifecycle commands", () => {
137 ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }],137 ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }],
138 ] as const;138 ] as const;
139139
140 for (const [action, input, expected] of actions) {140 it.effect.each(actions)(
141 it.effect(`shows ${action} before a delayed remote reply and rolls back a rejection`, () =>141 "shows %s before a delayed remote reply and rolls back a rejection",
142 ([action, input, expected]) =>
142 Effect.gen(function* () {143 Effect.gen(function* () {
143 const h = yield* makeHarness();144 const h = yield* makeHarness();
144 const source = h.snapshotAtom(ENVIRONMENT_ID);145 const source = h.snapshotAtom(ENVIRONMENT_ID);
...@@ -179,8 +180,7 @@ describe("remote thread lifecycle commands", () => {...@@ -179,8 +180,7 @@ describe("remote thread lifecycle commands", () => {
179 expect((yield* Effect.promise(() => result))._tag).toBe("Failure");180 expect((yield* Effect.promise(() => result))._tag).toBe("Failure");
180 expect(h.registry.get(h.visibleAtom)).toBe(initial);181 expect(h.registry.get(h.visibleAtom)).toBe(initial);
181 }),182 }),
182 );183 );
183 }
184184
185 it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () =>185 it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () =>
186 Effect.gen(function* () {186 Effect.gen(function* () {
...@@ -290,44 +290,45 @@ describe("remote thread lifecycle commands", () => {...@@ -290,44 +290,45 @@ describe("remote thread lifecycle commands", () => {
290 }),290 }),
291 );291 );
292292
293 for (const action of ["settle", "snooze"] as const) {293 const undoableActions = ["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 );
329294
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 Effect.gen(function* () {332 Effect.gen(function* () {
332 const h = yield* makeHarness();333 const h = yield* makeHarness();
333 const result = h.commands[action].run(h.registry, {334 const result = h.commands[action].run(h.registry, {
...@@ -346,9 +347,11 @@ describe("remote thread lifecycle commands", () => {...@@ -346,9 +347,11 @@ describe("remote thread lifecycle commands", () => {
346 expect((yield* Effect.promise(() => result))._tag).toBe("Success");347 expect((yield* Effect.promise(() => result))._tag).toBe("Success");
347 expect(h.registry.get(h.visibleAtom)).toBe(newer);348 expect(h.registry.get(h.visibleAtom)).toBe(newer);
348 }),349 }),
349 );350 );
350351
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 Effect.gen(function* () {355 Effect.gen(function* () {
353 const h = yield* makeHarness();356 const h = yield* makeHarness();
354 const stale = {357 const stale = {
...@@ -370,6 +373,5 @@ describe("remote thread lifecycle commands", () => {...@@ -370,6 +373,5 @@ describe("remote thread lifecycle commands", () => {
370 expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull();373 expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull();
371 expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale);374 expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale);
372 }),375 }),
373 );376 );
374 }
375});377});
packages/client-runtime/src/state/threads-sync.test.ts+12-12
...@@ -315,8 +315,9 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => {...@@ -315,8 +315,9 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => {
315};315};
316316
317describe("EnvironmentThreads", () => {317describe("EnvironmentThreads", () => {
318 for (const source of ["disk", "HTTP"] as const) {318 it.effect.each(["disk", "HTTP"] as const)(
319 it.effect(`does not rewrite an unchanged ${source} snapshot on navigation or warm return`, () =>319 "does not rewrite an unchanged %s snapshot on navigation or warm return",
320 (source) =>
320 Effect.gen(function* () {321 Effect.gen(function* () {
321 const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {322 const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
322 snapshot: undefined,323 snapshot: undefined,
...@@ -350,8 +351,7 @@ describe("EnvironmentThreads", () => {...@@ -350,8 +351,7 @@ describe("EnvironmentThreads", () => {
350 );351 );
351 expect(yield* Ref.get(nextSaved)).toEqual([]);352 expect(yield* Ref.get(nextSaved)).toEqual([]);
352 }),353 }),
353 );354 );
354 }
355355
356 it.effect("persists a complete bounded HTTP window only once", () =>356 it.effect("persists a complete bounded HTTP window only once", () =>
357 Effect.gen(function* () {357 Effect.gen(function* () {
...@@ -1012,8 +1012,9 @@ describe("EnvironmentThreads", () => {...@@ -1012,8 +1012,9 @@ describe("EnvironmentThreads", () => {
1012 }),1012 }),
1013 );1013 );
10141014
1015 for (const cacheKind of ["disk", "retained"] as const) {1015 it.effect.each(["disk", "retained"] as const)(
1016 it.effect(`retains paging support through a complete bounded ${cacheKind} cache`, () =>1016 "retains paging support through a complete bounded %s cache",
1017 (cacheKind) =>
1017 Effect.gen(function* () {1018 Effect.gen(function* () {
1018 const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {1019 const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
1019 snapshot: undefined,1020 snapshot: undefined,
...@@ -1071,11 +1072,11 @@ describe("EnvironmentThreads", () => {...@@ -1071,11 +1072,11 @@ describe("EnvironmentThreads", () => {
1071 expect(yield* Ref.get(warm.lastSubscribeAfterSequence)).toBe(5);1072 expect(yield* Ref.get(warm.lastSubscribeAfterSequence)).toBe(5);
1072 expect(yield* Ref.get(warm.lastAcceptBoundedSnapshot)).toBe(true);1073 expect(yield* Ref.get(warm.lastAcceptBoundedSnapshot)).toBe(true);
1073 }),1074 }),
1074 );1075 );
1075 }
10761076
1077 for (const historyPaging of ["no-http", "no-controller"] as const) {1077 it.effect.each(["no-http", "no-controller"] as const)(
1078 it.effect(`does not negotiate bounded fallbacks with ${historyPaging}`, () =>1078 "does not negotiate bounded fallbacks with %s",
1079 (historyPaging) =>
1079 Effect.gen(function* () {1080 Effect.gen(function* () {
1080 for (const source of ["cache", "http"] as const) {1081 for (const source of ["cache", "http"] as const) {
1081 const history = {1082 const history = {
...@@ -1105,8 +1106,7 @@ describe("EnvironmentThreads", () => {...@@ -1105,8 +1106,7 @@ describe("EnvironmentThreads", () => {
1105 expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBeUndefined();1106 expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBeUndefined();
1106 }1107 }
1107 }),1108 }),
1108 );1109 );
1109 }
11101110
1111 it.effect("socket snapshot clears progressive history meta left from a bounded window", () =>1111 it.effect("socket snapshot clears progressive history meta left from a bounded window", () =>
1112 Effect.gen(function* () {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,39 +21,37 @@ describe("provider slug validation (shared by driver + instance ids)", () => {
21 { schemaName: "ProviderDriverKind", decode: decodeProviderDriverKind },21 { schemaName: "ProviderDriverKind", decode: decodeProviderDriverKind },
22 ] as const;22 ] as const;
2323
24 for (const { schemaName, decode } of cases) {24 describe.each(cases)("$schemaName", ({ decode }) => {
25 describe(schemaName, () => {25 it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])(
26 it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])(26 "accepts %s",
27 "accepts %s",27 (id) => {
28 (id) => {28 expect(decode(id)).toBe(id);
29 expect(decode(id)).toBe(id);29 },
30 },30 );
31 );31
3232 it.each([
33 it.each([33 ["empty string", ""],
34 ["empty string", ""],34 ["leading digit", "1codex"],
35 ["leading digit", "1codex"],35 ["leading dash", "-codex"],
36 ["leading dash", "-codex"],36 ["leading underscore", "_codex"],
37 ["leading underscore", "_codex"],37 ["whitespace inside", "codex personal"],
38 ["whitespace inside", "codex personal"],38 ["dot inside", "codex.personal"],
39 ["dot inside", "codex.personal"],39 ["slash inside", "codex/personal"],
40 ["slash inside", "codex/personal"],40 ])("rejects %s", (_label, value) => {
41 ])("rejects %s", (_label, value) => {41 expect(() => decode(value)).toThrow();
42 expect(() => decode(value)).toThrow();42 });
43 });43
4444 it("trims surrounding whitespace before validating", () => {
45 it("trims surrounding whitespace before validating", () => {45 expect(decode(" codex_work ")).toBe("codex_work");
46 expect(decode(" codex_work ")).toBe("codex_work");46 });
47 });47
4848 it("rejects ids longer than 64 characters", () => {
49 it("rejects ids longer than 64 characters", () => {49 const tooLong = "a".repeat(65);
50 const tooLong = "a".repeat(65);50 expect(() => decode(tooLong)).toThrow();
51 expect(() => decode(tooLong)).toThrow();51 const justRight = "a".repeat(64);
52 const justRight = "a".repeat(64);52 expect(decode(justRight)).toBe(justRight);
53 expect(decode(justRight)).toBe(justRight);
54 });
55 });53 });
56 }54 });
57});55});
5856
59describe("ProviderInstanceRef", () => {57describe("ProviderInstanceRef", () => {
packages/effect-acp/src/protocol.test.ts+4-4
...@@ -1088,8 +1088,9 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -1088,8 +1088,9 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
1088 }),1088 }),
1089 );1089 );
10901090
1091 for (const operation of ["request", "notification"] as const) {1091 it.effect.each(["request", "notification"] as const)(
1092 it.effect(`rejects a ${operation} if the connection ends while its logger is running`, () =>1092 "rejects a %s if the connection ends while its logger is running",
1093 (operation) =>
1093 Effect.gen(function* () {1094 Effect.gen(function* () {
1094 const { stdio, input, output } = yield* makeInMemoryStdio();1095 const { stdio, input, output } = yield* makeInMemoryStdio();
1095 const writeStarted = yield* Deferred.make<void>();1096 const writeStarted = yield* Deferred.make<void>();
...@@ -1127,6 +1128,5 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -1127,6 +1128,5 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
1127 assert.strictEqual(failure, error);1128 assert.strictEqual(failure, error);
1128 assert.equal(yield* Queue.size(output), 0);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,15 +46,13 @@ describe("a name written into a header and read back out", () => {
46 `every\t\n\r"\\${BELL}.txt`,46 `every\t\n\r"\\${BELL}.txt`,
47 ];47 ];
4848
49 for (const name of names) {49 it.each(names)("is the name that went in: %j", (name) => {
50 it(`is the name that went in: ${JSON.stringify(name)}`, () => {50 const written = quoteGitPatchPath(name);
51 const written = quoteGitPatchPath(name);51 expect(unquoteGitPatchPath(written)).toBe(name);
52 expect(unquoteGitPatchPath(written)).toBe(name);52 // A parser that takes the quotes off itself, as the clients' one does, gets there too.
53 // 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 const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written;54 expect(unquoteGitPatchPath(unwrapped)).toBe(name);
55 expect(unquoteGitPatchPath(unwrapped)).toBe(name);55 });
56 });
57 }
5856
59 it("carries the whole name past the first thing a header stops at", () => {57 it("carries the whole name past the first thing a header stops at", () => {
60 const written = quoteGitPatchPath("tab\tfile.txt");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,8 +1269,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1269 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),1269 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
1270 );1270 );
12711271
1272 for (const targetArch of ["x64", "arm64"] as const) {1272 it.effect.each(["x64", "arm64"] as const)(
1273 it.effect(`accepts an embedded archive with the Linux ${targetArch} node-pty prebuild`, () =>1273 "accepts an embedded archive with the Linux %s node-pty prebuild",
1274 (targetArch) =>
1274 Effect.scoped(1275 Effect.scoped(
1275 Effect.gen(function* () {1276 Effect.gen(function* () {
1276 const fixture = yield* makeWindowsPayloadFixture({1277 const fixture = yield* makeWindowsPayloadFixture({
...@@ -1290,33 +1291,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1290,33 +1291,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1290 assert.equal(result.packagedAppDir, fixture.packagedAppDir);1291 assert.equal(result.packagedAppDir, fixture.packagedAppDir);
1291 }),1292 }),
1292 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),1293 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
1293 );1294 );
12941295
1295 it.effect(1296 it.effect.each(["x64", "arm64"] as const)(
1296 `rejects a node-pty prebuild for the wrong architecture in a Linux ${targetArch} archive`,1297 "rejects a node-pty prebuild for the wrong architecture in a Linux %s archive",
1297 () =>1298 (targetArch) =>
1298 Effect.scoped(1299 Effect.scoped(
1299 Effect.gen(function* () {1300 Effect.gen(function* () {
1300 const fixture = yield* makeWindowsPayloadFixture({1301 const fixture = yield* makeWindowsPayloadFixture({
1301 copyUnpackedNatives: true,1302 copyUnpackedNatives: true,
1302 wslRuntime: "valid",1303 wslRuntime: "valid",
1303 targetArch,1304 targetArch,
1304 ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64",1305 ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64",
1305 });1306 });
1306 const error = yield* validateWindowsPackagedPayload({1307 const error = yield* validateWindowsPackagedPayload({
1307 stageDistDir: fixture.stageDistDir,1308 stageDistDir: fixture.stageDistDir,
1308 appExecutableName: fixture.appExecutableName,1309 appExecutableName: fixture.appExecutableName,
1309 targetArch,1310 targetArch,
1310 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,1311 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1311 expectWslRuntime: true,1312 expectWslRuntime: true,
1312 }).pipe(Effect.flip);1313 }).pipe(Effect.flip);
13131314
1314 assert.instanceOf(error, WindowsPackagedPayloadValidationError);1315 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
1315 assert.equal(error.reason, "wsl-runtime-invalid");1316 assert.equal(error.reason, "wsl-runtime-invalid");
1316 }),1317 }),
1317 ),1318 ),
1318 );1319 );
1319 }
13201320
1321 it.effect("rejects an embedded archive built for a different release version", () =>1321 it.effect("rejects an embedded archive built for a different release version", () =>
1322 Effect.scoped(1322 Effect.scoped(
scripts/dev-runner.test.ts+22-24
...@@ -414,8 +414,9 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -414,8 +414,9 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
414 // Browser dev is single-origin: Vite proxies the backend, and the client414 // Browser dev is single-origin: Vite proxies the backend, and the client
415 // resolves it from window.location.origin. Baking a localhost URL here is415 // resolves it from window.location.origin. Baking a localhost URL here is
416 // what breaks sharing a dev server to another device.416 // what breaks sharing a dev server to another device.
417 for (const mode of ["dev", "dev:web"] as const) {417 it.effect.each(["dev", "dev:web"] as const)(
418 it.effect(`leaves the client backend URLs unset in ${mode} mode`, () =>418 "leaves the client backend URLs unset in %s mode",
419 (mode) =>
419 Effect.gen(function* () {420 Effect.gen(function* () {
420 const env = yield* createDevRunnerEnv({421 const env = yield* createDevRunnerEnv({
421 mode,422 mode,
...@@ -442,8 +443,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -442,8 +443,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
442 // the intent has to be stated positively.443 // the intent has to be stated positively.
443 assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1");444 assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1");
444 }),445 }),
445 );446 );
446 }
447447
448 // Desktop pins the renderer at loopback deliberately; an ambient marker448 // Desktop pins the renderer at loopback deliberately; an ambient marker
449 // must not make Vite discard those URLs.449 // must not make Vite discard those URLs.
...@@ -492,27 +492,25 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -492,27 +492,25 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
492 // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An492 // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An
493 // inherited one would survive into browser dev and point HMR at the wrong493 // inherited one would survive into browser dev and point HMR at the wrong
494 // interface — invisible over a shared origin, since the page still loads.494 // interface — invisible over a shared origin, since the page still loads.
495 for (const mode of ["dev", "dev:web"] as const) {495 it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) =>
496 it.effect(`drops an inherited HOST in ${mode} mode`, () =>496 Effect.gen(function* () {
497 Effect.gen(function* () {497 const env = yield* createDevRunnerEnv({
498 const env = yield* createDevRunnerEnv({498 mode,
499 mode,499 baseEnv: { HOST: "0.0.0.0" },
500 baseEnv: { HOST: "0.0.0.0" },500 serverOffset: 0,
501 serverOffset: 0,501 webOffset: 0,
502 webOffset: 0,502 t3Home: undefined,
503 t3Home: undefined,503 browser: undefined,
504 browser: undefined,504 autoBootstrapProjectFromCwd: undefined,
505 autoBootstrapProjectFromCwd: undefined,505 logWebSocketEvents: undefined,
506 logWebSocketEvents: undefined,506 host: undefined,
507 host: undefined,507 port: undefined,
508 port: undefined,508 devUrl: undefined,
509 devUrl: undefined,509 });
510 });
511510
512 assert.equal(env.HOST, undefined);511 assert.equal(env.HOST, undefined);
513 }),512 }),
514 );513 );
515 }
516514
517 // --host configures the *backend* (T3CODE_HOST). It must not become Vite's515 // --host configures the *backend* (T3CODE_HOST). It must not become Vite's
518 // bind address by way of an inherited HOST that happens to agree with it.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,8 +78,9 @@ function createList(bundle: string) {
78 };78 };
79}79}
8080
81for (const bundle of ["react-native.js", "react-native.mjs"]) {81describe.each(["react-native.js", "react-native.mjs"])(
82 describe(`initial inset end reveal (${bundle})`, () => {82 "initial inset end reveal (%s)",
83 (bundle) => {
83 it("waits for the native offset instead of the optimistic scroll target", () => {84 it("waits for the native offset instead of the optimistic scroll target", () => {
84 const list = createList(bundle);85 const list = createList(bundle);
85 list.start();86 list.start();
...@@ -210,5 +211,5 @@ for (const bundle of ["react-native.js", "react-native.mjs"]) {...@@ -210,5 +211,5 @@ for (const bundle of ["react-native.js", "react-native.mjs"]) {
210 empty.complete();211 empty.complete();
211 expect(empty.ready()).toBe(true);212 expect(empty.ready()).toBe(true);
212 });213 });
213 });214 },
214}215);
vite.config.ts+1
...@@ -167,6 +167,7 @@ export default defineConfig({...@@ -167,6 +167,7 @@ export default defineConfig({
167 "t3code/no-inline-schema-compile": "warn",167 "t3code/no-inline-schema-compile": "warn",
168 "t3code/no-manual-effect-runtime-in-tests": "error",168 "t3code/no-manual-effect-runtime-in-tests": "error",
169 "t3code/no-native-title-tooltip": "error",169 "t3code/no-native-title-tooltip": "error",
170 "t3code/no-test-in-loop": "error",
170 "t3code/namespace-node-imports": "error",171 "t3code/namespace-node-imports": "error",
171 },172 },
172 overrides: [173 overrides: [