Skip to content
Merged
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
175 changes: 175 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("passes the configured auto-compaction window to Claude", () => {
const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } });
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

const options = harness.getLastCreateQueryInput()?.options;
assert.deepEqual(options?.settings, { autoCompactWindow: 300000 });
assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("forwards claude effort levels into query options", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down Expand Up @@ -730,6 +749,39 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("keeps compact commands intact when ultrathink is selected", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const modelSelection = createModelSelection(
ProviderInstanceId.make("claudeAgent"),
"claude-sonnet-4-6",
[{ id: "effort", value: "ultrathink" }],
);
const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
modelSelection,
runtimeMode: "full-access",
});

yield* adapter.sendTurn({
threadId: session.threadId,
input: "/compact",
attachments: [],
modelSelection,
});

const promptText = yield* Effect.promise(() =>
readFirstPromptText(harness.getLastCreateQueryInput()),
);
assert.equal(promptText, "/compact");
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("embeds image attachments in Claude user messages", () => {
const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-"));
const harness = makeHarness({
Expand Down Expand Up @@ -4400,6 +4452,62 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("routes Claude resume compaction through the shared user-input UI", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const session = yield* adapter.startSession({
threadId: RESUME_THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" },
runtimeMode: "full-access",
});
yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);

const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog;
assert.equal(typeof onUserDialog, "function");
if (!onUserDialog) return;

const dialogPromise = onUserDialog(
{
dialogKind: "resume_return",
payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 },
},
{ signal: new AbortController().signal },
);

const requested = yield* Stream.runHead(adapter.streamEvents);
assert.equal(requested._tag, "Some");
if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return;
const question = requested.value.payload.questions[0];
assert.equal(question?.header, "Resume session");
assert.match(question?.question ?? "", /2h 25m/);
assert.match(question?.question ?? "", /275,123 tokens/);
assert.deepEqual(
question?.options.map((option) => option.label),
["Compact and continue", "Keep full history", "Don't ask again"],
);
if (!question || !requested.value.requestId) return;

yield* adapter.respondToUserInput(
session.threadId,
ApprovalRequestId.make(requested.value.requestId),
{ [question.id]: "Compact and continue" },
);

const resolved = yield* Stream.runHead(adapter.streamEvents);
assert.equal(resolved._tag, "Some");
if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved");
assert.deepEqual(yield* Effect.promise(() => dialogPromise), {
behavior: "completed",
result: "compact",
});
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down Expand Up @@ -4689,6 +4797,73 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

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) {
return;
}

const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe(
Stream.runCollect,
Effect.forkChild,
);

// Abort before the call so the adapter's listener registration can
// never observe the abort event, only the recheck can.
const controller = new AbortController();
controller.abort();
const permissionPromise = canUseTool(
"AskUserQuestion",
{
questions: [
{
question: "Continue?",
header: "Continue",
options: [{ label: "Yes", description: "Proceed" }],
multiSelect: false,
},
],
},
{
signal: controller.signal,
toolUseID: "tool-ask-pre-aborted",
},
);

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

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
assert.deepEqual(
runtimeEvents.map((event) => event.type),
["user-input.requested", "user-input.resolved"],
);
const resolvedEvent = runtimeEvents[1];
if (resolvedEvent?.type === "user-input.resolved") {
assert.deepEqual(resolvedEvent.payload.answers, {});
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("stopping a session settles pending user-input waits", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
100 changes: 100 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ import {
getProviderOptionDescriptors,
resolvePromptInjectedEffort,
} from "@t3tools/shared/model";
import {
CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
formatClaudeResumeCompactionQuestion,
} from "@t3tools/shared/claudeCompaction";
import * as Cause from "effect/Cause";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -543,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: {
readonly totalProcessedTokens?: number;
readonly lastUsedTokens?: number;
readonly compactsAutomatically?: boolean;
readonly autoCompactThreshold?: number;
}): ThreadTokenUsageSnapshot | undefined {
const activeTokens = finiteNonNegativeInteger(input.activeTokens);
if (activeTokens === undefined || activeTokens <= 0) {
Expand Down Expand Up @@ -570,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: {
...(input.compactsAutomatically !== undefined
? { compactsAutomatically: input.compactsAutomatically }
: {}),
...(input.autoCompactThreshold !== undefined
? { autoCompactThreshold: input.autoCompactThreshold }
: {}),
};
}

Expand Down Expand Up @@ -604,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot(
value: SDKControlGetContextUsageResponse,
totalProcessedTokens?: number,
): ThreadTokenUsageSnapshot | undefined {
const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold);
return makeClaudeTokenUsageSnapshot({
activeTokens: value.totalTokens,
contextWindow: value.maxTokens,
...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),
compactsAutomatically: value.isAutoCompactEnabled,
...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}),
});
}

Expand Down Expand Up @@ -3953,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
callbackOptions.signal.addEventListener("abort", onAbort, {
once: true,
});
// The signal may have aborted during the awaited event emissions
// above, before the listener existed; settle now so the dialog
// cannot hang with a lingering pending question.
if (callbackOptions.signal.aborted) {
yield* settleAsAborted;
}

// Block until the user provides answers.
const answers = yield* Deferred.await(answersDeferred);
Expand Down Expand Up @@ -4001,6 +4017,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
} satisfies PermissionResult;
});

const handleResumeDialog = Effect.fn("handleResumeDialog")(function* (
request: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[0],
callbackOptions: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[1],
) {
if (request.dialogKind !== "resume_return") {
return { behavior: "cancelled" as const };
}

const context = yield* Ref.get(contextRef);
if (!context) {
return { behavior: "cancelled" as const };
}

// The question copy lives in @t3tools/shared/claudeCompaction because
// the web client recognizes this exact text (and the "never" answer)
// to mirror a permanent dismissal.
const question = formatClaudeResumeCompactionQuestion({
ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0,
estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0,
});
const result = yield* handleAskUserQuestion(
context,
{
questions: [
{
header: "Resume session",
question,
options: [
{
label: "Compact and continue",
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,
...(request.toolUseID ? { toolUseID: request.toolUseID } : {}),
},
);

if (result.behavior !== "allow") {
return { behavior: "cancelled" as const };
}

const answers = result.updatedInput.answers;
const selection =
answers && typeof answers === "object" && !Array.isArray(answers)
? (answers as Record<string, unknown>)[question]
: undefined;
const action =
selection === "Compact and continue"
? "compact"
: selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER
? "never"
: "continue";

return { behavior: "completed" as const, result: action };
});

const canUseToolEffect = Effect.fn("canUseTool")(function* (
toolName: Parameters<CanUseTool>[0],
toolInput: Parameters<CanUseTool>[1],
Expand Down Expand Up @@ -4106,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
callbackOptions.signal.addEventListener("abort", onAbort, {
once: true,
});
// Same late-listener race as handleAskUserQuestion: the signal may
// have aborted while the request event emissions were awaited.
if (callbackOptions.signal.aborted) {
onAbort();
}

const decision = yield* Deferred.await(decisionDeferred);
pendingApprovals.delete(requestId);
Expand Down Expand Up @@ -4161,6 +4252,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (

const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
request,
callbackOptions,
) => runPromise(handleResumeDialog(request, callbackOptions));

const claudeBinaryPath = claudeSdkExecutablePath;
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
Expand Down Expand Up @@ -4196,6 +4291,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
...(fastMode ? { fastMode: true } : {}),
...(ultracode ? { ultracode: true } : {}),
...(claudeSettings.autoCompactWindow
? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) }
: {}),
};
const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
// The attachments dir grant lets the agent Read/copy pasted images at
Expand Down Expand Up @@ -4228,6 +4326,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(newSessionId ? { sessionId: newSessionId } : {}),
includePartialMessages: true,
canUseTool,
onUserDialog,
supportedDialogKinds: ["resume_return"],
env: claudeEnvironment,
additionalDirectories,
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
: undefined;
const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
const slashCommands = capabilities?.slashCommands ?? [];
const slashCommands = [
{
name: "compact",
description: "Summarize the conversation and reduce context usage",
},
...(capabilities?.slashCommands ?? []),
];
const dedupedSlashCommands = dedupeSlashCommands(slashCommands);

if (!capabilities) {
Expand Down
Loading
Loading