| 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,5 +1,14 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | 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 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | 12 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); |
| 4 | 13 | ||
| 5 | vi.mock("../../state/queries", () => ({ | 14 | vi.mock("../../state/queries", () => ({ |
| ... | @@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({ | ... | @@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({ |
| 8 | })); | 17 | })); |
| 9 | vi.mock("../../state/use-composer-drafts", () => ({ | 18 | vi.mock("../../state/use-composer-drafts", () => ({ |
| 10 | getComposerDraftSnapshot: vi.fn(), | 19 | getComposerDraftSnapshot: vi.fn(), |
| 20 | readComposerDraftSelection: vi.fn(), | ||
| 11 | setComposerDraftContext: vi.fn(), | 21 | setComposerDraftContext: vi.fn(), |
| 12 | })); | 22 | })); |
| 13 | vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); | 23 | vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); |
| ... | @@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({ | ... | @@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({ |
| 15 | serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, | 25 | serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, |
| 16 | })); | 26 | })); |
| 17 | vi.mock("../../state/use-atom-command", () => ({ | 27 | vi.mock("../../state/use-atom-command", () => ({ |
| 18 | useAtomCommand: () => vi.fn(), | 28 | useAtomCommand: () => refreshProviders, |
| 19 | })); | 29 | })); |
| 20 | 30 | ||
| 21 | import { | 31 | import { |
| 22 | buildComposerSlashCommandItems, | 32 | buildComposerSlashCommandItems, |
| 23 | resolveComposerCommandSelection, | 33 | resolveComposerCommandSelection, |
| 34 | useComposerCommandMenu, | ||
| 24 | } from "./use-composer-command-menu"; | 35 | } from "./use-composer-command-menu"; |
| 25 | 36 | ||
| 26 | describe("mobile slash commands", () => { | 37 | describe("mobile slash commands", () => { |
| ... | @@ -101,3 +112,213 @@ describe("mobile slash commands", () => { | ... | @@ -101,3 +112,213 @@ describe("mobile slash commands", () => { |
| 101 | ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); | 112 | ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); |
| 102 | }); | 113 | }); |
| 103 | }); | 114 | }); |
| 115 | |||
| 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,6 +36,7 @@ import { |
| 36 | getProviderSkillsForSlashMenu, | 36 | getProviderSkillsForSlashMenu, |
| 37 | getProviderSlashCommandsForSlashMenu, | 37 | getProviderSlashCommandsForSlashMenu, |
| 38 | isProviderSkillUserInvocable, | 38 | isProviderSkillUserInvocable, |
| 39 | hasCompleteProviderWorkspaceSnapshot, | ||
| 39 | resolveProviderSkillsForCwd, | 40 | resolveProviderSkillsForCwd, |
| 40 | resolveProviderSlashCommandsForCwd, | 41 | resolveProviderSlashCommandsForCwd, |
| 41 | } from "@t3tools/client-runtime/providerSkills"; | 42 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | @@ -245,17 +246,43 @@ export function useComposerCommandMenu({ | ... | @@ -245,17 +246,43 @@ export function useComposerCommandMenu({ |
| 245 | reportFailure: false, | 246 | reportFailure: false, |
| 246 | }); | 247 | }); |
| 247 | const selectedProviderInstanceId = selectedProviderStatus?.instanceId; | 248 | const selectedProviderInstanceId = selectedProviderStatus?.instanceId; |
| 248 | const hasWorkspaceSnapshot = Boolean( | 249 | const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( |
| 249 | projectCwd && | 250 | selectedProviderStatus, |
| 250 | selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd), | 251 | projectCwd, |
| 251 | ); | 252 | ); |
| 252 | const workspaceRefreshKeyRef = useRef<string | null>(null); | 253 | const workspaceRefreshKeyRef = useRef<string | null>(null); |
| 253 | const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); | 254 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ |
| 255 | key: string; | ||
| 256 | notBefore: number; | ||
| 257 | } | null>(null); | ||
| 258 | const workspaceRefreshScopeKey = | ||
| 259 | environmentId && projectCwd && selectedProviderInstanceId | ||
| 260 | ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}` | ||
| 261 | : null; | ||
| 262 | const workspaceSlashCommandsPending = | ||
| 263 | selectedProviderStatus?.workspaceSnapshots?.some( | ||
| 264 | (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true, | ||
| 265 | ) ?? false; | ||
| 266 | useEffect(() => { | ||
| 267 | if ( | ||
| 268 | !workspaceSlashCommandsPending || | ||
| 269 | !workspaceRefreshRetry || | ||
| 270 | workspaceRefreshRetry.key !== workspaceRefreshScopeKey | ||
| 271 | ) | ||
| 272 | return; | ||
| 273 | const timeout = setTimeout( | ||
| 274 | () => { | ||
| 275 | setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); | ||
| 276 | }, | ||
| 277 | Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), | ||
| 278 | ); | ||
| 279 | return () => clearTimeout(timeout); | ||
| 280 | }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); | ||
| 254 | const hadWorkspaceSnapshotRef = useRef(false); | 281 | const hadWorkspaceSnapshotRef = useRef(false); |
| 255 | useEffect(() => { | 282 | useEffect(() => { |
| 256 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { | 283 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { |
| 257 | workspaceRefreshKeyRef.current = null; | 284 | workspaceRefreshKeyRef.current = null; |
| 258 | workspaceRefreshRetryRef.current = null; | 285 | setWorkspaceRefreshRetry(null); |
| 259 | } | 286 | } |
| 260 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; | 287 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; |
| 261 | }, [hasWorkspaceSnapshot]); | 288 | }, [hasWorkspaceSnapshot]); |
| ... | @@ -265,19 +292,19 @@ export function useComposerCommandMenu({ | ... | @@ -265,19 +292,19 @@ export function useComposerCommandMenu({ |
| 265 | if (workspaceRefreshKeyRef.current === key) return; | 292 | if (workspaceRefreshKeyRef.current === key) return; |
| 266 | if (hasWorkspaceSnapshot) { | 293 | if (hasWorkspaceSnapshot) { |
| 267 | workspaceRefreshKeyRef.current = key; | 294 | workspaceRefreshKeyRef.current = key; |
| 268 | workspaceRefreshRetryRef.current = null; | 295 | setWorkspaceRefreshRetry(null); |
| 269 | return; | 296 | return; |
| 270 | } | 297 | } |
| 271 | const retry = workspaceRefreshRetryRef.current; | 298 | const retry = workspaceRefreshRetry; |
| 272 | if (retry?.key === key && Date.now() < retry.notBefore) return; | 299 | if (retry?.key === key && Date.now() < retry.notBefore) return; |
| 273 | workspaceRefreshKeyRef.current = key; | 300 | workspaceRefreshKeyRef.current = key; |
| 274 | const retryLater = () => { | 301 | const retryLater = () => { |
| 275 | if (workspaceRefreshKeyRef.current !== key) return; | 302 | if (workspaceRefreshKeyRef.current !== key) return; |
| 276 | workspaceRefreshKeyRef.current = null; | 303 | workspaceRefreshKeyRef.current = null; |
| 277 | workspaceRefreshRetryRef.current = { | 304 | setWorkspaceRefreshRetry({ |
| 278 | key, | 305 | key, |
| 279 | notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, | 306 | notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, |
| 280 | }; | 307 | }); |
| 281 | }; | 308 | }; |
| 282 | void refreshProviders({ | 309 | void refreshProviders({ |
| 283 | environmentId, | 310 | environmentId, |
| ... | @@ -285,9 +312,12 @@ export function useComposerCommandMenu({ | ... | @@ -285,9 +312,12 @@ export function useComposerCommandMenu({ |
| 285 | }).then((result) => { | 312 | }).then((result) => { |
| 286 | const refreshed = | 313 | const refreshed = |
| 287 | result._tag === "Success" && | 314 | result._tag === "Success" && |
| 288 | result.value.providers | 315 | hasCompleteProviderWorkspaceSnapshot( |
| 289 | .find((provider) => provider.instanceId === selectedProviderInstanceId) | 316 | result.value.providers.find( |
| 290 | ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd); | 317 | (provider) => provider.instanceId === selectedProviderInstanceId, |
| 318 | ), | ||
| 319 | projectCwd, | ||
| 320 | ); | ||
| 291 | if (!refreshed && workspaceRefreshKeyRef.current === key) { | 321 | if (!refreshed && workspaceRefreshKeyRef.current === key) { |
| 292 | retryLater(); | 322 | retryLater(); |
| 293 | } | 323 | } |
| ... | @@ -299,6 +329,7 @@ export function useComposerCommandMenu({ | ... | @@ -299,6 +329,7 @@ export function useComposerCommandMenu({ |
| 299 | projectCwd, | 329 | projectCwd, |
| 300 | refreshProviders, | 330 | refreshProviders, |
| 301 | selectedProviderInstanceId, | 331 | selectedProviderInstanceId, |
| 332 | workspaceRefreshRetry, | ||
| 302 | ]); | 333 | ]); |
| 303 | 334 | ||
| 304 | const trigger = useMemo(() => { | 335 | const trigger = useMemo(() => { |
apps/server/src/provider/Drivers/ClaudeDriver.ts+8-11| ... | @@ -41,6 +41,7 @@ import { | ... | @@ -41,6 +41,7 @@ import { |
| 41 | checkClaudeProviderStatus, | 41 | checkClaudeProviderStatus, |
| 42 | makePendingClaudeProvider, | 42 | makePendingClaudeProvider, |
| 43 | probeClaudeCapabilities, | 43 | probeClaudeCapabilities, |
| 44 | probeClaudeWorkspaceSnapshot, | ||
| 44 | } from "../Layers/ClaudeProvider.ts"; | 45 | } from "../Layers/ClaudeProvider.ts"; |
| 45 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 46 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 46 | import * as ModelManifest from "../ModelManifest.ts"; | 47 | import * as ModelManifest from "../ModelManifest.ts"; |
| ... | @@ -69,7 +70,6 @@ import { | ... | @@ -69,7 +70,6 @@ import { |
| 69 | makeClaudeContinuationGroupKey, | 70 | makeClaudeContinuationGroupKey, |
| 70 | resolveClaudeHomePath, | 71 | resolveClaudeHomePath, |
| 71 | } from "./ClaudeHome.ts"; | 72 | } from "./ClaudeHome.ts"; |
| 72 | import { discoverClaudeSkills } from "./ClaudeSkills.ts"; | ||
| 73 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); | 73 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 74 | 74 | ||
| 75 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); | 75 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| ... | @@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 348 | snapshot, | 348 | snapshot, |
| 349 | invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), | 349 | invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), |
| 350 | snapshotForCwd: (cwd: string) => | 350 | snapshotForCwd: (cwd: string) => |
| 351 | !effectiveConfig.enabled | 351 | snapshot.getSnapshot.pipe( |
| 352 | ? snapshot.getSnapshot | 352 | Effect.flatMap((machineSnapshot) => |
| 353 | : Effect.all([ | 353 | probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), |
| 354 | snapshot.getSnapshot, | 354 | ), |
| 355 | discoverClaudeSkills(effectiveConfig, cwd, processEnv), | 355 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 356 | ]).pipe( | 356 | Effect.provideService(Path.Path, path), |
| 357 | Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), | 357 | ), |
| 358 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 359 | Effect.provideService(Path.Path, path), | ||
| 360 | ), | ||
| 361 | orchestrationAdapter, | 358 | orchestrationAdapter, |
| 362 | textGeneration, | 359 | textGeneration, |
| 363 | consumeResetCredit, | 360 | consumeResetCredit, |
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+184-1| ... | @@ -4,7 +4,12 @@ import { vi } from "vite-plus/test"; | ... | @@ -4,7 +4,12 @@ import { vi } from "vite-plus/test"; |
| 4 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Fiber from "effect/Fiber"; | 5 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as TestClock from "effect/testing/TestClock"; | 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 | import * as NodeFSP from "node:fs/promises"; | 13 | import * as NodeFSP from "node:fs/promises"; |
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 10 | import { assert, it } from "@effect/vitest"; | 15 | import { assert, it } from "@effect/vitest"; |
| ... | @@ -17,7 +22,9 @@ import { | ... | @@ -17,7 +22,9 @@ import { |
| 17 | buildClaudeCapabilitiesProbeQueryOptions, | 22 | buildClaudeCapabilitiesProbeQueryOptions, |
| 18 | CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, | 23 | CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, |
| 19 | probeClaudeCapabilities, | 24 | probeClaudeCapabilities, |
| 25 | probeClaudeWorkspaceSnapshot, | ||
| 20 | } from "./ClaudeProvider.ts"; | 26 | } from "./ClaudeProvider.ts"; |
| 27 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; | ||
| 21 | 28 | ||
| 22 | vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); | 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,6 +60,182 @@ it("isolates Claude capability probes without dropping workspace setting sources |
| 53 | }); | 60 | }); |
| 54 | 61 | ||
| 55 | it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { | 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 | it.effect("serializes strict no-MCP options and still resolves account capabilities", () => | 239 | it.effect("serializes strict no-MCP options and still resolves account capabilities", () => |
| 57 | Effect.gen(function* () { | 240 | Effect.gen(function* () { |
| 58 | const fs = yield* FileSystem.FileSystem; | 241 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/provider/Layers/ClaudeProvider.ts+35-8| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | type ClaudeSettings, | 2 | type ClaudeSettings, |
| 3 | type ModelCapabilities, | 3 | type ModelCapabilities, |
| 4 | type ServerProvider, | ||
| 4 | type ServerProviderSlashCommand, | 5 | type ServerProviderSlashCommand, |
| 5 | type ServerProviderResetCredits, | 6 | type ServerProviderResetCredits, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| ... | @@ -36,6 +37,7 @@ import { | ... | @@ -36,6 +37,7 @@ import { |
| 36 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; | 37 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; |
| 37 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; | 38 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 38 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; | 39 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; |
| 40 | import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; | ||
| 39 | import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; | 41 | import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; |
| 40 | import { | 42 | import { |
| 41 | type ClaudeScopedLimitNames, | 43 | type ClaudeScopedLimitNames, |
| ... | @@ -333,6 +335,7 @@ const probeClaudeCapabilities = ( | ... | @@ -333,6 +335,7 @@ const probeClaudeCapabilities = ( |
| 333 | claudeSettings: ClaudeSettings, | 335 | claudeSettings: ClaudeSettings, |
| 334 | environment?: NodeJS.ProcessEnv, | 336 | environment?: NodeJS.ProcessEnv, |
| 335 | cwd?: string, | 337 | cwd?: string, |
| 338 | includeUsage = true, | ||
| 336 | ) => { | 339 | ) => { |
| 337 | const abort = new AbortController(); | 340 | const abort = new AbortController(); |
| 338 | return Effect.gen(function* () { | 341 | return Effect.gen(function* () { |
| ... | @@ -364,15 +367,18 @@ const probeClaudeCapabilities = ( | ... | @@ -364,15 +367,18 @@ const probeClaudeCapabilities = ( |
| 364 | Effect.flatMap(({ q, init }) => | 367 | Effect.flatMap(({ q, init }) => |
| 365 | Effect.gen(function* () { | 368 | Effect.gen(function* () { |
| 366 | // Usage has its own deadline so a slow optional request cannot discard initialization. | 369 | // Usage has its own deadline so a slow optional request cannot discard initialization. |
| 367 | const usageResult = yield* Effect.tryPromise(() => | 370 | const usageResult = includeUsage |
| 368 | q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), | 371 | ? yield* Effect.tryPromise(() => |
| 369 | ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result); | 372 | q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), |
| 370 | const usage = Result.isSuccess(usageResult) | 373 | ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) |
| 371 | ? { | ||
| 372 | rate_limits_available: usageResult.success.rate_limits_available, | ||
| 373 | rate_limits: usageResult.success.rate_limits, | ||
| 374 | } | ||
| 375 | : undefined; | 374 | : undefined; |
| 375 | const usage = | ||
| 376 | usageResult && Result.isSuccess(usageResult) | ||
| 377 | ? { | ||
| 378 | rate_limits_available: usageResult.success.rate_limits_available, | ||
| 379 | rate_limits: usageResult.success.rate_limits, | ||
| 380 | } | ||
| 381 | : undefined; | ||
| 376 | const account = init.account as | 382 | const account = init.account as |
| 377 | | { | 383 | | { |
| 378 | readonly email?: string; | 384 | readonly email?: string; |
| ... | @@ -417,6 +423,27 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( | ... | @@ -417,6 +423,27 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( |
| 417 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); | 423 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); |
| 418 | }); | 424 | }); |
| 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 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( | 447 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( |
| 421 | claudeSettings: ClaudeSettings, | 448 | claudeSettings: ClaudeSettings, |
| 422 | resolveCapabilities?: ( | 449 | resolveCapabilities?: ( |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+56-4| ... | @@ -63,7 +63,7 @@ import { | ... | @@ -63,7 +63,7 @@ import { |
| 63 | writeProviderStatusCache, | 63 | writeProviderStatusCache, |
| 64 | } from "../providerStatusCache.ts"; | 64 | } from "../providerStatusCache.ts"; |
| 65 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; | 65 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; |
| 66 | import type { ProviderInstance } from "../ProviderDriver.ts"; | 66 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; |
| 67 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; | 67 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; |
| 68 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; | 68 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; |
| 69 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | 69 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| ... | @@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test | ... | @@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 641 | skills: scopedSnapshot.skills, | 641 | skills: scopedSnapshot.skills, |
| 642 | }, | 642 | }, |
| 643 | ]); | 643 | ]); |
| 644 | |||
| 645 | const pendingSnapshot = { | ||
| 646 | ...scopedSnapshot, | ||
| 647 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 648 | slashCommandsPending: true, | ||
| 649 | } satisfies ProviderWorkspaceSnapshot; | ||
| 650 | const partial = upsertProviderWorkspaceSnapshot(result, "/project", pendingSnapshot); | ||
| 651 | assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [ | ||
| 652 | { name: "project" }, | ||
| 653 | ]); | ||
| 654 | assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true); | ||
| 655 | const otherProject = upsertProviderWorkspaceSnapshot( | ||
| 656 | partial, | ||
| 657 | "/other-project", | ||
| 658 | pendingSnapshot, | ||
| 659 | ); | ||
| 660 | assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [ | ||
| 661 | COMPACT_SLASH_COMMAND, | ||
| 662 | ]); | ||
| 663 | const recovered = upsertProviderWorkspaceSnapshot(partial, "/project", { | ||
| 664 | ...scopedSnapshot, | ||
| 665 | slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }], | ||
| 666 | }); | ||
| 667 | assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [ | ||
| 668 | COMPACT_SLASH_COMMAND, | ||
| 669 | { name: "replacement" }, | ||
| 670 | ]); | ||
| 671 | assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); | ||
| 644 | }); | 672 | }); |
| 645 | 673 | ||
| 646 | it("preserves previously discovered provider models when a refresh returns none", () => { | 674 | it("preserves previously discovered provider models when a refresh returns none", () => { |
| ... | @@ -1573,7 +1601,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test | ... | @@ -1573,7 +1601,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1573 | slashCommands: [], | 1601 | slashCommands: [], |
| 1574 | } as const satisfies ServerProvider; | 1602 | } as const satisfies ServerProvider; |
| 1575 | const snapshotCalls = yield* Ref.make(0); | 1603 | const snapshotCalls = yield* Ref.make(0); |
| 1576 | const scopedResult = yield* Ref.make<ServerProvider>(scopedProvider); | 1604 | const scopedResult = yield* Ref.make<ProviderWorkspaceSnapshot>({ |
| 1605 | ...scopedProvider, | ||
| 1606 | status: "error", | ||
| 1607 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 1608 | slashCommandsPending: true, | ||
| 1609 | }); | ||
| 1577 | const cacheInvalidations = yield* Ref.make(0); | 1610 | const cacheInvalidations = yield* Ref.make(0); |
| 1578 | const scanGate = yield* Ref.make<{ | 1611 | const scanGate = yield* Ref.make<{ |
| 1579 | readonly started: Deferred.Deferred<void>; | 1612 | readonly started: Deferred.Deferred<void>; |
| ... | @@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test | ... | @@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1698 | providers[0]?.workspaceSnapshots?.[0]?.skills, | 1731 | providers[0]?.workspaceSnapshots?.[0]?.skills, |
| 1699 | scopedProvider.skills, | 1732 | scopedProvider.skills, |
| 1700 | ); | 1733 | ); |
| 1734 | assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ | ||
| 1735 | COMPACT_SLASH_COMMAND, | ||
| 1736 | ]); | ||
| 1737 | assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); | ||
| 1738 | yield* Ref.set(scopedResult, { | ||
| 1739 | ...scopedProvider, | ||
| 1740 | status: "error", | ||
| 1741 | slashCommandsPending: false, | ||
| 1742 | }); | ||
| 1743 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1744 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | ||
| 1745 | assert.deepStrictEqual( | ||
| 1746 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, | ||
| 1747 | scopedProvider.slashCommands, | ||
| 1748 | ); | ||
| 1749 | assert.strictEqual( | ||
| 1750 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, | ||
| 1751 | undefined, | ||
| 1752 | ); | ||
| 1701 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | 1753 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); |
| 1702 | assert.strictEqual(yield* Ref.get(snapshotCalls), 2); | 1754 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); |
| 1703 | const newSkills = [ | 1755 | const newSkills = [ |
| 1704 | ...scopedProvider.skills, | 1756 | ...scopedProvider.skills, |
| 1705 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, | 1757 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, |
| ... | @@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test | ... | @@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1710 | cwd: "/workspace", | 1762 | cwd: "/workspace", |
| 1711 | fresh: true, | 1763 | fresh: true, |
| 1712 | }); | 1764 | }); |
| 1713 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | 1765 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); |
| 1714 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); | 1766 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); |
| 1715 | assert.deepStrictEqual( | 1767 | assert.deepStrictEqual( |
| 1716 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), | 1768 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), |
apps/server/src/provider/Layers/ProviderRegistry.ts+14-7| ... | @@ -53,7 +53,7 @@ import { | ... | @@ -53,7 +53,7 @@ import { |
| 53 | resolveProviderStatusCachePath, | 53 | resolveProviderStatusCachePath, |
| 54 | writeProviderStatusCache, | 54 | writeProviderStatusCache, |
| 55 | } from "../providerStatusCache.ts"; | 55 | } from "../providerStatusCache.ts"; |
| 56 | import type { ProviderInstance } from "../ProviderDriver.ts"; | 56 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; |
| 57 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | 57 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| 58 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; | 58 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; |
| 59 | 59 | ||
| ... | @@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S | ... | @@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S |
| 94 | export function upsertProviderWorkspaceSnapshot( | 94 | export function upsertProviderWorkspaceSnapshot( |
| 95 | provider: ServerProvider, | 95 | provider: ServerProvider, |
| 96 | cwd: string, | 96 | cwd: string, |
| 97 | scopedSnapshot: ServerProvider, | 97 | scopedSnapshot: ProviderWorkspaceSnapshot, |
| 98 | ): ServerProvider { | 98 | ): ServerProvider { |
| 99 | const workspaceSnapshot = { | 99 | const workspaceSnapshot = { |
| 100 | cwd, | 100 | cwd, |
| 101 | checkedAt: scopedSnapshot.checkedAt, | 101 | checkedAt: scopedSnapshot.checkedAt, |
| 102 | slashCommands: scopedSnapshot.slashCommands, | 102 | slashCommands: scopedSnapshot.slashCommandsPending |
| 103 | ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? | ||
| 104 | scopedSnapshot.slashCommands) | ||
| 105 | : scopedSnapshot.slashCommands, | ||
| 106 | ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), | ||
| 103 | skills: scopedSnapshot.skills, | 107 | skills: scopedSnapshot.skills, |
| 104 | } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number]; | 108 | } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number]; |
| 105 | return { | 109 | return { |
| ... | @@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect( |
| 918 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => | 922 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => |
| 919 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); | 923 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); |
| 920 | const scannedFrom = workspaceSnapshotOf(provider); | 924 | const scannedFrom = workspaceSnapshotOf(provider); |
| 921 | if (!provider || !provider.enabled || (!input.fresh && scannedFrom)) { | 925 | if ( |
| 926 | !provider || | ||
| 927 | !provider.enabled || | ||
| 928 | (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) | ||
| 929 | ) { | ||
| 922 | return providers; | 930 | return providers; |
| 923 | } | 931 | } |
| 924 | const instance = yield* instanceRegistry.getInstance(input.instanceId); | 932 | const instance = yield* instanceRegistry.getInstance(input.instanceId); |
| ... | @@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 932 | }); | 940 | }); |
| 933 | // A fresh scan never joins a running one, which may predate the change. | 941 | // A fresh scan never joins a running one, which may predate the change. |
| 934 | if (!claimed && !input.fresh) return yield* Ref.get(providersRef); | 942 | if (!claimed && !input.fresh) return yield* Ref.get(providersRef); |
| 935 | // Fresh scans also re-read the machine snapshot: Claude's plugin | 943 | // Fresh scans also re-read the machine snapshot after invalidating caches. |
| 936 | // commands come from it, not from the cwd scan. | ||
| 937 | const refreshMachineSnapshot = input.fresh | 944 | const refreshMachineSnapshot = input.fresh |
| 938 | ? (instance.invalidateCaches ?? Effect.void).pipe( | 945 | ? (instance.invalidateCaches ?? Effect.void).pipe( |
| 939 | Effect.andThen(refreshInstance(input.instanceId)), | 946 | Effect.andThen(refreshInstance(input.instanceId)), |
| ... | @@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 942 | return yield* refreshMachineSnapshot.pipe( | 949 | return yield* refreshMachineSnapshot.pipe( |
| 943 | Effect.andThen(instance.snapshotForCwd(input.cwd)), | 950 | Effect.andThen(instance.snapshotForCwd(input.cwd)), |
| 944 | Effect.flatMap((scopedSnapshot) => | 951 | Effect.flatMap((scopedSnapshot) => |
| 945 | scopedSnapshot.status === "error" | 952 | scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined |
| 946 | ? Ref.get(providersRef) | 953 | ? Ref.get(providersRef) |
| 947 | : instanceRegistry.getInstance(input.instanceId).pipe( | 954 | : instanceRegistry.getInstance(input.instanceId).pipe( |
| 948 | Effect.flatMap((currentInstance) => { | 955 | Effect.flatMap((currentInstance) => { |
apps/server/src/provider/ProviderDriver.ts+7-1| ... | @@ -31,6 +31,7 @@ import type { | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | ProviderInstanceEnvironment, | 31 | ProviderInstanceEnvironment, |
| 32 | ProviderInstanceId, | 32 | ProviderInstanceId, |
| 33 | ServerProvider, | 33 | ServerProvider, |
| 34 | ServerProviderWorkspaceSnapshot, | ||
| 34 | } from "@t3tools/contracts"; | 35 | } from "@t3tools/contracts"; |
| 35 | import type * as Effect from "effect/Effect"; | 36 | import type * as Effect from "effect/Effect"; |
| 36 | import type * as Schema from "effect/Schema"; | 37 | import type * as Schema from "effect/Schema"; |
| ... | @@ -59,6 +60,9 @@ export interface ProviderDriverMetadata { | ... | @@ -59,6 +60,9 @@ export interface ProviderDriverMetadata { |
| 59 | readonly supportsMultipleInstances?: boolean; | 60 | readonly supportsMultipleInstances?: boolean; |
| 60 | } | 61 | } |
| 61 | 62 | ||
| 63 | export type ProviderWorkspaceSnapshot = ServerProvider & | ||
| 64 | Pick<ServerProviderWorkspaceSnapshot, "slashCommandsPending">; | ||
| 65 | |||
| 62 | /** | 66 | /** |
| 63 | * One materialized provider instance. Held by the registry, looked up by | 67 | * One materialized provider instance. Held by the registry, looked up by |
| 64 | * `instanceId`, torn down by closing the scope it was created in. | 68 | * `instanceId`, torn down by closing the scope it was created in. |
| ... | @@ -76,7 +80,9 @@ export interface ProviderInstance { | ... | @@ -76,7 +80,9 @@ export interface ProviderInstance { |
| 76 | readonly accentColor?: string | undefined; | 80 | readonly accentColor?: string | undefined; |
| 77 | readonly enabled: boolean; | 81 | readonly enabled: boolean; |
| 78 | readonly snapshot: ServerProviderShape; | 82 | readonly snapshot: ServerProviderShape; |
| 79 | readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>; | 83 | readonly snapshotForCwd?: ( |
| 84 | cwd: string, | ||
| 85 | ) => Effect.Effect<ProviderWorkspaceSnapshot, ProviderDriverError>; | ||
| 80 | readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>; | 86 | readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>; |
| 81 | /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ | 87 | /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ |
| 82 | readonly invalidateCaches?: Effect.Effect<void>; | 88 | readonly invalidateCaches?: Effect.Effect<void>; |
apps/web/src/components/chat/ChatComposer.tsx+53-15| ... | @@ -1121,6 +1121,7 @@ import { | ... | @@ -1121,6 +1121,7 @@ import { |
| 1121 | formatProviderSkillDisplayName, | 1121 | formatProviderSkillDisplayName, |
| 1122 | getProviderSlashCommandsForSlashMenu, | 1122 | getProviderSlashCommandsForSlashMenu, |
| 1123 | getProviderSkillsForSlashMenu, | 1123 | getProviderSkillsForSlashMenu, |
| 1124 | hasCompleteProviderWorkspaceSnapshot, | ||
| 1124 | resolveProviderSkillsForCwd, | 1125 | resolveProviderSkillsForCwd, |
| 1125 | resolveProviderSlashCommandsForCwd, | 1126 | resolveProviderSlashCommandsForCwd, |
| 1126 | } from "@t3tools/client-runtime/providerSkills"; | 1127 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | @@ -2179,41 +2180,68 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2179,41 +2180,68 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2179 | reportFailure: false, | 2180 | reportFailure: false, |
| 2180 | }); | 2181 | }); |
| 2181 | const workspaceRefreshKeyRef = useRef<string | null>(null); | 2182 | const workspaceRefreshKeyRef = useRef<string | null>(null); |
| 2182 | const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); | 2183 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ |
| 2184 | key: string; | ||
| 2185 | notBefore: number; | ||
| 2186 | } | null>(null); | ||
| 2187 | const workspaceRefreshScopeKey = | ||
| 2188 | gitCwd && selectedProviderEntry | ||
| 2189 | ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` | ||
| 2190 | : null; | ||
| 2191 | const workspaceSlashCommandsPending = | ||
| 2192 | selectedProviderStatus?.workspaceSnapshots?.some( | ||
| 2193 | (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true, | ||
| 2194 | ) ?? false; | ||
| 2195 | useEffect(() => { | ||
| 2196 | if ( | ||
| 2197 | !workspaceSlashCommandsPending || | ||
| 2198 | !workspaceRefreshRetry || | ||
| 2199 | workspaceRefreshRetry.key !== workspaceRefreshScopeKey | ||
| 2200 | ) | ||
| 2201 | return; | ||
| 2202 | const timeout = setTimeout( | ||
| 2203 | () => { | ||
| 2204 | setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); | ||
| 2205 | }, | ||
| 2206 | Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), | ||
| 2207 | ); | ||
| 2208 | return () => clearTimeout(timeout); | ||
| 2209 | }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); | ||
| 2183 | const hadWorkspaceSnapshotRef = useRef(false); | 2210 | const hadWorkspaceSnapshotRef = useRef(false); |
| 2184 | useEffect(() => { | 2211 | useEffect(() => { |
| 2185 | const hasWorkspaceSnapshot = Boolean( | 2212 | const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( |
| 2186 | gitCwd && | 2213 | selectedProviderStatus, |
| 2187 | selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), | 2214 | gitCwd, |
| 2188 | ); | 2215 | ); |
| 2189 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { | 2216 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { |
| 2190 | workspaceRefreshKeyRef.current = null; | 2217 | workspaceRefreshKeyRef.current = null; |
| 2191 | workspaceRefreshRetryRef.current = null; | 2218 | setWorkspaceRefreshRetry(null); |
| 2192 | } | 2219 | } |
| 2193 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; | 2220 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; |
| 2194 | }, [gitCwd, selectedProviderStatus]); | 2221 | }, [gitCwd, selectedProviderStatus]); |
| 2195 | useEffect(() => { | 2222 | useEffect(() => { |
| 2196 | if (!gitCwd || !selectedProviderEntry) return; | 2223 | if (!gitCwd || !selectedProviderEntry) return; |
| 2197 | const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; | 2224 | const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; |
| 2198 | const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some( | 2225 | const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( |
| 2199 | (snapshot) => snapshot.cwd === gitCwd, | 2226 | selectedProviderStatus, |
| 2227 | gitCwd, | ||
| 2200 | ); | 2228 | ); |
| 2201 | if (workspaceRefreshKeyRef.current === key) return; | 2229 | if (workspaceRefreshKeyRef.current === key) return; |
| 2202 | if (hasWorkspaceSnapshot) { | 2230 | if (hasWorkspaceSnapshot) { |
| 2203 | workspaceRefreshKeyRef.current = key; | 2231 | workspaceRefreshKeyRef.current = key; |
| 2204 | workspaceRefreshRetryRef.current = null; | 2232 | setWorkspaceRefreshRetry(null); |
| 2205 | return; | 2233 | return; |
| 2206 | } | 2234 | } |
| 2207 | const retry = workspaceRefreshRetryRef.current; | 2235 | const retry = workspaceRefreshRetry; |
| 2208 | if (retry?.key === key && Date.now() < retry.notBefore) return; | 2236 | if (retry?.key === key && Date.now() < retry.notBefore) return; |
| 2209 | workspaceRefreshKeyRef.current = key; | 2237 | workspaceRefreshKeyRef.current = key; |
| 2210 | const retryLater = () => { | 2238 | const retryLater = () => { |
| 2211 | if (workspaceRefreshKeyRef.current !== key) return; | 2239 | if (workspaceRefreshKeyRef.current !== key) return; |
| 2212 | workspaceRefreshKeyRef.current = null; | 2240 | workspaceRefreshKeyRef.current = null; |
| 2213 | workspaceRefreshRetryRef.current = { | 2241 | setWorkspaceRefreshRetry({ |
| 2214 | key, | 2242 | key, |
| 2215 | notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, | 2243 | notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, |
| 2216 | }; | 2244 | }); |
| 2217 | }; | 2245 | }; |
| 2218 | void refreshProviders({ | 2246 | void refreshProviders({ |
| 2219 | environmentId, | 2247 | environmentId, |
| ... | @@ -2221,14 +2249,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2221,14 +2249,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2221 | }).then((result) => { | 2249 | }).then((result) => { |
| 2222 | const hasWorkspaceSnapshot = | 2250 | const hasWorkspaceSnapshot = |
| 2223 | result._tag === "Success" && | 2251 | result._tag === "Success" && |
| 2224 | result.value.providers | 2252 | hasCompleteProviderWorkspaceSnapshot( |
| 2225 | .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) | 2253 | result.value.providers.find( |
| 2226 | ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); | 2254 | (provider) => provider.instanceId === selectedProviderEntry.instanceId, |
| 2255 | ), | ||
| 2256 | gitCwd, | ||
| 2257 | ); | ||
| 2227 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { | 2258 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { |
| 2228 | retryLater(); | 2259 | retryLater(); |
| 2229 | } | 2260 | } |
| 2230 | }, retryLater); | 2261 | }, retryLater); |
| 2231 | }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]); | 2262 | }, [ |
| 2263 | environmentId, | ||
| 2264 | gitCwd, | ||
| 2265 | prompt, | ||
| 2266 | refreshProviders, | ||
| 2267 | selectedProviderEntry, | ||
| 2268 | workspaceRefreshRetry, | ||
| 2269 | ]); | ||
| 2232 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( | 2270 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| 2233 | () => selectedProviderEntry?.models ?? [], | 2271 | () => selectedProviderEntry?.models ?? [], |
| 2234 | [selectedProviderEntry], | 2272 | [selectedProviderEntry], |
packages/client-runtime/src/providerSkills.test.ts+23| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | formatProviderSkillDisplayName, | 6 | formatProviderSkillDisplayName, |
| 7 | getProviderSlashCommandsForSlashMenu, | 7 | getProviderSlashCommandsForSlashMenu, |
| 8 | getProviderSkillsForSlashMenu, | 8 | getProviderSkillsForSlashMenu, |
| 9 | hasCompleteProviderWorkspaceSnapshot, | ||
| 9 | resolveProviderSkillsForCwd, | 10 | resolveProviderSkillsForCwd, |
| 10 | resolveProviderSlashCommandsForCwd, | 11 | resolveProviderSlashCommandsForCwd, |
| 11 | resolveProviderSkillSourceKind, | 12 | resolveProviderSkillSourceKind, |
| ... | @@ -251,4 +252,26 @@ describe("workspace provider snapshots", () => { | ... | @@ -251,4 +252,26 @@ describe("workspace provider snapshots", () => { |
| 251 | expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); | 252 | expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); |
| 252 | expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); | 253 | expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); |
| 253 | }); | 254 | }); |
| 255 | |||
| 256 | it("uses partial workspace skills and commands while keeping discovery retryable", () => { | ||
| 257 | const partial = { | ||
| 258 | ...provider, | ||
| 259 | workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ | ||
| 260 | ...snapshot, | ||
| 261 | slashCommands: [{ name: "compact" }], | ||
| 262 | slashCommandsPending: true, | ||
| 263 | })), | ||
| 264 | } satisfies ServerProvider; | ||
| 265 | expect(resolveProviderSkillsForCwd(partial, "/workspace/project-a")).toEqual( | ||
| 266 | provider.workspaceSnapshots[0]?.skills, | ||
| 267 | ); | ||
| 268 | expect(resolveProviderSlashCommandsForCwd(partial, "/workspace/project-a")).toEqual([ | ||
| 269 | { name: "compact" }, | ||
| 270 | ]); | ||
| 271 | expect(hasCompleteProviderWorkspaceSnapshot(partial, "/workspace/project-a")).toBe(false); | ||
| 272 | expect(hasCompleteProviderWorkspaceSnapshot(provider, "/workspace/project-a")).toBe(true); | ||
| 273 | expect(hasCompleteProviderWorkspaceSnapshot(provider, "/workspace/project-b")).toBe(false); | ||
| 274 | expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false); | ||
| 275 | expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); | ||
| 276 | }); | ||
| 254 | }); | 277 | }); |
packages/client-runtime/src/providerSkills.ts+8| ... | @@ -111,6 +111,14 @@ function resolveProviderWorkspaceSnapshot( | ... | @@ -111,6 +111,14 @@ function resolveProviderWorkspaceSnapshot( |
| 111 | return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); | 111 | return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); |
| 112 | } | 112 | } |
| 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 | export function resolveProviderSkillsForCwd( | 122 | export function resolveProviderSkillsForCwd( |
| 115 | provider: ServerProvider, | 123 | provider: ServerProvider, |
| 116 | cwd: string | null | undefined, | 124 | cwd: string | null | undefined, |
packages/contracts/src/server.test.ts+15| ... | @@ -31,6 +31,21 @@ const baseProviderSnapshot = { | ... | @@ -31,6 +31,21 @@ const baseProviderSnapshot = { |
| 31 | }; | 31 | }; |
| 32 | 32 | ||
| 33 | describe("ServerProvider", () => { | 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 | it("defaults capability arrays when decoding provider snapshots", () => { | 49 | it("defaults capability arrays when decoding provider snapshots", () => { |
| 35 | const parsed = decodeServerProvider({ | 50 | const parsed = decodeServerProvider({ |
| 36 | instanceId: "codex", | 51 | instanceId: "codex", |
packages/contracts/src/server.ts+2| ... | @@ -125,6 +125,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ | ... | @@ -125,6 +125,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ |
| 125 | cwd: TrimmedNonEmptyString, | 125 | cwd: TrimmedNonEmptyString, |
| 126 | checkedAt: IsoDateTime, | 126 | checkedAt: IsoDateTime, |
| 127 | slashCommands: Schema.Array(ServerProviderSlashCommand), | 127 | slashCommands: Schema.Array(ServerProviderSlashCommand), |
| 128 | /** Skills are available, but command discovery still needs a retry. */ | ||
| 129 | slashCommandsPending: Schema.optional(Schema.Boolean), | ||
| 128 | skills: Schema.Array(ServerProviderSkill), | 130 | skills: Schema.Array(ServerProviderSkill), |
| 129 | }); | 131 | }); |
| 130 | export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; | 132 | export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; |