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", () => {
7878 expect(result.stderr.length).toBe(0);
7979 });
8080
81 for (const [scenario, code] of [
81 it.each([
8282 ["missing", 2],
8383 ["empty", 2],
8484 ["locked", 3],
......@@ -86,13 +86,11 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => {
8686 ["denied", 3],
8787 ["unavailable", 4],
8888 ["unloaded", 4],
89 ]) {
90 it(`reports ${scenario} without emitting a secret`, () => {
91 const result = run([scenario]);
92 expect(result.status).toBe(code);
93 expect(result.stdout.length).toBe(0);
94 });
95 }
89 ])("reports %s without emitting a secret", (scenario, code) => {
90 const result = run([scenario]);
91 expect(result.status).toBe(code);
92 expect(result.stdout.length).toBe(0);
93 });
9694 it("rejects invalid arguments before accessing the keyring", () => {
9795 for (const args of [[], [""], ["chrome", "extra"]]) {
9896 const result = run(args);
apps/desktop/src/app/DesktopClerk.test.ts+4-4
......@@ -303,8 +303,9 @@ it.effect(
303303 },
304304);
305305
306for (const entry of ["startup", "open-url"] as const) {
307 it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () =>
306it.effect.each(["startup", "open-url"] as const)(
307 "receives hosted web sign-in through the desktop %s handler",
308 (entry) =>
308309 Effect.gen(function* () {
309310 storageMock.mockReturnValue(storageAdapter);
310311 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
......@@ -382,5 +383,4 @@ for (const entry of ["startup", "open-url"] as const) {
382383 ),
383384 );
384385 }).pipe(Effect.scoped),
385 );
386}
386);
apps/desktop/src/app/DesktopLifecycle.test.ts+5-4
......@@ -101,8 +101,9 @@ function makeDesktopWindowLayer(
101101}
102102
103103describe("DesktopLifecycle", () => {
104 for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
105 it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
104 it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>)(
105 "lets the updater's quit event proceed on %s",
106 (platform) => {
106107 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
107108 let windowsDestroyed = false;
108109 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
......@@ -152,8 +153,8 @@ describe("DesktopLifecycle", () => {
152153 assert.isTrue(yield* Ref.get(state.quitting));
153154 }),
154155 ).pipe(Effect.provide(layer));
155 });
156 }
156 },
157 );
157158
158159 it.effect("destroys windows before waiting for backend shutdown", () =>
159160 Effect.gen(function* () {
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+45-47
......@@ -81,54 +81,52 @@ describe("DesktopPreReadyPlatform", () => {
8181 );
8282 });
8383
84 for (const previousEntry of [undefined, 'Exec="/Applications/deleted-previous.AppImage" %U']) {
85 it.effect(
86 `prepares a ${previousEntry ? "stale" : "missing"} Linux desktop entry before startup yields`,
87 () => {
88 vi.stubEnv("VITE_DEV_SERVER_URL", "");
89 vi.stubEnv("XDG_DATA_HOME", "/xdg");
90 vi.stubEnv("APPIMAGE", "/Applications/current.AppImage");
91 getSwitchValueMock.mockReturnValue("");
92 let desktopName = "t3code.desktop";
93 let desktopEntry = previousEntry;
94 let iconInstalled = false;
95 copyFileSyncMock.mockImplementation((_source: string, destination: string) => {
96 iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png";
97 });
98 setDesktopNameMock.mockImplementation((name: string) => {
99 desktopName = name;
100 });
101 writeFileSyncMock.mockImplementation((path: string, contents: string) => {
102 if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents;
103 });
84 it.effect.each([
85 { previousEntry: undefined, label: "missing" },
86 { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" },
87 ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => {
88 vi.stubEnv("VITE_DEV_SERVER_URL", "");
89 vi.stubEnv("XDG_DATA_HOME", "/xdg");
90 vi.stubEnv("APPIMAGE", "/Applications/current.AppImage");
91 getSwitchValueMock.mockReturnValue("");
92 let desktopName = "t3code.desktop";
93 let desktopEntry = previousEntry;
94 let iconInstalled = false;
95 copyFileSyncMock.mockImplementation((_source: string, destination: string) => {
96 iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png";
97 });
98 setDesktopNameMock.mockImplementation((name: string) => {
99 desktopName = name;
100 });
101 writeFileSyncMock.mockImplementation((path: string, contents: string) => {
102 if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents;
103 });
104104
105 return Effect.scoped(
106 Effect.gen(function* () {
107 const portalIdentity = Promise.resolve().then(() => ({
108 desktopName,
109 desktopEntry,
110 iconInstalled,
111 }));
112 yield* Layer.build(
113 DesktopPreReadyPlatform.layer.pipe(
114 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
115 ),
116 );
117 const identity = yield* Effect.promise(() => portalIdentity);
118 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");
119 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');
120 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");
121 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");
122 assert.include(
123 identity.desktopEntry ?? "",
124 "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png",
125 );
126 assert.isTrue(identity.iconInstalled);
127 }),
128 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
129 },
130 );
131 }
105 return Effect.scoped(
106 Effect.gen(function* () {
107 const portalIdentity = Promise.resolve().then(() => ({
108 desktopName,
109 desktopEntry,
110 iconInstalled,
111 }));
112 yield* Layer.build(
113 DesktopPreReadyPlatform.layer.pipe(
114 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
115 ),
116 );
117 const identity = yield* Effect.promise(() => portalIdentity);
118 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");
119 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');
120 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");
121 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");
122 assert.include(
123 identity.desktopEntry ?? "",
124 "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png",
125 );
126 assert.isTrue(identity.iconInstalled);
127 }),
128 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
129 });
132130
133131 it.effect("keeps startup available when the early desktop entry cannot be written", () => {
134132 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", () => {
3737 );
3838});
3939
40for (const sourceName of ["t3code", "T3 Code (Alpha)"]) {
41 it.effect(
42 `preserves Windows credential keys from ${sourceName} without copying browser databases`,
43 () =>
44 Effect.gen(function* () {
45 const fs = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;
47 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" });
48 const source = path.join(directory, sourceName);
49 const destination = path.join(directory, "t3code-v2");
50 const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}';
51 yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true });
52 yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true });
53 yield* fs.writeFileString(path.join(source, "Local State"), state);
54 yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database");
55 yield* resolveUserDataPath({
56 appDataDirectory: directory,
57 isDevelopment: false,
58 platform: "win32",
59 });
60 assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state);
61 assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state);
62 assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB")));
63 yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state");
64 yield* resolveUserDataPath({
65 appDataDirectory: directory,
66 isDevelopment: false,
67 platform: "win32",
68 });
69 assert.equal(
70 yield* fs.readFileString(path.join(destination, "Local State")),
71 "existing V2 state",
72 );
73 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
74 );
75}
40it.effect.each(["t3code", "T3 Code (Alpha)"])(
41 "preserves Windows credential keys from %s without copying browser databases",
42 (sourceName) =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" });
47 const source = path.join(directory, sourceName);
48 const destination = path.join(directory, "t3code-v2");
49 const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}';
50 yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true });
51 yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true });
52 yield* fs.writeFileString(path.join(source, "Local State"), state);
53 yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database");
54 yield* resolveUserDataPath({
55 appDataDirectory: directory,
56 isDevelopment: false,
57 platform: "win32",
58 });
59 assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state);
60 assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state);
61 assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB")));
62 yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state");
63 yield* resolveUserDataPath({
64 appDataDirectory: directory,
65 isDevelopment: false,
66 platform: "win32",
67 });
68 assert.equal(
69 yield* fs.readFileString(path.join(destination, "Local State")),
70 "existing V2 state",
71 );
72 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
73);
apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts+36-38
......@@ -386,45 +386,43 @@ describe("parseFirefoxProfiles", () => {
386386 }),
387387 );
388388
389 for (const [platform, root] of [
390 ["Linux", "/home/user/.mozilla/firefox"],
391 ["macOS", "/Users/user/Library/Application Support/Firefox"],
392 ] as const) {
393 it.effect(`validates relative and absolute ${platform} profile paths`, () =>
394 Effect.gen(function* () {
395 const parsed = yield* parsePosixFirefoxProfiles(
396 [
397 "[Profile0]",
398 "Name=Relative",
399 "IsRelative=1",
400 "Path=Profiles/relative.default",
401 "[Profile1]",
402 "Name=Custom",
403 "IsRelative=0",
404 "Path=/mnt/custom/firefox-profile",
405 "[Profile2]",
406 "IsRelative=1",
407 "Path=../../escape",
408 "[Profile3]",
409 "IsRelative=1",
410 "Path=/absolute-marked-relative",
411 "[Profile4]",
412 "IsRelative=0",
413 "Path=relative-marked-absolute",
414 "[Profile5]",
415 "IsRelative=1",
416 "Path=Profiles/nul\u0000escape",
417 ].join("\n"),
418 root,
419 );
389 it.effect.each([
390 { platform: "Linux", root: "/home/user/.mozilla/firefox" },
391 { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" },
392 ])("validates relative and absolute $platform profile paths", ({ root }) =>
393 Effect.gen(function* () {
394 const parsed = yield* parsePosixFirefoxProfiles(
395 [
396 "[Profile0]",
397 "Name=Relative",
398 "IsRelative=1",
399 "Path=Profiles/relative.default",
400 "[Profile1]",
401 "Name=Custom",
402 "IsRelative=0",
403 "Path=/mnt/custom/firefox-profile",
404 "[Profile2]",
405 "IsRelative=1",
406 "Path=../../escape",
407 "[Profile3]",
408 "IsRelative=1",
409 "Path=/absolute-marked-relative",
410 "[Profile4]",
411 "IsRelative=0",
412 "Path=relative-marked-absolute",
413 "[Profile5]",
414 "IsRelative=1",
415 "Path=Profiles/nul\u0000escape",
416 ].join("\n"),
417 root,
418 );
420419
421 expect(parsed).toEqual([
422 { directory: "Profiles/relative.default", name: "Relative" },
423 { directory: "/mnt/custom/firefox-profile", name: "Custom" },
424 ]);
425 }),
426 );
427 }
420 expect(parsed).toEqual([
421 { directory: "Profiles/relative.default", name: "Relative" },
422 { directory: "/mnt/custom/firefox-profile", name: "Custom" },
423 ]);
424 }),
425 );
428426
429427 it.effect("uses Windows path rules for relative and absolute profiles", () =>
430428 Effect.gen(function* () {
apps/desktop/src/preview/BrowserImport/Sources.test.ts+8-8
......@@ -763,8 +763,9 @@ describe("listSourceProfiles Firefox fallback", () => {
763763 { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") },
764764 ];
765765
766 for (const { platform, profileDirectory } of cases) {
767 it.effect(`scans the ${platform} profile location and excludes stale entries`, () =>
766 it.effect.each(cases)(
767 "scans the $platform profile location and excludes stale entries",
768 ({ platform, profileDirectory }) =>
768769 run(
769770 Effect.gen(function* () {
770771 const fileSystem = yield* FileSystem.FileSystem;
......@@ -800,8 +801,7 @@ describe("listSourceProfiles Firefox fallback", () => {
800801 ]);
801802 }),
802803 ),
803 );
804 }
804 );
805805
806806 it.effect("scans for profiles when profiles.ini declares only ones without cookies", () =>
807807 run(
......@@ -1166,8 +1166,9 @@ describe("Safari profiles", () => {
11661166 ),
11671167 );
11681168
1169 for (const metadataState of ["missing", "corrupt"] as const) {
1170 it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () =>
1169 it.effect.each(["missing", "corrupt"] as const)(
1170 "recovers separate cookie stores when metadata is %s",
1171 (metadataState) =>
11711172 run(
11721173 Effect.gen(function* () {
11731174 const { context, store, metadata } = yield* fixture();
......@@ -1181,8 +1182,7 @@ describe("Safari profiles", () => {
11811182 assert.isTrue(yield* isSourceInstalled(safari, context));
11821183 }),
11831184 ),
1184 );
1185 }
1185 );
11861186
11871187 it.effect("keeps Safari without profiles available", () =>
11881188 run(
apps/desktop/src/settings/DesktopClientSettings.test.ts+67-71
......@@ -149,58 +149,56 @@ describe("DesktopClientSettings", () => {
149149 ),
150150 );
151151
152 for (const failure of [
152 it.effect.each([
153153 { label: "permission", reason: "PermissionDenied" },
154154 { label: "I/O", reason: "Unknown" },
155 ] as const) {
156 it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () =>
157 withClientSettings(
158 Effect.gen(function* () {
159 const environment = yield* DesktopEnvironment.DesktopEnvironment;
160 const fileSystem = yield* FileSystem.FileSystem;
161 const settings = yield* DesktopClientSettings.DesktopClientSettings;
162 const savedSettings = {
163 ...clientSettings,
164 onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
165 };
166 yield* settings.set(savedSettings);
167 const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
168 const cause = PlatformError.systemError({
169 _tag: failure.reason,
170 module: "FileSystem",
171 method: "readFileString",
172 pathOrDescriptor: environment.clientSettingsPath,
173 });
174 let failRead = true;
175 const retryableSettings = yield* DesktopClientSettings.make.pipe(
176 Effect.provideService(
177 FileSystem.FileSystem,
178 FileSystem.FileSystem.of({
179 ...fileSystem,
180 readFileString: (path) =>
181 Effect.suspend(() =>
182 failRead ? Effect.fail(cause) : fileSystem.readFileString(path),
183 ),
184 }),
185 ),
186 );
155 ] as const)("preserves saved preferences across $label read failures and retries", (failure) =>
156 withClientSettings(
157 Effect.gen(function* () {
158 const environment = yield* DesktopEnvironment.DesktopEnvironment;
159 const fileSystem = yield* FileSystem.FileSystem;
160 const settings = yield* DesktopClientSettings.DesktopClientSettings;
161 const savedSettings = {
162 ...clientSettings,
163 onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
164 };
165 yield* settings.set(savedSettings);
166 const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
167 const cause = PlatformError.systemError({
168 _tag: failure.reason,
169 module: "FileSystem",
170 method: "readFileString",
171 pathOrDescriptor: environment.clientSettingsPath,
172 });
173 let failRead = true;
174 const retryableSettings = yield* DesktopClientSettings.make.pipe(
175 Effect.provideService(
176 FileSystem.FileSystem,
177 FileSystem.FileSystem.of({
178 ...fileSystem,
179 readFileString: (path) =>
180 Effect.suspend(() =>
181 failRead ? Effect.fail(cause) : fileSystem.readFileString(path),
182 ),
183 }),
184 ),
185 );
187186
188 const error = yield* retryableSettings.get.pipe(Effect.flip);
189 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
190 assert.equal(error.operation, "read-file");
191 assert.equal(error.path, environment.clientSettingsPath);
192 assert.strictEqual(error.cause, cause);
193 assert.equal(
194 yield* fileSystem.readFileString(environment.clientSettingsPath),
195 savedContents,
196 );
187 const error = yield* retryableSettings.get.pipe(Effect.flip);
188 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
189 assert.equal(error.operation, "read-file");
190 assert.equal(error.path, environment.clientSettingsPath);
191 assert.strictEqual(error.cause, cause);
192 assert.equal(
193 yield* fileSystem.readFileString(environment.clientSettingsPath),
194 savedContents,
195 );
197196
198 failRead = false;
199 assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));
200 }),
201 ),
202 );
203 }
197 failRead = false;
198 assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));
199 }),
200 ),
201 );
204202
205203 it.effect("reports the failed client settings write operation and path", () =>
206204 withClientSettings(
......@@ -288,31 +286,29 @@ describe("DesktopClientSettings", () => {
288286 ),
289287 );
290288
291 for (const document of [
289 it.effect.each([
292290 { label: "malformed JSON", contents: "{not-json" },
293291 { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' },
294292 { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' },
295 ]) {
296 it.effect(`reports ${document.label} without treating the settings file as absent`, () =>
297 withClientSettings(
298 Effect.gen(function* () {
299 const environment = yield* DesktopEnvironment.DesktopEnvironment;
300 const fileSystem = yield* FileSystem.FileSystem;
301 const settings = yield* DesktopClientSettings.DesktopClientSettings;
302 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
303 yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents);
293 ])("reports $label without treating the settings file as absent", (document) =>
294 withClientSettings(
295 Effect.gen(function* () {
296 const environment = yield* DesktopEnvironment.DesktopEnvironment;
297 const fileSystem = yield* FileSystem.FileSystem;
298 const settings = yield* DesktopClientSettings.DesktopClientSettings;
299 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
300 yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents);
304301
305 const error = yield* settings.get.pipe(Effect.flip);
306 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
307 assert.equal(error.operation, "decode-document");
308 assert.equal(error.path, environment.clientSettingsPath);
309 assert.instanceOf(error.cause, Schema.SchemaError);
310 assert.equal(
311 yield* fileSystem.readFileString(environment.clientSettingsPath),
312 document.contents,
313 );
314 }),
315 ),
316 );
317 }
302 const error = yield* settings.get.pipe(Effect.flip);
303 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
304 assert.equal(error.operation, "decode-document");
305 assert.equal(error.path, environment.clientSettingsPath);
306 assert.instanceOf(error.cause, Schema.SchemaError);
307 assert.equal(
308 yield* fileSystem.readFileString(environment.clientSettingsPath),
309 document.contents,
310 );
311 }),
312 ),
313 );
318314});
apps/desktop/src/snapShot/DesktopSnapShot.test.ts+5-4
......@@ -3873,8 +3873,9 @@ it.effect("waits to apply settings while permissions are pending", () => {
38733873 ).pipe(Effect.provide(layer));
38743874});
38753875
3876for (const fails of [false, true]) {
3877 it.effect(`tests macOS capture without publishing it and cleans up, failure=${fails}`, () => {
3876it.effect.each([false, true])(
3877 "tests macOS capture without publishing it and cleans up, failure=%s",
3878 (fails) => {
38783879 const active = {
38793880 platform: "macos",
38803881 id: 42,
......@@ -3915,8 +3916,8 @@ for (const fails of [false, true]) {
39153916 }),
39163917 ),
39173918 );
3918 });
3919}
3919 },
3920);
39203921
39213922it.effect("rejects macOS test capture on other platforms", () =>
39223923 Effect.scoped(
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+48-50
......@@ -949,59 +949,57 @@ describe("makeRelayDeviceRegistrationRequest", () => {
949949 await new Promise((resolve) => setTimeout(resolve, 0));
950950 expect(widgetMocks.start).toHaveBeenCalledTimes(1);
951951 });
952 for (const os of ["ios", "android"] as const) {
953 it.effect(
954 `does not enable ${os} notifications when a token rotates after permission is revoked`,
955 () => {
956 vi.spyOn(Platform, "OS", "get").mockReturnValue(os);
957 vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);
958 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
959 type: os,
960 data: "initial",
952 it.effect.each(["ios", "android"] as const)(
953 "does not enable %s notifications when a token rotates after permission is revoked",
954 (os) => {
955 vi.spyOn(Platform, "OS", "get").mockReturnValue(os);
956 vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);
957 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
958 type: os,
959 data: "initial",
960 });
961 const registrations: unknown[] = [];
962 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
963 const request = new Request(input, init);
964 if (request.url.endsWith("/v1/client/dpop-token")) {
965 return Response.json({
966 access_token: "dpop",
967 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
968 token_type: "DPoP",
969 expires_in: 300,
970 scope: "mobile:registration",
971 });
972 }
973 registrations.push(await request.json());
974 return Response.json({ ok: true });
975 });
976 Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };
977 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
978 return Effect.gen(function* () {
979 yield* runBackgroundOperations();
980 expect(registrations.at(-1)).toMatchObject({
981 preferences: { notificationsEnabled: true },
961982 });
962 const registrations: unknown[] = [];
963 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
964 const request = new Request(input, init);
965 if (request.url.endsWith("/v1/client/dpop-token")) {
966 return Response.json({
967 access_token: "dpop",
968 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
969 token_type: "DPoP",
970 expires_in: 300,
971 scope: "mobile:registration",
972 });
973 }
974 registrations.push(await request.json());
975 return Response.json({ ok: true });
983 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
984 granted: false,
985 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
986 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
987 listener({ type: os, data: "rotated" });
988 yield* runBackgroundOperations();
989 expect(registrations.at(-1)).toMatchObject({
990 preferences: { notificationsEnabled: false },
976991 });
977 Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };
978 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
979 return Effect.gen(function* () {
980 yield* runBackgroundOperations();
981 expect(registrations.at(-1)).toMatchObject({
982 preferences: { notificationsEnabled: true },
983 });
984 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
985 granted: false,
986 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
987 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
988 listener({ type: os, data: "rotated" });
989 yield* runBackgroundOperations();
990 expect(registrations.at(-1)).toMatchObject({
991 preferences: { notificationsEnabled: false },
992 });
993 expect(registrations.at(-1)).not.toHaveProperty("pushToken");
994 }).pipe(
995 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
996 Effect.provide(
997 managedRelayClientLayer("https://permission-relay.example.test").pipe(
998 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)),
999 ),
992 expect(registrations.at(-1)).not.toHaveProperty("pushToken");
993 }).pipe(
994 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
995 Effect.provide(
996 managedRelayClientLayer("https://permission-relay.example.test").pipe(
997 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)),
1000998 ),
1001 );
1002 },
1003 );
1004 }
999 ),
1000 );
1001 },
1002 );
10051003 it.effect("preserves relay rejection errors with React Native response headers", () => {
10061004 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
10071005 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
apps/server/src/cli/app.test.ts+4-4
......@@ -256,8 +256,9 @@ describe("t3 app", () => {
256256 ),
257257 );
258258
259 for (const responseKind of ["failure", "invalid"] as const) {
260 it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () =>
259 it.effect.each(["failure", "invalid"] as const)(
260 "never falls back after the default desktop sends a %s response",
261 (responseKind) =>
261262 withTempDirectory("t3-app-response-test-", (root) =>
262263 Effect.gen(function* () {
263264 vi.mocked(NodeOS.homedir).mockReturnValue(root);
......@@ -302,6 +303,5 @@ describe("t3 app", () => {
302303 }
303304 }).pipe(Effect.scoped),
304305 ),
305 );
306 }
306 );
307307});
apps/server/src/device/DeviceService.test.ts+33-23
......@@ -361,29 +361,39 @@ describe("device discovery after server restart", () => {
361361 );
362362});
363363
364for (const [diagnostic, reason, message] of [
365 ["Insufficient disk space at /private/user/path", "disk_space", "not enough free disk space"],
366 ["Timed out spawning /private/user/command", "timeout", "did not become ready in time"],
367 ["Unexpected failure: secret-token", "launch_failed", "could not start"],
368] as const) {
369 it.effect(`normalizes boot failure: ${reason}`, () =>
370 Effect.gen(function* () {
371 const { service } = yield* fixture(Effect.void, diagnostic);
372 yield* service.configure({ enabled: true });
373 const error = yield* service
374 .open({
375 threadId: ThreadId.make("boot-failure"),
376 deviceId: "Pixel_API_35",
377 platform: "android",
378 })
379 .pipe(Effect.flip);
380 expect(error._tag).toBe("DeviceBootError");
381 expect(error.message).toContain(message);
382 expect(error.message).not.toContain(diagnostic);
383 expect((yield* service.state).bootingDevices).toEqual([]);
384 }).pipe(Effect.scoped),
385 );
386}
364it.effect.each([
365 {
366 diagnostic: "Insufficient disk space at /private/user/path",
367 reason: "disk_space",
368 message: "not enough free disk space",
369 },
370 {
371 diagnostic: "Timed out spawning /private/user/command",
372 reason: "timeout",
373 message: "did not become ready in time",
374 },
375 {
376 diagnostic: "Unexpected failure: secret-token",
377 reason: "launch_failed",
378 message: "could not start",
379 },
380] as const)("normalizes boot failure: $reason", ({ diagnostic, message }) =>
381 Effect.gen(function* () {
382 const { service } = yield* fixture(Effect.void, diagnostic);
383 yield* service.configure({ enabled: true });
384 const error = yield* service
385 .open({
386 threadId: ThreadId.make("boot-failure"),
387 deviceId: "Pixel_API_35",
388 platform: "android",
389 })
390 .pipe(Effect.flip);
391 expect(error._tag).toBe("DeviceBootError");
392 expect(error.message).toContain(message);
393 expect(error.message).not.toContain(diagnostic);
394 expect((yield* service.state).bootingDevices).toEqual([]);
395 }).pipe(Effect.scoped),
396);
387397
388398it.effect("keeps shutdown successful when subsequent discovery fails", () =>
389399 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+8-8
......@@ -604,8 +604,9 @@ function makeTurnInput(input: {
604604}
605605
606606describe("AcpAdapterV2", () => {
607 for (const outcome of ["failed", "recovered", "completed", "cancelled"] as const) {
608 it.live(`projects Mistral retry notices and their ${outcome} outcome`, () =>
607 it.live.each(["failed", "recovered", "completed", "cancelled"] as const)(
608 "projects Mistral retry notices and their %s outcome",
609 (outcome) =>
609610 Effect.gen(function* () {
610611 const path = yield* Path.Path;
611612 const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`);
......@@ -692,8 +693,7 @@ describe("AcpAdapterV2", () => {
692693 assert.equal(retries.at(-1)?.title, "Provider recovered");
693694 }
694695 }).pipe(Effect.provide(testLayer), Effect.scoped),
695 );
696 }
696 );
697697
698698 it("preserves legacy ids and scopes v2 ids by provider instance", () => {
699699 const instanceId = ProviderInstanceId.make("acp-identity-test");
......@@ -2914,8 +2914,9 @@ describe("AcpAdapterV2", () => {
29142914 }).pipe(Effect.provide(testLayer)),
29152915 );
29162916
2917 for (const model of ["grok-build", "composer-2"]) {
2918 it.effect(`Grok configures the native session for ${model}`, () =>
2917 it.effect.each(["grok-build", "composer-2"])(
2918 "Grok configures the native session for %s",
2919 (model) =>
29192920 Effect.gen(function* () {
29202921 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
29212922 const fileSystem = yield* FileSystem.FileSystem;
......@@ -2968,8 +2969,7 @@ describe("AcpAdapterV2", () => {
29682969 model === "grok-build" ? 0 : 1,
29692970 );
29702971 }).pipe(Effect.provide(testLayer), Effect.scoped),
2971 );
2972 }
2972 );
29732973
29742974 it.live("Grok reapplies an explicit return to the session's setup-time model", () =>
29752975 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+120-122
......@@ -2252,78 +2252,78 @@ describe("ClaudeAdapterV2 background wake turns", () => {
22522252 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
22532253 );
22542254
2255 for (const terminalReason of ["aborted_tools", "aborted_streaming"] as const) {
2256 for (const steered of [true, false]) {
2257 it.effect(`handles ${terminalReason} with active steering=${steered}`, () =>
2258 Effect.scoped(
2259 Effect.gen(function* () {
2260 const harness = yield* makeWakeHarness;
2261 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2262 const attemptId = RunAttemptId.make("attempt-steering-abort");
2263 const input = makeClaudeTestTurnInput({
2264 threadId: harness.threadId,
2265 providerThread: harness.providerThread,
2266 now: yield* DateTime.now,
2267 attemptId,
2268 text: "Audit the settings pages.",
2255 it.effect.each(
2256 (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) =>
2257 [true, false].map((steered) => ({ terminalReason, steered })),
2258 ),
2259 )("handles $terminalReason with active steering=$steered", ({ terminalReason, steered }) =>
2260 Effect.scoped(
2261 Effect.gen(function* () {
2262 const harness = yield* makeWakeHarness;
2263 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2264 const attemptId = RunAttemptId.make("attempt-steering-abort");
2265 const input = makeClaudeTestTurnInput({
2266 threadId: harness.threadId,
2267 providerThread: harness.providerThread,
2268 now: yield* DateTime.now,
2269 attemptId,
2270 text: "Audit the settings pages.",
2271 attachments: [],
2272 });
2273 yield* harness.runtime.startTurn(input);
2274 if (steered) {
2275 yield* harness.runtime.steerTurn({
2276 threadId: harness.threadId,
2277 runId: input.runId,
2278 providerThread: harness.providerThread,
2279 providerTurnId: idAllocator.derive.providerTurn({
2280 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
2281 nativeTurnId: `turn:${attemptId}`,
2282 }),
2283 message: {
2284 createdBy: "user",
2285 creationSource: "web",
2286 messageId: MessageId.make("message-steering-abort"),
2287 text: "Include the hierarchy mock.",
22692288 attachments: [],
2270 });
2271 yield* harness.runtime.startTurn(input);
2272 if (steered) {
2273 yield* harness.runtime.steerTurn({
2274 threadId: harness.threadId,
2275 runId: input.runId,
2276 providerThread: harness.providerThread,
2277 providerTurnId: idAllocator.derive.providerTurn({
2278 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
2279 nativeTurnId: `turn:${attemptId}`,
2280 }),
2281 message: {
2282 createdBy: "user",
2283 creationSource: "web",
2284 messageId: MessageId.make("message-steering-abort"),
2285 text: "Include the hierarchy mock.",
2286 attachments: [],
2287 },
2288 });
2289 assert.equal(harness.offeredMessages[1]?.priority, "now");
2290 }
2291 yield* Queue.offer(
2292 harness.sdkMessages,
2293 makeResultFrame({
2294 uuid: "00000000-0000-4000-8000-000000000901",
2295 result: "",
2296 terminalReason,
2297 }),
2298 );
2299 if (steered) {
2300 yield* Queue.offer(harness.sdkMessages, wakeAssistant);
2301 yield* Queue.offer(
2302 harness.sdkMessages,
2303 makeResultFrame({
2304 uuid: "00000000-0000-4000-8000-000000000902",
2305 result: "Audit finished after the steer.",
2306 }),
2307 );
2308 }
2309 const terminal = yield* Queue.take(harness.terminalReceipts);
2310 assert.equal(terminal.status, steered ? "completed" : "interrupted");
2311 if (steered) {
2312 assert.isTrue(
2313 harness.events.some(
2314 (event) =>
2315 event.type === "turn_item.updated" &&
2316 event.turnItem.type === "assistant_message" &&
2317 event.turnItem.text === WAKE_ASSISTANT_TEXT,
2318 ),
2319 );
2320 }
2321 assert.lengthOf(harness.terminalEvents(), 1);
2322 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
2323 ),
2324 );
2325 }
2326 }
2289 },
2290 });
2291 assert.equal(harness.offeredMessages[1]?.priority, "now");
2292 }
2293 yield* Queue.offer(
2294 harness.sdkMessages,
2295 makeResultFrame({
2296 uuid: "00000000-0000-4000-8000-000000000901",
2297 result: "",
2298 terminalReason,
2299 }),
2300 );
2301 if (steered) {
2302 yield* Queue.offer(harness.sdkMessages, wakeAssistant);
2303 yield* Queue.offer(
2304 harness.sdkMessages,
2305 makeResultFrame({
2306 uuid: "00000000-0000-4000-8000-000000000902",
2307 result: "Audit finished after the steer.",
2308 }),
2309 );
2310 }
2311 const terminal = yield* Queue.take(harness.terminalReceipts);
2312 assert.equal(terminal.status, steered ? "completed" : "interrupted");
2313 if (steered) {
2314 assert.isTrue(
2315 harness.events.some(
2316 (event) =>
2317 event.type === "turn_item.updated" &&
2318 event.turnItem.type === "assistant_message" &&
2319 event.turnItem.text === WAKE_ASSISTANT_TEXT,
2320 ),
2321 );
2322 }
2323 assert.lengthOf(harness.terminalEvents(), 1);
2324 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
2325 ),
2326 );
23272327
23282328 it.effect("announces usage-limit pauses once per window and again on a new turn", () =>
23292329 Effect.gen(function* () {
......@@ -3187,7 +3187,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
31873187 ),
31883188 );
31893189
3190 for (const terminalReason of [
3190 it.effect.each([
31913191 "api_error",
31923192 "malformed_tool_use_exhausted",
31933193 "budget_exhausted",
......@@ -3200,56 +3200,54 @@ describe("ClaudeAdapterV2 background wake turns", () => {
32003200 "image_error",
32013201 "model_error",
32023202 "overloaded_status",
3203 ] as const) {
3204 it.effect(`fails a success-shaped Claude result with ${terminalReason}`, () =>
3205 Effect.scoped(
3206 Effect.gen(function* () {
3207 const harness = yield* makeWakeHarness;
3208 const now = yield* DateTime.now;
3209 yield* harness.runtime.startTurn(
3210 makeClaudeTestTurnInput({
3211 threadId: harness.threadId,
3212 providerThread: harness.providerThread,
3213 now,
3214 attemptId: RunAttemptId.make("attempt-structured-terminal-failure"),
3215 text: "Complete the task.",
3216 attachments: [],
3217 }),
3218 );
3219 yield* Queue.offer(
3220 harness.sdkMessages,
3221 makeResultFrame({
3222 uuid: "00000000-0000-4000-8000-000000000205",
3223 result: "Provider failure details.",
3224 isError: false,
3225 ...(terminalReason === "overloaded_status"
3226 ? { apiErrorStatus: 529 }
3227 : { terminalReason }),
3228 }),
3229 );
3230 const terminal = yield* Queue.take(harness.terminalReceipts);
3231 assert.equal(terminal.status, "failed");
3232 if (terminal.status !== "failed") return;
3233 assert.isNotEmpty(terminal.failure.message);
3234 assert.equal(
3235 terminal.failure.class,
3236 terminalReason === "blocking_limit" ? "usage_limit" : "provider_error",
3237 );
3238 assert.isFalse(
3239 harness.events.some(
3240 (event) =>
3241 event.type === "message.updated" &&
3242 event.message.text === "Provider failure details.",
3243 ),
3244 );
3245 assert.equal(
3246 terminal.failure.code,
3247 terminalReason === "overloaded_status" ? "api_error_529" : terminalReason,
3248 );
3249 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
3250 ),
3251 );
3252 }
3203 ] as const)("fails a success-shaped Claude result with %s", (terminalReason) =>
3204 Effect.scoped(
3205 Effect.gen(function* () {
3206 const harness = yield* makeWakeHarness;
3207 const now = yield* DateTime.now;
3208 yield* harness.runtime.startTurn(
3209 makeClaudeTestTurnInput({
3210 threadId: harness.threadId,
3211 providerThread: harness.providerThread,
3212 now,
3213 attemptId: RunAttemptId.make("attempt-structured-terminal-failure"),
3214 text: "Complete the task.",
3215 attachments: [],
3216 }),
3217 );
3218 yield* Queue.offer(
3219 harness.sdkMessages,
3220 makeResultFrame({
3221 uuid: "00000000-0000-4000-8000-000000000205",
3222 result: "Provider failure details.",
3223 isError: false,
3224 ...(terminalReason === "overloaded_status"
3225 ? { apiErrorStatus: 529 }
3226 : { terminalReason }),
3227 }),
3228 );
3229 const terminal = yield* Queue.take(harness.terminalReceipts);
3230 assert.equal(terminal.status, "failed");
3231 if (terminal.status !== "failed") return;
3232 assert.isNotEmpty(terminal.failure.message);
3233 assert.equal(
3234 terminal.failure.class,
3235 terminalReason === "blocking_limit" ? "usage_limit" : "provider_error",
3236 );
3237 assert.isFalse(
3238 harness.events.some(
3239 (event) =>
3240 event.type === "message.updated" &&
3241 event.message.text === "Provider failure details.",
3242 ),
3243 );
3244 assert.equal(
3245 terminal.failure.code,
3246 terminalReason === "overloaded_status" ? "api_error_529" : terminalReason,
3247 );
3248 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
3249 ),
3250 );
32533251
32543252 const providerThreadRosterEvents = (events: ReadonlyArray<ProviderAdapterV2Event>) =>
32553253 events.filter(
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+317-316
......@@ -1728,8 +1728,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
17281728 };
17291729 });
17301730
1731 for (const response of ["supported", "unsupported", "invalid"] as const) {
1732 it.effect(`delivers native history with ${response} app-server protocol`, () =>
1731 it.effect.each(["supported", "unsupported", "invalid"] as const)(
1732 "delivers native history with %s app-server protocol",
1733 (response) =>
17331734 Effect.gen(function* () {
17341735 const nativeThreadId = `inject-${response}`;
17351736 const prompt = "Only the current request";
......@@ -1830,8 +1831,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
18301831 assert.equal(requests.filter((method) => method === "turn/start").length, 1);
18311832 assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start"));
18321833 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
1833 );
1834 }
1834 );
18351835
18361836 it.effect("identifies sessions to Codex with the same client info as main", () =>
18371837 Effect.gen(function* () {
......@@ -2926,8 +2926,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
29262926 ),
29272927 );
29282928
2929 for (const terminalStatus of ["completed", "interrupted"] as const) {
2930 it.effect(`retains Codex reasoning parts when the turn is ${terminalStatus}`, () =>
2929 it.effect.each(["completed", "interrupted"] as const)(
2930 "retains Codex reasoning parts when the turn is %s",
2931 (terminalStatus) =>
29312932 Effect.scoped(
29322933 Effect.gen(function* () {
29332934 const scenario = `codex-reasoning-${terminalStatus}`;
......@@ -3087,8 +3088,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
30873088 assert.deepEqual(assistantMessages(harness.events), []);
30883089 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
30893090 ),
3090 );
3091 }
3091 );
30923092
30933093 const finalAnswerTranscript = (
30943094 scenario: string,
......@@ -3714,9 +3714,10 @@ describe("CodexAdapterV2 post-settle continuation", () => {
37143714 ),
37153715 );
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]) => {
37183719 const stillRunning = terminated === "still_running";
3719 const transcript = makeCodexReplayTranscript({
3720 return makeCodexReplayTranscript({
37203721 scenario: `codex-bg-stop-${terminated}`,
37213722 entries: [
37223723 ...backgroundExecTranscript.entries.slice(0, -1),
......@@ -3796,9 +3797,14 @@ describe("CodexAdapterV2 post-settle continuation", () => {
37963797 backgroundExecTranscript.entries.at(-1)!,
37973798 ],
37983799 });
3800 };
37993801
3800 it.effect(`stops a command after root completion when termination returns ${terminated}`, () =>
3801 Effect.scoped(
3802 it.effect.each(backgroundStopCases)(
3803 "stops a command after root completion when termination returns %s",
3804 (terminated) => {
3805 const stillRunning = terminated === "still_running";
3806 const transcript = makeBackgroundStopTranscript(terminated);
3807 return Effect.scoped(
38023808 Effect.gen(function* () {
38033809 const stopped = yield* Deferred.make<void>();
38043810 const harness = yield* makeCodexReplayHarness(transcript, (event) =>
......@@ -3852,140 +3858,137 @@ describe("CodexAdapterV2 post-settle continuation", () => {
38523858 assert.equal(harness.terminalEvents()[0]?.status, "completed");
38533859 assert.lengthOf(harness.continuationRequests, 0);
38543860 }).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 ),
39863861 );
3987 }
3988 }
3862 },
3863 );
3864 it.effect("interrupts a completed run's background command through orchestration", () => {
3865 const transcript = makeBackgroundStopTranscript(true);
3866 return Effect.scoped(
3867 Effect.gen(function* () {
3868 const fs = yield* FileSystem.FileSystem;
3869 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" });
3870 const localTranscript = yield* decodeReplayTranscriptJson(
3871 (yield* encodeReplayTranscriptJson(transcript)).replaceAll(
3872 yield* encodeStringJson("/workspace"),
3873 yield* encodeStringJson(cwd),
3874 ),
3875 );
3876 const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript);
3877 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3878 assert.equal(
3879 Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))),
3880 0,
3881 );
3882 assert.equal(
3883 Number(
3884 yield* spawner.exitCode(
3885 ChildProcess.make(
3886 "git",
3887 [
3888 "-c",
3889 "user.name=Test",
3890 "-c",
3891 "user.email=test@example.com",
3892 "commit",
3893 "--allow-empty",
3894 "--quiet",
3895 "-m",
3896 "Initial commit",
3897 ],
3898 { cwd },
3899 ),
3900 ),
3901 ),
3902 0,
3903 );
3904 yield* Effect.gen(function* () {
3905 const orchestrator = yield* Orchestrator.OrchestratorV2;
3906 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
3907 const threadId = ThreadId.make("thread:background-stop");
3908 yield* orchestrator.dispatch({
3909 type: "thread.create",
3910 commandId: CommandId.make("create-background-stop"),
3911 threadId,
3912 projectId: ProjectId.make("project:background-stop"),
3913 title: "Background stop",
3914 modelSelection: CODEX_TEST_MODEL_SELECTION,
3915 runtimeMode: "full-access",
3916 interactionMode: "default",
3917 branch: null,
3918 worktreePath: cwd,
3919 createdBy: "user",
3920 creationSource: "web",
3921 });
3922 const waiting = yield* orchestrator.streamDomainEvents.pipe(
3923 Stream.filter(
3924 (event) => event.type === "run.updated" && event.payload.status === "waiting",
3925 ),
3926 Stream.runHead,
3927 Effect.forkChild({ startImmediately: true }),
3928 );
3929 yield* orchestrator.dispatch({
3930 type: "message.dispatch",
3931 commandId: CommandId.make("start-background-stop"),
3932 threadId,
3933 messageId: MessageId.make("message:background-stop"),
3934 text: BG_PROMPT,
3935 attachments: [],
3936 createdBy: "user",
3937 creationSource: "web",
3938 dispatchMode: { type: "start_immediately" },
3939 });
3940 yield* worker.drain();
3941 assert.isNull((yield* Ref.get(replayDriver.state)).failure);
3942 yield* Fiber.join(waiting);
3943 yield* worker.drain();
3944 const projection = yield* orchestrator.getThreadProjection(threadId);
3945 const run = projection.runs.at(-1)!;
3946 assert.equal(run.status, "completed");
3947 assert.equal(
3948 (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length,
3949 1,
3950 );
3951 const stopped = yield* orchestrator.streamDomainEvents.pipe(
3952 Stream.filter(
3953 (event) =>
3954 event.type === "turn-item.updated" &&
3955 event.payload.type === "command_execution" &&
3956 event.payload.status === "interrupted",
3957 ),
3958 Stream.runHead,
3959 Effect.forkChild({ startImmediately: true }),
3960 );
3961 yield* orchestrator.dispatch({
3962 type: "run.interrupt",
3963 commandId: CommandId.make("stop-background-command"),
3964 threadId,
3965 runId: run.id,
3966 });
3967 yield* worker.drain();
3968 yield* Fiber.join(stopped);
3969 assert.equal(
3970 (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status,
3971 "completed",
3972 );
3973 assert.deepEqual(
3974 (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks,
3975 [],
3976 );
3977 }).pipe(
3978 Effect.provide(
3979 makeOrchestratorV2ReplayLayerWithRegistry(
3980 { name: "codex-background-stop", runtimePolicyOverride: { cwd } },
3981 makeCodexProviderAdapterRegistryReplayLayer({
3982 transcript: localTranscript,
3983 driver: replayDriver,
3984 }),
3985 { runEffectWorker: false },
3986 ),
3987 ),
3988 );
3989 }).pipe(Effect.provide(NodeServices.layer)),
3990 );
3991 });
39893992
39903993 // The app-server exits after the root turn, before the command's own
39913994 // item/completed (Codex always sends one, so only a lost notification or a
......@@ -5506,7 +5509,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
55065509 ),
55075510 );
55085511
5509 for (const scenario of [
5512 it.effect.each([
55105513 {
55115514 name: "usage",
55125515 code: "usageLimitExceeded",
......@@ -5557,192 +5560,190 @@ describe("CodexAdapterV2 post-settle continuation", () => {
55575560 expectedClass: "usage_limit",
55585561 },
55595562 { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" },
5560 ] as const) {
5561 it.effect(`classifies Codex terminal failures from ${scenario.name} evidence`, () =>
5562 Effect.scoped(
5563 Effect.gen(function* () {
5564 const nativeThreadId = `native-limit-${scenario.name}`;
5565 const nativeTurnId = `turn-limit-${scenario.name}`;
5566 const message = "Provider stopped this request.";
5567 const resetAt = "2033-05-19T07:20:00.000Z";
5568 const snapshot = {
5569 type: "emit_inbound" as const,
5570 label: "account/rateLimits/updated",
5571 frame: {
5572 method: "account/rateLimits/updated",
5573 params: {
5574 rateLimits: {
5575 limitId: "codex",
5576 primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 },
5577 },
5563 ] as const)("classifies Codex terminal failures from $name evidence", (scenario) =>
5564 Effect.scoped(
5565 Effect.gen(function* () {
5566 const nativeThreadId = `native-limit-${scenario.name}`;
5567 const nativeTurnId = `turn-limit-${scenario.name}`;
5568 const message = "Provider stopped this request.";
5569 const resetAt = "2033-05-19T07:20:00.000Z";
5570 const snapshot = {
5571 type: "emit_inbound" as const,
5572 label: "account/rateLimits/updated",
5573 frame: {
5574 method: "account/rateLimits/updated",
5575 params: {
5576 rateLimits: {
5577 limitId: "codex",
5578 primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 },
55785579 },
55795580 },
5580 };
5581 const transcript = makeCodexReplayTranscript({
5582 scenario: `codex-limit-${scenario.name}`,
5583 entries: [
5584 ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }),
5585 ...(scenario.name === "known-reset" || scenario.name === "deferred-reset"
5586 ? [snapshot]
5587 : []),
5588 ...(scenario.name === "deferred-reset"
5589 ? [
5590 {
5591 type: "emit_inbound" as const,
5592 label: "item/completed/subAgentActivity-started",
5593 frame: {
5594 method: "item/completed",
5595 params: {
5596 threadId: nativeThreadId,
5597 turnId: nativeTurnId,
5598 item: {
5599 type: "subAgentActivity",
5600 id: "limit-child-spawn",
5601 kind: "started",
5602 agentThreadId: "native-limit-child",
5603 agentPath: "/root/limit_child",
5604 },
5581 },
5582 };
5583 const transcript = makeCodexReplayTranscript({
5584 scenario: `codex-limit-${scenario.name}`,
5585 entries: [
5586 ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }),
5587 ...(scenario.name === "known-reset" || scenario.name === "deferred-reset"
5588 ? [snapshot]
5589 : []),
5590 ...(scenario.name === "deferred-reset"
5591 ? [
5592 {
5593 type: "emit_inbound" as const,
5594 label: "item/completed/subAgentActivity-started",
5595 frame: {
5596 method: "item/completed",
5597 params: {
5598 threadId: nativeThreadId,
5599 turnId: nativeTurnId,
5600 item: {
5601 type: "subAgentActivity",
5602 id: "limit-child-spawn",
5603 kind: "started",
5604 agentThreadId: "native-limit-child",
5605 agentPath: "/root/limit_child",
56055606 },
56065607 },
56075608 },
5608 {
5609 type: "emit_inbound" as const,
5610 label: "turn/started/child",
5611 frame: {
5612 method: "turn/started",
5613 params: {
5614 threadId: "native-limit-child",
5615 turn: makeCodexReplayTurn({
5616 id: "limit-child-turn",
5617 status: "inProgress",
5618 }),
5619 },
5609 },
5610 {
5611 type: "emit_inbound" as const,
5612 label: "turn/started/child",
5613 frame: {
5614 method: "turn/started",
5615 params: {
5616 threadId: "native-limit-child",
5617 turn: makeCodexReplayTurn({
5618 id: "limit-child-turn",
5619 status: "inProgress",
5620 }),
56205621 },
56215622 },
5622 ]
5623 : []),
5624 ...(scenario.notification
5625 ? [
5626 {
5627 type: "emit_inbound" as const,
5628 label: "error",
5629 frame: {
5630 method: "error",
5631 params: {
5632 threadId: nativeThreadId,
5633 turnId: nativeTurnId,
5634 willRetry: scenario.name === "retry",
5635 error: {
5636 message,
5637 codexErrorInfo: scenario.code,
5638 additionalDetails:
5639 scenario.name === "matching-details"
5640 ? "Detailed provider allowance explanation."
5641 : null,
5642 },
5623 },
5624 ]
5625 : []),
5626 ...(scenario.notification
5627 ? [
5628 {
5629 type: "emit_inbound" as const,
5630 label: "error",
5631 frame: {
5632 method: "error",
5633 params: {
5634 threadId: nativeThreadId,
5635 turnId: nativeTurnId,
5636 willRetry: scenario.name === "retry",
5637 error: {
5638 message,
5639 codexErrorInfo: scenario.code,
5640 additionalDetails:
5641 scenario.name === "matching-details"
5642 ? "Detailed provider allowance explanation."
5643 : null,
56435644 },
56445645 },
56455646 },
5646 ]
5647 : []),
5648 {
5649 type: "emit_inbound",
5650 label: "turn/completed",
5651 frame: {
5652 method: "turn/completed",
5653 params: {
5654 threadId: nativeThreadId,
5655 turn: {
5656 ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }),
5657 error: {
5658 message: scenario.name === "replacement" ? "A different failure." : message,
5659 ...(scenario.notification && scenario.name !== "matching-details"
5660 ? {}
5661 : { codexErrorInfo: scenario.code }),
5662 },
5647 },
5648 ]
5649 : []),
5650 {
5651 type: "emit_inbound",
5652 label: "turn/completed",
5653 frame: {
5654 method: "turn/completed",
5655 params: {
5656 threadId: nativeThreadId,
5657 turn: {
5658 ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }),
5659 error: {
5660 message: scenario.name === "replacement" ? "A different failure." : message,
5661 ...(scenario.notification && scenario.name !== "matching-details"
5662 ? {}
5663 : { codexErrorInfo: scenario.code }),
56635664 },
56645665 },
56655666 },
56665667 },
5667 ...(scenario.name === "late-reset" ? [snapshot] : []),
5668 ...(scenario.name === "deferred-reset"
5669 ? [
5670 {
5671 ...snapshot,
5672 frame: {
5673 method: "account/rateLimits/updated",
5674 params: {
5675 rateLimits: {
5676 limitId: "codex",
5677 primary: {
5678 usedPercent: 100,
5679 resetsAt: 2000200000,
5680 windowDurationMins: 300,
5681 },
5668 },
5669 ...(scenario.name === "late-reset" ? [snapshot] : []),
5670 ...(scenario.name === "deferred-reset"
5671 ? [
5672 {
5673 ...snapshot,
5674 frame: {
5675 method: "account/rateLimits/updated",
5676 params: {
5677 rateLimits: {
5678 limitId: "codex",
5679 primary: {
5680 usedPercent: 100,
5681 resetsAt: 2000200000,
5682 windowDurationMins: 300,
56825683 },
56835684 },
56845685 },
56855686 },
5686 {
5687 type: "emit_inbound" as const,
5688 label: "turn/completed/child",
5689 frame: {
5690 method: "turn/completed",
5691 params: {
5692 threadId: "native-limit-child",
5693 turn: makeCodexReplayTurn({
5694 id: "limit-child-turn",
5695 status: "completed",
5696 }),
5697 },
5687 },
5688 {
5689 type: "emit_inbound" as const,
5690 label: "turn/completed/child",
5691 frame: {
5692 method: "turn/completed",
5693 params: {
5694 threadId: "native-limit-child",
5695 turn: makeCodexReplayTurn({
5696 id: "limit-child-turn",
5697 status: "completed",
5698 }),
56985699 },
56995700 },
5700 ]
5701 : []),
5702 ],
5703 });
5704 const resetReceipt = yield* Deferred.make<void>();
5705 const harness = yield* makeCodexReplayHarness(transcript, (event) =>
5706 event.type === "turn_item.updated" &&
5707 event.turnItem.type === "error" &&
5708 event.turnItem.failure.resetAt === resetAt
5709 ? Deferred.succeed(resetReceipt, undefined)
5710 : Effect.void,
5711 );
5712 yield* harness.runtime.startTurn(
5713 makeCodexTestTurnInput({
5714 threadId: harness.threadId,
5715 providerThread: harness.providerThread,
5716 now: yield* DateTime.now,
5717 text: "Continue.",
5718 attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`),
5719 }),
5701 },
5702 ]
5703 : []),
5704 ],
5705 });
5706 const resetReceipt = yield* Deferred.make<void>();
5707 const harness = yield* makeCodexReplayHarness(transcript, (event) =>
5708 event.type === "turn_item.updated" &&
5709 event.turnItem.type === "error" &&
5710 event.turnItem.failure.resetAt === resetAt
5711 ? Deferred.succeed(resetReceipt, undefined)
5712 : Effect.void,
5713 );
5714 yield* harness.runtime.startTurn(
5715 makeCodexTestTurnInput({
5716 threadId: harness.threadId,
5717 providerThread: harness.providerThread,
5718 now: yield* DateTime.now,
5719 text: "Continue.",
5720 attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`),
5721 }),
5722 );
5723 yield* harness.firstTerminal;
5724 const terminal = harness.terminalEvents()[0];
5725 assert.equal(terminal?.status, "failed");
5726 if (terminal?.status !== "failed") return;
5727 assert.equal(terminal.failure.class, scenario.expectedClass);
5728 assert.equal(terminal.threadDisposition, "reusable");
5729 if (scenario.name === "known-reset" || scenario.name === "deferred-reset")
5730 assert.equal(terminal.failure.resetAt, resetAt);
5731 if (scenario.name === "matching-details")
5732 assert.equal(terminal.failure.message, "Detailed provider allowance explanation.");
5733 if (scenario.name === "late-reset") {
5734 yield* Deferred.await(resetReceipt);
5735 const item = harness.events.find(
5736 (event) =>
5737 event.type === "turn_item.updated" &&
5738 event.turnItem.type === "error" &&
5739 event.turnItem.failure.resetAt === resetAt,
57205740 );
5721 yield* harness.firstTerminal;
5722 const terminal = harness.terminalEvents()[0];
5723 assert.equal(terminal?.status, "failed");
5724 if (terminal?.status !== "failed") return;
5725 assert.equal(terminal.failure.class, scenario.expectedClass);
5726 assert.equal(terminal.threadDisposition, "reusable");
5727 if (scenario.name === "known-reset" || scenario.name === "deferred-reset")
5728 assert.equal(terminal.failure.resetAt, resetAt);
5729 if (scenario.name === "matching-details")
5730 assert.equal(terminal.failure.message, "Detailed provider allowance explanation.");
5731 if (scenario.name === "late-reset") {
5732 yield* Deferred.await(resetReceipt);
5733 const item = harness.events.find(
5734 (event) =>
5735 event.type === "turn_item.updated" &&
5736 event.turnItem.type === "error" &&
5737 event.turnItem.failure.resetAt === resetAt,
5738 );
5739 assert.isDefined(item);
5740 }
5741 if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1);
5742 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
5743 ),
5744 );
5745 }
5741 assert.isDefined(item);
5742 }
5743 if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1);
5744 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
5745 ),
5746 );
57465747
57475748 const FAILED_SCENARIO = "codex-failed-mid-command";
57485749 const FAILED_NATIVE_THREAD = "native-codex-failed-thread";
......@@ -6511,7 +6512,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
65116512 ),
65126513 );
65136514
6514 for (const [nativeStatus, expectedStatus] of [
6515 it.effect.each([
65156516 ["pendingInit", "pending"],
65166517 ["running", "running"],
65176518 ["interrupted", "interrupted"],
......@@ -6523,8 +6524,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
65236524 ["late-activity-completed", "completed"],
65246525 ["stale-running", "completed"],
65256526 ["duplicate-completed", "completed"],
6526 ] as const) {
6527 it.effect(`normalizes subagent ${nativeStatus} without losing its lifecycle`, () =>
6527 ] as const)(
6528 "normalizes subagent %s without losing its lifecycle",
6529 ([nativeStatus, expectedStatus]) =>
65286530 Effect.scoped(
65296531 Effect.gen(function* () {
65306532 const marker = yield* Deferred.make<void>();
......@@ -6670,8 +6672,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
66706672 }
66716673 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
66726674 ),
6673 );
6674 }
6675 );
66756676
66766677 const codexReplayThreadResult = (input: {
66776678 readonly nativeThreadId: string;
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts+5-5
......@@ -39,12 +39,13 @@ import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAge
3939const decodeCursorSettings = Schema.decodeEffect(CursorSettings);
4040
4141describe("CursorAdapterV2", () => {
42 for (const { status, model } of [
42 it.effect.each([
4343 { status: "finished", model: undefined },
4444 { status: "cancelled", model: "claude-opus-4-6" },
4545 { status: "error", model: "custom-fable" },
46 ] as const) {
47 it.effect(`settles missing task completions when the Cursor run is ${status}`, () =>
46 ] as const)(
47 "settles missing task completions when the Cursor run is $status",
48 ({ status, model }) =>
4849 Effect.gen(function* () {
4950 const fileSystem = yield* FileSystem.FileSystem;
5051 const path = yield* Path.Path;
......@@ -185,8 +186,7 @@ describe("CursorAdapterV2", () => {
185186 );
186187 assert.isNotNull(rows.at(-1)?.subagent.completedAt);
187188 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
188 );
189 }
189 );
190190
191191 it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () =>
192192 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+13-11
......@@ -371,17 +371,19 @@ describe("Grok launch permission mode", () => {
371371 ...override,
372372 });
373373
374 for (const [runtimeMode, args] of [
375 ["approval-required", ["--permission-mode", "default", "agent", "stdio"]],
376 ["auto", ["--permission-mode", "auto", "agent", "stdio"]],
377 ["full-access", ["agent", "--always-approve", "stdio"]],
378 ] as const) {
379 it.effect(`launches ${runtimeMode} threads with ${args.join(" ")}`, () =>
380 Effect.gen(function* () {
381 assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]);
382 }),
383 );
384 }
374 it.effect.each(
375 (
376 [
377 ["approval-required", ["--permission-mode", "default", "agent", "stdio"]],
378 ["auto", ["--permission-mode", "auto", "agent", "stdio"]],
379 ["full-access", ["agent", "--always-approve", "stdio"]],
380 ] as const
381 ).map(([runtimeMode, args]) => [runtimeMode, args.join(" "), args] as const),
382 )("launches %s threads with %s", ([runtimeMode, , args]) =>
383 Effect.gen(function* () {
384 assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]);
385 }),
386 );
385387
386388 it.effect("launches a thread stored as Auto-accept edits asking", () =>
387389 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+158-161
......@@ -3090,81 +3090,78 @@ describe("OpenCode2 adapter", () => {
30903090 }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)),
30913091 );
30923092
3093 for (const running of [true, false]) {
3094 it.effect(
3095 running
3096 ? "refuses a rollback while a timed-out Stop's run is still going"
3097 : "rolls back once a timed-out Stop's run has left the server",
3098 () =>
3099 Effect.gen(function* () {
3100 const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`;
3101 const { runtime, thread } = yield* resumed([
3102 ...stopTimedOut,
3103 // The server says whether the stopped run still goes.
3104 out("session.active"),
3105 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
3106 // Gone: the cut is made. Running: nothing is read or cut meanwhile.
3107 ...(running
3108 ? []
3109 : [
3110 out("message.list", "<any>"),
3111 reply("message.list", {
3112 data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }],
3113 cursor: {},
3114 }),
3115 out("session.revert.stage", {
3116 sessionID: SESSION,
3117 messageID: prompt,
3118 files: false,
3119 }),
3120 replyData("session.revert.stage", { messageID: prompt, files: [] }),
3121 out("session.revert.commit", { sessionID: SESSION }),
3122 reply("session.revert.commit", null),
3123 out("message.list", "<any>"),
3124 reply("message.list", { data: [], cursor: {} }),
3125 ]),
3126 ]);
3127 yield* stopFirstTurn(runtime, thread);
3128 const now = yield* DateTime.now;
3129 const first = {
3130 id: yield* providerTurnId,
3131 providerThreadId: thread.id,
3132 nodeId: NodeId.make("node:opencode2-adapter"),
3133 runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"),
3134 nativeTurnRef: {
3135 driver: OPENCODE_PROVIDER,
3136 nativeId: prompt,
3137 strength: "weak" as const,
3138 },
3139 ordinal: 1,
3140 status: "interrupted" as const,
3141 startedAt: now,
3142 completedAt: now,
3143 };
3144 const rollback = yield* runtime
3145 .rollbackThread({
3146 providerThread: thread,
3147 target: {
3148 type: "thread_start",
3149 checkpointId: CheckpointId.make("checkpoint:start"),
3150 appRunOrdinal: 0,
3151 },
3152 providerThreadTurns: [first],
3153 })
3154 .pipe(Effect.exit);
3155 if (running) {
3156 assert.isTrue(Exit.isFailure(rollback));
3157 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
3158 assert.equal(
3159 (error as { _tag?: string } | undefined)?._tag,
3160 "ProviderAdapterProtocolError",
3161 );
3162 } else {
3163 assert.isTrue(Exit.isSuccess(rollback));
3164 }
3165 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
3166 );
3167 }
3093 it.effect.each([
3094 ["refuses a rollback while a timed-out Stop's run is still going", true],
3095 ["rolls back once a timed-out Stop's run has left the server", false],
3096 ] as const)("%s", ([, running]) =>
3097 Effect.gen(function* () {
3098 const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`;
3099 const { runtime, thread } = yield* resumed([
3100 ...stopTimedOut,
3101 // The server says whether the stopped run still goes.
3102 out("session.active"),
3103 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
3104 // Gone: the cut is made. Running: nothing is read or cut meanwhile.
3105 ...(running
3106 ? []
3107 : [
3108 out("message.list", "<any>"),
3109 reply("message.list", {
3110 data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }],
3111 cursor: {},
3112 }),
3113 out("session.revert.stage", {
3114 sessionID: SESSION,
3115 messageID: prompt,
3116 files: false,
3117 }),
3118 replyData("session.revert.stage", { messageID: prompt, files: [] }),
3119 out("session.revert.commit", { sessionID: SESSION }),
3120 reply("session.revert.commit", null),
3121 out("message.list", "<any>"),
3122 reply("message.list", { data: [], cursor: {} }),
3123 ]),
3124 ]);
3125 yield* stopFirstTurn(runtime, thread);
3126 const now = yield* DateTime.now;
3127 const first = {
3128 id: yield* providerTurnId,
3129 providerThreadId: thread.id,
3130 nodeId: NodeId.make("node:opencode2-adapter"),
3131 runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"),
3132 nativeTurnRef: {
3133 driver: OPENCODE_PROVIDER,
3134 nativeId: prompt,
3135 strength: "weak" as const,
3136 },
3137 ordinal: 1,
3138 status: "interrupted" as const,
3139 startedAt: now,
3140 completedAt: now,
3141 };
3142 const rollback = yield* runtime
3143 .rollbackThread({
3144 providerThread: thread,
3145 target: {
3146 type: "thread_start",
3147 checkpointId: CheckpointId.make("checkpoint:start"),
3148 appRunOrdinal: 0,
3149 },
3150 providerThreadTurns: [first],
3151 })
3152 .pipe(Effect.exit);
3153 if (running) {
3154 assert.isTrue(Exit.isFailure(rollback));
3155 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
3156 assert.equal(
3157 (error as { _tag?: string } | undefined)?._tag,
3158 "ProviderAdapterProtocolError",
3159 );
3160 } else {
3161 assert.isTrue(Exit.isSuccess(rollback));
3162 }
3163 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
3164 );
31683165
31693166 it.effect("takes back a stranded steer again when its first cancel failed", () =>
31703167 Effect.gen(function* () {
......@@ -3376,92 +3373,92 @@ describe("OpenCode2 adapter", () => {
33763373 }).pipe(Effect.scoped),
33773374 );
33783375
3379 for (const running of [true, false]) {
3380 it.effect(
3381 running
3382 ? "refuses to roll back a session not loaded yet while the server runs it"
3383 : "keeps the recorded turns before the target when rolling back a session not loaded yet",
3384 () =>
3385 Effect.gen(function* () {
3386 const first = `msg_t3_turn_${SESSION}:attempt:first`;
3387 const second = `msg_t3_turn_${SESSION}:attempt:second`;
3388 // A runtime that never loaded the session, as after a T3 restart
3389 // against a server that kept running.
3390 const runtime = yield* openCode2ReplayRuntime([
3391 ...opening,
3392 out("session.active"),
3393 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
3394 ...(running
3395 ? []
3396 : [
3397 out("session.get", { sessionID: SESSION }),
3398 replyData("session.get", sessionInfo()),
3399 ...noOpenRequests,
3400 out("message.list", "<any>"),
3401 reply("message.list", {
3402 data: [
3403 { id: second, time: { created: 2 }, text: "second", type: "user" },
3404 { id: first, time: { created: 1 }, text: "first", type: "user" },
3405 ],
3406 cursor: {},
3407 }),
3408 out("session.revert.stage", {
3409 sessionID: SESSION,
3410 messageID: second,
3411 files: false,
3412 }),
3413 replyData("session.revert.stage", { messageID: second, files: [] }),
3414 out("session.revert.commit", { sessionID: SESSION }),
3415 reply("session.revert.commit", null),
3416 out("message.list", "<any>"),
3417 reply("message.list", {
3418 data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }],
3419 cursor: {},
3420 }),
3421 ]),
3422 ]);
3423 const thread = providerThread(yield* DateTime.now);
3424 const now = yield* DateTime.now;
3425 const recorded = (key: string, ordinal: number, nativeId: string) => ({
3426 id: ProviderTurnId.make(`provider-turn:${key}`),
3427 providerThreadId: thread.id,
3428 nodeId: NodeId.make(`node:${key}`),
3429 runAttemptId: RunAttemptId.make(`attempt:${key}`),
3430 nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const },
3431 ordinal,
3432 status: "completed" as const,
3433 startedAt: now,
3434 completedAt: now,
3435 });
3436 const kept = recorded("first", 1, first);
3437 const rollback = yield* runtime
3438 .rollbackThread({
3439 providerThread: thread,
3440 target: {
3441 type: "provider_turn",
3442 checkpointId: CheckpointId.make("checkpoint:first"),
3443 appRunOrdinal: 1,
3444 providerTurn: kept,
3445 },
3446 providerThreadTurns: [kept, recorded("second", 2, second)],
3447 })
3448 .pipe(Effect.exit);
3449 if (running) {
3450 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
3451 assert.equal(
3452 (error as { _tag?: string } | undefined)?._tag,
3453 "ProviderAdapterProtocolError",
3454 );
3455 } else {
3456 assert.isTrue(Exit.isSuccess(rollback));
3457 assert.deepEqual(
3458 Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [],
3459 [kept.id],
3460 );
3461 }
3462 }).pipe(Effect.scoped),
3463 );
3464 }
3376 it.effect.each([
3377 ["refuses to roll back a session not loaded yet while the server runs it", true],
3378 [
3379 "keeps the recorded turns before the target when rolling back a session not loaded yet",
3380 false,
3381 ],
3382 ] as const)("%s", ([, running]) =>
3383 Effect.gen(function* () {
3384 const first = `msg_t3_turn_${SESSION}:attempt:first`;
3385 const second = `msg_t3_turn_${SESSION}:attempt:second`;
3386 // A runtime that never loaded the session, as after a T3 restart
3387 // against a server that kept running.
3388 const runtime = yield* openCode2ReplayRuntime([
3389 ...opening,
3390 out("session.active"),
3391 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
3392 ...(running
3393 ? []
3394 : [
3395 out("session.get", { sessionID: SESSION }),
3396 replyData("session.get", sessionInfo()),
3397 ...noOpenRequests,
3398 out("message.list", "<any>"),
3399 reply("message.list", {
3400 data: [
3401 { id: second, time: { created: 2 }, text: "second", type: "user" },
3402 { id: first, time: { created: 1 }, text: "first", type: "user" },
3403 ],
3404 cursor: {},
3405 }),
3406 out("session.revert.stage", {
3407 sessionID: SESSION,
3408 messageID: second,
3409 files: false,
3410 }),
3411 replyData("session.revert.stage", { messageID: second, files: [] }),
3412 out("session.revert.commit", { sessionID: SESSION }),
3413 reply("session.revert.commit", null),
3414 out("message.list", "<any>"),
3415 reply("message.list", {
3416 data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }],
3417 cursor: {},
3418 }),
3419 ]),
3420 ]);
3421 const thread = providerThread(yield* DateTime.now);
3422 const now = yield* DateTime.now;
3423 const recorded = (key: string, ordinal: number, nativeId: string) => ({
3424 id: ProviderTurnId.make(`provider-turn:${key}`),
3425 providerThreadId: thread.id,
3426 nodeId: NodeId.make(`node:${key}`),
3427 runAttemptId: RunAttemptId.make(`attempt:${key}`),
3428 nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const },
3429 ordinal,
3430 status: "completed" as const,
3431 startedAt: now,
3432 completedAt: now,
3433 });
3434 const kept = recorded("first", 1, first);
3435 const rollback = yield* runtime
3436 .rollbackThread({
3437 providerThread: thread,
3438 target: {
3439 type: "provider_turn",
3440 checkpointId: CheckpointId.make("checkpoint:first"),
3441 appRunOrdinal: 1,
3442 providerTurn: kept,
3443 },
3444 providerThreadTurns: [kept, recorded("second", 2, second)],
3445 })
3446 .pipe(Effect.exit);
3447 if (running) {
3448 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
3449 assert.equal(
3450 (error as { _tag?: string } | undefined)?._tag,
3451 "ProviderAdapterProtocolError",
3452 );
3453 } else {
3454 assert.isTrue(Exit.isSuccess(rollback));
3455 assert.deepEqual(
3456 Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [],
3457 [kept.id],
3458 );
3459 }
3460 }).pipe(Effect.scoped),
3461 );
34653462
34663463 it.effect("refuses to fork a session while OpenCode runs a follow-up on it", () =>
34673464 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+12-12
......@@ -237,8 +237,9 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct
237237});
238238
239239describe("OpenCodeAdapterV2", () => {
240 for (const ending of ["completed", "failed", "unresolved", "unavailable", "reconnect"] as const) {
241 it.effect(`normalizes OpenCode step usage for ${ending} turns`, () =>
240 it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)(
241 "normalizes OpenCode step usage for %s turns",
242 (ending) =>
242243 Effect.gen(function* () {
243244 const nativeEvents = asyncEventStream();
244245 let promptId = "";
......@@ -375,11 +376,11 @@ describe("OpenCodeAdapterV2", () => {
375376 },
376377 );
377378 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
378 );
379 }
379 );
380380
381 for (const kind of ["permission", "question"] as const) {
382 it.effect(`cancels an undelivered ${kind} reply at its deadline`, () =>
381 it.effect.each(["permission", "question"] as const)(
382 "cancels an undelivered %s reply at its deadline",
383 (kind) =>
383384 Effect.gen(function* () {
384385 const nativeEvents = asyncEventStream();
385386 const called = promiseGate<void>();
......@@ -453,8 +454,7 @@ describe("OpenCodeAdapterV2", () => {
453454 // Failed delivery leaves the request available for an explicit retry.
454455 yield* harness.runtime.respondToRuntimeRequest(response);
455456 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
456 );
457 }
457 );
458458
459459 it.effect("aborts external root and descendants before closing the event stream", () =>
460460 Effect.gen(function* () {
......@@ -494,8 +494,9 @@ describe("OpenCodeAdapterV2", () => {
494494 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
495495 );
496496
497 for (const failure of ["enumeration", "abort", "not-found", "timeout"] as const) {
498 it.effect(`reports descendant cleanup ${failure}`, () =>
497 it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)(
498 "reports descendant cleanup %s",
499 (failure) =>
499500 Effect.gen(function* () {
500501 const nativeEvents = asyncEventStream();
501502 const called = promiseGate<void>();
......@@ -545,8 +546,7 @@ describe("OpenCodeAdapterV2", () => {
545546 assert.equal(Exit.isSuccess(result), failure === "not-found");
546547 if (failure === "timeout") assert.isTrue(childSignal?.aborted);
547548 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
548 );
549 }
549 );
550550
551551 it.effect(
552552 "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", () => {
589589 }).pipe(Effect.scoped, Effect.provide(testLayer)),
590590 );
591591
592 for (const invalidReplacement of ["veto", "same identity"] as const) {
593 it.effect(`rejects a replacement with ${invalidReplacement}`, () =>
592 it.effect.each(["veto", "same identity"] as const)(
593 "rejects a replacement with %s",
594 (invalidReplacement) =>
594595 Effect.gen(function* () {
595596 const fake = yield* makeFakePi;
596597 const { runtime } = yield* openRuntime(fake);
......@@ -617,8 +618,7 @@ describe("PiAdapterV2", () => {
617618 yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip);
618619 assert.isFalse(fake.allRequests().some((request) => request.type === "prompt"));
619620 }).pipe(Effect.scoped, Effect.provide(testLayer)),
620 );
621 }
621 );
622622
623623 it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () =>
624624 Effect.gen(function* () {
......@@ -983,90 +983,85 @@ describe("PiAdapterV2", () => {
983983 }).pipe(Effect.scoped, Effect.provide(testLayer)),
984984 );
985985
986 for (const historical of [false, true]) {
987 it.effect(
988 `natively forks ${historical ? "a historical turn" : "the latest turn"} into an independent session`,
989 () =>
990 Effect.gen(function* () {
991 const fake = yield* makeFakePi;
992 const forkFake = yield* makeFakePi;
993 const forkFile = "/fake/forked.jsonl";
994 const { runtime, takeEvent } = yield* openRuntime(
995 fake,
996 "default",
997 THREAD_ID,
998 SESSION_ID,
999 forkFake,
1000 );
1001 const source = yield* runtime.ensureThread({
1002 threadId: THREAD_ID,
1003 modelSelection: modelSelection("default"),
1004 runtimePolicy,
1005 });
1006 const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({
1007 id: ProviderTurnId.make(`turn-${ordinal}`),
1008 providerThreadId: source.id,
1009 nodeId: NodeId.make(`node-${ordinal}`),
1010 runAttemptId: null,
1011 nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" },
1012 ordinal,
1013 status: "completed",
1014 startedAt: null,
1015 completedAt: null,
1016 });
1017 forkFake.queueState({ sessionFile: forkFile });
1018 fake.queueState({ sessionFile: forkFile });
1019 const target = ThreadId.make("fork-target");
1020 const forked = yield* runtime.forkThread({
1021 sourceProviderThread: source,
1022 sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)],
1023 providerTurnId: turn(1).id,
1024 targetThreadId: target,
1025 });
1026 assert.equal(forked.appThreadId, target);
1027 assert.equal(forked.nativeThreadRef?.nativeId, forkFile);
1028 assert.notEqual(forked.id, source.id);
1029 assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE);
1030 const args = forkFake.lastSpawn().args;
1031 assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE);
1032 assert.include(args, "--no-extensions");
1033 assert.include(args, "--no-tools");
1034 assert.notInclude(args, "--no-session");
1035 assert.deepEqual(
1036 forkFake
1037 .allRequests()
1038 .filter((request) => request.type === "fork")
1039 .map((request) => request.entryId),
1040 historical ? ["u2"] : [],
1041 );
1042 assert.isFalse(
1043 fake
1044 .allRequests()
1045 .some((request) => request.type === "fork" || request.type === "clone"),
1046 );
1047 // ProviderTurnStartService adopts the fork into its pending row.
1048 const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") };
1049 yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target);
1050 yield* fake.emit({ type: "agent_start" });
1051 yield* fake.emit({ type: "agent_settled" });
1052 const updated = yield* takeEvent(
1053 (event) =>
1054 event.type === "provider_thread.updated" &&
1055 event.providerThread.appThreadId === target,
1056 );
1057 assert.isTrue(
1058 updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id,
1059 );
1060 yield* takeEvent((event) => event.type === "turn.terminal");
1061 yield* runtime.resumeThread({ providerThread: adopted });
1062 assert.equal(
1063 fake.allRequests().findLast((request) => request.type === "switch_session")
1064 ?.sessionPath,
1065 forkFile,
1066 );
1067 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1068 );
1069 }
986 it.effect.each([
987 { historical: false, label: "the latest turn" },
988 { historical: true, label: "a historical turn" },
989 ])("natively forks $label into an independent session", ({ historical }) =>
990 Effect.gen(function* () {
991 const fake = yield* makeFakePi;
992 const forkFake = yield* makeFakePi;
993 const forkFile = "/fake/forked.jsonl";
994 const { runtime, takeEvent } = yield* openRuntime(
995 fake,
996 "default",
997 THREAD_ID,
998 SESSION_ID,
999 forkFake,
1000 );
1001 const source = yield* runtime.ensureThread({
1002 threadId: THREAD_ID,
1003 modelSelection: modelSelection("default"),
1004 runtimePolicy,
1005 });
1006 const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({
1007 id: ProviderTurnId.make(`turn-${ordinal}`),
1008 providerThreadId: source.id,
1009 nodeId: NodeId.make(`node-${ordinal}`),
1010 runAttemptId: null,
1011 nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" },
1012 ordinal,
1013 status: "completed",
1014 startedAt: null,
1015 completedAt: null,
1016 });
1017 forkFake.queueState({ sessionFile: forkFile });
1018 fake.queueState({ sessionFile: forkFile });
1019 const target = ThreadId.make("fork-target");
1020 const forked = yield* runtime.forkThread({
1021 sourceProviderThread: source,
1022 sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)],
1023 providerTurnId: turn(1).id,
1024 targetThreadId: target,
1025 });
1026 assert.equal(forked.appThreadId, target);
1027 assert.equal(forked.nativeThreadRef?.nativeId, forkFile);
1028 assert.notEqual(forked.id, source.id);
1029 assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE);
1030 const args = forkFake.lastSpawn().args;
1031 assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE);
1032 assert.include(args, "--no-extensions");
1033 assert.include(args, "--no-tools");
1034 assert.notInclude(args, "--no-session");
1035 assert.deepEqual(
1036 forkFake
1037 .allRequests()
1038 .filter((request) => request.type === "fork")
1039 .map((request) => request.entryId),
1040 historical ? ["u2"] : [],
1041 );
1042 assert.isFalse(
1043 fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"),
1044 );
1045 // ProviderTurnStartService adopts the fork into its pending row.
1046 const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") };
1047 yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target);
1048 yield* fake.emit({ type: "agent_start" });
1049 yield* fake.emit({ type: "agent_settled" });
1050 const updated = yield* takeEvent(
1051 (event) =>
1052 event.type === "provider_thread.updated" && event.providerThread.appThreadId === target,
1053 );
1054 assert.isTrue(
1055 updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id,
1056 );
1057 yield* takeEvent((event) => event.type === "turn.terminal");
1058 yield* runtime.resumeThread({ providerThread: adopted });
1059 assert.equal(
1060 fake.allRequests().findLast((request) => request.type === "switch_session")?.sessionPath,
1061 forkFile,
1062 );
1063 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1064 );
10701065
10711066 it.effect("observes official subagent results without inventing child threads", () =>
10721067 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts+5-4
......@@ -28,8 +28,9 @@ async function loadRequestHook(): Promise<RequestHook> {
2828}
2929
3030describe("Pi upstream output-budget workaround", () => {
31 for (const key of ["max_tokens", "max_completion_tokens"]) {
32 it(`caps ${key} without changing the conversation or tools`, async () => {
31 it.each(["max_tokens", "max_completion_tokens"])(
32 "caps %s without changing the conversation or tools",
33 async (key) => {
3334 const hook = await loadRequestHook();
3435 const payload = {
3536 model: "moonshotai/kimi-k2.6",
......@@ -43,8 +44,8 @@ describe("Pi upstream output-budget workaround", () => {
4344 assert.strictEqual(result?.tools, payload.tools);
4445 assert.equal(result?.model, payload.model);
4546 assert.equal(payload[key], 231_969);
46 });
47 }
47 },
48 );
4849
4950 it("preserves smaller budgets and other providers' payloads", async () => {
5051 const hook = await loadRequestHook();
apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts+44-48
......@@ -413,55 +413,51 @@ describe("handoff budget", () => {
413413});
414414
415415describe("handoff delivery", () => {
416 for (const native of [true, false]) {
417 it.effect(
418 `records omitted recovery coverage separately from ${native ? "injected" : "inline"} text`,
419 () =>
420 Effect.gen(function* () {
421 const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation");
422 const oversized = message("item:oversized", "user", "x".repeat(20_000));
423 let durable: OrchestrationV2ContextHandoff = {
424 ...handoff,
425 history: {
426 ...handoff.history!,
427 messages: [...messages, oversized],
428 omittedItems: 1,
429 omittedItemIds: [omittedBeforeDelivery],
430 },
431 };
432 const result = yield* deliverContextHandoffs({
433 handoffs: [durable],
434 providerThread,
435 budget: 16_000,
436 alreadyDeliveredItemIds: new Set(),
437 inject: (value) => {
438 assert.include(value.context, "omitted 2 items");
439 assert.notInclude(
440 value.messages.map((item) => item.itemId),
441 oversized.itemId,
442 );
443 return Effect.succeed(native);
444 },
445 persist: (value) =>
446 Effect.sync(() => {
447 durable = value;
448 }),
449 });
450 assert.equal(durable.delivery?.status, native ? "injected" : "pending");
451 yield* result.delivered;
452 assert.equal(durable.delivery?.status, native ? "injected" : "inline");
453 assert.deepEqual(
454 durable.delivery?.itemIds,
455 messages.map((item) => item.itemId),
456 );
457 assert.deepEqual(durable.delivery?.omittedItemIds, [
458 omittedBeforeDelivery,
416 it.effect.each([
417 { native: true, label: "injected" },
418 { native: false, label: "inline" },
419 ])("records omitted recovery coverage separately from $label text", ({ native }) =>
420 Effect.gen(function* () {
421 const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation");
422 const oversized = message("item:oversized", "user", "x".repeat(20_000));
423 let durable: OrchestrationV2ContextHandoff = {
424 ...handoff,
425 history: {
426 ...handoff.history!,
427 messages: [...messages, oversized],
428 omittedItems: 1,
429 omittedItemIds: [omittedBeforeDelivery],
430 },
431 };
432 const result = yield* deliverContextHandoffs({
433 handoffs: [durable],
434 providerThread,
435 budget: 16_000,
436 alreadyDeliveredItemIds: new Set(),
437 inject: (value) => {
438 assert.include(value.context, "omitted 2 items");
439 assert.notInclude(
440 value.messages.map((item) => item.itemId),
459441 oversized.itemId,
460 ]);
461 assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery);
462 }),
463 );
464 }
442 );
443 return Effect.succeed(native);
444 },
445 persist: (value) =>
446 Effect.sync(() => {
447 durable = value;
448 }),
449 });
450 assert.equal(durable.delivery?.status, native ? "injected" : "pending");
451 yield* result.delivered;
452 assert.equal(durable.delivery?.status, native ? "injected" : "inline");
453 assert.deepEqual(
454 durable.delivery?.itemIds,
455 messages.map((item) => item.itemId),
456 );
457 assert.deepEqual(durable.delivery?.omittedItemIds, [omittedBeforeDelivery, oversized.itemId]);
458 assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery);
459 }),
460 );
465461 it.effect("loads the history budget only when a handoff needs delivery", () =>
466462 Effect.gen(function* () {
467463 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) => {
433433 ),
434434 );
435435
436 for (const phase of ["high-water", "replay"] as const) {
437 it.effect(`bounds live events while the V2 ${phase} query is blocked`, () =>
436 it.effect.each(["high-water", "replay"] as const)(
437 "bounds live events while the V2 %s query is blocked",
438 (phase) =>
438439 Effect.scoped(
439440 Effect.gen(function* () {
440441 const sink = yield* EventSink.EventSinkV2;
......@@ -486,8 +487,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => {
486487 }
487488 }),
488489 ),
489 );
490 }
490 );
491491
492492 it.effect(
493493 "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) => {
26612661 }),
26622662 );
26632663
2664 for (const replayRequest of [
2664 it.effect.each([
26652665 { type: "terminal.cleanup" },
26662666 { type: "provider-runtime.continue", sourceRunId: RunId.make("run:restart-replay") },
2667 ] as const) {
2668 it.effect(
2669 `retires live provider effects and requeues ${replayRequest.type} after process loss`,
2670 () =>
2671 Effect.gen(function* () {
2672 const outbox = yield* EffectOutbox.EffectOutboxV2;
2673 const commandId = CommandId.make(
2674 `command:foundation-reclaim-running:${replayRequest.type}`,
2675 );
2676 yield* outbox.enqueue([
2677 {
2678 id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`,
2679 commandId,
2680 threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`),
2681 request: {
2682 type: "provider-turn.start",
2683 runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`),
2684 },
2685 },
2686 {
2687 id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`,
2688 commandId,
2689 threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`),
2690 request: replayRequest,
2667 ] as const)(
2668 "retires live provider effects and requeues $type after process loss",
2669 (replayRequest) =>
2670 Effect.gen(function* () {
2671 const outbox = yield* EffectOutbox.EffectOutboxV2;
2672 const commandId = CommandId.make(
2673 `command:foundation-reclaim-running:${replayRequest.type}`,
2674 );
2675 yield* outbox.enqueue([
2676 {
2677 id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`,
2678 commandId,
2679 threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`),
2680 request: {
2681 type: "provider-turn.start",
2682 runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`),
26912683 },
2692 ]);
2693 assert.isTrue(
2694 Option.isSome(
2695 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),
2696 ),
2697 );
2698 assert.isTrue(
2699 Option.isSome(
2700 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),
2701 ),
2702 );
2703 assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, {
2704 cancelled: 1,
2705 requeued: 1,
2706 });
2707 const cancelled = yield* outbox.get(
2708 `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`,
2709 );
2710 assert.isTrue(Option.isSome(cancelled));
2711 if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled");
2684 },
2685 {
2686 id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`,
2687 commandId,
2688 threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`),
2689 request: replayRequest,
2690 },
2691 ]);
2692 assert.isTrue(
2693 Option.isSome(
2694 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),
2695 ),
2696 );
2697 assert.isTrue(
2698 Option.isSome(
2699 yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),
2700 ),
2701 );
2702 assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, {
2703 cancelled: 1,
2704 requeued: 1,
2705 });
2706 const cancelled = yield* outbox.get(
2707 `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`,
2708 );
2709 assert.isTrue(Option.isSome(cancelled));
2710 if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled");
27122711
2713 const reclaimed = yield* outbox.claimNext({
2714 workerId: "recovery-worker",
2715 leaseDurationMs: 30_000,
2716 });
2717 assert.isTrue(Option.isSome(reclaimed));
2718 if (Option.isSome(reclaimed)) {
2719 assert.equal(reclaimed.value.request.type, replayRequest.type);
2720 assert.equal(reclaimed.value.attemptCount, 2);
2721 yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" });
2722 }
2723 }),
2724 );
2725 }
2712 const reclaimed = yield* outbox.claimNext({
2713 workerId: "recovery-worker",
2714 leaseDurationMs: 30_000,
2715 });
2716 assert.isTrue(Option.isSome(reclaimed));
2717 if (Option.isSome(reclaimed)) {
2718 assert.equal(reclaimed.value.request.type, replayRequest.type);
2719 assert.equal(reclaimed.value.attemptCount, 2);
2720 yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" });
2721 }
2722 }),
2723 );
27262724
27272725 it.effect("atomically cancels stale runs and their process-bound effects", () =>
27282726 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",
7878type Event = { readonly sequence: number; readonly text: string; readonly threadId?: string };
7979
8080describe("replayAndBufferLiveEvents", () => {
81 for (const phase of ["high-water", "replay"] as const) {
82 it.effect(`unsubscribes and cancels a blocked ${phase} read on live overflow`, () =>
81 it.effect.each(["high-water", "replay"] as const)(
82 "unsubscribes and cancels a blocked %s read on live overflow",
83 (phase) =>
8384 Effect.scoped(
8485 Effect.gen(function* () {
8586 const pubsub = yield* PubSub.unbounded<Event>();
......@@ -121,11 +122,11 @@ describe("replayAndBufferLiveEvents", () => {
121122 expect(yield* PubSub.size(pubsub)).toBe(0);
122123 }),
123124 ),
124 );
125 }
125 );
126126
127 for (const limit of ["items", "bytes"] as const) {
128 it.effect(`counts an unacknowledged replay batch toward the live ${limit} limit`, () =>
127 it.effect.each(["items", "bytes"] as const)(
128 "counts an unacknowledged replay batch toward the live %s limit",
129 (limit) =>
129130 Effect.scoped(
130131 Effect.gen(function* () {
131132 const pubsub = yield* PubSub.unbounded<Event>();
......@@ -158,8 +159,7 @@ describe("replayAndBufferLiveEvents", () => {
158159 if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError");
159160 }),
160161 ),
161 );
162 }
162 );
163163
164164 it.effect("lets a fast reader replay a database page larger than both buffer limits", () =>
165165 Effect.scoped(
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts+53-55
......@@ -332,61 +332,59 @@ const runScenario = (input: {
332332 });
333333
334334describe("OpenCode 2 through the orchestrator", () => {
335 for (const via of ["message", "thread settings"] as const) {
336 it.effect(
337 `switches the session's model before the next prompt when changed from the ${via}`,
338 () =>
339 Effect.gen(function* () {
340 const name = `opencode2-model-switch-${via.replace(" ", "-")}`;
341 const cwd = yield* checkpointWorkspace(name);
342 const thread = threadCommands({ name, worktreePath: cwd });
343 const projection = yield* runScenario({
344 name,
345 threadId: thread.threadId,
346 entries: [
347 ...createdSession(cwd, name),
348 ...instructionsWritten,
349 ...answeredPrompt("FIRST"),
350 // The next turn resumes the session at its new selection.
351 out("session.get", { sessionID: SESSION }),
352 reply("session.get", sessionInfo(cwd, t3Rules(name))),
353 out("session.switchModel", {
354 sessionID: SESSION,
355 model: { providerID: "opencode", id: "mimo-v2.6-flash-free" },
356 }),
357 reply("session.switchModel", null),
358 // The instructions name the model, so they are written again.
359 ...instructionsWritten,
360 ...answeredPrompt("SECOND"),
361 ],
362 commands: [
363 thread.create,
364 thread.message("first"),
365 ...(via === "thread settings"
366 ? [
367 {
368 type: "thread.model-selection.set",
369 commandId: thread.command("model"),
370 threadId: thread.threadId,
371 modelSelection: mimo,
372 } satisfies OrchestrationV2Command,
373 ]
374 : []),
375 thread.message("second", mimo),
376 ],
377 });
378 assert.deepEqual(
379 projection.runs.map((run) => [run.status, run.modelSelection.model]),
380 [
381 ["completed", bigPickle.model],
382 ["completed", mimo.model],
383 ],
384 );
385 // One native session carried both turns.
386 assert.lengthOf(projection.providerThreads, 1);
387 }).pipe(Effect.scoped),
388 );
389 }
335 it.effect.each(["message", "thread settings"] as const)(
336 "switches the session's model before the next prompt when changed from the %s",
337 (via) =>
338 Effect.gen(function* () {
339 const name = `opencode2-model-switch-${via.replace(" ", "-")}`;
340 const cwd = yield* checkpointWorkspace(name);
341 const thread = threadCommands({ name, worktreePath: cwd });
342 const projection = yield* runScenario({
343 name,
344 threadId: thread.threadId,
345 entries: [
346 ...createdSession(cwd, name),
347 ...instructionsWritten,
348 ...answeredPrompt("FIRST"),
349 // The next turn resumes the session at its new selection.
350 out("session.get", { sessionID: SESSION }),
351 reply("session.get", sessionInfo(cwd, t3Rules(name))),
352 out("session.switchModel", {
353 sessionID: SESSION,
354 model: { providerID: "opencode", id: "mimo-v2.6-flash-free" },
355 }),
356 reply("session.switchModel", null),
357 // The instructions name the model, so they are written again.
358 ...instructionsWritten,
359 ...answeredPrompt("SECOND"),
360 ],
361 commands: [
362 thread.create,
363 thread.message("first"),
364 ...(via === "thread settings"
365 ? [
366 {
367 type: "thread.model-selection.set",
368 commandId: thread.command("model"),
369 threadId: thread.threadId,
370 modelSelection: mimo,
371 } satisfies OrchestrationV2Command,
372 ]
373 : []),
374 thread.message("second", mimo),
375 ],
376 });
377 assert.deepEqual(
378 projection.runs.map((run) => [run.status, run.modelSelection.model]),
379 [
380 ["completed", bigPickle.model],
381 ["completed", mimo.model],
382 ],
383 );
384 // One native session carried both turns.
385 assert.lengthOf(projection.providerThreads, 1);
386 }).pipe(Effect.scoped),
387 );
390388
391389 it.effect("moves the session to the thread's new worktree before the next prompt", () =>
392390 Effect.gen(function* () {
apps/server/src/orchestration-v2/ProjectCommands.test.ts+5-4
......@@ -128,16 +128,17 @@ describe("planProjectCommand", () => {
128128 assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode);
129129 });
130130
131 for (const id of ["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)]) {
132 it(`rejects a new script ID that cannot have a shortcut: ${id}`, () => {
131 it.each(["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)])(
132 "rejects a new script ID that cannot have a shortcut: %s",
133 (id) => {
133134 const failure = failureOf(update({ scripts: [script("lint"), script(id)] }));
134135 assert.equal(failure._tag, "ProjectCommandInvariantError");
135136 assert.include(failure.message, "Script ID");
136137 assert.include(failure.message, "24");
137138 // The detail is persisted in the rejected receipt, so it omits the raw ID.
138139 assert.notInclude(failure.message, `'${id}'`);
139 });
140 }
140 },
141 );
141142
142143 it("accepts a script ID at the shortcut length limit", () => {
143144 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
186186 ];
187187}
188188
189for (const storage of ["sqlite", "memory"] as const) {
190 const storeLayer =
189const storageCases = (["sqlite", "memory"] as const).map((storage) => ({
190 storage,
191 storeLayer:
191192 storage === "sqlite"
192193 ? ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory))
193 : ProjectionStore.layerMemory;
194 it.effect(`${storage}: finds the active root turn without an attempt reverse link`, () =>
194 : ProjectionStore.layerMemory,
195}));
196
197it.effect.each(storageCases)(
198 "$storage: finds the active root turn without an attempt reverse link",
199 ({ storeLayer }) =>
195200 Effect.gen(function* () {
196201 const store = yield* ProjectionStore.ProjectionStoreV2;
197202 const now = yield* DateTime.now;
......@@ -210,8 +215,10 @@ for (const storage of ["sqlite", "memory"] as const) {
210215 });
211216 assert.isUndefined((yield* store.getRunningTurnContext(threadId)).providerTurn);
212217 }).pipe(Effect.provide(storeLayer)),
213 );
214 it.effect(`${storage}: controls and replies read only their exact durable targets`, () =>
218);
219it.effect.each(storageCases)(
220 "$storage: controls and replies read only their exact durable targets",
221 ({ storage, storeLayer }) =>
215222 Effect.gen(function* () {
216223 const store = yield* ProjectionStore.ProjectionStoreV2;
217224 const now = yield* DateTime.now;
......@@ -344,5 +351,4 @@ for (const storage of ["sqlite", "memory"] as const) {
344351 ),
345352 );
346353 }).pipe(Effect.provide(Layer.merge(storeLayer, SqlitePersistenceMemory))),
347 );
348}
354);
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+166-173
......@@ -140,159 +140,157 @@ const createItem = Effect.fn(function* (
140140 });
141141});
142142
143for (const [name, testLayer] of [
143it.effect.each([
144144 ["sql", SqlLayer],
145145 ["memory", ProjectionStore.layerMemory],
146] as const) {
147 it.effect(
148 `${name}: discovers settlement work with the same activity and background semantics as the shell`,
149 () =>
150 Effect.gen(function* () {
151 const store = yield* ProjectionStore.ProjectionStoreV2;
152 const idle = yield* createThread("idle");
153 const completed = yield* createThread("completed");
154 yield* createRun(completed);
155 for (const [name, overrides] of [
156 ["archived", { archivedAt: old }],
157 ["deleted", { deletedAt: old }],
158 ["settled", { settledOverride: "settled" }],
159 ["unsettled", { settledOverride: "active" }],
160 ["pinned", { pinnedAt: old }],
161 ["auto-settle-disabled", { autoSettleDisabledAt: old }],
162 ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) {
163 yield* createRun(yield* createThread(name, overrides));
164 }
165 for (const status of ["preparing", "starting", "running", "waiting"] as const) {
166 yield* createRun(yield* createThread(status), status);
167 }
168 const queued = yield* createThread("queued");
169 yield* createRun(queued, "queued");
170 const blocked = yield* createThread("blocked");
171 yield* store.apply({
172 id: EventId.make("event:settlement:request"),
173 type: "runtime-request.updated",
174 threadId: blocked,
175 occurredAt: old,
176 payload: {
177 id: RuntimeRequestId.make("request:settlement"),
178 nodeId: NodeId.make("node:settlement"),
179 providerTurnId: null,
180 nativeRequestRef: null,
181 kind: "user_input",
182 status: "pending",
183 responseCapability: { type: "not_resumable", reason: "Process stopped" },
184 createdAt: old,
185 resolvedAt: null,
186 },
187 });
188 const recent = yield* createThread("recent-message");
189 yield* createRun(recent);
190 yield* store.apply({
191 id: EventId.make("event:settlement:message"),
192 type: "message.updated",
146] as const)(
147 "%s: discovers settlement work with the same activity and background semantics as the shell",
148 ([, testLayer]) =>
149 Effect.gen(function* () {
150 const store = yield* ProjectionStore.ProjectionStoreV2;
151 const idle = yield* createThread("idle");
152 const completed = yield* createThread("completed");
153 yield* createRun(completed);
154 for (const [name, overrides] of [
155 ["archived", { archivedAt: old }],
156 ["deleted", { deletedAt: old }],
157 ["settled", { settledOverride: "settled" }],
158 ["unsettled", { settledOverride: "active" }],
159 ["pinned", { pinnedAt: old }],
160 ["auto-settle-disabled", { autoSettleDisabledAt: old }],
161 ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) {
162 yield* createRun(yield* createThread(name, overrides));
163 }
164 for (const status of ["preparing", "starting", "running", "waiting"] as const) {
165 yield* createRun(yield* createThread(status), status);
166 }
167 const queued = yield* createThread("queued");
168 yield* createRun(queued, "queued");
169 const blocked = yield* createThread("blocked");
170 yield* store.apply({
171 id: EventId.make("event:settlement:request"),
172 type: "runtime-request.updated",
173 threadId: blocked,
174 occurredAt: old,
175 payload: {
176 id: RuntimeRequestId.make("request:settlement"),
177 nodeId: NodeId.make("node:settlement"),
178 providerTurnId: null,
179 nativeRequestRef: null,
180 kind: "user_input",
181 status: "pending",
182 responseCapability: { type: "not_resumable", reason: "Process stopped" },
183 createdAt: old,
184 resolvedAt: null,
185 },
186 });
187 const recent = yield* createThread("recent-message");
188 yield* createRun(recent);
189 yield* store.apply({
190 id: EventId.make("event:settlement:message"),
191 type: "message.updated",
192 threadId: recent,
193 occurredAt: now,
194 payload: {
195 createdBy: "user",
196 creationSource: "web",
197 id: MessageId.make("message:settlement:recent"),
193198 threadId: recent,
194 occurredAt: now,
195 payload: {
196 createdBy: "user",
197 creationSource: "web",
198 id: MessageId.make("message:settlement:recent"),
199 threadId: recent,
200 runId: null,
201 nodeId: null,
202 role: "user",
203 text: "Next turn",
204 attachments: [],
205 streaming: false,
206 createdAt: now,
207 updatedAt: now,
208 },
209 });
210 yield* createRun(
211 yield* createThread("snoozed", {
212 snoozedUntil: DateTime.add(now, { days: 1 }),
213 snoozedAt: now,
214 }),
215 );
216 const woke = yield* createThread("woke", {
199 runId: null,
200 nodeId: null,
201 role: "user",
202 text: "Next turn",
203 attachments: [],
204 streaming: false,
205 createdAt: now,
206 updatedAt: now,
207 },
208 });
209 yield* createRun(
210 yield* createThread("snoozed", {
217211 snoozedUntil: DateTime.add(now, { days: 1 }),
218 snoozedAt: DateTime.subtract(old, { days: 1 }),
219 });
220 yield* createRun(woke);
221 const background = yield* createThread("idle-background");
222 yield* createItem(background, yield* createRun(background), "idle");
223 const persistent = yield* createThread("persistent-monitor");
224 yield* createItem(persistent, yield* createRun(persistent), "running", true);
225 const rolledBack = yield* createThread("rolled-back-background");
226 yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running");
227 yield* createRun(rolledBack, "completed", 2);
228 const roster = yield* createThread("provider-roster");
229 yield* createRun(roster);
230 yield* store.apply({
231 id: EventId.make("event:settlement:roster"),
232 type: "provider-thread.updated",
233 threadId: roster,
234 occurredAt: old,
235 payload: {
236 id: ProviderThreadId.make("provider-thread:settlement"),
237 appThreadId: roster,
238 ownerNodeId: null,
239 driver: ProviderDriverKind.make("codex"),
240 providerInstanceId,
241 providerSessionId: null,
242 nativeThreadRef: null,
243 nativeConversationHeadRef: null,
244 status: "idle",
245 firstRunOrdinal: null,
246 lastRunOrdinal: null,
247 handoffIds: [],
248 forkedFrom: null,
249 createdAt: old,
250 updatedAt: old,
251 pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }],
252 },
253 });
254 const candidates = yield* store.getSettlementCandidates();
255 const shell = yield* store.getShellSnapshot({ location: "active" });
256 const eligible = candidates.filter((thread) =>
257 isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)),
258 );
259 assert.deepEqual(
260 new Set(eligible.map((thread) => thread.id)),
261 new Set([idle, completed, queued, woke, background, persistent, rolledBack]),
262 );
212 snoozedAt: now,
213 }),
214 );
215 const woke = yield* createThread("woke", {
216 snoozedUntil: DateTime.add(now, { days: 1 }),
217 snoozedAt: DateTime.subtract(old, { days: 1 }),
218 });
219 yield* createRun(woke);
220 const background = yield* createThread("idle-background");
221 yield* createItem(background, yield* createRun(background), "idle");
222 const persistent = yield* createThread("persistent-monitor");
223 yield* createItem(persistent, yield* createRun(persistent), "running", true);
224 const rolledBack = yield* createThread("rolled-back-background");
225 yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running");
226 yield* createRun(rolledBack, "completed", 2);
227 const roster = yield* createThread("provider-roster");
228 yield* createRun(roster);
229 yield* store.apply({
230 id: EventId.make("event:settlement:roster"),
231 type: "provider-thread.updated",
232 threadId: roster,
233 occurredAt: old,
234 payload: {
235 id: ProviderThreadId.make("provider-thread:settlement"),
236 appThreadId: roster,
237 ownerNodeId: null,
238 driver: ProviderDriverKind.make("codex"),
239 providerInstanceId,
240 providerSessionId: null,
241 nativeThreadRef: null,
242 nativeConversationHeadRef: null,
243 status: "idle",
244 firstRunOrdinal: null,
245 lastRunOrdinal: null,
246 handoffIds: [],
247 forkedFrom: null,
248 createdAt: old,
249 updatedAt: old,
250 pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }],
251 },
252 });
253 const candidates = yield* store.getSettlementCandidates();
254 const shell = yield* store.getShellSnapshot({ location: "active" });
255 const eligible = candidates.filter((thread) =>
256 isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)),
257 );
258 assert.deepEqual(
259 new Set(eligible.map((thread) => thread.id)),
260 new Set([idle, completed, queued, woke, background, persistent, rolledBack]),
261 );
262 assert.deepEqual(
263 new Set(eligible.map((thread) => thread.id)),
264 new Set(
265 shell.threads
266 .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)))
267 .map((thread) => thread.id),
268 ),
269 );
270 for (const candidate of candidates) {
271 const expected = shell.threads.find((thread) => thread.id === candidate.id)!;
272 assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks);
273 const settings = {
274 pullRequest: null,
275 nowMs: DateTime.toEpochMillis(now),
276 autoSettleAfterDays: 7,
277 autoSettleOnMerge: false,
278 };
263279 assert.deepEqual(
264 new Set(eligible.map((thread) => thread.id)),
265 new Set(
266 shell.threads
267 .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)))
268 .map((thread) => thread.id),
269 ),
270 );
271 for (const candidate of candidates) {
272 const expected = shell.threads.find((thread) => thread.id === candidate.id)!;
273 assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks);
274 const settings = {
275 pullRequest: null,
276 nowMs: DateTime.toEpochMillis(now),
277 autoSettleAfterDays: 7,
278 autoSettleOnMerge: false,
279 };
280 assert.deepEqual(
281 resolveAutoSettlementAt({ ...settings, thread: candidate }),
282 resolveAutoSettlementAt({ ...settings, thread: expected }),
283 );
284 }
285 assert.equal(
286 DateTime.formatIso((yield* store.getThread(completed)).createdAt),
287 DateTime.formatIso(old),
288 );
289 assert.equal(
290 (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag,
291 "ProjectionStoreThreadNotFoundError",
280 resolveAutoSettlementAt({ ...settings, thread: candidate }),
281 resolveAutoSettlementAt({ ...settings, thread: expected }),
292282 );
293 }).pipe(Effect.provide(testLayer)),
294 );
295}
283 }
284 assert.equal(
285 DateTime.formatIso((yield* store.getThread(completed)).createdAt),
286 DateTime.formatIso(old),
287 );
288 assert.equal(
289 (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag,
290 "ProjectionStoreThreadNotFoundError",
291 );
292 }).pipe(Effect.provide(testLayer)),
293);
296294
297295const pullRequestLink = (number: number) => ({
298296 host: "github.com",
......@@ -305,11 +303,12 @@ const pullRequestLink = (number: number) => ({
305303 stack: null,
306304});
307305
308for (const [name, testLayer] of [
306it.effect.each([
309307 ["sql", SqlLayer],
310308 ["memory", ProjectionStore.layerMemory],
311] as const) {
312 it.effect(`${name}: lists only active threads with pull request links, oldest first`, () =>
309] as const)(
310 "%s: lists only active threads with pull request links, oldest first",
311 ([, testLayer]) =>
313312 Effect.gen(function* () {
314313 const store = yield* ProjectionStore.ProjectionStoreV2;
315314 yield* createThread("no-links");
......@@ -337,33 +336,27 @@ for (const [name, testLayer] of [
337336 ],
338337 );
339338 }).pipe(Effect.provide(testLayer)),
340 );
341}
339);
342340
343for (const [name, testLayer] of [
341it.effect.each([
344342 ["sql", SqlLayer],
345343 ["memory", ProjectionStore.layerMemory],
346] as const) {
347 it.effect(`${name}: an unsettled-only shell read skips settled threads`, () =>
348 Effect.gen(function* () {
349 const store = yield* ProjectionStore.ProjectionStoreV2;
350 const open = yield* createThread("unsettled-open");
351 const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" });
352 yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old });
353 yield* createThread("unsettled-auto", { settledAt: old });
354 yield* createThread("unsettled-archived", { archivedAt: old });
344] as const)("%s: an unsettled-only shell read skips settled threads", ([, testLayer]) =>
345 Effect.gen(function* () {
346 const store = yield* ProjectionStore.ProjectionStoreV2;
347 const open = yield* createThread("unsettled-open");
348 const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" });
349 yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old });
350 yield* createThread("unsettled-auto", { settledAt: old });
351 yield* createThread("unsettled-archived", { archivedAt: old });
355352
356 const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true });
357 assert.deepEqual(
358 new Set(shell.threads.map((thread) => thread.id)),
359 new Set([open, reopened]),
360 );
361 assert.equal(shell.archivedThreads.length, 0);
362 const all = yield* store.getShellSnapshot({ location: "active" });
363 assert.equal(all.threads.length, 4);
364 }).pipe(Effect.provide(testLayer)),
365 );
366}
353 const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true });
354 assert.deepEqual(new Set(shell.threads.map((thread) => thread.id)), new Set([open, reopened]));
355 assert.equal(shell.archivedThreads.length, 0);
356 const all = yield* store.getShellSnapshot({ location: "active" });
357 assert.equal(all.threads.length, 4);
358 }).pipe(Effect.provide(testLayer)),
359);
367360
368361it.effect(
369362 "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) => {
724724 }),
725725 );
726726
727 for (const terminal of ["completed", "interrupted", "failed", "cancelled", "control"] as const) {
728 it.effect(`dismisses only native questions when a provider turn ends with ${terminal}`, () =>
727 it.effect.each(["completed", "interrupted", "failed", "cancelled", "control"] as const)(
728 "dismisses only native questions when a provider turn ends with %s",
729 (terminal) =>
729730 Effect.gen(function* () {
730731 const now = yield* DateTime.now;
731732 const eventSink = yield* EventSink.EventSinkV2;
......@@ -909,8 +910,7 @@ layer("ProviderEventIngestorV2", (it) => {
909910 const repeated = yield* ingestor.ingestNormalized(input);
910911 assert.isFalse(repeated.some((entry) => entry.event.type === "runtime-request.updated"));
911912 }),
912 );
913 }
913 );
914914
915915 it.effect(
916916 "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(
30073007 }),
30083008);
30093009
3010for (const workspaceState of ["missing", "file"] as const) {
3011 it.effect(`rejects a ${workspaceState} workspace before opening a provider session`, () =>
3010it.effect.each(["missing", "file"] as const)(
3011 "rejects a %s workspace before opening a provider session",
3012 (workspaceState) =>
30123013 Effect.gen(function* () {
30133014 const fileSystem = yield* FileSystem.FileSystem;
30143015 const root = yield* fileSystem.makeTempDirectoryScoped();
......@@ -3053,8 +3054,7 @@ for (const workspaceState of ["missing", "file"] as const) {
30533054 );
30543055 }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 })));
30553056 }).pipe(Effect.provide(NodeServices.layer)),
3056 );
3057}
3057);
30583058
30593059it.effect(
30603060 "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(
120120 ),
121121);
122122
123for (const deadStatus of ["stopped", "error"] as const) {
124 it.effect(
125 `restarts and releases the live session when a newer ${deadStatus} session exists`,
126 () =>
127 Effect.gen(function* () {
128 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
129 const thread = projection();
130 const result = yield* service.plan({
131 projection: {
132 ...thread,
133 providerSessions: [
134 ...thread.providerSessions,
135 deadSessionRecord("dead_session", deadStatus),
136 ],
137 },
138 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
139 });
140 assert.equal(result.transition.type, "restart_and_resume");
141 assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]);
142 }).pipe(
143 Effect.provide(
144 testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
145 ),
123it.effect.each(["stopped", "error"] as const)(
124 "restarts and releases the live session when a newer %s session exists",
125 (deadStatus) =>
126 Effect.gen(function* () {
127 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
128 const thread = projection();
129 const result = yield* service.plan({
130 projection: {
131 ...thread,
132 providerSessions: [
133 ...thread.providerSessions,
134 deadSessionRecord("dead_session", deadStatus),
135 ],
136 },
137 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
138 });
139 assert.equal(result.transition.type, "restart_and_resume");
140 assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]);
141 }).pipe(
142 Effect.provide(
143 testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
146144 ),
147 );
148}
145 ),
146);
149147
150148it.effect("releases the newest live session, not the newest record overall", () =>
151149 Effect.gen(function* () {
......@@ -275,32 +273,29 @@ it.effect("hands off from a native provider thread after its session detaches",
275273 ),
276274);
277275
278for (const deadStatus of ["stopped", "error"] as const) {
279 it.effect(
280 `applies a model change on next turn when a ${deadStatus} session negotiated model switching`,
281 () =>
282 Effect.gen(function* () {
283 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
284 const result = yield* service.plan({
285 projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2),
286 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
287 });
288 // Static capabilities report no in-session switch, but the dead
289 // record's negotiated capabilities describe the provider: without
290 // them the ACP classification rejects the selection instead of
291 // reopening with the requested model on the next run.
292 assert.equal(result.transition.type, "switch_model_in_session");
293 assert.deepEqual(result.releaseProviderSessionIds, []);
294 }).pipe(
295 Effect.provide(
296 testLayer(
297 { [currentInstanceId]: { continuationKey: "codex:account:primary" } },
298 (input) => Effect.succeed(acpSelectionTransition(input)),
299 ),
276it.effect.each(["stopped", "error"] as const)(
277 "applies a model change on next turn when a %s session negotiated model switching",
278 (deadStatus) =>
279 Effect.gen(function* () {
280 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
281 const result = yield* service.plan({
282 projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2),
283 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
284 });
285 // Static capabilities report no in-session switch, but the dead
286 // record's negotiated capabilities describe the provider: without
287 // them the ACP classification rejects the selection instead of
288 // reopening with the requested model on the next run.
289 assert.equal(result.transition.type, "switch_model_in_session");
290 assert.deepEqual(result.releaseProviderSessionIds, []);
291 }).pipe(
292 Effect.provide(
293 testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) =>
294 Effect.succeed(acpSelectionTransition(input)),
300295 ),
301296 ),
302 );
303}
297 ),
298);
304299
305300it.effect("rejects a model change the dead record never negotiated support for", () =>
306301 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", () =>
259259 }),
260260);
261261
262for (const [enabled, projectOverride] of [
262it.effect.each([
263263 [false, undefined],
264264 [true, undefined],
265265 [false, true],
266266 [true, false],
267] as const) {
268 it.effect(
269 `atomically records restart intent with cancellation when opt-in is ${enabled} and project override is ${projectOverride}`,
270 () =>
271 Effect.gen(function* () {
272 let committed: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | undefined;
273 const recovery = yield* ProviderRuntimeRecovery.make.pipe(
274 Effect.provide(
275 Layer.mergeAll(
276 ServerSettings.layerTest({
277 continueThreadsAfterServerUpdate: enabled,
278 projectSettingsOverrides:
279 projectOverride === undefined
280 ? {}
281 : {
282 [ProjectId.make("restart-project")]: {
283 continueThreadsAfterServerUpdate: projectOverride,
284 },
267] as const)(
268 "atomically records restart intent with cancellation when opt-in is %s and project override is %s",
269 ([enabled, projectOverride]) =>
270 Effect.gen(function* () {
271 let committed: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | undefined;
272 const recovery = yield* ProviderRuntimeRecovery.make.pipe(
273 Effect.provide(
274 Layer.mergeAll(
275 ServerSettings.layerTest({
276 continueThreadsAfterServerUpdate: enabled,
277 projectSettingsOverrides:
278 projectOverride === undefined
279 ? {}
280 : {
281 [ProjectId.make("restart-project")]: {
282 continueThreadsAfterServerUpdate: projectOverride,
285283 },
286 }),
287 Layer.mock(ProjectionStore.ProjectionStoreV2)({
288 getRecoveryThreadIds: () => Effect.succeed([threadId]),
289 getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()),
290 }),
291 Layer.mock(EventSink.EventSinkV2)({
292 commitCommand: (input) => {
293 committed = input;
294 return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never);
295 },
296 }),
297 IdAllocator.layer,
298 Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({
299 runRecoveryOnce: Effect.succeed(false),
300 }),
301 Layer.mock(EffectOutbox.EffectOutboxV2)({
302 reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }),
303 }),
304 ),
305 ),
306 );
307 yield* recovery.reconcile("startup");
308 assert.isDefined(committed);
309 assert.isTrue(
310 committed!.events.some(
311 (event) => event.type === "run.updated" && event.payload.status === "cancelled",
284 },
285 }),
286 Layer.mock(ProjectionStore.ProjectionStoreV2)({
287 getRecoveryThreadIds: () => Effect.succeed([threadId]),
288 getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()),
289 }),
290 Layer.mock(EventSink.EventSinkV2)({
291 commitCommand: (input) => {
292 committed = input;
293 return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never);
294 },
295 }),
296 IdAllocator.layer,
297 Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({
298 runRecoveryOnce: Effect.succeed(false),
299 }),
300 Layer.mock(EffectOutbox.EffectOutboxV2)({
301 reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }),
302 }),
312303 ),
313 );
314 assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0);
315 if (projectOverride ?? enabled)
316 assert.deepEqual(committed!.effects[0]?.request, {
317 type: "provider-runtime.continue",
318 sourceRunId: runId,
319 });
320 }),
321 );
322}
304 ),
305 );
306 yield* recovery.reconcile("startup");
307 assert.isDefined(committed);
308 assert.isTrue(
309 committed!.events.some(
310 (event) => event.type === "run.updated" && event.payload.status === "cancelled",
311 ),
312 );
313 assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0);
314 if (projectOverride ?? enabled)
315 assert.deepEqual(committed!.effects[0]?.request, {
316 type: "provider-runtime.continue",
317 sourceRunId: runId,
318 });
319 }),
320);
323321
324322it.effect("does not duplicate delivery and yields to newer user work or opt-out", () =>
325323 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", () =>
862862 }),
863863);
864864
865for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard"] as const) {
866 it.effect(`handles ${scenario} before the provider turn starts`, () =>
865it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)(
866 "handles %s before the provider turn starts",
867 (scenario) =>
867868 Effect.gen(function* () {
868869 const threadId = ThreadId.make("thread:run-execution-settings-failure");
869870 const runId = RunId.make("run:run-execution-settings-failure");
......@@ -1030,8 +1031,7 @@ for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard
10301031 assert.equal(errorItem.payload.failure.message, "Run preparation failed.");
10311032 }
10321033 }),
1033 );
1034}
1034);
10351035
10361036it.effect("keeps ingesting owned child events after the root turn terminalizes", () =>
10371037 Effect.gen(function* () {
......@@ -3149,8 +3149,9 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp
31493149 }),
31503150);
31513151
3152for (const status of ["completed", "interrupted", "cancelled", "failed"] as const) {
3153 it.effect(`refreshes pull requests after the current root run ${status}`, () =>
3152it.effect.each(["completed", "interrupted", "cancelled", "failed"] as const)(
3153 "refreshes pull requests after the current root run %s",
3154 (status) =>
31543155 Effect.gen(function* () {
31553156 const { observed } = yield* captureRootRunTermination({
31563157 key: `pull-request-refresh:${status}`,
......@@ -3162,8 +3163,7 @@ for (const status of ["completed", "interrupted", "cancelled", "failed"] as cons
31623163 "pull-requests-refreshed",
31633164 ]);
31643165 }),
3165 );
3166}
3166);
31673167
31683168it.effect("does not refresh pull requests for auxiliary or stale provider terminals", () =>
31693169 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"
5050 }).pipe(Effect.provide(layer));
5151});
5252
53for (const scenario of [
54 {
55 label: "discovers a new PR for the completed run's branch",
56 branch: "feature",
57 checkedOut: "feature",
58 activeRun: null,
59 expected: ["/repo"],
60 },
61 {
62 label: "leaves the default branch's PR cache alone",
63 branch: "main",
64 checkedOut: "main",
65 activeRun: null,
66 expected: [],
67 },
68 {
69 label: "does not refresh another thread's checkout",
70 branch: "feature",
71 checkedOut: "other",
72 activeRun: null,
73 expected: [],
74 },
75 {
76 label: "does not refresh during a newer active run",
77 branch: "feature",
78 checkedOut: "feature",
79 activeRun: "newer-run",
80 expected: [],
81 },
82] as const) {
83 it.effect(scenario.label, () => {
84 const refreshed: string[] = [];
85 const threadId = ThreadId.make("thread-pr-refresh");
86 const runId = RunId.make("completed-run");
87 const layer = RunFinalization.observerLive.pipe(
88 Layer.provide(
89 Layer.mergeAll(
90 Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
91 Layer.mock(PullRequestService.PullRequestService)({
92 refreshAfterTurn: () => Effect.void,
93 }),
94 Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
95 refreshLocalStatus: () =>
96 Effect.succeed({
97 isRepo: true,
98 hasPrimaryRemote: true,
99 isDefaultRef: scenario.checkedOut === "main",
100 refName: scenario.checkedOut,
101 hasWorkingTreeChanges: false,
102 workingTree: { files: [], insertions: 0, deletions: 0 },
103 }),
104 refreshStatus: () =>
105 Effect.die("turn completion must preserve known PRs and lookup backoff"),
106 refreshPullRequestStatus: (cwd) =>
107 Effect.sync(() => {
108 refreshed.push(cwd);
109 return null;
110 }),
111 }),
112 Layer.mock(ProjectionStore.ProjectionStoreV2)({
113 getThreadShell: () =>
114 Effect.succeed({
115 id: threadId,
116 branch: scenario.branch,
117 activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun),
118 } as OrchestrationV2ThreadShell),
119 }),
120 ),
53it.effect.each(
54 (
55 [
56 {
57 label: "discovers a new PR for the completed run's branch",
58 branch: "feature",
59 checkedOut: "feature",
60 activeRun: null,
61 expected: ["/repo"],
62 },
63 {
64 label: "leaves the default branch's PR cache alone",
65 branch: "main",
66 checkedOut: "main",
67 activeRun: null,
68 expected: [],
69 },
70 {
71 label: "does not refresh another thread's checkout",
72 branch: "feature",
73 checkedOut: "other",
74 activeRun: null,
75 expected: [],
76 },
77 {
78 label: "does not refresh during a newer active run",
79 branch: "feature",
80 checkedOut: "feature",
81 activeRun: "newer-run",
82 expected: [],
83 },
84 ] as const
85 ).map((scenario) => [scenario.label, scenario] as const),
86)("%s", ([, scenario]) => {
87 const refreshed: string[] = [];
88 const threadId = ThreadId.make("thread-pr-refresh");
89 const runId = RunId.make("completed-run");
90 const layer = RunFinalization.observerLive.pipe(
91 Layer.provide(
92 Layer.mergeAll(
93 Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
94 Layer.mock(PullRequestService.PullRequestService)({
95 refreshAfterTurn: () => Effect.void,
96 }),
97 Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
98 refreshLocalStatus: () =>
99 Effect.succeed({
100 isRepo: true,
101 hasPrimaryRemote: true,
102 isDefaultRef: scenario.checkedOut === "main",
103 refName: scenario.checkedOut,
104 hasWorkingTreeChanges: false,
105 workingTree: { files: [], insertions: 0, deletions: 0 },
106 }),
107 refreshStatus: () =>
108 Effect.die("turn completion must preserve known PRs and lookup backoff"),
109 refreshPullRequestStatus: (cwd) =>
110 Effect.sync(() => {
111 refreshed.push(cwd);
112 return null;
113 }),
114 }),
115 Layer.mock(ProjectionStore.ProjectionStoreV2)({
116 getThreadShell: () =>
117 Effect.succeed({
118 id: threadId,
119 branch: scenario.branch,
120 activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun),
121 } as OrchestrationV2ThreadShell),
122 }),
121123 ),
122 );
123 return Effect.gen(function* () {
124 const observer = yield* RunFinalization.RunFinalizationObserver;
125 yield* observer.refresh({ cwd: "/repo", threadId, runId });
126 assert.deepEqual(refreshed, [...scenario.expected]);
127 }).pipe(Effect.provide(layer));
128 });
129}
124 ),
125 );
126 return Effect.gen(function* () {
127 const observer = yield* RunFinalization.RunFinalizationObserver;
128 yield* observer.refresh({ cwd: "/repo", threadId, runId });
129 assert.deepEqual(refreshed, [...scenario.expected]);
130 }).pipe(Effect.provide(layer));
131});
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+164-166
......@@ -537,176 +537,174 @@ it.live("restarts selection as a new attempt and retries after old-session clean
537537 ),
538538);
539539
540for (const deadStatus of ["stopped", "error"] as const) {
541 it.live(
542 `restarts the live session on a model change when a newer ${deadStatus} session record exists`,
543 () =>
544 Effect.scoped(
545 Effect.gen(function* () {
546 const name = `selection-restart-dead-${deadStatus}`;
547 const cwd = yield* checkpointWorkspace(name);
548 const threadId = ThreadId.make(`thread:${name}`);
549 const state = yield* Ref.make<RestartAdapterState>({
550 activeTurn: null,
551 opened: [],
552 started: [],
553 closedSessionCount: 0,
554 // The dead record is seeded directly, so the adapter's one-shot
555 // simulated replacement-open failure is skipped.
556 failedReplacementOpen: true,
557 });
558 const registry = ProviderAdapterRegistry.makeSingleLayer(
559 makeRestartAdapter(state, exclusiveCapabilities),
560 );
540it.live.each(["stopped", "error"] as const)(
541 "restarts the live session on a model change when a newer %s session record exists",
542 (deadStatus) =>
543 Effect.scoped(
544 Effect.gen(function* () {
545 const name = `selection-restart-dead-${deadStatus}`;
546 const cwd = yield* checkpointWorkspace(name);
547 const threadId = ThreadId.make(`thread:${name}`);
548 const state = yield* Ref.make<RestartAdapterState>({
549 activeTurn: null,
550 opened: [],
551 started: [],
552 closedSessionCount: 0,
553 // The dead record is seeded directly, so the adapter's one-shot
554 // simulated replacement-open failure is skipped.
555 failedReplacementOpen: true,
556 });
557 const registry = ProviderAdapterRegistry.makeSingleLayer(
558 makeRestartAdapter(state, exclusiveCapabilities),
559 );
561560
562 const result = yield* Effect.gen(function* () {
563 const orchestrator = yield* Orchestrator.OrchestratorV2;
564 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
565 const eventSink = yield* EventSink.EventSinkV2;
566 const dispatch = (step: string, modelSelection: ModelSelection) =>
567 Effect.gen(function* () {
568 const terminal = yield* orchestrator.streamDomainEvents.pipe(
569 Stream.filter(
570 (event) => event.type === "run.updated" && event.payload.status === "completed",
571 ),
572 Stream.take(1),
573 Stream.runDrain,
574 Effect.forkScoped,
575 );
576 yield* orchestrator.dispatch({
577 type: "message.dispatch",
578 createdBy: "user",
579 creationSource: "web",
580 commandId: CommandId.make(`${name}:${step}`),
581 threadId,
582 messageId: MessageId.make(`${name}:${step}`),
583 text: step,
584 attachments: [],
585 modelSelection,
586 dispatchMode: { type: "start_immediately" },
587 });
588 yield* worker.drain();
589 yield* Fiber.join(terminal);
590 yield* worker.drain();
591 return yield* orchestrator.getThreadProjection(threadId);
561 const result = yield* Effect.gen(function* () {
562 const orchestrator = yield* Orchestrator.OrchestratorV2;
563 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
564 const eventSink = yield* EventSink.EventSinkV2;
565 const dispatch = (step: string, modelSelection: ModelSelection) =>
566 Effect.gen(function* () {
567 const terminal = yield* orchestrator.streamDomainEvents.pipe(
568 Stream.filter(
569 (event) => event.type === "run.updated" && event.payload.status === "completed",
570 ),
571 Stream.take(1),
572 Stream.runDrain,
573 Effect.forkScoped,
574 );
575 yield* orchestrator.dispatch({
576 type: "message.dispatch",
577 createdBy: "user",
578 creationSource: "web",
579 commandId: CommandId.make(`${name}:${step}`),
580 threadId,
581 messageId: MessageId.make(`${name}:${step}`),
582 text: step,
583 attachments: [],
584 modelSelection,
585 dispatchMode: { type: "start_immediately" },
592586 });
593 yield* orchestrator.dispatch({
594 type: "thread.create",
595 createdBy: "user",
596 creationSource: "web",
597 commandId: CommandId.make(`${name}:create`),
598 threadId,
599 projectId: ProjectId.make(`project:${name}`),
600 title: name,
601 modelSelection: seedSelection,
602 runtimeMode: "full-access",
603 interactionMode: "default",
604 branch: null,
605 worktreePath: cwd,
587 yield* worker.drain();
588 yield* Fiber.join(terminal);
589 yield* worker.drain();
590 return yield* orchestrator.getThreadProjection(threadId);
606591 });
607 const first = yield* dispatch("first", seedSelection);
608 const liveSession = first.providerSessions.find(
609 (session) => session.status !== "stopped" && session.status !== "error",
610 );
611 assert.isDefined(liveSession);
592 yield* orchestrator.dispatch({
593 type: "thread.create",
594 createdBy: "user",
595 creationSource: "web",
596 commandId: CommandId.make(`${name}:create`),
597 threadId,
598 projectId: ProjectId.make(`project:${name}`),
599 title: name,
600 modelSelection: seedSelection,
601 runtimeMode: "full-access",
602 interactionMode: "default",
603 branch: null,
604 worktreePath: cwd,
605 });
606 const first = yield* dispatch("first", seedSelection);
607 const liveSession = first.providerSessions.find(
608 (session) => session.status !== "stopped" && session.status !== "error",
609 );
610 assert.isDefined(liveSession);
612611
613 // Dead session records stay bound in the projection until
614 // detachment. A stale stopped/error record written after the
615 // live session attached must not hide it.
616 const deadAt = yield* DateTime.now;
617 const deadSession: OrchestrationV2ProviderSession = {
618 id: ProviderSessionId.make(`session:${name}:dead`),
619 driver,
620 providerInstanceId,
621 status: "ready",
622 cwd,
623 model: seedSelection.model,
624 capabilities: exclusiveCapabilities,
625 createdAt: deadAt,
626 updatedAt: deadAt,
627 lastError: null,
628 };
629 yield* eventSink.write({
630 events: [
631 {
632 id: EventId.make(`event:${name}:dead-attached`),
633 type: "provider-session.attached",
634 threadId,
635 driver,
636 providerInstanceId,
637 occurredAt: deadAt,
638 payload: deadSession,
639 },
640 {
641 id: EventId.make(`event:${name}:dead-updated`),
642 type: "provider-session.updated",
643 threadId,
644 driver,
645 providerInstanceId,
646 occurredAt: deadAt,
647 payload: {
648 ...deadSession,
649 status: deadStatus,
650 updatedAt: deadAt,
651 lastError: deadStatus === "error" ? "Simulated session failure." : null,
652 },
612 // Dead session records stay bound in the projection until
613 // detachment. A stale stopped/error record written after the
614 // live session attached must not hide it.
615 const deadAt = yield* DateTime.now;
616 const deadSession: OrchestrationV2ProviderSession = {
617 id: ProviderSessionId.make(`session:${name}:dead`),
618 driver,
619 providerInstanceId,
620 status: "ready",
621 cwd,
622 model: seedSelection.model,
623 capabilities: exclusiveCapabilities,
624 createdAt: deadAt,
625 updatedAt: deadAt,
626 lastError: null,
627 };
628 yield* eventSink.write({
629 events: [
630 {
631 id: EventId.make(`event:${name}:dead-attached`),
632 type: "provider-session.attached",
633 threadId,
634 driver,
635 providerInstanceId,
636 occurredAt: deadAt,
637 payload: deadSession,
638 },
639 {
640 id: EventId.make(`event:${name}:dead-updated`),
641 type: "provider-session.updated",
642 threadId,
643 driver,
644 providerInstanceId,
645 occurredAt: deadAt,
646 payload: {
647 ...deadSession,
648 status: deadStatus,
649 updatedAt: deadAt,
650 lastError: deadStatus === "error" ? "Simulated session failure." : null,
653651 },
654 ],
655 });
652 },
653 ],
654 });
656655
657 const switchCommandId = CommandId.make(`${name}:switch`);
658 yield* orchestrator.dispatch({
659 type: "thread.model-selection.set",
660 commandId: switchCommandId,
661 threadId,
662 modelSelection: replacementSelection,
663 });
664 yield* worker.drain();
665 const storedSwitchEvents = yield* eventSink
666 .readByCommandId({ commandId: switchCommandId })
667 .pipe(Stream.runCollect);
668 const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) =>
669 stored.event.type === "provider-session.detached"
670 ? [stored.event.payload.providerSessionId]
671 : [],
672 );
656 const switchCommandId = CommandId.make(`${name}:switch`);
657 yield* orchestrator.dispatch({
658 type: "thread.model-selection.set",
659 commandId: switchCommandId,
660 threadId,
661 modelSelection: replacementSelection,
662 });
663 yield* worker.drain();
664 const storedSwitchEvents = yield* eventSink
665 .readByCommandId({ commandId: switchCommandId })
666 .pipe(Stream.runCollect);
667 const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) =>
668 stored.event.type === "provider-session.detached"
669 ? [stored.event.payload.providerSessionId]
670 : [],
671 );
673672
674 const second = yield* dispatch("second", replacementSelection);
675 return {
676 projection: second,
677 captured: yield* Ref.get(state),
678 liveSessionId: liveSession.id,
679 detachedSessionIds,
680 };
681 }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry)));
673 const second = yield* dispatch("second", replacementSelection);
674 return {
675 projection: second,
676 captured: yield* Ref.get(state),
677 liveSessionId: liveSession.id,
678 detachedSessionIds,
679 };
680 }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry)));
682681
683 const { projection, captured } = result;
684 assert.lengthOf(projection.runs, 2);
685 assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model);
686 // The exact released session is the older live one, never the newer
687 // dead record.
688 assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]);
689 assert.equal(captured.closedSessionCount, 1);
690 assert.deepEqual(
691 captured.opened.map((open) => open.model),
692 [seedSelection.model, replacementSelection.model],
693 );
694 assert.deepEqual(
695 captured.started.map((turn) => turn.model),
696 [seedSelection.model, replacementSelection.model],
697 );
698 const servingSession = projection.providerSessions.find(
699 (session) =>
700 session.id ===
701 projection.providerThreads.find(
702 (providerThread) => providerThread.id === projection.thread.activeProviderThreadId,
703 )?.providerSessionId,
704 );
705 assert.equal(servingSession?.model, replacementSelection.model);
706 }),
707 ),
708 );
709}
682 const { projection, captured } = result;
683 assert.lengthOf(projection.runs, 2);
684 assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model);
685 // The exact released session is the older live one, never the newer
686 // dead record.
687 assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]);
688 assert.equal(captured.closedSessionCount, 1);
689 assert.deepEqual(
690 captured.opened.map((open) => open.model),
691 [seedSelection.model, replacementSelection.model],
692 );
693 assert.deepEqual(
694 captured.started.map((turn) => turn.model),
695 [seedSelection.model, replacementSelection.model],
696 );
697 const servingSession = projection.providerSessions.find(
698 (session) =>
699 session.id ===
700 projection.providerThreads.find(
701 (providerThread) => providerThread.id === projection.thread.activeProviderThreadId,
702 )?.providerSessionId,
703 );
704 assert.equal(servingSession?.model, replacementSelection.model);
705 }),
706 ),
707);
710708
711709it.live("detaches the old provider session after an active provider handoff", () =>
712710 Effect.scoped(
......@@ -822,8 +820,9 @@ it.live("detaches the old provider session after an active provider handoff", ()
822820 ),
823821);
824822
825for (const mode of ["active", "idle", "selection-command", "pooled", "separate-home"] as const) {
826 it.live(`preserves native history only for compatible account switches (${mode})`, () =>
823it.live.each(["active", "idle", "selection-command", "pooled", "separate-home"] as const)(
824 "preserves native history only for compatible account switches (%s)",
825 (mode) =>
827826 Effect.scoped(
828827 Effect.gen(function* () {
829828 const name = `shared-home-${mode}`;
......@@ -994,5 +993,4 @@ for (const mode of ["active", "idle", "selection-command", "pooled", "separate-h
994993 }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry)));
995994 }),
996995 ),
997 );
998}
996);
apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts+354-352
......@@ -37,371 +37,373 @@ const driver = ProviderDriverKind.make("codex");
3737const instanceId = ProviderInstanceId.make("codex");
3838const modelSelection = { instanceId, model: "test-model" };
3939
40for (const mailbox of [false, true]) {
41 for (const timing of [
42 "before delivery",
43 "during delivery",
44 "before dispatch",
45 "after delivery",
46 "without native steering",
47 "settled only",
48 ] as const) {
49 if (!mailbox && (timing === "without native steering" || timing === "settled only")) continue;
50 it.effect(
51 `delivers ${mailbox ? "mailbox notification" : "steering"} when completion wins ${timing}`,
52 () =>
53 Effect.scoped(
40it.effect.each(
41 [false, true]
42 .flatMap((mailbox) =>
43 (
44 [
45 "before delivery",
46 "during delivery",
47 "before dispatch",
48 "after delivery",
49 "without native steering",
50 "settled only",
51 ] as const
52 ).map((timing) => ({
53 mailbox,
54 timing,
55 label: mailbox ? "mailbox notification" : "steering",
56 })),
57 )
58 .filter(
59 ({ mailbox, timing }) =>
60 mailbox || (timing !== "without native steering" && timing !== "settled only"),
61 ),
62)("delivers $label when completion wins $timing", ({ mailbox, timing }) =>
63 Effect.scoped(
64 Effect.gen(function* () {
65 const cwd = yield* checkpointWorkspace(`steering-completion-${timing.replaceAll(" ", "-")}`);
66 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
67 const started: ProviderAdapterV2TurnInput[] = [];
68 const steerEntered = yield* Deferred.make<void>();
69 const rejectSteer = yield* Deferred.make<void>();
70 let steerCalls = 0;
71 const capabilities = {
72 ...CodexProviderCapabilitiesV2,
73 turns: {
74 ...CodexProviderCapabilitiesV2.turns,
75 supportsActiveSteering: timing !== "without native steering",
76 },
77 };
78 const adapter: ProviderAdapterV2Shape = {
79 instanceId,
80 driver,
81 getCapabilities: () => Effect.succeed(capabilities),
82 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),
83 openSession: (input) =>
5484 Effect.gen(function* () {
55 const cwd = yield* checkpointWorkspace(
56 `steering-completion-${timing.replaceAll(" ", "-")}`,
57 );
58 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
59 const started: ProviderAdapterV2TurnInput[] = [];
60 const steerEntered = yield* Deferred.make<void>();
61 const rejectSteer = yield* Deferred.make<void>();
62 let steerCalls = 0;
63 const capabilities = {
64 ...CodexProviderCapabilitiesV2,
65 turns: {
66 ...CodexProviderCapabilitiesV2.turns,
67 supportsActiveSteering: timing !== "without native steering",
68 },
69 };
70 const adapter: ProviderAdapterV2Shape = {
85 const now = yield* DateTime.now;
86 return {
7187 instanceId,
7288 driver,
73 getCapabilities: () => Effect.succeed(capabilities),
74 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),
75 openSession: (input) =>
89 providerSessionId: input.providerSessionId,
90 providerSession: {
91 id: input.providerSessionId,
92 driver,
93 providerInstanceId: instanceId,
94 status: "ready",
95 cwd,
96 model: modelSelection.model,
97 capabilities,
98 createdAt: now,
99 updatedAt: now,
100 lastError: null,
101 },
102 events: Stream.fromQueue(events),
103 ensureThread: ({ threadId }) =>
104 Effect.succeed({
105 id: ProviderThreadId.make(`provider-thread:${threadId}`),
106 driver,
107 providerInstanceId: instanceId,
108 providerSessionId: input.providerSessionId,
109 appThreadId: threadId,
110 ownerNodeId: null,
111 nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" },
112 nativeConversationHeadRef: null,
113 status: "idle",
114 firstRunOrdinal: null,
115 lastRunOrdinal: null,
116 handoffIds: [],
117 forkedFrom: null,
118 createdAt: now,
119 updatedAt: now,
120 }),
121 resumeThread: ({ providerThread }) => Effect.succeed(providerThread),
122 startTurn: (turn) =>
76123 Effect.gen(function* () {
77 const now = yield* DateTime.now;
78 return {
79 instanceId,
124 started.push(turn);
125 yield* Queue.offer(events, {
126 type: "provider_turn.updated",
80127 driver,
81 providerSessionId: input.providerSessionId,
82 providerSession: {
83 id: input.providerSessionId,
84 driver,
85 providerInstanceId: instanceId,
86 status: "ready",
87 cwd,
88 model: modelSelection.model,
89 capabilities,
90 createdAt: now,
91 updatedAt: now,
92 lastError: null,
93 },
94 events: Stream.fromQueue(events),
95 ensureThread: ({ threadId }) =>
96 Effect.succeed({
97 id: ProviderThreadId.make(`provider-thread:${threadId}`),
128 providerTurn: {
129 id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`),
130 providerThreadId: turn.providerThread.id,
131 nodeId: turn.rootNodeId,
132 runAttemptId: turn.attemptId,
133 nativeTurnRef: {
98134 driver,
99 providerInstanceId: instanceId,
100 providerSessionId: input.providerSessionId,
101 appThreadId: threadId,
102 ownerNodeId: null,
103 nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" },
104 nativeConversationHeadRef: null,
105 status: "idle",
106 firstRunOrdinal: null,
107 lastRunOrdinal: null,
108 handoffIds: [],
109 forkedFrom: null,
110 createdAt: now,
111 updatedAt: now,
112 }),
113 resumeThread: ({ providerThread }) => Effect.succeed(providerThread),
114 startTurn: (turn) =>
115 Effect.gen(function* () {
116 started.push(turn);
117 yield* Queue.offer(events, {
118 type: "provider_turn.updated",
119 driver,
120 providerTurn: {
121 id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`),
122 providerThreadId: turn.providerThread.id,
123 nodeId: turn.rootNodeId,
124 runAttemptId: turn.attemptId,
125 nativeTurnRef: {
126 driver,
127 nativeId: `native:${turn.attemptId}`,
128 strength: "strong",
129 },
130 ordinal: turn.providerTurnOrdinal,
131 status: "running",
132 startedAt: now,
133 completedAt: null,
134 },
135 });
136 }),
137 steerTurn: (turn) =>
138 Effect.gen(function* () {
139 steerCalls += 1;
140 if (timing === "after delivery") return;
141 yield* Deferred.succeed(steerEntered, undefined);
142 yield* Deferred.await(rejectSteer);
143 return yield* new ProviderAdapterSteerRunError({
144 driver,
145 providerThreadId: turn.providerThread.id,
146 providerTurnId: turn.providerTurnId,
147 cause: "turn already completed",
148 });
149 }),
150 interruptTurn: () => Effect.void,
151 respondToRuntimeRequest: () => Effect.void,
152 readThreadSnapshot: () => Effect.die("unused"),
153 rollbackThread: () => Effect.die("unused"),
154 forkThread: () => Effect.die("unused"),
155 };
156 }),
157 };
158 yield* Effect.gen(function* () {
159 const orchestrator = yield* Orchestrator.OrchestratorV2;
160 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
161 const threadId = ThreadId.make("thread:steering-completion");
162 const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) =>
163 orchestrator.streamDomainEvents.pipe(
164 Stream.filter(predicate),
165 Stream.take(1),
166 Stream.runDrain,
167 Effect.forkScoped,
168 );
169 yield* orchestrator.dispatch({
170 type: "thread.create",
171 commandId: CommandId.make("create"),
172 threadId,
173 projectId: ProjectId.make("project:steering-completion"),
174 title: "Steering race",
175 modelSelection,
176 runtimeMode: "full-access",
177 interactionMode: "default",
178 branch: null,
179 worktreePath: cwd,
180 createdBy: "user",
181 creationSource: "web",
182 });
183 yield* orchestrator.dispatch({
184 type: "message.dispatch",
185 commandId: CommandId.make("first"),
186 threadId,
187 messageId: MessageId.make("message:first"),
188 text: "first",
189 attachments: [],
190 dispatchMode: { type: "start_immediately" },
191 createdBy: "user",
192 creationSource: "web",
193 });
194 const running = yield* watch(
195 (event) =>
196 event.type === "provider-turn.updated" && event.payload.status === "running",
197 );
198 yield* worker.drain();
199 yield* Fiber.join(running);
200 const first = started[0]!;
201 const messageId = MessageId.make("message:steering");
202 const taskId = NodeId.make("task:mailbox");
203 if (mailbox) {
204 const sink = yield* EventSink.EventSinkV2;
205 const current = yield* orchestrator.getThreadProjection(threadId);
206 const parentRun = current.runs.find((run) => run.id === first.runId)!;
207 const now = yield* DateTime.now;
208 yield* sink.write({
209 events: [
210 {
211 id: EventId.make("mailbox:cohort"),
212 type: "run.updated",
213 threadId,
214 runId: first.runId,
215 occurredAt: now,
216 payload: {
217 ...parentRun,
218 delegatedCompletion: {
219 disposition: "open",
220 nextGeneration: 2,
221 delivery: { generation: 1, messageId, taskIds: [taskId] },
222 },
135 nativeId: `native:${turn.attemptId}`,
136 strength: "strong",
223137 },
138 ordinal: turn.providerTurnOrdinal,
139 status: "running",
140 startedAt: now,
141 completedAt: null,
224142 },
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,
343143 });
344 yield* worker.drain();
345 assert.equal(steerCalls, 1);
346 yield* orchestrator.dispatch({
347 type: "delegated_task.completion-delivery.acknowledge",
348 commandId: CommandId.make("read-result"),
349 parentThreadId: threadId,
350 taskId,
351 observedByRunId: first.runId,
144 }),
145 steerTurn: (turn) =>
146 Effect.gen(function* () {
147 steerCalls += 1;
148 if (timing === "after delivery") return;
149 yield* Deferred.succeed(steerEntered, undefined);
150 yield* Deferred.await(rejectSteer);
151 return yield* new ProviderAdapterSteerRunError({
152 driver,
153 providerThreadId: turn.providerThread.id,
154 providerTurnId: turn.providerTurnId,
155 cause: "turn already completed",
352156 });
353 const acknowledged = yield* orchestrator.getThreadProjection(threadId);
354 assert.equal(
355 acknowledged.subagents[0]?.completionDelivery?.state,
356 "acknowledged",
357 );
358 }
359 return;
360 }
361 assert.equal(started.length, 2);
362 assert.equal(started[1]?.message.messageId, messageId);
363 if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId));
364 else assert.equal(started[1]?.message.text, "fix the popover");
365 assert.deepEqual(started[1]?.message.attachments, [
366 {
367 type: "image",
368 id: "steering-screenshot",
369 name: "image.png",
370 mimeType: "image/png",
371 sizeBytes: 10,
157 }),
158 interruptTurn: () => Effect.void,
159 respondToRuntimeRequest: () => Effect.void,
160 readThreadSnapshot: () => Effect.die("unused"),
161 rollbackThread: () => Effect.die("unused"),
162 forkThread: () => Effect.die("unused"),
163 };
164 }),
165 };
166 yield* Effect.gen(function* () {
167 const orchestrator = yield* Orchestrator.OrchestratorV2;
168 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
169 const threadId = ThreadId.make("thread:steering-completion");
170 const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) =>
171 orchestrator.streamDomainEvents.pipe(
172 Stream.filter(predicate),
173 Stream.take(1),
174 Stream.runDrain,
175 Effect.forkScoped,
176 );
177 yield* orchestrator.dispatch({
178 type: "thread.create",
179 commandId: CommandId.make("create"),
180 threadId,
181 projectId: ProjectId.make("project:steering-completion"),
182 title: "Steering race",
183 modelSelection,
184 runtimeMode: "full-access",
185 interactionMode: "default",
186 branch: null,
187 worktreePath: cwd,
188 createdBy: "user",
189 creationSource: "web",
190 });
191 yield* orchestrator.dispatch({
192 type: "message.dispatch",
193 commandId: CommandId.make("first"),
194 threadId,
195 messageId: MessageId.make("message:first"),
196 text: "first",
197 attachments: [],
198 dispatchMode: { type: "start_immediately" },
199 createdBy: "user",
200 creationSource: "web",
201 });
202 const running = yield* watch(
203 (event) => event.type === "provider-turn.updated" && event.payload.status === "running",
204 );
205 yield* worker.drain();
206 yield* Fiber.join(running);
207 const first = started[0]!;
208 const messageId = MessageId.make("message:steering");
209 const taskId = NodeId.make("task:mailbox");
210 if (mailbox) {
211 const sink = yield* EventSink.EventSinkV2;
212 const current = yield* orchestrator.getThreadProjection(threadId);
213 const parentRun = current.runs.find((run) => run.id === first.runId)!;
214 const now = yield* DateTime.now;
215 yield* sink.write({
216 events: [
217 {
218 id: EventId.make("mailbox:cohort"),
219 type: "run.updated",
220 threadId,
221 runId: first.runId,
222 occurredAt: now,
223 payload: {
224 ...parentRun,
225 delegatedCompletion: {
226 disposition: "open",
227 nextGeneration: 2,
228 delivery: { generation: 1, messageId, taskIds: [taskId] },
229 },
230 },
231 },
232 {
233 id: EventId.make("mailbox:task"),
234 type: "subagent.updated",
235 threadId,
236 runId: first.runId,
237 nodeId: taskId,
238 occurredAt: now,
239 payload: {
240 id: taskId,
241 threadId,
242 runId: first.runId,
243 parentNodeId: first.rootNodeId,
244 origin: "app_owned",
245 createdBy: "agent",
246 driver,
247 providerInstanceId: instanceId,
248 providerThreadId: null,
249 childThreadId: null,
250 nativeTaskRef: null,
251 prompt: "Do background work",
252 title: "Background test",
253 model: null,
254 completionWake: timing === "settled only" ? "settled_only" : "always",
255 completionDelivery: { state: "claimed", observedByRunId: null },
256 status: "completed",
257 result: "done",
258 startedAt: now,
259 completedAt: now,
260 updatedAt: now,
372261 },
373 ]);
374 assert.equal(steerCalls, timing === "during delivery" ? 1 : 0);
375 const final = yield* orchestrator.getThreadProjection(threadId);
376 assert.equal(final.messages.filter((message) => message.id === messageId).length, 1);
377 assert.equal(
378 final.messages.find((message) => message.id === messageId)?.runId,
379 started[1]?.runId,
380 );
381 assert.equal(
382 final.turnItems.filter((item) =>
383 mailbox
384 ? item.type === "notification"
385 : item.type === "user_message" && item.messageId === messageId,
386 ).length,
387 1,
388 );
389 yield* worker.drain();
390 assert.equal(started.length, 2);
391 }).pipe(
392 Effect.provide(
393 makeOrchestratorV2ReplayLayerWithRegistry(
394 { name: `steering-completion-${timing}` },
395 ProviderAdapterRegistry.makeSingleLayer(adapter),
396 { runEffectWorker: false },
397 ),
398 ),
262 },
263 ],
264 });
265 }
266 const dispatchSteer = orchestrator.dispatch({
267 type: "message.dispatch",
268 commandId: CommandId.make("steer"),
269 threadId,
270 messageId,
271 text: "fix the popover",
272 attachments: [
273 {
274 type: "image",
275 id: "steering-screenshot",
276 name: "image.png",
277 mimeType: "image/png",
278 sizeBytes: 10,
279 },
280 ],
281 dispatchMode: mailbox
282 ? { type: "queue_after_active" }
283 : { type: "steer_active", targetRunId: first.runId },
284 createdBy: mailbox ? "agent" : "user",
285 creationSource: mailbox ? "server" : "web",
286 ...(mailbox
287 ? {
288 delegatedCompletion: {
289 parentRunId: first.runId,
290 generation: 1,
291 taskIds: [taskId],
292 },
293 }
294 : {}),
295 });
296 if (timing !== "before dispatch") yield* dispatchSteer;
297 if (timing === "after delivery") yield* worker.drain();
298 const delivery =
299 timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null;
300 if (delivery !== null) yield* Deferred.await(steerEntered);
301 const completed = yield* watch(
302 (event) =>
303 event.type === "run.updated" &&
304 event.payload.id === first.runId &&
305 event.payload.status === "waiting",
306 );
307 const projection = yield* orchestrator.getThreadProjection(threadId);
308 const turn = projection.providerTurns[0]!;
309 yield* Queue.offer(events, {
310 type: "provider_turn.updated",
311 driver,
312 providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now },
313 });
314 yield* Queue.offer(events, {
315 type: "turn.terminal",
316 driver,
317 providerThreadId: turn.providerThreadId,
318 providerTurnId: turn.id,
319 runOrdinal: first.runOrdinal,
320 status: "completed",
321 failure: null,
322 threadDisposition: "reusable",
323 });
324 yield* Fiber.join(completed);
325 if (delivery !== null) {
326 yield* Deferred.succeed(rejectSteer, undefined);
327 yield* Fiber.join(delivery);
328 }
329 if (timing === "before dispatch") yield* dispatchSteer;
330 yield* worker.drain();
331 yield* orchestrator.resumeQueuedRuns;
332 yield* worker.drain();
333 if (timing === "after delivery") {
334 assert.equal(steerCalls, 1);
335 assert.equal(started.length, 1);
336 if (mailbox) {
337 const delivered = yield* orchestrator.getThreadProjection(threadId);
338 assert.equal(delivered.subagents[0]?.completionDelivery?.state, "delivered");
339 assert.equal(delivered.subagents[0]?.completionDelivery?.observedByRunId, null);
340 assert.equal(delivered.runs[0]?.delegatedCompletion?.delivery, null);
341 assert.equal(
342 delivered.turnItems.filter((item) => item.type === "notification").length,
343 1,
399344 );
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 ),
401402 ),
402 );
403 }
404}
403 );
404 }),
405 ),
406);
405407
406408// Claude and Pi steer live but cannot interrupt-and-restart, so a changed
407409// 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";
2525import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
2626import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts";
2727
28for (const driverName of ["codex", "claudeAgent"] as const) {
28const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => {
2929 const driver = ProviderDriverKind.make(driverName);
3030 const instanceId = ProviderInstanceId.make(driver);
3131 const modelSelection = { instanceId, model: "test-model" };
......@@ -45,199 +45,207 @@ for (const driverName of ["codex", "claudeAgent"] as const) {
4545 { runEffectWorker: false },
4646 );
4747
48 for (const status of ["failed", "interrupted", "cancelled"] as const) {
49 it.effect(`bounds ${driver} context when continuing a fork of a ${status} run`, () =>
50 Effect.gen(function* () {
51 const orchestrator = yield* Orchestrator.OrchestratorV2;
52 const eventSink = yield* EventSink.EventSinkV2;
53 const now = yield* DateTime.now;
54 const sourceThreadId = ThreadId.make("fork-boundary-source");
55 const targetThreadId = ThreadId.make("fork-boundary-target");
56 const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread");
57 const sourceRunId = RunId.make("fork-boundary-source-run");
58 const attemptId = RunAttemptId.make("interrupted-source-attempt");
59 const providerTurnId = ProviderTurnId.make("interrupted-source-turn");
60 const rootNodeId = NodeId.make("interrupted-source-root");
48 return (["failed", "interrupted", "cancelled"] as const).map((status) => ({
49 driver,
50 status,
51 instanceId,
52 modelSelection,
53 layer,
54 }));
55});
6156
62 yield* orchestrator.dispatch({
63 type: "thread.create",
64 commandId: CommandId.make("create-source"),
65 threadId: sourceThreadId,
66 projectId: ProjectId.make("fork-boundary-project"),
67 title: "Fork boundary source",
68 modelSelection,
69 runtimeMode: "full-access",
70 interactionMode: "default",
71 branch: null,
72 worktreePath: null,
73 createdBy: "user",
74 creationSource: "web",
75 });
57it.effect.each(forkCases)(
58 "bounds $driver context when continuing a fork of a $status run",
59 ({ driver, status, instanceId, modelSelection, layer }) =>
60 Effect.gen(function* () {
61 const orchestrator = yield* Orchestrator.OrchestratorV2;
62 const eventSink = yield* EventSink.EventSinkV2;
63 const now = yield* DateTime.now;
64 const sourceThreadId = ThreadId.make("fork-boundary-source");
65 const targetThreadId = ThreadId.make("fork-boundary-target");
66 const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread");
67 const sourceRunId = RunId.make("fork-boundary-source-run");
68 const attemptId = RunAttemptId.make("interrupted-source-attempt");
69 const providerTurnId = ProviderTurnId.make("interrupted-source-turn");
70 const rootNodeId = NodeId.make("interrupted-source-root");
71
72 yield* orchestrator.dispatch({
73 type: "thread.create",
74 commandId: CommandId.make("create-source"),
75 threadId: sourceThreadId,
76 projectId: ProjectId.make("fork-boundary-project"),
77 title: "Fork boundary source",
78 modelSelection,
79 runtimeMode: "full-access",
80 interactionMode: "default",
81 branch: null,
82 worktreePath: null,
83 createdBy: "user",
84 creationSource: "web",
85 });
86 yield* eventSink.write({
87 events: [
88 {
89 id: EventId.make("source-provider-thread"),
90 type: "provider-thread.updated",
91 threadId: sourceThreadId,
92 occurredAt: now,
93 payload: {
94 id: providerThreadId,
95 driver,
96 providerInstanceId: instanceId,
97 providerSessionId: null,
98 appThreadId: sourceThreadId,
99 ownerNodeId: null,
100 nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" },
101 nativeConversationHeadRef: null,
102 status: "idle",
103 firstRunOrdinal: 1,
104 lastRunOrdinal: 2,
105 handoffIds: [],
106 forkedFrom: null,
107 createdAt: now,
108 updatedAt: now,
109 },
110 },
111 ],
112 });
113 // A cancelled queue entry has no provider turn; an early interruption
114 // can have a turn but no native assistant cursor.
115 if (status === "interrupted") {
76116 yield* eventSink.write({
77117 events: [
78118 {
79 id: EventId.make("source-provider-thread"),
80 type: "provider-thread.updated",
119 id: EventId.make("source-attempt"),
120 type: "run-attempt.created",
81121 threadId: sourceThreadId,
122 runId: sourceRunId,
82123 occurredAt: now,
83124 payload: {
84 id: providerThreadId,
85 driver,
125 id: attemptId,
126 runId: sourceRunId,
127 attemptOrdinal: 1,
128 rootNodeId,
86129 providerInstanceId: instanceId,
87 providerSessionId: null,
88 appThreadId: sourceThreadId,
89 ownerNodeId: null,
90 nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" },
91 nativeConversationHeadRef: null,
92 status: "idle",
93 firstRunOrdinal: 1,
94 lastRunOrdinal: 2,
95 handoffIds: [],
96 forkedFrom: null,
97 createdAt: now,
98 updatedAt: now,
130 providerThreadId,
131 providerTurnId,
132 reason: "initial",
133 status,
134 startedAt: now,
135 completedAt: now,
136 },
137 },
138 {
139 id: EventId.make("source-provider-turn"),
140 type: "provider-turn.updated",
141 threadId: sourceThreadId,
142 occurredAt: now,
143 payload: {
144 id: providerTurnId,
145 providerThreadId,
146 nodeId: rootNodeId,
147 runAttemptId: attemptId,
148 nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" },
149 ordinal: 1,
150 status,
151 startedAt: now,
152 completedAt: now,
99153 },
100154 },
101155 ],
102156 });
103 // A cancelled queue entry has no provider turn; an early interruption
104 // can have a turn but no native assistant cursor.
105 if (status === "interrupted") {
106 yield* eventSink.write({
107 events: [
108 {
109 id: EventId.make("source-attempt"),
110 type: "run-attempt.created",
111 threadId: sourceThreadId,
112 runId: sourceRunId,
113 occurredAt: now,
114 payload: {
115 id: attemptId,
116 runId: sourceRunId,
117 attemptOrdinal: 1,
118 rootNodeId,
119 providerInstanceId: instanceId,
120 providerThreadId,
121 providerTurnId,
122 reason: "initial",
123 status,
124 startedAt: now,
125 completedAt: now,
126 },
127 },
128 {
129 id: EventId.make("source-provider-turn"),
130 type: "provider-turn.updated",
131 threadId: sourceThreadId,
132 occurredAt: now,
133 payload: {
134 id: providerTurnId,
135 providerThreadId,
136 nodeId: rootNodeId,
137 runAttemptId: attemptId,
138 nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" },
139 ordinal: 1,
140 status,
141 startedAt: now,
142 completedAt: now,
143 },
144 },
145 ],
146 });
147 }
148 for (const ordinal of [1, 2]) {
149 const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run");
150 const messageId = MessageId.make(`source-message-${ordinal}`);
151 yield* eventSink.write({
152 events: [
153 {
154 id: EventId.make(`run-${ordinal}`),
155 type: "run.created",
157 }
158 for (const ordinal of [1, 2]) {
159 const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run");
160 const messageId = MessageId.make(`source-message-${ordinal}`);
161 yield* eventSink.write({
162 events: [
163 {
164 id: EventId.make(`run-${ordinal}`),
165 type: "run.created",
166 threadId: sourceThreadId,
167 runId,
168 occurredAt: now,
169 payload: {
170 id: runId,
156171 threadId: sourceThreadId,
157 runId,
158 occurredAt: now,
159 payload: {
160 id: runId,
161 threadId: sourceThreadId,
162 ordinal,
163 providerInstanceId: instanceId,
164 modelSelection,
165 providerThreadId,
166 userMessageId: messageId,
167 rootNodeId: null,
168 activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null,
169 status: ordinal === 1 ? status : "completed",
170 queuePosition: null,
171 requestedAt: now,
172 startedAt: now,
173 completedAt: now,
174 checkpointId: null,
175 contextHandoffId: null,
176 },
172 ordinal,
173 providerInstanceId: instanceId,
174 modelSelection,
175 providerThreadId,
176 userMessageId: messageId,
177 rootNodeId: null,
178 activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null,
179 status: ordinal === 1 ? status : "completed",
180 queuePosition: null,
181 requestedAt: now,
182 startedAt: now,
183 completedAt: now,
184 checkpointId: null,
185 contextHandoffId: null,
177186 },
178 {
179 id: EventId.make(`item-${ordinal}`),
180 type: "turn-item.updated",
187 },
188 {
189 id: EventId.make(`item-${ordinal}`),
190 type: "turn-item.updated",
191 threadId: sourceThreadId,
192 runId,
193 occurredAt: now,
194 payload: {
195 id: TurnItemId.make(`item-${ordinal}`),
181196 threadId: sourceThreadId,
182197 runId,
183 occurredAt: now,
184 payload: {
185 id: TurnItemId.make(`item-${ordinal}`),
186 threadId: sourceThreadId,
187 runId,
188 nodeId: null,
189 providerThreadId,
190 providerTurnId: null,
191 nativeItemRef: null,
192 parentItemId: null,
193 ordinal,
194 status: "completed",
195 title: null,
196 startedAt: now,
197 completedAt: now,
198 updatedAt: now,
199 type: "user_message",
200 createdBy: "user",
201 creationSource: "web",
202 inputIntent: "turn_start",
203 messageId,
204 text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER",
205 attachments: [],
206 },
198 nodeId: null,
199 providerThreadId,
200 providerTurnId: null,
201 nativeItemRef: null,
202 parentItemId: null,
203 ordinal,
204 status: "completed",
205 title: null,
206 startedAt: now,
207 completedAt: now,
208 updatedAt: now,
209 type: "user_message",
210 createdBy: "user",
211 creationSource: "web",
212 inputIntent: "turn_start",
213 messageId,
214 text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER",
215 attachments: [],
207216 },
208 ],
209 });
210 }
211 yield* orchestrator.dispatch({
212 type: "thread.fork",
213 commandId: CommandId.make("fork-source"),
214 sourceThreadId,
215 targetThreadId,
216 sourcePoint: { type: "run", runId: sourceRunId },
217 createdBy: "user",
218 creationSource: "web",
219 });
220 yield* orchestrator.dispatch({
221 type: "message.dispatch",
222 commandId: CommandId.make("continue-fork"),
223 threadId: targetThreadId,
224 messageId: MessageId.make("continue-fork"),
225 text: "Continue from the selected source run",
226 attachments: [],
227 modelSelection,
228 dispatchMode: { type: "start_immediately" },
229 createdBy: "user",
230 creationSource: "web",
217 },
218 ],
231219 });
232 const target = yield* orchestrator.getThreadProjection(targetThreadId);
233 assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context");
234 assert.lengthOf(target.contextHandoffs, 1);
235 const handoff = target.contextHandoffs[0]!;
236 const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? "";
237 assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER");
238 assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER");
239 assert.isNull(target.providerThreads[0]?.forkedFrom);
240 }).pipe(Effect.provide(layer)),
241 );
242 }
243}
220 }
221 yield* orchestrator.dispatch({
222 type: "thread.fork",
223 commandId: CommandId.make("fork-source"),
224 sourceThreadId,
225 targetThreadId,
226 sourcePoint: { type: "run", runId: sourceRunId },
227 createdBy: "user",
228 creationSource: "web",
229 });
230 yield* orchestrator.dispatch({
231 type: "message.dispatch",
232 commandId: CommandId.make("continue-fork"),
233 threadId: targetThreadId,
234 messageId: MessageId.make("continue-fork"),
235 text: "Continue from the selected source run",
236 attachments: [],
237 modelSelection,
238 dispatchMode: { type: "start_immediately" },
239 createdBy: "user",
240 creationSource: "web",
241 });
242 const target = yield* orchestrator.getThreadProjection(targetThreadId);
243 assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context");
244 assert.lengthOf(target.contextHandoffs, 1);
245 const handoff = target.contextHandoffs[0]!;
246 const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? "";
247 assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER");
248 assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER");
249 assert.isNull(target.providerThreads[0]?.forkedFrom);
250 }).pipe(Effect.provide(layer)),
251);
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+155-159
......@@ -271,64 +271,64 @@ function waitUntil<E, R>(predicate: () => Effect.Effect<boolean, E, R>): Effect.
271271 });
272272}
273273
274for (const target of ["new", "existing"] as const) {
275 for (const createdBy of ["user", "agent"] as const) {
276 it.effect(
277 `attributes ${createdBy}-configured automations in ${target} threads without changing their prompt`,
278 () => {
279 const harness = makeHarness();
280 const scheduledTasks = ScheduledTasks.layer.pipe(
281 Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)),
282 );
283 return Effect.gen(function* () {
284 const tasks = yield* ScheduledTasks.ScheduledTaskService;
285 const launches = yield* ThreadLaunch.ThreadLaunchService;
286 const threads = yield* ThreadManagement.ThreadManagementService;
287 const existing =
288 target === "existing"
289 ? yield* launches.launch(
290 launchInput({ command: "command:existing", thread: "thread:existing" }),
291 )
292 : null;
293 const { task } = yield* tasks.upsert({
294 id: ScheduledTaskId.make("scheduled-task:attribution"),
295 title: "Daily audit",
296 prompt: "Audit performance and crashes.",
297 enabled: false,
298 schedule: { type: "interval", everyMs: 60_000 },
299 projectId,
300 threadId: existing?.threadId ?? null,
301 workspaceStrategy: { type: "root" },
302 modelSelection,
303 runtimeMode: "full-access",
304 interactionMode: "default",
305 createdBy,
306 creationSource: createdBy === "agent" ? "mcp" : "web",
307 });
308 const result = yield* tasks.runNow({ id: task.id });
309 assert.equal(result.task.lastRunStatus, "succeeded");
310 const projectThreads = yield* threads.listProjectThreads({
311 projectId,
312 includeSubagents: false,
313 });
314 const thread =
315 projectThreads.find((candidate) => candidate.id === existing?.threadId) ??
316 projectThreads[0];
317 assert.isDefined(thread);
318 const projection = yield* threads.getThreadProjection(thread!.id);
319 // Encoding the persisted projection exercises both message and turn-item wire schemas.
320 const wire = yield* encodeThreadProjection(projection);
321 assert.equal(wire.messages[0]?.text, task.prompt);
322 assert.equal(wire.messages[0]?.scheduledTaskId, task.id);
323 assert.equal(wire.messages[0]?.createdBy, createdBy);
324 const turnItem = wire.turnItems.find((item) => item.type === "user_message");
325 assert.equal(turnItem?.text, task.prompt);
326 assert.equal(turnItem?.scheduledTaskId, task.id);
327 }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks)));
328 },
274it.effect.each(
275 (["new", "existing"] as const).flatMap((target) =>
276 (["user", "agent"] as const).map((createdBy) => ({ target, createdBy })),
277 ),
278)(
279 "attributes $createdBy-configured automations in $target threads without changing their prompt",
280 ({ target, createdBy }) => {
281 const harness = makeHarness();
282 const scheduledTasks = ScheduledTasks.layer.pipe(
283 Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)),
329284 );
330 }
331}
285 return Effect.gen(function* () {
286 const tasks = yield* ScheduledTasks.ScheduledTaskService;
287 const launches = yield* ThreadLaunch.ThreadLaunchService;
288 const threads = yield* ThreadManagement.ThreadManagementService;
289 const existing =
290 target === "existing"
291 ? yield* launches.launch(
292 launchInput({ command: "command:existing", thread: "thread:existing" }),
293 )
294 : null;
295 const { task } = yield* tasks.upsert({
296 id: ScheduledTaskId.make("scheduled-task:attribution"),
297 title: "Daily audit",
298 prompt: "Audit performance and crashes.",
299 enabled: false,
300 schedule: { type: "interval", everyMs: 60_000 },
301 projectId,
302 threadId: existing?.threadId ?? null,
303 workspaceStrategy: { type: "root" },
304 modelSelection,
305 runtimeMode: "full-access",
306 interactionMode: "default",
307 createdBy,
308 creationSource: createdBy === "agent" ? "mcp" : "web",
309 });
310 const result = yield* tasks.runNow({ id: task.id });
311 assert.equal(result.task.lastRunStatus, "succeeded");
312 const projectThreads = yield* threads.listProjectThreads({
313 projectId,
314 includeSubagents: false,
315 });
316 const thread =
317 projectThreads.find((candidate) => candidate.id === existing?.threadId) ??
318 projectThreads[0];
319 assert.isDefined(thread);
320 const projection = yield* threads.getThreadProjection(thread!.id);
321 // Encoding the persisted projection exercises both message and turn-item wire schemas.
322 const wire = yield* encodeThreadProjection(projection);
323 assert.equal(wire.messages[0]?.text, task.prompt);
324 assert.equal(wire.messages[0]?.scheduledTaskId, task.id);
325 assert.equal(wire.messages[0]?.createdBy, createdBy);
326 const turnItem = wire.turnItems.find((item) => item.type === "user_message");
327 assert.equal(turnItem?.text, task.prompt);
328 assert.equal(turnItem?.scheduledTaskId, task.id);
329 }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks)));
330 },
331);
332332
333333it.effect("retains automation and sender attribution while a message waits in the queue", () => {
334334 const harness = makeHarness({ runSetup: () => Effect.never });
......@@ -603,8 +603,9 @@ it.effect(
603603 }),
604604);
605605
606for (const nativeCommand of [" /COMPACT ", "/logout"]) {
607 it.effect(`uses the first conversation message for a title after ${nativeCommand}`, () =>
606it.effect.each([" /COMPACT ", "/logout"])(
607 "uses the first conversation message for a title after %s",
608 (nativeCommand) =>
608609 Effect.gen(function* () {
609610 const harness = makeHarness();
610611 yield* Effect.gen(function* () {
......@@ -653,8 +654,7 @@ for (const nativeCommand of [" /COMPACT ", "/logout"]) {
653654 );
654655 }).pipe(Effect.provide(harness.layer));
655656 }),
656 );
657}
657);
658658
659659it.effect("keeps native maintenance commands out of steering and restart messages", () =>
660660 Effect.gen(function* () {
......@@ -1293,47 +1293,45 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails
12931293 }).pipe(Effect.provide(harness.layer));
12941294});
12951295
1296for (const failurePoint of ["worktree", "setup"] as const) {
1297 it.effect(
1298 `${failurePoint} failure keeps the thread and message visible and emits failure items`,
1299 () =>
1300 Effect.gen(function* () {
1301 const failure = new Error(`${failurePoint} failed`);
1302 const harness = makeHarness(
1303 failurePoint === "worktree"
1304 ? { createWorktree: () => Effect.fail(failure as never) }
1305 : { runSetup: () => Effect.fail(failure as never) },
1296it.effect.each(["worktree", "setup"] as const)(
1297 "%s failure keeps the thread and message visible and emits failure items",
1298 (failurePoint) =>
1299 Effect.gen(function* () {
1300 const failure = new Error(`${failurePoint} failed`);
1301 const harness = makeHarness(
1302 failurePoint === "worktree"
1303 ? { createWorktree: () => Effect.fail(failure as never) }
1304 : { runSetup: () => Effect.fail(failure as never) },
1305 );
1306 yield* Effect.gen(function* () {
1307 const launches = yield* ThreadLaunch.ThreadLaunchService;
1308 const threads = yield* ThreadManagement.ThreadManagementService;
1309 const input = launchInput({
1310 command: `command:launch:${failurePoint}-failure`,
1311 thread: `thread:launch:${failurePoint}-failure`,
1312 message: `Fail during ${failurePoint}`,
1313 workspace: { type: "worktree", baseRef: "main" },
1314 });
1315 const launched = yield* launches.launch(input);
1316 yield* waitUntil(() =>
1317 threads
1318 .getThreadProjection(launched.threadId)
1319 .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")),
13061320 );
1307 yield* Effect.gen(function* () {
1308 const launches = yield* ThreadLaunch.ThreadLaunchService;
1309 const threads = yield* ThreadManagement.ThreadManagementService;
1310 const input = launchInput({
1311 command: `command:launch:${failurePoint}-failure`,
1312 thread: `thread:launch:${failurePoint}-failure`,
1313 message: `Fail during ${failurePoint}`,
1314 workspace: { type: "worktree", baseRef: "main" },
1315 });
1316 const launched = yield* launches.launch(input);
1317 yield* waitUntil(() =>
1318 threads
1319 .getThreadProjection(launched.threadId)
1320 .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")),
1321 );
1322 const projection = yield* threads.getThreadProjection(launched.threadId);
1323 assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`);
1324 assert.equal(projection.runs[0]?.status, "failed");
1325 assert.equal(
1326 projection.turnItems.find((item) => item.type === "command_execution")?.status,
1327 "failed",
1328 );
1329 assert.match(
1330 projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "",
1331 new RegExp(`${failurePoint} failed`, "u"),
1332 );
1333 }).pipe(Effect.provide(harness.layer));
1334 }),
1335 );
1336}
1321 const projection = yield* threads.getThreadProjection(launched.threadId);
1322 assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`);
1323 assert.equal(projection.runs[0]?.status, "failed");
1324 assert.equal(
1325 projection.turnItems.find((item) => item.type === "command_execution")?.status,
1326 "failed",
1327 );
1328 assert.match(
1329 projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "",
1330 new RegExp(`${failurePoint} failed`, "u"),
1331 );
1332 }).pipe(Effect.provide(harness.layer));
1333 }),
1334);
13371335
13381336it.effect("replays a server-allocated launch", () =>
13391337 Effect.gen(function* () {
......@@ -1989,61 +1987,59 @@ it.effect("cancels tracked setup before provider work is released", () =>
19891987 }),
19901988);
19911989
1992for (const exitCode of [0, 1]) {
1993 it.effect(`releases an async setup before its completion with exit ${exitCode}`, () =>
1994 Effect.gen(function* () {
1995 const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>();
1996 const harness = makeHarness({
1997 runSetup: () =>
1998 Effect.succeed({
1999 status: "started" as const,
2000 async: true,
2001 scriptId: "setup",
2002 scriptName: "Setup",
2003 scriptCommand: "vp install",
2004 terminalId: "setup",
2005 cwd: "/repo-worktrees/feature",
2006 completion: Deferred.await(completion),
2007 }),
2008 });
2009 yield* Effect.gen(function* () {
2010 const launches = yield* ThreadLaunch.ThreadLaunchService;
2011 const threads = yield* ThreadManagement.ThreadManagementService;
2012 const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
2013 const launched = yield* launches.launch(
2014 launchInput({
2015 command: `command:launch:async-${exitCode}`,
2016 thread: `thread:launch:async-${exitCode}`,
2017 message: "Start during setup",
2018 workspace: { type: "worktree", baseRef: "main" },
2019 }),
2020 );
2021 yield* tracker.stream(launched.threadId).pipe(
2022 Stream.filter(
2023 (snapshot) =>
2024 snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") ===
2025 true,
2026 ),
2027 Stream.runHead,
2028 );
2029 const running = yield* threads.getThreadProjection(launched.threadId);
2030 assert.equal(running.runs[0]?.status, "starting");
2031 assert.equal((yield* tracker.get(launched.threadId))?.phase, "running");
2032 yield* Deferred.succeed(completion, { exitCode, durationMs: 1 });
2033 yield* tracker.stream(launched.threadId).pipe(
2034 Stream.filter((snapshot) => snapshot?.phase === "done"),
2035 Stream.runHead,
2036 );
2037 const settled = yield* tracker.get(launched.threadId);
2038 assert.equal(
2039 settled?.stages.find((stage) => stage.id === "setup-script")?.status,
2040 exitCode === 0 ? "done" : "failed",
2041 );
2042 assert.equal(
2043 (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status,
2044 "starting",
2045 );
2046 }).pipe(Effect.provide(harness.layer));
2047 }),
2048 );
2049}
1990it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) =>
1991 Effect.gen(function* () {
1992 const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>();
1993 const harness = makeHarness({
1994 runSetup: () =>
1995 Effect.succeed({
1996 status: "started" as const,
1997 async: true,
1998 scriptId: "setup",
1999 scriptName: "Setup",
2000 scriptCommand: "vp install",
2001 terminalId: "setup",
2002 cwd: "/repo-worktrees/feature",
2003 completion: Deferred.await(completion),
2004 }),
2005 });
2006 yield* Effect.gen(function* () {
2007 const launches = yield* ThreadLaunch.ThreadLaunchService;
2008 const threads = yield* ThreadManagement.ThreadManagementService;
2009 const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
2010 const launched = yield* launches.launch(
2011 launchInput({
2012 command: `command:launch:async-${exitCode}`,
2013 thread: `thread:launch:async-${exitCode}`,
2014 message: "Start during setup",
2015 workspace: { type: "worktree", baseRef: "main" },
2016 }),
2017 );
2018 yield* tracker.stream(launched.threadId).pipe(
2019 Stream.filter(
2020 (snapshot) =>
2021 snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") ===
2022 true,
2023 ),
2024 Stream.runHead,
2025 );
2026 const running = yield* threads.getThreadProjection(launched.threadId);
2027 assert.equal(running.runs[0]?.status, "starting");
2028 assert.equal((yield* tracker.get(launched.threadId))?.phase, "running");
2029 yield* Deferred.succeed(completion, { exitCode, durationMs: 1 });
2030 yield* tracker.stream(launched.threadId).pipe(
2031 Stream.filter((snapshot) => snapshot?.phase === "done"),
2032 Stream.runHead,
2033 );
2034 const settled = yield* tracker.get(launched.threadId);
2035 assert.equal(
2036 settled?.stages.find((stage) => stage.id === "setup-script")?.status,
2037 exitCode === 0 ? "done" : "failed",
2038 );
2039 assert.equal(
2040 (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status,
2041 "starting",
2042 );
2043 }).pipe(Effect.provide(harness.layer));
2044 }),
2045);
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+67-69
......@@ -344,7 +344,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
344344 }).pipe(Effect.provide(testLayer));
345345});
346346
347for (const scenario of [
347it.effect.each([
348348 { finalStatus: "completed" as const, timedOut: false },
349349 { finalStatus: "failed" as const, timedOut: false },
350350 { finalStatus: "cancelled" as const, timedOut: false },
......@@ -352,72 +352,70 @@ for (const scenario of [
352352 { finalStatus: "rolled_back" as const, timedOut: false },
353353 { finalStatus: "running" as const, timedOut: true },
354354 { finalStatus: "missing" as const },
355]) {
356 it.effect(`waitForThread timeout final read when selected run is ${scenario.finalStatus}`, () =>
357 Effect.gen(function* () {
358 const projectId = ProjectId.make("project:thread-management:wait-timeout");
359 const threadId = ThreadId.make("thread:thread-management:wait-timeout");
360 const runId = RunId.make("run:thread-management:wait-timeout");
361 const loopRead = yield* Deferred.make<void>();
362 let reads = 0;
363 const projection = (status: OrchestrationV2Run["status"] | "missing") =>
364 ({
365 thread: { id: threadId, projectId, deletedAt: null },
366 runs: status === "missing" ? [] : [{ id: runId, status }],
367 }) as unknown as OrchestrationV2ThreadProjection;
368 const testLayer = ThreadManagementService.layer.pipe(
369 Layer.provide(
370 Layer.mock(Orchestrator.OrchestratorV2)({
371 getThreadRecords: () =>
372 Effect.gen(function* () {
373 reads += 1;
374 if (reads === 1) {
375 return projection("running");
376 }
377 if (reads === 2) {
378 // Park inside the wait loop so the timeout path runs while a
379 // final projection read can still observe a terminal run.
380 yield* Deferred.succeed(loopRead, undefined);
381 return yield* Effect.never;
382 }
383 return projection(scenario.finalStatus);
384 }),
385 }),
386 ),
387 );
388 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
389 Effect.provide(testLayer),
390 );
391 const fiber = yield* service
392 .waitForThread({
393 projectId,
355])("waitForThread timeout final read when selected run is $finalStatus", (scenario) =>
356 Effect.gen(function* () {
357 const projectId = ProjectId.make("project:thread-management:wait-timeout");
358 const threadId = ThreadId.make("thread:thread-management:wait-timeout");
359 const runId = RunId.make("run:thread-management:wait-timeout");
360 const loopRead = yield* Deferred.make<void>();
361 let reads = 0;
362 const projection = (status: OrchestrationV2Run["status"] | "missing") =>
363 ({
364 thread: { id: threadId, projectId, deletedAt: null },
365 runs: status === "missing" ? [] : [{ id: runId, status }],
366 }) as unknown as OrchestrationV2ThreadProjection;
367 const testLayer = ThreadManagementService.layer.pipe(
368 Layer.provide(
369 Layer.mock(Orchestrator.OrchestratorV2)({
370 getThreadRecords: () =>
371 Effect.gen(function* () {
372 reads += 1;
373 if (reads === 1) {
374 return projection("running");
375 }
376 if (reads === 2) {
377 // Park inside the wait loop so the timeout path runs while a
378 // final projection read can still observe a terminal run.
379 yield* Deferred.succeed(loopRead, undefined);
380 return yield* Effect.never;
381 }
382 return projection(scenario.finalStatus);
383 }),
384 }),
385 ),
386 );
387 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
388 Effect.provide(testLayer),
389 );
390 const fiber = yield* service
391 .waitForThread({
392 projectId,
393 threadId,
394 runId,
395 timeoutMs: 1,
396 })
397 .pipe(Effect.result, Effect.forkChild);
398 yield* Deferred.await(loopRead);
399 yield* TestClock.adjust(Duration.millis(1));
400 const result = yield* Fiber.join(fiber);
401
402 if (scenario.finalStatus === "missing") {
403 expect(result._tag).toBe("Failure");
404 expect(result).toMatchObject({
405 failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError),
406 });
407 expect(result).toMatchObject({
408 failure: { threadId, runId },
409 });
410 } else {
411 expect(result._tag).toBe("Success");
412 expect(result).toMatchObject({
413 success: {
394414 threadId,
395 runId,
396 timeoutMs: 1,
397 })
398 .pipe(Effect.result, Effect.forkChild);
399 yield* Deferred.await(loopRead);
400 yield* TestClock.adjust(Duration.millis(1));
401 const result = yield* Fiber.join(fiber);
402
403 if (scenario.finalStatus === "missing") {
404 expect(result._tag).toBe("Failure");
405 expect(result).toMatchObject({
406 failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError),
407 });
408 expect(result).toMatchObject({
409 failure: { threadId, runId },
410 });
411 } else {
412 expect(result._tag).toBe("Success");
413 expect(result).toMatchObject({
414 success: {
415 threadId,
416 timedOut: scenario.timedOut,
417 run: { id: runId, status: scenario.finalStatus },
418 },
419 });
420 }
421 }),
422 );
423}
415 timedOut: scenario.timedOut,
416 run: { id: runId, status: scenario.finalStatus },
417 },
418 });
419 }
420 }),
421);
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts+4-4
......@@ -438,8 +438,9 @@ describe("ThreadTitleRegenerationService", () => {
438438 );
439439});
440440
441for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) {
442 it.effect(`initial title retry: ${outcome}`, () =>
441it.effect.each(["success", "exhausted", "stale", "interrupted"] as const)(
442 "initial title retry: %s",
443 (outcome) =>
443444 Effect.gen(function* () {
444445 const attempted = yield* Deferred.make<void>();
445446 let attempts = 0;
......@@ -502,5 +503,4 @@ for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const)
502503 else assert.isNotOk(projection.thread.titleRegeneration);
503504 }).pipe(Effect.provide(harness.layer));
504505 }),
505 );
506}
506);
apps/server/src/orchestration-v2/runtimeLayer.test.ts+275-285
......@@ -2599,210 +2599,206 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
25992599 }),
26002600 );
26012601
2602 for (const automatic of [false, true]) {
2603 it.effect(
2604 `promotes only one queued run after each terminal run (notification: ${automatic})`,
2605 () =>
2606 Effect.gen(function* () {
2607 const orchestrator = yield* Orchestrator.OrchestratorV2;
2608 const eventSink = yield* EventSink.EventSinkV2;
2609 const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`);
2602 it.effect.each([false, true])(
2603 "promotes only one queued run after each terminal run (notification: %s)",
2604 (automatic) =>
2605 Effect.gen(function* () {
2606 const orchestrator = yield* Orchestrator.OrchestratorV2;
2607 const eventSink = yield* EventSink.EventSinkV2;
2608 const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`);
26102609
2611 yield* orchestrator.dispatch({
2612 type: "thread.create",
2613 createdBy: "user",
2614 creationSource: "web",
2615 commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`),
2616 threadId,
2617 projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`),
2618 title: "Serialized queue",
2619 modelSelection,
2620 runtimeMode: "full-access",
2621 interactionMode: "default",
2622 branch: null,
2623 worktreePath: process.cwd(),
2624 });
2625 yield* orchestrator.dispatch({
2626 type: "message.dispatch",
2627 createdBy: "user",
2628 creationSource: "web",
2629 commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`),
2630 threadId,
2631 messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`),
2632 text: "Active",
2633 attachments: [],
2634 modelSelection,
2635 dispatchMode: { type: "start_immediately" },
2636 });
2637 yield* orchestrator.dispatch({
2638 type: "message.dispatch",
2639 createdBy: automatic ? "agent" : "user",
2640 creationSource: automatic ? "provider" : "web",
2641 ...(automatic
2642 ? {
2643 notification: {
2644 source: { kind: "monitor" as const },
2645 outcome: "updated" as const,
2646 summary: "Monitor updated",
2647 detail: "Build is green",
2648 },
2649 }
2650 : {}),
2651 commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`),
2652 threadId,
2653 messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`),
2654 text: "First queued",
2655 attachments: [],
2656 modelSelection,
2657 dispatchMode: { type: "queue_after_active" },
2658 });
2659 yield* orchestrator.dispatch({
2660 type: "message.dispatch",
2661 createdBy: "user",
2662 creationSource: "web",
2663 commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`),
2664 threadId,
2665 messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`),
2666 text: "Second queued",
2667 attachments: [],
2668 modelSelection,
2669 dispatchMode: { type: "queue_after_active" },
2670 });
2610 yield* orchestrator.dispatch({
2611 type: "thread.create",
2612 createdBy: "user",
2613 creationSource: "web",
2614 commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`),
2615 threadId,
2616 projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`),
2617 title: "Serialized queue",
2618 modelSelection,
2619 runtimeMode: "full-access",
2620 interactionMode: "default",
2621 branch: null,
2622 worktreePath: process.cwd(),
2623 });
2624 yield* orchestrator.dispatch({
2625 type: "message.dispatch",
2626 createdBy: "user",
2627 creationSource: "web",
2628 commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`),
2629 threadId,
2630 messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`),
2631 text: "Active",
2632 attachments: [],
2633 modelSelection,
2634 dispatchMode: { type: "start_immediately" },
2635 });
2636 yield* orchestrator.dispatch({
2637 type: "message.dispatch",
2638 createdBy: automatic ? "agent" : "user",
2639 creationSource: automatic ? "provider" : "web",
2640 ...(automatic
2641 ? {
2642 notification: {
2643 source: { kind: "monitor" as const },
2644 outcome: "updated" as const,
2645 summary: "Monitor updated",
2646 detail: "Build is green",
2647 },
2648 }
2649 : {}),
2650 commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`),
2651 threadId,
2652 messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`),
2653 text: "First queued",
2654 attachments: [],
2655 modelSelection,
2656 dispatchMode: { type: "queue_after_active" },
2657 });
2658 yield* orchestrator.dispatch({
2659 type: "message.dispatch",
2660 createdBy: "user",
2661 creationSource: "web",
2662 commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`),
2663 threadId,
2664 messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`),
2665 text: "Second queued",
2666 attachments: [],
2667 modelSelection,
2668 dispatchMode: { type: "queue_after_active" },
2669 });
26712670
2672 const before = yield* orchestrator.getThreadProjection(threadId);
2673 const activeRun = before.runs.find((run) => run.status === "starting");
2674 const queuedRuns = before.runs
2675 .filter((run) => run.status === "queued")
2676 .toSorted((left, right) => left.ordinal - right.ordinal);
2677 const firstQueuedRun = queuedRuns[0];
2678 const secondQueuedRun = queuedRuns[1];
2679 assert.isDefined(activeRun);
2680 assert.isDefined(firstQueuedRun);
2681 assert.isDefined(secondQueuedRun);
2682 assert.isFalse(
2683 before.turnItems.some(
2684 (item) =>
2685 item.type === "user_message" &&
2686 (item.messageId === firstQueuedRun.userMessageId ||
2687 item.messageId === secondQueuedRun.userMessageId),
2688 ),
2689 "queued messages must not exist as turn items before dispatch",
2690 );
2671 const before = yield* orchestrator.getThreadProjection(threadId);
2672 const activeRun = before.runs.find((run) => run.status === "starting");
2673 const queuedRuns = before.runs
2674 .filter((run) => run.status === "queued")
2675 .toSorted((left, right) => left.ordinal - right.ordinal);
2676 const firstQueuedRun = queuedRuns[0];
2677 const secondQueuedRun = queuedRuns[1];
2678 assert.isDefined(activeRun);
2679 assert.isDefined(firstQueuedRun);
2680 assert.isDefined(secondQueuedRun);
2681 assert.isFalse(
2682 before.turnItems.some(
2683 (item) =>
2684 item.type === "user_message" &&
2685 (item.messageId === firstQueuedRun.userMessageId ||
2686 item.messageId === secondQueuedRun.userMessageId),
2687 ),
2688 "queued messages must not exist as turn items before dispatch",
2689 );
26912690
2692 const promotedRunIds = yield* Queue.unbounded<RunId>();
2693 const afterSequence = yield* orchestrator.getThreadEventSequence(threadId);
2694 yield* eventSink.stream({ threadId, afterSequence }).pipe(
2695 Stream.runForEach((stored) =>
2696 stored.event.type === "run.updated" && stored.event.payload.status === "starting"
2697 ? Queue.offer(promotedRunIds, stored.event.payload.id)
2698 : Effect.void,
2699 ),
2700 Effect.forkScoped,
2701 );
2702 yield* Effect.yieldNow;
2691 const promotedRunIds = yield* Queue.unbounded<RunId>();
2692 const afterSequence = yield* orchestrator.getThreadEventSequence(threadId);
2693 yield* eventSink.stream({ threadId, afterSequence }).pipe(
2694 Stream.runForEach((stored) =>
2695 stored.event.type === "run.updated" && stored.event.payload.status === "starting"
2696 ? Queue.offer(promotedRunIds, stored.event.payload.id)
2697 : Effect.void,
2698 ),
2699 Effect.forkScoped,
2700 );
2701 yield* Effect.yieldNow;
27032702
2704 const activeCompletedAt = yield* DateTime.now;
2705 yield* eventSink.write({
2706 events: [
2707 {
2708 id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`),
2709 type: "run.updated",
2710 threadId,
2711 runId: activeRun.id,
2712 ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }),
2713 providerInstanceId: activeRun.providerInstanceId,
2714 occurredAt: activeCompletedAt,
2715 payload: {
2716 ...activeRun,
2717 status: "completed",
2718 completedAt: activeCompletedAt,
2719 },
2703 const activeCompletedAt = yield* DateTime.now;
2704 yield* eventSink.write({
2705 events: [
2706 {
2707 id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`),
2708 type: "run.updated",
2709 threadId,
2710 runId: activeRun.id,
2711 ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }),
2712 providerInstanceId: activeRun.providerInstanceId,
2713 occurredAt: activeCompletedAt,
2714 payload: {
2715 ...activeRun,
2716 status: "completed",
2717 completedAt: activeCompletedAt,
27202718 },
2721 ],
2722 });
2719 },
2720 ],
2721 });
27232722
2724 assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id);
2725 const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId);
2723 assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id);
2724 const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId);
2725 assert.equal(
2726 afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status,
2727 "starting",
2728 );
2729 assert.equal(
2730 afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status,
2731 "queued",
2732 );
2733 const promotedMessageItem = afterFirstPromotion.turnItems.find(
2734 (item) =>
2735 item.runId === firstQueuedRun.id &&
2736 (item.type === "user_message" || item.type === "notification"),
2737 );
2738 assert.isDefined(promotedMessageItem);
2739 if (automatic) {
2740 assert.equal(promotedMessageItem.type, "notification");
27262741 assert.equal(
2727 afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status,
2728 "starting",
2742 afterFirstPromotion.messages.find(
2743 (message) => message.id === firstQueuedRun.userMessageId,
2744 )?.text,
2745 "First queued",
27292746 );
27302747 assert.equal(
2731 afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status,
2732 "queued",
2748 afterFirstPromotion.messages.find(
2749 (message) => message.id === firstQueuedRun.userMessageId,
2750 )?.notification?.summary,
2751 "Monitor updated",
27332752 );
2734 const promotedMessageItem = afterFirstPromotion.turnItems.find(
2735 (item) =>
2736 item.runId === firstQueuedRun.id &&
2737 (item.type === "user_message" || item.type === "notification"),
2738 );
2739 assert.isDefined(promotedMessageItem);
2740 if (automatic) {
2741 assert.equal(promotedMessageItem.type, "notification");
2742 assert.equal(
2743 afterFirstPromotion.messages.find(
2744 (message) => message.id === firstQueuedRun.userMessageId,
2745 )?.text,
2746 "First queued",
2747 );
2748 assert.equal(
2749 afterFirstPromotion.messages.find(
2750 (message) => message.id === firstQueuedRun.userMessageId,
2751 )?.notification?.summary,
2752 "Monitor updated",
2753 );
2754 assert.isFalse(
2755 afterFirstPromotion.turnItems.some(
2756 (item) =>
2757 item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId,
2758 ),
2759 );
2760 } else {
2761 assert.equal(promotedMessageItem.type, "user_message");
2762 }
2763 assert.isTrue(
2764 promotedMessageItem.startedAt !== null &&
2765 DateTime.toEpochMillis(promotedMessageItem.startedAt) >=
2766 DateTime.toEpochMillis(activeCompletedAt),
2753 assert.isFalse(
2754 afterFirstPromotion.turnItems.some(
2755 (item) =>
2756 item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId,
2757 ),
27672758 );
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(
2770 (run) => run.id === firstQueuedRun.id,
2771 );
2772 assert.isDefined(promotedFirst);
2773 const firstCompletedAt = yield* DateTime.now;
2774 yield* eventSink.write({
2775 events: [
2776 {
2777 id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`),
2778 type: "run.updated",
2779 threadId,
2780 runId: promotedFirst.id,
2781 ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }),
2782 providerInstanceId: promotedFirst.providerInstanceId,
2783 occurredAt: firstCompletedAt,
2784 payload: {
2785 ...promotedFirst,
2786 status: "completed",
2787 completedAt: firstCompletedAt,
2788 },
2768 const promotedFirst = afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id);
2769 assert.isDefined(promotedFirst);
2770 const firstCompletedAt = yield* DateTime.now;
2771 yield* eventSink.write({
2772 events: [
2773 {
2774 id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`),
2775 type: "run.updated",
2776 threadId,
2777 runId: promotedFirst.id,
2778 ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }),
2779 providerInstanceId: promotedFirst.providerInstanceId,
2780 occurredAt: firstCompletedAt,
2781 payload: {
2782 ...promotedFirst,
2783 status: "completed",
2784 completedAt: firstCompletedAt,
27892785 },
2790 ],
2791 });
2786 },
2787 ],
2788 });
27922789
2793 assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id);
2794 const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId);
2795 assert.equal(
2796 afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status,
2797 "completed",
2798 );
2799 assert.equal(
2800 afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status,
2801 "starting",
2802 );
2803 }),
2804 );
2805 }
2790 assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id);
2791 const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId);
2792 assert.equal(
2793 afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status,
2794 "completed",
2795 );
2796 assert.equal(
2797 afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status,
2798 "starting",
2799 );
2800 }),
2801 );
28062802
28072803 it.effect.each(["usage_limit", "provider_error"] as const)(
28082804 "handles a queued message after a %s failure",
......@@ -2993,112 +2989,106 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
29932989 }),
29942990 );
29952991
2996 for (const trigger of ["startup", "shutdown"] as const) {
2997 it.effect(
2998 `preserves and holds queued messages across ${trigger} until explicitly resumed`,
2999 () =>
3000 Effect.gen(function* () {
3001 const orchestrator = yield* Orchestrator.OrchestratorV2;
3002 const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService;
3003 const threadId = ThreadId.make(`queue-hold-${trigger}`);
2992 it.effect.each(["startup", "shutdown"] as const)(
2993 "preserves and holds queued messages across %s until explicitly resumed",
2994 (trigger) =>
2995 Effect.gen(function* () {
2996 const orchestrator = yield* Orchestrator.OrchestratorV2;
2997 const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService;
2998 const threadId = ThreadId.make(`queue-hold-${trigger}`);
2999 yield* orchestrator.dispatch({
3000 type: "thread.create",
3001 createdBy: "user",
3002 creationSource: "web",
3003 commandId: CommandId.make(`${threadId}:create`),
3004 threadId,
3005 projectId: ProjectId.make(`${threadId}:project`),
3006 title: "Recover queue",
3007 modelSelection,
3008 runtimeMode: "full-access",
3009 interactionMode: "default",
3010 branch: null,
3011 worktreePath: process.cwd(),
3012 });
3013 for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) {
30043014 yield* orchestrator.dispatch({
3005 type: "thread.create",
3015 type: "message.dispatch",
30063016 createdBy: "user",
30073017 creationSource: "web",
3008 commandId: CommandId.make(`${threadId}:create`),
3018 commandId: CommandId.make(`${threadId}:message:${index}`),
30093019 threadId,
3010 projectId: ProjectId.make(`${threadId}:project`),
3011 title: "Recover queue",
3020 messageId: MessageId.make(`${threadId}:message:${index}`),
3021 text,
3022 attachments: [],
30123023 modelSelection,
3013 runtimeMode: "full-access",
3014 interactionMode: "default",
3015 branch: null,
3016 worktreePath: process.cwd(),
3024 dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" },
30173025 });
3018 for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) {
3019 yield* orchestrator.dispatch({
3020 type: "message.dispatch",
3021 createdBy: "user",
3022 creationSource: "web",
3023 commandId: CommandId.make(`${threadId}:message:${index}`),
3024 threadId,
3025 messageId: MessageId.make(`${threadId}:message:${index}`),
3026 text,
3027 attachments: [],
3028 modelSelection,
3029 dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" },
3030 });
3031 }
3032 const before = yield* orchestrator.getThreadProjection(threadId);
3033 const queued = before.runs.filter((run) => run.status === "queued");
3034 assert.equal(queued.length, 2);
3035 yield* recovery.reconcile(trigger);
3036 // A second boot must preserve the hold, even when only queued work remains.
3037 yield* recovery.reconcile("startup");
3038 const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
3039 assert.isTrue((yield* maintenance.rebuild).valid);
3040 assert.equal(yield* orchestrator.resumeQueuedRuns, 0);
3041 const held = yield* orchestrator.getThreadProjection(threadId);
3026 }
3027 const before = yield* orchestrator.getThreadProjection(threadId);
3028 const queued = before.runs.filter((run) => run.status === "queued");
3029 assert.equal(queued.length, 2);
3030 yield* recovery.reconcile(trigger);
3031 // A second boot must preserve the hold, even when only queued work remains.
3032 yield* recovery.reconcile("startup");
3033 const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
3034 assert.isTrue((yield* maintenance.rebuild).valid);
3035 assert.equal(yield* orchestrator.resumeQueuedRuns, 0);
3036 const held = yield* orchestrator.getThreadProjection(threadId);
3037 assert.deepEqual(
3038 held.runs.map((run) => run.status),
3039 ["cancelled", "queued", "queued"],
3040 );
3041 for (const run of queued) {
30423042 assert.deepEqual(
3043 held.runs.map((run) => run.status),
3044 ["cancelled", "queued", "queued"],
3043 held.runs.find((row) => row.id === run.id),
3044 { ...run, queueHeld: true },
30453045 );
3046 for (const run of queued) {
3047 assert.deepEqual(
3048 held.runs.find((row) => row.id === run.id),
3049 { ...run, queueHeld: true },
3050 );
3051 assert.deepEqual(
3052 held.messages.find((row) => row.id === run.userMessageId),
3053 before.messages.find((row) => row.id === run.userMessageId),
3054 );
3055 assert.equal(
3056 held.attempts.find((row) => row.id === run.activeAttemptId)?.status,
3057 "pending",
3058 );
3059 assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending");
3060 }
3061 // Editing and reordering are allowed without releasing the hold.
3062 const first = queued[0]!;
3063 const second = queued[1]!;
3064 yield* orchestrator.dispatch({
3065 type: "queued-run.edit",
3066 commandId: CommandId.make(`${threadId}:edit`),
3067 threadId,
3068 runId: second.id,
3069 text: "Edited second message",
3070 });
3071 yield* orchestrator.dispatch({
3072 type: "queued-run.reorder",
3073 commandId: CommandId.make(`${threadId}:reorder`),
3074 threadId,
3075 runId: second.id,
3076 beforeRunId: first.id,
3077 });
3078 assert.equal(yield* orchestrator.resumeQueuedRuns, 0);
3079 const resume = {
3080 type: "queue.resume" as const,
3081 commandId: CommandId.make(`${threadId}:resume`),
3082 threadId,
3083 };
3084 yield* orchestrator.dispatch(resume);
3085 yield* orchestrator.dispatch(resume);
3086 const resumed = yield* orchestrator.getThreadProjection(threadId);
3087 assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting");
3088 assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued");
3089 assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld));
3090 assert.equal(
3091 resumed.messages.find((row) => row.id === second.userMessageId)?.text,
3092 "Edited second message",
3046 assert.deepEqual(
3047 held.messages.find((row) => row.id === run.userMessageId),
3048 before.messages.find((row) => row.id === run.userMessageId),
30933049 );
30943050 assert.equal(
3095 resumed.runs.length,
3096 3,
3097 "resume retries must not duplicate messages or runs",
3051 held.attempts.find((row) => row.id === run.activeAttemptId)?.status,
3052 "pending",
30983053 );
3099 }),
3100 );
3101 }
3054 assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending");
3055 }
3056 // Editing and reordering are allowed without releasing the hold.
3057 const first = queued[0]!;
3058 const second = queued[1]!;
3059 yield* orchestrator.dispatch({
3060 type: "queued-run.edit",
3061 commandId: CommandId.make(`${threadId}:edit`),
3062 threadId,
3063 runId: second.id,
3064 text: "Edited second message",
3065 });
3066 yield* orchestrator.dispatch({
3067 type: "queued-run.reorder",
3068 commandId: CommandId.make(`${threadId}:reorder`),
3069 threadId,
3070 runId: second.id,
3071 beforeRunId: first.id,
3072 });
3073 assert.equal(yield* orchestrator.resumeQueuedRuns, 0);
3074 const resume = {
3075 type: "queue.resume" as const,
3076 commandId: CommandId.make(`${threadId}:resume`),
3077 threadId,
3078 };
3079 yield* orchestrator.dispatch(resume);
3080 yield* orchestrator.dispatch(resume);
3081 const resumed = yield* orchestrator.getThreadProjection(threadId);
3082 assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting");
3083 assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued");
3084 assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld));
3085 assert.equal(
3086 resumed.messages.find((row) => row.id === second.userMessageId)?.text,
3087 "Edited second message",
3088 );
3089 assert.equal(resumed.runs.length, 3, "resume retries must not duplicate messages or runs");
3090 }),
3091 );
31023092
31033093 it.effect("edits and removes queued runs", () =>
31043094 Effect.gen(function* () {
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts+44-39
......@@ -221,19 +221,19 @@ function runFixtureProviderWithRegisteredHarness(input: {
221221}
222222
223223describe("orchestrator replay fixtures", () => {
224 for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) {
225 for (const provider of fixture.providers) {
226 it.effect(
227 `runs ${fixture.name}/${provider.driver} through OrchestratorV2 using deterministic replay`,
228 () =>
229 runFixtureProviderWithRegisteredHarness({
230 fixtureName: fixture.name,
231 buildInput: fixture.buildInput,
232 driver: provider,
233 }),
234 );
235 }
236 }
224 it.effect.each(
225 ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) =>
226 fixture.providers.map(
227 (provider) => [fixture.name, provider.driver, fixture, provider] as const,
228 ),
229 ),
230 )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) =>
231 runFixtureProviderWithRegisteredHarness({
232 fixtureName: fixture.name,
233 buildInput: fixture.buildInput,
234 driver: provider,
235 }),
236 );
237237
238238 const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find(
239239 (fixture) => fixture.name === "message_steering",
......@@ -254,33 +254,38 @@ describe("orchestrator replay fixtures", () => {
254254 // A later OpenCode may change an execution start's shape; the client then
255255 // reads it as `unreadable.execution.started`. A subagent's turn and a
256256 // background follow-up must still start from it.
257 for (const [fixtureName, label] of [
258 ["opencode2_subagent", "session.execution.started.2"],
259 ["opencode2_background", "session.execution.started.3"],
260 ] as const) {
261 const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find(
262 (candidate) => candidate.name === fixtureName,
263 );
264 const provider = fixture?.providers[0];
265 if (fixture === undefined || provider === undefined) continue;
266 it.effect(
267 `runs ${fixtureName} when ${label} is an execution start this build cannot decode`,
268 () =>
269 runFixtureProviderWithRegisteredHarness({
270 fixtureName,
271 buildInput: fixture.buildInput,
272 driver: provider,
273 transformTranscript: (transcript) => ({
274 ...transcript,
275 entries: transcript.entries.map((entry) =>
276 entry.type === "emit_inbound" && entry.label === label
277 ? undecodableEvent(entry)
278 : entry,
279 ),
280 }),
257 it.effect.each(
258 (
259 [
260 ["opencode2_subagent", "session.execution.started.2"],
261 ["opencode2_background", "session.execution.started.3"],
262 ] as const
263 ).flatMap(([fixtureName, label]) => {
264 const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find(
265 (candidate) => candidate.name === fixtureName,
266 );
267 const provider = fixture?.providers[0];
268 return fixture === undefined || provider === undefined
269 ? []
270 : [[fixtureName, label, fixture, provider] as const];
271 }),
272 )(
273 "runs %s when %s is an execution start this build cannot decode",
274 ([fixtureName, label, fixture, provider]) =>
275 runFixtureProviderWithRegisteredHarness({
276 fixtureName,
277 buildInput: fixture.buildInput,
278 driver: provider,
279 transformTranscript: (transcript) => ({
280 ...transcript,
281 entries: transcript.entries.map((entry) =>
282 entry.type === "emit_inbound" && entry.label === label
283 ? undecodableEvent(entry)
284 : entry,
285 ),
281286 }),
282 );
283 }
287 }),
288 );
284289});
285290
286291/** 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* (
374374});
375375
376376describe("orchestration v2 provider switching", () => {
377 for (const scenario of [
377 it.live.each([
378378 "compact-native",
379379 "compact-fallback",
380380 "compact-legacy",
......@@ -408,35 +408,496 @@ describe("orchestration v2 provider switching", () => {
408408 "prior-images-replacement-native",
409409 "prior-images-legacy-replacement-native",
410410 "delivery-write-failure",
411 ] as const) {
412 it.live(`preserves handoffs through ${scenario}`, () =>
411 ] as const)("preserves handoffs through %s", (scenario) =>
412 Effect.scoped(
413 Effect.gen(function* () {
414 const cwd = yield* checkpointWorkspace(`handoff-${scenario}`);
415 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
416 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
417 const reasoningScenario = scenario.includes("reasoning-change");
418 const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage");
419 const modelScenario =
420 scenario.includes("model") || scenario.includes("option-change") || reasoningScenario;
421 const failStartOnce = yield* Ref.make(false);
422 const failResumeOnce = yield* Ref.make(false);
423 const generation = yield* Ref.make(0);
424 const priorImages = scenario.includes("prior-images");
425 const replaceNative = scenario.includes("replacement");
426 const capacityScenario = modelScenario || scenario.includes("reported-capacity");
427 const returning =
428 scenario === "large-current-input" || scenario.includes("-change-") || priorImages;
429 const targetSelection: ModelSelection = !modelScenario
430 ? CLAUDE_MODEL_SELECTION
431 : {
432 ...CLAUDE_MODEL_SELECTION,
433 ...(reasoningScenario
434 ? { options: [{ id: "reasoningEffort", value: "low" }] }
435 : scenario.includes("option-change")
436 ? { options: [{ id: "contextWindow", value: "1m" }] }
437 : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }),
438 };
439 const registry = ProviderAdapterRegistry.makeLayer([
440 makeTestAdapter({
441 instanceId: CODEX_MODEL_SELECTION.instanceId,
442 driver: CODEX_DRIVER,
443 capabilities: CodexProviderCapabilitiesV2,
444 modelSelection: CODEX_MODEL_SELECTION,
445 responseByRunOrdinal: { 1: "Original partial work" },
446 capturedTurns,
447 }),
448 makeTestAdapter({
449 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
450 driver: CLAUDE_DRIVER,
451 capabilities: ClaudeProviderCapabilitiesV2,
452 modelSelection: CLAUDE_MODEL_SELECTION,
453 responseByRunOrdinal: {},
454 capturedTurns,
455 ...(scenario.includes("retry") || scenario.includes("unsent") ? { failStartOnce } : {}),
456 ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}),
457 ...(scenario.includes("reported-capacity")
458 ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } }
459 : {}),
460 ...(turnUsageScenario
461 ? {
462 canReuseContextUsage: canReuseCodexContextUsage,
463 tokenUsageByRunOrdinal: {
464 2: {
465 usedTokens: replaceNative ? 30_000 : 37_321,
466 maxTokens: replaceNative
467 ? scenario.includes("small")
468 ? 32_000
469 : 64_000
470 : 258_400,
471 },
472 },
473 }
474 : modelScenario
475 ? {
476 getModelContextWindow: (selection: ModelSelection) =>
477 selection.model.endsWith("-large") ||
478 selection.options?.some(
479 (option) => option.id === "contextWindow" && option.value === "1m",
480 )
481 ? 1_000_000
482 : 32_000,
483 }
484 : scenario === "large-current-input" || priorImages
485 ? { getModelContextWindow: () => 32_000 }
486 : {}),
487 ...(scenario.endsWith("-native") || scenario === "large-current-input"
488 ? { injectedHistory }
489 : {}),
490 }),
491 ]);
492 yield* Effect.gen(function* () {
493 const orchestrator = yield* Orchestrator.OrchestratorV2;
494 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
495 const eventSink = yield* EventSink.EventSinkV2;
496 const screenshot: ChatAttachment = {
497 type: "image",
498 id: "screenshot",
499 name: "screenshot.png",
500 mimeType: "image/png",
501 sizeBytes: 100_000,
502 };
503 const screenshots = Array.from(
504 {
505 length:
506 scenario.includes("eight") || capacityScenario
507 ? 8
508 : scenario.includes("pair")
509 ? 2
510 : 1,
511 },
512 (_, index) => ({ ...screenshot, id: `screenshot-${index}` }),
513 );
514 const targetOrdinal = returning ? 4 : 2;
515 const dispatch = (ordinal: number, text: string, selection: ModelSelection) =>
516 orchestrator.dispatch({
517 type: "message.dispatch",
518 commandId: CommandId.make(`regression:${ordinal}`),
519 threadId,
520 messageId: MessageId.make(`regression:${ordinal}`),
521 createdBy: "user",
522 creationSource: "web",
523 text,
524 attachments:
525 turnUsageScenario && ordinal === 2
526 ? Array.from({ length: 8 }, (_, index) => ({
527 ...screenshot,
528 id: `prior-${index}`,
529 }))
530 : turnUsageScenario && ordinal >= targetOrdinal
531 ? [screenshot, { ...screenshot, id: "current-2" }]
532 : scenario.startsWith("screenshot") && ordinal >= targetOrdinal
533 ? screenshots
534 : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2)
535 ? [screenshot]
536 : [],
537 modelSelection: selection,
538 dispatchMode: { type: "start_immediately" },
539 });
540 const wait = (ordinal: number) =>
541 orchestrator.streamStoredEvents.pipe(
542 Stream.filter(
543 ({ event }) =>
544 event.type === "run.updated" &&
545 event.payload.ordinal === ordinal &&
546 (event.payload.status === "completed" || event.payload.status === "failed"),
547 ),
548 Stream.runHead,
549 Effect.andThen(worker.drain()),
550 );
551 yield* orchestrator.dispatch({
552 type: "thread.create",
553 commandId: CommandId.make("regression:create"),
554 threadId,
555 projectId,
556 createdBy: "user",
557 creationSource: "web",
558 title: "Handoff regression",
559 modelSelection: CODEX_MODEL_SELECTION,
560 runtimeMode: "full-access",
561 interactionMode: "default",
562 branch: null,
563 worktreePath: null,
564 });
565 yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION);
566 yield* wait(1);
567 const write = eventSink.write;
568 const spy =
569 scenario === "delivery-write-failure"
570 ? vi.spyOn(eventSink, "write").mockImplementation((input) =>
571 input.events.some(
572 (event) =>
573 event.type === "context-handoff.updated" &&
574 event.payload.delivery?.status === "inline",
575 )
576 ? Effect.fail(
577 new EventSink.EventSinkWriteError({
578 eventCount: input.events.length,
579 cause: "bookkeeping unavailable",
580 }),
581 )
582 : write(input),
583 )
584 : undefined;
585 yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore()));
586 const current = scenario.startsWith("compact")
587 ? "/compact"
588 : capacityScenario && !turnUsageScenario
589 ? "x".repeat(70_000)
590 : scenario === "large-current-input"
591 ? "x".repeat(9_000)
592 : "Continue work";
593 // First establish the returning native thread: the current request must
594 // not be charged as existing context on the subsequent handoff.
595 if (returning) {
596 if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true);
597 yield* dispatch(
598 2,
599 turnUsageScenario ? "x".repeat(27_460) : "Establish target",
600 CLAUDE_MODEL_SELECTION,
601 );
602 yield* wait(2);
603 if (modelScenario && !turnUsageScenario) {
604 const target = (yield* orchestrator.getThreadProjection(
605 threadId,
606 )).providerThreads.find(
607 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
608 )!;
609 yield* eventSink.write({
610 events: [
611 {
612 id: EventId.make("old-model-usage"),
613 type: "provider-thread.updated",
614 threadId,
615 occurredAt: yield* DateTime.now,
616 payload: {
617 ...target,
618 contextUsage: {
619 usedTokens: 30_000,
620 maxTokens: 32_000,
621 autoCompactThreshold: 31_000,
622 },
623 },
624 },
625 ],
626 });
627 }
628 if (scenario.includes("legacy-replacement")) {
629 const existing = yield* orchestrator.getThreadProjection(threadId);
630 yield* eventSink.write({
631 events: existing.attempts.map(
632 ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({
633 id: EventId.make(`legacy-attempt:${index}`),
634 type: "run-attempt.updated" as const,
635 threadId,
636 occurredAt: existing.thread.createdAt,
637 payload: legacy,
638 }),
639 ),
640 });
641 }
642 if (scenario.includes("telemetry")) {
643 const target = (yield* orchestrator.getThreadProjection(
644 threadId,
645 )).providerThreads.find(
646 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
647 )!;
648 yield* eventSink.write({
649 events: [
650 {
651 id: EventId.make("compacted-context-usage"),
652 type: "provider-thread.updated",
653 threadId,
654 occurredAt: yield* DateTime.now,
655 payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } },
656 },
657 ],
658 });
659 }
660 yield* dispatch(
661 3,
662 priorImages && !replaceNative
663 ? "New source constraint " + "q".repeat(9_000)
664 : "New source constraint",
665 CODEX_MODEL_SELECTION,
666 );
667 yield* wait(3);
668 }
669 if (replaceNative) {
670 const target = (yield* orchestrator.getThreadProjection(threadId)).providerThreads.find(
671 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
672 )!;
673 yield* orchestrator.dispatch({
674 type: "provider-session.detach",
675 commandId: CommandId.make("detach-for-native-replacement"),
676 threadId,
677 providerSessionId: target.providerSessionId!,
678 });
679 yield* worker.drain();
680 yield* Ref.set(failResumeOnce, true);
681 }
682 if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true);
683 yield* dispatch(targetOrdinal, current, targetSelection);
684 yield* wait(targetOrdinal);
685 if (scenario.includes("retry")) {
686 const failed = yield* orchestrator.getThreadProjection(threadId);
687 assert.equal(failed.runs.at(-1)?.status, "failed");
688 const failedUsage = failed.providerThreads.find(
689 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
690 )!.contextUsage;
691 if (reasoningScenario || scenario.includes("turn-usage")) {
692 assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 });
693 } else {
694 assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 });
695 }
696 if (reasoningScenario) {
697 const target = failed.providerThreads.find(
698 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
699 )!;
700 yield* orchestrator.dispatch({
701 type: "provider-session.detach",
702 commandId: CommandId.make("detach-before-reasoning-retry"),
703 threadId,
704 providerSessionId: target.providerSessionId!,
705 });
706 yield* worker.drain();
707 }
708 yield* dispatch(targetOrdinal + 1, current, targetSelection);
709 yield* wait(targetOrdinal + 1);
710 }
711 if (reasoningScenario && replaceNative) {
712 const replaced = yield* orchestrator.getThreadProjection(threadId);
713 assert.equal(
714 replaced.runs.at(-1)?.status,
715 scenario.includes("small") ? "failed" : "completed",
716 );
717 const target = replaced.providerThreads.find(
718 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
719 )!;
720 assert.isNull(target.contextUsage);
721 assert.equal(yield* Ref.get(generation), 2);
722 assert.equal(
723 (yield* Ref.get(capturedTurns)).at(-1)!.driver,
724 scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER,
725 );
726 return;
727 }
728 if (replaceNative) {
729 if (scenario.includes("legacy-replacement")) {
730 const recovered = yield* orchestrator.getThreadProjection(threadId);
731 const handoff = recovered.contextHandoffs.at(-1)!;
732 const { history: _history, ...legacyHandoff } = handoff;
733 yield* eventSink.write({
734 events: [
735 {
736 id: EventId.make("legacy-replacement-handoff"),
737 type: "context-handoff.updated",
738 threadId,
739 occurredAt: yield* DateTime.now,
740 payload: {
741 ...legacyHandoff,
742 delivery: {
743 nativeThreadId: handoff.delivery!.nativeThreadId,
744 status: handoff.delivery!.status,
745 itemIds: [],
746 },
747 },
748 },
749 ],
750 });
751 }
752 yield* dispatch(
753 targetOrdinal + 1,
754 "Continue after native replacement",
755 CLAUDE_MODEL_SELECTION,
756 );
757 yield* wait(targetOrdinal + 1);
758 yield* dispatch(
759 targetOrdinal + 2,
760 "Another source constraint " + "q".repeat(9_000),
761 CODEX_MODEL_SELECTION,
762 );
763 yield* wait(targetOrdinal + 2);
764 yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION);
765 yield* wait(targetOrdinal + 3);
766 }
767 const projection = yield* orchestrator.getThreadProjection(threadId);
768 assert.equal(projection.runs.at(-1)?.status, "completed");
769 if (priorImages) {
770 const handoff = projection.contextHandoffs.at(-1)!;
771 const context = scenario.endsWith("-native")
772 ? yield* encodeJson(yield* Ref.get(injectedHistory))
773 : (yield* Ref.get(capturedTurns)).at(-1)!.text;
774 const shouldFit =
775 turnUsageScenario ||
776 scenario.startsWith("imported") ||
777 scenario.includes("telemetry") ||
778 scenario.includes("unsent") ||
779 replaceNative;
780 const sourceText =
781 `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000);
782 const sourceItem = projection.turnItems.find(
783 (item) => item.type === "user_message" && item.text === sourceText,
784 )!;
785 assert.isDefined(sourceItem);
786 if (shouldFit) {
787 assert.include(context, sourceText);
788 assert.include(
789 projection.contextHandoffs
790 .filter(
791 (record) =>
792 record.targetRunId ===
793 (scenario.includes("retry")
794 ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id
795 : projection.runs.at(-1)!.id),
796 )
797 .flatMap((record) => record.delivery?.itemIds ?? []),
798 sourceItem.id,
799 );
800 } else {
801 assert.notInclude(context, "q".repeat(9_000));
802 assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0);
803 }
804 const latestRun = projection.runs.at(-1)!;
805 const latestAttempt = projection.attempts.find(
806 (attempt) => attempt.id === latestRun.activeAttemptId,
807 )!;
808 const target = projection.providerThreads.find(
809 (thread) => thread.id === latestAttempt.providerThreadId,
810 )!;
811 assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId);
812 if (turnUsageScenario) {
813 if (replaceNative) assert.isNull(target.contextUsage);
814 else
815 assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 });
816 assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2);
817 }
818 if (replaceNative) assert.equal(yield* Ref.get(generation), 2);
819 return;
820 }
821 const handoff = projection.contextHandoffs.at(-1)!;
822 if (scenario.startsWith("screenshot")) {
823 assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots);
824 }
825 if (scenario === "compact-fallback" || scenario === "compact-legacy") {
826 if (scenario === "compact-legacy") {
827 const { history: _history, ...legacyHandoff } = handoff;
828 yield* eventSink.write({
829 events: [
830 {
831 id: EventId.make("legacy-handoff-shape"),
832 type: "context-handoff.updated",
833 threadId,
834 runId: handoff.targetRunId,
835 occurredAt: yield* DateTime.now,
836 payload: legacyHandoff,
837 },
838 ],
839 });
840 }
841 assert.isUndefined(handoff.delivery);
842 yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION);
843 yield* wait(3);
844 assert.include(
845 (yield* Ref.get(capturedTurns)).at(-1)!.text,
846 "Original request with constraints",
847 );
848 assert.equal(
849 (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery
850 ?.status,
851 "inline",
852 );
853 } else if (
854 scenario === "delivery-write-failure" ||
855 (scenario.startsWith("screenshot") && scenario.endsWith("fallback"))
856 ) {
857 assert.equal(
858 handoff.delivery?.status,
859 scenario.startsWith("screenshot") ? "inline" : "pending",
860 );
861 assert.include(
862 (yield* Ref.get(capturedTurns)).at(-1)!.text,
863 "Original request with constraints",
864 );
865 } else {
866 assert.equal(handoff.delivery?.status, "injected");
867 assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current);
868 assert.include(
869 yield* encodeJson(yield* Ref.get(injectedHistory)),
870 "Original request with constraints",
871 );
872 }
873 }).pipe(
874 Effect.provide(
875 makeOrchestratorV2ReplayLayerWithRegistry(
876 {
877 name: `handoff-${scenario}`,
878 runtimePolicyOverride: {
879 cwd,
880 approvalPolicy: "never",
881 sandboxPolicy: { type: "readOnly" },
882 },
883 },
884 registry,
885 ),
886 ),
887 );
888 }),
889 ),
890 );
891 it.live.each(["turn-start", "injection", "large-missed-request"] as const)(
892 "recovers %s failure without duplicating history in the same native thread",
893 (failure) =>
413894 Effect.scoped(
414895 Effect.gen(function* () {
415 const cwd = yield* checkpointWorkspace(`handoff-${scenario}`);
896 const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`);
416897 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
417898 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
418 const reasoningScenario = scenario.includes("reasoning-change");
419 const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage");
420 const modelScenario =
421 scenario.includes("model") || scenario.includes("option-change") || reasoningScenario;
422 const failStartOnce = yield* Ref.make(false);
423 const failResumeOnce = yield* Ref.make(false);
899 const failOnce = yield* Ref.make(true);
424900 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 };
440901 const registry = ProviderAdapterRegistry.makeLayer([
441902 makeTestAdapter({
442903 instanceId: CODEX_MODEL_SELECTION.instanceId,
......@@ -453,94 +914,31 @@ describe("orchestration v2 provider switching", () => {
453914 modelSelection: CLAUDE_MODEL_SELECTION,
454915 responseByRunOrdinal: {},
455916 capturedTurns,
456 ...(scenario.includes("retry") || scenario.includes("unsent")
457 ? { failStartOnce }
458 : {}),
459 ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}),
460 ...(scenario.includes("reported-capacity")
461 ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } }
462 : {}),
463 ...(turnUsageScenario
464 ? {
465 canReuseContextUsage: canReuseCodexContextUsage,
466 tokenUsageByRunOrdinal: {
467 2: {
468 usedTokens: replaceNative ? 30_000 : 37_321,
469 maxTokens: replaceNative
470 ? scenario.includes("small")
471 ? 32_000
472 : 64_000
473 : 258_400,
474 },
475 },
476 }
477 : modelScenario
478 ? {
479 getModelContextWindow: (selection: ModelSelection) =>
480 selection.model.endsWith("-large") ||
481 selection.options?.some(
482 (option) => option.id === "contextWindow" && option.value === "1m",
483 )
484 ? 1_000_000
485 : 32_000,
486 }
487 : scenario === "large-current-input" || priorImages
488 ? { getModelContextWindow: () => 32_000 }
489 : {}),
490 ...(scenario.endsWith("-native") || scenario === "large-current-input"
491 ? { injectedHistory }
492 : {}),
917 injectedHistory,
918 nativeThreadGeneration: generation,
919 getModelContextWindow: () => 32_000,
920 ...(failure !== "injection"
921 ? { failStartOnce: failOnce }
922 : { failInjectionOnce: failOnce }),
493923 }),
494924 ]);
495925 yield* Effect.gen(function* () {
496926 const orchestrator = yield* Orchestrator.OrchestratorV2;
497927 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;
518928 const dispatch = (ordinal: number, text: string, selection: ModelSelection) =>
519929 orchestrator.dispatch({
520930 type: "message.dispatch",
521 commandId: CommandId.make(`regression:${ordinal}`),
931 commandId: CommandId.make(`retry:${ordinal}`),
522932 threadId,
523 messageId: MessageId.make(`regression:${ordinal}`),
933 messageId: MessageId.make(`retry:${ordinal}`),
524934 createdBy: "user",
525935 creationSource: "web",
526936 text,
527 attachments:
528 turnUsageScenario && ordinal === 2
529 ? Array.from({ length: 8 }, (_, index) => ({
530 ...screenshot,
531 id: `prior-${index}`,
532 }))
533 : turnUsageScenario && ordinal >= targetOrdinal
534 ? [screenshot, { ...screenshot, id: "current-2" }]
535 : scenario.startsWith("screenshot") && ordinal >= targetOrdinal
536 ? screenshots
537 : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2)
538 ? [screenshot]
539 : [],
937 attachments: [],
540938 modelSelection: selection,
541939 dispatchMode: { type: "start_immediately" },
542940 });
543 const wait = (ordinal: number) =>
941 const wait = (ordinal: number, status: "failed" | "completed") =>
544942 orchestrator.streamStoredEvents.pipe(
545943 Stream.filter(
546944 ({ event }) =>
......@@ -550,15 +948,23 @@ describe("orchestration v2 provider switching", () => {
550948 ),
551949 Stream.runHead,
552950 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 ),
553959 );
554960 yield* orchestrator.dispatch({
555961 type: "thread.create",
556 commandId: CommandId.make("regression:create"),
962 commandId: CommandId.make("retry:create"),
557963 threadId,
558964 projectId,
559965 createdBy: "user",
560966 creationSource: "web",
561 title: "Handoff regression",
967 title: "Handoff retry",
562968 modelSelection: CODEX_MODEL_SELECTION,
563969 runtimeMode: "full-access",
564970 interactionMode: "default",
......@@ -566,320 +972,71 @@ describe("orchestration v2 provider switching", () => {
566972 worktreePath: null,
567973 });
568974 yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION);
569 yield* wait(1);
570 const write = eventSink.write;
571 const spy =
572 scenario === "delivery-write-failure"
573 ? vi.spyOn(eventSink, "write").mockImplementation((input) =>
574 input.events.some(
575 (event) =>
576 event.type === "context-handoff.updated" &&
577 event.payload.delivery?.status === "inline",
578 )
579 ? Effect.fail(
580 new EventSink.EventSinkWriteError({
581 eventCount: input.events.length,
582 cause: "bookkeeping unavailable",
583 }),
584 )
585 : write(input),
586 )
587 : undefined;
588 yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore()));
589 const current = scenario.startsWith("compact")
590 ? "/compact"
591 : capacityScenario && !turnUsageScenario
592 ? "x".repeat(70_000)
593 : scenario === "large-current-input"
594 ? "x".repeat(9_000)
595 : "Continue work";
596 // First establish the returning native thread: the current request must
597 // not be charged as existing context on the subsequent handoff.
598 if (returning) {
599 if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true);
600 yield* dispatch(
601 2,
602 turnUsageScenario ? "x".repeat(27_460) : "Establish target",
603 CLAUDE_MODEL_SELECTION,
975 yield* wait(1, "completed");
976 yield* dispatch(
977 2,
978 failure === "large-missed-request" ? "x".repeat(7_000) : "First target request",
979 CLAUDE_MODEL_SELECTION,
980 );
981 yield* wait(2, "failed");
982 const failed = yield* orchestrator.getThreadProjection(threadId);
983 const failedHandoff = failed.contextHandoffs.at(-1)!;
984 assert.equal(
985 failedHandoff.delivery?.status,
986 failure !== "injection" ? "injected" : "pending",
987 );
988 const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length;
989 const retryText =
990 failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request";
991 yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION);
992 yield* wait(3, "completed");
993 const retried = yield* orchestrator.getThreadProjection(threadId);
994 const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!;
995 assert.equal(lastTurn.text, retryText);
996 if (failure !== "injection") {
997 const delta = yield* encodeJson(
998 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
604999 );
605 yield* wait(2);
606 if (modelScenario && !turnUsageScenario) {
607 const target = (yield* orchestrator.getThreadProjection(
608 threadId,
609 )).providerThreads.find(
610 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
1000 if (failure === "large-missed-request") {
1001 assert.include(delta, "omitted 1 items");
1002 const missedRequest = retried.turnItems.find(
1003 (item) => item.type === "user_message" && item.text === "x".repeat(7_000),
6111004 )!;
612 yield* eventSink.write({
613 events: [
614 {
615 id: EventId.make("old-model-usage"),
616 type: "provider-thread.updated",
617 threadId,
618 occurredAt: yield* DateTime.now,
619 payload: {
620 ...target,
621 contextUsage: {
622 usedTokens: 30_000,
623 maxTokens: 32_000,
624 autoCompactThreshold: 31_000,
625 },
626 },
627 },
628 ],
629 });
630 }
631 if (scenario.includes("legacy-replacement")) {
632 const existing = yield* orchestrator.getThreadProjection(threadId);
633 yield* eventSink.write({
634 events: existing.attempts.map(
635 ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({
636 id: EventId.make(`legacy-attempt:${index}`),
637 type: "run-attempt.updated" as const,
638 threadId,
639 occurredAt: existing.thread.createdAt,
640 payload: legacy,
641 }),
642 ),
643 });
644 }
645 if (scenario.includes("telemetry")) {
646 const target = (yield* orchestrator.getThreadProjection(
647 threadId,
648 )).providerThreads.find(
649 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
650 )!;
651 yield* eventSink.write({
652 events: [
653 {
654 id: EventId.make("compacted-context-usage"),
655 type: "provider-thread.updated",
656 threadId,
657 occurredAt: yield* DateTime.now,
658 payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } },
659 },
660 ],
661 });
662 }
663 yield* dispatch(
664 3,
665 priorImages && !replaceNative
666 ? "New source constraint " + "q".repeat(9_000)
667 : "New source constraint",
668 CODEX_MODEL_SELECTION,
669 );
670 yield* wait(3);
671 }
672 if (replaceNative) {
673 const target = (yield* orchestrator.getThreadProjection(
674 threadId,
675 )).providerThreads.find(
676 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
677 )!;
678 yield* orchestrator.dispatch({
679 type: "provider-session.detach",
680 commandId: CommandId.make("detach-for-native-replacement"),
681 threadId,
682 providerSessionId: target.providerSessionId!,
683 });
684 yield* worker.drain();
685 yield* Ref.set(failResumeOnce, true);
686 }
687 if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true);
688 yield* dispatch(targetOrdinal, current, targetSelection);
689 yield* wait(targetOrdinal);
690 if (scenario.includes("retry")) {
691 const failed = yield* orchestrator.getThreadProjection(threadId);
692 assert.equal(failed.runs.at(-1)?.status, "failed");
693 const failedUsage = failed.providerThreads.find(
694 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
695 )!.contextUsage;
696 if (reasoningScenario || scenario.includes("turn-usage")) {
697 assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 });
698 } else {
699 assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 });
700 }
701 if (reasoningScenario) {
702 const target = failed.providerThreads.find(
703 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
704 )!;
705 yield* orchestrator.dispatch({
706 type: "provider-session.detach",
707 commandId: CommandId.make("detach-before-reasoning-retry"),
708 threadId,
709 providerSessionId: target.providerSessionId!,
710 });
711 yield* worker.drain();
712 }
713 yield* dispatch(targetOrdinal + 1, current, targetSelection);
714 yield* wait(targetOrdinal + 1);
715 }
716 if (reasoningScenario && replaceNative) {
717 const replaced = yield* orchestrator.getThreadProjection(threadId);
718 assert.equal(
719 replaced.runs.at(-1)?.status,
720 scenario.includes("small") ? "failed" : "completed",
721 );
722 const target = replaced.providerThreads.find(
723 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
724 )!;
725 assert.isNull(target.contextUsage);
1005 const delivery = retried.contextHandoffs.at(-1)!.delivery!;
1006 assert.include(delivery.omittedItemIds ?? [], missedRequest.id);
1007 assert.notInclude(delivery.itemIds, missedRequest.id);
1008 } else assert.include(delta, "First target request");
1009 assert.notInclude(delta, "Original request with constraints");
1010 assert.notInclude(delta, "Original partial work");
1011 assert.equal(yield* Ref.get(generation), 1);
1012 } else {
7261013 assert.equal(yield* Ref.get(generation), 2);
727 assert.equal(
728 (yield* Ref.get(capturedTurns)).at(-1)!.driver,
729 scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER,
730 );
731 return;
732 }
733 if (replaceNative) {
734 if (scenario.includes("legacy-replacement")) {
735 const recovered = yield* orchestrator.getThreadProjection(threadId);
736 const handoff = recovered.contextHandoffs.at(-1)!;
737 const { history: _history, ...legacyHandoff } = handoff;
738 yield* eventSink.write({
739 events: [
740 {
741 id: EventId.make("legacy-replacement-handoff"),
742 type: "context-handoff.updated",
743 threadId,
744 occurredAt: yield* DateTime.now,
745 payload: {
746 ...legacyHandoff,
747 delivery: {
748 nativeThreadId: handoff.delivery!.nativeThreadId,
749 status: handoff.delivery!.status,
750 itemIds: [],
751 },
752 },
753 },
754 ],
755 });
756 }
757 yield* dispatch(
758 targetOrdinal + 1,
759 "Continue after native replacement",
760 CLAUDE_MODEL_SELECTION,
761 );
762 yield* wait(targetOrdinal + 1);
763 yield* dispatch(
764 targetOrdinal + 2,
765 "Another source constraint " + "q".repeat(9_000),
766 CODEX_MODEL_SELECTION,
767 );
768 yield* wait(targetOrdinal + 2);
769 yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION);
770 yield* wait(targetOrdinal + 3);
771 }
772 const projection = yield* orchestrator.getThreadProjection(threadId);
773 assert.equal(projection.runs.at(-1)?.status, "completed");
774 if (priorImages) {
775 const handoff = projection.contextHandoffs.at(-1)!;
776 const context = scenario.endsWith("-native")
777 ? yield* encodeJson(yield* Ref.get(injectedHistory))
778 : (yield* Ref.get(capturedTurns)).at(-1)!.text;
779 const shouldFit =
780 turnUsageScenario ||
781 scenario.startsWith("imported") ||
782 scenario.includes("telemetry") ||
783 scenario.includes("unsent") ||
784 replaceNative;
785 const sourceText =
786 `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000);
787 const sourceItem = projection.turnItems.find(
788 (item) => item.type === "user_message" && item.text === sourceText,
789 )!;
790 assert.isDefined(sourceItem);
791 if (shouldFit) {
792 assert.include(context, sourceText);
793 assert.include(
794 projection.contextHandoffs
795 .filter(
796 (record) =>
797 record.targetRunId ===
798 (scenario.includes("retry")
799 ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id
800 : projection.runs.at(-1)!.id),
801 )
802 .flatMap((record) => record.delivery?.itemIds ?? []),
803 sourceItem.id,
804 );
805 } else {
806 assert.notInclude(context, "q".repeat(9_000));
807 assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0);
808 }
809 const latestRun = projection.runs.at(-1)!;
810 const latestAttempt = projection.attempts.find(
811 (attempt) => attempt.id === latestRun.activeAttemptId,
812 )!;
813 const target = projection.providerThreads.find(
814 (thread) => thread.id === latestAttempt.providerThreadId,
815 )!;
816 assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId);
817 if (turnUsageScenario) {
818 if (replaceNative) assert.isNull(target.contextUsage);
819 else
820 assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 });
821 assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2);
822 }
823 if (replaceNative) assert.equal(yield* Ref.get(generation), 2);
824 return;
825 }
826 const handoff = projection.contextHandoffs.at(-1)!;
827 if (scenario.startsWith("screenshot")) {
828 assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots);
829 }
830 if (scenario === "compact-fallback" || scenario === "compact-legacy") {
831 if (scenario === "compact-legacy") {
832 const { history: _history, ...legacyHandoff } = handoff;
833 yield* eventSink.write({
834 events: [
835 {
836 id: EventId.make("legacy-handoff-shape"),
837 type: "context-handoff.updated",
838 threadId,
839 runId: handoff.targetRunId,
840 occurredAt: yield* DateTime.now,
841 payload: legacyHandoff,
842 },
843 ],
844 });
845 }
846 assert.isUndefined(handoff.delivery);
847 yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION);
848 yield* wait(3);
849 assert.include(
850 (yield* Ref.get(capturedTurns)).at(-1)!.text,
851 "Original request with constraints",
852 );
853 assert.equal(
854 (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery
855 ?.status,
856 "inline",
857 );
858 } else if (
859 scenario === "delivery-write-failure" ||
860 (scenario.startsWith("screenshot") && scenario.endsWith("fallback"))
861 ) {
862 assert.equal(
863 handoff.delivery?.status,
864 scenario.startsWith("screenshot") ? "inline" : "pending",
865 );
866 assert.include(
867 (yield* Ref.get(capturedTurns)).at(-1)!.text,
868 "Original request with constraints",
1014 assert.notEqual(
1015 retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId,
1016 failedHandoff.delivery?.nativeThreadId,
8691017 );
870 } else {
871 assert.equal(handoff.delivery?.status, "injected");
872 assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current);
873 assert.include(
874 yield* encodeJson(yield* Ref.get(injectedHistory)),
875 "Original request with constraints",
1018 const newHistory = yield* encodeJson(
1019 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
8761020 );
1021 assert.include(newHistory, "Original request with constraints");
1022 assert.include(newHistory, "Original partial work");
1023 assert.notInclude(newHistory, "Retry target request");
8771024 }
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 );
8781035 }).pipe(
8791036 Effect.provide(
8801037 makeOrchestratorV2ReplayLayerWithRegistry(
8811038 {
882 name: `handoff-${scenario}`,
1039 name: `handoff-retry-${failure}`,
8831040 runtimePolicyOverride: {
8841041 cwd,
8851042 approvalPolicy: "never",
......@@ -892,368 +1049,194 @@ describe("orchestration v2 provider switching", () => {
8921049 );
8931050 }),
8941051 ),
895 );
896 }
897 for (const failure of ["turn-start", "injection", "large-missed-request"] as const) {
898 it.live(
899 `recovers ${failure} failure without duplicating history in the same native thread`,
900 () =>
901 Effect.scoped(
902 Effect.gen(function* () {
903 const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`);
904 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
905 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
906 const failOnce = yield* Ref.make(true);
907 const generation = yield* Ref.make(0);
908 const registry = ProviderAdapterRegistry.makeLayer([
909 makeTestAdapter({
910 instanceId: CODEX_MODEL_SELECTION.instanceId,
911 driver: CODEX_DRIVER,
912 capabilities: CodexProviderCapabilitiesV2,
913 modelSelection: CODEX_MODEL_SELECTION,
914 responseByRunOrdinal: { 1: "Original partial work" },
915 capturedTurns,
916 }),
917 makeTestAdapter({
918 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
919 driver: CLAUDE_DRIVER,
920 capabilities: ClaudeProviderCapabilitiesV2,
921 modelSelection: CLAUDE_MODEL_SELECTION,
922 responseByRunOrdinal: {},
923 capturedTurns,
924 injectedHistory,
925 nativeThreadGeneration: generation,
926 getModelContextWindow: () => 32_000,
927 ...(failure !== "injection"
928 ? { failStartOnce: failOnce }
929 : { failInjectionOnce: failOnce }),
930 }),
931 ]);
932 yield* Effect.gen(function* () {
933 const orchestrator = yield* Orchestrator.OrchestratorV2;
934 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
935 const dispatch = (ordinal: number, text: string, selection: ModelSelection) =>
936 orchestrator.dispatch({
937 type: "message.dispatch",
938 commandId: CommandId.make(`retry:${ordinal}`),
939 threadId,
940 messageId: MessageId.make(`retry:${ordinal}`),
941 createdBy: "user",
942 creationSource: "web",
943 text,
944 attachments: [],
945 modelSelection: selection,
946 dispatchMode: { type: "start_immediately" },
947 });
948 const wait = (ordinal: number, status: "failed" | "completed") =>
949 orchestrator.streamStoredEvents.pipe(
950 Stream.filter(
951 ({ event }) =>
952 event.type === "run.updated" &&
953 event.payload.ordinal === ordinal &&
954 (event.payload.status === "completed" || event.payload.status === "failed"),
955 ),
956 Stream.runHead,
957 Effect.andThen(worker.drain()),
958 Effect.andThen(
959 Effect.gen(function* () {
960 assert.equal(
961 (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status,
962 status,
963 );
964 }),
965 ),
966 );
967 yield* orchestrator.dispatch({
968 type: "thread.create",
969 commandId: CommandId.make("retry:create"),
970 threadId,
971 projectId,
972 createdBy: "user",
973 creationSource: "web",
974 title: "Handoff retry",
975 modelSelection: CODEX_MODEL_SELECTION,
976 runtimeMode: "full-access",
977 interactionMode: "default",
978 branch: null,
979 worktreePath: null,
980 });
981 yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION);
982 yield* wait(1, "completed");
983 yield* dispatch(
984 2,
985 failure === "large-missed-request" ? "x".repeat(7_000) : "First target request",
986 CLAUDE_MODEL_SELECTION,
987 );
988 yield* wait(2, "failed");
989 const failed = yield* orchestrator.getThreadProjection(threadId);
990 const failedHandoff = failed.contextHandoffs.at(-1)!;
991 assert.equal(
992 failedHandoff.delivery?.status,
993 failure !== "injection" ? "injected" : "pending",
994 );
995 const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length;
996 const retryText =
997 failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request";
998 yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION);
999 yield* wait(3, "completed");
1000 const retried = yield* orchestrator.getThreadProjection(threadId);
1001 const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!;
1002 assert.equal(lastTurn.text, retryText);
1003 if (failure !== "injection") {
1004 const delta = yield* encodeJson(
1005 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
1006 );
1007 if (failure === "large-missed-request") {
1008 assert.include(delta, "omitted 1 items");
1009 const missedRequest = retried.turnItems.find(
1010 (item) => item.type === "user_message" && item.text === "x".repeat(7_000),
1011 )!;
1012 const delivery = retried.contextHandoffs.at(-1)!.delivery!;
1013 assert.include(delivery.omittedItemIds ?? [], missedRequest.id);
1014 assert.notInclude(delivery.itemIds, missedRequest.id);
1015 } else assert.include(delta, "First target request");
1016 assert.notInclude(delta, "Original request with constraints");
1017 assert.notInclude(delta, "Original partial work");
1018 assert.equal(yield* Ref.get(generation), 1);
1019 } else {
1020 assert.equal(yield* Ref.get(generation), 2);
1021 assert.notEqual(
1022 retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId,
1023 failedHandoff.delivery?.nativeThreadId,
1024 );
1025 const newHistory = yield* encodeJson(
1026 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
1027 );
1028 assert.include(newHistory, "Original request with constraints");
1029 assert.include(newHistory, "Original partial work");
1030 assert.notInclude(newHistory, "Retry target request");
1031 }
1032 const beforeFollowup = yield* Ref.get(injectedHistory);
1033 const followup = "z".repeat(6_000);
1034 yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION);
1035 yield* wait(4, "completed");
1036 assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup);
1037 assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup);
1038 assert.equal(
1039 (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length,
1040 retried.contextHandoffs.length,
1041 );
1042 }).pipe(
1043 Effect.provide(
1044 makeOrchestratorV2ReplayLayerWithRegistry(
1045 {
1046 name: `handoff-retry-${failure}`,
1047 runtimePolicyOverride: {
1048 cwd,
1049 approvalPolicy: "never",
1050 sandboxPolicy: { type: "readOnly" },
1051 },
1052 },
1053 registry,
1054 ),
1055 ),
1056 );
1057 }),
1058 ),
1059 );
1060 }
1052 );
10611053
1062 for (const status of ["failed", "interrupted"] as const) {
1063 for (const queued of [false, true]) {
1064 for (const returning of [false, true]) {
1065 for (const native of [false, true]) {
1066 it.live(
1067 `hands off ${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`,
1068 () =>
1069 Effect.scoped(
1070 Effect.gen(function* () {
1071 const cwd = yield* checkpointWorkspace(
1072 `handoff-${status}-${queued}-${returning}`,
1073 );
1074 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1075 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
1076 const started = yield* Deferred.make<void>();
1077 const release = yield* Deferred.make<void>();
1078 const sourceOrdinal = returning ? 2 : 1;
1079 const sourceSelection = returning
1080 ? CLAUDE_MODEL_SELECTION
1081 : CODEX_MODEL_SELECTION;
1082 const targetSelection = returning
1083 ? CODEX_MODEL_SELECTION
1084 : CLAUDE_MODEL_SELECTION;
1085 const originalPrompt =
1086 "Keep the release marker violet and preserve the existing API.";
1087 const partialResponse = "I checked the API and found the release configuration.";
1088 const registryLayer = ProviderAdapterRegistry.makeLayer(
1089 (
1090 [
1091 [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2],
1092 [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2],
1093 ] as const
1094 ).map(([modelSelection, driver, capabilities]) =>
1095 makeTestAdapter({
1096 instanceId: modelSelection.instanceId,
1097 driver,
1098 capabilities,
1099 modelSelection,
1100 responseByRunOrdinal: { [sourceOrdinal]: partialResponse },
1101 capturedTurns,
1102 ...(native && modelSelection.instanceId === targetSelection.instanceId
1103 ? { injectedHistory }
1104 : {}),
1105 ...(modelSelection.instanceId === sourceSelection.instanceId
1106 ? {
1107 failedRunOrdinals: new Set(
1108 status === "failed" ? [sourceOrdinal] : [],
1109 ),
1110 interruptedRunOrdinals: new Set(
1111 status === "interrupted" ? [sourceOrdinal] : [],
1112 ),
1113 holdRunOrdinal: sourceOrdinal,
1114 holdFirstTurn: started,
1115 releaseFirstTurn: release,
1116 }
1117 : {}),
1118 }),
1119 ),
1120 );
1121 yield* Effect.gen(function* () {
1122 const orchestrator = yield* Orchestrator.OrchestratorV2;
1123 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1124 const waitForRun = (
1125 ordinal: number,
1126 expectedStatus: "completed" | "failed" | "interrupted",
1127 ) =>
1128 orchestrator.streamStoredEvents.pipe(
1129 Stream.filter(
1130 ({ event }) =>
1131 event.type === "run.updated" &&
1132 event.threadId === threadId &&
1133 event.payload.ordinal === ordinal &&
1134 event.payload.status === expectedStatus,
1135 ),
1136 Stream.runHead,
1137 Effect.andThen(worker.drain()),
1138 );
1139 const dispatch = (
1140 key: string,
1141 text: string,
1142 modelSelection: ModelSelection,
1143 queue = false,
1144 ) =>
1145 orchestrator.dispatch({
1146 type: "message.dispatch",
1147 commandId: CommandId.make(`command:handoff:${key}`),
1148 threadId,
1149 messageId: MessageId.make(`message:handoff:${key}`),
1150 createdBy: "user",
1151 creationSource: "web",
1152 text,
1153 attachments: [],
1154 modelSelection,
1155 dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" },
1156 });
1157 yield* orchestrator.dispatch({
1158 type: "thread.create",
1159 commandId: CommandId.make("command:handoff:create"),
1160 threadId,
1161 projectId,
1162 createdBy: "user",
1163 creationSource: "web",
1164 title: "Interrupted provider handoff",
1165 modelSelection: CODEX_MODEL_SELECTION,
1166 runtimeMode: "full-access",
1167 interactionMode: "default",
1168 branch: null,
1169 worktreePath: null,
1170 });
1171 if (returning) {
1172 yield* dispatch(
1173 "initial",
1174 "Earlier successful request.",
1175 CODEX_MODEL_SELECTION,
1176 );
1177 yield* waitForRun(1, "completed");
1178 }
1179 yield* dispatch("source", originalPrompt, sourceSelection);
1180 yield* Deferred.await(started);
1181 if (queued) {
1182 yield* dispatch("target", "Continue", targetSelection, true);
1183 assert.equal(
1184 (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status,
1185 "queued",
1186 );
1187 }
1188 yield* Deferred.succeed(release, undefined);
1189 yield* waitForRun(sourceOrdinal, status);
1190 if (!queued) {
1191 yield* dispatch("target", "Continue", targetSelection);
1192 }
1193 yield* waitForRun(sourceOrdinal + 1, "completed");
1194 const projection = yield* orchestrator.getThreadProjection(threadId);
1195 const targetRun = projection.runs.at(-1)!;
1196 const handoff = projection.contextHandoffs.find(
1197 (candidate) => candidate.id === targetRun.contextHandoffId,
1198 );
1199 assert.isDefined(handoff);
1200 assert.equal(
1201 handoff?.strategy,
1202 returning ? "delta_since_target_last_seen" : "full_thread_summary",
1203 );
1204 assert.deepEqual(handoff?.coveredRunOrdinals, {
1205 from: sourceOrdinal,
1206 to: sourceOrdinal,
1207 });
1208 const delivered = (yield* Ref.get(capturedTurns)).at(-1)!;
1209 const history = yield* Ref.get(injectedHistory);
1210 const deliveredHistory = native ? yield* encodeJson(history) : delivered.text;
1211 assert.include(deliveredHistory, originalPrompt);
1212 assert.include(deliveredHistory, partialResponse);
1213 assert.include(deliveredHistory, `run-status=${status}`);
1214 if (native) {
1215 assert.equal(delivered.text, "Continue");
1216 assert.notInclude(deliveredHistory, '"text":"Continue"');
1217 assert.include(deliveredHistory, '"role":"assistant"');
1218 assert.include(deliveredHistory, '"role":"user"');
1219 assert.equal(handoff?.delivery?.status, "injected");
1220 } else {
1221 assert.include(delivered.text, "User message:\nContinue");
1222 assert.equal(handoff?.delivery?.status, "inline");
1223 }
1224 assert.notInclude(deliveredHistory, "Earlier successful request.");
1225 // The returning source already has its own failed/interrupted native turn.
1226 yield* dispatch("back", "Finish the remaining work", sourceSelection);
1227 yield* waitForRun(sourceOrdinal + 2, "completed");
1228 const back = (yield* Ref.get(capturedTurns)).at(-1)!;
1229 assert.notInclude(back.text, originalPrompt);
1230 assert.notInclude(back.text, partialResponse);
1231 }).pipe(
1232 Effect.provide(
1233 makeOrchestratorV2ReplayLayerWithRegistry(
1234 {
1235 name: `handoff-${status}-${queued}-${returning}`,
1236 runtimePolicyOverride: {
1237 cwd,
1238 approvalPolicy: "never",
1239 sandboxPolicy: {
1240 type: "readOnly",
1241 access: { type: "fullAccess" },
1242 networkAccess: false,
1243 },
1244 },
1245 },
1246 registryLayer,
1247 ),
1054 it.live.each(
1055 (["failed", "interrupted"] as const).flatMap((status) =>
1056 [false, true].flatMap((queued) =>
1057 [false, true].flatMap((returning) =>
1058 [false, true].map(
1059 (native) =>
1060 [
1061 `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`,
1062 { status, queued, returning, native },
1063 ] as const,
1064 ),
1065 ),
1066 ),
1067 ),
1068 )("hands off %s", ([, { status, queued, returning, native }]) =>
1069 Effect.scoped(
1070 Effect.gen(function* () {
1071 const cwd = yield* checkpointWorkspace(`handoff-${status}-${queued}-${returning}`);
1072 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1073 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
1074 const started = yield* Deferred.make<void>();
1075 const release = yield* Deferred.make<void>();
1076 const sourceOrdinal = returning ? 2 : 1;
1077 const sourceSelection = returning ? CLAUDE_MODEL_SELECTION : CODEX_MODEL_SELECTION;
1078 const targetSelection = returning ? CODEX_MODEL_SELECTION : CLAUDE_MODEL_SELECTION;
1079 const originalPrompt = "Keep the release marker violet and preserve the existing API.";
1080 const partialResponse = "I checked the API and found the release configuration.";
1081 const registryLayer = ProviderAdapterRegistry.makeLayer(
1082 (
1083 [
1084 [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2],
1085 [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2],
1086 ] as const
1087 ).map(([modelSelection, driver, capabilities]) =>
1088 makeTestAdapter({
1089 instanceId: modelSelection.instanceId,
1090 driver,
1091 capabilities,
1092 modelSelection,
1093 responseByRunOrdinal: { [sourceOrdinal]: partialResponse },
1094 capturedTurns,
1095 ...(native && modelSelection.instanceId === targetSelection.instanceId
1096 ? { injectedHistory }
1097 : {}),
1098 ...(modelSelection.instanceId === sourceSelection.instanceId
1099 ? {
1100 failedRunOrdinals: new Set(status === "failed" ? [sourceOrdinal] : []),
1101 interruptedRunOrdinals: new Set(
1102 status === "interrupted" ? [sourceOrdinal] : [],
12481103 ),
1249 );
1250 }),
1104 holdRunOrdinal: sourceOrdinal,
1105 holdFirstTurn: started,
1106 releaseFirstTurn: release,
1107 }
1108 : {}),
1109 }),
1110 ),
1111 );
1112 yield* Effect.gen(function* () {
1113 const orchestrator = yield* Orchestrator.OrchestratorV2;
1114 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1115 const waitForRun = (
1116 ordinal: number,
1117 expectedStatus: "completed" | "failed" | "interrupted",
1118 ) =>
1119 orchestrator.streamStoredEvents.pipe(
1120 Stream.filter(
1121 ({ event }) =>
1122 event.type === "run.updated" &&
1123 event.threadId === threadId &&
1124 event.payload.ordinal === ordinal &&
1125 event.payload.status === expectedStatus,
12511126 ),
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,
12521185 );
1253 }
1254 }
1255 }
1256 }
1186 assert.isDefined(handoff);
1187 assert.equal(
1188 handoff?.strategy,
1189 returning ? "delta_since_target_last_seen" : "full_thread_summary",
1190 );
1191 assert.deepEqual(handoff?.coveredRunOrdinals, {
1192 from: sourceOrdinal,
1193 to: sourceOrdinal,
1194 });
1195 const delivered = (yield* Ref.get(capturedTurns)).at(-1)!;
1196 const history = yield* Ref.get(injectedHistory);
1197 const deliveredHistory = native ? yield* encodeJson(history) : delivered.text;
1198 assert.include(deliveredHistory, originalPrompt);
1199 assert.include(deliveredHistory, partialResponse);
1200 assert.include(deliveredHistory, `run-status=${status}`);
1201 if (native) {
1202 assert.equal(delivered.text, "Continue");
1203 assert.notInclude(deliveredHistory, '"text":"Continue"');
1204 assert.include(deliveredHistory, '"role":"assistant"');
1205 assert.include(deliveredHistory, '"role":"user"');
1206 assert.equal(handoff?.delivery?.status, "injected");
1207 } else {
1208 assert.include(delivered.text, "User message:\nContinue");
1209 assert.equal(handoff?.delivery?.status, "inline");
1210 }
1211 assert.notInclude(deliveredHistory, "Earlier successful request.");
1212 // The returning source already has its own failed/interrupted native turn.
1213 yield* dispatch("back", "Finish the remaining work", sourceSelection);
1214 yield* waitForRun(sourceOrdinal + 2, "completed");
1215 const back = (yield* Ref.get(capturedTurns)).at(-1)!;
1216 assert.notInclude(back.text, originalPrompt);
1217 assert.notInclude(back.text, partialResponse);
1218 }).pipe(
1219 Effect.provide(
1220 makeOrchestratorV2ReplayLayerWithRegistry(
1221 {
1222 name: `handoff-${status}-${queued}-${returning}`,
1223 runtimePolicyOverride: {
1224 cwd,
1225 approvalPolicy: "never",
1226 sandboxPolicy: {
1227 type: "readOnly",
1228 access: { type: "fullAccess" },
1229 networkAccess: false,
1230 },
1231 },
1232 },
1233 registryLayer,
1234 ),
1235 ),
1236 );
1237 }),
1238 ),
1239 );
12571240
12581241 it.live("checks the queued provider's capability while the current provider stays running", () =>
12591242 Effect.scoped(
......@@ -2998,229 +2981,226 @@ describe("orchestration v2 provider switching", () => {
29982981 ),
29992982 );
30002983
3001 for (const failResume of [false, true]) {
3002 it.live(
3003 `switches providers while consuming a pending cross-provider merge-back (resume failure: ${failResume})`,
3004 () =>
3005 Effect.scoped(
3006 Effect.gen(function* () {
3007 const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source");
3008 const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork");
3009 const firstSourcePrompt = "Remember that the first source marker is amber.";
3010 const secondSourcePrompt = "Remember that the second source marker is violet.";
3011 const forkPrompt = "Remember that the fork marker is cobalt.";
3012 const mergePrompt = "Report all three remembered markers.";
3013 const cwd = yield* checkpointWorkspace("cross-provider-merge");
3014 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
3015 const registryLayer = ProviderAdapterRegistry.makeLayer([
3016 makeTestAdapter({
3017 instanceId: ProviderInstanceId.make("codex"),
3018 driver: CODEX_DRIVER,
3019 capabilities: CodexProviderCapabilitiesV2,
3020 modelSelection: CODEX_MODEL_SELECTION,
3021 responseByRunOrdinal: {},
3022 responseByThreadId: {
3023 [sourceThreadId]: {
3024 1: "I will remember amber.",
3025 3: "The markers are amber, violet, and cobalt.",
3026 },
3027 [forkThreadId]: {
3028 1: "I will remember cobalt.",
3029 },
2984 it.live.each([false, true])(
2985 "switches providers while consuming a pending cross-provider merge-back (resume failure: %s)",
2986 (failResume) =>
2987 Effect.scoped(
2988 Effect.gen(function* () {
2989 const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source");
2990 const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork");
2991 const firstSourcePrompt = "Remember that the first source marker is amber.";
2992 const secondSourcePrompt = "Remember that the second source marker is violet.";
2993 const forkPrompt = "Remember that the fork marker is cobalt.";
2994 const mergePrompt = "Report all three remembered markers.";
2995 const cwd = yield* checkpointWorkspace("cross-provider-merge");
2996 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
2997 const registryLayer = ProviderAdapterRegistry.makeLayer([
2998 makeTestAdapter({
2999 instanceId: ProviderInstanceId.make("codex"),
3000 driver: CODEX_DRIVER,
3001 capabilities: CodexProviderCapabilitiesV2,
3002 modelSelection: CODEX_MODEL_SELECTION,
3003 responseByRunOrdinal: {},
3004 responseByThreadId: {
3005 [sourceThreadId]: {
3006 1: "I will remember amber.",
3007 3: "The markers are amber, violet, and cobalt.",
3008 },
3009 [forkThreadId]: {
3010 1: "I will remember cobalt.",
30303011 },
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" },
31243012 },
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* () {
3128 const orchestrator = yield* Orchestrator.OrchestratorV2;
3129 yield* orchestrator.dispatch(commands[0]!);
3130 yield* orchestrator.dispatch(commands[1]!);
3131 yield* waitForIdle(sourceThreadId);
3132 yield* orchestrator.dispatch(commands[2]!);
3133 yield* waitForIdle(sourceThreadId);
3134 yield* orchestrator.dispatch(commands[3]!);
3135 yield* orchestrator.dispatch(commands[4]!);
3136 yield* waitForIdle(forkThreadId);
3137 yield* orchestrator.dispatch(commands[5]!);
3138 if (failResume) {
3139 // A persisted native ref that is not loaded in this process must
3140 // exercise resume rather than the session manager's warm cache.
3141 const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId);
3142 const codexThread = beforeResume.providerThreads.find(
3143 (thread) => thread.id === beforeResume.runs[0]?.providerThreadId,
3144 )!;
3145 yield* (yield* EventSink.EventSinkV2).write({
3146 events: [
3147 {
3148 id: EventId.make("unloaded-merge-target"),
3149 type: "provider-thread.updated",
3150 threadId: sourceThreadId,
3151 occurredAt: yield* DateTime.now,
3152 payload: {
3153 ...codexThread,
3154 nativeThreadRef: {
3155 ...codexThread.nativeThreadRef!,
3156 nativeId: "unloaded-native-merge-target",
3157 },
3158 },
3159 },
3160 ],
3161 });
3162 }
3163 yield* orchestrator.dispatch(commands[6]!);
3164 return yield* waitForIdle(sourceThreadId);
3165 }).pipe(
3166 Effect.provide(
3167 makeOrchestratorV2ReplayLayerWithRegistry(
3109 const projection = yield* Effect.gen(function* () {
3110 const orchestrator = yield* Orchestrator.OrchestratorV2;
3111 yield* orchestrator.dispatch(commands[0]!);
3112 yield* orchestrator.dispatch(commands[1]!);
3113 yield* waitForIdle(sourceThreadId);
3114 yield* orchestrator.dispatch(commands[2]!);
3115 yield* waitForIdle(sourceThreadId);
3116 yield* orchestrator.dispatch(commands[3]!);
3117 yield* orchestrator.dispatch(commands[4]!);
3118 yield* waitForIdle(forkThreadId);
3119 yield* orchestrator.dispatch(commands[5]!);
3120 if (failResume) {
3121 // A persisted native ref that is not loaded in this process must
3122 // exercise resume rather than the session manager's warm cache.
3123 const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId);
3124 const codexThread = beforeResume.providerThreads.find(
3125 (thread) => thread.id === beforeResume.runs[0]?.providerThreadId,
3126 )!;
3127 yield* (yield* EventSink.EventSinkV2).write({
3128 events: [
31683129 {
3169 name: "cross-provider-merge",
3170 runtimePolicyOverride: {
3171 cwd,
3172 approvalPolicy: "never",
3173 sandboxPolicy: {
3174 type: "readOnly",
3175 access: { type: "fullAccess" },
3176 networkAccess: false,
3130 id: EventId.make("unloaded-merge-target"),
3131 type: "provider-thread.updated",
3132 threadId: sourceThreadId,
3133 occurredAt: yield* DateTime.now,
3134 payload: {
3135 ...codexThread,
3136 nativeThreadRef: {
3137 ...codexThread.nativeThreadRef!,
3138 nativeId: "unloaded-native-merge-target",
31773139 },
31783140 },
31793141 },
3180 registryLayer,
3181 ),
3142 ],
3143 });
3144 }
3145 yield* orchestrator.dispatch(commands[6]!);
3146 return yield* waitForIdle(sourceThreadId);
3147 }).pipe(
3148 Effect.provide(
3149 makeOrchestratorV2ReplayLayerWithRegistry(
3150 {
3151 name: "cross-provider-merge",
3152 runtimePolicyOverride: {
3153 cwd,
3154 approvalPolicy: "never",
3155 sandboxPolicy: {
3156 type: "readOnly",
3157 access: { type: "fullAccess" },
3158 networkAccess: false,
3159 },
3160 },
3161 },
3162 registryLayer,
31823163 ),
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,
31833180 );
3184 const turns = yield* Ref.get(capturedTurns);
3185 const mergedTurn = turns.findLast(
3186 (turn) => turn.threadId === sourceThreadId && turn.driver === "codex",
3187 );
3188 const mergeTransfer = projection.contextTransfers.find(
3189 (transfer) => transfer.type === "merge_back",
3181 assert.isDefined(mergedTurn);
3182 if (failResume)
3183 assert.notEqual(
3184 projection.providerThreads.find((thread) => thread.id === mergedTurn.providerThreadId)
3185 ?.nativeThreadRef?.nativeId,
3186 "unloaded-native-merge-target",
31903187 );
3191
3192 if (failResume)
3193 assert.isAtLeast(
3194 projection.contextHandoffs.filter(
3195 (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id,
3196 ).length,
3197 2,
3198 );
3199 assert.isDefined(mergedTurn);
3200 if (failResume)
3201 assert.notEqual(
3202 projection.providerThreads.find(
3203 (thread) => thread.id === mergedTurn.providerThreadId,
3204 )?.nativeThreadRef?.nativeId,
3205 "unloaded-native-merge-target",
3206 );
3207 assert.include(mergedTurn.text, "Context handoff (full_thread_summary):");
3208 assert.include(mergedTurn.text, firstSourcePrompt);
3209 assert.include(mergedTurn.text, "I will remember amber.");
3210 assert.include(mergedTurn.text, secondSourcePrompt);
3211 assert.include(mergedTurn.text, "I will remember violet.");
3212 assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):");
3213 assert.include(mergedTurn.text, forkPrompt);
3214 assert.include(mergedTurn.text, "I will remember cobalt.");
3215 assert.include(mergedTurn.text, mergePrompt);
3216 assert.isDefined(mergeTransfer);
3217 assert.equal(mergeTransfer.status, "consumed");
3218 assert.equal(mergeTransfer.targetProviderInstanceId, "codex");
3219 assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context");
3220 }),
3221 ),
3222 );
3223 }
3188 assert.include(mergedTurn.text, "Context handoff (full_thread_summary):");
3189 assert.include(mergedTurn.text, firstSourcePrompt);
3190 assert.include(mergedTurn.text, "I will remember amber.");
3191 assert.include(mergedTurn.text, secondSourcePrompt);
3192 assert.include(mergedTurn.text, "I will remember violet.");
3193 assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):");
3194 assert.include(mergedTurn.text, forkPrompt);
3195 assert.include(mergedTurn.text, "I will remember cobalt.");
3196 assert.include(mergedTurn.text, mergePrompt);
3197 assert.isDefined(mergeTransfer);
3198 assert.equal(mergeTransfer.status, "consumed");
3199 assert.equal(mergeTransfer.targetProviderInstanceId, "codex");
3200 assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context");
3201 }),
3202 ),
3203 );
32243204
32253205 it.live("routes two custom instances of the same driver independently", () =>
32263206 Effect.scoped(
apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts+8-6
......@@ -257,8 +257,9 @@ function makeCreateCommand(input: {
257257}
258258
259259describe("orchestration V2 merge-back provider replay", () => {
260 for (const variant of PROVIDERS) {
261 it.effect(`merges one fork delta back into the original ${variant.driver} thread`, () =>
260 it.effect.each(PROVIDERS)(
261 "merges one fork delta back into the original $driver thread",
262 (variant) =>
262263 Effect.gen(function* () {
263264 const rawTranscript = yield* readTranscript("thread_merge_back_continue", variant.driver);
264265 const materialized = yield* Effect.gen(function* () {
......@@ -452,9 +453,11 @@ describe("orchestration V2 merge-back provider replay", () => {
452453 assert.notInclude(visibleConversationText(source), "Context handoff (");
453454 assert.include(visibleConversationText(fork), "merge fork stored");
454455 }).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) =>
458461 Effect.gen(function* () {
459462 const rawTranscript = yield* readTranscript("thread_merge_back_siblings", variant.driver);
460463 const materialized = yield* Effect.gen(function* () {
......@@ -725,6 +728,5 @@ describe("orchestration V2 merge-back provider replay", () => {
725728 assert.include(visibleConversationText(secondFork), "second merge sibling stored");
726729 assert.notInclude(visibleConversationText(secondFork), "first merge sibling stored");
727730 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
728 );
729 }
731 );
730732});
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+4-4
......@@ -394,8 +394,9 @@ layer("OrchestrationEventStore", (it) => {
394394 );
395395});
396396
397for (const phase of ["high-water", "replay"] as const) {
398 it.effect(`bounds application live events while the ${phase} query is blocked`, () =>
397it.effect.each(["high-water", "replay"] as const)(
398 "bounds application live events while the %s query is blocked",
399 (phase) =>
399400 Effect.scoped(
400401 Effect.gen(function* () {
401402 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
......@@ -464,8 +465,7 @@ for (const phase of ["high-water", "replay"] as const) {
464465 }
465466 }),
466467 ).pipe(Effect.provide(Layer.fresh(TestLayer))),
467 );
468}
468);
469469
470470it.effect("releases consumed application replay pages", () =>
471471 Effect.gen(function* () {
apps/server/src/persistence/reconcileV2PreviewMigration.test.ts+4-4
......@@ -60,8 +60,9 @@ describe("V2 preview upgrade", () => {
6060 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
6161 );
6262
63 for (const withIndexes of [false, true]) {
64 it.effect(`upgrades preview migration 54 with index cleanup ${withIndexes}`, () =>
63 it.effect.each([false, true])(
64 "upgrades preview migration 54 with index cleanup %s",
65 (withIndexes) =>
6566 Effect.gen(function* () {
6667 const sql = yield* SqlClient.SqlClient;
6768 yield* runMigrations({ toMigrationInclusive: 52 });
......@@ -89,8 +90,7 @@ describe("V2 preview upgrade", () => {
8990 }>`PRAGMA table_info(projection_threads)`;
9091 assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at"));
9192 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
92 );
93 }
93 );
9494
9595 it.effect("rolls back schema and ledger together on failure and can retry", () =>
9696 Effect.gen(function* () {
apps/server/src/provider/CodexChatGptAuth.test.ts+107-118
......@@ -426,36 +426,34 @@ it.effect(
426426 }),
427427 ),
428428);
429for (const failure of ["identity", "sharing"] as const) {
430 it.effect(
431 `failed account change preserves the original credentials and registration: ${failure}`,
432 () =>
433 provision(
434 Effect.gen(function* () {
435 const h = yield* makeHarness;
436 yield* h.signIn;
437 yield* h.phase("succeeded");
438 const before = h.storedRecords();
439 h.setCallbackClientId("oaiapp_other_account");
440 if (failure === "identity") h.setInvalidNonce();
441 else h.declineSharing();
442 yield* h.changeAccount;
443 yield* h.phase("failed");
444 assert.strictEqual(
445 h.authorizationRequests[1]!.searchParams.get("client_id"),
446 "dynamic_agent_client",
429it.effect.each(["identity", "sharing"] as const)(
430 "failed account change preserves the original credentials and registration: %s",
431 (failure) =>
432 provision(
433 Effect.gen(function* () {
434 const h = yield* makeHarness;
435 yield* h.signIn;
436 yield* h.phase("succeeded");
437 const before = h.storedRecords();
438 h.setCallbackClientId("oaiapp_other_account");
439 if (failure === "identity") h.setInvalidNonce();
440 else h.declineSharing();
441 yield* h.changeAccount;
442 yield* h.phase("failed");
443 assert.strictEqual(
444 h.authorizationRequests[1]!.searchParams.get("client_id"),
445 "dynamic_agent_client",
446 );
447 if (failure === "sharing") {
448 assert.deepEqual(
449 Option.getOrThrow(yield* h.auth.read),
450 before.find((record) => record.accessToken),
447451 );
448 if (failure === "sharing") {
449 assert.deepEqual(
450 Option.getOrThrow(yield* h.auth.read),
451 before.find((record) => record.accessToken),
452 );
453 assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2);
454 } else assert.deepEqual(h.storedRecords(), before);
455 }),
456 ),
457 );
458}
452 assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2);
453 } else assert.deepEqual(h.storedRecords(), before);
454 }),
455 ),
456);
459457it.effect("retains the callback host and path after controller recreation and token removal", () =>
460458 provision(
461459 Effect.gen(function* () {
......@@ -961,48 +959,42 @@ it.effect("rejects mismatched callback state before any token exchange or creden
961959 }),
962960 ),
963961);
964for (const invalidClaim of ["issuer", "audience", "signature"] as const) {
965 it.effect(
966 `rejects ID token ${invalidClaim} verification before saving tokens or registration`,
967 () =>
968 provision(
969 Effect.gen(function* () {
970 const h = yield* makeHarness;
971 h.setIdentityFailure(invalidClaim);
972 yield* h.signIn;
973 assert.include((yield* h.phase("failed")).message!, "could not be verified");
974 assert.strictEqual(h.exchanges.length, 1);
975 assert.deepEqual(h.storedRecords(), []);
976 assert.isTrue(Option.isNone(yield* h.auth.read));
977 }),
978 ),
979 );
980}
981for (const revoked of [false, true]) {
982 it.effect(
983 `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`,
984 () =>
985 provision(
986 Effect.gen(function* () {
987 const h = yield* makeHarness;
988 yield* h.signIn;
989 yield* h.phase("succeeded");
990 if (revoked) yield* h.auth.revoke;
991 const before = h.storedRecords();
992 h.setCallbackClientId("oaiapp_untrusted_callback");
993 yield* h.signIn;
994 assert.include((yield* h.phase("failed")).message!, "registration is incomplete");
995 assert.strictEqual(
996 h.authorizationRequests[1]?.searchParams.get("client_id"),
997 "oaiapp_test",
998 );
999 assert.strictEqual(h.exchanges.length, 1);
1000 assert.deepEqual(h.storedRecords(), before);
1001 assert.strictEqual(Option.isNone(yield* h.auth.read), revoked);
1002 }),
1003 ),
1004 );
1005}
962it.effect.each(["issuer", "audience", "signature"] as const)(
963 "rejects ID token %s verification before saving tokens or registration",
964 (invalidClaim) =>
965 provision(
966 Effect.gen(function* () {
967 const h = yield* makeHarness;
968 h.setIdentityFailure(invalidClaim);
969 yield* h.signIn;
970 assert.include((yield* h.phase("failed")).message!, "could not be verified");
971 assert.strictEqual(h.exchanges.length, 1);
972 assert.deepEqual(h.storedRecords(), []);
973 assert.isTrue(Option.isNone(yield* h.auth.read));
974 }),
975 ),
976);
977it.effect.each([
978 { revoked: false, label: "without changing the current account" },
979 { revoked: true, label: "after token removal" },
980])("rejects conflicting callback client ID on reauthorization $label", ({ revoked }) =>
981 provision(
982 Effect.gen(function* () {
983 const h = yield* makeHarness;
984 yield* h.signIn;
985 yield* h.phase("succeeded");
986 if (revoked) yield* h.auth.revoke;
987 const before = h.storedRecords();
988 h.setCallbackClientId("oaiapp_untrusted_callback");
989 yield* h.signIn;
990 assert.include((yield* h.phase("failed")).message!, "registration is incomplete");
991 assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test");
992 assert.strictEqual(h.exchanges.length, 1);
993 assert.deepEqual(h.storedRecords(), before);
994 assert.strictEqual(Option.isNone(yield* h.auth.read), revoked);
995 }),
996 ),
997);
1006998
1007999it.effect("returns successful desktop sign-in to the original Welcome step", () =>
10081000 provision(
......@@ -1157,26 +1149,25 @@ it.effect(
11571149 ),
11581150);
11591151
1160for (const disconnected of [false, true]) {
1161 it.effect(
1162 `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`,
1163 () =>
1164 provision(
1165 Effect.gen(function* () {
1166 const h = yield* makeHarness;
1167 yield* h.signIn;
1168 yield* h.phase("succeeded");
1169 if (disconnected) yield* h.auth.controller.logout(Effect.void);
1170 const before = h.storedRecords();
1171 h.setIdentity("another-user", "another@example.test");
1172 yield* h.signIn;
1173 assert.include((yield* h.phase("failed")).message!, "different ChatGPT account");
1174 assert.deepEqual(h.storedRecords(), before);
1175 assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected);
1176 }),
1177 ),
1178 );
1179}
1152it.effect.each([
1153 { disconnected: false, label: "while connected" },
1154 { disconnected: true, label: "after Disconnect" },
1155])("rejects a different verified identity during saved-profile reauth $label", ({ disconnected }) =>
1156 provision(
1157 Effect.gen(function* () {
1158 const h = yield* makeHarness;
1159 yield* h.signIn;
1160 yield* h.phase("succeeded");
1161 if (disconnected) yield* h.auth.controller.logout(Effect.void);
1162 const before = h.storedRecords();
1163 h.setIdentity("another-user", "another@example.test");
1164 yield* h.signIn;
1165 assert.include((yield* h.phase("failed")).message!, "different ChatGPT account");
1166 assert.deepEqual(h.storedRecords(), before);
1167 assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected);
1168 }),
1169 ),
1170);
11801171
11811172it.effect(
11821173 "retains both profiles and reuses the original account's client and callback when returning from another account",
......@@ -1427,7 +1418,7 @@ it.effect(
14271418 ),
14281419);
14291420
1430for (const code of [
1421it.effect.each([
14311422 "invalid_grant",
14321423 "invalid_refresh_token",
14331424 "token_expired",
......@@ -1436,33 +1427,31 @@ for (const code of [
14361427 "refresh_token_reused",
14371428 "invalid_client",
14381429 "invalid_token",
1439]) {
1440 it.effect(`refresh recovery follows the machine-readable code: ${code}`, () =>
1441 provision(
1442 Effect.gen(function* () {
1443 const h = yield* makeHarness;
1444 yield* h.signIn;
1445 yield* h.phase("succeeded");
1446 yield* h.seedExpired;
1447 h.setRefreshError(code);
1448 yield* Effect.flip(h.auth.access);
1449 assert.strictEqual(
1450 Option.isNone(yield* h.auth.read),
1451 !["invalid_client", "invalid_token"].includes(code),
1452 );
1453 assert.isTrue(
1454 h
1455 .storedRecords()
1456 .some((record) =>
1457 record.profiles?.some(
1458 (profile: { clientId: string }) => profile.clientId === "oaiapp_test",
1459 ),
1430])("refresh recovery follows the machine-readable code: %s", (code) =>
1431 provision(
1432 Effect.gen(function* () {
1433 const h = yield* makeHarness;
1434 yield* h.signIn;
1435 yield* h.phase("succeeded");
1436 yield* h.seedExpired;
1437 h.setRefreshError(code);
1438 yield* Effect.flip(h.auth.access);
1439 assert.strictEqual(
1440 Option.isNone(yield* h.auth.read),
1441 !["invalid_client", "invalid_token"].includes(code),
1442 );
1443 assert.isTrue(
1444 h
1445 .storedRecords()
1446 .some((record) =>
1447 record.profiles?.some(
1448 (profile: { clientId: string }) => profile.clientId === "oaiapp_test",
14601449 ),
1461 );
1462 }),
1463 ),
1464 );
1465}
1450 ),
1451 );
1452 }),
1453 ),
1454);
14661455
14671456it.effect(
14681457 "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
9292 Effect.map(Option.getOrThrow),
9393 );
9494
95for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) {
96 it.effect(`reuses installed Codex ${version} without downloading or taking ownership of it`, () =>
95it.effect.each(["0.156.0", "0.156.1", "0.156.2", "0.157.0"])(
96 "reuses installed Codex %s without downloading or taking ownership of it",
97 (version) =>
9798 Effect.gen(function* () {
9899 const h = yield* makeHarness({ local: { version } });
99100 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"]) {
120121 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
121122 expect((yield* h.installation.state).source).toBe("local");
122123 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
123 );
124}
125for (const local of [
124);
125it.effect.each([
126126 { version: "0.128.9" },
127127 { version: "0.145.0" },
128128 { version: "0.155.1" },
......@@ -131,24 +131,20 @@ for (const local of [
131131 { version: "unknown" },
132132 { version: "0.156.1", appServerFails: true },
133133 { version: "0.156.1", versionFails: true },
134]) {
135 it.effect(
136 `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`,
137 () =>
138 Effect.gen(function* () {
139 const h = yield* makeHarness({ local });
140 expect((yield* h.installation.state).installedVersion).toBeNull();
141 yield* h.installation.start;
142 const installed = yield* terminalState(h.installation);
143 expect(installed.phase).toBe("succeeded");
144 const executable = yield* h.installation.resolve();
145 expect(executable.source).toBe("managed");
146 expect(installed.executablePath).toBe(executable.executablePath);
147 expect(h.downloads()).toBe(1);
148 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
149 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
150 );
151}
134])("downloads the pinned release when the local CLI is unsupported or broken: %j", (local) =>
135 Effect.gen(function* () {
136 const h = yield* makeHarness({ local });
137 expect((yield* h.installation.state).installedVersion).toBeNull();
138 yield* h.installation.start;
139 const installed = yield* terminalState(h.installation);
140 expect(installed.phase).toBe("succeeded");
141 const executable = yield* h.installation.resolve();
142 expect(executable.source).toBe("managed");
143 expect(installed.executablePath).toBe(executable.executablePath);
144 expect(h.downloads()).toBe(1);
145 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
146 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
147);
152148it.effect("falls back to a managed download when the reused local executable disappears", () =>
153149 Effect.gen(function* () {
154150 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
4848 assert.equal(configured.effectiveHomePath, "/custom/shadow");
4949 }).pipe(Effect.provide(NodeServices.layer)),
5050);
51for (const source of ["managed", "local"] as const)
52 for (const account of ["primary", "additional"] as const)
53 it.effect(
54 `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`,
55 () =>
56 Effect.gen(function* () {
57 const instanceId = ProviderInstanceId.make(
58 account === "primary" ? "codex" : "codex-personal",
59 );
60 const fs = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62 const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" });
63 yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged");
64 yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n");
65 const data = new Map<string, Uint8Array>();
66 const secrets = ServerSecretStore.ServerSecretStore.of({
67 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
68 set: (key, value) =>
69 Effect.sync(() => {
70 data.set(key, value);
71 }),
72 remove: (key) =>
51it.effect.each(
52 (["managed", "local"] as const).flatMap((source) =>
53 (["primary", "additional"] as const).map((account) => ({ source, account })),
54 ),
55)(
56 "$source Codex $account account shares home state without routing owned tokens through ambient CLI overrides",
57 ({ source, account }) =>
58 Effect.gen(function* () {
59 const instanceId = ProviderInstanceId.make(
60 account === "primary" ? "codex" : "codex-personal",
61 );
62 const fs = yield* FileSystem.FileSystem;
63 const path = yield* Path.Path;
64 const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" });
65 yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged");
66 yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n");
67 const data = new Map<string, Uint8Array>();
68 const secrets = ServerSecretStore.ServerSecretStore.of({
69 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
70 set: (key, value) =>
71 Effect.sync(() => {
72 data.set(key, value);
73 }),
74 remove: (key) =>
75 Effect.sync(() => {
76 data.delete(key);
77 }),
78 create: () => Effect.die("unused"),
79 getOrCreateRandom: () => Effect.die("unused"),
80 });
81 let leases = 0;
82 const executable = {
83 executablePath:
84 source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex",
85 managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null,
86 source,
87 version: "0.156.1",
88 };
89 const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({
90 managedDirectory: "/isolated/tools/codex",
91 resolve: () => Effect.succeed(executable),
92 acquire: () =>
93 Effect.gen(function* () {
94 leases++;
95 yield* Effect.addFinalizer(() =>
7396 Effect.sync(() => {
74 data.delete(key);
97 leases--;
7598 }),
76 create: () => Effect.die("unused"),
77 getOrCreateRandom: () => Effect.die("unused"),
78 });
79 let leases = 0;
80 const executable = {
81 executablePath:
82 source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex",
83 managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null,
84 source,
85 version: "0.156.1",
86 };
87 const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({
88 managedDirectory: "/isolated/tools/codex",
89 resolve: () => Effect.succeed(executable),
90 acquire: () =>
91 Effect.gen(function* () {
92 leases++;
93 yield* Effect.addFinalizer(() =>
94 Effect.sync(() => {
95 leases--;
96 }),
97 );
98 return executable;
99 }),
100 });
101 yield* Effect.gen(function* () {
102 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);
103 const json = yield* encodeJson({
104 clientId: "oaiapp_test",
105 accessToken: "dummy-owned-access",
106 refreshToken: "dummy-refresh",
107 expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000,
108 earliestRefreshAt: null,
109 scopes: ["chatgpt.tokens.use.direct"],
110 subject: "test-user",
111 email: null,
112 });
113 yield* store.set(new TextEncoder().encode(json));
114 const ambient = {
115 CODEX_HOME: "/user/.codex",
116 OPENAI_API_KEY: "dummy-global-key",
117 OPENAI_BASE_URL: "https://user-proxy.test",
118 T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy",
119 PATH: "/usr/bin",
120 };
121 const runtime = yield* makeCodexManagedRuntime({
122 instanceId,
123 enabled: true,
124 config: decodeSettings({ setupMode: "managed", homePath: sharedHome }),
125 environment: ambient,
126 });
127 yield* Effect.gen(function* () {
128 const effective = yield* runtime.resolve;
129 assert.strictEqual(leases, 1);
130 assert.strictEqual(effective.config.binaryPath, executable.executablePath);
131 assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME);
132 assert.equal(runtime.homeLayout.sharedHomePath, sharedHome);
133 if (account === "primary") {
134 assert.equal(effective.config.homePath, sharedHome);
135 assert.equal(runtime.homeLayout.mode, "direct");
136 } else {
137 assert.include(effective.config.homePath, instanceId);
138 assert.include(effective.config.homePath, "userdata/providers/codex");
139 assert.equal(runtime.homeLayout.mode, "authOverlay");
140 assert.equal(
141 yield* fs.readLink(path.join(effective.config.homePath, "sessions")),
142 path.join(sharedHome, "sessions"),
143 );
144 assert.equal(
145 yield* fs.readLink(path.join(effective.config.homePath, "config.toml")),
146 path.join(sharedHome, "config.toml"),
147 );
148 assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json")));
149 }
150 assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access");
151 assert.isUndefined(effective.environment.OPENAI_API_KEY);
152 assert.isUndefined(effective.environment.OPENAI_BASE_URL);
153 assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS);
154 const args = codexAppServerArgs(effective.config.launchArgs);
155 assert.include(
156 args,
157 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"',
158 );
159 assert.include(
160 args,
161 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"',
162 );
163 assert.include(args, "features.api_key_model_discovery=true");
164 assert.notInclude(effective.config.launchArgs, "model_catalog_json");
165 assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test");
166 assert.include(
167 args,
168 "model_providers.openai_token_sharing.supports_websockets=false",
169 );
170 assert.include(
171 args,
172 "model_providers.openai_token_sharing.requires_openai_auth=false",
173 );
174 assert.notInclude(effective.config.launchArgs, "dummy-owned-access");
175 assert.strictEqual(ambient.CODEX_HOME, "/user/.codex");
176 }).pipe(Effect.scoped);
177 assert.strictEqual(leases, 0);
178 yield* runtime.auth.controller.logout(Effect.void);
99 );
100 return executable;
101 }),
102 });
103 yield* Effect.gen(function* () {
104 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);
105 const json = yield* encodeJson({
106 clientId: "oaiapp_test",
107 accessToken: "dummy-owned-access",
108 refreshToken: "dummy-refresh",
109 expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000,
110 earliestRefreshAt: null,
111 scopes: ["chatgpt.tokens.use.direct"],
112 subject: "test-user",
113 email: null,
114 });
115 yield* store.set(new TextEncoder().encode(json));
116 const ambient = {
117 CODEX_HOME: "/user/.codex",
118 OPENAI_API_KEY: "dummy-global-key",
119 OPENAI_BASE_URL: "https://user-proxy.test",
120 T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy",
121 PATH: "/usr/bin",
122 };
123 const runtime = yield* makeCodexManagedRuntime({
124 instanceId,
125 enabled: true,
126 config: decodeSettings({ setupMode: "managed", homePath: sharedHome }),
127 environment: ambient,
128 });
129 yield* Effect.gen(function* () {
130 const effective = yield* runtime.resolve;
131 assert.strictEqual(leases, 1);
132 assert.strictEqual(effective.config.binaryPath, executable.executablePath);
133 assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME);
134 assert.equal(runtime.homeLayout.sharedHomePath, sharedHome);
135 if (account === "primary") {
136 assert.equal(effective.config.homePath, sharedHome);
137 assert.equal(runtime.homeLayout.mode, "direct");
138 } else {
139 assert.include(effective.config.homePath, instanceId);
140 assert.include(effective.config.homePath, "userdata/providers/codex");
141 assert.equal(runtime.homeLayout.mode, "authOverlay");
179142 assert.equal(
180 yield* fs.readFileString(path.join(sharedHome, "auth.json")),
181 "native-auth-unchanged",
143 yield* fs.readLink(path.join(effective.config.homePath, "sessions")),
144 path.join(sharedHome, "sessions"),
182145 );
183 }).pipe(
184 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
185 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, {
186 getEnvironmentId: Effect.succeed(
187 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
188 ),
189 }),
190 Effect.provide(installerLayer),
191 Effect.provideService(
192 HttpClient.HttpClient,
193 HttpClient.make((request) =>
194 Effect.sync(() => {
195 assert.isTrue(
196 [
197 "https://auth.openai.com/.well-known/openid-configuration",
198 "https://auth.openai.com/revoke",
199 ].includes(request.url),
200 );
201 return HttpClientResponse.fromWeb(
202 request,
203 request.url.endsWith("/revoke")
204 ? new Response(null, { status: 200 })
205 : Response.json({
206 issuer: "https://auth.openai.com",
207 authorization_endpoint: "https://auth.openai.com/api/accounts/authorize",
208 token_endpoint: "https://auth.openai.com/api/accounts/oauth/token",
209 jwks_uri: "https://auth.openai.com/jwks",
210 revocation_endpoint: "https://auth.openai.com/revoke",
211 }),
212 );
213 }),
214 ),
215 ),
146 assert.equal(
147 yield* fs.readLink(path.join(effective.config.homePath, "config.toml")),
148 path.join(sharedHome, "config.toml"),
149 );
150 assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json")));
151 }
152 assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access");
153 assert.isUndefined(effective.environment.OPENAI_API_KEY);
154 assert.isUndefined(effective.environment.OPENAI_BASE_URL);
155 assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS);
156 const args = codexAppServerArgs(effective.config.launchArgs);
157 assert.include(
158 args,
159 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"',
160 );
161 assert.include(
162 args,
163 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"',
216164 );
217 }).pipe(
218 Effect.scoped,
219 Effect.provide(
220 ServerConfig.layerTest(process.cwd(), {
221 prefix: "t3-managed-runtime-",
222 }).pipe(Layer.provideMerge(NodeServices.layer)),
165 assert.include(args, "features.api_key_model_discovery=true");
166 assert.notInclude(effective.config.launchArgs, "model_catalog_json");
167 assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test");
168 assert.include(args, "model_providers.openai_token_sharing.supports_websockets=false");
169 assert.include(args, "model_providers.openai_token_sharing.requires_openai_auth=false");
170 assert.notInclude(effective.config.launchArgs, "dummy-owned-access");
171 assert.strictEqual(ambient.CODEX_HOME, "/user/.codex");
172 }).pipe(Effect.scoped);
173 assert.strictEqual(leases, 0);
174 yield* runtime.auth.controller.logout(Effect.void);
175 assert.equal(
176 yield* fs.readFileString(path.join(sharedHome, "auth.json")),
177 "native-auth-unchanged",
178 );
179 }).pipe(
180 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
181 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, {
182 getEnvironmentId: Effect.succeed(
183 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
184 ),
185 }),
186 Effect.provide(installerLayer),
187 Effect.provideService(
188 HttpClient.HttpClient,
189 HttpClient.make((request) =>
190 Effect.sync(() => {
191 assert.isTrue(
192 [
193 "https://auth.openai.com/.well-known/openid-configuration",
194 "https://auth.openai.com/revoke",
195 ].includes(request.url),
196 );
197 return HttpClientResponse.fromWeb(
198 request,
199 request.url.endsWith("/revoke")
200 ? new Response(null, { status: 200 })
201 : Response.json({
202 issuer: "https://auth.openai.com",
203 authorization_endpoint: "https://auth.openai.com/api/accounts/authorize",
204 token_endpoint: "https://auth.openai.com/api/accounts/oauth/token",
205 jwks_uri: "https://auth.openai.com/jwks",
206 revocation_endpoint: "https://auth.openai.com/revoke",
207 }),
208 );
209 }),
223210 ),
224211 ),
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 = {
3333} satisfies AcpSessionRuntime.AcpSessionRuntimeOptions;
3434
3535describe("AcpSessionRuntime", () => {
36 for (const setupMethod of ["session/new", "session/resume"] as const) {
37 it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () =>
36 it.effect.each(["session/new", "session/resume"] as const)(
37 "buffers root metadata while %s startup is still pending",
38 (setupMethod) =>
3839 Effect.gen(function* () {
3940 const setupReplied = yield* Deferred.make<void>();
4041 const allowStartup = yield* Deferred.make<void>();
......@@ -91,8 +92,7 @@ describe("AcpSessionRuntime", () => {
9192 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),
9293 ).toMatchObject({ currentValue: "gpt-5.4" });
9394 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
94 );
95 }
95 );
9696
9797 it.effect("publishes model changes returned by a config request and live notifications", () =>
9898 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
22872287 }),
22882288);
22892289
2290for (const checkout of [
2290it.effect.each([
22912291 {
22922292 host: "ssh.dev.azure.com",
22932293 repository: "v3/org-b/project/web",
......@@ -2303,57 +2303,55 @@ for (const checkout of [
23032303 repository: "DefaultCollection/project/_git/web",
23042304 remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web",
23052305 },
2306]) {
2307 it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () =>
2308 Effect.gen(function* () {
2309 const seen: string[] = [];
2310 const target = project({
2311 id: "target",
2312 title: "target",
2313 workspaceRoot: "/target",
2314 provider: "azure-devops",
2315 ...checkout,
2316 });
2317 const service = yield* makeService({
2318 projects: [
2319 ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>
2320 project({
2321 id: repository,
2322 title: repository,
2323 workspaceRoot: `/${repository}`,
2324 provider: "azure-devops",
2325 host: "dev.azure.com",
2326 repository,
2327 }),
2328 ),
2329 target,
2330 ],
2331 providers: [
2332 fakeProvider("azure-devops", {
2333 getChangeRequestSummary: (input) =>
2334 Effect.sync(() => {
2335 seen.push(`read ${input.cwd} ${input.repository}`);
2336 return changeRequest(7, "2026-07-02T00:00:00Z");
2337 }),
2338 runAction: (input) =>
2339 Effect.sync(() => {
2340 seen.push(`write ${input.cwd} ${input.repository}`);
2341 }),
2306])("routes Azure URL reads and writes through a $host checkout", (checkout) =>
2307 Effect.gen(function* () {
2308 const seen: string[] = [];
2309 const target = project({
2310 id: "target",
2311 title: "target",
2312 workspaceRoot: "/target",
2313 provider: "azure-devops",
2314 ...checkout,
2315 });
2316 const service = yield* makeService({
2317 projects: [
2318 ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>
2319 project({
2320 id: repository,
2321 title: repository,
2322 workspaceRoot: `/${repository}`,
2323 provider: "azure-devops",
2324 host: "dev.azure.com",
2325 repository,
23422326 }),
2343 ],
2344 });
2345 const reference = {
2346 projectId: "org-a/project/_git/web" as ProjectId,
2347 host: "dev.azure.com",
2348 repository: "org-b/project/_git/web",
2349 number: 7,
2350 };
2351 yield* service.summary(reference, { recoverTransientFailure: false });
2352 yield* service.runAction({ ...reference, action: "merge" });
2353 assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]);
2354 }),
2355 );
2356}
2327 ),
2328 target,
2329 ],
2330 providers: [
2331 fakeProvider("azure-devops", {
2332 getChangeRequestSummary: (input) =>
2333 Effect.sync(() => {
2334 seen.push(`read ${input.cwd} ${input.repository}`);
2335 return changeRequest(7, "2026-07-02T00:00:00Z");
2336 }),
2337 runAction: (input) =>
2338 Effect.sync(() => {
2339 seen.push(`write ${input.cwd} ${input.repository}`);
2340 }),
2341 }),
2342 ],
2343 });
2344 const reference = {
2345 projectId: "org-a/project/_git/web" as ProjectId,
2346 host: "dev.azure.com",
2347 repository: "org-b/project/_git/web",
2348 number: 7,
2349 };
2350 yield* service.summary(reference, { recoverTransientFailure: false });
2351 yield* service.runAction({ ...reference, action: "merge" });
2352 assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]);
2353 }),
2354);
23572355
23582356it.effect("refuses Azure cross-organization reads and writes without its checkout", () =>
23592357 Effect.gen(function* () {
......@@ -5042,104 +5040,102 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho
50425040 }),
50435041);
50445042
5045for (const crossHost of [false, true]) {
5046 it.effect(
5047 `authorizes stack rebases and refreshes sibling layers (cross-host: ${crossHost})`,
5048 () =>
5049 Effect.gen(function* () {
5050 let taken = 0;
5051 let summaryReads = 0;
5052 let mutationFails = false;
5053 let stackRebase = true;
5054 let stackActions = true;
5055 const capabilities = {
5056 diff: true,
5057 comment: true,
5058 actions: ["update-branch"] as const,
5059 mergeMethods: ["merge"] as const,
5060 updateMethods: ["rebase"] as const,
5061 get stackActions() {
5062 return stackActions;
5063 },
5064 search: true,
5065 reactions: true,
5066 review: FULL_REVIEW,
5067 reviewers: FULL_REVIEWERS,
5068 };
5069 const service = yield* makeService({
5070 projects: [
5071 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5072 project({
5073 id: "p2",
5074 title: "enterprise",
5075 workspaceRoot: "/b",
5076 repository: "acme/web",
5077 host: "enterprise.test",
5078 }),
5079 ],
5080 providers: [
5081 fakeProvider("github", {
5082 capabilities,
5083 getViewerPermissions: () =>
5084 Effect.succeed({
5085 actions: [],
5086 stackRebase,
5087 comment: true,
5088 resolve: false,
5089 verdicts: [],
5090 requestReviewers: false,
5091 }),
5092 getChangeRequestSummary: () =>
5093 Effect.sync(() => {
5094 summaryReads++;
5095 return changeRequest(8, "2026-07-01T00:00:00Z");
5096 }),
5097 runAction: () =>
5098 Effect.gen(function* () {
5099 taken++;
5100 if (mutationFails) return yield* requestFailed;
5101 }),
5102 }),
5103 ],
5104 });
5105 const input = {
5106 ...(crossHost ? { host: "enterprise.test" } : {}),
5107 projectId: "p1" as ProjectId,
5108 repository: "acme/web",
5109 number: 3,
5110 action: "update-branch" as const,
5111 updateMethod: "rebase" as const,
5112 stackNumber: 50,
5113 expectedStackHeads: [{ number: 3, headSha: "ccc" }],
5114 };
5115 yield* service.runAction(input);
5116 assert.strictEqual(taken, 1);
5117 const unrelated = { ...input, number: 8 };
5118 yield* service.summary(unrelated);
5119 assert.strictEqual(summaryReads, 1);
5120 stackRebase = false;
5121 assert.strictEqual(
5122 (yield* Effect.flip(service.runAction(input)))._tag,
5123 "PullRequestOperationError",
5124 );
5125 stackRebase = true;
5126 stackActions = false;
5127 assert.strictEqual(
5128 (yield* Effect.flip(service.runAction(input)))._tag,
5129 "PullRequestOperationError",
5130 );
5131 assert.strictEqual(taken, 1);
5132 yield* service.summary(unrelated);
5133 assert.strictEqual(summaryReads, 1);
5134 stackActions = true;
5135 mutationFails = true;
5136 yield* Effect.flip(service.runAction(input));
5137 assert.strictEqual(taken, 2);
5138 yield* service.summary(unrelated);
5139 assert.strictEqual(summaryReads, 2);
5140 }),
5141 );
5142}
5043it.effect.each([false, true])(
5044 "authorizes stack rebases and refreshes sibling layers (cross-host: %s)",
5045 (crossHost) =>
5046 Effect.gen(function* () {
5047 let taken = 0;
5048 let summaryReads = 0;
5049 let mutationFails = false;
5050 let stackRebase = true;
5051 let stackActions = true;
5052 const capabilities = {
5053 diff: true,
5054 comment: true,
5055 actions: ["update-branch"] as const,
5056 mergeMethods: ["merge"] as const,
5057 updateMethods: ["rebase"] as const,
5058 get stackActions() {
5059 return stackActions;
5060 },
5061 search: true,
5062 reactions: true,
5063 review: FULL_REVIEW,
5064 reviewers: FULL_REVIEWERS,
5065 };
5066 const service = yield* makeService({
5067 projects: [
5068 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5069 project({
5070 id: "p2",
5071 title: "enterprise",
5072 workspaceRoot: "/b",
5073 repository: "acme/web",
5074 host: "enterprise.test",
5075 }),
5076 ],
5077 providers: [
5078 fakeProvider("github", {
5079 capabilities,
5080 getViewerPermissions: () =>
5081 Effect.succeed({
5082 actions: [],
5083 stackRebase,
5084 comment: true,
5085 resolve: false,
5086 verdicts: [],
5087 requestReviewers: false,
5088 }),
5089 getChangeRequestSummary: () =>
5090 Effect.sync(() => {
5091 summaryReads++;
5092 return changeRequest(8, "2026-07-01T00:00:00Z");
5093 }),
5094 runAction: () =>
5095 Effect.gen(function* () {
5096 taken++;
5097 if (mutationFails) return yield* requestFailed;
5098 }),
5099 }),
5100 ],
5101 });
5102 const input = {
5103 ...(crossHost ? { host: "enterprise.test" } : {}),
5104 projectId: "p1" as ProjectId,
5105 repository: "acme/web",
5106 number: 3,
5107 action: "update-branch" as const,
5108 updateMethod: "rebase" as const,
5109 stackNumber: 50,
5110 expectedStackHeads: [{ number: 3, headSha: "ccc" }],
5111 };
5112 yield* service.runAction(input);
5113 assert.strictEqual(taken, 1);
5114 const unrelated = { ...input, number: 8 };
5115 yield* service.summary(unrelated);
5116 assert.strictEqual(summaryReads, 1);
5117 stackRebase = false;
5118 assert.strictEqual(
5119 (yield* Effect.flip(service.runAction(input)))._tag,
5120 "PullRequestOperationError",
5121 );
5122 stackRebase = true;
5123 stackActions = false;
5124 assert.strictEqual(
5125 (yield* Effect.flip(service.runAction(input)))._tag,
5126 "PullRequestOperationError",
5127 );
5128 assert.strictEqual(taken, 1);
5129 yield* service.summary(unrelated);
5130 assert.strictEqual(summaryReads, 1);
5131 stackActions = true;
5132 mutationFails = true;
5133 yield* Effect.flip(service.runAction(input));
5134 assert.strictEqual(taken, 2);
5135 yield* service.summary(unrelated);
5136 assert.strictEqual(summaryReads, 2);
5137 }),
5138);
51435139
51445140it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>
51455141 Effect.gen(function* () {
apps/server/src/serverSettings.test.ts+75-74
......@@ -1245,77 +1245,78 @@ it.layer(NodeServices.layer)("server settings", (it) => {
12451245 }).pipe(Effect.provide(settingsLayer));
12461246 });
12471247
1248 for (const { label, variable, expected, duplicate } of [
1249 {
1250 label: "preserves an inline secret on a redacted settings save",
1251 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1252 expected: "inline-test-token",
1253 },
1254 {
1255 label: "preserves the effective last inline secret when names are duplicated",
1256 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1257 expected: "last-inline-test-token",
1258 duplicate: true,
1259 },
1260 {
1261 label: "replaces an inline secret with an explicit value",
1262 variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true },
1263 expected: "replacement-test-token",
1264 },
1265 {
1266 label: "clears an inline secret with an explicit empty value",
1267 variable: { name: "API_TOKEN", value: "", sensitive: true },
1268 expected: "",
1269 },
1270 ]) {
1271 it.effect(label, () =>
1272 Effect.gen(function* () {
1273 const instanceId = ProviderInstanceId.make("codex_personal");
1274 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1275 const serverConfig = yield* ServerConfig.ServerConfig;
1276 const fileSystem = yield* FileSystem.FileSystem;
1277 yield* fileSystem.writeFileString(
1278 serverConfig.settingsPath,
1279 duplicate
1280 ? '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true},{"name":"API_TOKEN","value":"last-inline-test-token","sensitive":true}],"config":{}}}}'
1281 : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}',
1282 );
1283 const initial = yield* serverSettings.getSettings;
1284 assert.equal(
1285 initial.providerInstances[instanceId]?.environment?.[0]?.value,
1286 "inline-test-token",
1287 );
1248 it.effect.each(
1249 [
1250 {
1251 label: "preserves an inline secret on a redacted settings save",
1252 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1253 expected: "inline-test-token",
1254 },
1255 {
1256 label: "preserves the effective last inline secret when names are duplicated",
1257 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1258 expected: "last-inline-test-token",
1259 duplicate: true,
1260 },
1261 {
1262 label: "replaces an inline secret with an explicit value",
1263 variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true },
1264 expected: "replacement-test-token",
1265 },
1266 {
1267 label: "clears an inline secret with an explicit empty value",
1268 variable: { name: "API_TOKEN", value: "", sensitive: true },
1269 expected: "",
1270 },
1271 ].map((testCase) => [testCase.label, testCase] as const),
1272 )("%s", ([, { variable, expected, duplicate }]) =>
1273 Effect.gen(function* () {
1274 const instanceId = ProviderInstanceId.make("codex_personal");
1275 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1276 const serverConfig = yield* ServerConfig.ServerConfig;
1277 const fileSystem = yield* FileSystem.FileSystem;
1278 yield* fileSystem.writeFileString(
1279 serverConfig.settingsPath,
1280 duplicate
1281 ? '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true},{"name":"API_TOKEN","value":"last-inline-test-token","sensitive":true}],"config":{}}}}'
1282 : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}',
1283 );
1284 const initial = yield* serverSettings.getSettings;
1285 assert.equal(
1286 initial.providerInstances[instanceId]?.environment?.[0]?.value,
1287 "inline-test-token",
1288 );
12881289
1289 const next = yield* serverSettings.updateSettings({
1290 providerInstances: {
1291 [instanceId]: {
1292 driver: ProviderDriverKind.make("codex"),
1293 displayName: "Renamed provider",
1294 environment: duplicate ? [variable, variable] : [variable],
1295 config: {},
1296 },
1290 const next = yield* serverSettings.updateSettings({
1291 providerInstances: {
1292 [instanceId]: {
1293 driver: ProviderDriverKind.make("codex"),
1294 displayName: "Renamed provider",
1295 environment: duplicate ? [variable, variable] : [variable],
1296 config: {},
12971297 },
1298 });
1299 assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1300 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1301 assert.notInclude(raw, "inline-test-token");
1302 assert.notInclude(raw, "replacement-test-token");
1303
1304 const reloaded = yield* Effect.gen(function* () {
1305 const fresh = yield* ServerSettingsModule.ServerSettingsService;
1306 return yield* fresh.getSettings;
1307 }).pipe(
1308 Effect.provide(
1309 Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),
1310 ),
1311 );
1312 assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1313 }).pipe(Effect.provide(makeServerSettingsLayer())),
1314 );
1315 }
1298 },
1299 });
1300 assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1301 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1302 assert.notInclude(raw, "inline-test-token");
1303 assert.notInclude(raw, "replacement-test-token");
1304
1305 const reloaded = yield* Effect.gen(function* () {
1306 const fresh = yield* ServerSettingsModule.ServerSettingsService;
1307 return yield* fresh.getSettings;
1308 }).pipe(
1309 Effect.provide(
1310 Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),
1311 ),
1312 );
1313 assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1314 }).pipe(Effect.provide(makeServerSettingsLayer())),
1315 );
13161316
1317 for (const sensitiveLast of [true, false]) {
1318 it.effect(`preserves duplicate secret operation order (sensitive last: ${sensitiveLast})`, () =>
1317 it.effect.each([true, false])(
1318 "preserves duplicate secret operation order (sensitive last: %s)",
1319 (sensitiveLast) =>
13191320 Effect.gen(function* () {
13201321 const service = yield* ServerSettingsModule.ServerSettingsService;
13211322 const instanceId = ProviderInstanceId.make("codex_duplicate");
......@@ -1339,8 +1340,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
13391340 sensitiveLast ? "secret-last" : "",
13401341 );
13411342 }).pipe(Effect.provide(makeServerSettingsLayer())),
1342 );
1343 }
1343 );
13441344
13451345 it.effect("stores sensitive provider instance environment values outside settings.json", () =>
13461346 Effect.gen(function* () {
......@@ -1630,8 +1630,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
16301630 }),
16311631 );
16321632
1633 for (const failure of ["response materialization", "partially committed write"] as const) {
1634 it.effect(`rolls back provider secret changes after ${failure} fails`, () => {
1633 it.effect.each(["response materialization", "partially committed write"] as const)(
1634 "rolls back provider secret changes after %s fails",
1635 (failure) => {
16351636 const textDecoder = new TextDecoder();
16361637 const secrets = new Map<string, Uint8Array>();
16371638 let rejectNewSecret = false;
......@@ -1734,8 +1735,8 @@ it.layer(NodeServices.layer)("server settings", (it) => {
17341735 "sk-kept",
17351736 );
17361737 }).pipe(Effect.provide(settingsLayer));
1737 });
1738 }
1738 },
1739 );
17391740
17401741 it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>
17411742 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",
408408 );
409409});
410410
411for (const kind of ["pull", "issues"]) {
412 it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () =>
411it.effect.each(["pull", "issues"])(
412 "resolves %s subjects on the linked host without using the checkout",
413 (kind) =>
413414 Effect.gen(function* () {
414415 const provider = yield* makeProvider({
415416 execute: (input) => {
......@@ -449,11 +450,11 @@ for (const kind of ["pull", "issues"]) {
449450 undefined,
450451 );
451452 }),
452 );
453}
453);
454454
455for (const stage of ["read", "decode"] as const) {
456 it.effect(`retains the ${stage} failure without exposing its raw contents`, () =>
455it.effect.each(["read", "decode"] as const)(
456 "retains the %s failure without exposing its raw contents",
457 (stage) =>
457458 Effect.gen(function* () {
458459 const cause = new GitHubCli.GitHubCliCommandError({
459460 command: "gh",
......@@ -484,5 +485,4 @@ for (const stage of ["read", "decode"] as const) {
484485 if (stage === "read") assert.strictEqual(error.cause, cause);
485486 else assert.propertyVal(error.cause, "_tag", "SchemaError");
486487 }),
487 );
488}
488);
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+8-8
......@@ -227,8 +227,9 @@ selfhosted
227227 );
228228});
229229
230for (const kind of ["merge_requests", "issues"]) {
231 it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () =>
230it.effect.each(["merge_requests", "issues"])(
231 "resolves %s subjects on the linked host without using the checkout",
232 (kind) =>
232233 Effect.gen(function* () {
233234 const provider = yield* makeProvider({
234235 execute: (input) => {
......@@ -269,11 +270,11 @@ for (const kind of ["merge_requests", "issues"]) {
269270 undefined,
270271 );
271272 }),
272 );
273}
273);
274274
275for (const stage of ["read", "decode"] as const) {
276 it.effect(`retains the ${stage} failure without exposing its raw contents`, () =>
275it.effect.each(["read", "decode"] as const)(
276 "retains the %s failure without exposing its raw contents",
277 (stage) =>
277278 Effect.gen(function* () {
278279 const cause = new GitLabCli.GitLabCliCommandError({
279280 command: "glab",
......@@ -305,5 +306,4 @@ for (const stage of ["read", "decode"] as const) {
305306 if (stage === "read") assert.strictEqual(error.cause, cause);
306307 else assert.propertyVal(error.cause, "_tag", "SchemaError");
307308 }),
308 );
309}
309);
apps/server/src/terminal/Manager.test.ts+4-4
......@@ -1461,8 +1461,9 @@ it.layer(
14611461 }),
14621462 );
14631463
1464 for (const source of ["current", "legacy"] as const) {
1465 it.effect(`reads only a Unicode-safe tail from oversized ${source} history`, () =>
1464 it.effect.each(["current", "legacy"] as const)(
1465 "reads only a Unicode-safe tail from oversized %s history",
1466 (source) =>
14661467 Effect.gen(function* () {
14671468 const fs = yield* FileSystem.FileSystem;
14681469 const path = yield* Path.Path;
......@@ -1513,8 +1514,7 @@ it.layer(
15131514 yield* manager.close({ threadId: "thread-1" });
15141515 expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré");
15151516 }),
1516 );
1517 }
1517 );
15181518
15191519 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
15201520 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", () =>
110110 }).pipe(Effect.provide(testLayer)),
111111);
112112
113for (const failure of ["exit", "close", "error", "invalid-pid"] as const) {
114 it.effect(`fails Windows startup on ${failure} and cleans up`, () =>
113it.effect.each(["exit", "close", "error", "invalid-pid"] as const)(
114 "fails Windows startup on %s and cleans up",
115 (failure) =>
115116 Effect.gen(function* () {
116117 const { nativeProcess, subscribed } = preparePendingProcess();
117118 const adapter = yield* PtyAdapter.PtyAdapter;
......@@ -129,8 +130,7 @@ for (const failure of ["exit", "close", "error", "invalid-pid"] as const) {
129130 assert.equal(nativeProcess.events.listenerCount("exit"), 0);
130131 assert.equal(nativeProcess._agent.kill.mock.calls.length, 1);
131132 }).pipe(Effect.provide(testLayer)),
132 );
133}
133);
134134
135135it.effect("cancels the Windows connection without waiting for output", () =>
136136 Effect.gen(function* () {
......@@ -160,8 +160,9 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", ()
160160 }).pipe(Effect.provide(testLayer)),
161161);
162162
163for (const platform of ["win32", "linux", "darwin"] as const) {
164 it.effect(`terminates through node-pty using ${platform} semantics`, () =>
163it.effect.each(["win32", "linux", "darwin"] as const)(
164 "terminates through node-pty using %s semantics",
165 (platform) =>
165166 Effect.gen(function* () {
166167 const adapter = yield* PtyAdapter.PtyAdapter;
167168 const process = yield* adapter.spawn({
......@@ -189,8 +190,7 @@ for (const platform of ["win32", "linux", "darwin"] as const) {
189190 : [["SIGTERM"], ["SIGKILL"], [undefined]],
190191 );
191192 }).pipe(Effect.provide(makeTestLayer(platform))),
192 );
193}
193);
194194
195195it.effect("spawns through the public adapter with the provided host references", () =>
196196 Effect.gen(function* () {
......@@ -279,8 +279,9 @@ it.effect("reports native module load failures as structured startup defects", (
279279 ),
280280);
281281
282for (const budget of [2048, 8]) {
283 it.effect(`preserves an exit during readiness handoff with scheduler budget ${budget}`, () =>
282it.effect.each([2048, 8])(
283 "preserves an exit during readiness handoff with scheduler budget %s",
284 (budget) =>
284285 Effect.gen(function* () {
285286 const { nativeProcess, subscribed } = preparePendingProcess();
286287 const adapter = yield* PtyAdapter.PtyAdapter;
......@@ -300,8 +301,7 @@ for (const budget of [2048, 8]) {
300301 yield* Fiber.join(fiber);
301302 assert.equal(exits.length, 1);
302303 }).pipe(Effect.provide(testLayer)),
303 );
304}
304);
305305
306306it.effect("replays an exit to late subscribers and respects unsubscription", () =>
307307 Effect.gen(function* () {
......@@ -320,8 +320,9 @@ it.effect("replays an exit to late subscribers and respects unsubscription", ()
320320 }).pipe(Effect.provide(testLayer)),
321321);
322322
323for (const failure of ["spawn", "interrupt"] as const) {
324 it.effect(`logs cleanup failures without replacing ${failure}`, () =>
323it.effect.each(["spawn", "interrupt"] as const)(
324 "logs cleanup failures without replacing %s",
325 (failure) =>
325326 Effect.gen(function* () {
326327 const { nativeProcess, subscribed } = preparePendingProcess();
327328 const killError = new Error("native kill failed");
......@@ -361,5 +362,4 @@ for (const failure of ["spawn", "interrupt"] as const) {
361362 assert.equal(nativeProcess.events.listenerCount("exit"), 0);
362363 assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0);
363364 }).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
1111import { withTerminalOutputWindow } from "./OutputProtocol.ts";
1212
1313describe("terminal output window", () => {
14 for (const { tag, size, limit } of [
14 it.effect.each([
1515 { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 },
1616 { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 },
1717 { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 },
1818 { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 },
19 ]) {
20 it.effect(`limits ${tag} with ${size}-byte values to ${limit} pending chunks`, () =>
21 Effect.gen(function* () {
22 const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true }));
23 const output = yield* Queue.unbounded<string>();
24 const responses = yield* Queue.unbounded<RpcMessage.FromServerEncoded>();
25 const receive = yield* Deferred.make<Parameters<RpcServer.Protocol["Service"]["run"]>[0]>();
26 const protocol = yield* RpcServer.Protocol.make((write) =>
27 Effect.gen(function* () {
28 yield* Deferred.succeed(receive, write);
29 const serialization = yield* RpcSerialization.RpcSerialization;
30 return {
31 disconnects: yield* Queue.unbounded<number>(),
32 send: (_clientId, response) => Queue.offer(responses, response),
33 end: () => Effect.void,
34 clientIds: Effect.succeed(new Set([0])),
35 initialMessage: Effect.succeedNone,
36 supportsAck: true,
37 supportsTransferables: false,
38 supportsSpanPropagation: false,
39 supportsNotifications: true,
40 codecFor: serialization.codecFor,
41 };
42 }),
43 );
44 yield* RpcServer.make(group).pipe(
45 Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))),
46 Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),
47 Effect.forkScoped,
48 );
49 const write = yield* Deferred.await(receive);
50 yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] });
19 ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) =>
20 Effect.gen(function* () {
21 const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true }));
22 const output = yield* Queue.unbounded<string>();
23 const responses = yield* Queue.unbounded<RpcMessage.FromServerEncoded>();
24 const receive = yield* Deferred.make<Parameters<RpcServer.Protocol["Service"]["run"]>[0]>();
25 const protocol = yield* RpcServer.Protocol.make((write) =>
26 Effect.gen(function* () {
27 yield* Deferred.succeed(receive, write);
28 const serialization = yield* RpcSerialization.RpcSerialization;
29 return {
30 disconnects: yield* Queue.unbounded<number>(),
31 send: (_clientId, response) => Queue.offer(responses, response),
32 end: () => Effect.void,
33 clientIds: Effect.succeed(new Set([0])),
34 initialMessage: Effect.succeedNone,
35 supportsAck: true,
36 supportsTransferables: false,
37 supportsSpanPropagation: false,
38 supportsNotifications: true,
39 codecFor: serialization.codecFor,
40 };
41 }),
42 );
43 yield* RpcServer.make(group).pipe(
44 Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))),
45 Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),
46 Effect.forkScoped,
47 );
48 const write = yield* Deferred.await(receive);
49 yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] });
5150
52 for (let index = 0; index < limit; index++) {
53 const value = String(index).repeat(size);
54 yield* Queue.offer(output, value);
55 yield* TestClock.adjust(0);
56 assert.equal(yield* Queue.size(responses), 1);
57 assert.deepEqual(yield* Queue.take(responses), {
58 _tag: "Chunk",
59 requestId: "1",
60 values: [value],
61 });
62 }
63 yield* Queue.offer(output, "i".repeat(size));
51 for (let index = 0; index < limit; index++) {
52 const value = String(index).repeat(size);
53 yield* Queue.offer(output, value);
6454 yield* TestClock.adjust(0);
65 assert.equal(yield* Queue.size(responses), 0);
66 yield* write(0, { _tag: "Ack", requestId: "1" });
67 const resumed = yield* Queue.take(responses);
68 assert.equal(resumed._tag, "Chunk");
69 if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]);
55 assert.equal(yield* Queue.size(responses), 1);
56 assert.deepEqual(yield* Queue.take(responses), {
57 _tag: "Chunk",
58 requestId: "1",
59 values: [value],
60 });
61 }
62 yield* Queue.offer(output, "i".repeat(size));
63 yield* TestClock.adjust(0);
64 assert.equal(yield* Queue.size(responses), 0);
65 yield* write(0, { _tag: "Ack", requestId: "1" });
66 const resumed = yield* Queue.take(responses);
67 assert.equal(resumed._tag, "Chunk");
68 if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]);
7069
71 yield* Queue.offer(output, "j");
72 yield* TestClock.adjust(0);
73 assert.equal(yield* Queue.size(responses), 0);
74 yield* write(0, { _tag: "Interrupt", requestId: "1" });
75 assert.equal((yield* Queue.take(responses))._tag, "Exit");
76 }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),
77 );
78 }
70 yield* Queue.offer(output, "j");
71 yield* TestClock.adjust(0);
72 assert.equal(yield* Queue.size(responses), 0);
73 yield* write(0, { _tag: "Interrupt", requestId: "1" });
74 assert.equal((yield* Queue.take(responses))._tag, "Exit");
75 }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),
76 );
7977});
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+75-77
......@@ -461,69 +461,69 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
461461 }),
462462 );
463463
464 for (const verbose of [false, true]) {
465 it.effect(`unwraps a JSON title in ${verbose ? "verbose" : "normal"} Claude output`, () => {
466 const result = {
467 type: "result",
468 structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' },
469 };
470 return withFakeClaudeEnv(
471 { output: JSON.stringify(verbose ? [result] : result) },
472 (textGeneration) =>
473 Effect.gen(function* () {
474 const generated = yield* textGeneration.generateThreadTitle({
475 cwd: process.cwd(),
476 message: "Refresh ev-stg APP ASG instances",
477 modelSelection: {
478 instanceId: ProviderInstanceId.make("claudeAgent"),
479 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
480 },
481 });
464 it.effect.each([
465 { verbose: false, label: "normal" },
466 { verbose: true, label: "verbose" },
467 ])("unwraps a JSON title in $label Claude output", ({ verbose }) => {
468 const result = {
469 type: "result",
470 structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' },
471 };
472 return withFakeClaudeEnv(
473 { output: JSON.stringify(verbose ? [result] : result) },
474 (textGeneration) =>
475 Effect.gen(function* () {
476 const generated = yield* textGeneration.generateThreadTitle({
477 cwd: process.cwd(),
478 message: "Refresh ev-stg APP ASG instances",
479 modelSelection: {
480 instanceId: ProviderInstanceId.make("claudeAgent"),
481 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
482 },
483 });
482484
483 expect(generated.title).toBe("Refresh ev-stg APP ASG instances");
484 }),
485 );
486 });
487 }
485 expect(generated.title).toBe("Refresh ev-stg APP ASG instances");
486 }),
487 );
488 });
488489
489 for (const previousTitle of [undefined, "Old thread title"]) {
490 it.effect(
491 `reads the result from verbose Claude output when ${previousTitle ? "regenerating" : "generating"} a title`,
492 () =>
493 withFakeClaudeEnv(
490 it.effect.each([
491 { previousTitle: undefined, label: "generating" },
492 { previousTitle: "Old thread title", label: "regenerating" },
493 ])("reads the result from verbose Claude output when $label a title", ({ previousTitle }) =>
494 withFakeClaudeEnv(
495 {
496 output: JSON.stringify([
497 { type: "system", subtype: "init" },
498 { type: "assistant", message: { content: [] } },
499 { type: "user", message: { content: [] } },
500 { type: "rate_limit_event" },
494501 {
495 output: JSON.stringify([
496 { type: "system", subtype: "init" },
497 { type: "assistant", message: { content: [] } },
498 { type: "user", message: { content: [] } },
499 { type: "rate_limit_event" },
500 {
501 type: "result",
502 subtype: "success",
503 result: '{"title":"Refresh ev-stg APP ASG Instances"}',
504 structured_output: { title: "Refresh ev-stg APP ASG Instances" },
505 },
506 ]),
502 type: "result",
503 subtype: "success",
504 result: '{"title":"Refresh ev-stg APP ASG Instances"}',
505 structured_output: { title: "Refresh ev-stg APP ASG Instances" },
507506 },
508 (textGeneration) =>
509 Effect.gen(function* () {
510 const generated = yield* textGeneration.generateThreadTitle({
511 cwd: process.cwd(),
512 message: "Refresh ev-stg APP ASG instances",
513 previousTitle,
514 modelSelection: {
515 instanceId: ProviderInstanceId.make("claudeAgent"),
516 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
517 },
518 });
519
520 expect(generated.title).toBe("Refresh ev-stg APP ASG Instances");
521 }),
522 ),
523 );
524 }
507 ]),
508 },
509 (textGeneration) =>
510 Effect.gen(function* () {
511 const generated = yield* textGeneration.generateThreadTitle({
512 cwd: process.cwd(),
513 message: "Refresh ev-stg APP ASG instances",
514 previousTitle,
515 modelSelection: {
516 instanceId: ProviderInstanceId.make("claudeAgent"),
517 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
518 },
519 });
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([
527527 ["empty message array", []],
528528 ["missing result", [{ type: "assistant", structured_output: { title: "Not a result" } }]],
529529 ["invalid title", [{ type: "result", structured_output: { title: 42 } }]],
......@@ -534,26 +534,24 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
534534 { type: "result", subtype: "error_max_structured_output_retries" },
535535 ],
536536 ],
537 ] as const) {
538 it.effect(`rejects verbose Claude output with ${name}`, () =>
539 withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) =>
540 Effect.gen(function* () {
541 const error = yield* Effect.flip(
542 textGeneration.generateThreadTitle({
543 cwd: process.cwd(),
544 message: "Name this thread",
545 modelSelection: {
546 instanceId: ProviderInstanceId.make("claudeAgent"),
547 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
548 },
549 }),
550 );
537 ] as const)("rejects verbose Claude output with %s", ([, output]) =>
538 withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) =>
539 Effect.gen(function* () {
540 const error = yield* Effect.flip(
541 textGeneration.generateThreadTitle({
542 cwd: process.cwd(),
543 message: "Name this thread",
544 modelSelection: {
545 instanceId: ProviderInstanceId.make("claudeAgent"),
546 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
547 },
548 }),
549 );
551550
552 expect(error._tag).toBe("TextGenerationError");
553 }),
554 ),
555 );
556 }
551 expect(error._tag).toBe("TextGenerationError");
552 }),
553 ),
554 );
557555
558556 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
559557 withFakeClaudeEnv(
apps/server/src/textGeneration/CodexTextGeneration.test.ts+27-29
......@@ -170,8 +170,9 @@ function withFakeCodexEnv<A, E, R>(
170170}
171171
172172it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
173 for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) {
174 it.effect(`dispatches the qualified live model for ${selectedModel}`, () =>
173 it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])(
174 "dispatches the qualified live model for %s",
175 (selectedModel) =>
175176 withFakeCodexEnv(
176177 {
177178 output: JSON.stringify({ title: "Bedrock title" }),
......@@ -189,8 +190,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
189190 expect(result.title).toBe("Bedrock title");
190191 }),
191192 ),
192 );
193 }
193 );
194194 it.effect("generates and sanitizes commit messages without branch by default", () =>
195195 withFakeCodexEnv(
196196 {
......@@ -406,7 +406,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
406406 ),
407407 );
408408
409 for (const example of [
409 it.effect.each([
410410 {
411411 mode: "static",
412412 output: "Add Search",
......@@ -425,30 +425,28 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
425425 expected: "Julius/ABC-123.v2",
426426 instruction: "Preserve the issue ID and capitalization.",
427427 },
428 ] as const) {
429 it.effect(`generates a branch using ${example.mode} naming`, () =>
430 withFakeCodexEnv(
431 {
432 output: JSON.stringify({ branch: example.output }),
433 stdinMustContain: example.instruction,
434 },
435 (textGeneration) =>
436 Effect.gen(function* () {
437 const generated = yield* textGeneration.generateBranchName({
438 cwd: process.cwd(),
439 message: "Add search",
440 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
441 naming: {
442 mode: example.mode,
443 prefix: "team/",
444 instructions: "Preserve the issue ID and capitalization.",
445 },
446 });
447 expect(generated.branch).toBe(example.expected);
448 }),
449 ),
450 );
451 }
428 ] as const)("generates a branch using $mode naming", (example) =>
429 withFakeCodexEnv(
430 {
431 output: JSON.stringify({ branch: example.output }),
432 stdinMustContain: example.instruction,
433 },
434 (textGeneration) =>
435 Effect.gen(function* () {
436 const generated = yield* textGeneration.generateBranchName({
437 cwd: process.cwd(),
438 message: "Add search",
439 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
440 naming: {
441 mode: example.mode,
442 prefix: "team/",
443 instructions: "Preserve the issue ID and capitalization.",
444 },
445 });
446 expect(generated.branch).toBe(example.expected);
447 }),
448 ),
449 );
452450
453451 it.effect("generates branch names even when the ambient scope is already closed", () =>
454452 withFakeCodexEnv(
apps/server/src/textGeneration/CursorTextGeneration.test.ts+8-8
......@@ -253,8 +253,9 @@ describe("CursorTextGeneration", () => {
253253 }).pipe(Effect.provide(fsLayer)),
254254 );
255255
256 for (const status of ["error", "cancelled"] as const) {
257 it.effect(`rejects a ${status} Cursor SDK run that includes valid title JSON`, () =>
256 it.effect.each(["error", "cancelled"] as const)(
257 "rejects a %s Cursor SDK run that includes valid title JSON",
258 (status) =>
258259 Effect.gen(function* () {
259260 const promptResult = {
260261 id: "run-cursor-partial-title-test",
......@@ -284,8 +285,7 @@ describe("CursorTextGeneration", () => {
284285 );
285286 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
286287 }).pipe(Effect.provide(fsLayer)),
287 );
288 }
288 );
289289
290290 it.effect("fails closed when ambient sandbox policy can expand write access", () =>
291291 Effect.gen(function* () {
......@@ -329,8 +329,9 @@ describe("CursorTextGeneration", () => {
329329 }).pipe(Effect.provide(fsLayer), Effect.scoped),
330330 );
331331
332 for (const phase of ["create", "send"] as const) {
333 it.effect(`times out pending ${phase} and releases its late SDK resource`, () =>
332 it.effect.each(["create", "send"] as const)(
333 "times out pending %s and releases its late SDK resource",
334 (phase) =>
334335 Effect.gen(function* () {
335336 let started!: () => void;
336337 const called = new Promise<void>((resolve) => {
......@@ -388,8 +389,7 @@ describe("CursorTextGeneration", () => {
388389 expect(wait).not.toHaveBeenCalled();
389390 if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
390391 }).pipe(Effect.provide(fsLayer), Effect.scoped),
391 );
392 }
392 );
393393
394394 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>
395395 Effect.gen(function* () {
apps/server/src/usage/UsageService.test.ts+83-82
......@@ -121,95 +121,96 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
121121}
122122
123123describe("UsageService", () => {
124 for (const explicitDefault of [true, false]) {
125 it.live(
126 `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`,
127 () =>
128 Effect.gen(function* () {
129 const { home, settings } = yield* setup;
130 const summary = yield* Effect.gen(function* () {
131 for (const [id, output] of [
132 ["codex", 17],
133 ["codex-personal", 23],
134 ] as const) {
135 const sessions = NodePath.join(home, "shared-codex", "sessions");
136 yield* Effect.promise(async () => {
137 await NodeFSP.mkdir(sessions, { recursive: true });
138 await NodeFSP.writeFile(
139 NodePath.join(sessions, `${id}-rollout.jsonl`),
140 [
141 { type: "session_meta", payload: { id } },
142 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },
143 {
144 type: "event_msg",
145 timestamp: "2026-08-01T10:00:00Z",
146 payload: {
147 type: "token_count",
148 info: { last_token_usage: { input_tokens: 10, output_tokens: output } },
149 },
124 it.live.each([
125 { explicitDefault: true, label: "explicit" },
126 { explicitDefault: false, label: "legacy" },
127 ])(
128 "reads shared managed $label default and disabled extra account history once",
129 ({ explicitDefault }) =>
130 Effect.gen(function* () {
131 const { home, settings } = yield* setup;
132 const summary = yield* Effect.gen(function* () {
133 for (const [id, output] of [
134 ["codex", 17],
135 ["codex-personal", 23],
136 ] as const) {
137 const sessions = NodePath.join(home, "shared-codex", "sessions");
138 yield* Effect.promise(async () => {
139 await NodeFSP.mkdir(sessions, { recursive: true });
140 await NodeFSP.writeFile(
141 NodePath.join(sessions, `${id}-rollout.jsonl`),
142 [
143 { type: "session_meta", payload: { id } },
144 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },
145 {
146 type: "event_msg",
147 timestamp: "2026-08-01T10:00:00Z",
148 payload: {
149 type: "token_count",
150 info: { last_token_usage: { input_tokens: 10, output_tokens: output } },
150151 },
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") },
169152 },
170 providerInstances: {
171 ...(explicitDefault
172 ? {
173 [ProviderInstanceId.make("codex")]: {
174 driver: ProviderDriverKind.make("codex"),
175 config: {
176 setupMode: "managed",
177 homePath: NodePath.join(home, "shared-codex"),
178 },
153 ]
154 .map((line) => encodeUnknownJsonString(line))
155 .join("\n") + "\n",
156 );
157 });
158 }
159 const service = yield* UsageService.make;
160 return yield* service.readSummary(WINDOW);
161 }).pipe(
162 Effect.provide(
163 serviceLayers({
164 prefix: "usage-managed-accounts",
165 home,
166 settings: {
167 ...settings,
168 providers: {
169 ...settings.providers,
170 codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") },
171 },
172 providerInstances: {
173 ...(explicitDefault
174 ? {
175 [ProviderInstanceId.make("codex")]: {
176 driver: ProviderDriverKind.make("codex"),
177 config: {
178 setupMode: "managed",
179 homePath: NodePath.join(home, "shared-codex"),
179180 },
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,
195181 },
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"),
197191 },
192 environment: [
193 {
194 name: "CODEX_HOME",
195 value: NodePath.join(home, "ignored-environment"),
196 sensitive: false,
197 },
198 ],
198199 },
199200 },
200 }),
201 ),
202 );
203 assert.strictEqual(totalOutputTokens(summary), 40);
204 assert.strictEqual(
205 summary.sources.filter(
206 (source) => source.fingerprint.provider === "codex" && source.status === "ok",
207 ).length,
208 1,
209 );
210 }).pipe(Effect.scoped),
211 );
212 }
201 },
202 }),
203 ),
204 );
205 assert.strictEqual(totalOutputTokens(summary), 40);
206 assert.strictEqual(
207 summary.sources.filter(
208 (source) => source.fingerprint.provider === "codex" && source.status === "ok",
209 ).length,
210 1,
211 );
212 }).pipe(Effect.scoped),
213 );
213214 it.live("omits Cursor account usage when no file login is saved", () =>
214215 Effect.gen(function* () {
215216 const { settings, home } = yield* setup;
apps/server/src/usage/cliproxyApi.test.ts+11-13
......@@ -234,22 +234,20 @@ describe("CLIProxyAPI built-in management API", () => {
234234 }),
235235 );
236236
237 for (const [code, outcome] of [
237 it.effect.each([
238238 ["nothing_to_reset", "nothingToReset"],
239239 ["no_credit", "noCredit"],
240240 ["already_redeemed", "alreadyRedeemed"],
241 ] as const) {
242 it.effect(`reports ${code} accurately`, () =>
243 Effect.gen(function* () {
244 const test = fixture({ upstream: () => ({ status: 200, body: { code } }) });
245 const api = yield* test.api;
246 expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome });
247 expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(
248 code === "already_redeemed",
249 );
250 }),
251 );
252 }
241 ] as const)("reports %s accurately", ([code, outcome]) =>
242 Effect.gen(function* () {
243 const test = fixture({ upstream: () => ({ status: 200, body: { code } }) });
244 const api = yield* test.api;
245 expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome });
246 expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(
247 code === "already_redeemed",
248 );
249 }),
250 );
253251
254252 it.effect("reports redemption success even if cooldown clearing fails", () =>
255253 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriver.test.ts+358-394
......@@ -392,8 +392,9 @@ it.effect.each([
392392 }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)),
393393);
394394
395for (const blockedPhase of ["discovery", "probe", "retry"] as const) {
396 it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () =>
395it.effect.each(["discovery", "probe", "retry"] as const)(
396 "checkpoint recovery has one deadline including %s",
397 (blockedPhase) =>
397398 Effect.gen(function* () {
398399 const fs = yield* FileSystem.FileSystem;
399400 const path = yield* Path.Path;
......@@ -478,8 +479,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) {
478479 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
479480 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
480481 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
481 );
482}
482);
483483
484484it.effect("checkpoint recovery preserves interruption and removes the private index", () =>
485485 Effect.gen(function* () {
......@@ -548,116 +548,103 @@ it.effect("checkpoint capture does not rerun clean filters for unchanged indexed
548548 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
549549);
550550
551for (const nested of [false, true]) {
552 for (const indexState of [
553 "sparse",
554 "flags",
555 "manual-skip",
556 "missing",
557 "non-cone-missing",
558 ] as const) {
559 it.effect(
560 `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`,
561 () =>
562 Effect.gen(function* () {
563 const fs = yield* FileSystem.FileSystem;
564 const path = yield* Path.Path;
565 const driver = yield* GitVcsDriver.makeVcsDriverShape();
566 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
567 const { git } = yield* makeCheckpointFixture(driver, cwd);
568 const write = Effect.fn(function* (name: string, contents: string) {
569 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
570 yield* fs.writeFileString(path.join(cwd, name), contents);
571 });
572 for (const name of [
573 "scope/in/edit",
574 "scope/in/delete",
575 "scope/out/deep/absent",
576 "scope/out/present",
577 "elsewhere/file",
578 ]) {
579 yield* write(name, "original\n");
580 }
581 yield* git(["add", "."]);
582 yield* git(["commit", "-m", "sparse fixture"]);
583 yield* git([
584 "sparse-checkout",
585 "set",
586 "--cone",
587 "--sparse-index",
588 "scope/in",
589 "elsewhere",
590 ]);
591 if (indexState === "non-cone-missing")
592 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
593 yield* write("scope/in/edit", "staged\n");
594 yield* write("elsewhere/file", "staged outside\n");
595 yield* git(["add", "."]);
596 if (indexState === "flags")
597 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
598 if (indexState === "manual-skip")
599 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
600 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
601 yield* write("scope/in/edit", "working\n");
602 yield* write("scope/out/present", "modified skipped\n");
603 yield* write("scope/out/new file", "new outside cone\n");
604 yield* write("elsewhere/file", "working outside\n");
605 yield* fs.remove(path.join(cwd, "scope/in/delete"));
606 const indexPath = path.join(cwd, ".git/index");
607 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
608 const originalIndex = yield* fs
609 .readFile(indexPath)
610 .pipe(Effect.orElseSucceed(() => null));
611 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
612 for (const turn of [1, 2]) {
613 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
614 if (turn === 2) {
615 yield* write("scope/in/edit", "second\n");
616 yield* fs.remove(path.join(cwd, "scope/out/new file"));
617 yield* write("scope/out/second", "second addition\n");
618 }
619 const capture = driver.checkpoints.captureCheckpoint({
620 cwd: captureCwd,
621 checkpointRef: ref,
622 });
623 if (indexState === "non-cone-missing") {
624 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
625 assert.isFalse(
626 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
627 );
628 assert.isFalse(yield* fs.exists(indexPath));
629 break;
630 }
631 yield* capture;
632 for (const [name, content] of [
633 ["scope/out/deep/absent", "original\n"],
634 ["scope/out/present", "modified skipped\n"],
635 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
636 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
637 [
638 turn === 1 ? "scope/out/new file" : "scope/out/second",
639 turn === 1 ? "new outside cone\n" : "second addition\n",
640 ],
641 ]) {
642 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
643 }
644 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
645 assert.notInclude(files, "scope/in/delete");
646 if (turn === 2) assert.notInclude(files, "scope/out/new file");
647 assert.deepEqual(
648 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
649 originalIndex,
650 );
651 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
652 assert.strictEqual(
653 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
654 "working outside\n",
655 );
656 }
657 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
658 );
659 }
660}
551it.effect.each(
552 [false, true].flatMap((nested) =>
553 (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map(
554 (indexState) => ({ nested, indexState }),
555 ),
556 ),
557)(
558 "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)",
559 ({ nested, indexState }) =>
560 Effect.gen(function* () {
561 const fs = yield* FileSystem.FileSystem;
562 const path = yield* Path.Path;
563 const driver = yield* GitVcsDriver.makeVcsDriverShape();
564 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
565 const { git } = yield* makeCheckpointFixture(driver, cwd);
566 const write = Effect.fn(function* (name: string, contents: string) {
567 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
568 yield* fs.writeFileString(path.join(cwd, name), contents);
569 });
570 for (const name of [
571 "scope/in/edit",
572 "scope/in/delete",
573 "scope/out/deep/absent",
574 "scope/out/present",
575 "elsewhere/file",
576 ]) {
577 yield* write(name, "original\n");
578 }
579 yield* git(["add", "."]);
580 yield* git(["commit", "-m", "sparse fixture"]);
581 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]);
582 if (indexState === "non-cone-missing")
583 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
584 yield* write("scope/in/edit", "staged\n");
585 yield* write("elsewhere/file", "staged outside\n");
586 yield* git(["add", "."]);
587 if (indexState === "flags")
588 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
589 if (indexState === "manual-skip")
590 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
591 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
592 yield* write("scope/in/edit", "working\n");
593 yield* write("scope/out/present", "modified skipped\n");
594 yield* write("scope/out/new file", "new outside cone\n");
595 yield* write("elsewhere/file", "working outside\n");
596 yield* fs.remove(path.join(cwd, "scope/in/delete"));
597 const indexPath = path.join(cwd, ".git/index");
598 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
599 const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null));
600 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
601 for (const turn of [1, 2]) {
602 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
603 if (turn === 2) {
604 yield* write("scope/in/edit", "second\n");
605 yield* fs.remove(path.join(cwd, "scope/out/new file"));
606 yield* write("scope/out/second", "second addition\n");
607 }
608 const capture = driver.checkpoints.captureCheckpoint({
609 cwd: captureCwd,
610 checkpointRef: ref,
611 });
612 if (indexState === "non-cone-missing") {
613 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
614 assert.isFalse(
615 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
616 );
617 assert.isFalse(yield* fs.exists(indexPath));
618 break;
619 }
620 yield* capture;
621 for (const [name, content] of [
622 ["scope/out/deep/absent", "original\n"],
623 ["scope/out/present", "modified skipped\n"],
624 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
625 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
626 [
627 turn === 1 ? "scope/out/new file" : "scope/out/second",
628 turn === 1 ? "new outside cone\n" : "second addition\n",
629 ],
630 ]) {
631 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
632 }
633 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
634 assert.notInclude(files, "scope/in/delete");
635 if (turn === 2) assert.notInclude(files, "scope/out/new file");
636 assert.deepEqual(
637 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
638 originalIndex,
639 );
640 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
641 assert.strictEqual(
642 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
643 "working outside\n",
644 );
645 }
646 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
647);
661648
662649it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () =>
663650 Effect.gen(function* () {
......@@ -699,206 +686,186 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse"
699686 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
700687);
701688
702for (const indexMode of ["normal", "flags", "sparse"] as const) {
703 it.effect(
704 `checkpoint index inspection handles entries beyond the output cap (index=${indexMode})`,
705 () =>
706 Effect.gen(function* () {
707 const fs = yield* FileSystem.FileSystem;
708 const path = yield* Path.Path;
709 const liveProcess = yield* VcsProcess.VcsProcess;
710 const driver = yield* GitVcsDriver.makeVcsDriverShape();
711 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" });
712 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
713 yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n");
714 yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n");
715 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n");
716 yield* fs.makeDirectory(path.join(cwd, "excluded"));
717 yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n");
718 yield* fs.writeFileString(
719 path.join(cwd, ".git/filter.cjs"),
720 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);',
721 );
722 yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]);
723 yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000);
724 yield* git(["add", "."]);
725 yield* git(["commit", "-m", "inspection fixture"]);
726 if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]);
727 if (indexMode === "sparse")
728 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]);
729 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n");
730 yield* fs.writeFileString(path.join(cwd, ".git/reads"), "");
731 const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index"));
732 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
733 Effect.provideService(VcsProcess.VcsProcess, {
734 run: (input) =>
735 liveProcess.run(
736 input.args.includes("ls-files")
737 ? {
738 ...input,
739 maxOutputBytes: 8,
740 onStdoutChunk: (chunk) => {
741 for (let i = 0; i < chunk.length; i++)
742 input.onStdoutChunk?.(chunk.subarray(i, i + 1));
743 },
744 }
745 : input,
746 ),
747 }),
748 );
749 yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
750 assert.strictEqual(
751 (yield* git(["show", `${checkpointRef}:z-skipped`])).stdout,
752 "modified\n",
753 );
754 if (indexMode !== "flags")
755 assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), "");
756 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex);
757 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
758 );
759}
689it.effect.each(["normal", "flags", "sparse"] as const)(
690 "checkpoint index inspection handles entries beyond the output cap (index=%s)",
691 (indexMode) =>
692 Effect.gen(function* () {
693 const fs = yield* FileSystem.FileSystem;
694 const path = yield* Path.Path;
695 const liveProcess = yield* VcsProcess.VcsProcess;
696 const driver = yield* GitVcsDriver.makeVcsDriverShape();
697 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" });
698 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
699 yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n");
700 yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n");
701 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n");
702 yield* fs.makeDirectory(path.join(cwd, "excluded"));
703 yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n");
704 yield* fs.writeFileString(
705 path.join(cwd, ".git/filter.cjs"),
706 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);',
707 );
708 yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]);
709 yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000);
710 yield* git(["add", "."]);
711 yield* git(["commit", "-m", "inspection fixture"]);
712 if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]);
713 if (indexMode === "sparse")
714 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]);
715 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n");
716 yield* fs.writeFileString(path.join(cwd, ".git/reads"), "");
717 const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index"));
718 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
719 Effect.provideService(VcsProcess.VcsProcess, {
720 run: (input) =>
721 liveProcess.run(
722 input.args.includes("ls-files")
723 ? {
724 ...input,
725 maxOutputBytes: 8,
726 onStdoutChunk: (chunk) => {
727 for (let i = 0; i < chunk.length; i++)
728 input.onStdoutChunk?.(chunk.subarray(i, i + 1));
729 },
730 }
731 : input,
732 ),
733 }),
734 );
735 yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
736 assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n");
737 if (indexMode !== "flags")
738 assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), "");
739 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex);
740 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
741);
760742
761for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) {
762 it.effect(
763 `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`,
764 () =>
765 Effect.gen(function* () {
766 const fileSystem = yield* FileSystem.FileSystem;
767 const path = yield* Path.Path;
768 const driver = yield* GitVcsDriver.makeVcsDriverShape();
769 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" });
770 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
771 const filePath = path.join(cwd, "file.txt");
772 const indexPath = path.join(cwd, ".git", "index");
773 yield* git(["config", "core.trustctime", "false"]);
774 yield* fileSystem.writeFileString(filePath, "before\n");
775 yield* fileSystem.utimes(filePath, timestamp, timestamp);
776 yield* git(["add", "file.txt"]);
777 yield* git(["commit", "-m", "record racy file"]);
778 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
779 const originalIndex = yield* fileSystem.readFile(indexPath);
780 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
781 yield* fileSystem.writeFileString(filePath, "after!\n");
782 yield* fileSystem.utimes(filePath, timestamp, timestamp);
743it.effect.each([1_700_000_000, 1_700_000_000.9999])(
744 "checkpoint capture preserves same-size edits with racy index timestamps (%s)",
745 (timestamp) =>
746 Effect.gen(function* () {
747 const fileSystem = yield* FileSystem.FileSystem;
748 const path = yield* Path.Path;
749 const driver = yield* GitVcsDriver.makeVcsDriverShape();
750 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" });
751 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
752 const filePath = path.join(cwd, "file.txt");
753 const indexPath = path.join(cwd, ".git", "index");
754 yield* git(["config", "core.trustctime", "false"]);
755 yield* fileSystem.writeFileString(filePath, "before\n");
756 yield* fileSystem.utimes(filePath, timestamp, timestamp);
757 yield* git(["add", "file.txt"]);
758 yield* git(["commit", "-m", "record racy file"]);
759 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
760 const originalIndex = yield* fileSystem.readFile(indexPath);
761 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
762 yield* fileSystem.writeFileString(filePath, "after!\n");
763 yield* fileSystem.utimes(filePath, timestamp, timestamp);
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");
787 assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex);
788 assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
789 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
790 );
791}
792for (const nested of [false, true]) {
793 for (const indexState of [
794 "sparse",
795 "flags",
796 "manual-skip",
797 "missing",
798 "non-cone-missing",
799 ] as const) {
800 it.effect(
801 `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`,
802 () =>
803 Effect.gen(function* () {
804 const fs = yield* FileSystem.FileSystem;
805 const path = yield* Path.Path;
806 const driver = yield* GitVcsDriver.makeVcsDriverShape();
807 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
808 const { git } = yield* makeCheckpointFixture(driver, cwd);
809 const write = Effect.fn(function* (name: string, contents: string) {
810 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
811 yield* fs.writeFileString(path.join(cwd, name), contents);
812 });
813 for (const name of [
814 "scope/in/edit",
815 "scope/in/delete",
816 "scope/out/deep/absent",
817 "scope/out/present",
818 "elsewhere/file",
819 ]) {
820 yield* write(name, "original\n");
821 }
822 yield* git(["add", "."]);
823 yield* git(["commit", "-m", "sparse fixture"]);
824 yield* git([
825 "sparse-checkout",
826 "set",
827 "--cone",
828 "--sparse-index",
829 "scope/in",
830 "elsewhere",
831 ]);
832 if (indexState === "non-cone-missing")
833 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
834 yield* write("scope/in/edit", "staged\n");
835 yield* write("elsewhere/file", "staged outside\n");
836 yield* git(["add", "."]);
837 if (indexState === "flags")
838 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
839 if (indexState === "manual-skip")
840 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
841 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
842 yield* write("scope/in/edit", "working\n");
843 yield* write("scope/out/present", "modified skipped\n");
844 yield* write("scope/out/new file", "new outside cone\n");
845 yield* write("elsewhere/file", "working outside\n");
846 yield* fs.remove(path.join(cwd, "scope/in/delete"));
847 const indexPath = path.join(cwd, ".git/index");
848 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
849 const originalIndex = yield* fs
850 .readFile(indexPath)
851 .pipe(Effect.orElseSucceed(() => null));
852 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
853 for (const turn of [1, 2]) {
854 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
855 if (turn === 2) {
856 yield* write("scope/in/edit", "second\n");
857 yield* fs.remove(path.join(cwd, "scope/out/new file"));
858 yield* write("scope/out/second", "second addition\n");
859 }
860 const capture = driver.checkpoints.captureCheckpoint({
861 cwd: captureCwd,
862 checkpointRef: ref,
863 });
864 if (indexState === "non-cone-missing") {
865 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
866 assert.isFalse(
867 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
868 );
869 assert.isFalse(yield* fs.exists(indexPath));
870 break;
871 }
872 yield* capture;
873 for (const [name, content] of [
874 ["scope/out/deep/absent", "original\n"],
875 ["scope/out/present", "modified skipped\n"],
876 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
877 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
878 [
879 turn === 1 ? "scope/out/new file" : "scope/out/second",
880 turn === 1 ? "new outside cone\n" : "second addition\n",
881 ],
882 ]) {
883 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
884 }
885 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
886 assert.notInclude(files, "scope/in/delete");
887 if (turn === 2) assert.notInclude(files, "scope/out/new file");
888 assert.deepEqual(
889 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
890 originalIndex,
891 );
892 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
893 assert.strictEqual(
894 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
895 "working outside\n",
896 );
897 }
898 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
899 );
900 }
901}
767 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n");
768 assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex);
769 assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
770 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
771);
772it.effect.each(
773 [false, true].flatMap((nested) =>
774 (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map(
775 (indexState) => ({ nested, indexState }),
776 ),
777 ),
778)(
779 "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)",
780 ({ nested, indexState }) =>
781 Effect.gen(function* () {
782 const fs = yield* FileSystem.FileSystem;
783 const path = yield* Path.Path;
784 const driver = yield* GitVcsDriver.makeVcsDriverShape();
785 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
786 const { git } = yield* makeCheckpointFixture(driver, cwd);
787 const write = Effect.fn(function* (name: string, contents: string) {
788 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
789 yield* fs.writeFileString(path.join(cwd, name), contents);
790 });
791 for (const name of [
792 "scope/in/edit",
793 "scope/in/delete",
794 "scope/out/deep/absent",
795 "scope/out/present",
796 "elsewhere/file",
797 ]) {
798 yield* write(name, "original\n");
799 }
800 yield* git(["add", "."]);
801 yield* git(["commit", "-m", "sparse fixture"]);
802 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]);
803 if (indexState === "non-cone-missing")
804 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
805 yield* write("scope/in/edit", "staged\n");
806 yield* write("elsewhere/file", "staged outside\n");
807 yield* git(["add", "."]);
808 if (indexState === "flags")
809 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
810 if (indexState === "manual-skip")
811 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
812 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
813 yield* write("scope/in/edit", "working\n");
814 yield* write("scope/out/present", "modified skipped\n");
815 yield* write("scope/out/new file", "new outside cone\n");
816 yield* write("elsewhere/file", "working outside\n");
817 yield* fs.remove(path.join(cwd, "scope/in/delete"));
818 const indexPath = path.join(cwd, ".git/index");
819 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
820 const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null));
821 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
822 for (const turn of [1, 2]) {
823 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
824 if (turn === 2) {
825 yield* write("scope/in/edit", "second\n");
826 yield* fs.remove(path.join(cwd, "scope/out/new file"));
827 yield* write("scope/out/second", "second addition\n");
828 }
829 const capture = driver.checkpoints.captureCheckpoint({
830 cwd: captureCwd,
831 checkpointRef: ref,
832 });
833 if (indexState === "non-cone-missing") {
834 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
835 assert.isFalse(
836 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
837 );
838 assert.isFalse(yield* fs.exists(indexPath));
839 break;
840 }
841 yield* capture;
842 for (const [name, content] of [
843 ["scope/out/deep/absent", "original\n"],
844 ["scope/out/present", "modified skipped\n"],
845 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
846 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
847 [
848 turn === 1 ? "scope/out/new file" : "scope/out/second",
849 turn === 1 ? "new outside cone\n" : "second addition\n",
850 ],
851 ]) {
852 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
853 }
854 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
855 assert.notInclude(files, "scope/in/delete");
856 if (turn === 2) assert.notInclude(files, "scope/out/new file");
857 assert.deepEqual(
858 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
859 originalIndex,
860 );
861 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
862 assert.strictEqual(
863 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
864 "working outside\n",
865 );
866 }
867 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
868);
902869
903870it.effect("checkpoint capture preserves racy edits made after resetting the index", () =>
904871 Effect.gen(function* () {
......@@ -943,89 +910,87 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde
943910 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
944911);
945912
946for (const nested of [false, true]) {
947 for (const indexMode of ["normal", "flags", "split"] as const) {
948 it.effect(
949 `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`,
950 () =>
951 Effect.gen(function* () {
952 const fileSystem = yield* FileSystem.FileSystem;
953 const path = yield* Path.Path;
954 const driver = yield* GitVcsDriver.makeVcsDriverShape();
955 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" });
956 const { git } = yield* makeCheckpointFixture(driver, cwd);
957 const write = (name: string, contents: string) =>
958 fileSystem.writeFileString(path.join(cwd, name), contents);
959 yield* fileSystem.makeDirectory(path.join(cwd, "scope"));
960 for (const name of [
961 "scope/staged",
962 "scope/deleted",
963 "scope/assumed",
964 "scope/skipped",
965 "outside",
966 ]) {
967 yield* write(name, "original\n");
968 }
969 yield* git(["add", "."]);
970 yield* git(["commit", "-m", "initial scoped files"]);
971 yield* write("scope/staged", "staged\n");
972 yield* write("scope/new-deleted", "staged then deleted\n");
973 yield* write("outside", "staged outside\n");
974 yield* git(["add", "."]);
975 if (indexMode === "flags") {
976 yield* git(["update-index", "--assume-unchanged", "scope/assumed"]);
977 yield* git(["update-index", "--skip-worktree", "scope/skipped"]);
978 }
979 if (indexMode === "split") {
980 yield* git(["update-index", "--split-index"]);
981 }
982 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));
983 for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) {
984 yield* write(name, "working\n");
985 }
986 yield* write("scope/new", "first\n");
987 yield* fileSystem.remove(path.join(cwd, "scope/deleted"));
988 yield* fileSystem.remove(path.join(cwd, "scope/new-deleted"));
989 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
990 const first = CheckpointRef.make("refs/t3/checkpoints/turns/1");
991 const second = CheckpointRef.make("refs/t3/checkpoints/turns/2");
992 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first });
993 for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) {
994 assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n");
995 }
996 assert.strictEqual(
997 (yield* git(["show", `${first}:outside`])).stdout,
998 nested ? "original\n" : "working\n",
999 );
1000 const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n");
1001 assert.notInclude(files, "scope/deleted");
1002 assert.notInclude(files, "scope/new-deleted");
1003 assert.include(files, "scope/new");
913it.effect.each(
914 [false, true].flatMap((nested) =>
915 (["normal", "flags", "split"] as const).map((indexMode) => ({ nested, indexMode })),
916 ),
917)(
918 "checkpoint index reuse preserves two turns (nested=$nested, index=$indexMode)",
919 ({ nested, indexMode }) =>
920 Effect.gen(function* () {
921 const fileSystem = yield* FileSystem.FileSystem;
922 const path = yield* Path.Path;
923 const driver = yield* GitVcsDriver.makeVcsDriverShape();
924 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" });
925 const { git } = yield* makeCheckpointFixture(driver, cwd);
926 const write = (name: string, contents: string) =>
927 fileSystem.writeFileString(path.join(cwd, name), contents);
928 yield* fileSystem.makeDirectory(path.join(cwd, "scope"));
929 for (const name of [
930 "scope/staged",
931 "scope/deleted",
932 "scope/assumed",
933 "scope/skipped",
934 "outside",
935 ]) {
936 yield* write(name, "original\n");
937 }
938 yield* git(["add", "."]);
939 yield* git(["commit", "-m", "initial scoped files"]);
940 yield* write("scope/staged", "staged\n");
941 yield* write("scope/new-deleted", "staged then deleted\n");
942 yield* write("outside", "staged outside\n");
943 yield* git(["add", "."]);
944 if (indexMode === "flags") {
945 yield* git(["update-index", "--assume-unchanged", "scope/assumed"]);
946 yield* git(["update-index", "--skip-worktree", "scope/skipped"]);
947 }
948 if (indexMode === "split") {
949 yield* git(["update-index", "--split-index"]);
950 }
951 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));
952 for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) {
953 yield* write(name, "working\n");
954 }
955 yield* write("scope/new", "first\n");
956 yield* fileSystem.remove(path.join(cwd, "scope/deleted"));
957 yield* fileSystem.remove(path.join(cwd, "scope/new-deleted"));
958 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
959 const first = CheckpointRef.make("refs/t3/checkpoints/turns/1");
960 const second = CheckpointRef.make("refs/t3/checkpoints/turns/2");
961 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first });
962 for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) {
963 assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n");
964 }
965 assert.strictEqual(
966 (yield* git(["show", `${first}:outside`])).stdout,
967 nested ? "original\n" : "working\n",
968 );
969 const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n");
970 assert.notInclude(files, "scope/deleted");
971 assert.notInclude(files, "scope/new-deleted");
972 assert.include(files, "scope/new");
1004973
1005 yield* write("scope/staged", "second\n");
1006 yield* fileSystem.remove(path.join(cwd, "scope/new"));
1007 yield* write("scope/second", "added in second turn\n");
1008 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second });
1009 assert.strictEqual(
1010 (yield* git(["diff", "--name-only", first, second])).stdout,
1011 "scope/new\nscope/second\nscope/staged\n",
1012 );
1013 assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n");
1014 assert.strictEqual(
1015 (yield* git(["show", `${second}:scope/second`])).stdout,
1016 "added in second turn\n",
1017 );
1018 assert.deepEqual(
1019 yield* fileSystem.readFile(path.join(cwd, ".git", "index")),
1020 originalIndex,
1021 );
1022 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
1023 );
1024 }
1025}
974 yield* write("scope/staged", "second\n");
975 yield* fileSystem.remove(path.join(cwd, "scope/new"));
976 yield* write("scope/second", "added in second turn\n");
977 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second });
978 assert.strictEqual(
979 (yield* git(["diff", "--name-only", first, second])).stdout,
980 "scope/new\nscope/second\nscope/staged\n",
981 );
982 assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n");
983 assert.strictEqual(
984 (yield* git(["show", `${second}:scope/second`])).stdout,
985 "added in second turn\n",
986 );
987 assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex);
988 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
989);
1026990
1027for (const indexState of ["missing", "invalid"] as const) {
1028 it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () =>
991it.effect.each(["missing", "invalid"] as const)(
992 "checkpoint capture falls back when the user index is %s",
993 (indexState) =>
1029994 Effect.gen(function* () {
1030995 const fileSystem = yield* FileSystem.FileSystem;
1031996 const path = yield* Path.Path;
......@@ -1048,8 +1013,7 @@ for (const indexState of ["missing", "invalid"] as const) {
10481013 assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index");
10491014 }
10501015 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
1051 );
1052}
1016);
10531017
10541018it.effect("restores empty checkpoints without changing paths outside the workspace", () =>
10551019 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriverCore.test.ts+134-140
......@@ -254,50 +254,48 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])(
254254 }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
255255);
256256
257for (const location of ["root", "nested", "worktree"] as const) {
258 it.effect(
259 `skips clean filters while the ${location} index is locked and resumes after unlock`,
260 () =>
261 Effect.gen(function* () {
262 const driver = yield* GitVcsDriver.GitVcsDriver;
263 const fs = yield* FileSystem.FileSystem;
264 const path = yield* Path.Path;
265 const repository = yield* makeTmpDir();
266 yield* initRepoWithCommit(repository);
267 const cwd = location === "worktree" ? yield* makeTmpDir() : repository;
268 if (location === "worktree") {
269 yield* git(repository, ["worktree", "add", "--detach", cwd]);
270 }
271 yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]);
272 yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n");
273 yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n");
274 yield* writeTextFile(cwd, "asset.bin", "original\n");
275 yield* git(cwd, ["add", "."]);
276 yield* git(cwd, ["commit", "-m", "filtered asset"]);
277 NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1);
278 const runsPath = path.join(cwd, ".filter-runs");
279 yield* fs.remove(runsPath, { force: true });
280 const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]);
281 const lockPath = `${path.resolve(cwd, indexPath)}.lock`;
282 yield* fs.writeFileString(lockPath, "");
283 const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd;
284 yield* fs.makeDirectory(statusCwd, { recursive: true });
285
286 for (let poll = 0; poll < 3; poll++) {
287 const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result);
288 assert.isTrue(Result.isFailure(result));
289 if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked");
290 }
291 assert.isFalse(yield* fs.exists(runsPath));
292 assert.isTrue(yield* fs.exists(lockPath));
293
294 yield* fs.remove(lockPath);
295 const status = yield* driver.statusDetailsLocal(statusCwd);
296 assert.isFalse(status.hasWorkingTreeChanges);
297 assert.include(yield* fs.readFileString(runsPath), "clean");
298 }).pipe(Effect.provide(TestLayer)),
299 );
300}
257it.effect.each(["root", "nested", "worktree"] as const)(
258 "skips clean filters while the %s index is locked and resumes after unlock",
259 (location) =>
260 Effect.gen(function* () {
261 const driver = yield* GitVcsDriver.GitVcsDriver;
262 const fs = yield* FileSystem.FileSystem;
263 const path = yield* Path.Path;
264 const repository = yield* makeTmpDir();
265 yield* initRepoWithCommit(repository);
266 const cwd = location === "worktree" ? yield* makeTmpDir() : repository;
267 if (location === "worktree") {
268 yield* git(repository, ["worktree", "add", "--detach", cwd]);
269 }
270 yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]);
271 yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n");
272 yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n");
273 yield* writeTextFile(cwd, "asset.bin", "original\n");
274 yield* git(cwd, ["add", "."]);
275 yield* git(cwd, ["commit", "-m", "filtered asset"]);
276 NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1);
277 const runsPath = path.join(cwd, ".filter-runs");
278 yield* fs.remove(runsPath, { force: true });
279 const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]);
280 const lockPath = `${path.resolve(cwd, indexPath)}.lock`;
281 yield* fs.writeFileString(lockPath, "");
282 const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd;
283 yield* fs.makeDirectory(statusCwd, { recursive: true });
284
285 for (let poll = 0; poll < 3; poll++) {
286 const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result);
287 assert.isTrue(Result.isFailure(result));
288 if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked");
289 }
290 assert.isFalse(yield* fs.exists(runsPath));
291 assert.isTrue(yield* fs.exists(lockPath));
292
293 yield* fs.remove(lockPath);
294 const status = yield* driver.statusDetailsLocal(statusCwd);
295 assert.isFalse(status.hasWorkingTreeChanges);
296 assert.include(yield* fs.readFileString(runsPath), "clean");
297 }).pipe(Effect.provide(TestLayer)),
298);
301299
302300it.effect("uses stable diagnostics for every parsed non-repository command", () => {
303301 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", ()
822820 ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
823821);
824822
825for (const scenario of [
823it.effect.each([
826824 {
827825 name: "HTTPS credentials",
828826 stderr: "fatal: Authentication failed for",
......@@ -885,53 +883,51 @@ for (const scenario of [
885883 stderr: "fatal: unexpected remote failure",
886884 expected: "git fetch origin failed",
887885 },
888] as const) {
889 it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () =>
890 Effect.gen(function* () {
891 const secret = "secret-fetch-token";
892 const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`;
893 const attempts = yield* Ref.make(0);
894 const spawner = ChildProcessSpawner.make((command) =>
895 Effect.gen(function* () {
896 if (!ChildProcess.isStandardCommand(command))
897 return yield* Effect.die("expected Git command");
898 if (command.args[0] !== "fetch") return makeNonRepositoryHandle();
899 assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]);
900 assert.equal(command.options.env?.LC_ALL, "C");
901 assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0");
902 yield* Ref.update(attempts, (count) => count + 1);
903 return ChildProcessSpawner.makeHandle({
904 pid: ChildProcessSpawner.ProcessId(1),
905 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
906 isRunning: Effect.succeed(false),
907 kill: () => Effect.void,
908 unref: Effect.succeed(Effect.void),
909 stdin: Sink.drain,
910 stdout: Stream.encodeText(Stream.make(secret)),
911 stderr: Stream.encodeText(Stream.make(stderr)),
912 all: Stream.empty,
913 getInputFd: () => Sink.drain,
914 getOutputFd: () => Stream.empty,
915 });
916 }),
917 );
918 const driver = yield* makeGitVcsDriverCore().pipe(
919 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
920 );
921 const cwd = yield* makeTmpDir();
922 const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip);
923 assert.include(error.detail, scenario.expected);
924 assert.equal(error.exitCode, 128);
925 assert.equal(error.stderrLength, stderr.length);
926 assert.equal(error.stdoutLength, secret.length);
927 assert.notInclude(error.message, secret);
928 assert.notInclude(yield* encodeGitCommandError(error), secret);
929 assert.notProperty(error, "stderr");
930 assert.notProperty(error, "args");
931 assert.equal(yield* Ref.get(attempts), 1);
932 }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
933 );
934}
886] as const)("reports $name during fetch without retaining remote output", (scenario) =>
887 Effect.gen(function* () {
888 const secret = "secret-fetch-token";
889 const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`;
890 const attempts = yield* Ref.make(0);
891 const spawner = ChildProcessSpawner.make((command) =>
892 Effect.gen(function* () {
893 if (!ChildProcess.isStandardCommand(command))
894 return yield* Effect.die("expected Git command");
895 if (command.args[0] !== "fetch") return makeNonRepositoryHandle();
896 assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]);
897 assert.equal(command.options.env?.LC_ALL, "C");
898 assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0");
899 yield* Ref.update(attempts, (count) => count + 1);
900 return ChildProcessSpawner.makeHandle({
901 pid: ChildProcessSpawner.ProcessId(1),
902 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
903 isRunning: Effect.succeed(false),
904 kill: () => Effect.void,
905 unref: Effect.succeed(Effect.void),
906 stdin: Sink.drain,
907 stdout: Stream.encodeText(Stream.make(secret)),
908 stderr: Stream.encodeText(Stream.make(stderr)),
909 all: Stream.empty,
910 getInputFd: () => Sink.drain,
911 getOutputFd: () => Stream.empty,
912 });
913 }),
914 );
915 const driver = yield* makeGitVcsDriverCore().pipe(
916 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
917 );
918 const cwd = yield* makeTmpDir();
919 const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip);
920 assert.include(error.detail, scenario.expected);
921 assert.equal(error.exitCode, 128);
922 assert.equal(error.stderrLength, stderr.length);
923 assert.equal(error.stdoutLength, secret.length);
924 assert.notInclude(error.message, secret);
925 assert.notInclude(yield* encodeGitCommandError(error), secret);
926 assert.notProperty(error, "stderr");
927 assert.notProperty(error, "args");
928 assert.equal(yield* Ref.get(attempts), 1);
929 }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
930);
935931
936932it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
937933 describe("process environment", () => {
......@@ -1449,8 +1445,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
14491445 }),
14501446 );
14511447
1452 for (const splitIndex of [false, true]) {
1453 it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () =>
1448 it.effect.each([false, true])(
1449 "keeps the preceding second cached in review previews (split: %s)",
1450 (splitIndex) =>
14541451 Effect.gen(function* () {
14551452 const cwd = yield* makeTmpDir();
14561453 yield* initRepoWithCommit(cwd);
......@@ -1484,53 +1481,50 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
14841481 assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex);
14851482 assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime);
14861483 }),
1487 );
1488 }
1484 );
14891485
1490 for (const [timestamp, splitIndex] of [
1486 it.effect.each([
14911487 [1_700_000_000, false],
14921488 [1_700_000_000.9999, false],
14931489 [1_700_000_000, true],
14941490 [1_700_000_000.9999, true],
1495 ] as const) {
1496 it.effect(
1497 `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`,
1498 () =>
1499 Effect.gen(function* () {
1500 const cwd = yield* makeTmpDir();
1501 yield* initRepoWithCommit(cwd);
1502 const driver = yield* GitVcsDriver.GitVcsDriver;
1503 const fileSystem = yield* FileSystem.FileSystem;
1504 const path = yield* Path.Path;
1505 const filePath = path.join(cwd, "tracked.txt");
1506 const indexPath = path.join(cwd, ".git", "index");
1507 // Reproduce a same-timestamp edit without relying on filesystem clock resolution.
1508 yield* git(cwd, ["config", "core.trustctime", "false"]);
1509 yield* writeTextFile(cwd, "tracked.txt", "before\n");
1510 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1511 yield* git(cwd, ["add", "tracked.txt"]);
1512 yield* git(cwd, ["commit", "-m", "record racy file"]);
1513 if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]);
1514 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
1515 const originalIndex = yield* fileSystem.readFile(indexPath);
1516 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
1517 yield* writeTextFile(cwd, "tracked.txt", "after!\n");
1518 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1519 yield* writeTextFile(cwd, "untracked.txt", "new\n");
1520
1521 const preview = yield* driver.getReviewDiffPreview({ cwd });
1522 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
1523 assert.deepStrictEqual(dirty.files, [
1524 { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 },
1525 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
1526 ]);
1527 assert.include(dirty.diff, "-before");
1528 assert.include(dirty.diff, "+after!");
1529 assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex);
1530 assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
1531 }),
1532 );
1533 }
1491 ] as const)(
1492 "preserves same-size edits with a racy review index (%s, split: %s)",
1493 ([timestamp, splitIndex]) =>
1494 Effect.gen(function* () {
1495 const cwd = yield* makeTmpDir();
1496 yield* initRepoWithCommit(cwd);
1497 const driver = yield* GitVcsDriver.GitVcsDriver;
1498 const fileSystem = yield* FileSystem.FileSystem;
1499 const path = yield* Path.Path;
1500 const filePath = path.join(cwd, "tracked.txt");
1501 const indexPath = path.join(cwd, ".git", "index");
1502 // Reproduce a same-timestamp edit without relying on filesystem clock resolution.
1503 yield* git(cwd, ["config", "core.trustctime", "false"]);
1504 yield* writeTextFile(cwd, "tracked.txt", "before\n");
1505 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1506 yield* git(cwd, ["add", "tracked.txt"]);
1507 yield* git(cwd, ["commit", "-m", "record racy file"]);
1508 if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]);
1509 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
1510 const originalIndex = yield* fileSystem.readFile(indexPath);
1511 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
1512 yield* writeTextFile(cwd, "tracked.txt", "after!\n");
1513 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1514 yield* writeTextFile(cwd, "untracked.txt", "new\n");
1515
1516 const preview = yield* driver.getReviewDiffPreview({ cwd });
1517 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
1518 assert.deepStrictEqual(dirty.files, [
1519 { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 },
1520 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
1521 ]);
1522 assert.include(dirty.diff, "-before");
1523 assert.include(dirty.diff, "+after!");
1524 assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex);
1525 assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
1526 }),
1527 );
15341528
15351529 it.effect("keeps complete stats for files beyond the combined patch limit", () =>
15361530 Effect.gen(function* () {
......@@ -2940,8 +2934,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
29402934 });
29412935
29422936 describe("remote operations", () => {
2943 for (const failure of ["offline", "auth", "timeout"] as const) {
2944 it.effect(`does not retry a scoped fetch after ${failure}`, () =>
2937 it.effect.each(["offline", "auth", "timeout"] as const)(
2938 "does not retry a scoped fetch after %s",
2939 (failure) =>
29452940 Effect.gen(function* () {
29462941 const cwd = yield* makeTmpDir();
29472942 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
......@@ -2996,8 +2991,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
29962991 );
29972992 }
29982993 }),
2999 );
3000 }
2994 );
30012995
30022996 it.effect("creates a worktree from the latest fetched remote commit", () =>
30032997 Effect.gen(function* () {
apps/web/src/components/chat/queuedMessageEdit.test.ts+18-17
......@@ -42,24 +42,25 @@ describe("queued message file edits", () => {
4242 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
4343 });
4444
45 for (const images of [[], [image]]) {
46 it(`preserves a generic file in a ${images.length === 0 ? "file-only" : "mixed"} save`, async () => {
47 const attachments = await prepareQueuedEditAttachments({
48 existingAttachments: [],
49 images,
50 files: [file],
51 uploadFiles: async () => [uploadedFile],
52 readImage: async () => "data:image/png;base64,aW1hZ2U=",
53 });
54 expect(attachments.at(-1)).toEqual(uploadedFile);
55 expect(attachments.length).toBe(images.length + 1);
56 if (images.length > 0)
57 expect(attachments[0]).toMatchObject({
58 type: "image",
59 dataUrl: "data:image/png;base64,aW1hZ2U=",
60 });
45 it.each([
46 { images: [], label: "file-only" },
47 { images: [image], label: "mixed" },
48 ])("preserves a generic file in a $label save", async ({ images }) => {
49 const attachments = await prepareQueuedEditAttachments({
50 existingAttachments: [],
51 images,
52 files: [file],
53 uploadFiles: async () => [uploadedFile],
54 readImage: async () => "data:image/png;base64,aW1hZ2U=",
6155 });
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
6465 it("retains saved attachments alongside newly uploaded files", async () => {
6566 const saved = { ...uploadedFile, id: "saved:earlier" };
apps/web/src/keybindings.test.ts+10-8
......@@ -1279,8 +1279,9 @@ describe("composer and pull request shortcuts", () => {
12791279 ["Enter", "thread.steerQueuedMessage"],
12801280 ] as const;
12811281
1282 for (const platform of ["MacIntel", "Win32", "Linux"]) {
1283 it(`separates queued steering and background start on ${platform}`, () => {
1282 it.each(["MacIntel", "Win32", "Linux"])(
1283 "separates queued steering and background start on %s",
1284 (platform) => {
12841285 const modifier = {
12851286 metaKey: platform === "MacIntel",
12861287 ctrlKey: platform !== "MacIntel",
......@@ -1301,8 +1302,8 @@ describe("composer and pull request shortcuts", () => {
13011302 }),
13021303 "composer.sendBackground",
13031304 );
1304 });
1305 }
1305 },
1306 );
13061307
13071308 for (const platform of ["MacIntel", "Win32", "Linux"]) {
13081309 it.each(shortcuts)(
......@@ -1331,8 +1332,9 @@ describe("composer and pull request shortcuts", () => {
13311332 );
13321333 }
13331334
1334 for (const platform of ["MacIntel", "Win32", "Linux"]) {
1335 it(`edits the last queued message with Alt+ArrowUp from the composer on ${platform}`, () => {
1335 it.each(["MacIntel", "Win32", "Linux"])(
1336 "edits the last queued message with Alt+ArrowUp from the composer on %s",
1337 (platform) => {
13361338 const input = event({ key: "ArrowUp", altKey: true });
13371339 assert.strictEqual(
13381340 resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, {
......@@ -1347,8 +1349,8 @@ describe("composer and pull request shortcuts", () => {
13471349 context: { composerFocus: false },
13481350 }),
13491351 );
1350 });
1351 }
1352 },
1353 );
13521354
13531355 for (const platform of ["MacIntel", "Win32", "Linux"]) {
13541356 it.each([
infra/relay/src/agentActivity/ApnsClient.test.ts+74-74
......@@ -366,79 +366,79 @@ describe("ApnsClient", () => {
366366 }).pipe(Effect.provide(layer));
367367 });
368368
369 for (const requestKind of ["live-activity", "push-notification"] as const) {
370 for (const stage of ["send", "read-response"] as const) {
371 it.effect(`aborts a stalled ${requestKind} ${stage} after ten seconds`, () =>
372 Effect.gen(function* () {
373 const started = yield* Deferred.make<void>();
374 const signals: AbortSignal[] = [];
375 const stalledHttpClient = HttpClient.make((request, _url, signal) => {
376 signals.push(signal);
377 const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));
378 if (stage === "send") return stall;
379 const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
380 Object.defineProperty(response, "text", { value: stall });
381 return Effect.succeed(response);
382 });
383 const layer = ApnsClient.layer.pipe(
384 Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)),
385 Layer.provide(
386 Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, {
387 getJwt: () => Effect.succeed("test-jwt"),
388 }),
389 ),
390 );
391 const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer));
392 const credentials = {
393 teamId: "team-timeout",
394 keyId: "key-timeout",
395 privateKey: Redacted.make("unused-test-key"),
396 bundleId: "com.t3tools.test",
397 environment: "sandbox",
398 } satisfies ApnsCredentials;
399 const send =
400 requestKind === "live-activity"
401 ? apns.sendLiveActivityRequest({
402 credentials,
403 issuedAtUnixSeconds: 123,
404 request: apns.makeLiveActivityRequest({
405 event: "update",
406 token: "long-push-token",
407 state,
408 nowEpochSeconds: 123,
409 nowIso: DateTime.formatIso(now),
410 }),
411 })
412 : apns.sendPushNotificationRequest({
413 credentials,
414 issuedAtUnixSeconds: 123,
415 request: apns.makePushNotificationRequest({
416 token: "long-push-token",
417 notification: {
418 title: "Thread",
419 body: "Done",
420 environmentId: "env",
421 threadId: "thread",
422 deepLink: "/",
423 },
424 }),
425 });
426 const fiber = yield* send.pipe(Effect.flip, Effect.forkChild);
427 yield* Deferred.await(started);
428 yield* TestClock.adjust("10 seconds");
429 expect(signals[0]?.aborted).toBe(true);
430 const error = yield* Fiber.join(fiber);
431 expect(error).toMatchObject({
432 _tag: "ApnsHttpRequestError",
433 requestKind,
434 event: requestKind === "live-activity" ? "update" : null,
435 stage,
436 status: stage === "read-response" ? 200 : null,
437 tokenSuffix: "sh-token",
438 cause: { _tag: "TimeoutError" },
439 });
440 }),
369 it.effect.each(
370 (["live-activity", "push-notification"] as const).flatMap((requestKind) =>
371 (["send", "read-response"] as const).map((stage) => ({ requestKind, stage })),
372 ),
373 )("aborts a stalled $requestKind $stage after ten seconds", ({ requestKind, stage }) =>
374 Effect.gen(function* () {
375 const started = yield* Deferred.make<void>();
376 const signals: AbortSignal[] = [];
377 const stalledHttpClient = HttpClient.make((request, _url, signal) => {
378 signals.push(signal);
379 const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));
380 if (stage === "send") return stall;
381 const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
382 Object.defineProperty(response, "text", { value: stall });
383 return Effect.succeed(response);
384 });
385 const layer = ApnsClient.layer.pipe(
386 Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)),
387 Layer.provide(
388 Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, {
389 getJwt: () => Effect.succeed("test-jwt"),
390 }),
391 ),
441392 );
442 }
443 }
393 const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer));
394 const credentials = {
395 teamId: "team-timeout",
396 keyId: "key-timeout",
397 privateKey: Redacted.make("unused-test-key"),
398 bundleId: "com.t3tools.test",
399 environment: "sandbox",
400 } satisfies ApnsCredentials;
401 const send =
402 requestKind === "live-activity"
403 ? apns.sendLiveActivityRequest({
404 credentials,
405 issuedAtUnixSeconds: 123,
406 request: apns.makeLiveActivityRequest({
407 event: "update",
408 token: "long-push-token",
409 state,
410 nowEpochSeconds: 123,
411 nowIso: DateTime.formatIso(now),
412 }),
413 })
414 : apns.sendPushNotificationRequest({
415 credentials,
416 issuedAtUnixSeconds: 123,
417 request: apns.makePushNotificationRequest({
418 token: "long-push-token",
419 notification: {
420 title: "Thread",
421 body: "Done",
422 environmentId: "env",
423 threadId: "thread",
424 deepLink: "/",
425 },
426 }),
427 });
428 const fiber = yield* send.pipe(Effect.flip, Effect.forkChild);
429 yield* Deferred.await(started);
430 yield* TestClock.adjust("10 seconds");
431 expect(signals[0]?.aborted).toBe(true);
432 const error = yield* Fiber.join(fiber);
433 expect(error).toMatchObject({
434 _tag: "ApnsHttpRequestError",
435 requestKind,
436 event: requestKind === "live-activity" ? "update" : null,
437 stage,
438 status: stage === "read-response" ? 200 : null,
439 tokenSuffix: "sh-token",
440 cause: { _tag: "TimeoutError" },
441 });
442 }),
443 );
444444});
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+74-73
......@@ -1909,8 +1909,9 @@ describe("live activity alert decisions", () => {
19091909});
19101910
19111911describe("queued iOS alert policy", () => {
1912 for (const scenario of ["enabled", "muted", "late"] as const) {
1913 it.effect(`checks the current policy for a ${scenario} completion`, () => {
1912 it.effect.each(["enabled", "muted", "late"] as const)(
1913 "checks the current policy for a %s completion",
1914 (scenario) => {
19141915 let sent = 0;
19151916 const completed = { ...state, phase: "completed" as const };
19161917 const prefs = JSON.parse(enabledPreferences);
......@@ -1957,8 +1958,8 @@ describe("queued iOS alert policy", () => {
19571958 }),
19581959 ),
19591960 );
1960 });
1961 }
1961 },
1962 );
19621963});
19631964
19641965describe("fast completion delivery", () => {
......@@ -2019,74 +2020,74 @@ describe("fast completion delivery", () => {
20192020});
20202021
20212022describe("signed APNs registration metadata", () => {
2022 for (const kind of ["live_activity_update", "push_notification"] as const) {
2023 for (const changed of ["bundle", "environment", "legacy"] as const) {
2024 it.effect(`routes ${kind} using current registration with ${changed} job metadata`, () => {
2025 const attempts: DeliveryAttempts.DeliveryAttemptInput[] = [];
2026 const requests: HttpClientRequest.HttpClientRequest[] = [];
2027 const payload = makeApnsDeliveryJobPayload({
2028 kind,
2029 userId: target.user_id,
2030 deviceId: target.device_id,
2031 token: "unchanged-token",
2032 ...(changed === "legacy"
2033 ? {}
2034 : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }),
2035 aggregate: kind === "live_activity_update" ? aggregate : null,
2036 ...(kind === "push_notification"
2037 ? {
2038 notification: {
2039 title: "Thread",
2040 body: "Input: Project",
2041 environmentId: "env",
2042 threadId: "thread",
2043 deepLink: "/",
2044 },
2045 }
2046 : {}),
2047 createdAt: "1970-01-01T00:00:00.000Z",
2048 expiresAt: "1970-01-01T00:10:00.000Z",
2049 jobId: `metadata-${kind}-${changed}`,
2050 });
2051 const signed = signApnsDeliveryJob({
2052 secret: config.apnsDeliveryJobSigningSecret,
2053 payload,
2054 });
2055 return Effect.gen(function* () {
2056 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
2057 const result = yield* deliveries.processSignedJob(signed);
2058 expect(result.ok).toBe(true);
2059 expect(requests).toHaveLength(1);
2060 expect(requests[0]?.url).toBe(
2061 `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`,
2062 );
2063 expect(requests[0]?.headers["apns-topic"]).toBe(
2064 `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`,
2065 );
2066 }).pipe(
2067 Effect.provide(
2068 makeLayer({
2069 attempts,
2070 config: signingConfig,
2071 currentTargets: [
2072 {
2073 ...target,
2074 push_token: "unchanged-token",
2075 activity_push_token: "unchanged-token",
2076 bundle_id:
2077 changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev",
2078 aps_environment: changed === "environment" ? "production" : "sandbox",
2079 },
2080 ],
2081 execute: (request) =>
2082 Effect.sync(() => {
2083 requests.push(request);
2084 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
2085 }),
2023 it.effect.each(
2024 (["live_activity_update", "push_notification"] as const).flatMap((kind) =>
2025 (["bundle", "environment", "legacy"] as const).map((changed) => ({ kind, changed })),
2026 ),
2027 )("routes $kind using current registration with $changed job metadata", ({ kind, changed }) => {
2028 const attempts: DeliveryAttempts.DeliveryAttemptInput[] = [];
2029 const requests: HttpClientRequest.HttpClientRequest[] = [];
2030 const payload = makeApnsDeliveryJobPayload({
2031 kind,
2032 userId: target.user_id,
2033 deviceId: target.device_id,
2034 token: "unchanged-token",
2035 ...(changed === "legacy"
2036 ? {}
2037 : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }),
2038 aggregate: kind === "live_activity_update" ? aggregate : null,
2039 ...(kind === "push_notification"
2040 ? {
2041 notification: {
2042 title: "Thread",
2043 body: "Input: Project",
2044 environmentId: "env",
2045 threadId: "thread",
2046 deepLink: "/",
2047 },
2048 }
2049 : {}),
2050 createdAt: "1970-01-01T00:00:00.000Z",
2051 expiresAt: "1970-01-01T00:10:00.000Z",
2052 jobId: `metadata-${kind}-${changed}`,
2053 });
2054 const signed = signApnsDeliveryJob({
2055 secret: config.apnsDeliveryJobSigningSecret,
2056 payload,
2057 });
2058 return Effect.gen(function* () {
2059 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
2060 const result = yield* deliveries.processSignedJob(signed);
2061 expect(result.ok).toBe(true);
2062 expect(requests).toHaveLength(1);
2063 expect(requests[0]?.url).toBe(
2064 `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`,
2065 );
2066 expect(requests[0]?.headers["apns-topic"]).toBe(
2067 `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`,
2068 );
2069 }).pipe(
2070 Effect.provide(
2071 makeLayer({
2072 attempts,
2073 config: signingConfig,
2074 currentTargets: [
2075 {
2076 ...target,
2077 push_token: "unchanged-token",
2078 activity_push_token: "unchanged-token",
2079 bundle_id:
2080 changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev",
2081 aps_environment: changed === "environment" ? "production" : "sandbox",
2082 },
2083 ],
2084 execute: (request) =>
2085 Effect.sync(() => {
2086 requests.push(request);
2087 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
20862088 }),
2087 ),
2088 );
2089 });
2090 }
2091 }
2089 }),
2090 ),
2091 );
2092 });
20922093});
infra/relay/src/agentActivity/FcmDeliveries.test.ts+75-73
......@@ -215,36 +215,39 @@ describe("Android delivery routing", () => {
215215 deepLink: "/threads/env/second-thread",
216216 };
217217
218 for (const [firstPhase, secondPhase, title, active] of [
219 ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"],
220 ["completed", "failed", "2 agents finished", "false"],
221 ] as const) {
222 it.effect(
223 `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`,
224 () => {
225 const h = harness();
226 h.current.otherStates = [secondState];
227 return Effect.gen(function* () {
228 const delivery = yield* FcmDeliveries.FcmDeliveries;
229 yield* delivery.process(h.job);
230 h.current.state = { ...state, phase: firstPhase };
231 h.current.otherStates = [{ ...secondState, phase: secondPhase }];
232 yield* delivery.process({ ...h.job, state: h.current.state });
233 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });
234 yield* delivery.process({ ...h.job, state: h.current.state });
235 const alerts = h.sent.filter((message) => message.alert);
236 expect(alerts).toHaveLength(1);
237 expect(alerts[0]?.data).toMatchObject({
238 alert_title: title,
239 alert_body: "Fix notifications, Second thread",
240 alert_path: "/",
241 active,
242 });
243 expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2);
244 }).pipe(Effect.provide(h.layer));
245 },
246 );
247 }
218 it.effect.each([
219 {
220 firstPhase: "waiting_for_approval",
221 secondPhase: "waiting_for_input",
222 title: "2 agents need attention",
223 active: "true",
224 },
225 { firstPhase: "completed", secondPhase: "failed", title: "2 agents finished", active: "false" },
226 ] as const)(
227 "routes grouped $firstPhase and $secondPhase to the overview once across their queued jobs",
228 ({ firstPhase, secondPhase, title, active }) => {
229 const h = harness();
230 h.current.otherStates = [secondState];
231 return Effect.gen(function* () {
232 const delivery = yield* FcmDeliveries.FcmDeliveries;
233 yield* delivery.process(h.job);
234 h.current.state = { ...state, phase: firstPhase };
235 h.current.otherStates = [{ ...secondState, phase: secondPhase }];
236 yield* delivery.process({ ...h.job, state: h.current.state });
237 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });
238 yield* delivery.process({ ...h.job, state: h.current.state });
239 const alerts = h.sent.filter((message) => message.alert);
240 expect(alerts).toHaveLength(1);
241 expect(alerts[0]?.data).toMatchObject({
242 alert_title: title,
243 alert_body: "Fix notifications, Second thread",
244 alert_path: "/",
245 active,
246 });
247 expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2);
248 }).pipe(Effect.provide(h.layer));
249 },
250 );
248251
249252 it.effect("filters disabled event types before counting a group", () => {
250253 const h = harness();
......@@ -294,13 +297,9 @@ describe("Android delivery routing", () => {
294297 }).pipe(Effect.provide(h.layer));
295298 });
296299
297 for (const phase of [
298 "completed",
299 "waiting_for_approval",
300 "waiting_for_input",
301 "failed",
302 ] as const) {
303 it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => {
300 it.effect.each(["completed", "waiting_for_approval", "waiting_for_input", "failed"] as const)(
301 "deleting one thread preserves another thread's %s alert",
302 (phase) => {
304303 const h = harness();
305304 h.current.otherStates = [secondState];
306305 return Effect.gen(function* () {
......@@ -316,8 +315,8 @@ describe("Android delivery routing", () => {
316315 yield* delivery.process({ ...h.job, state: h.current.state });
317316 expect(h.sent.filter((message) => message.alert)).toHaveLength(1);
318317 }).pipe(Effect.provide(h.layer));
319 });
320 }
318 },
319 );
321320
322321 it.effect("registration replay establishes a baseline without alerting", () => {
323322 const h = harness();
......@@ -333,8 +332,9 @@ describe("Android delivery routing", () => {
333332 }).pipe(Effect.provide(h.layer));
334333 });
335334
336 for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) {
337 it.effect(`excludes ${restriction} when forming cross-environment groups`, () => {
335 it.effect.each(["mutedEnvironments", "revokedEnvironments"] as const)(
336 "excludes %s when forming cross-environment groups",
337 (restriction) => {
338338 const h = harness();
339339 const other = { ...secondState, environmentId: EnvironmentId.make("other-env") };
340340 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other]));
......@@ -349,8 +349,8 @@ describe("Android delivery routing", () => {
349349 alert_body: "Approval: Project",
350350 });
351351 }).pipe(Effect.provide(h.layer));
352 });
353 }
352 },
353 );
354354
355355 it("gives a group a stable retry identity independent of row order", () => {
356356 const other = {
......@@ -409,13 +409,14 @@ describe("Android delivery routing", () => {
409409 ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" });
410410 });
411411
412 for (const [phase, body, preference] of [
413 ["waiting_for_approval", "Approval: Project", "notifyOnApproval"],
414 ["waiting_for_input", "Input: Project", "notifyOnInput"],
415 ["completed", "Done: Project", "notifyOnCompletion"],
416 ["failed", "Failed: Project", "notifyOnFailure"],
417 ] as const) {
418 it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => {
412 it.effect.each([
413 { phase: "waiting_for_approval", body: "Approval: Project", preference: "notifyOnApproval" },
414 { phase: "waiting_for_input", body: "Input: Project", preference: "notifyOnInput" },
415 { phase: "completed", body: "Done: Project", preference: "notifyOnCompletion" },
416 { phase: "failed", body: "Failed: Project", preference: "notifyOnFailure" },
417 ] as const)(
418 "uses iOS alert wording for $phase and honors its preference",
419 ({ phase, body, preference }) => {
419420 const h = harness();
420421 h.current.state = { ...state, phase };
421422 return Effect.gen(function* () {
......@@ -433,8 +434,8 @@ describe("Android delivery routing", () => {
433434 yield* delivery.process({ ...h.job, state: h.current.state });
434435 expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true);
435436 }).pipe(Effect.provide(h.layer));
436 });
437 }
437 },
438 );
438439
439440 it("trims and truncates alert text like iOS", () => {
440441 expect(
......@@ -572,23 +573,23 @@ describe("Android delivery routing", () => {
572573 expect(h.sent[1]?.data.alert_id).toBeUndefined();
573574 }).pipe(Effect.provide(h.layer));
574575 });
575 for (const ongoing of [true, false]) {
576 for (const phase of ["completed", "failed"] as const) {
577 it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => {
578 const h = harness();
579 h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" };
580 h.current.target.preferences_json = encodeJson({
581 ...preferences,
582 liveActivitiesEnabled: ongoing,
583 });
584 return Effect.gen(function* () {
585 const delivery = yield* FcmDeliveries.FcmDeliveries;
586 yield* delivery.process({ ...h.job, state: h.current.state });
587 expect(h.sent.every((message) => !message.alert)).toBe(true);
588 }).pipe(Effect.provide(h.layer));
589 });
590 }
591 }
576 it.effect.each(
577 [true, false].flatMap((ongoing) =>
578 (["completed", "failed"] as const).map((phase) => ({ ongoing, phase })),
579 ),
580 )("does not alert a stale $phase without a baseline (ongoing=$ongoing)", ({ ongoing, phase }) => {
581 const h = harness();
582 h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" };
583 h.current.target.preferences_json = encodeJson({
584 ...preferences,
585 liveActivitiesEnabled: ongoing,
586 });
587 return Effect.gen(function* () {
588 const delivery = yield* FcmDeliveries.FcmDeliveries;
589 yield* delivery.process({ ...h.job, state: h.current.state });
590 expect(h.sent.every((message) => !message.alert)).toBe(true);
591 }).pipe(Effect.provide(h.layer));
592 });
592593
593594 it.effect(
594595 "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", () =
821822});
822823
823824describe("FCM queue message isolation", () => {
824 for (const failure of ["invalid-job", "fcm-rejection"] as const) {
825 it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => {
825 it.effect.each(["invalid-job", "fcm-rejection"] as const)(
826 "retries only the %s message and delivers the rest of its batch",
827 (failure) => {
826828 const h = harness();
827829 const outcomes = new Map<string, "ack" | "retry">();
828830 const message = (id: string, body: unknown): Cloudflare.Queues.Message<unknown> => ({
......@@ -863,6 +865,6 @@ describe("FCM queue message isolation", () => {
863865 expect(h.sent).toHaveLength(1);
864866 expect(h.marked).toHaveLength(1);
865867 }).pipe(Effect.provide(h.layer));
866 });
867 }
868 },
869 );
868870});
oxlint-plugin-t3code/index.ts+2
......@@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
77import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
88import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
99import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noTestInLoop from "./rules/no-test-in-loop.ts";
1011
1112export default definePlugin({
1213 meta: {
......@@ -20,5 +21,6 @@ export default definePlugin({
2021 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
2122 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
2223 "no-native-title-tooltip": noNativeTitleTooltip,
24 "no-test-in-loop": noTestInLoop,
2325 },
2426});
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", () => {
444444 }),
445445 );
446446
447 for (const scenario of ["unchanged", "changed", "revocation-failed"] as const) {
448 it.effect(`handles ${scenario} SSH routing consent before saving or authorizing`, () =>
447 it.effect.each(["unchanged", "changed", "revocation-failed"] as const)(
448 "handles %s SSH routing consent before saving or authorizing",
449 (scenario) =>
449450 Effect.gen(function* () {
450451 const calls: string[] = [];
451452 const target = new SshConnectionTarget({
......@@ -537,8 +538,7 @@ describe("ConnectionResolver", () => {
537538 }
538539 expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write");
539540 }),
540 );
541 }
541 );
542542
543543 it.effect("preserves relay authorization failure classification and trace details", () =>
544544 Effect.gen(function* () {
packages/client-runtime/src/operations/commands.test.ts+67-69
......@@ -502,83 +502,81 @@ describe("V2 environment commands", () => {
502502 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
503503 );
504504
505 for (const status of [
505 it.effect.each([
506506 "waiting",
507507 "completed",
508508 "failed",
509509 "interrupted",
510510 "cancelled",
511511 "rolled_back",
512 ] as const) {
513 it.effect(`dispatches Stop for ${status} runs with background commands except rollback`, () =>
514 Effect.gen(function* () {
515 const waitingRunId = RunId.make("run-waiting");
516 const projection: OrchestrationV2ThreadProjection = {
517 ...v2Projection,
518 runs: [
519 {
520 id: waitingRunId,
521 threadId: v2ThreadId,
522 ordinal: 1,
523 providerInstanceId: v2Projection.thread.providerInstanceId,
524 modelSelection: v2Projection.thread.modelSelection,
525 providerThreadId: null,
526 userMessageId: MessageId.make("message-waiting"),
527 rootNodeId: null,
528 activeAttemptId: null,
529 status,
530 requestedAt: v2Now,
531 startedAt: v2Now,
532 completedAt: null,
533 checkpointId: null,
534 contextHandoffId: null,
535 },
536 ],
537 turnItems: [
538 {
539 id: TurnItemId.make("background-command"),
540 threadId: v2ThreadId,
541 runId: waitingRunId,
542 nodeId: null,
543 providerThreadId: null,
544 providerTurnId: null,
545 nativeItemRef: null,
546 parentItemId: null,
547 ordinal: 1,
548 status: "running",
549 title: null,
550 startedAt: v2Now,
551 completedAt: null,
552 updatedAt: v2Now,
553 type: "command_execution",
554 input: "vp run dev",
555 },
556 ],
557 };
558 const commands: OrchestrationV2Command[] = [];
559 const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
512 ] as const)("dispatches Stop for %s runs with background commands except rollback", (status) =>
513 Effect.gen(function* () {
514 const waitingRunId = RunId.make("run-waiting");
515 const projection: OrchestrationV2ThreadProjection = {
516 ...v2Projection,
517 runs: [
518 {
519 id: waitingRunId,
520 threadId: v2ThreadId,
521 ordinal: 1,
522 providerInstanceId: v2Projection.thread.providerInstanceId,
523 modelSelection: v2Projection.thread.modelSelection,
524 providerThreadId: null,
525 userMessageId: MessageId.make("message-waiting"),
526 rootNodeId: null,
527 activeAttemptId: null,
528 status,
529 requestedAt: v2Now,
530 startedAt: v2Now,
531 completedAt: null,
532 checkpointId: null,
533 contextHandoffId: null,
534 },
535 ],
536 turnItems: [
537 {
538 id: TurnItemId.make("background-command"),
539 threadId: v2ThreadId,
540 runId: waitingRunId,
541 nodeId: null,
542 providerThreadId: null,
543 providerTurnId: null,
544 nativeItemRef: null,
545 parentItemId: null,
546 ordinal: 1,
547 status: "running",
548 title: null,
549 startedAt: v2Now,
550 completedAt: null,
551 updatedAt: v2Now,
552 type: "command_execution",
553 input: "vp run dev",
554 },
555 ],
556 };
557 const commands: OrchestrationV2Command[] = [];
558 const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
560559
561 const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe(
562 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
563 );
560 const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe(
561 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
562 );
564563
565 expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 });
566 expect(commands).toEqual(
567 status === "rolled_back"
568 ? []
569 : [
570 {
571 type: "run.interrupt",
572 commandId: expect.any(String),
573 threadId: v2ThreadId,
574 runId: waitingRunId,
575 holdQueue: true,
576 },
577 ],
578 );
579 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
580 );
581 }
564 expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 });
565 expect(commands).toEqual(
566 status === "rolled_back"
567 ? []
568 : [
569 {
570 type: "run.interrupt",
571 commandId: expect.any(String),
572 threadId: v2ThreadId,
573 runId: waitingRunId,
574 holdQueue: true,
575 },
576 ],
577 );
578 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
579 );
582580
583581 it.effect(
584582 "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", () => {
429429 ),
430430 );
431431
432 for (const options of [
432 it.effect.each([
433433 { environmentThemes: true },
434434 { usageLimitSources: true },
435435 { environmentThemes: true, usageLimitSources: true },
436 ]) {
437 it.effect(
438 `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`,
439 () =>
440 Effect.scoped(
441 Effect.gen(function* () {
442 const { factory, sockets } = yield* makeFactory(options);
443 const session = yield* factory.connect(PREPARED);
444 const readyFiber = yield* Effect.forkChild(session.ready);
445 const socket = yield* awaitSocket(sockets);
446 socket.open();
447 yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options);
448 yield* Fiber.join(readyFiber);
449
450 const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead);
451 expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } });
452 expect(
453 socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest),
454 ).toHaveLength(1);
455
456 const fallbackFiber = yield* session
457 .subscribeServerConfig({})
458 .pipe(Stream.runHead, Effect.forkChild);
459 const fallbackRequest = yield* awaitRequest(socket, 1);
460 expect(fallbackRequest).toMatchObject({
461 tag: WS_METHODS.subscribeServerConfig,
462 payload: {},
463 });
464 socket.serverMessage(
465 encodeJson({
466 _tag: "Chunk",
467 requestId: fallbackRequest.id,
468 values: [
469 {
470 version: 1,
471 type: "snapshot",
472 config: ENCODED_THEME_SERVER_CONFIG,
473 },
474 ],
475 }),
476 );
477 expect(yield* Fiber.join(fallbackFiber)).toMatchObject({
478 _tag: "Some",
479 value: { type: "snapshot" },
480 });
436 ])("shares only a config subscription with the same opt-ins: %j", (options) =>
437 Effect.scoped(
438 Effect.gen(function* () {
439 const { factory, sockets } = yield* makeFactory(options);
440 const session = yield* factory.connect(PREPARED);
441 const readyFiber = yield* Effect.forkChild(session.ready);
442 const socket = yield* awaitSocket(sockets);
443 socket.open();
444 yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options);
445 yield* Fiber.join(readyFiber);
446
447 const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead);
448 expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } });
449 expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength(
450 1,
451 );
452
453 const fallbackFiber = yield* session
454 .subscribeServerConfig({})
455 .pipe(Stream.runHead, Effect.forkChild);
456 const fallbackRequest = yield* awaitRequest(socket, 1);
457 expect(fallbackRequest).toMatchObject({
458 tag: WS_METHODS.subscribeServerConfig,
459 payload: {},
460 });
461 socket.serverMessage(
462 encodeJson({
463 _tag: "Chunk",
464 requestId: fallbackRequest.id,
465 values: [
466 {
467 version: 1,
468 type: "snapshot",
469 config: ENCODED_THEME_SERVER_CONFIG,
470 },
471 ],
481472 }),
482 ),
483 );
484 }
473 );
474 expect(yield* Fiber.join(fallbackFiber)).toMatchObject({
475 _tag: "Some",
476 value: { type: "snapshot" },
477 });
478 }),
479 ),
480 );
485481
486482 it.effect.each([
487483 { usageLimitSources: true },
......@@ -1191,37 +1187,38 @@ describe("RpcSessionFactory", () => {
11911187 ),
11921188 );
11931189
1194 for (const relay of [false, true]) {
1195 it.effect(`fails readiness when the ${relay ? "relay" : "direct"} websocket never opens`, () =>
1196 Effect.gen(function* () {
1197 const { factory, sockets } = yield* makeFactory();
1190 it.effect.each([
1191 { relay: false, label: "direct" },
1192 { relay: true, label: "relay" },
1193 ])("fails readiness when the $label websocket never opens", ({ relay }) =>
1194 Effect.gen(function* () {
1195 const { factory, sockets } = yield* makeFactory();
11981196
1199 const error = yield* Effect.scoped(
1200 Effect.gen(function* () {
1201 const session = yield* factory.connect({
1202 ...PREPARED,
1203 target: relay
1204 ? new RelayConnectionTarget({
1205 environmentId: TARGET.environmentId,
1206 label: TARGET.label,
1207 })
1208 : TARGET,
1209 });
1210 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));
1211 yield* awaitSocket(sockets);
1212
1213 yield* TestClock.adjust("15 seconds");
1214 return yield* Fiber.join(readyFiber);
1215 }),
1216 );
1197 const error = yield* Effect.scoped(
1198 Effect.gen(function* () {
1199 const session = yield* factory.connect({
1200 ...PREPARED,
1201 target: relay
1202 ? new RelayConnectionTarget({
1203 environmentId: TARGET.environmentId,
1204 label: TARGET.label,
1205 })
1206 : TARGET,
1207 });
1208 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));
1209 yield* awaitSocket(sockets);
12171210
1218 expect(error).toBeInstanceOf(ConnectionTransientError);
1219 expect(error).toMatchObject({
1220 reason: "transport",
1221 message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`,
1222 });
1223 expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED);
1224 }).pipe(Effect.provide(TestClock.layer())),
1225 );
1226 }
1211 yield* TestClock.adjust("15 seconds");
1212 return yield* Fiber.join(readyFiber);
1213 }),
1214 );
1215
1216 expect(error).toBeInstanceOf(ConnectionTransientError);
1217 expect(error).toMatchObject({
1218 reason: "transport",
1219 message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`,
1220 });
1221 expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED);
1222 }).pipe(Effect.provide(TestClock.layer())),
1223 );
12271224});
packages/client-runtime/src/state/assets.test.ts+103-105
......@@ -60,7 +60,7 @@ describe("asset collection keys", () => {
6060});
6161
6262describe("createAssetEnvironmentAtoms", () => {
63 for (const scenario of [
63 it.effect.each([
6464 { name: "missing video", path: "/tmp/clip.mp4", fallback: true },
6565 { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true },
6666 { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true },
......@@ -73,113 +73,111 @@ describe("createAssetEnvironmentAtoms", () => {
7373 { name: "same environment", path: "/tmp/clip.mp4", primary: "same" },
7474 { name: "non-media", path: "/tmp/report.html" },
7575 { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" },
76 ]) {
77 it.effect(`uses the correct environment for ${scenario.name}`, () =>
78 Effect.gen(function* () {
79 const remoteId = EnvironmentId.make("remote");
80 const localId = EnvironmentId.make("local");
81 const resource = {
82 _tag: "media-file" as const,
83 threadId: ThreadId.make("foreign-thread"),
84 path: scenario.path,
85 };
86 const error =
87 scenario.error === "auth"
88 ? new EnvironmentAuthorizationError({
89 message: "denied",
90 requiredScope: "orchestration:read",
91 })
92 : scenario.error === "inspection"
93 ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") })
94 : scenario.error === "context"
95 ? new AssetWorkspaceContextNotFoundError({ resource })
96 : new AssetWorkspaceAssetNotFoundError({ resource });
97 const calls: EnvironmentId[] = [];
98 const supervisors = new Map<
99 EnvironmentId,
100 EnvironmentSupervisor.EnvironmentSupervisor["Service"]
101 >();
102 for (const environmentId of [remoteId, localId]) {
103 const client = {
104 [WS_METHODS.assetsCreateUrl]: () => {
105 calls.push(environmentId);
106 return environmentId === remoteId && !scenario.success
107 ? Effect.fail(error)
108 : Effect.succeed({
109 relativeUrl: `/api/assets/${environmentId}/media`,
110 expiresAt: 999999,
111 });
112 },
113 } as unknown as WsRpcProtocolClient;
114 const session = { client } as RpcSession;
115 supervisors.set(
116 environmentId,
117 EnvironmentSupervisor.EnvironmentSupervisor.of({
118 target: new PrimaryConnectionTarget({
119 environmentId,
120 label: environmentId,
121 httpBaseUrl: `https://${environmentId}.test`,
122 wsBaseUrl: `wss://${environmentId}.test`,
123 }),
124 state: yield* SubscriptionRef.make<SupervisorConnectionState>({
125 ...AVAILABLE_CONNECTION_STATE,
126 phase: "connected" as const,
127 }),
128 session: yield* SubscriptionRef.make(Option.some(session)),
129 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
130 connect: Effect.void,
131 disconnect: Effect.void,
132 retryNow: Effect.void,
76 ])("uses the correct environment for $name", (scenario) =>
77 Effect.gen(function* () {
78 const remoteId = EnvironmentId.make("remote");
79 const localId = EnvironmentId.make("local");
80 const resource = {
81 _tag: "media-file" as const,
82 threadId: ThreadId.make("foreign-thread"),
83 path: scenario.path,
84 };
85 const error =
86 scenario.error === "auth"
87 ? new EnvironmentAuthorizationError({
88 message: "denied",
89 requiredScope: "orchestration:read",
90 })
91 : scenario.error === "inspection"
92 ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") })
93 : scenario.error === "context"
94 ? new AssetWorkspaceContextNotFoundError({ resource })
95 : new AssetWorkspaceAssetNotFoundError({ resource });
96 const calls: EnvironmentId[] = [];
97 const supervisors = new Map<
98 EnvironmentId,
99 EnvironmentSupervisor.EnvironmentSupervisor["Service"]
100 >();
101 for (const environmentId of [remoteId, localId]) {
102 const client = {
103 [WS_METHODS.assetsCreateUrl]: () => {
104 calls.push(environmentId);
105 return environmentId === remoteId && !scenario.success
106 ? Effect.fail(error)
107 : Effect.succeed({
108 relativeUrl: `/api/assets/${environmentId}/media`,
109 expiresAt: 999999,
110 });
111 },
112 } as unknown as WsRpcProtocolClient;
113 const session = { client } as RpcSession;
114 supervisors.set(
115 environmentId,
116 EnvironmentSupervisor.EnvironmentSupervisor.of({
117 target: new PrimaryConnectionTarget({
118 environmentId,
119 label: environmentId,
120 httpBaseUrl: `https://${environmentId}.test`,
121 wsBaseUrl: `wss://${environmentId}.test`,
122 }),
123 state: yield* SubscriptionRef.make<SupervisorConnectionState>({
124 ...AVAILABLE_CONNECTION_STATE,
125 phase: "connected" as const,
133126 }),
134 );
135 }
136 const environments = EnvironmentRegistry.EnvironmentRegistry.of({
137 run: (id, effect) =>
138 Effect.provideService(
139 effect,
140 EnvironmentSupervisor.EnvironmentSupervisor,
141 supervisors.get(id)!,
142 ),
143 followStream: (id, stream) =>
144 Stream.provideService(
145 stream,
146 EnvironmentSupervisor.EnvironmentSupervisor,
147 supervisors.get(id)!,
148 ),
149 } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
150 const registry = AtomRegistry.make();
151 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
152 const localTarget = {
153 environmentId: scenario.primary === "same" ? remoteId : localId,
154 httpBaseUrl: "https://local.test",
155 };
156 const localEnvironment = Atom.make<typeof localTarget | null>(
157 scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget,
127 session: yield* SubscriptionRef.make(Option.some(session)),
128 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
129 connect: Effect.void,
130 disconnect: Effect.void,
131 retryNow: Effect.void,
132 }),
158133 );
159 const assets = createAssetEnvironmentAtoms(
160 Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)),
161 localEnvironment,
134 }
135 const environments = EnvironmentRegistry.EnvironmentRegistry.of({
136 run: (id, effect) =>
137 Effect.provideService(
138 effect,
139 EnvironmentSupervisor.EnvironmentSupervisor,
140 supervisors.get(id)!,
141 ),
142 followStream: (id, stream) =>
143 Stream.provideService(
144 stream,
145 EnvironmentSupervisor.EnvironmentSupervisor,
146 supervisors.get(id)!,
147 ),
148 } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
149 const registry = AtomRegistry.make();
150 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
151 const localTarget = {
152 environmentId: scenario.primary === "same" ? remoteId : localId,
153 httpBaseUrl: "https://local.test",
154 };
155 const localEnvironment = Atom.make<typeof localTarget | null>(
156 scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget,
157 );
158 const assets = createAssetEnvironmentAtoms(
159 Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)),
160 localEnvironment,
161 );
162 const query = assets.createUrl({ environmentId: remoteId, input: { resource } });
163 const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true });
164 if (scenario.fallback || scenario.success) {
165 expect((yield* result).relativeUrl).toBe(
166 scenario.fallback
167 ? "https://local.test/api/assets/local/media"
168 : "/api/assets/remote/media",
162169 );
163 const query = assets.createUrl({ environmentId: remoteId, input: { resource } });
164 const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true });
165 if (scenario.fallback || scenario.success) {
166 expect((yield* result).relativeUrl).toBe(
167 scenario.fallback
168 ? "https://local.test/api/assets/local/media"
169 : "/api/assets/remote/media",
170 );
171 } else {
172 expect(yield* Effect.flip(result)).toEqual(error);
173 }
174 expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]);
175 if (scenario.primary === "reconnecting") {
176 registry.set(localEnvironment, localTarget);
177 expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media");
178 expect(calls).toEqual([remoteId, remoteId, localId]);
179 }
180 }).pipe(Effect.scoped),
181 );
182 }
170 } else {
171 expect(yield* Effect.flip(result)).toEqual(error);
172 }
173 expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]);
174 if (scenario.primary === "reconnecting") {
175 registry.set(localEnvironment, localTarget);
176 expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media");
177 expect(calls).toEqual([remoteId, remoteId, localId]);
178 }
179 }).pipe(Effect.scoped),
180 );
183181
184182 it("keys asset URL queries by environment and resource", () => {
185183 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 [
236236 );
237237}
238238
239for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as const) {
240 it.effect(`routes ${provider} viewed marks to their storage environment`, () =>
239it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)(
240 "routes %s viewed marks to their storage environment",
241 (provider) =>
241242 Effect.scoped(
242243 Effect.gen(function* () {
243244 const reference = {
......@@ -311,8 +312,7 @@ for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as cons
311312 }
312313 }),
313314 ),
314 );
315}
315);
316316
317317const TARGET = new PrimaryConnectionTarget({
318318 environmentId: EnvironmentId.make("environment-1"),
......@@ -420,8 +420,9 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (
420420 return { runtime, atoms, registry, environmentRegistry, supervisor };
421421});
422422
423for (const permission of ["default", "origin-off", "destination-off", "read-only"] as const) {
424 it.effect(`does not probe another environment with ${permission} routing permission`, () =>
423it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)(
424 "does not probe another environment with %s routing permission",
425 (permission) =>
425426 Effect.scoped(
426427 Effect.gen(function* () {
427428 const calls: string[] = [];
......@@ -480,125 +481,128 @@ for (const permission of ["default", "origin-off", "destination-off", "read-only
480481 );
481482 }),
482483 ),
483 );
484}
484);
485485
486for (const side of ["origin", "destination"] as const) {
487 for (const stored of ["matching", "changed", "missing", "unavailable", "failed"] as const) {
488 it.effect(`checks the current ${side} SSH profile before routing with ${stored} storage`, () =>
489 Effect.scoped(
490 Effect.gen(function* () {
491 const calls: string[] = [];
492 const identity = {
493 host: "github.com",
494 provider: "github",
495 viewer: "maria",
496 accountId: "123",
497 };
498 const client = {
499 [WS_METHODS.pullRequestsRouting]: () =>
500 Effect.sync(() => {
501 calls.push("source-probe");
502 return identity;
503 }),
504 [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) =>
505 Effect.gen(function* () {
506 if (input.expectedAccountId !== undefined) {
507 return yield* new PullRequestOperationError({
508 operation: "routeIdentity",
509 detail: "Source guard refused.",
510 });
511 }
512 calls.push("source-write");
513 }),
514 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
515 } as unknown as WsRpcProtocolClient;
516 const alternate = {
517 [WS_METHODS.pullRequestsRoutingIdentity]: () =>
518 Effect.sync(() => {
519 calls.push("alternate-probe");
520 return identity;
521 }),
522 [WS_METHODS.pullRequestsRunAction]: () =>
523 Effect.sync(() => {
524 calls.push("alternate-write");
525 }),
526 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
527 } as unknown as WsRpcProtocolClient;
528 const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate);
529 const environmentId =
530 side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment");
531 const profile = new SshConnectionProfile({
532 connectionId: "ssh-1",
486it.effect.each(
487 (["origin", "destination"] as const).flatMap((side) =>
488 (["matching", "changed", "missing", "unavailable", "failed"] as const).map((stored) => ({
489 side,
490 stored,
491 })),
492 ),
493)("checks the current $side SSH profile before routing with $stored storage", ({ side, stored }) =>
494 Effect.scoped(
495 Effect.gen(function* () {
496 const calls: string[] = [];
497 const identity = {
498 host: "github.com",
499 provider: "github",
500 viewer: "maria",
501 accountId: "123",
502 };
503 const client = {
504 [WS_METHODS.pullRequestsRouting]: () =>
505 Effect.sync(() => {
506 calls.push("source-probe");
507 return identity;
508 }),
509 [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) =>
510 Effect.gen(function* () {
511 if (input.expectedAccountId !== undefined) {
512 return yield* new PullRequestOperationError({
513 operation: "routeIdentity",
514 detail: "Source guard refused.",
515 });
516 }
517 calls.push("source-write");
518 }),
519 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
520 } as unknown as WsRpcProtocolClient;
521 const alternate = {
522 [WS_METHODS.pullRequestsRoutingIdentity]: () =>
523 Effect.sync(() => {
524 calls.push("alternate-probe");
525 return identity;
526 }),
527 [WS_METHODS.pullRequestsRunAction]: () =>
528 Effect.sync(() => {
529 calls.push("alternate-write");
530 }),
531 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
532 } as unknown as WsRpcProtocolClient;
533 const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate);
534 const environmentId =
535 side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment");
536 const profile = new SshConnectionProfile({
537 connectionId: "ssh-1",
538 environmentId,
539 label: "SSH",
540 target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 },
541 });
542 yield* SubscriptionRef.update(environmentRegistry.entries, (entries) =>
543 new Map(entries).set(environmentId, {
544 target: new SshConnectionTarget({
533545 environmentId,
546 connectionId: profile.connectionId,
534547 label: "SSH",
535 target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 },
536 });
537 yield* SubscriptionRef.update(environmentRegistry.entries, (entries) =>
538 new Map(entries).set(environmentId, {
539 target: new SshConnectionTarget({
540 environmentId,
541 connectionId: profile.connectionId,
542 label: "SSH",
548 }),
549 profile: Option.some(profile),
550 enabled: true,
551 }),
552 );
553 const read = Effect.suspend(() =>
554 stored === "failed"
555 ? Effect.fail(
556 new ConnectionTransientError({
557 reason: "remote-unavailable",
558 detail: "Profile storage unavailable.",
543559 }),
544 profile: Option.some(profile),
545 enabled: true,
560 )
561 : Effect.succeed(
562 stored === "missing"
563 ? Option.none()
564 : Option.some(
565 stored === "changed"
566 ? new SshConnectionProfile({
567 ...profile,
568 target: { ...profile.target, hostname: "replacement.example.test" },
569 })
570 : profile,
571 ),
572 ),
573 );
574 const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, {
575 projectId: ProjectId.make("project-1"),
576 repository: "private/repo",
577 number: 7,
578 action: "merge",
579 }).pipe(
580 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
581 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
582 // This also represents a user enabling the stale catalog entry after re-resolution.
583 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
584 );
585 yield* stored === "unavailable"
586 ? route
587 : route.pipe(
588 Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, {
589 get: () => read,
590 put: () => Effect.die("unused"),
591 remove: () => Effect.die("unused"),
546592 }),
547593 );
548 const read = Effect.suspend(() =>
549 stored === "failed"
550 ? Effect.fail(
551 new ConnectionTransientError({
552 reason: "remote-unavailable",
553 detail: "Profile storage unavailable.",
554 }),
555 )
556 : Effect.succeed(
557 stored === "missing"
558 ? Option.none()
559 : Option.some(
560 stored === "changed"
561 ? new SshConnectionProfile({
562 ...profile,
563 target: { ...profile.target, hostname: "replacement.example.test" },
564 })
565 : profile,
566 ),
567 ),
568 );
569 const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, {
570 projectId: ProjectId.make("project-1"),
571 repository: "private/repo",
572 number: 7,
573 action: "merge",
574 }).pipe(
575 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
576 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
577 // This also represents a user enabling the stale catalog entry after re-resolution.
578 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
579 );
580 yield* stored === "unavailable"
581 ? route
582 : route.pipe(
583 Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, {
584 get: () => read,
585 put: () => Effect.die("unused"),
586 remove: () => Effect.die("unused"),
587 }),
588 );
589 expect(calls).toEqual(
590 stored === "matching"
591 ? ["source-probe", "alternate-probe", "alternate-write"]
592 : ["source-write"],
593 );
594 }),
595 ),
596 );
597 }
598}
594 expect(calls).toEqual(
595 stored === "matching"
596 ? ["source-probe", "alternate-probe", "alternate-write"]
597 : ["source-write"],
598 );
599 }),
600 ),
601);
599602
600for (const probe of ["origin", "alternate"] as const) {
601 it.live(`bounds a stalled ${probe} metadata probe without repeating a strict source read`, () =>
603it.live.each(["origin", "alternate"] as const)(
604 "bounds a stalled %s metadata probe without repeating a strict source read",
605 (probe) =>
602606 Effect.scoped(
603607 Effect.gen(function* () {
604608 let sourceReads = 0;
......@@ -638,81 +642,81 @@ for (const probe of ["origin", "alternate"] as const) {
638642 expect(sourceReads).toBe(1);
639643 }),
640644 ),
641 );
642}
645);
643646
644for (const source of ["pending", "pending-local", "failed-local", "failed", "offline"] as const) {
645 it.effect(
646 source === "offline"
647 ? "returns held source data only after both fresh paths fail"
648 : `uses one shared reader with a ${source} source`,
649 () =>
650 Effect.scoped(
651 Effect.gen(function* () {
652 const started = yield* Deferred.make<void>();
653 const calls: string[] = [];
654 const clientFor = (local: boolean) =>
655 ({
656 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]:
657 () =>
658 Effect.succeed({
659 host: "github.com",
660 provider: "github",
661 viewer: "maria-rcks",
662 accountId: "123",
663 }),
664 [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) =>
665 Effect.gen(function* () {
666 calls.push(local ? "local" : input.allowStale === false ? "origin" : "held");
667 if (source === "offline" && !local && input.allowStale === undefined) {
668 return { state: "open" };
669 }
670 expect(input.allowStale).toBe(false);
671 if (local && source !== "offline") return null;
672 if (source !== "pending" && source !== "pending-local")
673 return yield* new PullRequestOperationError({
674 operation: "summary",
675 detail: "github unreachable",
676 });
677 yield* Deferred.succeed(started, undefined);
678 return yield* Effect.never;
679 }),
680 }) as unknown as WsRpcProtocolClient;
681 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
682 clientFor(false),
683 clientFor(true),
684 source === "failed-local" || source === "pending-local",
685 );
686 const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {
687 projectId: ProjectId.make("project-1"),
688 repository: "acme/web",
689 number: 7,
690 }).pipe(
691 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
692 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
693 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
694 );
695 const fiber = yield* request.pipe(Effect.forkChild);
696 if (source === "pending-local") {
697 yield* Deferred.await(started);
698 yield* TestClock.adjust("30 seconds");
699 }
700 const result = yield* Fiber.join(fiber);
701 if (source === "offline") {
702 expect(result).toEqual({ state: "open" });
703 expect(calls).toEqual(["local", "origin", "held"]);
704 } else {
705 expect(result).toBeNull();
706 expect(calls).toEqual(
707 source === "failed-local" || source === "pending-local"
708 ? ["origin", "local"]
709 : ["local"],
710 );
711 }
712 }),
713 ),
714 );
715}
647it.effect.each(
648 (["pending", "pending-local", "failed-local", "failed", "offline"] as const).map(
649 (source) =>
650 [
651 source === "offline"
652 ? "returns held source data only after both fresh paths fail"
653 : `uses one shared reader with a ${source} source`,
654 source,
655 ] as const,
656 ),
657)("%s", ([, source]) =>
658 Effect.scoped(
659 Effect.gen(function* () {
660 const started = yield* Deferred.make<void>();
661 const calls: string[] = [];
662 const clientFor = (local: boolean) =>
663 ({
664 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () =>
665 Effect.succeed({
666 host: "github.com",
667 provider: "github",
668 viewer: "maria-rcks",
669 accountId: "123",
670 }),
671 [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) =>
672 Effect.gen(function* () {
673 calls.push(local ? "local" : input.allowStale === false ? "origin" : "held");
674 if (source === "offline" && !local && input.allowStale === undefined) {
675 return { state: "open" };
676 }
677 expect(input.allowStale).toBe(false);
678 if (local && source !== "offline") return null;
679 if (source !== "pending" && source !== "pending-local")
680 return yield* new PullRequestOperationError({
681 operation: "summary",
682 detail: "github unreachable",
683 });
684 yield* Deferred.succeed(started, undefined);
685 return yield* Effect.never;
686 }),
687 }) as unknown as WsRpcProtocolClient;
688 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
689 clientFor(false),
690 clientFor(true),
691 source === "failed-local" || source === "pending-local",
692 );
693 const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {
694 projectId: ProjectId.make("project-1"),
695 repository: "acme/web",
696 number: 7,
697 }).pipe(
698 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
699 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
700 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
701 );
702 const fiber = yield* request.pipe(Effect.forkChild);
703 if (source === "pending-local") {
704 yield* Deferred.await(started);
705 yield* TestClock.adjust("30 seconds");
706 }
707 const result = yield* Fiber.join(fiber);
708 if (source === "offline") {
709 expect(result).toEqual({ state: "open" });
710 expect(calls).toEqual(["local", "origin", "held"]);
711 } else {
712 expect(result).toBeNull();
713 expect(calls).toEqual(
714 source === "failed-local" || source === "pending-local" ? ["origin", "local"] : ["local"],
715 );
716 }
717 }),
718 ),
719);
716720
717721it.live("keeps source workspace metadata when an alternate answers a detail read", () =>
718722 Effect.scoped(
......@@ -1191,8 +1195,9 @@ it.effect("refreshes checks without refreshing full detail", () =>
11911195 ),
11921196);
11931197
1194for (const oldAlternate of [false, true]) {
1195 it.effect(`routes checks through one reader with old alternate: ${oldAlternate}`, () =>
1198it.effect.each([false, true])(
1199 "routes checks through one reader with old alternate: %s",
1200 (oldAlternate) =>
11961201 Effect.scoped(
11971202 Effect.gen(function* () {
11981203 const calls: string[] = [];
......@@ -1236,8 +1241,7 @@ for (const oldAlternate of [false, true]) {
12361241 expect(calls).toEqual(oldAlternate ? ["origin"] : ["local"]);
12371242 }),
12381243 ),
1239 );
1240}
1244);
12411245
12421246it.effect("keeps live detail reads separate from reads that allow stale data", () =>
12431247 Effect.scoped(
packages/client-runtime/src/state/threadCommands.test.ts+47-45
......@@ -137,8 +137,9 @@ describe("remote thread lifecycle commands", () => {
137137 ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }],
138138 ] as const;
139139
140 for (const [action, input, expected] of actions) {
141 it.effect(`shows ${action} before a delayed remote reply and rolls back a rejection`, () =>
140 it.effect.each(actions)(
141 "shows %s before a delayed remote reply and rolls back a rejection",
142 ([action, input, expected]) =>
142143 Effect.gen(function* () {
143144 const h = yield* makeHarness();
144145 const source = h.snapshotAtom(ENVIRONMENT_ID);
......@@ -179,8 +180,7 @@ describe("remote thread lifecycle commands", () => {
179180 expect((yield* Effect.promise(() => result))._tag).toBe("Failure");
180181 expect(h.registry.get(h.visibleAtom)).toBe(initial);
181182 }),
182 );
183 }
183 );
184184
185185 it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () =>
186186 Effect.gen(function* () {
......@@ -290,44 +290,45 @@ describe("remote thread lifecycle commands", () => {
290290 }),
291291 );
292292
293 for (const action of ["settle", "snooze"] as const) {
294 it.effect(`restores a confirmed ${action} when a queued undo fails`, () =>
295 Effect.gen(function* () {
296 const h = yield* makeHarness();
297 const parked =
298 action === "settle" ? { settledOverride: "settled" as const } : { snoozedUntil: FUTURE };
299 const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null };
300 const result = h.commands[action].run(h.registry, {
301 environmentId: ENVIRONMENT_ID,
302 input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" },
303 });
304 const first = yield* Queue.take(h.requests);
305 const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, {
306 environmentId: ENVIRONMENT_ID,
307 input: { threadId: THREAD_ID, reason: "user" },
308 });
309 expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake);
310 yield* Deferred.succeed(first.reply, { sequence: 2 });
311 expect((yield* Effect.promise(() => result))._tag).toBe("Success");
312 expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake);
313 const confirmed = {
314 ...SNAPSHOT,
315 snapshotSequence: 2,
316 threads: [{ ...SNAPSHOT.threads[0]!, ...parked }],
317 };
318 h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed);
319 expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake);
320 const second = yield* Queue.take(h.requests);
321 expect(second.command.type).toBe(
322 action === "settle" ? "thread.unsettle" : "thread.unsnooze",
323 );
324 yield* Deferred.fail(second.reply, new Error("Undo rejected"));
325 expect((yield* Effect.promise(() => undo))._tag).toBe("Failure");
326 expect(h.registry.get(h.visibleAtom)).toBe(confirmed);
327 }),
328 );
293 const undoableActions = ["settle", "snooze"] as const;
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) =>
331332 Effect.gen(function* () {
332333 const h = yield* makeHarness();
333334 const result = h.commands[action].run(h.registry, {
......@@ -346,9 +347,11 @@ describe("remote thread lifecycle commands", () => {
346347 expect((yield* Effect.promise(() => result))._tag).toBe("Success");
347348 expect(h.registry.get(h.visibleAtom)).toBe(newer);
348349 }),
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) =>
352355 Effect.gen(function* () {
353356 const h = yield* makeHarness();
354357 const stale = {
......@@ -370,6 +373,5 @@ describe("remote thread lifecycle commands", () => {
370373 expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull();
371374 expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale);
372375 }),
373 );
374 }
376 );
375377});
packages/client-runtime/src/state/threads-sync.test.ts+12-12
......@@ -315,8 +315,9 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => {
315315};
316316
317317describe("EnvironmentThreads", () => {
318 for (const source of ["disk", "HTTP"] as const) {
319 it.effect(`does not rewrite an unchanged ${source} snapshot on navigation or warm return`, () =>
318 it.effect.each(["disk", "HTTP"] as const)(
319 "does not rewrite an unchanged %s snapshot on navigation or warm return",
320 (source) =>
320321 Effect.gen(function* () {
321322 const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
322323 snapshot: undefined,
......@@ -350,8 +351,7 @@ describe("EnvironmentThreads", () => {
350351 );
351352 expect(yield* Ref.get(nextSaved)).toEqual([]);
352353 }),
353 );
354 }
354 );
355355
356356 it.effect("persists a complete bounded HTTP window only once", () =>
357357 Effect.gen(function* () {
......@@ -1012,8 +1012,9 @@ describe("EnvironmentThreads", () => {
10121012 }),
10131013 );
10141014
1015 for (const cacheKind of ["disk", "retained"] as const) {
1016 it.effect(`retains paging support through a complete bounded ${cacheKind} cache`, () =>
1015 it.effect.each(["disk", "retained"] as const)(
1016 "retains paging support through a complete bounded %s cache",
1017 (cacheKind) =>
10171018 Effect.gen(function* () {
10181019 const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
10191020 snapshot: undefined,
......@@ -1071,11 +1072,11 @@ describe("EnvironmentThreads", () => {
10711072 expect(yield* Ref.get(warm.lastSubscribeAfterSequence)).toBe(5);
10721073 expect(yield* Ref.get(warm.lastAcceptBoundedSnapshot)).toBe(true);
10731074 }),
1074 );
1075 }
1075 );
10761076
1077 for (const historyPaging of ["no-http", "no-controller"] as const) {
1078 it.effect(`does not negotiate bounded fallbacks with ${historyPaging}`, () =>
1077 it.effect.each(["no-http", "no-controller"] as const)(
1078 "does not negotiate bounded fallbacks with %s",
1079 (historyPaging) =>
10791080 Effect.gen(function* () {
10801081 for (const source of ["cache", "http"] as const) {
10811082 const history = {
......@@ -1105,8 +1106,7 @@ describe("EnvironmentThreads", () => {
11051106 expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBeUndefined();
11061107 }
11071108 }),
1108 );
1109 }
1109 );
11101110
11111111 it.effect("socket snapshot clears progressive history meta left from a bounded window", () =>
11121112 Effect.gen(function* () {
packages/contracts/src/providerInstance.test.ts+30-32
......@@ -21,39 +21,37 @@ describe("provider slug validation (shared by driver + instance ids)", () => {
2121 { schemaName: "ProviderDriverKind", decode: decodeProviderDriverKind },
2222 ] as const;
2323
24 for (const { schemaName, decode } of cases) {
25 describe(schemaName, () => {
26 it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])(
27 "accepts %s",
28 (id) => {
29 expect(decode(id)).toBe(id);
30 },
31 );
32
33 it.each([
34 ["empty string", ""],
35 ["leading digit", "1codex"],
36 ["leading dash", "-codex"],
37 ["leading underscore", "_codex"],
38 ["whitespace inside", "codex personal"],
39 ["dot inside", "codex.personal"],
40 ["slash inside", "codex/personal"],
41 ])("rejects %s", (_label, value) => {
42 expect(() => decode(value)).toThrow();
43 });
44
45 it("trims surrounding whitespace before validating", () => {
46 expect(decode(" codex_work ")).toBe("codex_work");
47 });
48
49 it("rejects ids longer than 64 characters", () => {
50 const tooLong = "a".repeat(65);
51 expect(() => decode(tooLong)).toThrow();
52 const justRight = "a".repeat(64);
53 expect(decode(justRight)).toBe(justRight);
54 });
24 describe.each(cases)("$schemaName", ({ decode }) => {
25 it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])(
26 "accepts %s",
27 (id) => {
28 expect(decode(id)).toBe(id);
29 },
30 );
31
32 it.each([
33 ["empty string", ""],
34 ["leading digit", "1codex"],
35 ["leading dash", "-codex"],
36 ["leading underscore", "_codex"],
37 ["whitespace inside", "codex personal"],
38 ["dot inside", "codex.personal"],
39 ["slash inside", "codex/personal"],
40 ])("rejects %s", (_label, value) => {
41 expect(() => decode(value)).toThrow();
42 });
43
44 it("trims surrounding whitespace before validating", () => {
45 expect(decode(" codex_work ")).toBe("codex_work");
46 });
47
48 it("rejects ids longer than 64 characters", () => {
49 const tooLong = "a".repeat(65);
50 expect(() => decode(tooLong)).toThrow();
51 const justRight = "a".repeat(64);
52 expect(decode(justRight)).toBe(justRight);
5553 });
56 }
54 });
5755});
5856
5957describe("ProviderInstanceRef", () => {
packages/effect-acp/src/protocol.test.ts+4-4
......@@ -1088,8 +1088,9 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
10881088 }),
10891089 );
10901090
1091 for (const operation of ["request", "notification"] as const) {
1092 it.effect(`rejects a ${operation} if the connection ends while its logger is running`, () =>
1091 it.effect.each(["request", "notification"] as const)(
1092 "rejects a %s if the connection ends while its logger is running",
1093 (operation) =>
10931094 Effect.gen(function* () {
10941095 const { stdio, input, output } = yield* makeInMemoryStdio();
10951096 const writeStarted = yield* Deferred.make<void>();
......@@ -1127,6 +1128,5 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
11271128 assert.strictEqual(failure, error);
11281129 assert.equal(yield* Queue.size(output), 0);
11291130 }),
1130 );
1131 }
1131 );
11321132});
packages/shared/src/gitPatchPath.test.ts+7-9
......@@ -46,15 +46,13 @@ describe("a name written into a header and read back out", () => {
4646 `every\t\n\r"\\${BELL}.txt`,
4747 ];
4848
49 for (const name of names) {
50 it(`is the name that went in: ${JSON.stringify(name)}`, () => {
51 const written = quoteGitPatchPath(name);
52 expect(unquoteGitPatchPath(written)).toBe(name);
53 // A parser that takes the quotes off itself, as the clients' one does, gets there too.
54 const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written;
55 expect(unquoteGitPatchPath(unwrapped)).toBe(name);
56 });
57 }
49 it.each(names)("is the name that went in: %j", (name) => {
50 const written = quoteGitPatchPath(name);
51 expect(unquoteGitPatchPath(written)).toBe(name);
52 // A parser that takes the quotes off itself, as the clients' one does, gets there too.
53 const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written;
54 expect(unquoteGitPatchPath(unwrapped)).toBe(name);
55 });
5856
5957 it("carries the whole name past the first thing a header stops at", () => {
6058 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) => {
12691269 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
12701270 );
12711271
1272 for (const targetArch of ["x64", "arm64"] as const) {
1273 it.effect(`accepts an embedded archive with the Linux ${targetArch} node-pty prebuild`, () =>
1272 it.effect.each(["x64", "arm64"] as const)(
1273 "accepts an embedded archive with the Linux %s node-pty prebuild",
1274 (targetArch) =>
12741275 Effect.scoped(
12751276 Effect.gen(function* () {
12761277 const fixture = yield* makeWindowsPayloadFixture({
......@@ -1290,33 +1291,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12901291 assert.equal(result.packagedAppDir, fixture.packagedAppDir);
12911292 }),
12921293 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
1293 );
1294 );
12941295
1295 it.effect(
1296 `rejects a node-pty prebuild for the wrong architecture in a Linux ${targetArch} archive`,
1297 () =>
1298 Effect.scoped(
1299 Effect.gen(function* () {
1300 const fixture = yield* makeWindowsPayloadFixture({
1301 copyUnpackedNatives: true,
1302 wslRuntime: "valid",
1303 targetArch,
1304 ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64",
1305 });
1306 const error = yield* validateWindowsPackagedPayload({
1307 stageDistDir: fixture.stageDistDir,
1308 appExecutableName: fixture.appExecutableName,
1309 targetArch,
1310 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1311 expectWslRuntime: true,
1312 }).pipe(Effect.flip);
1313
1314 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
1315 assert.equal(error.reason, "wsl-runtime-invalid");
1316 }),
1317 ),
1318 );
1319 }
1296 it.effect.each(["x64", "arm64"] as const)(
1297 "rejects a node-pty prebuild for the wrong architecture in a Linux %s archive",
1298 (targetArch) =>
1299 Effect.scoped(
1300 Effect.gen(function* () {
1301 const fixture = yield* makeWindowsPayloadFixture({
1302 copyUnpackedNatives: true,
1303 wslRuntime: "valid",
1304 targetArch,
1305 ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64",
1306 });
1307 const error = yield* validateWindowsPackagedPayload({
1308 stageDistDir: fixture.stageDistDir,
1309 appExecutableName: fixture.appExecutableName,
1310 targetArch,
1311 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1312 expectWslRuntime: true,
1313 }).pipe(Effect.flip);
1314
1315 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
1316 assert.equal(error.reason, "wsl-runtime-invalid");
1317 }),
1318 ),
1319 );
13201320
13211321 it.effect("rejects an embedded archive built for a different release version", () =>
13221322 Effect.scoped(
scripts/dev-runner.test.ts+22-24
......@@ -414,8 +414,9 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
414414 // Browser dev is single-origin: Vite proxies the backend, and the client
415415 // resolves it from window.location.origin. Baking a localhost URL here is
416416 // what breaks sharing a dev server to another device.
417 for (const mode of ["dev", "dev:web"] as const) {
418 it.effect(`leaves the client backend URLs unset in ${mode} mode`, () =>
417 it.effect.each(["dev", "dev:web"] as const)(
418 "leaves the client backend URLs unset in %s mode",
419 (mode) =>
419420 Effect.gen(function* () {
420421 const env = yield* createDevRunnerEnv({
421422 mode,
......@@ -442,8 +443,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
442443 // the intent has to be stated positively.
443444 assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1");
444445 }),
445 );
446 }
446 );
447447
448448 // Desktop pins the renderer at loopback deliberately; an ambient marker
449449 // must not make Vite discard those URLs.
......@@ -492,27 +492,25 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
492492 // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An
493493 // inherited one would survive into browser dev and point HMR at the wrong
494494 // interface — invisible over a shared origin, since the page still loads.
495 for (const mode of ["dev", "dev:web"] as const) {
496 it.effect(`drops an inherited HOST in ${mode} mode`, () =>
497 Effect.gen(function* () {
498 const env = yield* createDevRunnerEnv({
499 mode,
500 baseEnv: { HOST: "0.0.0.0" },
501 serverOffset: 0,
502 webOffset: 0,
503 t3Home: undefined,
504 browser: undefined,
505 autoBootstrapProjectFromCwd: undefined,
506 logWebSocketEvents: undefined,
507 host: undefined,
508 port: undefined,
509 devUrl: undefined,
510 });
495 it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) =>
496 Effect.gen(function* () {
497 const env = yield* createDevRunnerEnv({
498 mode,
499 baseEnv: { HOST: "0.0.0.0" },
500 serverOffset: 0,
501 webOffset: 0,
502 t3Home: undefined,
503 browser: undefined,
504 autoBootstrapProjectFromCwd: undefined,
505 logWebSocketEvents: undefined,
506 host: undefined,
507 port: undefined,
508 devUrl: undefined,
509 });
511510
512 assert.equal(env.HOST, undefined);
513 }),
514 );
515 }
511 assert.equal(env.HOST, undefined);
512 }),
513 );
516514
517515 // --host configures the *backend* (T3CODE_HOST). It must not become Vite's
518516 // 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) {
7878 };
7979}
8080
81for (const bundle of ["react-native.js", "react-native.mjs"]) {
82 describe(`initial inset end reveal (${bundle})`, () => {
81describe.each(["react-native.js", "react-native.mjs"])(
82 "initial inset end reveal (%s)",
83 (bundle) => {
8384 it("waits for the native offset instead of the optimistic scroll target", () => {
8485 const list = createList(bundle);
8586 list.start();
......@@ -210,5 +211,5 @@ for (const bundle of ["react-native.js", "react-native.mjs"]) {
210211 empty.complete();
211212 expect(empty.ready()).toBe(true);
212213 });
213 });
214}
214 },
215);
vite.config.ts+1
......@@ -167,6 +167,7 @@ export default defineConfig({
167167 "t3code/no-inline-schema-compile": "warn",
168168 "t3code/no-manual-effect-runtime-in-tests": "error",
169169 "t3code/no-native-title-tooltip": "error",
170 "t3code/no-test-in-loop": "error",
170171 "t3code/namespace-node-imports": "error",
171172 },
172173 overrides: [