| ... | ... | @@ -62,6 +62,7 @@ import { |
| 62 | 62 | type ProviderAdapterV2TurnInput, |
| 63 | 63 | } from "../ProviderAdapter.ts"; |
| 64 | 64 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; |
| 65 | import { makeProviderFailure } from "../ProviderFailure.ts"; |
| 65 | 66 | import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; |
| 66 | 67 | import * as IdAllocator from "../IdAllocator.ts"; |
| 67 | 68 | |
| ... | ... | @@ -6608,12 +6609,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6608 | 6609 | assert.equal(processQueues.length, 1); |
| 6609 | 6610 | const firstProcess = processQueues[0]!; |
| 6610 | 6611 | yield* Queue.offer(firstProcess, wakeTaskStarted); |
| 6612 | // The shell leaves the roster before its notification arrives, so |
| 6613 | // nothing runs in this process any more and a model change may |
| 6614 | // replace it. Wake eligibility outlives the empty level. |
| 6615 | yield* Queue.offer( |
| 6616 | firstProcess, |
| 6617 | claudeSdkFrame({ |
| 6618 | type: "system", |
| 6619 | subtype: "background_tasks_changed", |
| 6620 | tasks: [], |
| 6621 | uuid: "00000000-0000-4000-8000-000000000603", |
| 6622 | session_id: WAKE_NATIVE_SESSION, |
| 6623 | }), |
| 6624 | ); |
| 6611 | 6625 | yield* Queue.offer(firstProcess, turnOneResult); |
| 6612 | 6626 | yield* awaitUntil( |
| 6613 | 6627 | () => events.some((event) => event.type === "turn.terminal"), |
| 6614 | 6628 | "first turn terminal", |
| 6615 | 6629 | ); |
| 6616 | | assert.isTrue(yield* hasPendingBackgroundWork); |
| 6630 | assert.isFalse(yield* hasPendingBackgroundWork); |
| 6617 | 6631 | |
| 6618 | 6632 | const alternateModel = { |
| 6619 | 6633 | ...CLAUDE_TEST_MODEL_SELECTION, |
| ... | ... | @@ -7117,8 +7131,172 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7117 | 7131 | ), |
| 7118 | 7132 | ); |
| 7119 | 7133 | |
| 7134 | it.effect("refuses a model change that would kill a running background subagent", () => |
| 7135 | Effect.scoped( |
| 7136 | Effect.gen(function* () { |
| 7137 | const SUBAGENT_TASK_ID = "task-model-change-running-subagent"; |
| 7138 | const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent"; |
| 7139 | const fileSystem = yield* FileSystem.FileSystem; |
| 7140 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 7141 | const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 7142 | prefix: "t3-claude-v2-model-change-running-subagent-", |
| 7143 | }); |
| 7144 | const processQueues: Array<Queue.Queue<SDKMessage>> = []; |
| 7145 | const events: Array<ProviderAdapterV2Event> = []; |
| 7146 | const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ |
| 7147 | instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, |
| 7148 | settings: DEFAULT_CLAUDE_SETTINGS, |
| 7149 | environment: {}, |
| 7150 | attachmentsDir, |
| 7151 | fileSystem, |
| 7152 | path: yield* Path.Path, |
| 7153 | idAllocator, |
| 7154 | continuationRequests: { offer: () => Effect.void }, |
| 7155 | queryRunner: { |
| 7156 | allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), |
| 7157 | open: () => |
| 7158 | Effect.gen(function* () { |
| 7159 | const sdkMessages = yield* Queue.unbounded<SDKMessage>(); |
| 7160 | processQueues.push(sdkMessages); |
| 7161 | return { |
| 7162 | messages: Stream.fromQueue(sdkMessages), |
| 7163 | offer: () => Effect.void, |
| 7164 | setModel: () => Effect.void, |
| 7165 | interrupt: Effect.void, |
| 7166 | close: Queue.shutdown(sdkMessages), |
| 7167 | }; |
| 7168 | }), |
| 7169 | forkSession: () => Effect.die("unused forkSession"), |
| 7170 | subagentLaunchToolUseId: () => Effect.succeed(null), |
| 7171 | assertComplete: Effect.void, |
| 7172 | }, |
| 7173 | }); |
| 7174 | const threadId = ThreadId.make("thread-claude-model-change-running-subagent"); |
| 7175 | const runtime = yield* adapter.openSession({ |
| 7176 | threadId, |
| 7177 | providerSessionId: ProviderSessionId.make( |
| 7178 | "provider-session-claude-model-change-running-subagent", |
| 7179 | ), |
| 7180 | modelSelection: CLAUDE_TEST_MODEL_SELECTION, |
| 7181 | runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, |
| 7182 | }); |
| 7183 | const providerThread = yield* runtime.ensureThread({ |
| 7184 | threadId, |
| 7185 | modelSelection: CLAUDE_TEST_MODEL_SELECTION, |
| 7186 | runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, |
| 7187 | }); |
| 7188 | yield* runtime.events.pipe( |
| 7189 | Stream.runForEach((event) => |
| 7190 | Effect.sync(() => { |
| 7191 | events.push(event); |
| 7192 | }), |
| 7193 | ), |
| 7194 | Effect.forkScoped, |
| 7195 | ); |
| 7196 | const terminals = () => events.filter((event) => event.type === "turn.terminal"); |
| 7197 | const now = yield* DateTime.now; |
| 7198 | |
| 7199 | yield* runtime.startTurn( |
| 7200 | makeClaudeTestTurnInput({ |
| 7201 | threadId, |
| 7202 | providerThread, |
| 7203 | now, |
| 7204 | attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-a"), |
| 7205 | text: "Spawn a background subagent and stop.", |
| 7206 | attachments: [], |
| 7207 | }), |
| 7208 | ); |
| 7209 | const firstProcess = processQueues[0]!; |
| 7210 | yield* Queue.offer( |
| 7211 | firstProcess, |
| 7212 | claudeSdkFrame({ |
| 7213 | type: "system", |
| 7214 | subtype: "task_started", |
| 7215 | task_id: SUBAGENT_TASK_ID, |
| 7216 | tool_use_id: SUBAGENT_TOOL_USE_ID, |
| 7217 | description: "Background research", |
| 7218 | subagent_type: "general-purpose", |
| 7219 | task_type: "local_agent", |
| 7220 | prompt: "Research, then report.", |
| 7221 | uuid: "00000000-0000-4000-8000-000000000901", |
| 7222 | session_id: WAKE_NATIVE_SESSION, |
| 7223 | }), |
| 7224 | ); |
| 7225 | yield* Queue.offer( |
| 7226 | firstProcess, |
| 7227 | makeResultFrame({ |
| 7228 | uuid: "00000000-0000-4000-8000-000000000902", |
| 7229 | result: "Spawned the subagent in the background.", |
| 7230 | }), |
| 7231 | ); |
| 7232 | yield* awaitUntil(() => terminals().length === 1, "first turn terminal"); |
| 7233 | const settledTurn = terminals()[0]!; |
| 7234 | |
| 7235 | // The subagent runs inside the first CLI process. Another model needs |
| 7236 | // another process, so the turn must not start and close this one. |
| 7237 | const alternateModel = { |
| 7238 | ...CLAUDE_TEST_MODEL_SELECTION, |
| 7239 | model: "claude-haiku-4-5-20251001", |
| 7240 | } satisfies ModelSelection; |
| 7241 | const switchTurn = (attempt: string) => |
| 7242 | runtime.startTurn( |
| 7243 | makeClaudeTestTurnInput({ |
| 7244 | threadId, |
| 7245 | providerThread: { ...providerThread, status: "active" }, |
| 7246 | now, |
| 7247 | attemptId: RunAttemptId.make(attempt), |
| 7248 | text: "Switch model while the subagent runs.", |
| 7249 | attachments: [], |
| 7250 | providerTurnOrdinal: 2, |
| 7251 | modelSelection: alternateModel, |
| 7252 | }), |
| 7253 | ); |
| 7254 | const refused = yield* switchTurn("attempt-claude-model-change-running-subagent-b").pipe( |
| 7255 | Effect.flip, |
| 7256 | ); |
| 7257 | assert.equal( |
| 7258 | makeProviderFailure({ cause: refused, class: "provider_error" }).message, |
| 7259 | new ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError().message, |
| 7260 | ); |
| 7261 | assert.lengthOf(processQueues, 1); |
| 7262 | |
| 7263 | // Stop ends the background work, so the switch may replace the process. |
| 7264 | yield* runtime.interruptTurn({ |
| 7265 | providerThread, |
| 7266 | providerTurnId: settledTurn.providerTurnId, |
| 7267 | requestRuntimeRestart: true, |
| 7268 | }); |
| 7269 | yield* switchTurn("attempt-claude-model-change-running-subagent-c"); |
| 7270 | assert.lengthOf(processQueues, 2); |
| 7271 | |
| 7272 | // The stopped subagent never reports its end, so it must not block |
| 7273 | // later changes on the replacement process either. |
| 7274 | yield* Queue.offer( |
| 7275 | processQueues[1]!, |
| 7276 | makeResultFrame({ |
| 7277 | uuid: "00000000-0000-4000-8000-000000000903", |
| 7278 | result: "Switched model.", |
| 7279 | }), |
| 7280 | ); |
| 7281 | yield* awaitUntil(() => terminals().length === 2, "switched turn terminal"); |
| 7282 | yield* runtime.startTurn( |
| 7283 | makeClaudeTestTurnInput({ |
| 7284 | threadId, |
| 7285 | providerThread: { ...providerThread, status: "active" }, |
| 7286 | now, |
| 7287 | attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-d"), |
| 7288 | text: "Switch back.", |
| 7289 | attachments: [], |
| 7290 | providerTurnOrdinal: 3, |
| 7291 | }), |
| 7292 | ); |
| 7293 | assert.lengthOf(processQueues, 3); |
| 7294 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7295 | ), |
| 7296 | ); |
| 7297 | |
| 7120 | 7298 | it.effect( |
| 7121 | | "clears process-scoped roster when same-native-thread replacement open fails after close", |
| 7299 | "keeps the process and its roster when a model change meets a running background shell", |
| 7122 | 7300 | () => |
| 7123 | 7301 | Effect.scoped( |
| 7124 | 7302 | Effect.gen(function* () { |
| ... | ... | @@ -7236,17 +7414,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7236 | 7414 | ) |
| 7237 | 7415 | .pipe(Effect.exit); |
| 7238 | 7416 | assert.isTrue(Exit.isFailure(failedStart)); |
| 7239 | | // Old process was closed before the failed open: roster must not stick. |
| 7240 | | yield* awaitUntil( |
| 7241 | | () => |
| 7242 | | providerThreadRosterEvents(events).some( |
| 7243 | | (event) => |
| 7244 | | event.providerThread.status === "idle" && |
| 7245 | | (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, |
| 7246 | | ), |
| 7247 | | "roster cleared after failed same-thread replacement open", |
| 7248 | | ); |
| 7249 | | assert.isFalse(yield* hasPendingBackgroundWork); |
| 7417 | // The shell runs in the first process, so it is never closed and |
| 7418 | // no replacement is opened. |
| 7419 | assert.equal(openCount, 1); |
| 7420 | assert.isTrue(yield* hasPendingBackgroundWork); |
| 7250 | 7421 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7251 | 7422 | ), |
| 7252 | 7423 | ); |