- Return a deny permission result when the user cancels a pending Claude question - Add coverage for aborting AskUserQuestion while user input is pending
Unified · Split
| @@ -2497,6 +2497,74 @@ describe("ClaudeAdapterLive", () => { · 2496 unmodified lines | |||
| 2497 | ); | 2497 | ); |
| 2498 | }); | 2498 | }); |
| 2499 | 2499 | ||
| 2500 | it.effect("denies AskUserQuestion when the waiting turn is aborted", () => { | ||
| 2501 | const harness = makeHarness(); | ||
| 2502 | return Effect.gen(function* () { | ||
| 2503 | const adapter = yield* ClaudeAdapter; | ||
| 2504 | |||
| 2505 | const session = yield* adapter.startSession({ | ||
| 2506 | threadId: THREAD_ID, | ||
| 2507 | provider: "claudeAgent", | ||
| 2508 | runtimeMode: "approval-required", | ||
| 2509 | }); | ||
| 2510 | |||
| 2511 | yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); | ||
| 2512 | |||
| 2513 | const createInput = harness.getLastCreateQueryInput(); | ||
| 2514 | const canUseTool = createInput?.options.canUseTool; | ||
| 2515 | assert.equal(typeof canUseTool, "function"); | ||
| 2516 | if (!canUseTool) { | ||
| 2517 | return; | ||
| 2518 | } | ||
| 2519 | |||
| 2520 | const controller = new AbortController(); | ||
| 2521 | const permissionPromise = canUseTool( | ||
| 2522 | "AskUserQuestion", | ||
| 2523 | { | ||
| 2524 | questions: [ | ||
| 2525 | { | ||
| 2526 | question: "Continue?", | ||
| 2527 | header: "Continue", | ||
| 2528 | options: [{ label: "Yes", description: "Proceed" }], | ||
| 2529 | multiSelect: false, | ||
| 2530 | }, | ||
| 2531 | ], | ||
| 2532 | }, | ||
| 2533 | { | ||
| 2534 | signal: controller.signal, | ||
| 2535 | toolUseID: "tool-ask-abort", | ||
| 2536 | }, | ||
| 2537 | ); | ||
| 2538 | |||
| 2539 | const requestedEvent = yield* Stream.runHead(adapter.streamEvents); | ||
| 2540 | assert.equal(requestedEvent._tag, "Some"); | ||
| 2541 | if (requestedEvent._tag !== "Some" || requestedEvent.value.type !== "user-input.requested") { | ||
| 2542 | assert.fail("Expected user-input.requested event"); | ||
| 2543 | return; | ||
| 2544 | } | ||
| 2545 | assert.equal(requestedEvent.value.threadId, session.threadId); | ||
| 2546 | |||
| 2547 | controller.abort(); | ||
| 2548 | |||
| 2549 | const resolvedEvent = yield* Stream.runHead(adapter.streamEvents); | ||
| 2550 | assert.equal(resolvedEvent._tag, "Some"); | ||
| 2551 | if (resolvedEvent._tag !== "Some" || resolvedEvent.value.type !== "user-input.resolved") { | ||
| 2552 | assert.fail("Expected user-input.resolved event"); | ||
| 2553 | return; | ||
| 2554 | } | ||
| 2555 | assert.deepEqual(resolvedEvent.value.payload.answers, {}); | ||
| 2556 | |||
| 2557 | const permissionResult = yield* Effect.promise(() => permissionPromise); | ||
| 2558 | assert.deepEqual(permissionResult, { | ||
| 2559 | behavior: "deny", | ||
| 2560 | message: "User cancelled tool execution.", | ||
| 2561 | } satisfies PermissionResult); | ||
| 2562 | }).pipe( | ||
| 2563 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 2564 | Effect.provide(harness.layer), | ||
| 2565 | ); | ||
| 2566 | }); | ||
| 2567 | |||
| 2500 | it.effect("writes provider-native observability records when enabled", () => { | 2568 | it.effect("writes provider-native observability records when enabled", () => { |
| 2501 | const nativeEvents: Array<{ | 2569 | const nativeEvents: Array<{ |
| 2502 | event?: { | 2570 | event?: { |
| @@ -2209,6 +2209,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 2208 unmodified lines | |||
| 2209 | ); | 2209 | ); |
| 2210 | 2210 | ||
| 2211 | const answersDeferred = yield* Deferred.make<ProviderUserInputAnswers>(); | 2211 | const answersDeferred = yield* Deferred.make<ProviderUserInputAnswers>(); |
| 2212 | let aborted = false; | ||
| 2212 | const pendingInput: PendingUserInput = { | 2213 | const pendingInput: PendingUserInput = { |
| 2213 | questions, | 2214 | questions, |
| 2214 | answers: answersDeferred, | 2215 | answers: answersDeferred, |
| @@ -2242,6 +2243,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 27 unmodified lines | |||
| 2242 | if (!pendingUserInputs.has(requestId)) { | 2243 | if (!pendingUserInputs.has(requestId)) { |
| 2243 | return; | 2244 | return; |
| 2244 | } | 2245 | } |
| 2246 | aborted = true; | ||
| 2245 | pendingUserInputs.delete(requestId); | 2247 | pendingUserInputs.delete(requestId); |
| 2246 | Effect.runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); | 2248 | Effect.runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); |
| 2247 | }; | 2249 | }; |
| @@ -2272,6 +2274,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 24 unmodified lines | |||
| 2272 | }, | 2274 | }, |
| 2273 | }); | 2275 | }); |
| 2274 | 2276 | ||
| 2277 | if (aborted) { | ||
| 2278 | return { | ||
| 2279 | behavior: "deny", | ||
| 2280 | message: "User cancelled tool execution.", | ||
| 2281 | } satisfies PermissionResult; | ||
| 2282 | } | ||
| 2283 | |||
| 2275 | // Return the answers to the SDK in the expected format: | 2284 | // Return the answers to the SDK in the expected format: |
| 2276 | // { questions: [...], answers: { questionText: selectedLabel } } | 2285 | // { questions: [...], answers: { questionText: selectedLabel } } |
| 2277 | return { | 2286 | return { |