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(
108 "restarts and releases the current session for unsupported in-session model changes",
109 () =>
110 Effect.gen(function* () {
111 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
112 const result = yield* service.plan({
113 projection: projection(),
114 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
115 });
116 assert.equal(result.transition.type, "restart_and_resume");
117 assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]);
118 }).pipe(
119 Effect.provide(
120 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
121 ),
122 ),
123);
124
125it.effect.each(["stopped", "error"] as const)(
126 "restarts and releases the live session when a newer %s session exists",
127 (deadStatus) =>
128 Effect.gen(function* () {
129 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
130 const thread = projection();
131 const result = yield* service.plan({
132 projection: {
133 ...thread,
134 providerSessions: [
135 ...thread.providerSessions,
136 deadSessionRecord("dead_session", deadStatus),
137 ],
138 },
139 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
140 });
141 assert.equal(result.transition.type, "restart_and_resume");
142 assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]);
143 }).pipe(
144 Effect.provide(
145 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
146 ),
147 ),
148);
149
150it.effect("releases the newest live session, not the newest record overall", () =>
151 Effect.gen(function* () {
152 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
153 const thread = projection();
154 const newerLiveSessionId = ProviderSessionId.make("session_newer_live");
155 const result = yield* service.plan({
156 projection: {
157 ...thread,
158 providerSessions: [
159 ...thread.providerSessions,
160 {
161 ...thread.providerSessions[0]!,
162 id: newerLiveSessionId,
163 updatedAt: DateTime.add(now, { seconds: 1 }),
164 },
165 deadSessionRecord("dead_session", "stopped", DateTime.add(now, { seconds: 2 })),
166 ],
167 },
168 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
169 });
170 assert.equal(result.transition.type, "restart_and_resume");
171 assert.deepEqual(result.releaseProviderSessionIds, [newerLiveSessionId]);
172 }).pipe(
173 Effect.provide(
174 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
175 ),
176 ),
177);
178
179it.effect("creates a fresh session with handoff when every recorded session is dead", () =>
180 Effect.gen(function* () {
181 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
182 const thread = projection();
183 const result = yield* service.plan({
184 projection: {
185 ...thread,
186 providerSessions: [
187 deadSessionRecord("dead_session_older", "stopped"),
188 deadSessionRecord("dead_session_newer", "error", DateTime.add(now, { seconds: 2 })),
189 ],
190 },
191 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
192 });
193 assert.equal(result.transition.type, "create_with_handoff");
194 assert.deepEqual(result.releaseProviderSessionIds, []);
195 }).pipe(
196 Effect.provide(
197 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
198 ),
199 ),
200);
201
202function deadNativeThreadProjection(
203 status: "stopped" | "error",
204 capabilities = capabilitiesWithoutModelSwitch,
205): OrchestrationV2ThreadProjection {
206 const thread = projection();
207 return {
208 ...thread,
209 thread: {
210 ...thread.thread,
211 activeProviderThreadId: ProviderThreadId.make("provider-thread:native"),
212 },
213 providerSessions: [{ ...deadSessionRecord("dead_session", status), capabilities }],
214 providerThreads: [
215 {
216 id: ProviderThreadId.make("provider-thread:native"),
217 driver,
218 providerInstanceId: currentInstanceId,
219 providerSessionId: ProviderSessionId.make("dead_session"),
220 appThreadId: thread.thread.id,
221 ownerNodeId: null,
222 nativeThreadRef: {
223 driver,
224 nativeId: "native-thread:abc",
225 strength: "strong",
226 },
227 nativeConversationHeadRef: null,
228 status: "idle",
229 firstRunOrdinal: null,
230 lastRunOrdinal: null,
231 handoffIds: [],
232 forkedFrom: null,
233 createdAt: now,
234 updatedAt: now,
235 },
236 ],
237 } as OrchestrationV2ThreadProjection;
238}
239
240it.effect("falls back to the native provider thread when every recorded session is dead", () =>
241 Effect.gen(function* () {
242 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
243 const result = yield* service.plan({
244 projection: deadNativeThreadProjection("stopped"),
245 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
246 });
247 assert.equal(result.transition.type, "restart_and_resume");
248 assert.deepEqual(result.releaseProviderSessionIds, []);
249 }).pipe(
250 Effect.provide(
251 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }),
252 ),
253 ),
254);
255
256it.effect("hands off from a native provider thread after its session detaches", () =>
257 Effect.gen(function* () {
258 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
259 const result = yield* service.plan({
260 projection: { ...deadNativeThreadProjection("stopped"), providerSessions: [] },
261 targetModelSelection: {
262 instanceId: ProviderInstanceId.make("codex_other"),
263 model: "gpt-5.2-codex",
264 },
265 });
266 assert.equal(result.transition.type, "create_with_handoff");
267 assert.deepEqual(result.releaseProviderSessionIds, []);
268 }).pipe(
269 Effect.provide(
270 layerTest({
271 [currentInstanceId]: { continuationKey: "codex:account:primary" },
272 codex_other: { continuationKey: "codex:account:other" },
273 }),
274 ),
275 ),
276);
277
278it.effect.each(["stopped", "error"] as const)(
279 "applies a model change on next turn when a %s session negotiated model switching",
280 (deadStatus) =>
281 Effect.gen(function* () {
282 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
283 const result = yield* service.plan({
284 projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2),
285 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
286 });
287 // Static capabilities report no in-session switch, but the dead
288 // record's negotiated capabilities describe the provider: without
289 // them the ACP classification rejects the selection instead of
290 // reopening with the requested model on the next run.
291 assert.equal(result.transition.type, "switch_model_in_session");
292 assert.deepEqual(result.releaseProviderSessionIds, []);
293 }).pipe(
294 Effect.provide(
295 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) =>
296 Effect.succeed(acpSelectionTransition(input)),
297 ),
298 ),
299 ),
300);
301
302it.effect("rejects a model change the dead record never negotiated support for", () =>
303 Effect.gen(function* () {
304 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
305 const result = yield* service
306 .plan({
307 projection: deadNativeThreadProjection("stopped"),
308 targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" },
309 })
310 .pipe(Effect.flip);
311 assert.instanceOf(result, ProviderSwitch.ProviderSwitchPlanError);
312 }).pipe(
313 Effect.provide(
314 layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) =>
315 Effect.succeed(acpSelectionTransition(input)),
316 ),
317 ),
318 ),
319);
320
321it.effect("distinguishes compatible and incompatible instances of the same driver", () =>
322 Effect.gen(function* () {
323 const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
324 const compatibleId = ProviderInstanceId.make("codex_compatible");
325 const incompatibleId = ProviderInstanceId.make("codex_incompatible");
326 const compatible = yield* service.plan({
327 projection: projection(),
328 targetModelSelection: { instanceId: compatibleId, model: "gpt-5.1-codex" },
329 });
330 const incompatible = yield* service.plan({
331 projection: projection(),
332 targetModelSelection: { instanceId: incompatibleId, model: "gpt-5.1-codex" },
333 });
334 assert.equal(compatible.transition.type, "restart_and_resume");
335 assert.equal(incompatible.transition.type, "create_with_handoff");
336 }).pipe(
337 Effect.provide(
338 layerTest({
339 [currentInstanceId]: { continuationKey: "codex:account:primary" },
340 codex_compatible: { continuationKey: "codex:account:primary" },
341 codex_incompatible: { continuationKey: "codex:account:other" },
342 }),
343 ),
344 ),
345);