Skip to content
Closed
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
4 changes: 4 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -163,12 +165,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);

const lastGoodSlashCommands = yield* Ref.make<ServerProvider["slashCommands"]>([]);
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),
Expand Down
55 changes: 55 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSlashCommands.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<ServerProviderSlashCommand>>([]);
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([]);
}),
);
});
40 changes: 40 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSlashCommands.ts
Original file line number Diff line number Diff line change
@@ -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<ServerProviderSlashCommand>,
previous: ReadonlyArray<ServerProviderSlashCommand>,
reusePreviousOnEmpty: boolean,
): ReadonlyArray<ServerProviderSlashCommand> {
if (incoming.length > 0) {
return incoming;
}
return reusePreviousOnEmpty && previous.length > 0 ? previous : incoming;
}

export const rememberClaudeSlashCommands = <
Snapshot extends {
readonly status: string;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
},
>(
lastGoodSlashCommands: Ref.Ref<ReadonlyArray<ServerProviderSlashCommand>>,
snapshot: Snapshot,
): Effect.Effect<Snapshot> =>
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 };
});
Loading