| author | |
| committer | |
| log | 80a14b6588f0e856983e6d10002cde9e00e9d3e2 |
| tree | acf5311537428750cae2ee530763920840f95f91 |
| parent | 082358f9ef6feaadfaafd59a7512da26beb00100 |
| signature | Signed by PGP key B5690EEEBB952194 |
24 files changed, 692 insertions(+), 40 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+18-10| ... | @@ -657,21 +657,28 @@ const make = Effect.gen(function* () { | ... | @@ -657,21 +657,28 @@ const make = Effect.gen(function* () { |
| 657 | thread, | 657 | thread, |
| 658 | projects: project ? [project] : [], | 658 | projects: project ? [project] : [], |
| 659 | }); | 659 | }); |
| 660 | const refreshWorkspaceSnapshot = effectiveCwd | ||
| 661 | ? providerRegistry | ||
| 662 | .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) | ||
| 663 | .pipe(Effect.forkDetach) | ||
| 664 | : Effect.void; | ||
| 660 | 665 | ||
| 661 | const startProviderSession = (input?: { | 666 | const startProviderSession = (input?: { |
| 662 | readonly resumeCursor?: unknown; | 667 | readonly resumeCursor?: unknown; |
| 663 | readonly provider?: ProviderDriverKind; | 668 | readonly provider?: ProviderDriverKind; |
| 664 | }) => | 669 | }) => |
| 665 | providerService.startSession(threadId, { | 670 | providerService |
| 666 | threadId, | 671 | .startSession(threadId, { |
| 667 | ...(preferredProvider ? { provider: preferredProvider } : {}), | 672 | threadId, |
| 668 | providerInstanceId: desiredInstanceId, | 673 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 669 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | 674 | providerInstanceId: desiredInstanceId, |
| 670 | ...(thread.title ? { title: thread.title } : {}), | 675 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 671 | modelSelection: desiredModelSelection, | 676 | ...(thread.title ? { title: thread.title } : {}), |
| 672 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | 677 | modelSelection: desiredModelSelection, |
| 673 | runtimeMode: desiredRuntimeMode, | 678 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 674 | }); | 679 | runtimeMode: desiredRuntimeMode, |
| 680 | }) | ||
| 681 | .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); | ||
| 675 | 682 | ||
| 676 | const bindSessionToThread = (session: ProviderSession) => | 683 | const bindSessionToThread = (session: ProviderSession) => |
| 677 | Effect.gen(function* () { | 684 | Effect.gen(function* () { |
| ... | @@ -729,6 +736,7 @@ const make = Effect.gen(function* () { | ... | @@ -729,6 +736,7 @@ const make = Effect.gen(function* () { |
| 729 | !shouldRestartForModelChange && | 736 | !shouldRestartForModelChange && |
| 730 | !shouldRestartForModelSelectionChange | 737 | !shouldRestartForModelSelectionChange |
| 731 | ) { | 738 | ) { |
| 739 | yield* refreshWorkspaceSnapshot; | ||
| 732 | return existingSessionThreadId; | 740 | return existingSessionThreadId; |
| 733 | } | 741 | } |
| 734 | 742 |
apps/server/src/provider/Drivers/CodexDriver.ts+35-1| ... | @@ -36,7 +36,12 @@ import { ServerConfig } from "../../config.ts"; | ... | @@ -36,7 +36,12 @@ import { ServerConfig } from "../../config.ts"; |
| 36 | import { ServerSettingsService } from "../../serverSettings.ts"; | 36 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 37 | import { ProviderDriverError } from "../Errors.ts"; | 37 | import { ProviderDriverError } from "../Errors.ts"; |
| 38 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | 38 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; |
| 39 | import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; | 39 | import { |
| 40 | checkCodexProviderStatus, | ||
| 41 | makePendingCodexProvider, | ||
| 42 | probeCodexSkillsForCwd, | ||
| 43 | } from "../Layers/CodexProvider.ts"; | ||
| 44 | import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; | ||
| 40 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 45 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 46 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 42 | import * as ModelManifest from "../ModelManifest.ts"; | 47 | import * as ModelManifest from "../ModelManifest.ts"; |
| ... | @@ -195,6 +200,34 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -195,6 +200,34 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 195 | }), | 200 | }), |
| 196 | ), | 201 | ), |
| 197 | ); | 202 | ); |
| 203 | const snapshotForCwd = (cwd: string) => | ||
| 204 | !effectiveConfig.enabled | ||
| 205 | ? snapshot.getSnapshot | ||
| 206 | : Effect.all([ | ||
| 207 | snapshot.getSnapshot, | ||
| 208 | probeCodexSkillsForCwd({ | ||
| 209 | binaryPath: effectiveConfig.binaryPath, | ||
| 210 | homePath: effectiveConfig.homePath, | ||
| 211 | launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), | ||
| 212 | cwd, | ||
| 213 | environment: processEnv, | ||
| 214 | }).pipe( | ||
| 215 | Effect.scoped, | ||
| 216 | Effect.timeout("20 seconds"), | ||
| 217 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 218 | ), | ||
| 219 | ]).pipe( | ||
| 220 | Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), | ||
| 221 | Effect.mapError( | ||
| 222 | (cause) => | ||
| 223 | new ProviderDriverError({ | ||
| 224 | driver: DRIVER_KIND, | ||
| 225 | instanceId, | ||
| 226 | detail: `Failed to probe Codex skills for '${cwd}'`, | ||
| 227 | cause, | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | ); | ||
| 198 | 231 | ||
| 199 | return { | 232 | return { |
| 200 | instanceId, | 233 | instanceId, |
| ... | @@ -204,6 +237,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -204,6 +237,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 204 | accentColor, | 237 | accentColor, |
| 205 | enabled, | 238 | enabled, |
| 206 | snapshot, | 239 | snapshot, |
| 240 | snapshotForCwd, | ||
| 207 | adapter, | 241 | adapter, |
| 208 | textGeneration, | 242 | textGeneration, |
| 209 | } satisfies ProviderInstance; | 243 | } satisfies ProviderInstance; |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+50| ... | @@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; | ... | @@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; |
| 30 | import { | 30 | import { |
| 31 | checkOpenCodeProviderStatus, | 31 | checkOpenCodeProviderStatus, |
| 32 | makePendingOpenCodeProvider, | 32 | makePendingOpenCodeProvider, |
| 33 | openCodeSkillsToServerProviderSkills, | ||
| 33 | } from "../Layers/OpenCodeProvider.ts"; | 34 | } from "../Layers/OpenCodeProvider.ts"; |
| 34 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 35 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 35 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 36 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| ... | @@ -148,6 +149,34 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -148,6 +149,34 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 148 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | 149 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), |
| 149 | Effect.provideService(OpenCodeRuntime, openCodeRuntime), | 150 | Effect.provideService(OpenCodeRuntime, openCodeRuntime), |
| 150 | ); | 151 | ); |
| 152 | const loadSkillsForCwd = (cwd: string) => | ||
| 153 | effectiveConfig.serverUrl.trim().length > 0 | ||
| 154 | ? Effect.scoped( | ||
| 155 | Effect.gen(function* () { | ||
| 156 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ | ||
| 157 | binaryPath: effectiveConfig.binaryPath, | ||
| 158 | directory: cwd, | ||
| 159 | serverUrl: effectiveConfig.serverUrl, | ||
| 160 | ...(effectiveConfig.serverPassword | ||
| 161 | ? { serverPassword: effectiveConfig.serverPassword } | ||
| 162 | : {}), | ||
| 163 | environment: processEnv, | ||
| 164 | }); | ||
| 165 | const client = openCodeRuntime.createOpenCodeSdkClient({ | ||
| 166 | baseUrl: server.url, | ||
| 167 | directory: cwd, | ||
| 168 | ...(effectiveConfig.serverPassword | ||
| 169 | ? { serverPassword: effectiveConfig.serverPassword } | ||
| 170 | : {}), | ||
| 171 | }); | ||
| 172 | return yield* openCodeRuntime.loadOpenCodeSkills(client); | ||
| 173 | }), | ||
| 174 | ) | ||
| 175 | : openCodeRuntime.loadSkillsFromCli({ | ||
| 176 | binaryPath: effectiveConfig.binaryPath, | ||
| 177 | cwd, | ||
| 178 | environment: processEnv, | ||
| 179 | }); | ||
| 151 | 180 | ||
| 152 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); | 181 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| 153 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>( | 182 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>( |
| ... | @@ -189,6 +218,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -189,6 +218,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 189 | accentColor, | 218 | accentColor, |
| 190 | enabled, | 219 | enabled, |
| 191 | snapshot, | 220 | snapshot, |
| 221 | snapshotForCwd: (cwd) => | ||
| 222 | !effectiveConfig.enabled | ||
| 223 | ? snapshot.getSnapshot | ||
| 224 | : Effect.all([ | ||
| 225 | snapshot.getSnapshot, | ||
| 226 | loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")), | ||
| 227 | ]).pipe( | ||
| 228 | Effect.map(([machineSnapshot, skills]) => ({ | ||
| 229 | ...machineSnapshot, | ||
| 230 | skills: openCodeSkillsToServerProviderSkills(skills), | ||
| 231 | })), | ||
| 232 | Effect.mapError( | ||
| 233 | (cause) => | ||
| 234 | new ProviderDriverError({ | ||
| 235 | driver: DRIVER_KIND, | ||
| 236 | instanceId, | ||
| 237 | detail: `Failed to probe OpenCode skills for '${cwd}'`, | ||
| 238 | cause, | ||
| 239 | }), | ||
| 240 | ), | ||
| 241 | ), | ||
| 192 | adapter, | 242 | adapter, |
| 193 | textGeneration, | 243 | textGeneration, |
| 194 | } satisfies ProviderInstance; | 244 | } satisfies ProviderInstance; |
apps/server/src/provider/Layers/CodexProvider.ts+47| ... | @@ -414,6 +414,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun | ... | @@ -414,6 +414,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 414 | } satisfies CodexAppServerProviderSnapshot; | 414 | } satisfies CodexAppServerProviderSnapshot; |
| 415 | }); | 415 | }); |
| 416 | 416 | ||
| 417 | export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { | ||
| 418 | readonly binaryPath: string; | ||
| 419 | readonly homePath?: string; | ||
| 420 | readonly launchArgs?: string; | ||
| 421 | readonly cwd: string; | ||
| 422 | readonly environment?: NodeJS.ProcessEnv; | ||
| 423 | }) { | ||
| 424 | const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; | ||
| 425 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 426 | const environment = { | ||
| 427 | ...input.environment, | ||
| 428 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | ||
| 429 | }; | ||
| 430 | const spawnCommand = yield* resolveSpawnCommand( | ||
| 431 | input.binaryPath, | ||
| 432 | codexAppServerArgs(input.launchArgs), | ||
| 433 | { env: environment, extendEnv: true }, | ||
| 434 | ); | ||
| 435 | const child = yield* spawner | ||
| 436 | .spawn( | ||
| 437 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 438 | cwd: input.cwd, | ||
| 439 | env: environment, | ||
| 440 | extendEnv: true, | ||
| 441 | forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, | ||
| 442 | shell: spawnCommand.shell, | ||
| 443 | }), | ||
| 444 | ) | ||
| 445 | .pipe( | ||
| 446 | Effect.mapError( | ||
| 447 | (cause) => | ||
| 448 | new CodexErrors.CodexAppServerSpawnError({ | ||
| 449 | command: `${input.binaryPath} app-server`, | ||
| 450 | cause, | ||
| 451 | }), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); | ||
| 455 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( | ||
| 456 | Effect.provide(clientContext), | ||
| 457 | ); | ||
| 458 | yield* client.request("initialize", buildCodexInitializeParams()); | ||
| 459 | yield* client.notify("initialized", undefined); | ||
| 460 | const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); | ||
| 461 | return parseCodexSkillsListResponse(skillsResponse, input.cwd); | ||
| 462 | }); | ||
| 463 | |||
| 417 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { | 464 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { |
| 418 | const models = new Set<string>(); | 465 | const models = new Set<string>(); |
| 419 | for (const model of codexSettings.customModels) { | 466 | for (const model of codexSettings.customModels) { |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+2| ... | @@ -407,6 +407,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -407,6 +407,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 407 | cause: null, | 407 | cause: null, |
| 408 | }), | 408 | }), |
| 409 | ), | 409 | ), |
| 410 | loadOpenCodeSkills: () => Effect.succeed([]), | ||
| 410 | loadInventoryFromCli: () => | 411 | loadInventoryFromCli: () => |
| 411 | Effect.fail( | 412 | Effect.fail( |
| 412 | new OpenCodeRuntimeError({ | 413 | new OpenCodeRuntimeError({ |
| ... | @@ -415,6 +416,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -415,6 +416,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 415 | cause: null, | 416 | cause: null, |
| 416 | }), | 417 | }), |
| 417 | ), | 418 | ), |
| 419 | loadSkillsFromCli: () => Effect.succeed([]), | ||
| 418 | }; | 420 | }; |
| 419 | 421 | ||
| 420 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { | 422 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+2| ... | @@ -149,6 +149,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -149,6 +149,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 149 | ) | 149 | ) |
| 150 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); | 150 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); |
| 151 | }, | 151 | }, |
| 152 | loadOpenCodeSkills: () => Effect.succeed([]), | ||
| 153 | loadSkillsFromCli: () => Effect.succeed([]), | ||
| 152 | }; | 154 | }; |
| 153 | 155 | ||
| 154 | beforeEach(() => { | 156 | beforeEach(() => { |
apps/server/src/provider/Layers/OpenCodeProvider.ts+5-3| ... | @@ -260,9 +260,11 @@ function trimOptional(value: string | null | undefined): string | undefined { | ... | @@ -260,9 +260,11 @@ function trimOptional(value: string | null | undefined): string | undefined { |
| 260 | return trimmed && trimmed.length > 0 ? trimmed : undefined; | 260 | return trimmed && trimmed.length > 0 ? trimmed : undefined; |
| 261 | } | 261 | } |
| 262 | 262 | ||
| 263 | function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray<ServerProviderSkill> { | 263 | export function openCodeSkillsToServerProviderSkills( |
| 264 | input: OpenCodeInventory["skills"] | undefined, | ||
| 265 | ): ReadonlyArray<ServerProviderSkill> { | ||
| 264 | const skills: ServerProviderSkill[] = []; | 266 | const skills: ServerProviderSkill[] = []; |
| 265 | for (const skill of input.skills ?? []) { | 267 | for (const skill of input ?? []) { |
| 266 | const name = trimOptional(skill.name); | 268 | const name = trimOptional(skill.name); |
| 267 | const path = trimOptional(skill.location); | 269 | const path = trimOptional(skill.location); |
| 268 | if (!name || !path) { | 270 | if (!name || !path) { |
| ... | @@ -475,7 +477,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu | ... | @@ -475,7 +477,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 475 | customModels, | 477 | customModels, |
| 476 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | 478 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 477 | ); | 479 | ); |
| 478 | const skills = flattenOpenCodeSkills(inventoryExit.value.inventory); | 480 | const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); |
| 479 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; | 481 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; |
| 480 | return buildServerProvider({ | 482 | return buildServerProvider({ |
| 481 | presentation: OPENCODE_PRESENTATION, | 483 | presentation: OPENCODE_PRESENTATION, |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+195| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, it, assert } from "@effect/vitest"; | 2 | import { describe, it, assert } from "@effect/vitest"; |
| 3 | import * as DateTime from "effect/DateTime"; | 3 | import * as DateTime from "effect/DateTime"; |
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 6 | import * as Exit from "effect/Exit"; |
| 6 | import * as Fiber from "effect/Fiber"; | 7 | import * as Fiber from "effect/Fiber"; |
| ... | @@ -41,6 +42,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr | ... | @@ -41,6 +42,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr |
| 41 | import { | 42 | import { |
| 42 | haveProvidersChanged, | 43 | haveProvidersChanged, |
| 43 | mergeProviderSnapshot, | 44 | mergeProviderSnapshot, |
| 45 | upsertProviderWorkspaceSnapshot, | ||
| 44 | ProviderRegistryLive, | 46 | ProviderRegistryLive, |
| 45 | } from "./ProviderRegistry.ts"; | 47 | } from "./ProviderRegistry.ts"; |
| 46 | import * as ServerConfig from "../../config.ts"; | 48 | import * as ServerConfig from "../../config.ts"; |
| ... | @@ -563,6 +565,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -563,6 +565,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 563 | assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); | 565 | assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); |
| 564 | }); | 566 | }); |
| 565 | 567 | ||
| 568 | it("stores workspace skills and commands without changing machine metadata", () => { | ||
| 569 | const provider = { | ||
| 570 | instanceId: ProviderInstanceId.make("codex"), | ||
| 571 | driver: ProviderDriverKind.make("codex"), | ||
| 572 | status: "ready", | ||
| 573 | enabled: true, | ||
| 574 | installed: true, | ||
| 575 | auth: { status: "authenticated" }, | ||
| 576 | checkedAt: "2026-03-25T00:00:00.000Z", | ||
| 577 | version: "1.0.0", | ||
| 578 | models: [], | ||
| 579 | slashCommands: [{ name: "global" }], | ||
| 580 | skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], | ||
| 581 | } satisfies ServerProvider; | ||
| 582 | const scopedSnapshot = { | ||
| 583 | ...provider, | ||
| 584 | checkedAt: "2026-03-25T00:01:00.000Z", | ||
| 585 | slashCommands: [{ name: "project" }], | ||
| 586 | skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], | ||
| 587 | } satisfies ServerProvider; | ||
| 588 | |||
| 589 | const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot); | ||
| 590 | |||
| 591 | assert.deepStrictEqual(result.slashCommands, provider.slashCommands); | ||
| 592 | assert.deepStrictEqual(result.skills, provider.skills); | ||
| 593 | assert.deepStrictEqual(result.workspaceSnapshots, [ | ||
| 594 | { | ||
| 595 | cwd: "/project", | ||
| 596 | checkedAt: scopedSnapshot.checkedAt, | ||
| 597 | slashCommands: scopedSnapshot.slashCommands, | ||
| 598 | skills: scopedSnapshot.skills, | ||
| 599 | }, | ||
| 600 | ]); | ||
| 601 | }); | ||
| 602 | |||
| 566 | it("preserves previously discovered provider models when a refresh returns none", () => { | 603 | it("preserves previously discovered provider models when a refresh returns none", () => { |
| 567 | const previousProvider = { | 604 | const previousProvider = { |
| 568 | instanceId: ProviderInstanceId.make("cursor"), | 605 | instanceId: ProviderInstanceId.make("cursor"), |
| ... | @@ -983,6 +1020,164 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -983,6 +1020,164 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 983 | }), | 1020 | }), |
| 984 | ); | 1021 | ); |
| 985 | 1022 | ||
| 1023 | it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => | ||
| 1024 | Effect.gen(function* () { | ||
| 1025 | const driver = ProviderDriverKind.make("codex"); | ||
| 1026 | const instanceId = ProviderInstanceId.make("codex"); | ||
| 1027 | const machineProvider = { | ||
| 1028 | instanceId, | ||
| 1029 | driver, | ||
| 1030 | status: "ready", | ||
| 1031 | enabled: true, | ||
| 1032 | installed: true, | ||
| 1033 | auth: { status: "authenticated" }, | ||
| 1034 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1035 | version: "1.0.0", | ||
| 1036 | models: [], | ||
| 1037 | slashCommands: [{ name: "global" }], | ||
| 1038 | skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], | ||
| 1039 | } as const satisfies ServerProvider; | ||
| 1040 | const scopedProvider = { | ||
| 1041 | ...machineProvider, | ||
| 1042 | checkedAt: "2026-06-10T00:01:00.000Z", | ||
| 1043 | slashCommands: [{ name: "project" }], | ||
| 1044 | skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], | ||
| 1045 | } as const satisfies ServerProvider; | ||
| 1046 | const pendingScopedProvider = { | ||
| 1047 | ...scopedProvider, | ||
| 1048 | status: "error", | ||
| 1049 | installed: false, | ||
| 1050 | slashCommands: [], | ||
| 1051 | } as const satisfies ServerProvider; | ||
| 1052 | const snapshotCalls = yield* Ref.make(0); | ||
| 1053 | const returnPendingSnapshot = yield* Ref.make(true); | ||
| 1054 | const probeStarted = yield* Deferred.make<void>(); | ||
| 1055 | const releaseProbe = yield* Deferred.make<void>(); | ||
| 1056 | const makeInstance = ( | ||
| 1057 | provider: ServerProvider, | ||
| 1058 | snapshotForCwd: NonNullable<ProviderInstance["snapshotForCwd"]>, | ||
| 1059 | ): ProviderInstance => ({ | ||
| 1060 | instanceId, | ||
| 1061 | driverKind: driver, | ||
| 1062 | continuationIdentity: { | ||
| 1063 | driverKind: driver, | ||
| 1064 | continuationKey: "codex:instance:codex", | ||
| 1065 | }, | ||
| 1066 | displayName: undefined, | ||
| 1067 | enabled: true, | ||
| 1068 | snapshot: { | ||
| 1069 | maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1070 | provider: driver, | ||
| 1071 | packageName: null, | ||
| 1072 | }), | ||
| 1073 | getSnapshot: Effect.succeed(provider), | ||
| 1074 | refresh: Effect.succeed(provider), | ||
| 1075 | streamChanges: Stream.empty, | ||
| 1076 | }, | ||
| 1077 | snapshotForCwd, | ||
| 1078 | adapter: {} as ProviderInstance["adapter"], | ||
| 1079 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1080 | }); | ||
| 1081 | const firstInstance = makeInstance(machineProvider, () => | ||
| 1082 | Effect.gen(function* () { | ||
| 1083 | yield* Ref.update(snapshotCalls, (count) => count + 1); | ||
| 1084 | if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; | ||
| 1085 | yield* Deferred.succeed(probeStarted, undefined); | ||
| 1086 | yield* Deferred.await(releaseProbe); | ||
| 1087 | return scopedProvider; | ||
| 1088 | }), | ||
| 1089 | ); | ||
| 1090 | const rebuiltProvider = { | ||
| 1091 | ...machineProvider, | ||
| 1092 | checkedAt: "2026-06-10T00:02:00.000Z", | ||
| 1093 | status: "warning", | ||
| 1094 | installed: false, | ||
| 1095 | auth: { status: "unknown" }, | ||
| 1096 | } satisfies ServerProvider; | ||
| 1097 | const rebuiltInstance = makeInstance(rebuiltProvider, () => | ||
| 1098 | Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), | ||
| 1099 | ); | ||
| 1100 | const registryChanges = yield* PubSub.unbounded<void>(); | ||
| 1101 | const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([firstInstance]); | ||
| 1102 | const instanceRegistryLayer = Layer.succeed( | ||
| 1103 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1104 | { | ||
| 1105 | getInstance: (requestedId) => | ||
| 1106 | Ref.get(instancesRef).pipe( | ||
| 1107 | Effect.map((instances) => | ||
| 1108 | instances.find((instance) => instance.instanceId === requestedId), | ||
| 1109 | ), | ||
| 1110 | ), | ||
| 1111 | listInstances: Ref.get(instancesRef), | ||
| 1112 | listUnavailable: Effect.succeed([]), | ||
| 1113 | streamChanges: Stream.fromPubSub(registryChanges), | ||
| 1114 | subscribeChanges: PubSub.subscribe(registryChanges), | ||
| 1115 | }, | ||
| 1116 | ); | ||
| 1117 | const scope = yield* Scope.make(); | ||
| 1118 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 1119 | const runtimeServices = yield* Layer.build( | ||
| 1120 | ProviderRegistryLive.pipe( | ||
| 1121 | Layer.provideMerge(instanceRegistryLayer), | ||
| 1122 | Layer.provideMerge( | ||
| 1123 | ServerConfig.layerTest(process.cwd(), { | ||
| 1124 | prefix: "t3-provider-registry-workspace-snapshot-", | ||
| 1125 | }), | ||
| 1126 | ), | ||
| 1127 | Layer.provideMerge(NodeServices.layer), | ||
| 1128 | ), | ||
| 1129 | ).pipe(Scope.provide(scope)); | ||
| 1130 | |||
| 1131 | yield* Effect.gen(function* () { | ||
| 1132 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1133 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1134 | assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); | ||
| 1135 | yield* Ref.set(returnPendingSnapshot, false); | ||
| 1136 | const workspaceUpdate = yield* registry.streamChanges.pipe( | ||
| 1137 | Stream.runHead, | ||
| 1138 | Effect.forkChild, | ||
| 1139 | ); | ||
| 1140 | yield* Effect.yieldNow; | ||
| 1141 | const firstRefresh = yield* registry | ||
| 1142 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) | ||
| 1143 | .pipe(Effect.forkChild); | ||
| 1144 | yield* Deferred.await(probeStarted); | ||
| 1145 | const duplicateRefresh = yield* registry | ||
| 1146 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) | ||
| 1147 | .pipe(Effect.forkChild); | ||
| 1148 | yield* Effect.yieldNow; | ||
| 1149 | assert.strictEqual(yield* Ref.get(snapshotCalls), 2); | ||
| 1150 | yield* Deferred.succeed(releaseProbe, undefined); | ||
| 1151 | yield* Fiber.join(firstRefresh); | ||
| 1152 | yield* Fiber.join(duplicateRefresh); | ||
| 1153 | const published = yield* Fiber.join(workspaceUpdate); | ||
| 1154 | assert.strictEqual(published._tag, "Some"); | ||
| 1155 | const providers = yield* registry.getProviders; | ||
| 1156 | assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); | ||
| 1157 | assert.deepStrictEqual( | ||
| 1158 | providers[0]?.workspaceSnapshots?.[0]?.skills, | ||
| 1159 | scopedProvider.skills, | ||
| 1160 | ); | ||
| 1161 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1162 | assert.strictEqual(yield* Ref.get(snapshotCalls), 2); | ||
| 1163 | |||
| 1164 | yield* Ref.set(instancesRef, [rebuiltInstance]); | ||
| 1165 | yield* PubSub.publish(registryChanges, undefined); | ||
| 1166 | let rebuilt = yield* registry.getProviders; | ||
| 1167 | for ( | ||
| 1168 | let attempt = 0; | ||
| 1169 | attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; | ||
| 1170 | attempt += 1 | ||
| 1171 | ) { | ||
| 1172 | yield* Effect.yieldNow; | ||
| 1173 | rebuilt = yield* registry.getProviders; | ||
| 1174 | } | ||
| 1175 | assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); | ||
| 1176 | assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); | ||
| 1177 | }).pipe(Effect.provide(runtimeServices)); | ||
| 1178 | }), | ||
| 1179 | ); | ||
| 1180 | |||
| 986 | it.effect("refreshes OpenCode catalogs and preserves other providers", () => | 1181 | it.effect("refreshes OpenCode catalogs and preserves other providers", () => |
| 987 | Effect.gen(function* () { | 1182 | Effect.gen(function* () { |
| 988 | const codexDriver = ProviderDriverKind.make("codex"); | 1183 | const codexDriver = ProviderDriverKind.make("codex"); |
apps/server/src/provider/Layers/ProviderRegistry.ts+118-1| ... | @@ -78,6 +78,28 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) | ... | @@ -78,6 +78,28 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) |
| 78 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => | 78 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => |
| 79 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | 79 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; |
| 80 | 80 | ||
| 81 | const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; | ||
| 82 | |||
| 83 | export function upsertProviderWorkspaceSnapshot( | ||
| 84 | provider: ServerProvider, | ||
| 85 | cwd: string, | ||
| 86 | scopedSnapshot: ServerProvider, | ||
| 87 | ): ServerProvider { | ||
| 88 | const workspaceSnapshot = { | ||
| 89 | cwd, | ||
| 90 | checkedAt: scopedSnapshot.checkedAt, | ||
| 91 | slashCommands: scopedSnapshot.slashCommands, | ||
| 92 | skills: scopedSnapshot.skills, | ||
| 93 | } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number]; | ||
| 94 | return { | ||
| 95 | ...provider, | ||
| 96 | workspaceSnapshots: [ | ||
| 97 | ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), | ||
| 98 | workspaceSnapshot, | ||
| 99 | ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), | ||
| 100 | }; | ||
| 101 | } | ||
| 102 | |||
| 81 | const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { | 103 | const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { |
| 82 | if (provider.driver !== ProviderDriverKind.make("opencode")) { | 104 | if (provider.driver !== ProviderDriverKind.make("opencode")) { |
| 83 | return true; | 105 | return true; |
| ... | @@ -140,6 +162,11 @@ export const mergeProviderSnapshot = ( | ... | @@ -140,6 +162,11 @@ export const mergeProviderSnapshot = ( |
| 140 | : { | 162 | : { |
| 141 | ...nextProvider, | 163 | ...nextProvider, |
| 142 | models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), | 164 | models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), |
| 165 | ...(nextProvider.workspaceSnapshots !== undefined | ||
| 166 | ? { workspaceSnapshots: nextProvider.workspaceSnapshots } | ||
| 167 | : previousProvider.workspaceSnapshots !== undefined | ||
| 168 | ? { workspaceSnapshots: previousProvider.workspaceSnapshots } | ||
| 169 | : {}), | ||
| 143 | ...(shouldRetainMissingOpenCodeMetadata(nextProvider) | 170 | ...(shouldRetainMissingOpenCodeMetadata(nextProvider) |
| 144 | ? { | 171 | ? { |
| 145 | slashCommands: | 172 | slashCommands: |
| ... | @@ -283,6 +310,9 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -283,6 +310,9 @@ export const ProviderRegistryLive = Layer.effect( |
| 283 | ), | 310 | ), |
| 284 | ); | 311 | ); |
| 285 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); | 312 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); |
| 313 | const workspaceRefreshesRef = yield* Ref.make< | ||
| 314 | ReadonlyMap<ProviderInstance, ReadonlySet<string>> | ||
| 315 | >(new Map()); | ||
| 286 | const maintenanceActionStatesRef = yield* Ref.make< | 316 | const maintenanceActionStatesRef = yield* Ref.make< |
| 287 | ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }> | 317 | ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }> |
| 288 | >(new Map()); | 318 | >(new Map()); |
| ... | @@ -315,7 +345,8 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -315,7 +345,8 @@ export const ProviderRegistryLive = Layer.effect( |
| 315 | cacheDir: config.providerStatusCacheDir, | 345 | cacheDir: config.providerStatusCacheDir, |
| 316 | instanceId: key, | 346 | instanceId: key, |
| 317 | }).pipe(Effect.provideService(Path.Path, path)); | 347 | }).pipe(Effect.provideService(Path.Path, path)); |
| 318 | yield* writeProviderStatusCache({ filePath, provider }).pipe( | 348 | const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; |
| 349 | yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( | ||
| 319 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 350 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 320 | Effect.provideService(Path.Path, path), | 351 | Effect.provideService(Path.Path, path), |
| 321 | Effect.tapError(Effect.logError), | 352 | Effect.tapError(Effect.logError), |
| ... | @@ -557,6 +588,28 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -557,6 +588,28 @@ export const ProviderRegistryLive = Layer.effect( |
| 557 | newlyAdded.push([instanceId, instance] as const); | 588 | newlyAdded.push([instanceId, instance] as const); |
| 558 | } | 589 | } |
| 559 | 590 | ||
| 591 | const rebuiltInstanceIds = new Set( | ||
| 592 | newlyAdded | ||
| 593 | .map(([instanceId]) => instanceId) | ||
| 594 | .filter((instanceId) => previousSubs.has(instanceId)), | ||
| 595 | ); | ||
| 596 | if (rebuiltInstanceIds.size > 0) { | ||
| 597 | const [previousProviders, providers] = yield* Ref.modify( | ||
| 598 | providersRef, | ||
| 599 | (previousProviders) => { | ||
| 600 | const providers = previousProviders.map((provider) => { | ||
| 601 | if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; | ||
| 602 | const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; | ||
| 603 | return machineSnapshot; | ||
| 604 | }); | ||
| 605 | return [[previousProviders, providers] as const, providers]; | ||
| 606 | }, | ||
| 607 | ); | ||
| 608 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 609 | yield* PubSub.publish(changesPubSub, providers); | ||
| 610 | } | ||
| 611 | } | ||
| 612 | |||
| 560 | // Fork long-lived subscriptions to each new/rebuilt instance's | 613 | // Fork long-lived subscriptions to each new/rebuilt instance's |
| 561 | // change stream before reading its current snapshot. If the | 614 | // change stream before reading its current snapshot. If the |
| 562 | // driver's own initial probe finishes during this sync, either | 615 | // driver's own initial probe finishes during this sync, either |
| ... | @@ -698,12 +751,76 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -698,12 +751,76 @@ export const ProviderRegistryLive = Layer.effect( |
| 698 | return yield* Ref.get(providersRef); | 751 | return yield* Ref.get(providersRef); |
| 699 | }); | 752 | }); |
| 700 | 753 | ||
| 754 | const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { | ||
| 755 | readonly instanceId: ProviderInstanceId; | ||
| 756 | readonly cwd: string; | ||
| 757 | }) { | ||
| 758 | const providers = yield* Ref.get(providersRef); | ||
| 759 | const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); | ||
| 760 | if ( | ||
| 761 | !provider || | ||
| 762 | !provider.enabled || | ||
| 763 | provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) | ||
| 764 | ) { | ||
| 765 | return providers; | ||
| 766 | } | ||
| 767 | const instance = yield* instanceRegistry.getInstance(input.instanceId); | ||
| 768 | if (!instance?.snapshotForCwd) return providers; | ||
| 769 | const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { | ||
| 770 | const current = refreshes.get(instance); | ||
| 771 | if (current?.has(input.cwd)) return [false, refreshes] as const; | ||
| 772 | const next = new Map(refreshes); | ||
| 773 | next.set(instance, new Set(current).add(input.cwd)); | ||
| 774 | return [true, next] as const; | ||
| 775 | }); | ||
| 776 | if (!claimed) return yield* Ref.get(providersRef); | ||
| 777 | return yield* instance.snapshotForCwd(input.cwd).pipe( | ||
| 778 | Effect.flatMap((scopedSnapshot) => | ||
| 779 | scopedSnapshot.status === "error" | ||
| 780 | ? Ref.get(providersRef) | ||
| 781 | : instanceRegistry.getInstance(input.instanceId).pipe( | ||
| 782 | Effect.flatMap((currentInstance) => { | ||
| 783 | if (currentInstance !== instance) return Ref.get(providersRef); | ||
| 784 | return Ref.modify(providersRef, (currentProviders) => { | ||
| 785 | const nextProviders = currentProviders.map((candidate) => | ||
| 786 | candidate.instanceId === input.instanceId && | ||
| 787 | !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) | ||
| 788 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) | ||
| 789 | : candidate, | ||
| 790 | ); | ||
| 791 | return [[currentProviders, nextProviders] as const, nextProviders]; | ||
| 792 | }).pipe( | ||
| 793 | Effect.tap(([previousProviders, nextProviders]) => | ||
| 794 | haveProvidersChanged(previousProviders, nextProviders) | ||
| 795 | ? PubSub.publish(changesPubSub, nextProviders) | ||
| 796 | : Effect.void, | ||
| 797 | ), | ||
| 798 | Effect.map(([, nextProviders]) => nextProviders), | ||
| 799 | ); | ||
| 800 | }), | ||
| 801 | ), | ||
| 802 | ), | ||
| 803 | Effect.ensuring( | ||
| 804 | Ref.update(workspaceRefreshesRef, (refreshes) => { | ||
| 805 | const next = new Map(refreshes); | ||
| 806 | const current = new Set(next.get(instance)); | ||
| 807 | current.delete(input.cwd); | ||
| 808 | if (current.size) next.set(instance, current); | ||
| 809 | else next.delete(instance); | ||
| 810 | return next; | ||
| 811 | }), | ||
| 812 | ), | ||
| 813 | ); | ||
| 814 | }); | ||
| 815 | |||
| 701 | return { | 816 | return { |
| 702 | getProviders: Ref.get(providersRef), | 817 | getProviders: Ref.get(providersRef), |
| 703 | refresh: (provider?: ProviderDriverKind) => | 818 | refresh: (provider?: ProviderDriverKind) => |
| 704 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), | 819 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 705 | refreshInstance: (instanceId: ProviderInstanceId) => | 820 | refreshInstance: (instanceId: ProviderInstanceId) => |
| 706 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), | 821 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 822 | refreshWorkspaceSnapshot: (input) => | ||
| 823 | refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), | ||
| 707 | getProviderMaintenanceCapabilitiesForInstance, | 824 | getProviderMaintenanceCapabilitiesForInstance, |
| 708 | setProviderMaintenanceActionState, | 825 | setProviderMaintenanceActionState, |
| 709 | get streamChanges() { | 826 | get streamChanges() { |
apps/server/src/provider/OpenCodeServerOwner.test.ts+8| ... | @@ -56,7 +56,9 @@ const makeRuntime = Effect.gen(function* () { | ... | @@ -56,7 +56,9 @@ const makeRuntime = Effect.gen(function* () { |
| 56 | runOpenCodeCommand: unusedRuntimeMethod, | 56 | runOpenCodeCommand: unusedRuntimeMethod, |
| 57 | createOpenCodeSdkClient: () => ({}) as never, | 57 | createOpenCodeSdkClient: () => ({}) as never, |
| 58 | loadOpenCodeInventory: unusedRuntimeMethod, | 58 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 59 | loadOpenCodeSkills: unusedRuntimeMethod, | ||
| 59 | loadInventoryFromCli: unusedRuntimeMethod, | 60 | loadInventoryFromCli: unusedRuntimeMethod, |
| 61 | loadSkillsFromCli: unusedRuntimeMethod, | ||
| 60 | }; | 62 | }; |
| 61 | return { runtime, starts, closes, failNextStart, started, closed }; | 63 | return { runtime, starts, closes, failNextStart, started, closed }; |
| 62 | }); | 64 | }); |
| ... | @@ -137,7 +139,9 @@ it.effect("invalidates an exited process so the next borrower starts a new one", | ... | @@ -137,7 +139,9 @@ it.effect("invalidates an exited process so the next borrower starts a new one", |
| 137 | runOpenCodeCommand: unusedRuntimeMethod, | 139 | runOpenCodeCommand: unusedRuntimeMethod, |
| 138 | createOpenCodeSdkClient: () => ({}) as never, | 140 | createOpenCodeSdkClient: () => ({}) as never, |
| 139 | loadOpenCodeInventory: unusedRuntimeMethod, | 141 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 142 | loadOpenCodeSkills: unusedRuntimeMethod, | ||
| 140 | loadInventoryFromCli: unusedRuntimeMethod, | 143 | loadInventoryFromCli: unusedRuntimeMethod, |
| 144 | loadSkillsFromCli: unusedRuntimeMethod, | ||
| 141 | }; | 145 | }; |
| 142 | 146 | ||
| 143 | yield* Effect.scoped( | 147 | yield* Effect.scoped( |
| ... | @@ -183,7 +187,9 @@ it.effect("replaces a dead cached process before its exit watcher runs", () => | ... | @@ -183,7 +187,9 @@ it.effect("replaces a dead cached process before its exit watcher runs", () => |
| 183 | runOpenCodeCommand: unusedRuntimeMethod, | 187 | runOpenCodeCommand: unusedRuntimeMethod, |
| 184 | createOpenCodeSdkClient: () => ({}) as never, | 188 | createOpenCodeSdkClient: () => ({}) as never, |
| 185 | loadOpenCodeInventory: unusedRuntimeMethod, | 189 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 190 | loadOpenCodeSkills: unusedRuntimeMethod, | ||
| 186 | loadInventoryFromCli: unusedRuntimeMethod, | 191 | loadInventoryFromCli: unusedRuntimeMethod, |
| 192 | loadSkillsFromCli: unusedRuntimeMethod, | ||
| 187 | }; | 193 | }; |
| 188 | 194 | ||
| 189 | yield* Effect.scoped( | 195 | yield* Effect.scoped( |
| ... | @@ -236,7 +242,9 @@ it.effect("cleans up an interrupted startup and allows a retry", () => | ... | @@ -236,7 +242,9 @@ it.effect("cleans up an interrupted startup and allows a retry", () => |
| 236 | runOpenCodeCommand: unusedRuntimeMethod, | 242 | runOpenCodeCommand: unusedRuntimeMethod, |
| 237 | createOpenCodeSdkClient: () => ({}) as never, | 243 | createOpenCodeSdkClient: () => ({}) as never, |
| 238 | loadOpenCodeInventory: unusedRuntimeMethod, | 244 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 245 | loadOpenCodeSkills: unusedRuntimeMethod, | ||
| 239 | loadInventoryFromCli: unusedRuntimeMethod, | 246 | loadInventoryFromCli: unusedRuntimeMethod, |
| 247 | loadSkillsFromCli: unusedRuntimeMethod, | ||
| 240 | }; | 248 | }; |
| 241 | 249 | ||
| 242 | yield* Effect.scoped( | 250 | yield* Effect.scoped( |
apps/server/src/provider/ProviderDriver.ts+2| ... | @@ -25,6 +25,7 @@ import type { | ... | @@ -25,6 +25,7 @@ import type { |
| 25 | ProviderDriverKind, | 25 | ProviderDriverKind, |
| 26 | ProviderInstanceEnvironment, | 26 | ProviderInstanceEnvironment, |
| 27 | ProviderInstanceId, | 27 | ProviderInstanceId, |
| 28 | ServerProvider, | ||
| 28 | } from "@t3tools/contracts"; | 29 | } from "@t3tools/contracts"; |
| 29 | import type * as Effect from "effect/Effect"; | 30 | import type * as Effect from "effect/Effect"; |
| 30 | import type * as Schema from "effect/Schema"; | 31 | import type * as Schema from "effect/Schema"; |
| ... | @@ -69,6 +70,7 @@ export interface ProviderInstance { | ... | @@ -69,6 +70,7 @@ export interface ProviderInstance { |
| 69 | readonly accentColor?: string | undefined; | 70 | readonly accentColor?: string | undefined; |
| 70 | readonly enabled: boolean; | 71 | readonly enabled: boolean; |
| 71 | readonly snapshot: ServerProviderShape; | 72 | readonly snapshot: ServerProviderShape; |
| 73 | readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>; | ||
| 72 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; | 74 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; |
| 73 | readonly textGeneration: TextGeneration.TextGeneration["Service"]; | 75 | readonly textGeneration: TextGeneration.TextGeneration["Service"]; |
| 74 | } | 76 | } |
apps/server/src/provider/Services/ProviderRegistry.ts+5| ... | @@ -48,6 +48,11 @@ export interface ProviderRegistryShape { | ... | @@ -48,6 +48,11 @@ export interface ProviderRegistryShape { |
| 48 | instanceId: ProviderInstanceId, | 48 | instanceId: ProviderInstanceId, |
| 49 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; | 49 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; |
| 50 | 50 | ||
| 51 | readonly refreshWorkspaceSnapshot: (input: { | ||
| 52 | readonly instanceId: ProviderInstanceId; | ||
| 53 | readonly cwd: string; | ||
| 54 | }) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 55 | |||
| 51 | /** | 56 | /** |
| 52 | * Resolve the maintenance capabilities owned by one live provider instance. | 57 | * Resolve the maintenance capabilities owned by one live provider instance. |
| 53 | * Falls back to manual-only capabilities when the instance is not live. | 58 | * Falls back to manual-only capabilities when the instance is not live. |
apps/server/src/provider/opencodeRuntime.ts+34-3| ... | @@ -254,11 +254,19 @@ export interface OpenCodeRuntimeShape { | ... | @@ -254,11 +254,19 @@ export interface OpenCodeRuntimeShape { |
| 254 | readonly loadOpenCodeInventory: ( | 254 | readonly loadOpenCodeInventory: ( |
| 255 | client: OpencodeClient, | 255 | client: OpencodeClient, |
| 256 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | 256 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 257 | readonly loadOpenCodeSkills: ( | ||
| 258 | client: OpencodeClient, | ||
| 259 | ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; | ||
| 257 | readonly loadInventoryFromCli: (input: { | 260 | readonly loadInventoryFromCli: (input: { |
| 258 | readonly binaryPath: string; | 261 | readonly binaryPath: string; |
| 259 | readonly cwd: string; | 262 | readonly cwd: string; |
| 260 | readonly environment?: NodeJS.ProcessEnv; | 263 | readonly environment?: NodeJS.ProcessEnv; |
| 261 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; | 264 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 265 | readonly loadSkillsFromCli: (input: { | ||
| 266 | readonly binaryPath: string; | ||
| 267 | readonly cwd: string; | ||
| 268 | readonly environment?: NodeJS.ProcessEnv; | ||
| 269 | }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; | ||
| 262 | } | 270 | } |
| 263 | 271 | ||
| 264 | function parseServerUrlFromOutput(output: string): string | null { | 272 | function parseServerUrlFromOutput(output: string): string | null { |
| ... | @@ -855,8 +863,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -855,8 +863,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 855 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), | 863 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), |
| 856 | ); | 864 | ); |
| 857 | 865 | ||
| 858 | const loadSkills = (client: OpencodeClient) => | 866 | const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => |
| 859 | runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( | 867 | runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( |
| 860 | Effect.map((result) => | 868 | Effect.map((result) => |
| 861 | (result.data ?? []).map((skill) => ({ | 869 | (result.data ?? []).map((skill) => ({ |
| 862 | name: skill.name, | 870 | name: skill.name, |
| ... | @@ -864,8 +872,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -864,8 +872,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 864 | location: skill.location, | 872 | location: skill.location, |
| 865 | })), | 873 | })), |
| 866 | ), | 874 | ), |
| 867 | Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []), | ||
| 868 | ); | 875 | ); |
| 876 | const loadSkills = (client: OpencodeClient) => | ||
| 877 | loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => [])); | ||
| 869 | 878 | ||
| 870 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => | 879 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => |
| 871 | Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { | 880 | Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { |
| ... | @@ -971,13 +980,35 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -971,13 +980,35 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 971 | }; | 980 | }; |
| 972 | }); | 981 | }); |
| 973 | 982 | ||
| 983 | const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => | ||
| 984 | runOpenCodeCommand({ | ||
| 985 | binaryPath: input.binaryPath, | ||
| 986 | args: ["debug", "skill"], | ||
| 987 | cwd: input.cwd, | ||
| 988 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, | ||
| 989 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | ||
| 990 | }).pipe( | ||
| 991 | Effect.flatMap((result) => | ||
| 992 | result.code === 0 | ||
| 993 | ? Effect.succeed(parseSkillsCliOutput(result.stdout)) | ||
| 994 | : Effect.fail( | ||
| 995 | new OpenCodeRuntimeError({ | ||
| 996 | operation: "loadSkillsFromCli", | ||
| 997 | detail: `OpenCode skills command exited with code ${result.code}.`, | ||
| 998 | }), | ||
| 999 | ), | ||
| 1000 | ), | ||
| 1001 | ); | ||
| 1002 | |||
| 974 | return { | 1003 | return { |
| 975 | startOpenCodeServerProcess, | 1004 | startOpenCodeServerProcess, |
| 976 | connectToOpenCodeServer, | 1005 | connectToOpenCodeServer, |
| 977 | runOpenCodeCommand, | 1006 | runOpenCodeCommand, |
| 978 | createOpenCodeSdkClient, | 1007 | createOpenCodeSdkClient, |
| 979 | loadOpenCodeInventory, | 1008 | loadOpenCodeInventory, |
| 1009 | loadOpenCodeSkills, | ||
| 980 | loadInventoryFromCli, | 1010 | loadInventoryFromCli, |
| 1011 | loadSkillsFromCli, | ||
| 981 | } satisfies OpenCodeRuntimeShape; | 1012 | } satisfies OpenCodeRuntimeShape; |
| 982 | }); | 1013 | }); |
| 983 | 1014 |
apps/server/src/provider/providerMaintenanceRunner.test.ts+1| ... | @@ -189,6 +189,7 @@ function makeRegistry( | ... | @@ -189,6 +189,7 @@ function makeRegistry( |
| 189 | getProviders: Ref.get(providersRef), | 189 | getProviders: Ref.get(providersRef), |
| 190 | refresh: () => Ref.get(providersRef), | 190 | refresh: () => Ref.get(providersRef), |
| 191 | refreshInstance: () => Ref.get(providersRef), | 191 | refreshInstance: () => Ref.get(providersRef), |
| 192 | refreshWorkspaceSnapshot: () => Ref.get(providersRef), | ||
| 192 | getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => | 193 | getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => |
| 193 | Effect.succeed(lifecycleFor(provider)), | 194 | Effect.succeed(lifecycleFor(provider)), |
| 194 | setProviderMaintenanceActionState, | 195 | setProviderMaintenanceActionState, |
apps/server/src/provider/testUtils/providerRegistryMock.ts+1| ... | @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = ( | ... | @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = ( |
| 11 | getProviders: Effect.succeed(providers), | 11 | getProviders: Effect.succeed(providers), |
| 12 | refresh: () => Effect.succeed(providers), | 12 | refresh: () => Effect.succeed(providers), |
| 13 | refreshInstance: () => Effect.succeed(providers), | 13 | refreshInstance: () => Effect.succeed(providers), |
| 14 | refreshWorkspaceSnapshot: () => Effect.succeed(providers), | ||
| 14 | getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => | 15 | getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => |
| 15 | Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), | 16 | Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), |
| 16 | setProviderMaintenanceActionState: () => Effect.succeed(providers), | 17 | setProviderMaintenanceActionState: () => Effect.succeed(providers), |
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+2| ... | @@ -136,6 +136,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | ... | @@ -136,6 +136,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { |
| 136 | cause: null, | 136 | cause: null, |
| 137 | }), | 137 | }), |
| 138 | ), | 138 | ), |
| 139 | loadOpenCodeSkills: () => Effect.succeed([]), | ||
| 139 | loadInventoryFromCli: () => | 140 | loadInventoryFromCli: () => |
| 140 | Effect.fail( | 141 | Effect.fail( |
| 141 | new OpenCodeRuntime.OpenCodeRuntimeError({ | 142 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| ... | @@ -144,6 +145,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | ... | @@ -144,6 +145,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { |
| 144 | cause: null, | 145 | cause: null, |
| 145 | }), | 146 | }), |
| 146 | ), | 147 | ), |
| 148 | loadSkillsFromCli: () => Effect.succeed([]), | ||
| 147 | }; | 149 | }; |
| 148 | 150 | ||
| 149 | const DEFAULT_TEST_MODEL_SELECTION = { | 151 | const DEFAULT_TEST_MODEL_SELECTION = { |
apps/server/src/ws.ts+8-3| ... | @@ -1662,9 +1662,14 @@ const makeWsRpcLayer = ( | ... | @@ -1662,9 +1662,14 @@ const makeWsRpcLayer = ( |
| 1662 | [WS_METHODS.serverRefreshProviders]: (input) => | 1662 | [WS_METHODS.serverRefreshProviders]: (input) => |
| 1663 | observeRpcEffect( | 1663 | observeRpcEffect( |
| 1664 | WS_METHODS.serverRefreshProviders, | 1664 | WS_METHODS.serverRefreshProviders, |
| 1665 | (input.instanceId !== undefined | 1665 | (input.cwd !== undefined && input.instanceId !== undefined |
| 1666 | ? providerRegistry.refreshInstance(input.instanceId) | 1666 | ? providerRegistry.refreshWorkspaceSnapshot({ |
| 1667 | : providerRegistry.refresh() | 1667 | instanceId: input.instanceId, |
| 1668 | cwd: input.cwd, | ||
| 1669 | }) | ||
| 1670 | : input.instanceId !== undefined | ||
| 1671 | ? providerRegistry.refreshInstance(input.instanceId) | ||
| 1672 | : providerRegistry.refresh() | ||
| 1668 | ).pipe(Effect.map((providers) => ({ providers }))), | 1673 | ).pipe(Effect.map((providers) => ({ providers }))), |
| 1669 | { "rpc.aggregate": "server" }, | 1674 | { "rpc.aggregate": "server" }, |
| 1670 | ), | 1675 | ), |
apps/web/src/components/ChatView.tsx+6-1| ... | @@ -278,6 +278,7 @@ import { | ... | @@ -278,6 +278,7 @@ import { |
| 278 | requestOlderThreadTurns, | 278 | requestOlderThreadTurns, |
| 279 | threadHasOlderTurns, | 279 | threadHasOlderTurns, |
| 280 | } from "@t3tools/client-runtime/state/threads"; | 280 | } from "@t3tools/client-runtime/state/threads"; |
| 281 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | ||
| 281 | import { vcsEnvironment } from "../state/vcs"; | 282 | import { vcsEnvironment } from "../state/vcs"; |
| 282 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; | 283 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; |
| 283 | import { | 284 | import { |
| ... | @@ -7303,7 +7304,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7303,7 +7304,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 7303 | resolvedTheme={resolvedTheme} | 7304 | resolvedTheme={resolvedTheme} |
| 7304 | timestampFormat={timestampFormat} | 7305 | timestampFormat={timestampFormat} |
| 7305 | workspaceRoot={activeWorkspaceRoot} | 7306 | workspaceRoot={activeWorkspaceRoot} |
| 7306 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | 7307 | skills={ |
| 7308 | activeProviderStatus | ||
| 7309 | ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) | ||
| 7310 | : EMPTY_PROVIDER_SKILLS | ||
| 7311 | } | ||
| 7307 | anchorMessageId={timelineAnchorMessageId} | 7312 | anchorMessageId={timelineAnchorMessageId} |
| 7308 | onAnchorReady={onTimelineAnchorReady} | 7313 | onAnchorReady={onTimelineAnchorReady} |
| 7309 | contentInsetEndAdjustment={composerOverlayHeight} | 7314 | contentInsetEndAdjustment={composerOverlayHeight} |
apps/web/src/components/chat/ChatComposer.tsx+72-16| ... | @@ -334,9 +334,13 @@ import { | ... | @@ -334,9 +334,13 @@ import { |
| 334 | formatProviderSkillDisplayName, | 334 | formatProviderSkillDisplayName, |
| 335 | getProviderSlashCommandsForSlashMenu, | 335 | getProviderSlashCommandsForSlashMenu, |
| 336 | getProviderSkillsForSlashMenu, | 336 | getProviderSkillsForSlashMenu, |
| 337 | resolveProviderSkillsForCwd, | ||
| 338 | resolveProviderSlashCommandsForCwd, | ||
| 337 | } from "@t3tools/client-runtime/providerSkills"; | 339 | } from "@t3tools/client-runtime/providerSkills"; |
| 338 | import { searchProviderSkills } from "../../providerSkillSearch"; | 340 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 339 | import { useMediaQuery } from "../../hooks/useMediaQuery"; | 341 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 342 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 343 | import { serverEnvironment } from "../../state/server"; | ||
| 340 | import type { ReviewCommentContext } from "../../reviewCommentContext"; | 344 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 341 | 345 | ||
| 342 | const runtimeModeConfig: Record< | 346 | const runtimeModeConfig: Record< |
| ... | @@ -1113,6 +1117,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1113,6 +1117,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1113 | () => selectedProviderEntry?.snapshot ?? null, | 1117 | () => selectedProviderEntry?.snapshot ?? null, |
| 1114 | [selectedProviderEntry], | 1118 | [selectedProviderEntry], |
| 1115 | ); | 1119 | ); |
| 1120 | const selectedProviderSkills = selectedProviderStatus | ||
| 1121 | ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) | ||
| 1122 | : []; | ||
| 1123 | const selectedProviderSlashCommands = selectedProviderStatus | ||
| 1124 | ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) | ||
| 1125 | : []; | ||
| 1126 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | ||
| 1127 | reportFailure: false, | ||
| 1128 | }); | ||
| 1129 | const workspaceRefreshKeyRef = useRef<string | null>(null); | ||
| 1130 | const hadWorkspaceSnapshotRef = useRef(false); | ||
| 1131 | useEffect(() => { | ||
| 1132 | const hasWorkspaceSnapshot = Boolean( | ||
| 1133 | gitCwd && | ||
| 1134 | selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), | ||
| 1135 | ); | ||
| 1136 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { | ||
| 1137 | workspaceRefreshKeyRef.current = null; | ||
| 1138 | } | ||
| 1139 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; | ||
| 1140 | }, [gitCwd, selectedProviderStatus]); | ||
| 1141 | useEffect(() => { | ||
| 1142 | if (!gitCwd || !selectedProviderEntry) return; | ||
| 1143 | const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; | ||
| 1144 | const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some( | ||
| 1145 | (snapshot) => snapshot.cwd === gitCwd, | ||
| 1146 | ); | ||
| 1147 | if (workspaceRefreshKeyRef.current === key) return; | ||
| 1148 | if (hasWorkspaceSnapshot) { | ||
| 1149 | workspaceRefreshKeyRef.current = key; | ||
| 1150 | return; | ||
| 1151 | } | ||
| 1152 | workspaceRefreshKeyRef.current = key; | ||
| 1153 | void refreshProviders({ | ||
| 1154 | environmentId, | ||
| 1155 | input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, | ||
| 1156 | }).then( | ||
| 1157 | (result) => { | ||
| 1158 | const hasWorkspaceSnapshot = | ||
| 1159 | result._tag === "Success" && | ||
| 1160 | result.value.providers | ||
| 1161 | .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) | ||
| 1162 | ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); | ||
| 1163 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { | ||
| 1164 | workspaceRefreshKeyRef.current = null; | ||
| 1165 | } | ||
| 1166 | }, | ||
| 1167 | () => { | ||
| 1168 | if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null; | ||
| 1169 | }, | ||
| 1170 | ); | ||
| 1171 | }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]); | ||
| 1116 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( | 1172 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| 1117 | () => selectedProviderEntry?.models ?? [], | 1173 | () => selectedProviderEntry?.models ?? [], |
| 1118 | [selectedProviderEntry], | 1174 | [selectedProviderEntry], |
| ... | @@ -1340,11 +1396,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1340,11 +1396,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1340 | : []), | 1396 | : []), |
| 1341 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; | 1397 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| 1342 | const slashMenuSkills = getProviderSkillsForSlashMenu( | 1398 | const slashMenuSkills = getProviderSkillsForSlashMenu( |
| 1343 | selectedProviderStatus?.skills ?? [], | 1399 | selectedProviderSkills, |
| 1344 | settings.showSkillsInSlashMenu, | 1400 | settings.showSkillsInSlashMenu, |
| 1345 | ); | 1401 | ); |
| 1346 | const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( | 1402 | const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( |
| 1347 | selectedProviderStatus?.slashCommands ?? [], | 1403 | selectedProviderSlashCommands, |
| 1348 | slashMenuSkills, | 1404 | slashMenuSkills, |
| 1349 | ).map((command) => ({ | 1405 | ).map((command) => ({ |
| 1350 | id: `provider-slash-command:${selectedProvider}:${command.name}`, | 1406 | id: `provider-slash-command:${selectedProvider}:${command.name}`, |
| ... | @@ -1373,25 +1429,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1373,25 +1429,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1373 | return searchSlashCommandItems(slashCommandItems, query); | 1429 | return searchSlashCommandItems(slashCommandItems, query); |
| 1374 | } | 1430 | } |
| 1375 | if (composerTrigger.kind === "skill") { | 1431 | if (composerTrigger.kind === "skill") { |
| 1376 | return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( | 1432 | return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ |
| 1377 | (skill) => ({ | 1433 | id: `skill:${selectedProvider}:${skill.name}`, |
| 1378 | id: `skill:${selectedProvider}:${skill.name}`, | 1434 | type: "skill" as const, |
| 1379 | type: "skill" as const, | 1435 | provider: selectedProvider, |
| 1380 | provider: selectedProvider, | 1436 | skill, |
| 1381 | skill, | 1437 | label: formatProviderSkillDisplayName(skill), |
| 1382 | label: formatProviderSkillDisplayName(skill), | 1438 | description: |
| 1383 | description: | 1439 | skill.shortDescription ?? |
| 1384 | skill.shortDescription ?? | 1440 | skill.description ?? |
| 1385 | skill.description ?? | 1441 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), |
| 1386 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), | 1442 | })); |
| 1387 | }), | ||
| 1388 | ); | ||
| 1389 | } | 1443 | } |
| 1390 | return []; | 1444 | return []; |
| 1391 | }, [ | 1445 | }, [ |
| 1392 | composerTrigger, | 1446 | composerTrigger, |
| 1393 | planModeUiEnabled, | 1447 | planModeUiEnabled, |
| 1394 | selectedProvider, | 1448 | selectedProvider, |
| 1449 | selectedProviderSkills, | ||
| 1450 | selectedProviderSlashCommands, | ||
| 1395 | selectedProviderStatus, | 1451 | selectedProviderStatus, |
| 1396 | settings.showSkillsInSlashMenu, | 1452 | settings.showSkillsInSlashMenu, |
| 1397 | workspaceEntries.entries, | 1453 | workspaceEntries.entries, |
| ... | @@ -4096,7 +4152,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4096,7 +4152,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4096 | ? composerTerminalContexts | 4152 | ? composerTerminalContexts |
| 4097 | : [] | 4153 | : [] |
| 4098 | } | 4154 | } |
| 4099 | skills={selectedProviderStatus?.skills ?? []} | 4155 | skills={selectedProviderSkills} |
| 4100 | {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} | 4156 | {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} |
| 4101 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} | 4157 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} |
| 4102 | onChange={onPromptChange} | 4158 | onChange={onPromptChange} |
packages/client-runtime/src/providerSkills.test.ts+41| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| ... | @@ -5,9 +6,33 @@ import { | ... | @@ -5,9 +6,33 @@ import { |
| 5 | formatProviderSkillDisplayName, | 6 | formatProviderSkillDisplayName, |
| 6 | getProviderSlashCommandsForSlashMenu, | 7 | getProviderSlashCommandsForSlashMenu, |
| 7 | getProviderSkillsForSlashMenu, | 8 | getProviderSkillsForSlashMenu, |
| 9 | resolveProviderSkillsForCwd, | ||
| 10 | resolveProviderSlashCommandsForCwd, | ||
| 8 | resolveProviderSkillSourceKind, | 11 | resolveProviderSkillSourceKind, |
| 9 | } from "./providerSkills.ts"; | 12 | } from "./providerSkills.ts"; |
| 10 | 13 | ||
| 14 | const provider = { | ||
| 15 | instanceId: ProviderInstanceId.make("codex"), | ||
| 16 | driver: ProviderDriverKind.make("codex"), | ||
| 17 | enabled: true, | ||
| 18 | installed: true, | ||
| 19 | version: "1.0.0", | ||
| 20 | status: "ready", | ||
| 21 | auth: { status: "authenticated" }, | ||
| 22 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 23 | models: [], | ||
| 24 | slashCommands: [{ name: "global" }], | ||
| 25 | skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], | ||
| 26 | workspaceSnapshots: [ | ||
| 27 | { | ||
| 28 | cwd: "/workspace/project-a", | ||
| 29 | checkedAt: "2026-01-01T00:01:00.000Z", | ||
| 30 | slashCommands: [{ name: "project" }], | ||
| 31 | skills: [{ name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }], | ||
| 32 | }, | ||
| 33 | ], | ||
| 34 | } satisfies ServerProvider; | ||
| 35 | |||
| 11 | describe("formatProviderSkillDisplayName", () => { | 36 | describe("formatProviderSkillDisplayName", () => { |
| 12 | it("prefers the provider display name", () => { | 37 | it("prefers the provider display name", () => { |
| 13 | expect( | 38 | expect( |
| ... | @@ -211,3 +236,19 @@ describe("resolveProviderSkillSourceKind", () => { | ... | @@ -211,3 +236,19 @@ describe("resolveProviderSkillSourceKind", () => { |
| 211 | ).toBe("other"); | 236 | ).toBe("other"); |
| 212 | }); | 237 | }); |
| 213 | }); | 238 | }); |
| 239 | |||
| 240 | describe("workspace provider snapshots", () => { | ||
| 241 | it("uses the cwd snapshot after a provider session has populated it", () => { | ||
| 242 | expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([ | ||
| 243 | { name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }, | ||
| 244 | ]); | ||
| 245 | expect(resolveProviderSlashCommandsForCwd(provider, "/workspace/project-a")).toEqual([ | ||
| 246 | { name: "project" }, | ||
| 247 | ]); | ||
| 248 | }); | ||
| 249 | |||
| 250 | it("keeps the machine snapshot before this cwd has a provider snapshot", () => { | ||
| 251 | expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); | ||
| 252 | expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); | ||
| 253 | }); | ||
| 254 | }); |
packages/client-runtime/src/providerSkills.ts+27-1| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; | 1 | import type { |
| 2 | ServerProvider, | ||
| 3 | ServerProviderSkill, | ||
| 4 | ServerProviderSlashCommand, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | 6 | ||
| 3 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; | 7 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; |
| 4 | 8 | ||
| ... | @@ -98,3 +102,25 @@ export function resolveProviderSkillSourceKind( | ... | @@ -98,3 +102,25 @@ export function resolveProviderSkillSourceKind( |
| 98 | return "other"; | 102 | return "other"; |
| 99 | } | 103 | } |
| 100 | } | 104 | } |
| 105 | |||
| 106 | function resolveProviderWorkspaceSnapshot( | ||
| 107 | provider: ServerProvider, | ||
| 108 | cwd: string | null | undefined, | ||
| 109 | ) { | ||
| 110 | if (!cwd) return undefined; | ||
| 111 | return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); | ||
| 112 | } | ||
| 113 | |||
| 114 | export function resolveProviderSkillsForCwd( | ||
| 115 | provider: ServerProvider, | ||
| 116 | cwd: string | null | undefined, | ||
| 117 | ): ServerProvider["skills"] { | ||
| 118 | return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills; | ||
| 119 | } | ||
| 120 | |||
| 121 | export function resolveProviderSlashCommandsForCwd( | ||
| 122 | provider: ServerProvider, | ||
| 123 | cwd: string | null | undefined, | ||
| 124 | ): ServerProvider["slashCommands"] { | ||
| 125 | return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; | ||
| 126 | } |
packages/client-runtime/src/state/server.ts+2-1| ... | @@ -849,7 +849,8 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -849,7 +849,8 @@ export function createServerEnvironmentAtoms<R, E>( |
| 849 | tag: WS_METHODS.serverRefreshProviders, | 849 | tag: WS_METHODS.serverRefreshProviders, |
| 850 | concurrency: { | 850 | concurrency: { |
| 851 | mode: "singleFlight", | 851 | mode: "singleFlight", |
| 852 | key: ({ environmentId }) => environmentId, | 852 | key: ({ environmentId, input }) => |
| 853 | JSON.stringify([environmentId, input.instanceId ?? null, input.cwd ?? null]), | ||
| 853 | }, | 854 | }, |
| 854 | }), | 855 | }), |
| 855 | updateProvider: createEnvironmentRpcCommand(runtime, { | 856 | updateProvider: createEnvironmentRpcCommand(runtime, { |
packages/contracts/src/rpc.ts+2| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; | 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 4 | 5 | ||
| 5 | import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; | 6 | import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; |
| 6 | import { | 7 | import { |
| ... | @@ -368,6 +369,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv | ... | @@ -368,6 +369,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv |
| 368 | * refreshes. | 369 | * refreshes. |
| 369 | */ | 370 | */ |
| 370 | instanceId: Schema.optional(ProviderInstanceId), | 371 | instanceId: Schema.optional(ProviderInstanceId), |
| 372 | cwd: Schema.optional(TrimmedNonEmptyString), | ||
| 371 | }), | 373 | }), |
| 372 | success: ServerProviderUpdatedPayload, | 374 | success: ServerProviderUpdatedPayload, |
| 373 | error: EnvironmentAuthorizationError, | 375 | error: EnvironmentAuthorizationError, |
packages/contracts/src/server.ts+9| ... | @@ -110,6 +110,14 @@ export const ServerProviderSkill = Schema.Struct({ | ... | @@ -110,6 +110,14 @@ export const ServerProviderSkill = Schema.Struct({ |
| 110 | }); | 110 | }); |
| 111 | export type ServerProviderSkill = typeof ServerProviderSkill.Type; | 111 | export type ServerProviderSkill = typeof ServerProviderSkill.Type; |
| 112 | 112 | ||
| 113 | export const ServerProviderWorkspaceSnapshot = Schema.Struct({ | ||
| 114 | cwd: TrimmedNonEmptyString, | ||
| 115 | checkedAt: IsoDateTime, | ||
| 116 | slashCommands: Schema.Array(ServerProviderSlashCommand), | ||
| 117 | skills: Schema.Array(ServerProviderSkill), | ||
| 118 | }); | ||
| 119 | export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; | ||
| 120 | |||
| 113 | /** | 121 | /** |
| 114 | * Availability of a configured provider instance from the runtime's POV. | 122 | * Availability of a configured provider instance from the runtime's POV. |
| 115 | * | 123 | * |
| ... | @@ -206,6 +214,7 @@ export const ServerProvider = Schema.Struct({ | ... | @@ -206,6 +214,7 @@ export const ServerProvider = Schema.Struct({ |
| 206 | Schema.withDecodingDefault(Effect.succeed([])), | 214 | Schema.withDecodingDefault(Effect.succeed([])), |
| 207 | ), | 215 | ), |
| 208 | skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | 216 | skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 217 | workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), | ||
| 209 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), | 218 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), |
| 210 | updateState: Schema.optionalKey(ServerProviderUpdateState), | 219 | updateState: Schema.optionalKey(ServerProviderUpdateState), |
| 211 | }); | 220 | }); |