authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 08:54:52-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 08:54:52-03:00
log836098543c6ecdf66734596df0ea3a8f6513f2a6
tree04f9b5eed1c9fddcc69b0d30c0b18880b1b0972a
parent32b77f4fa370a5195ad16ab709405b1038c49fcd
signature Signed by PGP key B5690EEEBB952194

fix(server): discover Claude commands in each workspace (#15462)


13 files changed, 672 insertions(+), 62 deletions(-)

apps/mobile/src/features/threads/use-composer-command-menu.test.ts+224-3
...@@ -1,5 +1,14 @@...@@ -1,5 +1,14 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { ProviderDriverKind } from "@t3tools/contracts";2import {
3 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { act, createElement } from "react";
9import { createRoot, type Root } from "react-dom/client";
10
11const refreshProviders = vi.hoisted(() => vi.fn());
3vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));12vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
413
5vi.mock("../../state/queries", () => ({14vi.mock("../../state/queries", () => ({
...@@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({...@@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({
8}));17}));
9vi.mock("../../state/use-composer-drafts", () => ({18vi.mock("../../state/use-composer-drafts", () => ({
10 getComposerDraftSnapshot: vi.fn(),19 getComposerDraftSnapshot: vi.fn(),
20 readComposerDraftSelection: vi.fn(),
11 setComposerDraftContext: vi.fn(),21 setComposerDraftContext: vi.fn(),
12}));22}));
13vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));23vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));
...@@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({...@@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({
15 serverEnvironment: { refreshProviders: Symbol("refreshProviders") },25 serverEnvironment: { refreshProviders: Symbol("refreshProviders") },
16}));26}));
17vi.mock("../../state/use-atom-command", () => ({27vi.mock("../../state/use-atom-command", () => ({
18 useAtomCommand: () => vi.fn(),28 useAtomCommand: () => refreshProviders,
19}));29}));
2030
21import {31import {
22 buildComposerSlashCommandItems,32 buildComposerSlashCommandItems,
23 resolveComposerCommandSelection,33 resolveComposerCommandSelection,
34 useComposerCommandMenu,
24} from "./use-composer-command-menu";35} from "./use-composer-command-menu";
2536
26describe("mobile slash commands", () => {37describe("mobile slash commands", () => {
...@@ -101,3 +112,213 @@ describe("mobile slash commands", () => {...@@ -101,3 +112,213 @@ describe("mobile slash commands", () => {
101 ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });112 ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
102 });113 });
103});114});
115
116describe("workspace command discovery retry", () => {
117 let root: Root;
118 const environmentId = EnvironmentId.make("test-environment");
119 const instanceId = ProviderInstanceId.make("claude");
120 const provider = {
121 instanceId,
122 driver: ProviderDriverKind.make("claude"),
123 enabled: true,
124 installed: true,
125 version: "1.0.0",
126 status: "ready",
127 auth: { status: "authenticated" },
128 checkedAt: "2026-01-01T00:00:00.000Z",
129 models: [],
130 slashCommands: [],
131 skills: [],
132 workspaceSnapshots: [
133 {
134 cwd: "/project-a",
135 checkedAt: "2026-01-01T00:00:00.000Z",
136 slashCommandsPending: true,
137 slashCommands: [{ name: "compact" }],
138 skills: [],
139 },
140 ],
141 } satisfies ServerProvider;
142
143 function Probe({ cwd, status = provider }: { cwd: string; status?: ServerProvider }) {
144 useComposerCommandMenu({
145 draftMessage: "/project",
146 ownerKey: null,
147 environmentId,
148 projectCwd: cwd,
149 selectedProviderStatus: status,
150 hasThread: false,
151 hasCompactableConversation: false,
152 onChangeDraftMessage: () => {},
153 });
154 return null;
155 }
156
157 beforeEach(() => {
158 vi.useFakeTimers();
159 refreshProviders.mockReset();
160 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [provider] } });
161 const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} };
162 const container = {
163 nodeType: 1,
164 tagName: "DIV",
165 namespaceURI: "http://www.w3.org/1999/xhtml",
166 ownerDocument: document,
167 addEventListener() {},
168 removeEventListener() {},
169 };
170 vi.stubGlobal("document", document);
171 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
172 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
173 root = createRoot(container as unknown as HTMLElement);
174 });
175
176 afterEach(async () => {
177 await act(async () => {
178 root.unmount();
179 });
180 vi.useRealTimers();
181 vi.unstubAllGlobals();
182 });
183
184 it("retries partial commands after the cooldown without editing the draft", async () => {
185 const recovered = {
186 ...provider,
187 workspaceSnapshots: [{ ...provider.workspaceSnapshots[0], slashCommandsPending: false }],
188 };
189 refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [provider] } });
190 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
191 await act(async () => {
192 root.render(createElement(Probe, { cwd: "/project-a" }));
193 });
194 expect(refreshProviders).toHaveBeenCalledTimes(1);
195 await act(() => vi.advanceTimersByTimeAsync(9_999));
196 expect(refreshProviders).toHaveBeenCalledTimes(1);
197 await act(() => vi.advanceTimersByTimeAsync(1));
198 expect(refreshProviders).toHaveBeenCalledTimes(2);
199 expect(refreshProviders).toHaveBeenLastCalledWith({
200 environmentId,
201 input: { instanceId, cwd: "/project-a" },
202 });
203 await act(() => vi.advanceTimersByTimeAsync(20_000));
204 expect(refreshProviders).toHaveBeenCalledTimes(2);
205 });
206
207 it.each(["pi", "acpRegistry"])(
208 "does not poll a healthy %s workspace without discovery",
209 async (driver) => {
210 const unsupported = {
211 ...provider,
212 driver: ProviderDriverKind.make(driver),
213 workspaceSnapshots: [],
214 };
215 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [unsupported] } });
216 await act(async () => {
217 root.render(createElement(Probe, { cwd: "/project-a", status: unsupported }));
218 });
219 expect(refreshProviders).toHaveBeenCalledTimes(1);
220 await act(() => vi.advanceTimersByTimeAsync(30_000));
221 expect(refreshProviders).toHaveBeenCalledTimes(1);
222 expect(vi.getTimerCount()).toBe(0);
223 },
224 );
225
226 it("arms the remaining cooldown when a concurrent scan publishes partial commands", async () => {
227 const missing = { ...provider, workspaceSnapshots: [] };
228 const recovered = {
229 ...provider,
230 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
231 ...snapshot,
232 slashCommandsPending: false,
233 })),
234 };
235 refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [missing] } });
236 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
237 await act(async () => {
238 root.render(createElement(Probe, { cwd: "/project-a", status: missing }));
239 });
240 await act(() => vi.advanceTimersByTimeAsync(5_000));
241 expect(refreshProviders).toHaveBeenCalledTimes(1);
242 expect(vi.getTimerCount()).toBe(0);
243 await act(async () => {
244 root.render(createElement(Probe, { cwd: "/project-a", status: provider }));
245 });
246 await act(() => vi.advanceTimersByTimeAsync(4_999));
247 expect(refreshProviders).toHaveBeenCalledTimes(1);
248 await act(() => vi.advanceTimersByTimeAsync(1));
249 expect(refreshProviders).toHaveBeenCalledTimes(2);
250 await act(() => vi.advanceTimersByTimeAsync(20_000));
251 expect(refreshProviders).toHaveBeenCalledTimes(2);
252 });
253
254 it("does not retry when a concurrent scan publishes a complete workspace", async () => {
255 const missing = { ...provider, workspaceSnapshots: [] };
256 const recovered = {
257 ...provider,
258 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
259 ...snapshot,
260 slashCommandsPending: false,
261 })),
262 };
263 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [missing] } });
264 await act(async () => {
265 root.render(createElement(Probe, { cwd: "/project-a", status: missing }));
266 });
267 await act(() => vi.advanceTimersByTimeAsync(5_000));
268 await act(async () => {
269 root.render(createElement(Probe, { cwd: "/project-a", status: recovered }));
270 });
271 await act(() => vi.advanceTimersByTimeAsync(30_000));
272 expect(refreshProviders).toHaveBeenCalledTimes(1);
273 expect(vi.getTimerCount()).toBe(0);
274 });
275
276 it("retries a rejected refresh while published discovery remains pending", async () => {
277 const recovered = {
278 ...provider,
279 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
280 ...snapshot,
281 slashCommandsPending: false,
282 })),
283 };
284 refreshProviders.mockRejectedValueOnce(new Error("Connection lost"));
285 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
286 await act(async () => {
287 root.render(createElement(Probe, { cwd: "/project-a" }));
288 });
289 await act(() => vi.advanceTimersByTimeAsync(10_000));
290 expect(refreshProviders).toHaveBeenCalledTimes(2);
291 await act(() => vi.advanceTimersByTimeAsync(20_000));
292 expect(refreshProviders).toHaveBeenCalledTimes(2);
293 });
294
295 it("cancels the old workspace retry and does not duplicate an in-flight request", async () => {
296 await act(async () => {
297 root.render(createElement(Probe, { cwd: "/project-a" }));
298 });
299 refreshProviders.mockImplementationOnce(() => new Promise(() => {}));
300 await act(async () => {
301 root.render(createElement(Probe, { cwd: "/project-b" }));
302 });
303 expect(refreshProviders).toHaveBeenCalledTimes(2);
304 await act(() => vi.advanceTimersByTimeAsync(20_000));
305 expect(refreshProviders).toHaveBeenCalledTimes(2);
306 expect(refreshProviders).toHaveBeenLastCalledWith({
307 environmentId,
308 input: { instanceId, cwd: "/project-b" },
309 });
310 expect(vi.getTimerCount()).toBe(0);
311 });
312
313 it("cancels a scheduled retry when the composer unmounts", async () => {
314 await act(async () => {
315 root.render(createElement(Probe, { cwd: "/project-a" }));
316 });
317 await act(async () => {
318 root.unmount();
319 });
320 await act(() => vi.advanceTimersByTimeAsync(20_000));
321 expect(refreshProviders).toHaveBeenCalledTimes(1);
322 expect(vi.getTimerCount()).toBe(0);
323 });
324});
apps/mobile/src/features/threads/use-composer-command-menu.ts+43-12
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 getProviderSkillsForSlashMenu,36 getProviderSkillsForSlashMenu,
37 getProviderSlashCommandsForSlashMenu,37 getProviderSlashCommandsForSlashMenu,
38 isProviderSkillUserInvocable,38 isProviderSkillUserInvocable,
39 hasCompleteProviderWorkspaceSnapshot,
39 resolveProviderSkillsForCwd,40 resolveProviderSkillsForCwd,
40 resolveProviderSlashCommandsForCwd,41 resolveProviderSlashCommandsForCwd,
41} from "@t3tools/client-runtime/providerSkills";42} from "@t3tools/client-runtime/providerSkills";
...@@ -245,17 +246,43 @@ export function useComposerCommandMenu({...@@ -245,17 +246,43 @@ export function useComposerCommandMenu({
245 reportFailure: false,246 reportFailure: false,
246 });247 });
247 const selectedProviderInstanceId = selectedProviderStatus?.instanceId;248 const selectedProviderInstanceId = selectedProviderStatus?.instanceId;
248 const hasWorkspaceSnapshot = Boolean(249 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
249 projectCwd &&250 selectedProviderStatus,
250 selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd),251 projectCwd,
251 );252 );
252 const workspaceRefreshKeyRef = useRef<string | null>(null);253 const workspaceRefreshKeyRef = useRef<string | null>(null);
253 const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null);254 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
255 key: string;
256 notBefore: number;
257 } | null>(null);
258 const workspaceRefreshScopeKey =
259 environmentId && projectCwd && selectedProviderInstanceId
260 ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`
261 : null;
262 const workspaceSlashCommandsPending =
263 selectedProviderStatus?.workspaceSnapshots?.some(
264 (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true,
265 ) ?? false;
266 useEffect(() => {
267 if (
268 !workspaceSlashCommandsPending ||
269 !workspaceRefreshRetry ||
270 workspaceRefreshRetry.key !== workspaceRefreshScopeKey
271 )
272 return;
273 const timeout = setTimeout(
274 () => {
275 setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current));
276 },
277 Math.max(0, workspaceRefreshRetry.notBefore - Date.now()),
278 );
279 return () => clearTimeout(timeout);
280 }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]);
254 const hadWorkspaceSnapshotRef = useRef(false);281 const hadWorkspaceSnapshotRef = useRef(false);
255 useEffect(() => {282 useEffect(() => {
256 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {283 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
257 workspaceRefreshKeyRef.current = null;284 workspaceRefreshKeyRef.current = null;
258 workspaceRefreshRetryRef.current = null;285 setWorkspaceRefreshRetry(null);
259 }286 }
260 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;287 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
261 }, [hasWorkspaceSnapshot]);288 }, [hasWorkspaceSnapshot]);
...@@ -265,19 +292,19 @@ export function useComposerCommandMenu({...@@ -265,19 +292,19 @@ export function useComposerCommandMenu({
265 if (workspaceRefreshKeyRef.current === key) return;292 if (workspaceRefreshKeyRef.current === key) return;
266 if (hasWorkspaceSnapshot) {293 if (hasWorkspaceSnapshot) {
267 workspaceRefreshKeyRef.current = key;294 workspaceRefreshKeyRef.current = key;
268 workspaceRefreshRetryRef.current = null;295 setWorkspaceRefreshRetry(null);
269 return;296 return;
270 }297 }
271 const retry = workspaceRefreshRetryRef.current;298 const retry = workspaceRefreshRetry;
272 if (retry?.key === key && Date.now() < retry.notBefore) return;299 if (retry?.key === key && Date.now() < retry.notBefore) return;
273 workspaceRefreshKeyRef.current = key;300 workspaceRefreshKeyRef.current = key;
274 const retryLater = () => {301 const retryLater = () => {
275 if (workspaceRefreshKeyRef.current !== key) return;302 if (workspaceRefreshKeyRef.current !== key) return;
276 workspaceRefreshKeyRef.current = null;303 workspaceRefreshKeyRef.current = null;
277 workspaceRefreshRetryRef.current = {304 setWorkspaceRefreshRetry({
278 key,305 key,
279 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,306 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
280 };307 });
281 };308 };
282 void refreshProviders({309 void refreshProviders({
283 environmentId,310 environmentId,
...@@ -285,9 +312,12 @@ export function useComposerCommandMenu({...@@ -285,9 +312,12 @@ export function useComposerCommandMenu({
285 }).then((result) => {312 }).then((result) => {
286 const refreshed =313 const refreshed =
287 result._tag === "Success" &&314 result._tag === "Success" &&
288 result.value.providers315 hasCompleteProviderWorkspaceSnapshot(
289 .find((provider) => provider.instanceId === selectedProviderInstanceId)316 result.value.providers.find(
290 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd);317 (provider) => provider.instanceId === selectedProviderInstanceId,
318 ),
319 projectCwd,
320 );
291 if (!refreshed && workspaceRefreshKeyRef.current === key) {321 if (!refreshed && workspaceRefreshKeyRef.current === key) {
292 retryLater();322 retryLater();
293 }323 }
...@@ -299,6 +329,7 @@ export function useComposerCommandMenu({...@@ -299,6 +329,7 @@ export function useComposerCommandMenu({
299 projectCwd,329 projectCwd,
300 refreshProviders,330 refreshProviders,
301 selectedProviderInstanceId,331 selectedProviderInstanceId,
332 workspaceRefreshRetry,
302 ]);333 ]);
303334
304 const trigger = useMemo(() => {335 const trigger = useMemo(() => {
apps/server/src/provider/Drivers/ClaudeDriver.ts+8-11
...@@ -41,6 +41,7 @@ import {...@@ -41,6 +41,7 @@ import {
41 checkClaudeProviderStatus,41 checkClaudeProviderStatus,
42 makePendingClaudeProvider,42 makePendingClaudeProvider,
43 probeClaudeCapabilities,43 probeClaudeCapabilities,
44 probeClaudeWorkspaceSnapshot,
44} from "../Layers/ClaudeProvider.ts";45} from "../Layers/ClaudeProvider.ts";
45import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";46import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
46import * as ModelManifest from "../ModelManifest.ts";47import * as ModelManifest from "../ModelManifest.ts";
...@@ -69,7 +70,6 @@ import {...@@ -69,7 +70,6 @@ import {
69 makeClaudeContinuationGroupKey,70 makeClaudeContinuationGroupKey,
70 resolveClaudeHomePath,71 resolveClaudeHomePath,
71} from "./ClaudeHome.ts";72} from "./ClaudeHome.ts";
72import { discoverClaudeSkills } from "./ClaudeSkills.ts";
73const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);73const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
7474
75const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");75const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
...@@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
348 snapshot,348 snapshot,
349 invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),349 invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
350 snapshotForCwd: (cwd: string) =>350 snapshotForCwd: (cwd: string) =>
351 !effectiveConfig.enabled351 snapshot.getSnapshot.pipe(
352 ? snapshot.getSnapshot352 Effect.flatMap((machineSnapshot) =>
353 : Effect.all([353 probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv),
354 snapshot.getSnapshot,354 ),
355 discoverClaudeSkills(effectiveConfig, cwd, processEnv),355 Effect.provideService(FileSystem.FileSystem, fileSystem),
356 ]).pipe(356 Effect.provideService(Path.Path, path),
357 Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),357 ),
358 Effect.provideService(FileSystem.FileSystem, fileSystem),
359 Effect.provideService(Path.Path, path),
360 ),
361 orchestrationAdapter,358 orchestrationAdapter,
362 textGeneration,359 textGeneration,
363 consumeResetCredit,360 consumeResetCredit,
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+184-1
...@@ -4,7 +4,12 @@ import { vi } from "vite-plus/test";...@@ -4,7 +4,12 @@ import { vi } from "vite-plus/test";
4import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";5import * as Fiber from "effect/Fiber";
6import * as TestClock from "effect/testing/TestClock";6import * as TestClock from "effect/testing/TestClock";
7import { ClaudeSettings } from "@t3tools/contracts";7import {
8 ClaudeSettings,
9 ProviderDriverKind,
10 ProviderInstanceId,
11 type ServerProvider,
12} from "@t3tools/contracts";
8import * as NodeFSP from "node:fs/promises";13import * as NodeFSP from "node:fs/promises";
9import * as NodeServices from "@effect/platform-node/NodeServices";14import * as NodeServices from "@effect/platform-node/NodeServices";
10import { assert, it } from "@effect/vitest";15import { assert, it } from "@effect/vitest";
...@@ -17,7 +22,9 @@ import {...@@ -17,7 +22,9 @@ import {
17 buildClaudeCapabilitiesProbeQueryOptions,22 buildClaudeCapabilitiesProbeQueryOptions,
18 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,23 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
19 probeClaudeCapabilities,24 probeClaudeCapabilities,
25 probeClaudeWorkspaceSnapshot,
20} from "./ClaudeProvider.ts";26} from "./ClaudeProvider.ts";
27import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
2128
22vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });29vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
2330
...@@ -53,6 +60,182 @@ it("isolates Claude capability probes without dropping workspace setting sources...@@ -53,6 +60,182 @@ it("isolates Claude capability probes without dropping workspace setting sources
53});60});
5461
55it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {62it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
63 it.effect(
64 "discovers commands and skills separately for each cwd without replacing machine metadata",
65 () =>
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" });
70 const configDir = path.join(tempDir, "claude-home");
71 const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")];
72 for (const cwd of workspaces) {
73 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
74 yield* fs.makeDirectory(skillDir, { recursive: true });
75 yield* fs.writeFileString(
76 path.join(skillDir, "SKILL.md"),
77 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
78 );
79 }
80 const machineSnapshot = {
81 instanceId: ProviderInstanceId.make("claude"),
82 driver: ProviderDriverKind.make("claudeAgent"),
83 enabled: true,
84 installed: true,
85 status: "ready",
86 auth: { status: "authenticated", email: "machine@example.com" },
87 checkedAt: "2026-03-25T00:00:00.000Z",
88 version: "2.1.288",
89 models: [],
90 slashCommands: [{ name: "server-cwd-only" }],
91 skills: [],
92 } satisfies ServerProvider;
93 let usageCalls = 0;
94 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
95 assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir);
96 assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance");
97 return {
98 initializationResult: async () => ({
99 account: { email: "workspace@example.com" },
100 commands: [
101 {
102 name: options?.cwd === workspaces[0] ? "start-session" : "other-project",
103 description: "Project command",
104 argumentHint: "[topic]",
105 },
106 { name: "nested:review", description: "Review changes", argumentHint: "" },
107 { name: "NESTED:review", description: "", argumentHint: "[path]" },
108 { name: "compact", description: "Provider compact", argumentHint: "" },
109 { name: "user-command", description: "Existing user command", argumentHint: "" },
110 ],
111 }),
112 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => {
113 usageCalls++;
114 return { rate_limits_available: false, rate_limits: null };
115 },
116 } as ReturnType<typeof ClaudeSdk.query>;
117 });
118 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
119 for (const [index, cwd] of workspaces.entries()) {
120 const scoped = yield* probeClaudeWorkspaceSnapshot(
121 decodeClaudeSettings({ homePath: configDir }),
122 machineSnapshot,
123 cwd,
124 { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" },
125 );
126 assert.deepEqual(scoped, {
127 ...machineSnapshot,
128 slashCommandsPending: false,
129 slashCommands: [
130 COMPACT_SLASH_COMMAND,
131 {
132 name: index === 0 ? "start-session" : "other-project",
133 description: "Project command",
134 input: { hint: "[topic]" },
135 },
136 { name: "nested:review", description: "Review changes", input: { hint: "[path]" } },
137 { name: "user-command", description: "Existing user command" },
138 ],
139 skills: [
140 {
141 name: "existing-skill",
142 path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"),
143 enabled: true,
144 scope: "project",
145 description: "Existing project skill",
146 },
147 ],
148 });
149 }
150 assert.deepEqual(
151 query.mock.calls.map(([input]) => input.options?.cwd),
152 workspaces,
153 );
154 assert.equal(usageCalls, 0);
155 }).pipe(Effect.scoped),
156 );
157
158 it.effect("keeps readable skills during failed command discovery and recovers on retry", () =>
159 Effect.gen(function* () {
160 const fs = yield* FileSystem.FileSystem;
161 const path = yield* Path.Path;
162 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" });
163 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
164 yield* fs.makeDirectory(skillDir, { recursive: true });
165 yield* fs.writeFileString(
166 path.join(skillDir, "SKILL.md"),
167 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
168 );
169 const machineSnapshot = {
170 instanceId: ProviderInstanceId.make("claude"),
171 driver: ProviderDriverKind.make("claudeAgent"),
172 enabled: true,
173 installed: true,
174 status: "ready",
175 auth: { status: "authenticated" },
176 checkedAt: "2026-03-25T00:00:00.000Z",
177 version: "2.1.288",
178 models: [],
179 slashCommands: [{ name: "server-cwd-only" }],
180 skills: [],
181 } satisfies ServerProvider;
182 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(
183 () =>
184 ({
185 initializationResult: () =>
186 Promise.reject<ClaudeSdk.SDKControlInitializeResponse>(
187 new Error("Initialization failed"),
188 ),
189 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
190 rate_limits_available: false,
191 rate_limits: null,
192 }),
193 }) as ReturnType<typeof ClaudeSdk.query>,
194 );
195 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
196 const settings = decodeClaudeSettings({ homePath: cwd });
197 const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
198 assert.deepEqual(failed, {
199 ...machineSnapshot,
200 slashCommands: [COMPACT_SLASH_COMMAND],
201 slashCommandsPending: true,
202 skills: [
203 {
204 name: "existing-skill",
205 path: path.join(skillDir, "SKILL.md"),
206 enabled: true,
207 scope: "project",
208 description: "Existing project skill",
209 },
210 ],
211 });
212 query.mockImplementation(
213 () =>
214 ({
215 initializationResult: async () => ({
216 commands: [{ name: "recovered", description: "", argumentHint: "" }],
217 }),
218 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
219 rate_limits_available: false,
220 rate_limits: null,
221 }),
222 }) as ReturnType<typeof ClaudeSdk.query>,
223 );
224 const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
225 assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]);
226 assert.equal(recovered.status, "ready");
227 assert.equal(recovered.slashCommandsPending, false);
228 assert.deepEqual(recovered.skills, failed.skills);
229 const disabled = yield* probeClaudeWorkspaceSnapshot(
230 { ...settings, enabled: false },
231 machineSnapshot,
232 cwd,
233 );
234 assert.equal(disabled, machineSnapshot);
235 assert.equal(query.mock.calls.length, 2);
236 }).pipe(Effect.scoped),
237 );
238
56 it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>239 it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
57 Effect.gen(function* () {240 Effect.gen(function* () {
58 const fs = yield* FileSystem.FileSystem;241 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/Layers/ClaudeProvider.ts+35-8
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 type ClaudeSettings,2 type ClaudeSettings,
3 type ModelCapabilities,3 type ModelCapabilities,
4 type ServerProvider,
4 type ServerProviderSlashCommand,5 type ServerProviderSlashCommand,
5 type ServerProviderResetCredits,6 type ServerProviderResetCredits,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -36,6 +37,7 @@ import {...@@ -36,6 +37,7 @@ import {
36import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";37import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
37import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";38import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
38import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";39import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
40import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
39import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";41import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
40import {42import {
41 type ClaudeScopedLimitNames,43 type ClaudeScopedLimitNames,
...@@ -333,6 +335,7 @@ const probeClaudeCapabilities = (...@@ -333,6 +335,7 @@ const probeClaudeCapabilities = (
333 claudeSettings: ClaudeSettings,335 claudeSettings: ClaudeSettings,
334 environment?: NodeJS.ProcessEnv,336 environment?: NodeJS.ProcessEnv,
335 cwd?: string,337 cwd?: string,
338 includeUsage = true,
336) => {339) => {
337 const abort = new AbortController();340 const abort = new AbortController();
338 return Effect.gen(function* () {341 return Effect.gen(function* () {
...@@ -364,15 +367,18 @@ const probeClaudeCapabilities = (...@@ -364,15 +367,18 @@ const probeClaudeCapabilities = (
364 Effect.flatMap(({ q, init }) =>367 Effect.flatMap(({ q, init }) =>
365 Effect.gen(function* () {368 Effect.gen(function* () {
366 // Usage has its own deadline so a slow optional request cannot discard initialization.369 // Usage has its own deadline so a slow optional request cannot discard initialization.
367 const usageResult = yield* Effect.tryPromise(() =>370 const usageResult = includeUsage
368 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),371 ? yield* Effect.tryPromise(() =>
369 ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result);372 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
370 const usage = Result.isSuccess(usageResult)373 ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result)
371 ? {
372 rate_limits_available: usageResult.success.rate_limits_available,
373 rate_limits: usageResult.success.rate_limits,
374 }
375 : undefined;374 : undefined;
375 const usage =
376 usageResult && Result.isSuccess(usageResult)
377 ? {
378 rate_limits_available: usageResult.success.rate_limits_available,
379 rate_limits: usageResult.success.rate_limits,
380 }
381 : undefined;
376 const account = init.account as382 const account = init.account as
377 | {383 | {
378 readonly email?: string;384 readonly email?: string;
...@@ -417,6 +423,27 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (...@@ -417,6 +423,27 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
417 return yield* spawnAndCollect(claudeSettings.binaryPath, command);423 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
418});424});
419425
426/** Read commands from the same cwd Claude uses for a workspace session. */
427export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* (
428 claudeSettings: ClaudeSettings,
429 machineSnapshot: ServerProvider,
430 cwd: string,
431 environment?: NodeJS.ProcessEnv,
432): Effect.fn.Return<ProviderWorkspaceSnapshot, never, FileSystem.FileSystem | Path.Path> {
433 if (!claudeSettings.enabled) return machineSnapshot;
434 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment);
435 const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false);
436 return {
437 ...machineSnapshot,
438 skills,
439 slashCommands: dedupeSlashCommands([
440 COMPACT_SLASH_COMMAND,
441 ...(capabilities?.slashCommands ?? []),
442 ]),
443 slashCommandsPending: !capabilities,
444 };
445});
446
420export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (447export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
421 claudeSettings: ClaudeSettings,448 claudeSettings: ClaudeSettings,
422 resolveCapabilities?: (449 resolveCapabilities?: (
apps/server/src/provider/Layers/ProviderRegistry.test.ts+56-4
...@@ -63,7 +63,7 @@ import {...@@ -63,7 +63,7 @@ import {
63 writeProviderStatusCache,63 writeProviderStatusCache,
64} from "../providerStatusCache.ts";64} from "../providerStatusCache.ts";
65import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";65import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
66import type { ProviderInstance } from "../ProviderDriver.ts";66import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
67import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";67import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
68import * as ProviderRegistry from "../Services/ProviderRegistry.ts";68import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
69import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";69import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
...@@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test...@@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
641 skills: scopedSnapshot.skills,641 skills: scopedSnapshot.skills,
642 },642 },
643 ]);643 ]);
644
645 const pendingSnapshot = {
646 ...scopedSnapshot,
647 slashCommands: [COMPACT_SLASH_COMMAND],
648 slashCommandsPending: true,
649 } satisfies ProviderWorkspaceSnapshot;
650 const partial = upsertProviderWorkspaceSnapshot(result, "/project", pendingSnapshot);
651 assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [
652 { name: "project" },
653 ]);
654 assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true);
655 const otherProject = upsertProviderWorkspaceSnapshot(
656 partial,
657 "/other-project",
658 pendingSnapshot,
659 );
660 assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [
661 COMPACT_SLASH_COMMAND,
662 ]);
663 const recovered = upsertProviderWorkspaceSnapshot(partial, "/project", {
664 ...scopedSnapshot,
665 slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }],
666 });
667 assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [
668 COMPACT_SLASH_COMMAND,
669 { name: "replacement" },
670 ]);
671 assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined);
644 });672 });
645673
646 it("preserves previously discovered provider models when a refresh returns none", () => {674 it("preserves previously discovered provider models when a refresh returns none", () => {
...@@ -1573,7 +1601,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test...@@ -1573,7 +1601,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
1573 slashCommands: [],1601 slashCommands: [],
1574 } as const satisfies ServerProvider;1602 } as const satisfies ServerProvider;
1575 const snapshotCalls = yield* Ref.make(0);1603 const snapshotCalls = yield* Ref.make(0);
1576 const scopedResult = yield* Ref.make<ServerProvider>(scopedProvider);1604 const scopedResult = yield* Ref.make<ProviderWorkspaceSnapshot>({
1605 ...scopedProvider,
1606 status: "error",
1607 slashCommands: [COMPACT_SLASH_COMMAND],
1608 slashCommandsPending: true,
1609 });
1577 const cacheInvalidations = yield* Ref.make(0);1610 const cacheInvalidations = yield* Ref.make(0);
1578 const scanGate = yield* Ref.make<{1611 const scanGate = yield* Ref.make<{
1579 readonly started: Deferred.Deferred<void>;1612 readonly started: Deferred.Deferred<void>;
...@@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test...@@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
1698 providers[0]?.workspaceSnapshots?.[0]?.skills,1731 providers[0]?.workspaceSnapshots?.[0]?.skills,
1699 scopedProvider.skills,1732 scopedProvider.skills,
1700 );1733 );
1734 assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [
1735 COMPACT_SLASH_COMMAND,
1736 ]);
1737 assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true);
1738 yield* Ref.set(scopedResult, {
1739 ...scopedProvider,
1740 status: "error",
1741 slashCommandsPending: false,
1742 });
1743 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1744 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1745 assert.deepStrictEqual(
1746 (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands,
1747 scopedProvider.slashCommands,
1748 );
1749 assert.strictEqual(
1750 (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending,
1751 undefined,
1752 );
1701 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });1753 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1702 assert.strictEqual(yield* Ref.get(snapshotCalls), 2);1754 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1703 const newSkills = [1755 const newSkills = [
1704 ...scopedProvider.skills,1756 ...scopedProvider.skills,
1705 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },1757 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },
...@@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test...@@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
1710 cwd: "/workspace",1762 cwd: "/workspace",
1711 fresh: true,1763 fresh: true,
1712 });1764 });
1713 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);1765 assert.strictEqual(yield* Ref.get(snapshotCalls), 4);
1714 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);1766 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);
1715 assert.deepStrictEqual(1767 assert.deepStrictEqual(
1716 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),1768 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
apps/server/src/provider/Layers/ProviderRegistry.ts+14-7
...@@ -53,7 +53,7 @@ import {...@@ -53,7 +53,7 @@ import {
53 resolveProviderStatusCachePath,53 resolveProviderStatusCachePath,
54 writeProviderStatusCache,54 writeProviderStatusCache,
55} from "../providerStatusCache.ts";55} from "../providerStatusCache.ts";
56import type { ProviderInstance } from "../ProviderDriver.ts";56import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
57import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";57import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
58import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";58import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
5959
...@@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S...@@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S
94export function upsertProviderWorkspaceSnapshot(94export function upsertProviderWorkspaceSnapshot(
95 provider: ServerProvider,95 provider: ServerProvider,
96 cwd: string,96 cwd: string,
97 scopedSnapshot: ServerProvider,97 scopedSnapshot: ProviderWorkspaceSnapshot,
98): ServerProvider {98): ServerProvider {
99 const workspaceSnapshot = {99 const workspaceSnapshot = {
100 cwd,100 cwd,
101 checkedAt: scopedSnapshot.checkedAt,101 checkedAt: scopedSnapshot.checkedAt,
102 slashCommands: scopedSnapshot.slashCommands,102 slashCommands: scopedSnapshot.slashCommandsPending
103 ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ??
104 scopedSnapshot.slashCommands)
105 : scopedSnapshot.slashCommands,
106 ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}),
103 skills: scopedSnapshot.skills,107 skills: scopedSnapshot.skills,
104 } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number];108 } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number];
105 return {109 return {
...@@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect(...@@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect(
918 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>922 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>
919 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);923 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);
920 const scannedFrom = workspaceSnapshotOf(provider);924 const scannedFrom = workspaceSnapshotOf(provider);
921 if (!provider || !provider.enabled || (!input.fresh && scannedFrom)) {925 if (
926 !provider ||
927 !provider.enabled ||
928 (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending)
929 ) {
922 return providers;930 return providers;
923 }931 }
924 const instance = yield* instanceRegistry.getInstance(input.instanceId);932 const instance = yield* instanceRegistry.getInstance(input.instanceId);
...@@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect(...@@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect(
932 });940 });
933 // A fresh scan never joins a running one, which may predate the change.941 // A fresh scan never joins a running one, which may predate the change.
934 if (!claimed && !input.fresh) return yield* Ref.get(providersRef);942 if (!claimed && !input.fresh) return yield* Ref.get(providersRef);
935 // Fresh scans also re-read the machine snapshot: Claude's plugin943 // Fresh scans also re-read the machine snapshot after invalidating caches.
936 // commands come from it, not from the cwd scan.
937 const refreshMachineSnapshot = input.fresh944 const refreshMachineSnapshot = input.fresh
938 ? (instance.invalidateCaches ?? Effect.void).pipe(945 ? (instance.invalidateCaches ?? Effect.void).pipe(
939 Effect.andThen(refreshInstance(input.instanceId)),946 Effect.andThen(refreshInstance(input.instanceId)),
...@@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect(...@@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect(
942 return yield* refreshMachineSnapshot.pipe(949 return yield* refreshMachineSnapshot.pipe(
943 Effect.andThen(instance.snapshotForCwd(input.cwd)),950 Effect.andThen(instance.snapshotForCwd(input.cwd)),
944 Effect.flatMap((scopedSnapshot) =>951 Effect.flatMap((scopedSnapshot) =>
945 scopedSnapshot.status === "error"952 scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined
946 ? Ref.get(providersRef)953 ? Ref.get(providersRef)
947 : instanceRegistry.getInstance(input.instanceId).pipe(954 : instanceRegistry.getInstance(input.instanceId).pipe(
948 Effect.flatMap((currentInstance) => {955 Effect.flatMap((currentInstance) => {
apps/server/src/provider/ProviderDriver.ts+7-1
...@@ -31,6 +31,7 @@ import type {...@@ -31,6 +31,7 @@ import type {
31 ProviderInstanceEnvironment,31 ProviderInstanceEnvironment,
32 ProviderInstanceId,32 ProviderInstanceId,
33 ServerProvider,33 ServerProvider,
34 ServerProviderWorkspaceSnapshot,
34} from "@t3tools/contracts";35} from "@t3tools/contracts";
35import type * as Effect from "effect/Effect";36import type * as Effect from "effect/Effect";
36import type * as Schema from "effect/Schema";37import type * as Schema from "effect/Schema";
...@@ -59,6 +60,9 @@ export interface ProviderDriverMetadata {...@@ -59,6 +60,9 @@ export interface ProviderDriverMetadata {
59 readonly supportsMultipleInstances?: boolean;60 readonly supportsMultipleInstances?: boolean;
60}61}
6162
63export type ProviderWorkspaceSnapshot = ServerProvider &
64 Pick<ServerProviderWorkspaceSnapshot, "slashCommandsPending">;
65
62/**66/**
63 * One materialized provider instance. Held by the registry, looked up by67 * One materialized provider instance. Held by the registry, looked up by
64 * `instanceId`, torn down by closing the scope it was created in.68 * `instanceId`, torn down by closing the scope it was created in.
...@@ -76,7 +80,9 @@ export interface ProviderInstance {...@@ -76,7 +80,9 @@ export interface ProviderInstance {
76 readonly accentColor?: string | undefined;80 readonly accentColor?: string | undefined;
77 readonly enabled: boolean;81 readonly enabled: boolean;
78 readonly snapshot: ServerProviderShape;82 readonly snapshot: ServerProviderShape;
79 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;83 readonly snapshotForCwd?: (
84 cwd: string,
85 ) => Effect.Effect<ProviderWorkspaceSnapshot, ProviderDriverError>;
80 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;86 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
81 /** Invalidate T3-owned discovery caches before an explicit provider refresh. */87 /** Invalidate T3-owned discovery caches before an explicit provider refresh. */
82 readonly invalidateCaches?: Effect.Effect<void>;88 readonly invalidateCaches?: Effect.Effect<void>;
apps/web/src/components/chat/ChatComposer.tsx+53-15
...@@ -1121,6 +1121,7 @@ import {...@@ -1121,6 +1121,7 @@ import {
1121 formatProviderSkillDisplayName,1121 formatProviderSkillDisplayName,
1122 getProviderSlashCommandsForSlashMenu,1122 getProviderSlashCommandsForSlashMenu,
1123 getProviderSkillsForSlashMenu,1123 getProviderSkillsForSlashMenu,
1124 hasCompleteProviderWorkspaceSnapshot,
1124 resolveProviderSkillsForCwd,1125 resolveProviderSkillsForCwd,
1125 resolveProviderSlashCommandsForCwd,1126 resolveProviderSlashCommandsForCwd,
1126} from "@t3tools/client-runtime/providerSkills";1127} from "@t3tools/client-runtime/providerSkills";
...@@ -2179,41 +2180,68 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2179,41 +2180,68 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2179 reportFailure: false,2180 reportFailure: false,
2180 });2181 });
2181 const workspaceRefreshKeyRef = useRef<string | null>(null);2182 const workspaceRefreshKeyRef = useRef<string | null>(null);
2182 const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null);2183 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
2184 key: string;
2185 notBefore: number;
2186 } | null>(null);
2187 const workspaceRefreshScopeKey =
2188 gitCwd && selectedProviderEntry
2189 ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`
2190 : null;
2191 const workspaceSlashCommandsPending =
2192 selectedProviderStatus?.workspaceSnapshots?.some(
2193 (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true,
2194 ) ?? false;
2195 useEffect(() => {
2196 if (
2197 !workspaceSlashCommandsPending ||
2198 !workspaceRefreshRetry ||
2199 workspaceRefreshRetry.key !== workspaceRefreshScopeKey
2200 )
2201 return;
2202 const timeout = setTimeout(
2203 () => {
2204 setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current));
2205 },
2206 Math.max(0, workspaceRefreshRetry.notBefore - Date.now()),
2207 );
2208 return () => clearTimeout(timeout);
2209 }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]);
2183 const hadWorkspaceSnapshotRef = useRef(false);2210 const hadWorkspaceSnapshotRef = useRef(false);
2184 useEffect(() => {2211 useEffect(() => {
2185 const hasWorkspaceSnapshot = Boolean(2212 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
2186 gitCwd &&2213 selectedProviderStatus,
2187 selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd),2214 gitCwd,
2188 );2215 );
2189 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {2216 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
2190 workspaceRefreshKeyRef.current = null;2217 workspaceRefreshKeyRef.current = null;
2191 workspaceRefreshRetryRef.current = null;2218 setWorkspaceRefreshRetry(null);
2192 }2219 }
2193 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;2220 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
2194 }, [gitCwd, selectedProviderStatus]);2221 }, [gitCwd, selectedProviderStatus]);
2195 useEffect(() => {2222 useEffect(() => {
2196 if (!gitCwd || !selectedProviderEntry) return;2223 if (!gitCwd || !selectedProviderEntry) return;
2197 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;2224 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;
2198 const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some(2225 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
2199 (snapshot) => snapshot.cwd === gitCwd,2226 selectedProviderStatus,
2227 gitCwd,
2200 );2228 );
2201 if (workspaceRefreshKeyRef.current === key) return;2229 if (workspaceRefreshKeyRef.current === key) return;
2202 if (hasWorkspaceSnapshot) {2230 if (hasWorkspaceSnapshot) {
2203 workspaceRefreshKeyRef.current = key;2231 workspaceRefreshKeyRef.current = key;
2204 workspaceRefreshRetryRef.current = null;2232 setWorkspaceRefreshRetry(null);
2205 return;2233 return;
2206 }2234 }
2207 const retry = workspaceRefreshRetryRef.current;2235 const retry = workspaceRefreshRetry;
2208 if (retry?.key === key && Date.now() < retry.notBefore) return;2236 if (retry?.key === key && Date.now() < retry.notBefore) return;
2209 workspaceRefreshKeyRef.current = key;2237 workspaceRefreshKeyRef.current = key;
2210 const retryLater = () => {2238 const retryLater = () => {
2211 if (workspaceRefreshKeyRef.current !== key) return;2239 if (workspaceRefreshKeyRef.current !== key) return;
2212 workspaceRefreshKeyRef.current = null;2240 workspaceRefreshKeyRef.current = null;
2213 workspaceRefreshRetryRef.current = {2241 setWorkspaceRefreshRetry({
2214 key,2242 key,
2215 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,2243 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
2216 };2244 });
2217 };2245 };
2218 void refreshProviders({2246 void refreshProviders({
2219 environmentId,2247 environmentId,
...@@ -2221,14 +2249,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2221,14 +2249,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2221 }).then((result) => {2249 }).then((result) => {
2222 const hasWorkspaceSnapshot =2250 const hasWorkspaceSnapshot =
2223 result._tag === "Success" &&2251 result._tag === "Success" &&
2224 result.value.providers2252 hasCompleteProviderWorkspaceSnapshot(
2225 .find((provider) => provider.instanceId === selectedProviderEntry.instanceId)2253 result.value.providers.find(
2226 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd);2254 (provider) => provider.instanceId === selectedProviderEntry.instanceId,
2255 ),
2256 gitCwd,
2257 );
2227 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {2258 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {
2228 retryLater();2259 retryLater();
2229 }2260 }
2230 }, retryLater);2261 }, retryLater);
2231 }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]);2262 }, [
2263 environmentId,
2264 gitCwd,
2265 prompt,
2266 refreshProviders,
2267 selectedProviderEntry,
2268 workspaceRefreshRetry,
2269 ]);
2232 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(2270 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
2233 () => selectedProviderEntry?.models ?? [],2271 () => selectedProviderEntry?.models ?? [],
2234 [selectedProviderEntry],2272 [selectedProviderEntry],
packages/client-runtime/src/providerSkills.test.ts+23
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 formatProviderSkillDisplayName,6 formatProviderSkillDisplayName,
7 getProviderSlashCommandsForSlashMenu,7 getProviderSlashCommandsForSlashMenu,
8 getProviderSkillsForSlashMenu,8 getProviderSkillsForSlashMenu,
9 hasCompleteProviderWorkspaceSnapshot,
9 resolveProviderSkillsForCwd,10 resolveProviderSkillsForCwd,
10 resolveProviderSlashCommandsForCwd,11 resolveProviderSlashCommandsForCwd,
11 resolveProviderSkillSourceKind,12 resolveProviderSkillSourceKind,
...@@ -251,4 +252,26 @@ describe("workspace provider snapshots", () => {...@@ -251,4 +252,26 @@ describe("workspace provider snapshots", () => {
251 expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills);252 expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills);
252 expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands);253 expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands);
253 });254 });
255
256 it("uses partial workspace skills and commands while keeping discovery retryable", () => {
257 const partial = {
258 ...provider,
259 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
260 ...snapshot,
261 slashCommands: [{ name: "compact" }],
262 slashCommandsPending: true,
263 })),
264 } satisfies ServerProvider;
265 expect(resolveProviderSkillsForCwd(partial, "/workspace/project-a")).toEqual(
266 provider.workspaceSnapshots[0]?.skills,
267 );
268 expect(resolveProviderSlashCommandsForCwd(partial, "/workspace/project-a")).toEqual([
269 { name: "compact" },
270 ]);
271 expect(hasCompleteProviderWorkspaceSnapshot(partial, "/workspace/project-a")).toBe(false);
272 expect(hasCompleteProviderWorkspaceSnapshot(provider, "/workspace/project-a")).toBe(true);
273 expect(hasCompleteProviderWorkspaceSnapshot(provider, "/workspace/project-b")).toBe(false);
274 expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false);
275 expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false);
276 });
254});277});
packages/client-runtime/src/providerSkills.ts+8
...@@ -111,6 +111,14 @@ function resolveProviderWorkspaceSnapshot(...@@ -111,6 +111,14 @@ function resolveProviderWorkspaceSnapshot(
111 return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd);111 return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd);
112}112}
113113
114export function hasCompleteProviderWorkspaceSnapshot(
115 provider: ServerProvider | null | undefined,
116 cwd: string | null | undefined,
117): boolean {
118 const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd);
119 return Boolean(snapshot && !snapshot.slashCommandsPending);
120}
121
114export function resolveProviderSkillsForCwd(122export function resolveProviderSkillsForCwd(
115 provider: ServerProvider,123 provider: ServerProvider,
116 cwd: string | null | undefined,124 cwd: string | null | undefined,
packages/contracts/src/server.test.ts+15
...@@ -31,6 +31,21 @@ const baseProviderSnapshot = {...@@ -31,6 +31,21 @@ const baseProviderSnapshot = {
31};31};
3232
33describe("ServerProvider", () => {33describe("ServerProvider", () => {
34 it.each([undefined, true, false])("decodes workspace command discovery pending=%s", (pending) => {
35 const workspace = {
36 cwd: "/workspace/project",
37 checkedAt: baseProviderSnapshot.checkedAt,
38 slashCommands: [{ name: "compact" }],
39 ...(pending === undefined ? {} : { slashCommandsPending: pending }),
40 skills: [{ name: "project", path: "/workspace/project/SKILL.md", enabled: true }],
41 };
42 const parsed = decodeServerProvider({
43 ...baseProviderSnapshot,
44 workspaceSnapshots: [workspace],
45 });
46 expect(parsed.workspaceSnapshots).toEqual([workspace]);
47 });
48
34 it("defaults capability arrays when decoding provider snapshots", () => {49 it("defaults capability arrays when decoding provider snapshots", () => {
35 const parsed = decodeServerProvider({50 const parsed = decodeServerProvider({
36 instanceId: "codex",51 instanceId: "codex",
packages/contracts/src/server.ts+2
...@@ -125,6 +125,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({...@@ -125,6 +125,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({
125 cwd: TrimmedNonEmptyString,125 cwd: TrimmedNonEmptyString,
126 checkedAt: IsoDateTime,126 checkedAt: IsoDateTime,
127 slashCommands: Schema.Array(ServerProviderSlashCommand),127 slashCommands: Schema.Array(ServerProviderSlashCommand),
128 /** Skills are available, but command discovery still needs a retry. */
129 slashCommandsPending: Schema.optional(Schema.Boolean),
128 skills: Schema.Array(ServerProviderSkill),130 skills: Schema.Array(ServerProviderSkill),
129});131});
130export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;132export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;