diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 17aeff2d0e3..23d3cb00294 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -25,6 +25,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Random from "effect/Random"; @@ -221,6 +222,55 @@ function makeDeterministicRandomService(seed = 0x1234_5678): { }; } +const beginPendingAskUserQuestion = Effect.fn("beginPendingAskUserQuestion")(function* ( + adapter: ClaudeAdapterShape, + harness: ReturnType, + options: { + readonly signal: AbortSignal; + readonly toolUseID: string; + }, +) { + 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 canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + assert.fail("Expected Claude canUseTool callback"); + } + + const permissionPromise = canUseTool( + "AskUserQuestion", + { + questions: [ + { + question: "Which path should we take?", + header: "Path", + options: [{ label: "A", description: "First path" }], + multiSelect: false, + }, + ], + }, + options, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") { + assert.fail("Expected user-input.requested event"); + } + + return { + session, + permissionPromise, + requested: requested.value, + }; +}); + async function readFirstPromptText( input: | { @@ -3688,6 +3738,270 @@ 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("resolves an AskUserQuestion exactly once when abort races session stop", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const controller = new AbortController(); + const pending = yield* beginPendingAskUserQuestion(adapter, harness, { + signal: controller.signal, + toolUseID: "tool-ask-abort-stop", + }); + + controller.abort(); + yield* adapter.stopSession(pending.session.threadId); + + const events = yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runCollect); + assert.deepEqual( + Array.from(events, (event) => event.type), + ["user-input.resolved", "session.exited"], + ); + const resolved = events[0]; + assert.equal(resolved?.type, "user-input.resolved"); + if (resolved?.type === "user-input.resolved") { + assert.equal(resolved.requestId, pending.requested.requestId); + assert.deepEqual(resolved.payload.answers, {}); + assert.deepEqual(resolved.providerRefs, { + providerItemId: ProviderItemId.make("tool-ask-abort-stop"), + }); + } + + assert.deepEqual(yield* Effect.promise(() => pending.permissionPromise), { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("rejects AskUserQuestion callbacks after the session has stopped", () => { + 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 canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + assert.fail("Expected Claude canUseTool callback"); + } + + yield* adapter.stopSession(session.threadId); + const exited = yield* Stream.runHead(adapter.streamEvents); + assert.equal(exited._tag, "Some"); + if (exited._tag === "Some") { + assert.equal(exited.value.type, "session.exited"); + } + + const result = yield* Effect.promise(() => + 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-after-stop", + }, + ), + ); + + assert.deepEqual(result, { + behavior: "deny", + message: "Claude session context is unavailable or stopped.", + } satisfies PermissionResult); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect.each([ + { exitKind: "normal" as const, expectedTypes: ["user-input.resolved", "session.exited"] }, + { + exitKind: "crash" as const, + expectedTypes: ["runtime.error", "turn.completed", "user-input.resolved", "session.exited"], + }, + ])("settles a pending AskUserQuestion on provider $exitKind", ({ exitKind, expectedTypes }) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const pending = yield* beginPendingAskUserQuestion(adapter, harness, { + signal: new AbortController().signal, + toolUseID: `tool-ask-provider-${exitKind}`, + }); + + if (exitKind === "crash") { + harness.query.fail(new Error("provider crashed")); + } else { + harness.query.finish(); + } + + const events = yield* Stream.take(adapter.streamEvents, expectedTypes.length).pipe( + Stream.runCollect, + ); + assert.deepEqual( + Array.from(events, (event) => event.type), + expectedTypes, + ); + assert.equal( + Array.from(events).filter((event) => event.type === "user-input.resolved").length, + 1, + ); + assert.equal(yield* adapter.hasSession(pending.session.threadId), false); + assert.deepEqual(yield* Effect.promise(() => pending.permissionPromise), { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("rejects duplicate user-input responses without publishing twice", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const pending = yield* beginPendingAskUserQuestion(adapter, harness, { + signal: new AbortController().signal, + toolUseID: "tool-ask-duplicate", + }); + const requestId = ApprovalRequestId.make(pending.requested.requestId!); + const answers = { "Which path should we take?": "A" }; + + yield* adapter.respondToUserInput(pending.session.threadId, requestId, answers); + const duplicateExit = yield* Effect.exit( + adapter.respondToUserInput(pending.session.threadId, requestId, answers), + ); + assert.equal(Exit.isFailure(duplicateExit), true); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag === "Some") { + assert.equal(resolved.value.type, "user-input.resolved"); + } + const permissionResult = yield* Effect.promise(() => pending.permissionPromise); + assert.equal(permissionResult.behavior, "allow"); + + yield* adapter.stopSession(pending.session.threadId); + const exited = yield* Stream.runHead(adapter.streamEvents); + assert.equal(exited._tag, "Some"); + if (exited._tag === "Some") { + assert.equal(exited.value.type, "session.exited"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("settles pending user input during adapter-wide cleanup", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const pending = yield* beginPendingAskUserQuestion(adapter, harness, { + signal: new AbortController().signal, + toolUseID: "tool-ask-stop-all", + }); + yield* adapter.stopAll(); + + const events = yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runCollect); + assert.deepEqual( + Array.from(events, (event) => event.type), + ["user-input.resolved", "session.exited"], + ); + assert.deepEqual(yield* Effect.promise(() => pending.permissionPromise), { + 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?: { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f6e63eeffad..85b239f169f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -156,9 +156,22 @@ interface PendingApproval { interface PendingUserInput { readonly questions: ReadonlyArray; - readonly answers: Deferred.Deferred; + readonly resolution: Deferred.Deferred; + readonly requested: Deferred.Deferred; + readonly published: Deferred.Deferred; + readonly providerItemId?: string; } +type PendingUserInputResolution = + | { + readonly _tag: "answered"; + readonly answers: ProviderUserInputAnswers; + } + | { + readonly _tag: "cancelled"; + readonly answers: ProviderUserInputAnswers; + }; + interface ToolInFlight { readonly itemId: string; readonly itemType: CanonicalItemType; @@ -2951,6 +2964,56 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); }); + const settlePendingUserInput = Effect.fn("settlePendingUserInput")(function* ( + context: ClaudeSessionContext, + requestId: ApprovalRequestId, + pending: PendingUserInput, + resolution: PendingUserInputResolution, + ) { + const didSettle = yield* Deferred.succeed(pending.resolution, resolution); + if (!didSettle) { + // Another terminal path won the race. Wait until it publishes the + // matching resolved event so session teardown cannot overtake it. + yield* Deferred.await(pending.published); + return false; + } + + // A teardown may race the request callback between registration and + // publication. Preserve requested -> resolved ordering for projections. + yield* Deferred.await(pending.requested); + yield* Effect.gen(function* () { + 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: resolution.answers }, + providerRefs: nativeProviderRefs(context, { + providerItemId: pending.providerItemId, + }), + raw: { + source: "claude.sdk.permission", + method: + resolution._tag === "answered" + ? "canUseTool/AskUserQuestion/resolved" + : "canUseTool/AskUserQuestion/cancelled", + payload: { answers: resolution.answers }, + }, + }); + }).pipe( + Effect.ensuring( + Effect.sync(() => context.pendingUserInputs.delete(requestId)).pipe( + Effect.andThen(Deferred.succeed(pending.published, undefined)), + ), + ), + ); + return true; + }, Effect.uninterruptible); + const stopSessionInternal = Effect.fn("stopSessionInternal")(function* ( context: ClaudeSessionContext, options?: { readonly emitExitEvent?: boolean }, @@ -2979,6 +3042,13 @@ 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* settlePendingUserInput(context, requestId, pending, resolution); + } + context.pendingUserInputs.clear(); + if (context.turnState) { yield* completeTurn(context, "interrupted", "Session stopped."); } @@ -3155,86 +3225,86 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }), ); - const answersDeferred = yield* Deferred.make(); - let aborted = false; + const resolutionDeferred = yield* Deferred.make(); + const requestedDeferred = yield* Deferred.make(); + const publishedDeferred = yield* Deferred.make(); const pendingInput: PendingUserInput = { questions, - answers: answersDeferred, + resolution: resolutionDeferred, + requested: requestedDeferred, + published: publishedDeferred, + ...(callbackOptions.toolUseID ? { providerItemId: callbackOptions.toolUseID } : {}), }; // Emit user-input.requested so the UI can present the questions. const requestedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "user-input.requested", - eventId: requestedStamp.eventId, - provider: PROVIDER, - createdAt: requestedStamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState - ? { - turnId: asCanonicalTurnId(context.turnState.turnId), - } - : {}), - requestId: asRuntimeRequestId(requestId), - payload: { questions }, - providerRefs: nativeProviderRefs(context, { - providerItemId: callbackOptions.toolUseID, - }), - raw: { - source: "claude.sdk.permission", - method: "canUseTool/AskUserQuestion", - payload: { - toolName: "AskUserQuestion", - input: toolInput, - }, - }, - }); + const didRegister = yield* Effect.gen(function* () { + const registered = yield* Effect.sync(() => { + if (context.stopped || callbackOptions.signal.aborted) { + return false; + } + pendingUserInputs.set(requestId, pendingInput); + return true; + }); + if (!registered) { + return false; + } - pendingUserInputs.set(requestId, pendingInput); + yield* offerRuntimeEvent({ + type: "user-input.requested", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { questions }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/AskUserQuestion", + payload: { + toolName: "AskUserQuestion", + input: toolInput, + }, + }, + }).pipe(Effect.ensuring(Deferred.succeed(requestedDeferred, undefined))); + return true; + }).pipe(Effect.uninterruptible); + if (!didRegister) { + return { + behavior: "deny", + message: "Claude session context is unavailable or stopped.", + } satisfies PermissionResult; + } // Handle abort (e.g. turn interrupted while waiting for user input). const onAbort = () => { - 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(settlePendingUserInput(context, requestId, pendingInput, resolution)); }; callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + if (callbackOptions.signal.aborted) { + onAbort(); + } // Block until the user provides answers. - const answers = yield* Deferred.await(answersDeferred); - 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", - payload: { answers }, - }, - }); + const resolution = yield* Deferred.await(resolutionDeferred); + const answers = resolution.answers; + callbackOptions.signal.removeEventListener("abort", onAbort); - if (aborted) { + if (resolution._tag === "cancelled") { return { behavior: "deny", message: "User cancelled tool execution.", @@ -3258,10 +3328,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions: Parameters[2], ) { const context = yield* Ref.get(contextRef); - if (!context) { + if (!context || context.stopped) { return { behavior: "deny", - message: "Claude session context is unavailable.", + message: "Claude session context is unavailable or stopped.", } satisfies PermissionResult; } @@ -3803,8 +3873,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - context.pendingUserInputs.delete(requestId); - yield* Deferred.succeed(pending.answers, answers); + const resolution: PendingUserInputResolution = { _tag: "answered", answers }; + const didSettle = yield* settlePendingUserInput(context, requestId, pending, resolution); + if (!didSettle) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/respondToUserInput", + detail: `User-input request already resolved: ${requestId}`, + }); + } }); const stopSession: ClaudeAdapterShape["stopSession"] = Effect.fn("stopSession")( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..9c8f534fe50 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1867,8 +1867,8 @@ function ChatViewContent(props: ChatViewProps) { [threadActivities], ); const pendingUserInputs = useMemo( - () => derivePendingUserInputs(threadActivities), - [threadActivities], + () => derivePendingUserInputs(threadActivities, activeThread?.session?.status), + [activeThread?.session?.status, threadActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..d0640cfbddc 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -307,6 +307,33 @@ describe("derivePendingUserInputs", () => { expect(derivePendingUserInputs(activities)).toEqual([]); }); + + it("clears a persisted prompt when its provider session has stopped", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open-before-provider-stop", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-provider-stop", + questions: [ + { + id: "continue", + header: "Continue", + question: "Continue?", + options: [{ label: "yes", description: "Continue execution" }], + multiSelect: false, + }, + ], + }, + }), + ]; + + expect(derivePendingUserInputs(activities, "running")).toHaveLength(1); + expect(derivePendingUserInputs(activities, "stopped")).toEqual([]); + }); }); describe("deriveActivePlanState", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..72f073304d9 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -4,6 +4,7 @@ import { ApprovalRequestId, isToolLifecycleItemType, type OrchestrationLatestTurn, + type OrchestrationSessionStatus, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, ProviderDriverKind, @@ -460,7 +461,14 @@ function parseUserInputQuestions( export function derivePendingUserInputs( activities: ReadonlyArray, + sessionStatus?: OrchestrationSessionStatus | null, ): PendingUserInput[] { + // A stopped provider cannot service a persisted callback. This also clears + // prompts left behind by older builds that did not project a resolved event. + if (sessionStatus === "stopped") { + return []; + } + const openByRequestId = new Map(); const ordered = [...activities].toSorted(compareActivitiesByOrder);