| 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { |
| 3 | ProviderDriverKind, |
| 4 | ProviderInstanceId, |
| 5 | ProviderSessionId, |
| 6 | ProviderThreadId, |
| 7 | ThreadId, |
| 8 | type OrchestrationV2ThreadProjection, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import * as DateTime from "effect/DateTime"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | |
| 14 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 15 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 16 | import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; |
| 17 | import * as ProviderSwitch from "./ProviderSwitchService.ts"; |
| 18 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 19 | |
| 20 | const driver = ProviderDriverKind.make("codex"); |
| 21 | const currentInstanceId = ProviderInstanceId.make("codex_primary"); |
| 22 | const currentSessionId = ProviderSessionId.make("session_primary"); |
| 23 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 24 | const capabilitiesWithoutModelSwitch = { |
| 25 | ...CodexProviderCapabilitiesV2, |
| 26 | sessions: { |
| 27 | ...CodexProviderCapabilitiesV2.sessions, |
| 28 | supportsModelSwitchInSession: false, |
| 29 | }, |
| 30 | }; |
| 31 | |
| 32 | function 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 | |
| 55 | function 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 | |
| 68 | function 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 | |
| 107 | it.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 | |
| 125 | it.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 | |
| 150 | it.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 | |
| 179 | it.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 | |
| 202 | function 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 | |
| 240 | it.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 | |
| 256 | it.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 | |
| 278 | it.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 | |
| 302 | it.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 | |
| 321 | it.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 | ); |