| 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 | 657 | thread, |
| 658 | 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 | 666 | const startProviderSession = (input?: { |
| 662 | 667 | readonly resumeCursor?: unknown; |
| 663 | 668 | readonly provider?: ProviderDriverKind; |
| 664 | 669 | }) => |
| 665 | providerService.startSession(threadId, { | |
| 666 | threadId, | |
| 667 | ...(preferredProvider ? { provider: preferredProvider } : {}), | |
| 668 | providerInstanceId: desiredInstanceId, | |
| 669 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | |
| 670 | ...(thread.title ? { title: thread.title } : {}), | |
| 671 | modelSelection: desiredModelSelection, | |
| 672 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | |
| 673 | runtimeMode: desiredRuntimeMode, | |
| 674 | }); | |
| 670 | providerService | |
| 671 | .startSession(threadId, { | |
| 672 | threadId, | |
| 673 | ...(preferredProvider ? { provider: preferredProvider } : {}), | |
| 674 | providerInstanceId: desiredInstanceId, | |
| 675 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | |
| 676 | ...(thread.title ? { title: thread.title } : {}), | |
| 677 | modelSelection: desiredModelSelection, | |
| 678 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | |
| 679 | runtimeMode: desiredRuntimeMode, | |
| 680 | }) | |
| 681 | .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); | |
| 675 | 682 | |
| 676 | 683 | const bindSessionToThread = (session: ProviderSession) => |
| 677 | 684 | Effect.gen(function* () { |
| ... | ... | @@ -729,6 +736,7 @@ const make = Effect.gen(function* () { |
| 729 | 736 | !shouldRestartForModelChange && |
| 730 | 737 | !shouldRestartForModelSelectionChange |
| 731 | 738 | ) { |
| 739 | yield* refreshWorkspaceSnapshot; | |
| 732 | 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 | 36 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 37 | 37 | import { ProviderDriverError } from "../Errors.ts"; |
| 38 | 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 | 45 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 41 | 46 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 42 | 47 | import * as ModelManifest from "../ModelManifest.ts"; |
| ... | ... | @@ -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 | 232 | return { |
| 200 | 233 | instanceId, |
| ... | ... | @@ -204,6 +237,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 204 | 237 | accentColor, |
| 205 | 238 | enabled, |
| 206 | 239 | snapshot, |
| 240 | snapshotForCwd, | |
| 207 | 241 | adapter, |
| 208 | 242 | textGeneration, |
| 209 | 243 | } satisfies ProviderInstance; |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+50| ... | ... | @@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; |
| 30 | 30 | import { |
| 31 | 31 | checkOpenCodeProviderStatus, |
| 32 | 32 | makePendingOpenCodeProvider, |
| 33 | openCodeSkillsToServerProviderSkills, | |
| 33 | 34 | } from "../Layers/OpenCodeProvider.ts"; |
| 34 | 35 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 35 | 36 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| ... | ... | @@ -148,6 +149,34 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 148 | 149 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), |
| 149 | 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 | 181 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| 153 | 182 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>( |
| ... | ... | @@ -189,6 +218,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 189 | 218 | accentColor, |
| 190 | 219 | enabled, |
| 191 | 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 | 242 | adapter, |
| 193 | 243 | textGeneration, |
| 194 | 244 | } satisfies ProviderInstance; |
apps/server/src/provider/Layers/CodexProvider.ts+47| ... | ... | @@ -414,6 +414,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 414 | 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 | 464 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { |
| 418 | 465 | const models = new Set<string>(); |
| 419 | 466 | for (const model of codexSettings.customModels) { |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+2| ... | ... | @@ -407,6 +407,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 407 | 407 | cause: null, |
| 408 | 408 | }), |
| 409 | 409 | ), |
| 410 | loadOpenCodeSkills: () => Effect.succeed([]), | |
| 410 | 411 | loadInventoryFromCli: () => |
| 411 | 412 | Effect.fail( |
| 412 | 413 | new OpenCodeRuntimeError({ |
| ... | ... | @@ -415,6 +416,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 415 | 416 | cause: null, |
| 416 | 417 | }), |
| 417 | 418 | ), |
| 419 | loadSkillsFromCli: () => Effect.succeed([]), | |
| 418 | 420 | }; |
| 419 | 421 | |
| 420 | 422 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+2| ... | ... | @@ -149,6 +149,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 149 | 149 | ) |
| 150 | 150 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); |
| 151 | 151 | }, |
| 152 | loadOpenCodeSkills: () => Effect.succeed([]), | |
| 153 | loadSkillsFromCli: () => Effect.succeed([]), | |
| 152 | 154 | }; |
| 153 | 155 | |
| 154 | 156 | beforeEach(() => { |
apps/server/src/provider/Layers/OpenCodeProvider.ts+5-3| ... | ... | @@ -260,9 +260,11 @@ function trimOptional(value: string | null | undefined): string | undefined { |
| 260 | 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 | 266 | const skills: ServerProviderSkill[] = []; |
| 265 | for (const skill of input.skills ?? []) { | |
| 267 | for (const skill of input ?? []) { | |
| 266 | 268 | const name = trimOptional(skill.name); |
| 267 | 269 | const path = trimOptional(skill.location); |
| 268 | 270 | if (!name || !path) { |
| ... | ... | @@ -475,7 +477,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 475 | 477 | customModels, |
| 476 | 478 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 477 | 479 | ); |
| 478 | const skills = flattenOpenCodeSkills(inventoryExit.value.inventory); | |
| 480 | const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); | |
| 479 | 481 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; |
| 480 | 482 | return buildServerProvider({ |
| 481 | 483 | presentation: OPENCODE_PRESENTATION, |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+195| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { describe, it, assert } from "@effect/vitest"; |
| 3 | 3 | import * as DateTime from "effect/DateTime"; |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as Exit from "effect/Exit"; |
| 6 | 7 | import * as Fiber from "effect/Fiber"; |
| ... | ... | @@ -41,6 +42,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr |
| 41 | 42 | import { |
| 42 | 43 | haveProvidersChanged, |
| 43 | 44 | mergeProviderSnapshot, |
| 45 | upsertProviderWorkspaceSnapshot, | |
| 44 | 46 | ProviderRegistryLive, |
| 45 | 47 | } from "./ProviderRegistry.ts"; |
| 46 | 48 | import * as ServerConfig from "../../config.ts"; |
| ... | ... | @@ -563,6 +565,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 563 | 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 | 603 | it("preserves previously discovered provider models when a refresh returns none", () => { |
| 567 | 604 | const previousProvider = { |
| 568 | 605 | instanceId: ProviderInstanceId.make("cursor"), |
| ... | ... | @@ -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 | 1181 | it.effect("refreshes OpenCode catalogs and preserves other providers", () => |
| 987 | 1182 | Effect.gen(function* () { |
| 988 | 1183 | const codexDriver = ProviderDriverKind.make("codex"); |
apps/server/src/provider/Layers/ProviderRegistry.ts+118-1| ... | ... | @@ -78,6 +78,28 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) |
| 78 | 78 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => |
| 79 | 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 | 103 | const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { |
| 82 | 104 | if (provider.driver !== ProviderDriverKind.make("opencode")) { |
| 83 | 105 | return true; |
| ... | ... | @@ -140,6 +162,11 @@ export const mergeProviderSnapshot = ( |
| 140 | 162 | : { |
| 141 | 163 | ...nextProvider, |
| 142 | 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 | 170 | ...(shouldRetainMissingOpenCodeMetadata(nextProvider) |
| 144 | 171 | ? { |
| 145 | 172 | slashCommands: |
| ... | ... | @@ -283,6 +310,9 @@ export const ProviderRegistryLive = Layer.effect( |
| 283 | 310 | ), |
| 284 | 311 | ); |
| 285 | 312 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); |
| 313 | const workspaceRefreshesRef = yield* Ref.make< | |
| 314 | ReadonlyMap<ProviderInstance, ReadonlySet<string>> | |
| 315 | >(new Map()); | |
| 286 | 316 | const maintenanceActionStatesRef = yield* Ref.make< |
| 287 | 317 | ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }> |
| 288 | 318 | >(new Map()); |
| ... | ... | @@ -315,7 +345,8 @@ export const ProviderRegistryLive = Layer.effect( |
| 315 | 345 | cacheDir: config.providerStatusCacheDir, |
| 316 | 346 | instanceId: key, |
| 317 | 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 | 350 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 320 | 351 | Effect.provideService(Path.Path, path), |
| 321 | 352 | Effect.tapError(Effect.logError), |
| ... | ... | @@ -557,6 +588,28 @@ export const ProviderRegistryLive = Layer.effect( |
| 557 | 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 | 613 | // Fork long-lived subscriptions to each new/rebuilt instance's |
| 561 | 614 | // change stream before reading its current snapshot. If the |
| 562 | 615 | // driver's own initial probe finishes during this sync, either |
| ... | ... | @@ -698,12 +751,76 @@ export const ProviderRegistryLive = Layer.effect( |
| 698 | 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 | 816 | return { |
| 702 | 817 | getProviders: Ref.get(providersRef), |
| 703 | 818 | refresh: (provider?: ProviderDriverKind) => |
| 704 | 819 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 705 | 820 | refreshInstance: (instanceId: ProviderInstanceId) => |
| 706 | 821 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 822 | refreshWorkspaceSnapshot: (input) => | |
| 823 | refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), | |
| 707 | 824 | getProviderMaintenanceCapabilitiesForInstance, |
| 708 | 825 | setProviderMaintenanceActionState, |
| 709 | 826 | get streamChanges() { |
apps/server/src/provider/OpenCodeServerOwner.test.ts+8| ... | ... | @@ -56,7 +56,9 @@ const makeRuntime = Effect.gen(function* () { |
| 56 | 56 | runOpenCodeCommand: unusedRuntimeMethod, |
| 57 | 57 | createOpenCodeSdkClient: () => ({}) as never, |
| 58 | 58 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 59 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 59 | 60 | loadInventoryFromCli: unusedRuntimeMethod, |
| 61 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 60 | 62 | }; |
| 61 | 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 | 139 | runOpenCodeCommand: unusedRuntimeMethod, |
| 138 | 140 | createOpenCodeSdkClient: () => ({}) as never, |
| 139 | 141 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 142 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 140 | 143 | loadInventoryFromCli: unusedRuntimeMethod, |
| 144 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 141 | 145 | }; |
| 142 | 146 | |
| 143 | 147 | yield* Effect.scoped( |
| ... | ... | @@ -183,7 +187,9 @@ it.effect("replaces a dead cached process before its exit watcher runs", () => |
| 183 | 187 | runOpenCodeCommand: unusedRuntimeMethod, |
| 184 | 188 | createOpenCodeSdkClient: () => ({}) as never, |
| 185 | 189 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 190 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 186 | 191 | loadInventoryFromCli: unusedRuntimeMethod, |
| 192 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 187 | 193 | }; |
| 188 | 194 | |
| 189 | 195 | yield* Effect.scoped( |
| ... | ... | @@ -236,7 +242,9 @@ it.effect("cleans up an interrupted startup and allows a retry", () => |
| 236 | 242 | runOpenCodeCommand: unusedRuntimeMethod, |
| 237 | 243 | createOpenCodeSdkClient: () => ({}) as never, |
| 238 | 244 | loadOpenCodeInventory: unusedRuntimeMethod, |
| 245 | loadOpenCodeSkills: unusedRuntimeMethod, | |
| 239 | 246 | loadInventoryFromCli: unusedRuntimeMethod, |
| 247 | loadSkillsFromCli: unusedRuntimeMethod, | |
| 240 | 248 | }; |
| 241 | 249 | |
| 242 | 250 | yield* Effect.scoped( |
apps/server/src/provider/ProviderDriver.ts+2| ... | ... | @@ -25,6 +25,7 @@ import type { |
| 25 | 25 | ProviderDriverKind, |
| 26 | 26 | ProviderInstanceEnvironment, |
| 27 | 27 | ProviderInstanceId, |
| 28 | ServerProvider, | |
| 28 | 29 | } from "@t3tools/contracts"; |
| 29 | 30 | import type * as Effect from "effect/Effect"; |
| 30 | 31 | import type * as Schema from "effect/Schema"; |
| ... | ... | @@ -69,6 +70,7 @@ export interface ProviderInstance { |
| 69 | 70 | readonly accentColor?: string | undefined; |
| 70 | 71 | readonly enabled: boolean; |
| 71 | 72 | readonly snapshot: ServerProviderShape; |
| 73 | readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>; | |
| 72 | 74 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; |
| 73 | 75 | readonly textGeneration: TextGeneration.TextGeneration["Service"]; |
| 74 | 76 | } |
apps/server/src/provider/Services/ProviderRegistry.ts+5| ... | ... | @@ -48,6 +48,11 @@ export interface ProviderRegistryShape { |
| 48 | 48 | instanceId: ProviderInstanceId, |
| 49 | 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 | 57 | * Resolve the maintenance capabilities owned by one live provider instance. |
| 53 | 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 | 254 | readonly loadOpenCodeInventory: ( |
| 255 | 255 | client: OpencodeClient, |
| 256 | 256 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 257 | readonly loadOpenCodeSkills: ( | |
| 258 | client: OpencodeClient, | |
| 259 | ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; | |
| 257 | 260 | readonly loadInventoryFromCli: (input: { |
| 258 | 261 | readonly binaryPath: string; |
| 259 | 262 | readonly cwd: string; |
| 260 | 263 | readonly environment?: NodeJS.ProcessEnv; |
| 261 | 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 | 272 | function parseServerUrlFromOutput(output: string): string | null { |
| ... | ... | @@ -855,8 +863,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 855 | 863 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), |
| 856 | 864 | ); |
| 857 | 865 | |
| 858 | const loadSkills = (client: OpencodeClient) => | |
| 859 | runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( | |
| 866 | const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => | |
| 867 | runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( | |
| 860 | 868 | Effect.map((result) => |
| 861 | 869 | (result.data ?? []).map((skill) => ({ |
| 862 | 870 | name: skill.name, |
| ... | ... | @@ -864,8 +872,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 864 | 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 | 879 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => |
| 871 | 880 | Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { |
| ... | ... | @@ -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 | 1003 | return { |
| 975 | 1004 | startOpenCodeServerProcess, |
| 976 | 1005 | connectToOpenCodeServer, |
| 977 | 1006 | runOpenCodeCommand, |
| 978 | 1007 | createOpenCodeSdkClient, |
| 979 | 1008 | loadOpenCodeInventory, |
| 1009 | loadOpenCodeSkills, | |
| 980 | 1010 | loadInventoryFromCli, |
| 1011 | loadSkillsFromCli, | |
| 981 | 1012 | } satisfies OpenCodeRuntimeShape; |
| 982 | 1013 | }); |
| 983 | 1014 |
apps/server/src/provider/providerMaintenanceRunner.test.ts+1| ... | ... | @@ -189,6 +189,7 @@ function makeRegistry( |
| 189 | 189 | getProviders: Ref.get(providersRef), |
| 190 | 190 | refresh: () => Ref.get(providersRef), |
| 191 | 191 | refreshInstance: () => Ref.get(providersRef), |
| 192 | refreshWorkspaceSnapshot: () => Ref.get(providersRef), | |
| 192 | 193 | getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => |
| 193 | 194 | Effect.succeed(lifecycleFor(provider)), |
| 194 | 195 | setProviderMaintenanceActionState, |
apps/server/src/provider/testUtils/providerRegistryMock.ts+1| ... | ... | @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = ( |
| 11 | 11 | getProviders: Effect.succeed(providers), |
| 12 | 12 | refresh: () => Effect.succeed(providers), |
| 13 | 13 | refreshInstance: () => Effect.succeed(providers), |
| 14 | refreshWorkspaceSnapshot: () => Effect.succeed(providers), | |
| 14 | 15 | getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => |
| 15 | 16 | Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), |
| 16 | 17 | setProviderMaintenanceActionState: () => Effect.succeed(providers), |
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+2| ... | ... | @@ -136,6 +136,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { |
| 136 | 136 | cause: null, |
| 137 | 137 | }), |
| 138 | 138 | ), |
| 139 | loadOpenCodeSkills: () => Effect.succeed([]), | |
| 139 | 140 | loadInventoryFromCli: () => |
| 140 | 141 | Effect.fail( |
| 141 | 142 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| ... | ... | @@ -144,6 +145,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { |
| 144 | 145 | cause: null, |
| 145 | 146 | }), |
| 146 | 147 | ), |
| 148 | loadSkillsFromCli: () => Effect.succeed([]), | |
| 147 | 149 | }; |
| 148 | 150 | |
| 149 | 151 | const DEFAULT_TEST_MODEL_SELECTION = { |
apps/server/src/ws.ts+8-3| ... | ... | @@ -1662,9 +1662,14 @@ const makeWsRpcLayer = ( |
| 1662 | 1662 | [WS_METHODS.serverRefreshProviders]: (input) => |
| 1663 | 1663 | observeRpcEffect( |
| 1664 | 1664 | WS_METHODS.serverRefreshProviders, |
| 1665 | (input.instanceId !== undefined | |
| 1666 | ? providerRegistry.refreshInstance(input.instanceId) | |
| 1667 | : providerRegistry.refresh() | |
| 1665 | (input.cwd !== undefined && input.instanceId !== undefined | |
| 1666 | ? providerRegistry.refreshWorkspaceSnapshot({ | |
| 1667 | instanceId: input.instanceId, | |
| 1668 | cwd: input.cwd, | |
| 1669 | }) | |
| 1670 | : input.instanceId !== undefined | |
| 1671 | ? providerRegistry.refreshInstance(input.instanceId) | |
| 1672 | : providerRegistry.refresh() | |
| 1668 | 1673 | ).pipe(Effect.map((providers) => ({ providers }))), |
| 1669 | 1674 | { "rpc.aggregate": "server" }, |
| 1670 | 1675 | ), |
apps/web/src/components/ChatView.tsx+6-1| ... | ... | @@ -278,6 +278,7 @@ import { |
| 278 | 278 | requestOlderThreadTurns, |
| 279 | 279 | threadHasOlderTurns, |
| 280 | 280 | } from "@t3tools/client-runtime/state/threads"; |
| 281 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | |
| 281 | 282 | import { vcsEnvironment } from "../state/vcs"; |
| 282 | 283 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; |
| 283 | 284 | import { |
| ... | ... | @@ -7303,7 +7304,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 7303 | 7304 | resolvedTheme={resolvedTheme} |
| 7304 | 7305 | timestampFormat={timestampFormat} |
| 7305 | 7306 | workspaceRoot={activeWorkspaceRoot} |
| 7306 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | |
| 7307 | skills={ | |
| 7308 | activeProviderStatus | |
| 7309 | ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) | |
| 7310 | : EMPTY_PROVIDER_SKILLS | |
| 7311 | } | |
| 7307 | 7312 | anchorMessageId={timelineAnchorMessageId} |
| 7308 | 7313 | onAnchorReady={onTimelineAnchorReady} |
| 7309 | 7314 | contentInsetEndAdjustment={composerOverlayHeight} |
apps/web/src/components/chat/ChatComposer.tsx+72-16| ... | ... | @@ -334,9 +334,13 @@ import { |
| 334 | 334 | formatProviderSkillDisplayName, |
| 335 | 335 | getProviderSlashCommandsForSlashMenu, |
| 336 | 336 | getProviderSkillsForSlashMenu, |
| 337 | resolveProviderSkillsForCwd, | |
| 338 | resolveProviderSlashCommandsForCwd, | |
| 337 | 339 | } from "@t3tools/client-runtime/providerSkills"; |
| 338 | 340 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 339 | 341 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 342 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 343 | import { serverEnvironment } from "../../state/server"; | |
| 340 | 344 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 341 | 345 | |
| 342 | 346 | const runtimeModeConfig: Record< |
| ... | ... | @@ -1113,6 +1117,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1113 | 1117 | () => selectedProviderEntry?.snapshot ?? null, |
| 1114 | 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 | 1172 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| 1117 | 1173 | () => selectedProviderEntry?.models ?? [], |
| 1118 | 1174 | [selectedProviderEntry], |
| ... | ... | @@ -1340,11 +1396,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1340 | 1396 | : []), |
| 1341 | 1397 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| 1342 | 1398 | const slashMenuSkills = getProviderSkillsForSlashMenu( |
| 1343 | selectedProviderStatus?.skills ?? [], | |
| 1399 | selectedProviderSkills, | |
| 1344 | 1400 | settings.showSkillsInSlashMenu, |
| 1345 | 1401 | ); |
| 1346 | 1402 | const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( |
| 1347 | selectedProviderStatus?.slashCommands ?? [], | |
| 1403 | selectedProviderSlashCommands, | |
| 1348 | 1404 | slashMenuSkills, |
| 1349 | 1405 | ).map((command) => ({ |
| 1350 | 1406 | id: `provider-slash-command:${selectedProvider}:${command.name}`, |
| ... | ... | @@ -1373,25 +1429,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1373 | 1429 | return searchSlashCommandItems(slashCommandItems, query); |
| 1374 | 1430 | } |
| 1375 | 1431 | if (composerTrigger.kind === "skill") { |
| 1376 | return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( | |
| 1377 | (skill) => ({ | |
| 1378 | id: `skill:${selectedProvider}:${skill.name}`, | |
| 1379 | type: "skill" as const, | |
| 1380 | provider: selectedProvider, | |
| 1381 | skill, | |
| 1382 | label: formatProviderSkillDisplayName(skill), | |
| 1383 | description: | |
| 1384 | skill.shortDescription ?? | |
| 1385 | skill.description ?? | |
| 1386 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), | |
| 1387 | }), | |
| 1388 | ); | |
| 1432 | return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ | |
| 1433 | id: `skill:${selectedProvider}:${skill.name}`, | |
| 1434 | type: "skill" as const, | |
| 1435 | provider: selectedProvider, | |
| 1436 | skill, | |
| 1437 | label: formatProviderSkillDisplayName(skill), | |
| 1438 | description: | |
| 1439 | skill.shortDescription ?? | |
| 1440 | skill.description ?? | |
| 1441 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), | |
| 1442 | })); | |
| 1389 | 1443 | } |
| 1390 | 1444 | return []; |
| 1391 | 1445 | }, [ |
| 1392 | 1446 | composerTrigger, |
| 1393 | 1447 | planModeUiEnabled, |
| 1394 | 1448 | selectedProvider, |
| 1449 | selectedProviderSkills, | |
| 1450 | selectedProviderSlashCommands, | |
| 1395 | 1451 | selectedProviderStatus, |
| 1396 | 1452 | settings.showSkillsInSlashMenu, |
| 1397 | 1453 | workspaceEntries.entries, |
| ... | ... | @@ -4096,7 +4152,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4096 | 4152 | ? composerTerminalContexts |
| 4097 | 4153 | : [] |
| 4098 | 4154 | } |
| 4099 | skills={selectedProviderStatus?.skills ?? []} | |
| 4155 | skills={selectedProviderSkills} | |
| 4100 | 4156 | {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} |
| 4101 | 4157 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} |
| 4102 | 4158 | onChange={onPromptChange} |
packages/client-runtime/src/providerSkills.test.ts+41| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | 4 | import { |
| ... | ... | @@ -5,9 +6,33 @@ import { |
| 5 | 6 | formatProviderSkillDisplayName, |
| 6 | 7 | getProviderSlashCommandsForSlashMenu, |
| 7 | 8 | getProviderSkillsForSlashMenu, |
| 9 | resolveProviderSkillsForCwd, | |
| 10 | resolveProviderSlashCommandsForCwd, | |
| 8 | 11 | resolveProviderSkillSourceKind, |
| 9 | 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 | 36 | describe("formatProviderSkillDisplayName", () => { |
| 12 | 37 | it("prefers the provider display name", () => { |
| 13 | 38 | expect( |
| ... | ... | @@ -211,3 +236,19 @@ describe("resolveProviderSkillSourceKind", () => { |
| 211 | 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 | import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | ServerProvider, | |
| 3 | ServerProviderSkill, | |
| 4 | ServerProviderSlashCommand, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; |
| 4 | 8 | |
| ... | ... | @@ -98,3 +102,25 @@ export function resolveProviderSkillSourceKind( |
| 98 | 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 | 849 | tag: WS_METHODS.serverRefreshProviders, |
| 850 | 850 | concurrency: { |
| 851 | 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 | 856 | updateProvider: createEnvironmentRpcCommand(runtime, { |
packages/contracts/src/rpc.ts+2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | 5 | |
| 5 | 6 | import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; |
| 6 | 7 | import { |
| ... | ... | @@ -368,6 +369,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv |
| 368 | 369 | * refreshes. |
| 369 | 370 | */ |
| 370 | 371 | instanceId: Schema.optional(ProviderInstanceId), |
| 372 | cwd: Schema.optional(TrimmedNonEmptyString), | |
| 371 | 373 | }), |
| 372 | 374 | success: ServerProviderUpdatedPayload, |
| 373 | 375 | error: EnvironmentAuthorizationError, |
packages/contracts/src/server.ts+9| ... | ... | @@ -110,6 +110,14 @@ export const ServerProviderSkill = Schema.Struct({ |
| 110 | 110 | }); |
| 111 | 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 | 122 | * Availability of a configured provider instance from the runtime's POV. |
| 115 | 123 | * |
| ... | ... | @@ -206,6 +214,7 @@ export const ServerProvider = Schema.Struct({ |
| 206 | 214 | Schema.withDecodingDefault(Effect.succeed([])), |
| 207 | 215 | ), |
| 208 | 216 | skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 217 | workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), | |
| 209 | 218 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), |
| 210 | 219 | updateState: Schema.optionalKey(ServerProviderUpdateState), |
| 211 | 220 | }); |