Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3688,6 +3688,73 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("resolves pending AskUserQuestion prompts when the session stops", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "approval-required",
});

yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);

const createInput = harness.getLastCreateQueryInput();
const canUseTool = createInput?.options.canUseTool;
assert.equal(typeof canUseTool, "function");
if (!canUseTool) {
return;
}

const permissionPromise = canUseTool(
"AskUserQuestion",
{
questions: [
{
question: "Which path should we take?",
header: "Path",
options: [{ label: "A", description: "First path" }],
multiSelect: false,
},
],
},
{
signal: new AbortController().signal,
toolUseID: "tool-ask-stop",
},
);

const requestedEvent = yield* Stream.runHead(adapter.streamEvents);
assert.equal(requestedEvent._tag, "Some");
if (requestedEvent._tag !== "Some" || requestedEvent.value.type !== "user-input.requested") {
assert.fail("Expected user-input.requested event");
return;
}

yield* adapter.stopSession(session.threadId);

const resolvedEvent = yield* Stream.runHead(adapter.streamEvents);
assert.equal(resolvedEvent._tag, "Some");
if (resolvedEvent._tag !== "Some" || resolvedEvent.value.type !== "user-input.resolved") {
assert.fail("Expected user-input.resolved event");
return;
}
assert.equal(resolvedEvent.value.requestId, requestedEvent.value.requestId);
assert.deepEqual(resolvedEvent.value.payload.answers, {});

const permissionResult = yield* Effect.promise(() => permissionPromise);
assert.deepEqual(permissionResult, {
behavior: "deny",
message: "User cancelled tool execution.",
} satisfies PermissionResult);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("writes provider-native observability records when enabled", () => {
const nativeEvents: Array<{
event?: {
Expand Down
104 changes: 72 additions & 32 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,19 @@ interface PendingApproval {

interface PendingUserInput {
readonly questions: ReadonlyArray<UserInputQuestion>;
readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
readonly resolution: Deferred.Deferred<PendingUserInputResolution>;
}

type PendingUserInputResolution =
| {
readonly _tag: "answered";
readonly answers: ProviderUserInputAnswers;
}
| {
readonly _tag: "cancelled";
readonly answers: ProviderUserInputAnswers;
};

interface ToolInFlight {
readonly itemId: string;
readonly itemType: CanonicalItemType;
Expand Down Expand Up @@ -2974,6 +2984,30 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}
context.pendingApprovals.clear();

for (const [requestId, pending] of context.pendingUserInputs) {
const answers = {} as ProviderUserInputAnswers;
const resolution: PendingUserInputResolution = { _tag: "cancelled", answers };
yield* Deferred.succeed(pending.resolution, resolution);
const stamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "user-input.resolved",
eventId: stamp.eventId,
provider: PROVIDER,
createdAt: stamp.createdAt,
threadId: context.session.threadId,
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
requestId: asRuntimeRequestId(requestId),
payload: { answers },
providerRefs: nativeProviderRefs(context),
raw: {
source: "claude.sdk.permission",
method: "canUseTool/AskUserQuestion/cancelled",
payload: {},
},
});
}
context.pendingUserInputs.clear();

if (context.turnState) {
yield* completeTurn(context, "interrupted", "Session stopped.");
}
Expand Down Expand Up @@ -3150,11 +3184,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}),
);

const answersDeferred = yield* Deferred.make<ProviderUserInputAnswers>();
let aborted = false;
const resolutionDeferred = yield* Deferred.make<PendingUserInputResolution>();
const pendingInput: PendingUserInput = {
questions,
answers: answersDeferred,
resolution: resolutionDeferred,
};

// Emit user-input.requested so the UI can present the questions.
Expand Down Expand Up @@ -3192,44 +3225,50 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
if (!pendingUserInputs.has(requestId)) {
return;
}
aborted = true;
pendingUserInputs.delete(requestId);
runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers));
const resolution: PendingUserInputResolution = {
_tag: "cancelled",
answers: {} as ProviderUserInputAnswers,
};
runFork(Deferred.succeed(resolutionDeferred, resolution));
};
callbackOptions.signal.addEventListener("abort", onAbort, {
once: true,
});

// Block until the user provides answers.
const answers = yield* Deferred.await(answersDeferred);
const resolution = yield* Deferred.await(resolutionDeferred);
const answers = resolution.answers;
pendingUserInputs.delete(requestId);

// Emit user-input.resolved so the UI knows the interaction completed.
const resolvedStamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "user-input.resolved",
eventId: resolvedStamp.eventId,
provider: PROVIDER,
createdAt: resolvedStamp.createdAt,
threadId: context.session.threadId,
...(context.turnState
? {
turnId: asCanonicalTurnId(context.turnState.turnId),
}
: {}),
requestId: asRuntimeRequestId(requestId),
payload: { answers },
providerRefs: nativeProviderRefs(context, {
providerItemId: callbackOptions.toolUseID,
}),
raw: {
source: "claude.sdk.permission",
method: "canUseTool/AskUserQuestion/resolved",
if (!(context.stopped && resolution._tag === "cancelled")) {
// Emit user-input.resolved so the UI knows the interaction completed.
const resolvedStamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "user-input.resolved",
eventId: resolvedStamp.eventId,
provider: PROVIDER,
createdAt: resolvedStamp.createdAt,
threadId: context.session.threadId,
...(context.turnState
? {
turnId: asCanonicalTurnId(context.turnState.turnId),
}
: {}),
requestId: asRuntimeRequestId(requestId),
payload: { answers },
},
});
providerRefs: nativeProviderRefs(context, {
providerItemId: callbackOptions.toolUseID,
}),
raw: {
source: "claude.sdk.permission",
method: "canUseTool/AskUserQuestion/resolved",
payload: { answers },
},
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing resolved after abort then stop

Medium Severity

When an AskUserQuestion wait is cancelled via the SDK AbortSignal, onAbort removes the request from pendingUserInputs before session teardown runs. If stopSessionInternal then sets context.stopped with an empty map, no user-input.resolved is emitted, and the handler skips emission for cancelled resolutions while stopped—so projected threads can keep a stale user-input prompt.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f7f07cf. Configure here.


if (aborted) {
if (resolution._tag === "cancelled") {
return {
behavior: "deny",
message: "User cancelled tool execution.",
Expand Down Expand Up @@ -3799,7 +3838,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}

context.pendingUserInputs.delete(requestId);
yield* Deferred.succeed(pending.answers, answers);
const resolution: PendingUserInputResolution = { _tag: "answered", answers };
yield* Deferred.succeed(pending.resolution, resolution);
});

const stopSession: ClaudeAdapterShape["stopSession"] = Effect.fn("stopSession")(
Expand Down
Loading