| author | |
| committer | |
| log | 836098543c6ecdf66734596df0ea3a8f6513f2a6 |
| tree | 04f9b5eed1c9fddcc69b0d30c0b18880b1b0972a |
| parent | 32b77f4fa370a5195ad16ab709405b1038c49fcd |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | ProviderDriverKind, | |
| 5 | ProviderInstanceId, | |
| 6 | type ServerProvider, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { act, createElement } from "react"; | |
| 9 | import { createRoot, type Root } from "react-dom/client"; | |
| 10 | ||
| 11 | const refreshProviders = vi.hoisted(() => vi.fn()); | |
| 3 | 12 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); |
| 4 | 13 | |
| 5 | 14 | vi.mock("../../state/queries", () => ({ |
| ... | ... | @@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({ |
| 8 | 17 | })); |
| 9 | 18 | vi.mock("../../state/use-composer-drafts", () => ({ |
| 10 | 19 | getComposerDraftSnapshot: vi.fn(), |
| 20 | readComposerDraftSelection: vi.fn(), | |
| 11 | 21 | setComposerDraftContext: vi.fn(), |
| 12 | 22 | })); |
| 13 | 23 | vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); |
| ... | ... | @@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({ |
| 15 | 25 | serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, |
| 16 | 26 | })); |
| 17 | 27 | vi.mock("../../state/use-atom-command", () => ({ |
| 18 | useAtomCommand: () => vi.fn(), | |
| 28 | useAtomCommand: () => refreshProviders, | |
| 19 | 29 | })); |
| 20 | 30 | |
| 21 | 31 | import { |
| 22 | 32 | buildComposerSlashCommandItems, |
| 23 | 33 | resolveComposerCommandSelection, |
| 34 | useComposerCommandMenu, | |
| 24 | 35 | } from "./use-composer-command-menu"; |
| 25 | 36 | |
| 26 | 37 | describe("mobile slash commands", () => { |
| ... | ... | @@ -101,3 +112,213 @@ describe("mobile slash commands", () => { |
| 101 | 112 | ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); |
| 102 | 113 | }); |
| 103 | 114 | }); |
| 115 | ||
| 116 | describe("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 | 36 | getProviderSkillsForSlashMenu, |
| 37 | 37 | getProviderSlashCommandsForSlashMenu, |
| 38 | 38 | isProviderSkillUserInvocable, |
| 39 | hasCompleteProviderWorkspaceSnapshot, | |
| 39 | 40 | resolveProviderSkillsForCwd, |
| 40 | 41 | resolveProviderSlashCommandsForCwd, |
| 41 | 42 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | ... | @@ -245,17 +246,43 @@ export function useComposerCommandMenu({ |
| 245 | 246 | reportFailure: false, |
| 246 | 247 | }); |
| 247 | 248 | 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, | |
| 251 | 252 | ); |
| 252 | 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 | 281 | const hadWorkspaceSnapshotRef = useRef(false); |
| 255 | 282 | useEffect(() => { |
| 256 | 283 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { |
| 257 | 284 | workspaceRefreshKeyRef.current = null; |
| 258 | workspaceRefreshRetryRef.current = null; | |
| 285 | setWorkspaceRefreshRetry(null); | |
| 259 | 286 | } |
| 260 | 287 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; |
| 261 | 288 | }, [hasWorkspaceSnapshot]); |
| ... | ... | @@ -265,19 +292,19 @@ export function useComposerCommandMenu({ |
| 265 | 292 | if (workspaceRefreshKeyRef.current === key) return; |
| 266 | 293 | if (hasWorkspaceSnapshot) { |
| 267 | 294 | workspaceRefreshKeyRef.current = key; |
| 268 | workspaceRefreshRetryRef.current = null; | |
| 295 | setWorkspaceRefreshRetry(null); | |
| 269 | 296 | return; |
| 270 | 297 | } |
| 271 | const retry = workspaceRefreshRetryRef.current; | |
| 298 | const retry = workspaceRefreshRetry; | |
| 272 | 299 | if (retry?.key === key && Date.now() < retry.notBefore) return; |
| 273 | 300 | workspaceRefreshKeyRef.current = key; |
| 274 | 301 | const retryLater = () => { |
| 275 | 302 | if (workspaceRefreshKeyRef.current !== key) return; |
| 276 | 303 | workspaceRefreshKeyRef.current = null; |
| 277 | workspaceRefreshRetryRef.current = { | |
| 304 | setWorkspaceRefreshRetry({ | |
| 278 | 305 | key, |
| 279 | 306 | notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, |
| 280 | }; | |
| 307 | }); | |
| 281 | 308 | }; |
| 282 | 309 | void refreshProviders({ |
| 283 | 310 | environmentId, |
| ... | ... | @@ -285,9 +312,12 @@ export function useComposerCommandMenu({ |
| 285 | 312 | }).then((result) => { |
| 286 | 313 | const refreshed = |
| 287 | 314 | 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 | ); | |
| 291 | 321 | if (!refreshed && workspaceRefreshKeyRef.current === key) { |
| 292 | 322 | retryLater(); |
| 293 | 323 | } |
| ... | ... | @@ -299,6 +329,7 @@ export function useComposerCommandMenu({ |
| 299 | 329 | projectCwd, |
| 300 | 330 | refreshProviders, |
| 301 | 331 | selectedProviderInstanceId, |
| 332 | workspaceRefreshRetry, | |
| 302 | 333 | ]); |
| 303 | 334 | |
| 304 | 335 | const trigger = useMemo(() => { |
apps/server/src/provider/Drivers/ClaudeDriver.ts+8-11| ... | ... | @@ -41,6 +41,7 @@ import { |
| 41 | 41 | checkClaudeProviderStatus, |
| 42 | 42 | makePendingClaudeProvider, |
| 43 | 43 | probeClaudeCapabilities, |
| 44 | probeClaudeWorkspaceSnapshot, | |
| 44 | 45 | } from "../Layers/ClaudeProvider.ts"; |
| 45 | 46 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 46 | 47 | import * as ModelManifest from "../ModelManifest.ts"; |
| ... | ... | @@ -69,7 +70,6 @@ import { |
| 69 | 70 | makeClaudeContinuationGroupKey, |
| 70 | 71 | resolveClaudeHomePath, |
| 71 | 72 | } from "./ClaudeHome.ts"; |
| 72 | import { discoverClaudeSkills } from "./ClaudeSkills.ts"; | |
| 73 | 73 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 74 | 74 | |
| 75 | 75 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| ... | ... | @@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 348 | 348 | snapshot, |
| 349 | 349 | invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), |
| 350 | 350 | 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 | ), | |
| 361 | 358 | orchestrationAdapter, |
| 362 | 359 | textGeneration, |
| 363 | 360 | consumeResetCredit, |
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+184-1| ... | ... | @@ -4,7 +4,12 @@ import { vi } from "vite-plus/test"; |
| 4 | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | 5 | import * as Fiber from "effect/Fiber"; |
| 6 | 6 | import * as TestClock from "effect/testing/TestClock"; |
| 7 | import { ClaudeSettings } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | ClaudeSettings, | |
| 9 | ProviderDriverKind, | |
| 10 | ProviderInstanceId, | |
| 11 | type ServerProvider, | |
| 12 | } from "@t3tools/contracts"; | |
| 8 | 13 | import * as NodeFSP from "node:fs/promises"; |
| 9 | 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 10 | 15 | import { assert, it } from "@effect/vitest"; |
| ... | ... | @@ -17,7 +22,9 @@ import { |
| 17 | 22 | buildClaudeCapabilitiesProbeQueryOptions, |
| 18 | 23 | CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, |
| 19 | 24 | probeClaudeCapabilities, |
| 25 | probeClaudeWorkspaceSnapshot, | |
| 20 | 26 | } from "./ClaudeProvider.ts"; |
| 27 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; | |
| 21 | 28 | |
| 22 | 29 | vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); |
| 23 | 30 | |
| ... | ... | @@ -53,6 +60,182 @@ it("isolates Claude capability probes without dropping workspace setting sources |
| 53 | 60 | }); |
| 54 | 61 | |
| 55 | 62 | it.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 | 239 | it.effect("serializes strict no-MCP options and still resolves account capabilities", () => |
| 57 | 240 | Effect.gen(function* () { |
| 58 | 241 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/provider/Layers/ClaudeProvider.ts+35-8| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ClaudeSettings, |
| 3 | 3 | type ModelCapabilities, |
| 4 | type ServerProvider, | |
| 4 | 5 | type ServerProviderSlashCommand, |
| 5 | 6 | type ServerProviderResetCredits, |
| 6 | 7 | } from "@t3tools/contracts"; |
| ... | ... | @@ -36,6 +37,7 @@ import { |
| 36 | 37 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; |
| 37 | 38 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 38 | 39 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; |
| 40 | import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; | |
| 39 | 41 | import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; |
| 40 | 42 | import { |
| 41 | 43 | type ClaudeScopedLimitNames, |
| ... | ... | @@ -333,6 +335,7 @@ const probeClaudeCapabilities = ( |
| 333 | 335 | claudeSettings: ClaudeSettings, |
| 334 | 336 | environment?: NodeJS.ProcessEnv, |
| 335 | 337 | cwd?: string, |
| 338 | includeUsage = true, | |
| 336 | 339 | ) => { |
| 337 | 340 | const abort = new AbortController(); |
| 338 | 341 | return Effect.gen(function* () { |
| ... | ... | @@ -364,15 +367,18 @@ const probeClaudeCapabilities = ( |
| 364 | 367 | Effect.flatMap(({ q, init }) => |
| 365 | 368 | Effect.gen(function* () { |
| 366 | 369 | // 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) | |
| 375 | 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 | 382 | const account = init.account as |
| 377 | 383 | | { |
| 378 | 384 | readonly email?: string; |
| ... | ... | @@ -417,6 +423,27 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( |
| 417 | 423 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); |
| 418 | 424 | }); |
| 419 | 425 | |
| 426 | /** Read commands from the same cwd Claude uses for a workspace session. */ | |
| 427 | export 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 | ||
| 420 | 447 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( |
| 421 | 448 | claudeSettings: ClaudeSettings, |
| 422 | 449 | resolveCapabilities?: ( |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+56-4| ... | ... | @@ -63,7 +63,7 @@ import { |
| 63 | 63 | writeProviderStatusCache, |
| 64 | 64 | } from "../providerStatusCache.ts"; |
| 65 | 65 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; |
| 66 | import type { ProviderInstance } from "../ProviderDriver.ts"; | |
| 66 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; | |
| 67 | 67 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; |
| 68 | 68 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; |
| 69 | 69 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| ... | ... | @@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 641 | 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 | }); |
| 645 | 673 | |
| 646 | 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 | 1601 | slashCommands: [], |
| 1574 | 1602 | } as const satisfies ServerProvider; |
| 1575 | 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 | 1610 | const cacheInvalidations = yield* Ref.make(0); |
| 1578 | 1611 | const scanGate = yield* Ref.make<{ |
| 1579 | 1612 | readonly started: Deferred.Deferred<void>; |
| ... | ... | @@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1698 | 1731 | providers[0]?.workspaceSnapshots?.[0]?.skills, |
| 1699 | 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 | 1753 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); |
| 1702 | assert.strictEqual(yield* Ref.get(snapshotCalls), 2); | |
| 1754 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | |
| 1703 | 1755 | const newSkills = [ |
| 1704 | 1756 | ...scopedProvider.skills, |
| 1705 | 1757 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, |
| ... | ... | @@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1710 | 1762 | cwd: "/workspace", |
| 1711 | 1763 | fresh: true, |
| 1712 | 1764 | }); |
| 1713 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | |
| 1765 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); | |
| 1714 | 1766 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); |
| 1715 | 1767 | assert.deepStrictEqual( |
| 1716 | 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 | 53 | resolveProviderStatusCachePath, |
| 54 | 54 | writeProviderStatusCache, |
| 55 | 55 | } from "../providerStatusCache.ts"; |
| 56 | import type { ProviderInstance } from "../ProviderDriver.ts"; | |
| 56 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; | |
| 57 | 57 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| 58 | 58 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; |
| 59 | 59 | |
| ... | ... | @@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S |
| 94 | 94 | export function upsertProviderWorkspaceSnapshot( |
| 95 | 95 | provider: ServerProvider, |
| 96 | 96 | cwd: string, |
| 97 | scopedSnapshot: ServerProvider, | |
| 97 | scopedSnapshot: ProviderWorkspaceSnapshot, | |
| 98 | 98 | ): ServerProvider { |
| 99 | 99 | const workspaceSnapshot = { |
| 100 | 100 | cwd, |
| 101 | 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 | 107 | skills: scopedSnapshot.skills, |
| 104 | 108 | } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number]; |
| 105 | 109 | return { |
| ... | ... | @@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect( |
| 918 | 922 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => |
| 919 | 923 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); |
| 920 | 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 | 930 | return providers; |
| 923 | 931 | } |
| 924 | 932 | const instance = yield* instanceRegistry.getInstance(input.instanceId); |
| ... | ... | @@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 932 | 940 | }); |
| 933 | 941 | // A fresh scan never joins a running one, which may predate the change. |
| 934 | 942 | 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. | |
| 937 | 944 | const refreshMachineSnapshot = input.fresh |
| 938 | 945 | ? (instance.invalidateCaches ?? Effect.void).pipe( |
| 939 | 946 | Effect.andThen(refreshInstance(input.instanceId)), |
| ... | ... | @@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 942 | 949 | return yield* refreshMachineSnapshot.pipe( |
| 943 | 950 | Effect.andThen(instance.snapshotForCwd(input.cwd)), |
| 944 | 951 | Effect.flatMap((scopedSnapshot) => |
| 945 | scopedSnapshot.status === "error" | |
| 952 | scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined | |
| 946 | 953 | ? Ref.get(providersRef) |
| 947 | 954 | : instanceRegistry.getInstance(input.instanceId).pipe( |
| 948 | 955 | Effect.flatMap((currentInstance) => { |
apps/server/src/provider/ProviderDriver.ts+7-1| ... | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | 31 | ProviderInstanceEnvironment, |
| 32 | 32 | ProviderInstanceId, |
| 33 | 33 | ServerProvider, |
| 34 | ServerProviderWorkspaceSnapshot, | |
| 34 | 35 | } from "@t3tools/contracts"; |
| 35 | 36 | import type * as Effect from "effect/Effect"; |
| 36 | 37 | import type * as Schema from "effect/Schema"; |
| ... | ... | @@ -59,6 +60,9 @@ export interface ProviderDriverMetadata { |
| 59 | 60 | readonly supportsMultipleInstances?: boolean; |
| 60 | 61 | } |
| 61 | 62 | |
| 63 | export type ProviderWorkspaceSnapshot = ServerProvider & | |
| 64 | Pick<ServerProviderWorkspaceSnapshot, "slashCommandsPending">; | |
| 65 | ||
| 62 | 66 | /** |
| 63 | 67 | * One materialized provider instance. Held by the registry, looked up by |
| 64 | 68 | * `instanceId`, torn down by closing the scope it was created in. |
| ... | ... | @@ -76,7 +80,9 @@ export interface ProviderInstance { |
| 76 | 80 | readonly accentColor?: string | undefined; |
| 77 | 81 | readonly enabled: boolean; |
| 78 | 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 | 86 | readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>; |
| 81 | 87 | /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ |
| 82 | 88 | readonly invalidateCaches?: Effect.Effect<void>; |
apps/web/src/components/chat/ChatComposer.tsx+53-15| ... | ... | @@ -1121,6 +1121,7 @@ import { |
| 1121 | 1121 | formatProviderSkillDisplayName, |
| 1122 | 1122 | getProviderSlashCommandsForSlashMenu, |
| 1123 | 1123 | getProviderSkillsForSlashMenu, |
| 1124 | hasCompleteProviderWorkspaceSnapshot, | |
| 1124 | 1125 | resolveProviderSkillsForCwd, |
| 1125 | 1126 | resolveProviderSlashCommandsForCwd, |
| 1126 | 1127 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | ... | @@ -2179,41 +2180,68 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2179 | 2180 | reportFailure: false, |
| 2180 | 2181 | }); |
| 2181 | 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 | 2210 | const hadWorkspaceSnapshotRef = useRef(false); |
| 2184 | 2211 | useEffect(() => { |
| 2185 | const hasWorkspaceSnapshot = Boolean( | |
| 2186 | gitCwd && | |
| 2187 | selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), | |
| 2212 | const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( | |
| 2213 | selectedProviderStatus, | |
| 2214 | gitCwd, | |
| 2188 | 2215 | ); |
| 2189 | 2216 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { |
| 2190 | 2217 | workspaceRefreshKeyRef.current = null; |
| 2191 | workspaceRefreshRetryRef.current = null; | |
| 2218 | setWorkspaceRefreshRetry(null); | |
| 2192 | 2219 | } |
| 2193 | 2220 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; |
| 2194 | 2221 | }, [gitCwd, selectedProviderStatus]); |
| 2195 | 2222 | useEffect(() => { |
| 2196 | 2223 | if (!gitCwd || !selectedProviderEntry) return; |
| 2197 | 2224 | 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, | |
| 2200 | 2228 | ); |
| 2201 | 2229 | if (workspaceRefreshKeyRef.current === key) return; |
| 2202 | 2230 | if (hasWorkspaceSnapshot) { |
| 2203 | 2231 | workspaceRefreshKeyRef.current = key; |
| 2204 | workspaceRefreshRetryRef.current = null; | |
| 2232 | setWorkspaceRefreshRetry(null); | |
| 2205 | 2233 | return; |
| 2206 | 2234 | } |
| 2207 | const retry = workspaceRefreshRetryRef.current; | |
| 2235 | const retry = workspaceRefreshRetry; | |
| 2208 | 2236 | if (retry?.key === key && Date.now() < retry.notBefore) return; |
| 2209 | 2237 | workspaceRefreshKeyRef.current = key; |
| 2210 | 2238 | const retryLater = () => { |
| 2211 | 2239 | if (workspaceRefreshKeyRef.current !== key) return; |
| 2212 | 2240 | workspaceRefreshKeyRef.current = null; |
| 2213 | workspaceRefreshRetryRef.current = { | |
| 2241 | setWorkspaceRefreshRetry({ | |
| 2214 | 2242 | key, |
| 2215 | 2243 | notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, |
| 2216 | }; | |
| 2244 | }); | |
| 2217 | 2245 | }; |
| 2218 | 2246 | void refreshProviders({ |
| 2219 | 2247 | environmentId, |
| ... | ... | @@ -2221,14 +2249,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2221 | 2249 | }).then((result) => { |
| 2222 | 2250 | const hasWorkspaceSnapshot = |
| 2223 | 2251 | 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 | ); | |
| 2227 | 2258 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { |
| 2228 | 2259 | retryLater(); |
| 2229 | 2260 | } |
| 2230 | 2261 | }, retryLater); |
| 2231 | }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]); | |
| 2262 | }, [ | |
| 2263 | environmentId, | |
| 2264 | gitCwd, | |
| 2265 | prompt, | |
| 2266 | refreshProviders, | |
| 2267 | selectedProviderEntry, | |
| 2268 | workspaceRefreshRetry, | |
| 2269 | ]); | |
| 2232 | 2270 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| 2233 | 2271 | () => selectedProviderEntry?.models ?? [], |
| 2234 | 2272 | [selectedProviderEntry], |
packages/client-runtime/src/providerSkills.test.ts+23| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | formatProviderSkillDisplayName, |
| 7 | 7 | getProviderSlashCommandsForSlashMenu, |
| 8 | 8 | getProviderSkillsForSlashMenu, |
| 9 | hasCompleteProviderWorkspaceSnapshot, | |
| 9 | 10 | resolveProviderSkillsForCwd, |
| 10 | 11 | resolveProviderSlashCommandsForCwd, |
| 11 | 12 | resolveProviderSkillSourceKind, |
| ... | ... | @@ -251,4 +252,26 @@ describe("workspace provider snapshots", () => { |
| 251 | 252 | expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); |
| 252 | 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 | 111 | return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | export 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 | ||
| 114 | 122 | export function resolveProviderSkillsForCwd( |
| 115 | 123 | provider: ServerProvider, |
| 116 | 124 | cwd: string | null | undefined, |
packages/contracts/src/server.test.ts+15| ... | ... | @@ -31,6 +31,21 @@ const baseProviderSnapshot = { |
| 31 | 31 | }; |
| 32 | 32 | |
| 33 | 33 | describe("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 | 49 | it("defaults capability arrays when decoding provider snapshots", () => { |
| 35 | 50 | const parsed = decodeServerProvider({ |
| 36 | 51 | instanceId: "codex", |
packages/contracts/src/server.ts+2| ... | ... | @@ -125,6 +125,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ |
| 125 | 125 | cwd: TrimmedNonEmptyString, |
| 126 | 126 | checkedAt: IsoDateTime, |
| 127 | 127 | slashCommands: Schema.Array(ServerProviderSlashCommand), |
| 128 | /** Skills are available, but command discovery still needs a retry. */ | |
| 129 | slashCommandsPending: Schema.optional(Schema.Boolean), | |
| 128 | 130 | skills: Schema.Array(ServerProviderSkill), |
| 129 | 131 | }); |
| 130 | 132 | export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; |