- 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 | 2568 | it.effect("writes provider-native observability records when enabled", () => { |
| 2501 | 2569 | const nativeEvents: Array<{ |
| 2502 | 2570 | event?: { |
| @@ -2209,6 +2209,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 2208 unmodified lines | ||
| 2209 | 2209 | ); |
| 2210 | 2210 | |
| 2211 | 2211 | const answersDeferred = yield* Deferred.make<ProviderUserInputAnswers>(); |
| 2212 | + let aborted = false; | |
| 2212 | 2213 | const pendingInput: PendingUserInput = { |
| 2213 | 2214 | questions, |
| 2214 | 2215 | answers: answersDeferred, |
| @@ -2242,6 +2243,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 27 unmodified lines | ||
| 2242 | 2243 | if (!pendingUserInputs.has(requestId)) { |
| 2243 | 2244 | return; |
| 2244 | 2245 | } |
| 2246 | + aborted = true; | |
| 2245 | 2247 | pendingUserInputs.delete(requestId); |
| 2246 | 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 | 2284 | // Return the answers to the SDK in the expected format: |
| 2276 | 2285 | // { questions: [...], answers: { questionText: selectedLabel } } |
| 2277 | 2286 | return { |