Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
19 changes: 8 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
checkClaudeProviderStatus,
makePendingClaudeProvider,
probeClaudeCapabilities,
probeClaudeWorkspaceSnapshot,
} from "../Layers/ClaudeProvider.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import * as ModelManifest from "../ModelManifest.ts";
Expand Down Expand Up @@ -69,7 +70,6 @@ import {
makeClaudeContinuationGroupKey,
resolveClaudeHomePath,
} from "./ClaudeHome.ts";
import { discoverClaudeSkills } from "./ClaudeSkills.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
Expand Down Expand Up @@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
snapshot,
invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
snapshotForCwd: (cwd: string) =>
!effectiveConfig.enabled
? snapshot.getSnapshot
: Effect.all([
snapshot.getSnapshot,
discoverClaudeSkills(effectiveConfig, cwd, processEnv),
]).pipe(
Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
snapshot.getSnapshot.pipe(
Effect.flatMap((machineSnapshot) =>
probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
orchestrationAdapter,
textGeneration,
consumeResetCredit,
Expand Down
162 changes: 161 additions & 1 deletion apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { vi } from "vite-plus/test";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import { ClaudeSettings } from "@t3tools/contracts";
import {
ClaudeSettings,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
} from "@t3tools/contracts";
import * as NodeFSP from "node:fs/promises";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
Expand All @@ -17,7 +22,9 @@ import {
buildClaudeCapabilitiesProbeQueryOptions,
CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
probeClaudeCapabilities,
probeClaudeWorkspaceSnapshot,
} from "./ClaudeProvider.ts";
import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";

vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });

Expand Down Expand Up @@ -53,6 +60,159 @@ it("isolates Claude capability probes without dropping workspace setting sources
});

it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
it.effect(
"discovers commands and skills separately for each cwd without replacing machine metadata",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" });
const configDir = path.join(tempDir, "claude-home");
const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")];
for (const cwd of workspaces) {
const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
yield* fs.makeDirectory(skillDir, { recursive: true });
yield* fs.writeFileString(
path.join(skillDir, "SKILL.md"),
"---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
);
}
const machineSnapshot = {
instanceId: ProviderInstanceId.make("claude"),
driver: ProviderDriverKind.make("claudeAgent"),
enabled: true,
installed: true,
status: "ready",
auth: { status: "authenticated", email: "machine@example.com" },
checkedAt: "2026-03-25T00:00:00.000Z",
version: "2.1.288",
models: [],
slashCommands: [{ name: "server-cwd-only" }],
skills: [],
} satisfies ServerProvider;
let usageCalls = 0;
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir);
assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance");
return {
initializationResult: async () => ({
account: { email: "workspace@example.com" },
commands: [
{
name: options?.cwd === workspaces[0] ? "start-session" : "other-project",
description: "Project command",
argumentHint: "[topic]",
},
{ name: "nested:review", description: "Review changes", argumentHint: "" },
{ name: "NESTED:review", description: "", argumentHint: "[path]" },
{ name: "compact", description: "Provider compact", argumentHint: "" },
{ name: "user-command", description: "Existing user command", argumentHint: "" },
],
}),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => {
usageCalls++;
return { rate_limits_available: false, rate_limits: null };
},
} as ReturnType<typeof ClaudeSdk.query>;
});
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
for (const [index, cwd] of workspaces.entries()) {
const scoped = yield* probeClaudeWorkspaceSnapshot(
decodeClaudeSettings({ homePath: configDir }),
machineSnapshot,
cwd,
{ ...process.env, T3_WORKSPACE_PROBE: "owned-instance" },
);
assert.deepEqual(scoped, {
...machineSnapshot,
slashCommands: [
COMPACT_SLASH_COMMAND,
{
name: index === 0 ? "start-session" : "other-project",
description: "Project command",
input: { hint: "[topic]" },
},
{ name: "nested:review", description: "Review changes", input: { hint: "[path]" } },
{ name: "user-command", description: "Existing user command" },
],
skills: [
{
name: "existing-skill",
path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"),
enabled: true,
scope: "project",
description: "Existing project skill",
},
],
});
}
assert.deepEqual(
query.mock.calls.map(([input]) => input.options?.cwd),
workspaces,
);
assert.equal(usageCalls, 0);
}).pipe(Effect.scoped),
);

it.effect("keeps failed workspace probes retryable and skips disabled providers", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" });
const machineSnapshot = {
instanceId: ProviderInstanceId.make("claude"),
driver: ProviderDriverKind.make("claudeAgent"),
enabled: true,
installed: true,
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-03-25T00:00:00.000Z",
version: "2.1.288",
models: [],
slashCommands: [COMPACT_SLASH_COMMAND],
skills: [],
} satisfies ServerProvider;
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(
() =>
({
initializationResult: () =>
Promise.reject<ClaudeSdk.SDKControlInitializeResponse>(
new Error("Initialization failed"),
),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
rate_limits_available: false,
rate_limits: null,
}),
}) as ReturnType<typeof ClaudeSdk.query>,
);
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
const settings = decodeClaudeSettings({ homePath: cwd });
const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
assert.deepEqual(failed, { ...machineSnapshot, status: "error" });
query.mockImplementation(
() =>
({
initializationResult: async () => ({
commands: [{ name: "recovered", description: "", argumentHint: "" }],
}),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
rate_limits_available: false,
rate_limits: null,
}),
}) as ReturnType<typeof ClaudeSdk.query>,
);
const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]);
assert.equal(recovered.status, "ready");
const disabled = yield* probeClaudeWorkspaceSnapshot(
{ ...settings, enabled: false },
machineSnapshot,
cwd,
);
assert.equal(disabled, machineSnapshot);
assert.equal(query.mock.calls.length, 2);
}).pipe(Effect.scoped),
);

it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
42 changes: 34 additions & 8 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type ClaudeSettings,
type ModelCapabilities,
type ServerProvider,
type ServerProviderSlashCommand,
type ServerProviderResetCredits,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -333,6 +334,7 @@ const probeClaudeCapabilities = (
claudeSettings: ClaudeSettings,
environment?: NodeJS.ProcessEnv,
cwd?: string,
includeUsage = true,
) => {
const abort = new AbortController();
return Effect.gen(function* () {
Expand Down Expand Up @@ -364,15 +366,18 @@ const probeClaudeCapabilities = (
Effect.flatMap(({ q, init }) =>
Effect.gen(function* () {
// Usage has its own deadline so a slow optional request cannot discard initialization.
const usageResult = yield* Effect.tryPromise(() =>
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result);
const usage = Result.isSuccess(usageResult)
? {
rate_limits_available: usageResult.success.rate_limits_available,
rate_limits: usageResult.success.rate_limits,
}
const usageResult = includeUsage
? yield* Effect.tryPromise(() =>
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result)
: undefined;
const usage =
usageResult && Result.isSuccess(usageResult)
? {
rate_limits_available: usageResult.success.rate_limits_available,
rate_limits: usageResult.success.rate_limits,
}
: undefined;
const account = init.account as
| {
readonly email?: string;
Expand Down Expand Up @@ -417,6 +422,27 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
return yield* spawnAndCollect(claudeSettings.binaryPath, command);
});

/** Read commands from the same cwd Claude uses for a workspace session. */
export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* (
claudeSettings: ClaudeSettings,
machineSnapshot: ServerProvider,
cwd: string,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<ServerProvider, never, FileSystem.FileSystem | Path.Path> {
if (!claudeSettings.enabled) return machineSnapshot;
const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false);
if (!capabilities) {
// The registry skips failed workspace snapshots, leaving discovery retryable.
return { ...machineSnapshot, status: "error" };
}
const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment);
return {
...machineSnapshot,
skills,
slashCommands: dedupeSlashCommands([COMPACT_SLASH_COMMAND, ...capabilities.slashCommands]),
};
});

export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
claudeSettings: ClaudeSettings,
resolveCapabilities?: (
Expand Down
Loading