1import { assert, it } from "@effect/vitest";
2import {
3 ProviderDriverKind,
4 ProviderInstanceId,
5 ProviderSessionId,
6 ProviderThreadId,
7 ThreadId,
8 type OrchestrationV2ThreadProjection,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13
14import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
15import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
16import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
17import * as ProviderSwitch from "./ProviderSwitchService.ts";
18import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
19
20const driver = ProviderDriverKind.make("codex");
21const currentInstanceId = ProviderInstanceId.make("codex_primary");
22const currentSessionId = ProviderSessionId.make("session_primary");
23const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
24const capabilitiesWithoutModelSwitch = {
25 ...CodexProviderCapabilitiesV2,
26 sessions: {
27 ...CodexProviderCapabilitiesV2.sessions,
28 supportsModelSwitchInSession: false,
29 },
30};
31
32function projection(): OrchestrationV2ThreadProjection {
33 return {
34 thread: {
35 id: ThreadId.make("thread_switch_service"),
36 modelSelection: { instanceId: currentInstanceId, model: "gpt-5.1-codex" },
37 runtimeMode: "full-access",
38 interactionMode: "default",
39 worktreePath: "/repo",
40 },
41 providerSessions: [
42 {
43 id: currentSessionId,
44 providerInstanceId: currentInstanceId,
45 status: "ready",
46 cwd: "/repo",
47 capabilities: capabilitiesWithoutModelSwitch,
48 updatedAt: now,
49 },
50 ],
51 providerThreads: [],
52 } as unknown as OrchestrationV2ThreadProjection;
53}
54
55function deadSessionRecord(
56 id: string,
57 status: "stopped" | "error",
58 updatedAt: DateTime.Utc = DateTime.add(now, { seconds: 1 }),
59) {
60 return {
61 ...projection().providerSessions[0]!,
62 id: ProviderSessionId.make(id),
63 status,
64 updatedAt,
65 };
66}
67
68function layerTest(
69 metadata: Readonly<Record<string, { continuationKey: string }>>,
70 planSelectionTransition: ProviderAdapter.ProviderAdapterV2["Service"]["planSelectionTransition"] = () =>
71 Effect.succeed({ type: "restart_session" }),
72) {
73 const adapter = (
74 instanceId: ProviderInstanceId,
75 ): ProviderAdapter.ProviderAdapterV2["Service"] => ({
76 instanceId,
77 driver,
78 getCapabilities: () => Effect.succeed(capabilitiesWithoutModelSwitch),
79 planSelectionTransition,
80 openSession: () => Effect.die("ProviderSwitchService tests do not open sessions."),
81 });
82 const layerRegistry = Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
83 get: (instanceId) =>
84 metadata[instanceId] === undefined
85 ? Effect.fail(
86 new ProviderAdapterRegistry.ProviderAdapterRegistryLookupError({ instanceId }),
87 )
88 : Effect.succeed(adapter(instanceId)),
89 list: () => Effect.succeed(Object.keys(metadata).map((id) => ProviderInstanceId.make(id))),
90 getMetadata: (instanceId) => {
91 const value = metadata[instanceId];
92 return value === undefined
93 ? Effect.fail(
94 new ProviderAdapterRegistry.ProviderAdapterRegistryLookupError({ instanceId }),
95 )
96 : Effect.succeed({
97 driver,
98 continuationKey: value.continuationKey,
99 enabled: true,
100 capabilities: capabilitiesWithoutModelSwitch,
101 });
102 },
103 });
104 return ProviderSwitch.layer.pipe(Layer.provide(layerRegistry));
105}
106
107it.effect("releases the newest live session, not the newest record overall", () =>
108 Effect.gen(function* () {
109 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
110 const thread = projection();
111 const newerLiveSessionId = ProviderSessionId.make("session_newer_live");
112 const result = yield* service.plan({
113 projection: {
114 ...thread,
115 providerSessions: [
116 ...thread.providerSessions,
117 {
118 ...thread.providerSessions[0]!,
119 id: newerLiveSessionId,
120 updatedAt: DateTime.add(now, { seconds: 1 }),
121 },
122 deadSessionRecord("dead_session", "stopped", DateTime.add(now, { seconds: 2 })),
123 ],
124 },
125 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
126 });
127 assert.equal(result.transition.type, "restart_and_resume");
128 assert.deepEqual(result.releaseProviderSessionIds, [newerLiveSessionId]);
129 }).pipe(
130 Effect.provide(
131 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
132 ),
133 ),
134);
135
136it.effect("creates a fresh session with handoff when every recorded session is dead", () =>
137 Effect.gen(function* () {
138 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
139 const thread = projection();
140 const result = yield* service.plan({
141 projection: {
142 ...thread,
143 providerSessions: [
144 deadSessionRecord("dead_session_older", "stopped"),
145 deadSessionRecord("dead_session_newer", "error", DateTime.add(now, { seconds: 2 })),
146 ],
147 },
148 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
149 });
150 assert.equal(result.transition.type, "create_with_handoff");
151 assert.deepEqual(result.releaseProviderSessionIds, []);
152 }).pipe(
153 Effect.provide(
154 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
155 ),
156 ),
157);
158
159function deadNativeThreadProjection(
160 status: "stopped" | "error",
161 capabilities = capabilitiesWithoutModelSwitch,
162): OrchestrationV2ThreadProjection {
163 const thread = projection();
164 return {
165 ...thread,
166 thread: {
167 ...thread.thread,
168 activeProviderThreadId: ProviderThreadId.make("provider-thread:native"),
169 },
170 providerSessions: [{ ...deadSessionRecord("dead_session", status), capabilities }],
171 providerThreads: [
172 {
173 id: ProviderThreadId.make("provider-thread:native"),
174 driver,
175 providerInstanceId: currentInstanceId,
176 providerSessionId: ProviderSessionId.make("dead_session"),
177 appThreadId: thread.thread.id,
178 ownerNodeId: null,
179 nativeThreadRef: {
180 driver,
181 nativeId: "native-thread:abc",
182 strength: "strong",
183 },
184 nativeConversationHeadRef: null,
185 status: "idle",
186 firstRunOrdinal: null,
187 lastRunOrdinal: null,
188 handoffIds: [],
189 forkedFrom: null,
190 createdAt: now,
191 updatedAt: now,
192 },
193 ],
194 } as OrchestrationV2ThreadProjection;
195}
196
197it.effect("falls back to the native provider thread when every recorded session is dead", () =>
198 Effect.gen(function* () {
199 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
200 const result = yield* service.plan({
201 projection: deadNativeThreadProjection("stopped"),
202 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
203 });
204 assert.equal(result.transition.type, "restart_and_resume");
205 assert.deepEqual(result.releaseProviderSessionIds, []);
206 }).pipe(
207 Effect.provide(
208 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
209 ),
210 ),
211);
212
213it.effect("hands off from a native provider thread after its session detaches", () =>
214 Effect.gen(function* () {
215 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
216 const result = yield* service.plan({
217 projection: { ...deadNativeThreadProjection("stopped"), providerSessions: [] },
218 targetModelSelection: {
219 instanceId: ProviderInstanceId.make("codex_other"),
220 model: "gpt-5.2-codex",
221 },
222 });
223 assert.equal(result.transition.type, "create_with_handoff");
224 assert.deepEqual(result.releaseProviderSessionIds, []);
225 }).pipe(
226 Effect.provide(
227 layerTest({
228 [currentInstanceId]: { continuationKey: "codex:account:primary" },
229 codex_other: { continuationKey: "codex:account:other" },
230 }),
231 ),
232 ),
233);
234
235it.effect.each(["stopped", "error"] as const)(
236 "applies a model change on next turn when a %s session negotiated model switching",
237 (deadStatus) =>
238 Effect.gen(function* () {
239 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
240 const result = yield* service.plan({
241 projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2),
242 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
243 });
244 assert.equal(result.transition.type, "switch_model_in_session");
245 assert.deepEqual(result.releaseProviderSessionIds, []);
246 }).pipe(
247 Effect.provide(
248 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) =>
249 Effect.succeed(acpSelectionTransition(input)),
250 ),
251 ),
252 ),
253);
254
255it.effect("applies a model change on next turn after a settled thread's session detached", () =>
256 Effect.gen(function* () {
257 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
258 const result = yield* service.plan({
259 projection: { ...deadNativeThreadProjection("stopped"), providerSessions: [] },
260 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
261 });
262 assert.equal(result.transition.type, "switch_model_in_session");
263 assert.deepEqual(result.releaseProviderSessionIds, []);
264 }).pipe(
265 Effect.provide(
266 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) =>
267 Effect.succeed(acpSelectionTransition(input)),
268 ),
269 ),
270 ),
271);
272
273it.effect("rejects a model change the dead record never negotiated support for", () =>
274 Effect.gen(function* () {
275 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
276 const result = yield* service
277 .plan({
278 projection: deadNativeThreadProjection("stopped"),
279 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
280 })
281 .pipe(Effect.flip);
282 assert.instanceOf(result, ProviderSwitch.ProviderSwitchPlanError);
283 }).pipe(
284 Effect.provide(
285 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) =>
286 Effect.succeed(acpSelectionTransition(input)),
287 ),
288 ),
289 ),
290);
291
292it.effect("distinguishes compatible and incompatible instances of the same driver", () =>
293 Effect.gen(function* () {
294 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
295 const compatibleId = ProviderInstanceId.make("codex_compatible");
296 const incompatibleId = ProviderInstanceId.make("codex_incompatible");
297 const compatible = yield* service.plan({
298 projection: projection(),
299 targetModelSelection: { instanceId: compatibleId, model: "gpt-5.1-codex" },
300 });
301 const incompatible = yield* service.plan({
302 projection: projection(),
303 targetModelSelection: { instanceId: incompatibleId, model: "gpt-5.1-codex" },
304 });
305 assert.equal(compatible.transition.type, "restart_and_resume");
306 assert.equal(incompatible.transition.type, "create_with_handoff");
307 }).pipe(
308 Effect.provide(
309 layerTest({
310 [currentInstanceId]: { continuationKey: "codex:account:primary" },
311 codex_compatible: { continuationKey: "codex:account:primary" },
312 codex_incompatible: { continuationKey: "codex:account:other" },
313 }),
314 ),
315 ),
316);