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
75 changes: 75 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
123 changes: 75 additions & 48 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -11779,12 +11779,67 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
callbackOptions,
) => runPromise(onElicitationEffect(request, callbackOptions));

const askUserDialog = Effect.fnUntraced(function* (
request: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[0],
callbackOptions: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[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<ClaudeQueryOptions["onUserDialog"]> = (
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 };
}
Expand All @@ -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:
Expand Down Expand Up @@ -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 }),
});
Expand Down
Loading