diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..004078c4a29c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -28,6 +29,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { rememberClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { checkClaudeProviderStatus, @@ -163,12 +165,14 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const lastGoodSlashCommands = yield* Ref.make([]); const checkProvider = checkClaudeProviderStatus( effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, cwd, ).pipe( + Effect.flatMap((snapshot) => rememberClaudeSlashCommands(lastGoodSlashCommands, snapshot)), Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts new file mode 100644 index 000000000000..1fe3266ace4e --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ServerProviderSlashCommand } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { rememberClaudeSlashCommands, retainClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; + +const review: ServerProviderSlashCommand = { + name: "review", + description: "Review the diff", +}; +const commit: ServerProviderSlashCommand = { + name: "commit", + description: "Commit changes", +}; + +describe("retainClaudeSlashCommands", () => { + it("keeps a populated probe result", () => { + expect(retainClaudeSlashCommands([review, commit], [review], true)).toEqual([review, commit]); + }); + + it("reuses the previous list when a failed probe is empty", () => { + expect(retainClaudeSlashCommands([], [review, commit], true)).toEqual([review, commit]); + }); + + it("keeps a successful empty probe instead of stale commands", () => { + expect(retainClaudeSlashCommands([], [review, commit], false)).toEqual([]); + }); + + it("passes through empty when there is no previous list", () => { + expect(retainClaudeSlashCommands([], [], true)).toEqual([]); + }); +}); + +describe("rememberClaudeSlashCommands", () => { + it.effect("keeps the snapshot empty after a successful empty probe then a failed probe", () => + Effect.gen(function* () { + const lastGoodSlashCommands = yield* Ref.make>([]); + yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { + status: "ready", + slashCommands: [review, commit], + }); + yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { + status: "ready", + slashCommands: [], + }); + const snapshot = yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { + status: "error", + slashCommands: [], + }); + + expect(snapshot.slashCommands).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts b/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts new file mode 100644 index 000000000000..0ae6895cc56e --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts @@ -0,0 +1,40 @@ +import type { ServerProviderSlashCommand } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +/** + * A failed Claude capability probe must not wipe a previously discovered + * slash-command list (#7111). A successful empty probe is kept as-is so + * cleared commands leave the `/` menu, and the last-good cache is cleared + * with them so a later failed probe cannot resurrect deleted commands. + */ +export function retainClaudeSlashCommands( + incoming: ReadonlyArray, + previous: ReadonlyArray, + reusePreviousOnEmpty: boolean, +): ReadonlyArray { + if (incoming.length > 0) { + return incoming; + } + return reusePreviousOnEmpty && previous.length > 0 ? previous : incoming; +} + +export const rememberClaudeSlashCommands = < + Snapshot extends { + readonly status: string; + readonly slashCommands: ReadonlyArray; + }, +>( + lastGoodSlashCommands: Ref.Ref>, + snapshot: Snapshot, +): Effect.Effect => + Effect.gen(function* () { + const previous = yield* Ref.get(lastGoodSlashCommands); + const slashCommands = retainClaudeSlashCommands( + snapshot.slashCommands, + previous, + snapshot.status !== "ready", + ); + yield* Ref.set(lastGoodSlashCommands, slashCommands); + return slashCommands === snapshot.slashCommands ? snapshot : { ...snapshot, slashCommands }; + });