| ... | @@ -62,6 +62,7 @@ import { | ... | @@ -62,6 +62,7 @@ import { |
| 62 | type ProviderAdapterV2TurnInput, | 62 | type ProviderAdapterV2TurnInput, |
| 63 | } from "../ProviderAdapter.ts"; | 63 | } from "../ProviderAdapter.ts"; |
| 64 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | 64 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; |
| | 65 | import { makeProviderFailure } from "../ProviderFailure.ts"; |
| 65 | import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; | 66 | import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; |
| 66 | import * as IdAllocator from "../IdAllocator.ts"; | 67 | import * as IdAllocator from "../IdAllocator.ts"; |
| 67 | | 68 | |
| ... | @@ -6608,12 +6609,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -6608,12 +6609,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6608 | assert.equal(processQueues.length, 1); | 6609 | assert.equal(processQueues.length, 1); |
| 6609 | const firstProcess = processQueues[0]!; | 6610 | const firstProcess = processQueues[0]!; |
| 6610 | yield* Queue.offer(firstProcess, wakeTaskStarted); | 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 | yield* Queue.offer(firstProcess, turnOneResult); | 6625 | yield* Queue.offer(firstProcess, turnOneResult); |
| 6612 | yield* awaitUntil( | 6626 | yield* awaitUntil( |
| 6613 | () => events.some((event) => event.type === "turn.terminal"), | 6627 | () => events.some((event) => event.type === "turn.terminal"), |
| 6614 | "first turn terminal", | 6628 | "first turn terminal", |
| 6615 | ); | 6629 | ); |
| 6616 | assert.isTrue(yield* hasPendingBackgroundWork); | 6630 | assert.isFalse(yield* hasPendingBackgroundWork); |
| 6617 | | 6631 | |
| 6618 | const alternateModel = { | 6632 | const alternateModel = { |
| 6619 | ...CLAUDE_TEST_MODEL_SELECTION, | 6633 | ...CLAUDE_TEST_MODEL_SELECTION, |
| ... | @@ -7117,8 +7131,172 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -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 | it.effect( | 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 | Effect.scoped( | 7301 | Effect.scoped( |
| 7124 | Effect.gen(function* () { | 7302 | Effect.gen(function* () { |
| ... | @@ -7236,17 +7414,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -7236,17 +7414,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7236 | ) | 7414 | ) |
| 7237 | .pipe(Effect.exit); | 7415 | .pipe(Effect.exit); |
| 7238 | assert.isTrue(Exit.isFailure(failedStart)); | 7416 | assert.isTrue(Exit.isFailure(failedStart)); |
| 7239 | // Old process was closed before the failed open: roster must not stick. | 7417 | // The shell runs in the first process, so it is never closed and |
| 7240 | yield* awaitUntil( | 7418 | // no replacement is opened. |
| 7241 | () => | 7419 | assert.equal(openCount, 1); |
| 7242 | providerThreadRosterEvents(events).some( | 7420 | assert.isTrue(yield* hasPendingBackgroundWork); |
| 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); | | |
| 7250 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | 7421 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7251 | ), | 7422 | ), |
| 7252 | ); | 7423 | ); |