| ... | ... | @@ -34,7 +34,10 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 34 | 34 | |
| 35 | 35 | import * as SqlitePersistence from "../../persistence/Sqlite.ts"; |
| 36 | 36 | import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; |
| 37 | | import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts"; |
| 37 | import { |
| 38 | ClaudeBackgroundWorkBlocksQueryReplacementError, |
| 39 | ClaudeProviderCapabilitiesV2, |
| 40 | } from "../Adapters/ClaudeAdapterV2.ts"; |
| 38 | 41 | import { |
| 39 | 42 | CodexProviderCapabilitiesV2, |
| 40 | 43 | canReuseCodexContextUsage, |
| ... | ... | @@ -53,6 +56,7 @@ import { |
| 53 | 56 | type ProviderAdapterV2Event, |
| 54 | 57 | type ProviderAdapterV2HistoricalContext, |
| 55 | 58 | ProviderAdapterProtocolError, |
| 59 | ProviderAdapterTurnStartError, |
| 56 | 60 | type ProviderAdapterV2Shape, |
| 57 | 61 | type ProviderAdapterV2SessionRuntime, |
| 58 | 62 | } from "../ProviderAdapter.ts"; |
| ... | ... | @@ -104,6 +108,8 @@ function makeTestAdapter(input: { |
| 104 | 108 | readonly capturedTurns: Ref.Ref<ReadonlyArray<CapturedTurn>>; |
| 105 | 109 | readonly injectedHistory?: Ref.Ref<ReadonlyArray<unknown>>; |
| 106 | 110 | readonly failStartOnce?: Ref.Ref<boolean>; |
| 111 | /** Starts left to refuse the way Claude does while background work runs. */ |
| 112 | readonly refuseStarts?: Ref.Ref<number>; |
| 107 | 113 | readonly failInjectionOnce?: Ref.Ref<boolean>; |
| 108 | 114 | readonly nativeThreadGeneration?: Ref.Ref<number>; |
| 109 | 115 | readonly failResume?: boolean; |
| ... | ... | @@ -226,6 +232,17 @@ function makeTestAdapter(input: { |
| 226 | 232 | (yield* Ref.getAndSet(input.failStartOnce, false)) |
| 227 | 233 | ) |
| 228 | 234 | return yield* unimplemented(input.driver, "turn start failed after injection"); |
| 235 | if ( |
| 236 | input.refuseStarts !== undefined && |
| 237 | (yield* Ref.getAndUpdate(input.refuseStarts, (left) => Math.max(0, left - 1))) > 0 |
| 238 | ) |
| 239 | return yield* new ProviderAdapterTurnStartError({ |
| 240 | driver: input.driver, |
| 241 | threadId: turnInput.threadId, |
| 242 | providerThreadId: turnInput.providerThread.id, |
| 243 | runId: turnInput.runId, |
| 244 | cause: new ClaudeBackgroundWorkBlocksQueryReplacementError(), |
| 245 | }); |
| 229 | 246 | yield* Effect.yieldNow; |
| 230 | 247 | yield* Ref.update(input.capturedTurns, (turns) => [ |
| 231 | 248 | ...turns, |
| ... | ... | @@ -1188,6 +1205,101 @@ describe("orchestration v2 provider switching", () => { |
| 1188 | 1205 | ), |
| 1189 | 1206 | ); |
| 1190 | 1207 | |
| 1208 | it.live("keeps the native session after turns refused before reaching the provider", () => |
| 1209 | Effect.scoped( |
| 1210 | Effect.gen(function* () { |
| 1211 | const cwd = yield* checkpointWorkspace("refused-start-keeps-native"); |
| 1212 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); |
| 1213 | const refuseStarts = yield* Ref.make(0); |
| 1214 | const generation = yield* Ref.make(0); |
| 1215 | const registry = ProviderAdapterRegistry.makeLayer([ |
| 1216 | makeTestAdapter({ |
| 1217 | instanceId: CLAUDE_MODEL_SELECTION.instanceId, |
| 1218 | driver: CLAUDE_DRIVER, |
| 1219 | capabilities: ClaudeProviderCapabilitiesV2, |
| 1220 | modelSelection: CLAUDE_MODEL_SELECTION, |
| 1221 | responseByRunOrdinal: {}, |
| 1222 | capturedTurns, |
| 1223 | refuseStarts, |
| 1224 | nativeThreadGeneration: generation, |
| 1225 | }), |
| 1226 | ]); |
| 1227 | yield* Effect.gen(function* () { |
| 1228 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 1229 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; |
| 1230 | const run = Effect.fn("run")(function* (ordinal: number) { |
| 1231 | yield* orchestrator.dispatch({ |
| 1232 | type: "message.dispatch", |
| 1233 | commandId: CommandId.make(`refused-start:${ordinal}`), |
| 1234 | threadId, |
| 1235 | messageId: MessageId.make(`refused-start:${ordinal}`), |
| 1236 | createdBy: "user", |
| 1237 | creationSource: "web", |
| 1238 | text: `Request ${ordinal}`, |
| 1239 | attachments: [], |
| 1240 | modelSelection: CLAUDE_MODEL_SELECTION, |
| 1241 | dispatchMode: { type: "start_immediately" }, |
| 1242 | }); |
| 1243 | yield* orchestrator.streamStoredEvents.pipe( |
| 1244 | Stream.filter( |
| 1245 | ({ event }) => |
| 1246 | event.type === "run.updated" && |
| 1247 | event.payload.ordinal === ordinal && |
| 1248 | (event.payload.status === "completed" || event.payload.status === "failed"), |
| 1249 | ), |
| 1250 | Stream.runHead, |
| 1251 | ); |
| 1252 | yield* worker.drain(); |
| 1253 | return (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status; |
| 1254 | }); |
| 1255 | yield* orchestrator.dispatch({ |
| 1256 | type: "thread.create", |
| 1257 | commandId: CommandId.make("refused-start:create"), |
| 1258 | threadId, |
| 1259 | projectId, |
| 1260 | createdBy: "user", |
| 1261 | creationSource: "web", |
| 1262 | title: "Refused start", |
| 1263 | modelSelection: CLAUDE_MODEL_SELECTION, |
| 1264 | runtimeMode: "full-access", |
| 1265 | interactionMode: "default", |
| 1266 | branch: null, |
| 1267 | worktreePath: null, |
| 1268 | }); |
| 1269 | assert.equal(yield* run(1), "completed"); |
| 1270 | // Run 3 carries run 2's missed request as a handoff and is refused too. |
| 1271 | yield* Ref.set(refuseStarts, 2); |
| 1272 | assert.equal(yield* run(2), "failed"); |
| 1273 | assert.equal(yield* run(3), "failed"); |
| 1274 | assert.equal(yield* run(4), "completed"); |
| 1275 | |
| 1276 | const turns = yield* Ref.get(capturedTurns); |
| 1277 | assert.equal(turns.length, 2); |
| 1278 | // Nothing reached the provider, so the next turn continues the same |
| 1279 | // native session instead of replacing it with a summary. |
| 1280 | assert.equal(yield* Ref.get(generation), 1); |
| 1281 | assert.equal(turns[1]?.nativeThreadId, turns[0]?.nativeThreadId); |
| 1282 | assert.include(turns[1]?.text, "Request 2"); |
| 1283 | assert.include(turns[1]?.text, "Request 3"); |
| 1284 | }).pipe( |
| 1285 | Effect.provide( |
| 1286 | makeOrchestratorV2ReplayLayerWithRegistry( |
| 1287 | { |
| 1288 | name: "refused-start-keeps-native", |
| 1289 | runtimePolicyOverride: { |
| 1290 | cwd, |
| 1291 | approvalPolicy: "never", |
| 1292 | sandboxPolicy: { type: "readOnly" }, |
| 1293 | }, |
| 1294 | }, |
| 1295 | registry, |
| 1296 | ), |
| 1297 | ), |
| 1298 | ); |
| 1299 | }), |
| 1300 | ), |
| 1301 | ); |
| 1302 | |
| 1191 | 1303 | it.live.each( |
| 1192 | 1304 | (["failed", "interrupted"] as const).flatMap((status) => |
| 1193 | 1305 | [false, true].flatMap((queued) => |