diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 95432c40e688..53178c20fb1b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1991,6 +1991,81 @@ describe("ClaudeAdapterV2 resume compaction", () => { }); assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "compact" }); + assert.deepEqual(openedOptions?.supportedDialogKinds, [ + "resume_return", + "refusal_fallback_prompt", + ]); + const answerRefusalDialog = (requestId: string, answer: string | null) => + Effect.gen(function* () { + const refusalRequestEvent = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.type === "runtime_request.updated" && + event.runtimeRequest.status === "pending", + ), + Stream.runCollect, + Effect.forkScoped, + ); + // @effect-diagnostics-next-line abortControllerInEffect:off - The SDK cancels this dialog independently of the test fiber. + const controller = new AbortController(); + const refusalDialog = yield* Effect.promise(() => + callback!( + { + dialogKind: "refusal_fallback_prompt", + payload: { + originalModel: "claude-opus-5-5", + fallbackModel: "claude-opus-4-8", + apiRefusalCategory: "cyber", + }, + }, + { signal: controller.signal, requestId }, + ), + ).pipe(Effect.forkScoped); + const refusalEvents = yield* Fiber.join(refusalRequestEvent); + assert.isFalse( + refusalEvents.some( + (event) => event.type === "node.updated" && event.node.kind === "tool_call", + ), + ); + const refusalEvent = refusalEvents.find( + (event) => event.type === "runtime_request.updated", + ); + if (refusalEvent?.type !== "runtime_request.updated") { + return yield* Effect.die("missing refusal question"); + } + if (answer === null) { + controller.abort(); + } else { + yield* runtime.respondToRuntimeRequest({ + requestId: refusalEvent.runtimeRequest.id, + answers: { "Claude Opus 5.5's safeguards flagged this message.": answer }, + }); + } + return yield* Fiber.join(refusalDialog); + }); + assert.deepEqual( + yield* answerRefusalDialog("dialog-refusal-1", "Switch to Claude Opus 4.8"), + { behavior: "completed", result: "retry_fallback" }, + ); + for (const [requestId, answer] of [ + ["dialog-refusal-stop", "Stop"], + ["dialog-refusal-custom", "Do something else"], + ["dialog-refusal-aborted", null], + ] as const) { + assert.deepEqual(yield* answerRefusalDialog(requestId, answer), { + behavior: "cancelled", + }); + } + assert.deepEqual( + yield* Effect.promise(() => + callback!( + { dialogKind: "refusal_fallback_prompt", payload: {} }, + { signal: AbortSignal.abort(), requestId: "dialog-refusal-invalid" }, + ), + ), + { behavior: "cancelled" }, + ); + assert.deepEqual( yield* Effect.promise(() => callback!( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3e10e0990679..cd6fda3a218d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -83,7 +83,7 @@ import { } from "@t3tools/shared/mcpApp"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, formatModelSlugName } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, @@ -11779,12 +11779,67 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( callbackOptions, ) => runPromise(onElicitationEffect(request, callbackOptions)); + const askUserDialog = Effect.fnUntraced(function* ( + request: Parameters>[0], + callbackOptions: Parameters>[1], + question: { + readonly header: string; + readonly question: string; + readonly options: ReadonlyArray<{ + readonly label: string; + readonly description: string; + }>; + }, + ) { + const result = yield* canUseToolEffect( + "AskUserQuestion", + { questions: [{ ...question, multiSelect: false }] }, + { + signal: callbackOptions.signal, + requestId: callbackOptions.requestId, + toolUseID: request.toolUseID ?? callbackOptions.requestId, + }, + // The dialog is a control request, not a tool call, so no + // tool_result would ever settle a tool row started for it. + { startToolCall: false }, + ); + if (result.behavior !== "allow") return null; + const answers = + result.updatedInput === undefined + ? undefined + : Reflect.get(result.updatedInput, "answers"); + return typeof answers === "object" && answers !== null + ? Reflect.get(answers, question.question) + : undefined; + }); + const onUserDialog: NonNullable = ( request, callbackOptions, ) => runPromise( Effect.gen(function* () { + if (request.dialogKind === "refusal_fallback_prompt") { + const { originalModel, fallbackModel } = request.payload; + if (typeof originalModel !== "string" || typeof fallbackModel !== "string") { + return { behavior: "cancelled" as const }; + } + const switchLabel = `Switch to ${formatModelSlugName(fallbackModel)}`; + const selection = yield* askUserDialog(request, callbackOptions, { + header: "Message flagged", + question: `${formatModelSlugName(originalModel)}'s safeguards flagged this message.`, + options: [ + { + label: switchLabel, + description: "Send the same message again on the fallback model.", + }, + { label: "Stop", description: "End the turn without retrying." }, + ], + }); + return selection === switchLabel + ? { behavior: "completed" as const, result: "retry_fallback" as const } + : { behavior: "cancelled" as const }; + } if (request.dialogKind !== "resume_return") { return { behavior: "cancelled" as const }; } @@ -11809,53 +11864,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Number.isFinite(request.payload.estimatedTokens) ? Math.max(0, Math.floor(request.payload.estimatedTokens)) : 0; - const question = formatClaudeResumeCompactionQuestion({ - ageMinutes, - estimatedTokens, + const selection = yield* askUserDialog(request, callbackOptions, { + header: "Resume session", + question: formatClaudeResumeCompactionQuestion({ ageMinutes, estimatedTokens }), + options: [ + { + label: CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, + description: "Resume with a summary and use fewer tokens.", + }, + { + label: "Keep full history", + description: "Resume without changing the conversation.", + }, + { + label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + description: "Keep full history and skip future resume prompts.", + }, + ], }); - const result = yield* canUseToolEffect( - "AskUserQuestion", - { - questions: [ - { - header: "Resume session", - question, - options: [ - { - label: CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, - description: "Resume with a summary and use fewer tokens.", - }, - { - label: "Keep full history", - description: "Resume without changing the conversation.", - }, - { - label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, - description: "Keep full history and skip future resume prompts.", - }, - ], - multiSelect: false, - }, - ], - }, - { - signal: callbackOptions.signal, - requestId: callbackOptions.requestId, - toolUseID: request.toolUseID ?? callbackOptions.requestId, - }, - // The dialog is a control request, not a tool call, so no - // tool_result would ever settle a tool row started for it. - { startToolCall: false }, - ); - if (result.behavior !== "allow") return { behavior: "cancelled" as const }; - const answers = - result.updatedInput === undefined - ? undefined - : Reflect.get(result.updatedInput, "answers"); - const selection = - typeof answers === "object" && answers !== null - ? Reflect.get(answers, question) - : undefined; + if (selection === null) return { behavior: "cancelled" as const }; return { behavior: "completed" as const, result: @@ -12080,7 +12107,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( canUseTool, onUserDialog, onElicitation, - supportedDialogKinds: ["resume_return"], + supportedDialogKinds: ["resume_return", "refusal_fallback_prompt"], ...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }), ...(toolSearchOverride === undefined ? {} : { toolSearchOverride }), });