From 7b8062b27ff2ce30c604e4437ec8eba982d1cec7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 03:44:12 +0000 Subject: [PATCH 1/5] fix(server): discover Claude commands in each workspace --- .../src/provider/Drivers/ClaudeDriver.ts | 19 +-- .../Layers/ClaudeCapabilitiesProbe.test.ts | 160 +++++++++++++++++- .../src/provider/Layers/ClaudeProvider.ts | 22 +++ 3 files changed, 189 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 1673d3e6d639..87c33163a28b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -41,6 +41,7 @@ import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, + probeClaudeWorkspaceSnapshot, } from "../Layers/ClaudeProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; @@ -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"); @@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver = { 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), + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), orchestrationAdapter, textGeneration, consumeResetCredit, diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index ad54e41f0cc9..7d68524d756f 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -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"; @@ -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 }); @@ -53,6 +60,157 @@ 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; + 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 () => ({ + rate_limits_available: false, + rate_limits: null, + }), + } as ReturnType; + }); + 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, + ); + }).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( + 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, + ); + 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, + ); + 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; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index db06557c7c8e..133911e3d6e6 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,6 +1,7 @@ import { type ClaudeSettings, type ModelCapabilities, + type ServerProvider, type ServerProviderSlashCommand, type ServerProviderResetCredits, } from "@t3tools/contracts"; @@ -417,6 +418,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 { + if (!claudeSettings.enabled) return machineSnapshot; + const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd); + 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?: ( From 1ef21d63ce6d013a2c8a88609a2e2f24ca3cb46f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 03:59:31 +0000 Subject: [PATCH 2/5] fix(server): skip usage probes for Claude workspace commands --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 10 +++++---- .../src/provider/Layers/ClaudeProvider.ts | 22 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 7d68524d756f..6ec206b6818e 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -90,6 +90,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { 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"); @@ -108,10 +109,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { { name: "user-command", description: "Existing user command", argumentHint: "" }, ], }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: false, - rate_limits: null, - }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { + usageCalls++; + return { rate_limits_available: false, rate_limits: null }; + }, } as ReturnType; }); yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); @@ -149,6 +150,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { query.mock.calls.map(([input]) => input.options?.cwd), workspaces, ); + assert.equal(usageCalls, 0); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 133911e3d6e6..75cff3186dee 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -334,6 +334,7 @@ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, + includeUsage = true, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -365,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; @@ -426,7 +430,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; - const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd); + const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false); if (!capabilities) { // The registry skips failed workspace snapshots, leaving discovery retryable. return { ...machineSnapshot, status: "error" }; From bc2876a8f6918251e243a1510fa4e2708a957db0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 04:42:08 +0000 Subject: [PATCH 3/5] fix(provider): retain workspace skills when command discovery fails --- .../threads/use-composer-command-menu.ts | 16 +++-- .../Layers/ClaudeCapabilitiesProbe.test.ts | 29 ++++++++- .../src/provider/Layers/ClaudeProvider.ts | 15 ++--- .../provider/Layers/ProviderRegistry.test.ts | 60 +++++++++++++++++-- .../src/provider/Layers/ProviderRegistry.ts | 21 ++++--- apps/server/src/provider/ProviderDriver.ts | 8 ++- apps/web/src/components/chat/ChatComposer.tsx | 21 ++++--- .../client-runtime/src/providerSkills.test.ts | 23 +++++++ packages/client-runtime/src/providerSkills.ts | 8 +++ packages/contracts/src/server.test.ts | 15 +++++ packages/contracts/src/server.ts | 2 + 11 files changed, 182 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index d42f16ff71c2..0d6f0fcd46c6 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -36,6 +36,7 @@ import { getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, isProviderSkillUserInvocable, + hasCompleteProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -245,9 +246,9 @@ export function useComposerCommandMenu({ reportFailure: false, }); const selectedProviderInstanceId = selectedProviderStatus?.instanceId; - const hasWorkspaceSnapshot = Boolean( - projectCwd && - selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd), + const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( + selectedProviderStatus, + projectCwd, ); const workspaceRefreshKeyRef = useRef(null); const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); @@ -285,9 +286,12 @@ export function useComposerCommandMenu({ }).then((result) => { const refreshed = result._tag === "Success" && - result.value.providers - .find((provider) => provider.instanceId === selectedProviderInstanceId) - ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd); + hasCompleteProviderWorkspaceSnapshot( + result.value.providers.find( + (provider) => provider.instanceId === selectedProviderInstanceId, + ), + projectCwd, + ); if (!refreshed && workspaceRefreshKeyRef.current === key) { retryLater(); } diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 6ec206b6818e..5c8734480af9 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -125,6 +125,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ); assert.deepEqual(scoped, { ...machineSnapshot, + slashCommandsPending: false, slashCommands: [ COMPACT_SLASH_COMMAND, { @@ -154,10 +155,17 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { }).pipe(Effect.scoped), ); - it.effect("keeps failed workspace probes retryable and skips disabled providers", () => + it.effect("keeps readable skills during failed command discovery and recovers on retry", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); + 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"), @@ -168,7 +176,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { checkedAt: "2026-03-25T00:00:00.000Z", version: "2.1.288", models: [], - slashCommands: [COMPACT_SLASH_COMMAND], + slashCommands: [{ name: "server-cwd-only" }], skills: [], } satisfies ServerProvider; const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( @@ -187,7 +195,20 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { 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" }); + assert.deepEqual(failed, { + ...machineSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + skills: [ + { + name: "existing-skill", + path: path.join(skillDir, "SKILL.md"), + enabled: true, + scope: "project", + description: "Existing project skill", + }, + ], + }); query.mockImplementation( () => ({ @@ -203,6 +224,8 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); assert.equal(recovered.status, "ready"); + assert.equal(recovered.slashCommandsPending, false); + assert.deepEqual(recovered.skills, failed.skills); const disabled = yield* probeClaudeWorkspaceSnapshot( { ...settings, enabled: false }, machineSnapshot, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 75cff3186dee..78805fab5ae2 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -37,6 +37,7 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import { type ClaudeScopedLimitNames, @@ -428,18 +429,18 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps machineSnapshot: ServerProvider, cwd: string, environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { +): Effect.fn.Return { 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); + const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false); return { ...machineSnapshot, skills, - slashCommands: dedupeSlashCommands([COMPACT_SLASH_COMMAND, ...capabilities.slashCommands]), + slashCommands: dedupeSlashCommands([ + COMPACT_SLASH_COMMAND, + ...(capabilities?.slashCommands ?? []), + ]), + slashCommandsPending: !capabilities, }; }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 332a5e931d29..ee52a71bba5f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -63,7 +63,7 @@ import { writeProviderStatusCache, } from "../providerStatusCache.ts"; import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; @@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test skills: scopedSnapshot.skills, }, ]); + + const pendingSnapshot = { + ...scopedSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + } satisfies ProviderWorkspaceSnapshot; + const partial = upsertProviderWorkspaceSnapshot(result, "/project", pendingSnapshot); + assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [ + { name: "project" }, + ]); + assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true); + const otherProject = upsertProviderWorkspaceSnapshot( + partial, + "/other-project", + pendingSnapshot, + ); + assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + ]); + const recovered = upsertProviderWorkspaceSnapshot(partial, "/project", { + ...scopedSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }], + }); + assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + { name: "replacement" }, + ]); + assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); }); it("preserves previously discovered provider models when a refresh returns none", () => { @@ -1573,7 +1601,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test slashCommands: [], } as const satisfies ServerProvider; const snapshotCalls = yield* Ref.make(0); - const scopedResult = yield* Ref.make(scopedProvider); + const scopedResult = yield* Ref.make({ + ...scopedProvider, + status: "error", + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + }); const cacheInvalidations = yield* Ref.make(0); const scanGate = yield* Ref.make<{ readonly started: Deferred.Deferred; @@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test providers[0]?.workspaceSnapshots?.[0]?.skills, scopedProvider.skills, ); + assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + ]); + assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); + yield* Ref.set(scopedResult, { + ...scopedProvider, + status: "error", + slashCommandsPending: false, + }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, + scopedProvider.slashCommands, + ); + assert.strictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, + undefined, + ); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); const newSkills = [ ...scopedProvider.skills, { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, @@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test cwd: "/workspace", fresh: true, }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + assert.strictEqual(yield* Ref.get(snapshotCalls), 4); assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); assert.deepStrictEqual( (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index de476ca0299a..b9c28c155283 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -53,7 +53,7 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; @@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S export function upsertProviderWorkspaceSnapshot( provider: ServerProvider, cwd: string, - scopedSnapshot: ServerProvider, + scopedSnapshot: ProviderWorkspaceSnapshot, ): ServerProvider { const workspaceSnapshot = { cwd, checkedAt: scopedSnapshot.checkedAt, - slashCommands: scopedSnapshot.slashCommands, + slashCommands: scopedSnapshot.slashCommandsPending + ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? + scopedSnapshot.slashCommands) + : scopedSnapshot.slashCommands, + ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), skills: scopedSnapshot.skills, } satisfies NonNullable[number]; return { @@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect( const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); const scannedFrom = workspaceSnapshotOf(provider); - if (!provider || !provider.enabled || (!input.fresh && scannedFrom)) { + if ( + !provider || + !provider.enabled || + (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) + ) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect( }); // A fresh scan never joins a running one, which may predate the change. if (!claimed && !input.fresh) return yield* Ref.get(providersRef); - // Fresh scans also re-read the machine snapshot: Claude's plugin - // commands come from it, not from the cwd scan. + // Fresh scans also re-read the machine snapshot after invalidating caches. const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), @@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshMachineSnapshot.pipe( Effect.andThen(instance.snapshotForCwd(input.cwd)), Effect.flatMap((scopedSnapshot) => - scopedSnapshot.status === "error" + scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined ? Ref.get(providersRef) : instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 4f92df965c8a..cb86075616d1 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -31,6 +31,7 @@ import type { ProviderInstanceEnvironment, ProviderInstanceId, ServerProvider, + ServerProviderWorkspaceSnapshot, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -59,6 +60,9 @@ export interface ProviderDriverMetadata { readonly supportsMultipleInstances?: boolean; } +export type ProviderWorkspaceSnapshot = ServerProvider & + Pick; + /** * One materialized provider instance. Held by the registry, looked up by * `instanceId`, torn down by closing the scope it was created in. @@ -76,7 +80,9 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; - readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + readonly snapshotForCwd?: ( + cwd: string, + ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ readonly invalidateCaches?: Effect.Effect; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8cd297925f31..5f81f5943224 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1121,6 +1121,7 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + hasCompleteProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -2182,9 +2183,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { - const hasWorkspaceSnapshot = Boolean( - gitCwd && - selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), + const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( + selectedProviderStatus, + gitCwd, ); if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = null; @@ -2195,8 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (!gitCwd || !selectedProviderEntry) return; const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; - const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === gitCwd, + const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( + selectedProviderStatus, + gitCwd, ); if (workspaceRefreshKeyRef.current === key) return; if (hasWorkspaceSnapshot) { @@ -2221,9 +2223,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }).then((result) => { const hasWorkspaceSnapshot = result._tag === "Success" && - result.value.providers - .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) - ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); + hasCompleteProviderWorkspaceSnapshot( + result.value.providers.find( + (provider) => provider.instanceId === selectedProviderEntry.instanceId, + ), + gitCwd, + ); if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { retryLater(); } diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 62d0319db1bc..30b804d176ac 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -6,6 +6,7 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + hasCompleteProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, @@ -251,4 +252,26 @@ describe("workspace provider snapshots", () => { expect(resolveProviderSkillsForCwd(provider, "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/workspace/project-b")).toEqual(provider.skills); expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); }); + + it("uses partial workspace skills and commands while keeping discovery retryable", () => { + const partial = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommands: [{ name: "compact" }], + slashCommandsPending: true, + })), + } satisfies ServerProvider; + expect(resolveProviderSkillsForCwd(partial, "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/workspace/project-a")).toEqual( + provider.workspaceSnapshots[0]?.skills, + ); + expect(resolveProviderSlashCommandsForCwd(partial, "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/workspace/project-a")).toEqual([ + { name: "compact" }, + ]); + expect(hasCompleteProviderWorkspaceSnapshot(partial, "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/workspace/project-a")).toBe(false); + expect(hasCompleteProviderWorkspaceSnapshot(provider, "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/workspace/project-a")).toBe(true); + expect(hasCompleteProviderWorkspaceSnapshot(provider, "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/workspace/project-b")).toBe(false); + expect(hasCompleteProviderWorkspaceSnapshot(undefined, "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/workspace/project-a")).toBe(false); + expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); + }); }); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index b80cd5803809..b52377b0165e 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -111,6 +111,14 @@ function resolveProviderWorkspaceSnapshot( return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); } +export function hasCompleteProviderWorkspaceSnapshot( + provider: ServerProvider | null | undefined, + cwd: string | null | undefined, +): boolean { + const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); + return Boolean(snapshot && !snapshot.slashCommandsPending); +} + export function resolveProviderSkillsForCwd( provider: ServerProvider, cwd: string | null | undefined, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index ffbeb5cb9f11..fdb6b82b3dbc 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -31,6 +31,21 @@ const baseProviderSnapshot = { }; describe("ServerProvider", () => { + it.each([undefined, true, false])("decodes workspace command discovery pending=%s", (pending) => { + const workspace = { + cwd: "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/workspace/project", + checkedAt: baseProviderSnapshot.checkedAt, + slashCommands: [{ name: "compact" }], + ...(pending === undefined ? {} : { slashCommandsPending: pending }), + skills: [{ name: "project", path: "/workspace/project/SKILL.md", enabled: true }], + }; + const parsed = decodeServerProvider({ + ...baseProviderSnapshot, + workspaceSnapshots: [workspace], + }); + expect(parsed.workspaceSnapshots).toEqual([workspace]); + }); + it("defaults capability arrays when decoding provider snapshots", () => { const parsed = decodeServerProvider({ instanceId: "codex", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e152a3dbf072..81e0899777a5 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -125,6 +125,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ cwd: TrimmedNonEmptyString, checkedAt: IsoDateTime, slashCommands: Schema.Array(ServerProviderSlashCommand), + /** Skills are available, but command discovery still needs a retry. */ + slashCommandsPending: Schema.optional(Schema.Boolean), skills: Schema.Array(ServerProviderSkill), }); export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; From e0b2bdf4044bfe03180bccd8cb30c7a55dae4909 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 05:19:03 +0000 Subject: [PATCH 4/5] fix(client): retry pending workspace commands after cooldown --- .../threads/use-composer-command-menu.test.ts | 139 +++++++++++++++++- .../threads/use-composer-command-menu.ts | 30 +++- apps/web/src/components/chat/ChatComposer.tsx | 38 ++++- 3 files changed, 191 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 3b748f2a99d0..070cf6677c0b 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { ProviderDriverKind } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +const refreshProviders = vi.hoisted(() => vi.fn()); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ @@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({ })); vi.mock("../../state/use-composer-drafts", () => ({ getComposerDraftSnapshot: vi.fn(), + readComposerDraftSelection: vi.fn(), setComposerDraftContext: vi.fn(), })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); @@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, })); vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: () => vi.fn(), + useAtomCommand: () => refreshProviders, })); import { buildComposerSlashCommandItems, resolveComposerCommandSelection, + useComposerCommandMenu, } from "./use-composer-command-menu"; describe("mobile slash commands", () => { @@ -101,3 +112,125 @@ describe("mobile slash commands", () => { ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); }); }); + +describe("workspace command discovery retry", () => { + let root: Root; + const environmentId = EnvironmentId.make("test-environment"); + const instanceId = ProviderInstanceId.make("claude"); + const provider = { + instanceId, + driver: ProviderDriverKind.make("claude"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [ + { + cwd: "/project-a", + checkedAt: "2026-01-01T00:00:00.000Z", + slashCommandsPending: true, + slashCommands: [{ name: "compact" }], + skills: [], + }, + ], + } satisfies ServerProvider; + + function Probe({ cwd }: { cwd: string }) { + useComposerCommandMenu({ + draftMessage: "/project", + ownerKey: null, + environmentId, + projectCwd: cwd, + selectedProviderStatus: provider, + hasThread: false, + hasCompactableConversation: false, + onChangeDraftMessage: () => {}, + }); + return null; + } + + beforeEach(() => { + vi.useFakeTimers(); + refreshProviders.mockReset(); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [provider] } }); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("retries partial commands after the cooldown without editing the draft", async () => { + const recovered = { + ...provider, + workspaceSnapshots: [{ ...provider.workspaceSnapshots[0], slashCommandsPending: false }], + }; + refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [provider] } }); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(9_999)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + expect(refreshProviders).toHaveBeenLastCalledWith({ + environmentId, + input: { instanceId, cwd: "/project-a" }, + }); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); + + it("cancels the old workspace retry and does not duplicate an in-flight request", async () => { + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + refreshProviders.mockImplementationOnce(() => new Promise(() => {})); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-b" })); + }); + expect(refreshProviders).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + expect(refreshProviders).toHaveBeenLastCalledWith({ + environmentId, + input: { instanceId, cwd: "/project-b" }, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels a scheduled retry when the composer unmounts", async () => { + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + await act(async () => { + root.unmount(); + }); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 0d6f0fcd46c6..80a4cc5b7f1a 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -251,12 +251,29 @@ export function useComposerCommandMenu({ projectCwd, ); const workspaceRefreshKeyRef = useRef(null); - const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); + const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ + key: string; + notBefore: number; + } | null>(null); + const workspaceRefreshScopeKey = + environmentId && projectCwd && selectedProviderInstanceId + ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}` + : null; + useEffect(() => { + if (!workspaceRefreshRetry || workspaceRefreshRetry.key !== workspaceRefreshScopeKey) return; + const timeout = setTimeout( + () => { + setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); + }, + Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), + ); + return () => clearTimeout(timeout); + }, [workspaceRefreshRetry, workspaceRefreshScopeKey]); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); } hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; }, [hasWorkspaceSnapshot]); @@ -266,19 +283,19 @@ export function useComposerCommandMenu({ if (workspaceRefreshKeyRef.current === key) return; if (hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = key; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); return; } - const retry = workspaceRefreshRetryRef.current; + const retry = workspaceRefreshRetry; if (retry?.key === key && Date.now() < retry.notBefore) return; workspaceRefreshKeyRef.current = key; const retryLater = () => { if (workspaceRefreshKeyRef.current !== key) return; workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = { + setWorkspaceRefreshRetry({ key, notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }; + }); }; void refreshProviders({ environmentId, @@ -303,6 +320,7 @@ export function useComposerCommandMenu({ projectCwd, refreshProviders, selectedProviderInstanceId, + workspaceRefreshRetry, ]); const trigger = useMemo(() => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f81f5943224..6336df1c2eb4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2180,7 +2180,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) reportFailure: false, }); const workspaceRefreshKeyRef = useRef(null); - const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); + const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ + key: string; + notBefore: number; + } | null>(null); + const workspaceRefreshScopeKey = + gitCwd && selectedProviderEntry + ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` + : null; + useEffect(() => { + if (!workspaceRefreshRetry || workspaceRefreshRetry.key !== workspaceRefreshScopeKey) return; + const timeout = setTimeout( + () => { + setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); + }, + Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), + ); + return () => clearTimeout(timeout); + }, [workspaceRefreshRetry, workspaceRefreshScopeKey]); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( @@ -2189,7 +2206,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); } hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; }, [gitCwd, selectedProviderStatus]); @@ -2203,19 +2220,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (workspaceRefreshKeyRef.current === key) return; if (hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = key; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); return; } - const retry = workspaceRefreshRetryRef.current; + const retry = workspaceRefreshRetry; if (retry?.key === key && Date.now() < retry.notBefore) return; workspaceRefreshKeyRef.current = key; const retryLater = () => { if (workspaceRefreshKeyRef.current !== key) return; workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = { + setWorkspaceRefreshRetry({ key, notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }; + }); }; void refreshProviders({ environmentId, @@ -2233,7 +2250,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) retryLater(); } }, retryLater); - }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]); + }, [ + environmentId, + gitCwd, + prompt, + refreshProviders, + selectedProviderEntry, + workspaceRefreshRetry, + ]); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], From c7c7e9ebe1bc1eb09f749cded9febea433aeb6fc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 05:40:14 +0000 Subject: [PATCH 5/5] fix(client): retry only pending workspace discovery --- .../threads/use-composer-command-menu.test.ts | 92 ++++++++++++++++++- .../threads/use-composer-command-menu.ts | 13 ++- apps/web/src/components/chat/ChatComposer.tsx | 13 ++- 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 070cf6677c0b..79965f76e924 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -140,13 +140,13 @@ describe("workspace command discovery retry", () => { ], } satisfies ServerProvider; - function Probe({ cwd }: { cwd: string }) { + function Probe({ cwd, status = provider }: { cwd: string; status?: ServerProvider }) { useComposerCommandMenu({ draftMessage: "/project", ownerKey: null, environmentId, projectCwd: cwd, - selectedProviderStatus: provider, + selectedProviderStatus: status, hasThread: false, hasCompactableConversation: false, onChangeDraftMessage: () => {}, @@ -204,6 +204,94 @@ describe("workspace command discovery retry", () => { expect(refreshProviders).toHaveBeenCalledTimes(2); }); + it.each(["pi", "acpRegistry"])( + "does not poll a healthy %s workspace without discovery", + async (driver) => { + const unsupported = { + ...provider, + driver: ProviderDriverKind.make(driver), + workspaceSnapshots: [], + }; + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [unsupported] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: unsupported })); + }); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(30_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("arms the remaining cooldown when a concurrent scan publishes partial commands", async () => { + const missing = { ...provider, workspaceSnapshots: [] }; + const recovered = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommandsPending: false, + })), + }; + refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [missing] } }); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: missing })); + }); + await act(() => vi.advanceTimersByTimeAsync(5_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: provider })); + }); + await act(() => vi.advanceTimersByTimeAsync(4_999)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); + + it("does not retry when a concurrent scan publishes a complete workspace", async () => { + const missing = { ...provider, workspaceSnapshots: [] }; + const recovered = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommandsPending: false, + })), + }; + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [missing] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: missing })); + }); + await act(() => vi.advanceTimersByTimeAsync(5_000)); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: recovered })); + }); + await act(() => vi.advanceTimersByTimeAsync(30_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("retries a rejected refresh while published discovery remains pending", async () => { + const recovered = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommandsPending: false, + })), + }; + refreshProviders.mockRejectedValueOnce(new Error("Connection lost")); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); + it("cancels the old workspace retry and does not duplicate an in-flight request", async () => { await act(async () => { root.render(createElement(Probe, { cwd: "/project-a" })); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 80a4cc5b7f1a..722cb1d5852b 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -259,8 +259,17 @@ export function useComposerCommandMenu({ environmentId && projectCwd && selectedProviderInstanceId ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}` : null; + const workspaceSlashCommandsPending = + selectedProviderStatus?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true, + ) ?? false; useEffect(() => { - if (!workspaceRefreshRetry || workspaceRefreshRetry.key !== workspaceRefreshScopeKey) return; + if ( + !workspaceSlashCommandsPending || + !workspaceRefreshRetry || + workspaceRefreshRetry.key !== workspaceRefreshScopeKey + ) + return; const timeout = setTimeout( () => { setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); @@ -268,7 +277,7 @@ export function useComposerCommandMenu({ Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), ); return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey]); + }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6336df1c2eb4..36b2b2a6a271 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2188,8 +2188,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) gitCwd && selectedProviderEntry ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` : null; + const workspaceSlashCommandsPending = + selectedProviderStatus?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true, + ) ?? false; useEffect(() => { - if (!workspaceRefreshRetry || workspaceRefreshRetry.key !== workspaceRefreshScopeKey) return; + if ( + !workspaceSlashCommandsPending || + !workspaceRefreshRetry || + workspaceRefreshRetry.key !== workspaceRefreshScopeKey + ) + return; const timeout = setTimeout( () => { setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); @@ -2197,7 +2206,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), ); return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey]); + }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(