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 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import { ProviderDriverKind } from "@t3tools/contracts";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
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());
312vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
413
514vi.mock("../../state/queries", () => ({
......@@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({
817}));
918vi.mock("../../state/use-composer-drafts", () => ({
1019 getComposerDraftSnapshot: vi.fn(),
20 readComposerDraftSelection: vi.fn(),
1121 setComposerDraftContext: vi.fn(),
1222}));
1323vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));
......@@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({
1525 serverEnvironment: { refreshProviders: Symbol("refreshProviders") },
1626}));
1727vi.mock("../../state/use-atom-command", () => ({
18 useAtomCommand: () => vi.fn(),
28 useAtomCommand: () => refreshProviders,
1929}));
2030
2131import {
2232 buildComposerSlashCommandItems,
2333 resolveComposerCommandSelection,
34 useComposerCommandMenu,
2435} from "./use-composer-command-menu";
2536
2637describe("mobile slash commands", () => {
......@@ -101,3 +112,213 @@ describe("mobile slash commands", () => {
101112 ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
102113 });
103114});
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 {
3636 getProviderSkillsForSlashMenu,
3737 getProviderSlashCommandsForSlashMenu,
3838 isProviderSkillUserInvocable,
39 hasCompleteProviderWorkspaceSnapshot,
3940 resolveProviderSkillsForCwd,
4041 resolveProviderSlashCommandsForCwd,
4142} from "@t3tools/client-runtime/providerSkills";
......@@ -245,17 +246,43 @@ export function useComposerCommandMenu({
245246 reportFailure: false,
246247 });
247248 const selectedProviderInstanceId = selectedProviderStatus?.instanceId;
248 const hasWorkspaceSnapshot = Boolean(
249 projectCwd &&
250 selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd),
249 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
250 selectedProviderStatus,
251 projectCwd,
251252 );
252253 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]);
254281 const hadWorkspaceSnapshotRef = useRef(false);
255282 useEffect(() => {
256283 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
257284 workspaceRefreshKeyRef.current = null;
258 workspaceRefreshRetryRef.current = null;
285 setWorkspaceRefreshRetry(null);
259286 }
260287 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
261288 }, [hasWorkspaceSnapshot]);
......@@ -265,19 +292,19 @@ export function useComposerCommandMenu({
265292 if (workspaceRefreshKeyRef.current === key) return;
266293 if (hasWorkspaceSnapshot) {
267294 workspaceRefreshKeyRef.current = key;
268 workspaceRefreshRetryRef.current = null;
295 setWorkspaceRefreshRetry(null);
269296 return;
270297 }
271 const retry = workspaceRefreshRetryRef.current;
298 const retry = workspaceRefreshRetry;
272299 if (retry?.key === key && Date.now() < retry.notBefore) return;
273300 workspaceRefreshKeyRef.current = key;
274301 const retryLater = () => {
275302 if (workspaceRefreshKeyRef.current !== key) return;
276303 workspaceRefreshKeyRef.current = null;
277 workspaceRefreshRetryRef.current = {
304 setWorkspaceRefreshRetry({
278305 key,
279306 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
280 };
307 });
281308 };
282309 void refreshProviders({
283310 environmentId,
......@@ -285,9 +312,12 @@ export function useComposerCommandMenu({
285312 }).then((result) => {
286313 const refreshed =
287314 result._tag === "Success" &&
288 result.value.providers
289 .find((provider) => provider.instanceId === selectedProviderInstanceId)
290 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd);
315 hasCompleteProviderWorkspaceSnapshot(
316 result.value.providers.find(
317 (provider) => provider.instanceId === selectedProviderInstanceId,
318 ),
319 projectCwd,
320 );
291321 if (!refreshed && workspaceRefreshKeyRef.current === key) {
292322 retryLater();
293323 }
......@@ -299,6 +329,7 @@ export function useComposerCommandMenu({
299329 projectCwd,
300330 refreshProviders,
301331 selectedProviderInstanceId,
332 workspaceRefreshRetry,
302333 ]);
303334
304335 const trigger = useMemo(() => {
apps/server/src/provider/Drivers/ClaudeDriver.ts+8-11
......@@ -41,6 +41,7 @@ import {
4141 checkClaudeProviderStatus,
4242 makePendingClaudeProvider,
4343 probeClaudeCapabilities,
44 probeClaudeWorkspaceSnapshot,
4445} from "../Layers/ClaudeProvider.ts";
4546import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
4647import * as ModelManifest from "../ModelManifest.ts";
......@@ -69,7 +70,6 @@ import {
6970 makeClaudeContinuationGroupKey,
7071 resolveClaudeHomePath,
7172} from "./ClaudeHome.ts";
72import { discoverClaudeSkills } from "./ClaudeSkills.ts";
7373const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
7474
7575const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
......@@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
348348 snapshot,
349349 invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
350350 snapshotForCwd: (cwd: string) =>
351 !effectiveConfig.enabled
352 ? snapshot.getSnapshot
353 : Effect.all([
354 snapshot.getSnapshot,
355 discoverClaudeSkills(effectiveConfig, cwd, processEnv),
356 ]).pipe(
357 Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
358 Effect.provideService(FileSystem.FileSystem, fileSystem),
359 Effect.provideService(Path.Path, path),
360 ),
351 snapshot.getSnapshot.pipe(
352 Effect.flatMap((machineSnapshot) =>
353 probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv),
354 ),
355 Effect.provideService(FileSystem.FileSystem, fileSystem),
356 Effect.provideService(Path.Path, path),
357 ),
361358 orchestrationAdapter,
362359 textGeneration,
363360 consumeResetCredit,
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+184-1
......@@ -4,7 +4,12 @@ import { vi } from "vite-plus/test";
44import * as Deferred from "effect/Deferred";
55import * as Fiber from "effect/Fiber";
66import * 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";
813import * as NodeFSP from "node:fs/promises";
914import * as NodeServices from "@effect/platform-node/NodeServices";
1015import { assert, it } from "@effect/vitest";
......@@ -17,7 +22,9 @@ import {
1722 buildClaudeCapabilitiesProbeQueryOptions,
1823 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
1924 probeClaudeCapabilities,
25 probeClaudeWorkspaceSnapshot,
2026} from "./ClaudeProvider.ts";
27import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
2128
2229vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
2330
......@@ -53,6 +60,182 @@ it("isolates Claude capability probes without dropping workspace setting sources
5360});
5461
5562it.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
56239 it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
57240 Effect.gen(function* () {
58241 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/Layers/ClaudeProvider.ts+35-8
......@@ -1,6 +1,7 @@
11import {
22 type ClaudeSettings,
33 type ModelCapabilities,
4 type ServerProvider,
45 type ServerProviderSlashCommand,
56 type ServerProviderResetCredits,
67} from "@t3tools/contracts";
......@@ -36,6 +37,7 @@ import {
3637import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
3738import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
3839import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
40import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
3941import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
4042import {
4143 type ClaudeScopedLimitNames,
......@@ -333,6 +335,7 @@ const probeClaudeCapabilities = (
333335 claudeSettings: ClaudeSettings,
334336 environment?: NodeJS.ProcessEnv,
335337 cwd?: string,
338 includeUsage = true,
336339) => {
337340 const abort = new AbortController();
338341 return Effect.gen(function* () {
......@@ -364,15 +367,18 @@ const probeClaudeCapabilities = (
364367 Effect.flatMap(({ q, init }) =>
365368 Effect.gen(function* () {
366369 // Usage has its own deadline so a slow optional request cannot discard initialization.
367 const usageResult = yield* Effect.tryPromise(() =>
368 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
369 ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result);
370 const usage = Result.isSuccess(usageResult)
371 ? {
372 rate_limits_available: usageResult.success.rate_limits_available,
373 rate_limits: usageResult.success.rate_limits,
374 }
370 const usageResult = includeUsage
371 ? yield* Effect.tryPromise(() =>
372 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
373 ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result)
375374 : 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;
376382 const account = init.account as
377383 | {
378384 readonly email?: string;
......@@ -417,6 +423,27 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
417423 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
418424});
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
420447export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
421448 claudeSettings: ClaudeSettings,
422449 resolveCapabilities?: (
apps/server/src/provider/Layers/ProviderRegistry.test.ts+56-4
......@@ -63,7 +63,7 @@ import {
6363 writeProviderStatusCache,
6464} from "../providerStatusCache.ts";
6565import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
66import type { ProviderInstance } from "../ProviderDriver.ts";
66import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
6767import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
6868import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
6969import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
......@@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
641641 skills: scopedSnapshot.skills,
642642 },
643643 ]);
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);
644672 });
645673
646674 it("preserves previously discovered provider models when a refresh returns none", () => {
......@@ -1573,7 +1601,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
15731601 slashCommands: [],
15741602 } as const satisfies ServerProvider;
15751603 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 });
15771610 const cacheInvalidations = yield* Ref.make(0);
15781611 const scanGate = yield* Ref.make<{
15791612 readonly started: Deferred.Deferred<void>;
......@@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
16981731 providers[0]?.workspaceSnapshots?.[0]?.skills,
16991732 scopedProvider.skills,
17001733 );
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 );
17011753 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1702 assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1754 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
17031755 const newSkills = [
17041756 ...scopedProvider.skills,
17051757 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },
......@@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
17101762 cwd: "/workspace",
17111763 fresh: true,
17121764 });
1713 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1765 assert.strictEqual(yield* Ref.get(snapshotCalls), 4);
17141766 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);
17151767 assert.deepStrictEqual(
17161768 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
apps/server/src/provider/Layers/ProviderRegistry.ts+14-7
......@@ -53,7 +53,7 @@ import {
5353 resolveProviderStatusCachePath,
5454 writeProviderStatusCache,
5555} from "../providerStatusCache.ts";
56import type { ProviderInstance } from "../ProviderDriver.ts";
56import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
5757import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
5858import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
5959
......@@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S
9494export function upsertProviderWorkspaceSnapshot(
9595 provider: ServerProvider,
9696 cwd: string,
97 scopedSnapshot: ServerProvider,
97 scopedSnapshot: ProviderWorkspaceSnapshot,
9898): ServerProvider {
9999 const workspaceSnapshot = {
100100 cwd,
101101 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 } : {}),
103107 skills: scopedSnapshot.skills,
104108 } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number];
105109 return {
......@@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect(
918922 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>
919923 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);
920924 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 ) {
922930 return providers;
923931 }
924932 const instance = yield* instanceRegistry.getInstance(input.instanceId);
......@@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect(
932940 });
933941 // A fresh scan never joins a running one, which may predate the change.
934942 if (!claimed && !input.fresh) return yield* Ref.get(providersRef);
935 // Fresh scans also re-read the machine snapshot: Claude's plugin
936 // commands come from it, not from the cwd scan.
943 // Fresh scans also re-read the machine snapshot after invalidating caches.
937944 const refreshMachineSnapshot = input.fresh
938945 ? (instance.invalidateCaches ?? Effect.void).pipe(
939946 Effect.andThen(refreshInstance(input.instanceId)),
......@@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect(
942949 return yield* refreshMachineSnapshot.pipe(
943950 Effect.andThen(instance.snapshotForCwd(input.cwd)),
944951 Effect.flatMap((scopedSnapshot) =>
945 scopedSnapshot.status === "error"
952 scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined
946953 ? Ref.get(providersRef)
947954 : instanceRegistry.getInstance(input.instanceId).pipe(
948955 Effect.flatMap((currentInstance) => {
apps/server/src/provider/ProviderDriver.ts+7-1
......@@ -31,6 +31,7 @@ import type {
3131 ProviderInstanceEnvironment,
3232 ProviderInstanceId,
3333 ServerProvider,
34 ServerProviderWorkspaceSnapshot,
3435} from "@t3tools/contracts";
3536import type * as Effect from "effect/Effect";
3637import type * as Schema from "effect/Schema";
......@@ -59,6 +60,9 @@ export interface ProviderDriverMetadata {
5960 readonly supportsMultipleInstances?: boolean;
6061}
6162
63export type ProviderWorkspaceSnapshot = ServerProvider &
64 Pick<ServerProviderWorkspaceSnapshot, "slashCommandsPending">;
65
6266/**
6367 * One materialized provider instance. Held by the registry, looked up by
6468 * `instanceId`, torn down by closing the scope it was created in.
......@@ -76,7 +80,9 @@ export interface ProviderInstance {
7680 readonly accentColor?: string | undefined;
7781 readonly enabled: boolean;
7882 readonly snapshot: ServerProviderShape;
79 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
83 readonly snapshotForCwd?: (
84 cwd: string,
85 ) => Effect.Effect<ProviderWorkspaceSnapshot, ProviderDriverError>;
8086 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
8187 /** Invalidate T3-owned discovery caches before an explicit provider refresh. */
8288 readonly invalidateCaches?: Effect.Effect<void>;
apps/web/src/components/chat/ChatComposer.tsx+53-15
......@@ -1121,6 +1121,7 @@ import {
11211121 formatProviderSkillDisplayName,
11221122 getProviderSlashCommandsForSlashMenu,
11231123 getProviderSkillsForSlashMenu,
1124 hasCompleteProviderWorkspaceSnapshot,
11241125 resolveProviderSkillsForCwd,
11251126 resolveProviderSlashCommandsForCwd,
11261127} from "@t3tools/client-runtime/providerSkills";
......@@ -2179,41 +2180,68 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21792180 reportFailure: false,
21802181 });
21812182 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]);
21832210 const hadWorkspaceSnapshotRef = useRef(false);
21842211 useEffect(() => {
2185 const hasWorkspaceSnapshot = Boolean(
2186 gitCwd &&
2187 selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd),
2212 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
2213 selectedProviderStatus,
2214 gitCwd,
21882215 );
21892216 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
21902217 workspaceRefreshKeyRef.current = null;
2191 workspaceRefreshRetryRef.current = null;
2218 setWorkspaceRefreshRetry(null);
21922219 }
21932220 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
21942221 }, [gitCwd, selectedProviderStatus]);
21952222 useEffect(() => {
21962223 if (!gitCwd || !selectedProviderEntry) return;
21972224 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;
2198 const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some(
2199 (snapshot) => snapshot.cwd === gitCwd,
2225 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
2226 selectedProviderStatus,
2227 gitCwd,
22002228 );
22012229 if (workspaceRefreshKeyRef.current === key) return;
22022230 if (hasWorkspaceSnapshot) {
22032231 workspaceRefreshKeyRef.current = key;
2204 workspaceRefreshRetryRef.current = null;
2232 setWorkspaceRefreshRetry(null);
22052233 return;
22062234 }
2207 const retry = workspaceRefreshRetryRef.current;
2235 const retry = workspaceRefreshRetry;
22082236 if (retry?.key === key && Date.now() < retry.notBefore) return;
22092237 workspaceRefreshKeyRef.current = key;
22102238 const retryLater = () => {
22112239 if (workspaceRefreshKeyRef.current !== key) return;
22122240 workspaceRefreshKeyRef.current = null;
2213 workspaceRefreshRetryRef.current = {
2241 setWorkspaceRefreshRetry({
22142242 key,
22152243 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
2216 };
2244 });
22172245 };
22182246 void refreshProviders({
22192247 environmentId,
......@@ -2221,14 +2249,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22212249 }).then((result) => {
22222250 const hasWorkspaceSnapshot =
22232251 result._tag === "Success" &&
2224 result.value.providers
2225 .find((provider) => provider.instanceId === selectedProviderEntry.instanceId)
2226 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd);
2252 hasCompleteProviderWorkspaceSnapshot(
2253 result.value.providers.find(
2254 (provider) => provider.instanceId === selectedProviderEntry.instanceId,
2255 ),
2256 gitCwd,
2257 );
22272258 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {
22282259 retryLater();
22292260 }
22302261 }, retryLater);
2231 }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]);
2262 }, [
2263 environmentId,
2264 gitCwd,
2265 prompt,
2266 refreshProviders,
2267 selectedProviderEntry,
2268 workspaceRefreshRetry,
2269 ]);
22322270 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
22332271 () => selectedProviderEntry?.models ?? [],
22342272 [selectedProviderEntry],
packages/client-runtime/src/providerSkills.test.ts+23
......@@ -6,6 +6,7 @@ import {
66 formatProviderSkillDisplayName,
77 getProviderSlashCommandsForSlashMenu,
88 getProviderSkillsForSlashMenu,
9 hasCompleteProviderWorkspaceSnapshot,
910 resolveProviderSkillsForCwd,
1011 resolveProviderSlashCommandsForCwd,
1112 resolveProviderSkillSourceKind,
......@@ -251,4 +252,26 @@ describe("workspace provider snapshots", () => {
251252 expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills);
252253 expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands);
253254 });
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 });
254277});
packages/client-runtime/src/providerSkills.ts+8
......@@ -111,6 +111,14 @@ function resolveProviderWorkspaceSnapshot(
111111 return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd);
112112}
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
114122export function resolveProviderSkillsForCwd(
115123 provider: ServerProvider,
116124 cwd: string | null | undefined,
packages/contracts/src/server.test.ts+15
......@@ -31,6 +31,21 @@ const baseProviderSnapshot = {
3131};
3232
3333describe("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
3449 it("defaults capability arrays when decoding provider snapshots", () => {
3550 const parsed = decodeServerProvider({
3651 instanceId: "codex",
packages/contracts/src/server.ts+2
......@@ -125,6 +125,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({
125125 cwd: TrimmedNonEmptyString,
126126 checkedAt: IsoDateTime,
127127 slashCommands: Schema.Array(ServerProviderSlashCommand),
128 /** Skills are available, but command discovery still needs a retry. */
129 slashCommandsPending: Schema.optional(Schema.Boolean),
128130 skills: Schema.Array(ServerProviderSkill),
129131});
130132export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;