Skip to content
Source
Sign inSign up
Open History to see the last commit

ClaudeCapabilitiesProbe.test.ts

90 lines3.8 KB

1// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts";
4import { vi } from "vite-plus/test";
5import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
6import { assert, it } from "@effect/vitest";
7import * as HostProcess from "@t3tools/shared/HostProcess";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Schema from "effect/Schema";
11import * as TestClock from "effect/testing/TestClock";
12​
13import {
14 buildClaudeCapabilitiesProbeQueryOptions,
15 probeClaudeCapabilities,
16 probeClaudeWorkspaceSnapshot,
17} from "./ClaudeProvider.ts";
18​
19vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
20​
21const decodeClaudeSettings = Schema.decodeEffect(ClaudeSettings);
22​
23it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => {
24 const options = buildClaudeCapabilitiesProbeQueryOptions({
25 executablePath: "/usr/bin/claude",
26 abortController: new AbortController(),
27 environment: { HOME: "/home/user" },
28 cwd: undefined,
29 managedMcpConfig: true,
30 });
31​
32 // Claude exits before initializing when handed --strict-mcp-config here.
33 assert.equal(options.strictMcpConfig, undefined);
34 assert.deepEqual(options.mcpServers, {});
35 assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
36});
37​
38it.effect("reports a deleted worktree's scan as finished, not pending", () =>
39 Effect.gen(function* () {
40 const snapshot = yield* probeClaudeWorkspaceSnapshot(
41 yield* decodeClaudeSettings({}),
42 { skills: [], slashCommands: [] } as unknown as ServerProvider,
43 "/nonexistent/t3-deleted-worktree",
44 { HOME: "/nonexistent/t3-home" },
45 );
46 assert.strictEqual(snapshot.slashCommandsPending, false);
47 }).pipe(Effect.provide(NodeServices.layer)),
48);
49​
50it.effect("keeps the probe subprocess alive while gateway model discovery can finish", () =>
51 Effect.gen(function* () {
52 let abortSignal: AbortSignal | undefined;
53 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
54 abortSignal = options?.abortController?.signal;
55 return {
56 initializationResult: async () => ({
57 account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
58 commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
59 models: [{ value: "gateway/claude-relay", displayName: "Relay", description: "" }],
60 }),
61 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () =>
62 new Promise((_, reject) => reject(new Error("usage unavailable"))),
63 } as ReturnType<typeof ClaudeSdk.query>;
64 });
65 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
66 const fileSystem = yield* FileSystem.FileSystem;
67 const configDir = yield* fileSystem.makeTempDirectoryScoped();
68 // Claude Code also reads these from settings.json, which is where gateway users set them.
69 yield* fileSystem.writeFileString(
70 `${configDir}/settings.json`,
71 JSON.stringify({
72 env: {
73 ANTHROPIC_BASE_URL: "https://gateway.example",
74 CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
75 },
76 }),
77 );
78 const capabilities = yield* probeClaudeCapabilities(
79 yield* decodeClaudeSettings({ binaryPath: "claude" }),
80 { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir },
81 );
82 assert.deepEqual(
83 capabilities.models?.map((model) => model.value),
84 ["gateway/claude-relay"],
85 );
86 assert.equal(abortSignal?.aborted, false);
87 yield* TestClock.adjust("3 seconds");
88 assert.equal(abortSignal?.aborted, true);
89 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
90);