From 84347eade2cd1591a7ad2a9ee22f7dbc0cdc4352 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 15:32:31 +0700 Subject: [PATCH 1/5] fix(claude): keep last good slash commands when a capability probe fails A timed-out or failed Claude capability probe was cached as an empty slash-command list for five minutes and written to the provider cache. The / menu then lost every provider command until the next success. --- apps/server/src/provider/Drivers/ClaudeDriver.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..eb428238d0d3 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"; @@ -163,12 +164,26 @@ 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) => + Effect.gen(function* () { + if (snapshot.slashCommands.length > 0) { + yield* Ref.set(lastGoodSlashCommands, snapshot.slashCommands); + return snapshot; + } + const previous = yield* Ref.get(lastGoodSlashCommands); + if (previous.length === 0) { + return snapshot; + } + return { ...snapshot, slashCommands: previous }; + }), + ), Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), From 16736ef260814c136aa6999c79989fc8f80dabd6 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 15:38:58 +0700 Subject: [PATCH 2/5] test(claude): extract retainClaudeSlashCommands and cover empty-probe reuse A failed capability probe must keep the last non-empty slash-command list. The merge now lives next to the other Claude driver helpers. --- .../src/provider/Drivers/ClaudeDriver.ts | 14 ++++++------- .../Drivers/ClaudeSlashCommands.test.ts | 20 +++++++++++++++++++ .../provider/Drivers/ClaudeSlashCommands.ts | 15 ++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeSlashCommands.ts diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index eb428238d0d3..2943db0fb622 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -29,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 { retainClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { checkClaudeProviderStatus, @@ -173,15 +174,14 @@ export const ClaudeDriver: ProviderDriver = { ).pipe( Effect.flatMap((snapshot) => Effect.gen(function* () { - if (snapshot.slashCommands.length > 0) { - yield* Ref.set(lastGoodSlashCommands, snapshot.slashCommands); - return snapshot; - } const previous = yield* Ref.get(lastGoodSlashCommands); - if (previous.length === 0) { - return snapshot; + const slashCommands = retainClaudeSlashCommands(snapshot.slashCommands, previous); + if (slashCommands.length > 0) { + yield* Ref.set(lastGoodSlashCommands, slashCommands); } - return { ...snapshot, slashCommands: previous }; + return slashCommands === snapshot.slashCommands + ? snapshot + : { ...snapshot, slashCommands }; }), ), Effect.map(stampIdentity), 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..853ac4a29b22 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { retainClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; + +const review = { name: "review", description: "Review the diff" }; +const commit = { name: "commit", description: "Commit changes" }; + +describe("retainClaudeSlashCommands", () => { + it("keeps a populated probe result", () => { + expect(retainClaudeSlashCommands([review, commit], [review])).toEqual([review, commit]); + }); + + it("reuses the previous list when the probe is empty", () => { + expect(retainClaudeSlashCommands([], [review, commit])).toEqual([review, commit]); + }); + + it("passes through empty when there is no previous list", () => { + expect(retainClaudeSlashCommands([], [])).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..2d77a2412f91 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts @@ -0,0 +1,15 @@ +import type { ServerProviderSlashCommand } from "@t3tools/contracts"; + +/** + * A failed or empty Claude capability probe must not wipe a previously + * discovered slash-command list (#7111). + */ +export function retainClaudeSlashCommands( + incoming: ReadonlyArray, + previous: ReadonlyArray, +): ReadonlyArray { + if (incoming.length > 0) { + return incoming; + } + return previous.length > 0 ? previous : incoming; +} From 98fe772769e21143fd73d77516bcb6adfb066b25 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 15:51:26 +0700 Subject: [PATCH 3/5] fix(claude): reuse slash commands only after a failed probe A successful empty probe must clear the / menu. Keep the last good list only when the snapshot is not ready, so a transient capability failure does not wipe commands and a real empty result does not linger. --- apps/server/src/provider/Drivers/ClaudeDriver.ts | 6 +++++- .../src/provider/Drivers/ClaudeSlashCommands.test.ts | 12 ++++++++---- .../src/provider/Drivers/ClaudeSlashCommands.ts | 8 +++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 2943db0fb622..65349a4c22c0 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -175,7 +175,11 @@ export const ClaudeDriver: ProviderDriver = { Effect.flatMap((snapshot) => Effect.gen(function* () { const previous = yield* Ref.get(lastGoodSlashCommands); - const slashCommands = retainClaudeSlashCommands(snapshot.slashCommands, previous); + const slashCommands = retainClaudeSlashCommands( + snapshot.slashCommands, + previous, + snapshot.status !== "ready", + ); if (slashCommands.length > 0) { yield* Ref.set(lastGoodSlashCommands, slashCommands); } diff --git a/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts index 853ac4a29b22..e15a7e23ff03 100644 --- a/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts @@ -7,14 +7,18 @@ const commit = { name: "commit", description: "Commit changes" }; describe("retainClaudeSlashCommands", () => { it("keeps a populated probe result", () => { - expect(retainClaudeSlashCommands([review, commit], [review])).toEqual([review, commit]); + expect(retainClaudeSlashCommands([review, commit], [review], true)).toEqual([review, commit]); }); - it("reuses the previous list when the probe is empty", () => { - expect(retainClaudeSlashCommands([], [review, commit])).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([], [])).toEqual([]); + expect(retainClaudeSlashCommands([], [], true)).toEqual([]); }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts b/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts index 2d77a2412f91..6f52ff09d2f8 100644 --- a/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts @@ -1,15 +1,17 @@ import type { ServerProviderSlashCommand } from "@t3tools/contracts"; /** - * A failed or empty Claude capability probe must not wipe a previously - * discovered slash-command list (#7111). + * 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. */ export function retainClaudeSlashCommands( incoming: ReadonlyArray, previous: ReadonlyArray, + reusePreviousOnEmpty: boolean, ): ReadonlyArray { if (incoming.length > 0) { return incoming; } - return previous.length > 0 ? previous : incoming; + return reusePreviousOnEmpty && previous.length > 0 ? previous : incoming; } From b4205435ae13edd7102ed52182a7365be6197046 Mon Sep 17 00:00:00 2001 From: maslin Date: Mon, 24 Aug 2026 19:19:20 +0700 Subject: [PATCH 4/5] fix(claude): clear slash-command cache after a successful empty probe A successful empty probe already hid the commands in the snapshot, but lastGoodSlashCommands kept the old list. The next failed probe then restored deleted commands. Always write the merged list, including empty. --- .../src/provider/Drivers/ClaudeDriver.ts | 19 +-------- .../Drivers/ClaudeSlashCommands.test.ts | 41 +++++++++++++++++-- .../provider/Drivers/ClaudeSlashCommands.ts | 25 ++++++++++- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 65349a4c22c0..004078c4a29c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -29,7 +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 { retainClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; +import { rememberClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { checkClaudeProviderStatus, @@ -172,22 +172,7 @@ export const ClaudeDriver: ProviderDriver = { processEnv, cwd, ).pipe( - Effect.flatMap((snapshot) => - Effect.gen(function* () { - const previous = yield* Ref.get(lastGoodSlashCommands); - const slashCommands = retainClaudeSlashCommands( - snapshot.slashCommands, - previous, - snapshot.status !== "ready", - ); - if (slashCommands.length > 0) { - yield* Ref.set(lastGoodSlashCommands, slashCommands); - } - return slashCommands === snapshot.slashCommands - ? snapshot - : { ...snapshot, slashCommands }; - }), - ), + 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 index e15a7e23ff03..517c4e561a20 100644 --- a/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts @@ -1,9 +1,18 @@ +import type { ServerProviderSlashCommand } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; -import { retainClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; +import { rememberClaudeSlashCommands, retainClaudeSlashCommands } from "./ClaudeSlashCommands.ts"; -const review = { name: "review", description: "Review the diff" }; -const commit = { name: "commit", description: "Commit changes" }; +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", () => { @@ -22,3 +31,29 @@ describe("retainClaudeSlashCommands", () => { expect(retainClaudeSlashCommands([], [], true)).toEqual([]); }); }); + +describe("rememberClaudeSlashCommands", () => { + it("keeps the snapshot empty after a successful empty probe then a failed probe", () => { + const snapshot = Effect.runSync( + Effect.gen(function* () { + const lastGoodSlashCommands = yield* Ref.make>( + [], + ); + yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { + status: "ready", + slashCommands: [review, commit], + }); + yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { + status: "ready", + slashCommands: [], + }); + return 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 index 6f52ff09d2f8..0ae6895cc56e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.ts @@ -1,9 +1,12 @@ 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. + * 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, @@ -15,3 +18,23 @@ export function retainClaudeSlashCommands( } 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 }; + }); From c56c600185eeb85a17810088a2b82310b9396a20 Mon Sep 17 00:00:00 2001 From: maslin Date: Mon, 24 Aug 2026 19:57:47 +0700 Subject: [PATCH 5/5] test(claude): run slash-command cache regression as it.effect vp check forbids Effect.runSync in new tests. Drive the last-good cache sequence through @effect/vitest instead. --- .../Drivers/ClaudeSlashCommands.test.ts | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts index 517c4e561a20..1fe3266ace4e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "@effect/vitest"; import type { ServerProviderSlashCommand } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; @@ -33,27 +33,23 @@ describe("retainClaudeSlashCommands", () => { }); describe("rememberClaudeSlashCommands", () => { - it("keeps the snapshot empty after a successful empty probe then a failed probe", () => { - const snapshot = Effect.runSync( - Effect.gen(function* () { - const lastGoodSlashCommands = yield* Ref.make>( - [], - ); - yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { - status: "ready", - slashCommands: [review, commit], - }); - yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { - status: "ready", - slashCommands: [], - }); - return yield* rememberClaudeSlashCommands(lastGoodSlashCommands, { - status: "error", - slashCommands: [], - }); - }), - ); - - expect(snapshot.slashCommands).toEqual([]); - }); + 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([]); + }), + ); });