From 887c71854c9f9fc64b8eb6a72eb82b577726650b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 14 Jul 2026 20:45:27 -0400 Subject: [PATCH 1/5] Expose project provider skills in chat --- .../src/provider/Drivers/CodexDriver.ts | 45 +++- .../server/src/provider/Drivers/GrokDriver.ts | 26 ++ .../src/provider/Layers/CodexProvider.ts | 165 ++++++------- .../src/provider/Layers/GrokAdapter.test.ts | 142 ++++++++++- .../server/src/provider/Layers/GrokAdapter.ts | 50 +++- .../Layers/GrokSkillDiscovery.test.ts | 225 ++++++++++++++++++ .../src/provider/Layers/GrokSkillDiscovery.ts | 161 +++++++++++++ .../ProviderInstanceRegistryLive.test.ts | 2 + .../provider/Layers/ProviderRegistry.test.ts | 14 ++ .../src/provider/Layers/ProviderRegistry.ts | 14 ++ apps/server/src/provider/ProviderDriver.ts | 4 + .../src/provider/Services/ProviderRegistry.ts | 7 + .../providerMaintenanceRunner.test.ts | 1 + .../testUtils/providerRegistryMock.ts | 1 + apps/server/src/ws.ts | 28 +++ .../src/components/ComposerPromptEditor.tsx | 47 +++- apps/web/src/components/chat/ChatComposer.tsx | 65 +++-- apps/web/src/state/queries.ts | 20 ++ .../src/state/projectCommands.ts | 6 + packages/contracts/src/rpc.ts | 18 ++ packages/contracts/src/server.ts | 36 +++ .../shared/src/composerInlineTokens.test.ts | 12 + packages/shared/src/composerInlineTokens.ts | 10 +- 23 files changed, 995 insertions(+), 104 deletions(-) create mode 100644 apps/server/src/provider/Layers/GrokSkillDiscovery.test.ts create mode 100644 apps/server/src/provider/Layers/GrokSkillDiscovery.ts diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..2289086d7af 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -36,11 +36,15 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexProviderStatus, + makePendingCodexProvider, + probeCodexAppServerProvider, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { AUTH_PROBE_TIMEOUT_MS, type ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -161,6 +165,42 @@ export const CodexDriver: ProviderDriver = { ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); + const listSkills = Effect.fn("CodexDriver.listSkills")(function* (cwd: string) { + if (!effectiveConfig.enabled) { + return []; + } + + return yield* probeCodexAppServerProvider({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + cwd, + customModels: [], + environment: processEnv, + includeModels: false, + }).pipe( + Effect.scoped, + Effect.map((result) => result.skills), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to list Codex skills: ${cause.message}`, + cause, + }), + ), + Effect.timeoutOrElse({ + duration: Duration.millis(AUTH_PROBE_TIMEOUT_MS), + orElse: () => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Timed out listing Codex skills after ${AUTH_PROBE_TIMEOUT_MS}ms`, + }), + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + }); // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place @@ -209,6 +249,7 @@ export const CodexDriver: ProviderDriver = { snapshot, adapter, textGeneration, + listSkills, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index d855d1a4515..531914e323e 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -18,6 +18,7 @@ import { checkGrokProviderStatus, enrichGrokSnapshot, } from "../Layers/GrokProvider.ts"; +import { listGrokSkills } from "../Layers/GrokSkillDiscovery.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -105,10 +106,34 @@ export const GrokDriver: ProviderDriver = { env: processEnv, }); + const listSkills = Effect.fn("GrokDriver.listSkills")(function* (cwd: string) { + if (!effectiveConfig.enabled) { + return []; + } + + return yield* listGrokSkills({ + settings: effectiveConfig, + cwd, + environment: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: cause.message, + cause, + }), + ), + ); + }); + const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, + listSkills, }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); @@ -157,6 +182,7 @@ export const GrokDriver: ProviderDriver = { snapshot, adapter, textGeneration, + listSkills, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c..1d84968b5dc 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -286,94 +286,99 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly cwd: string; - readonly customModels?: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; -}) { - // `~` is not shell-expanded when env vars are set via `child_process.spawn`, - // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip - // "CODEX_HOME points to '~/.codex_work', but that path does not exist". - // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. - const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = { - ...input.environment, - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }; - const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: environment, - extendEnv: true, - }); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.cwd, - env: environment, - extendEnv: true, - forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, - shell: spawnCommand.shell, - }), - ) - .pipe( - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerSpawnError({ - command: `${input.binaryPath} app-server`, - cause, - }), - ), +export const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")( + function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly customModels?: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + readonly includeModels?: boolean; + }) { + // `~` is not shell-expanded when env vars are set via `child_process.spawn`, + // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip + // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: environment, + extendEnv: true, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), ); - const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(clientContext), - ); - const initialize = yield* client.request("initialize", { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); - yield* client.notify("initialized", undefined); + const initialize = yield* client.request("initialize", { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + }); + yield* client.notify("initialized", undefined); + + // Extract the version string after the first '/' in userAgent, up to the next space or the end + const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); + const version = versionMatch ? versionMatch[1] : undefined; + + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return { + account: accountResponse, + version, + models: appendCustomCodexModels([], input.customModels ?? []), + skills: [], + } satisfies CodexAppServerProviderSnapshot; + } - // Extract the version string after the first '/' in userAgent, up to the next space or the end - const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); - const version = versionMatch ? versionMatch[1] : undefined; + let skillsResponse: CodexSchema.V2SkillsListResponse; + let models: ReadonlyArray; + if (input.includeModels === false) { + skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + models = []; + } else { + [skillsResponse, models] = yield* Effect.all( + [client.request("skills/list", { cwds: [input.cwd] }), requestAllCodexModels(client)], + { concurrency: "unbounded" }, + ); + } - const accountResponse = yield* client.request("account/read", {}); - if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, version, - models: appendCustomCodexModels([], input.customModels ?? []), - skills: [], + models: appendCustomCodexModels(models, input.customModels ?? []), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; - } - - const [skillsResponse, models] = yield* Effect.all( - [ - client.request("skills/list", { - cwds: [input.cwd], - }), - requestAllCodexModels(client), - ], - { concurrency: "unbounded" }, - ); - - return { - account: accountResponse, - version, - models: appendCustomCodexModels(models, input.customModels ?? []), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), - } satisfies CodexAppServerProviderSnapshot; -}); + }, +); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..4bebacff0b6 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -26,7 +26,12 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + grokPromptSettlementBelongsToContext, + makeGrokAdapter, + rewriteGrokSkillReferences, +} from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -122,7 +127,142 @@ it("requires a settlement to match the live Grok turn", () => { ); }); +it("rewrites only exact enabled Grok skill references", () => { + const skills = [ + { + name: "review", + description: "Review the work", + path: "/skills/review/SKILL.md", + scope: "project", + enabled: true, + }, + { + name: "local:verify", + path: "/skills/local-verify/SKILL.md", + scope: "plugin:local", + enabled: true, + }, + { + name: "disabled", + path: "/skills/disabled/SKILL.md", + scope: "project", + enabled: false, + }, + ]; + + assert.equal( + rewriteGrokSkillReferences( + "Use $review and keep $PATH plus $disabled then run $local:verify", + skills, + ), + "Use /review and keep $PATH plus $disabled then run /local:verify", + ); +}); + it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { + it.effect("rewrites discovered skill references only in the ACP prompt", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-skill-rewrite"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-skill-rewrite-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const originalInput = "Please $review this and preserve $PATH"; + const adapter = yield* makeTestAdapter(wrapperPath, { + listSkills: () => + Effect.succeed([ + { + name: "review", + path: "/skills/review/SKILL.md", + scope: "project", + enabled: true, + }, + ]), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: originalInput, attachments: [] }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + assert.isDefined(promptRequest); + const params = promptRequest?.params; + assert.isTrue(typeof params === "object" && params !== null && "prompt" in params); + if (typeof params === "object" && params !== null && "prompt" in params) { + assert.deepStrictEqual(params.prompt, [ + { type: "text", text: "Please /review this and preserve $PATH" }, + ]); + } + assert.equal(originalInput, "Please $review this and preserve $PATH"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("bypasses discovery when a prompt has no skill token", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-skill-discovery-bypass"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const discoveryCalls = yield* Ref.make(0); + const adapter = yield* makeTestAdapter(wrapperPath, { + listSkills: () => Ref.update(discoveryCalls, (count) => count + 1).pipe(Effect.as([])), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "ordinary prompt", attachments: [] }); + + assert.equal(yield* Ref.get(discoveryCalls), 0); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces skill discovery failures as typed adapter errors", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-skill-discovery-failure"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath, { + listSkills: () => + Effect.fail( + new ProviderDriverError({ + driver: "grok", + instanceId: "grok", + detail: "inspect failed", + }), + ), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const error = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "$review the change", attachments: [] }), + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.equal(error.method, "skills/list"); + assert.instanceOf(error.cause, ProviderDriverError); + } + yield* adapter.stopSession(threadId); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c22b2180183..b24aab78296 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -9,9 +9,11 @@ import { ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, + type ServerProviderSkill, type ThreadId, TurnId, } from "@t3tools/contracts"; +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -40,6 +42,7 @@ import { ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, + type ProviderDriverError, } from "../Errors.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; @@ -84,6 +87,36 @@ export interface GrokAdapterLiveOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; + readonly listSkills?: ( + cwd: string, + ) => Effect.Effect, ProviderDriverError>; +} + +function submittedSkillTokens(input: string) { + return collectComposerInlineTokens(input, { includeTrailingSkillToken: true }).filter( + (token) => token.type === "skill", + ); +} + +export function rewriteGrokSkillReferences( + input: string, + skills: ReadonlyArray, +): string { + const enabledSkillNames = new Set( + skills.filter((skill) => skill.enabled).map((skill) => skill.name), + ); + const replacements = submittedSkillTokens(input).filter((token) => + enabledSkillNames.has(token.value), + ); + if (replacements.length === 0) { + return input; + } + + let rewritten = input; + for (const token of replacements.toReversed()) { + rewritten = `${rewritten.slice(0, token.start)}/${token.value}${rewritten.slice(token.end)}`; + } + return rewritten; } interface PendingApproval { @@ -949,7 +982,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); - const text = input.input?.trim(); + const rawText = input.input?.trim(); + const text = + rawText && options?.listSkills && submittedSkillTokens(rawText).length > 0 + ? yield* options.listSkills(ctx.session.cwd ?? "").pipe( + Effect.map((skills) => rewriteGrokSkillReferences(rawText, skills)), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "skills/list", + detail: "Failed to resolve Grok skill references for this prompt.", + cause, + }), + ), + ) + : rawText; const imagePromptParts = yield* Effect.forEach( input.attachments ?? [], (attachment) => diff --git a/apps/server/src/provider/Layers/GrokSkillDiscovery.test.ts b/apps/server/src/provider/Layers/GrokSkillDiscovery.test.ts new file mode 100644 index 00000000000..48d1351be20 --- /dev/null +++ b/apps/server/src/provider/Layers/GrokSkillDiscovery.test.ts @@ -0,0 +1,225 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { listGrokSkills, parseGrokInspectSkills } from "./GrokSkillDiscovery.ts"; + +const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +const inspectPayload = encodeJson({ + ignoredTopLevelField: true, + skills: [ + { + name: "project-skill", + description: "Project instructions", + source: { type: "project", path: "/repo/.grok/skills/project-skill/SKILL.md" }, + userInvocable: true, + displayName: "Project Skill", + shortDescription: "Project summary", + }, + { + name: "user-skill", + source: { type: "user", path: "/home/test/.grok/skills/user-skill/SKILL.md" }, + userInvocable: true, + }, + { + name: "bundled-skill", + source: { type: "bundled", path: "/home/test/.grok/skills/bundled/SKILL.md" }, + userInvocable: true, + }, + { + name: "config-skill", + source: { type: "config", path: "/team/config-skill/SKILL.md" }, + userInvocable: true, + }, + { + name: "server-skill", + source: { type: "server", path: "/managed/server-skill/SKILL.md" }, + userInvocable: true, + }, + { + name: "plugin:review", + source: { + type: "plugin", + plugin: "review-tools", + path: "/plugins/review-tools/skills/review/SKILL.md", + }, + userInvocable: true, + }, + { + name: "disabled-skill", + source: { type: "project", path: "/repo/.grok/skills/disabled/SKILL.md" }, + userInvocable: true, + disabled: true, + }, + { + name: "model-only", + source: { type: "project", path: "/repo/.grok/skills/model-only/SKILL.md" }, + userInvocable: false, + }, + ], +}); + +it.effect("maps Grok inspect skills into the shared provider contract", () => + Effect.gen(function* () { + const skills = yield* parseGrokInspectSkills(inspectPayload); + + assert.deepStrictEqual( + skills.map((skill) => ({ name: skill.name, scope: skill.scope, enabled: skill.enabled })), + [ + { name: "project-skill", scope: "project", enabled: true }, + { name: "user-skill", scope: "user", enabled: true }, + { name: "bundled-skill", scope: "bundled", enabled: true }, + { name: "config-skill", scope: "config", enabled: true }, + { name: "server-skill", scope: "server", enabled: true }, + { name: "plugin:review", scope: "plugin:review-tools", enabled: true }, + { name: "disabled-skill", scope: "project", enabled: false }, + ], + ); + assert.deepInclude(skills[0], { + displayName: "Project Skill", + shortDescription: "Project summary", + description: "Project instructions", + }); + }), +); + +it.effect("rejects malformed and structurally invalid inspect output", () => + Effect.gen(function* () { + const malformed = yield* Effect.flip(parseGrokInspectSkills("not-json")); + const missingPath = yield* Effect.flip( + parseGrokInspectSkills( + encodeJson({ + skills: [{ name: "broken", source: { type: "project" }, userInvocable: true }], + }), + ), + ); + const emptyName = yield* Effect.flip( + parseGrokInspectSkills( + encodeJson({ + skills: [ + { + name: " ", + source: { type: "project", path: "/skills/broken/SKILL.md" }, + userInvocable: true, + }, + ], + }), + ), + ); + + assert.equal(malformed.operation, "decode"); + assert.equal(missingPath.operation, "decode"); + assert.equal(emptyName.operation, "decode"); + }), +); + +it.layer(NodeServices.layer)("listGrokSkills", (it) => { + it.effect("uses the configured binary, cwd, arguments, and environment", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-skills-" }); + const binaryPath = path.join(dir, "grok-custom"); + const argsPath = path.join(dir, "args.txt"); + const envPath = path.join(dir, "env.txt"); + const cwdPath = path.join(dir, "cwd.txt"); + yield* fs.writeFileString( + binaryPath, + [ + "#!/bin/sh", + 'printf "%s\\n" "$@" > "$T3_GROK_ARGS_PATH"', + 'printf "%s" "$T3_GROK_MARKER" > "$T3_GROK_ENV_PATH"', + 'pwd > "$T3_GROK_CWD_PATH"', + `printf '%s' '${inspectPayload}'`, + "", + ].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const skills = yield* listGrokSkills({ + settings: { binaryPath }, + cwd: dir, + environment: { + ...process.env, + T3_GROK_ARGS_PATH: argsPath, + T3_GROK_ENV_PATH: envPath, + T3_GROK_CWD_PATH: cwdPath, + T3_GROK_MARKER: "environment-forwarded", + }, + }); + + assert.equal(skills[0]?.name, "project-skill"); + assert.deepStrictEqual((yield* fs.readFileString(argsPath)).trim().split("\n"), [ + "--cwd", + dir, + "inspect", + "--json", + ]); + assert.equal(yield* fs.readFileString(envPath), "environment-forwarded"); + assert.equal( + yield* fs.realPath((yield* fs.readFileString(cwdPath)).trim()), + yield* fs.realPath(dir), + ); + }), + ), + ); + + it.effect("returns typed failures for non-zero exits and malformed JSON", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-errors-" }); + const failingPath = path.join(dir, "grok-failing"); + const malformedPath = path.join(dir, "grok-malformed"); + yield* fs.writeFileString(failingPath, "#!/bin/sh\nexit 7\n"); + yield* fs.writeFileString(malformedPath, "#!/bin/sh\nprintf 'not-json'\n"); + yield* fs.chmod(failingPath, 0o755); + yield* fs.chmod(malformedPath, 0o755); + + const failed = yield* Effect.flip( + listGrokSkills({ settings: { binaryPath: failingPath }, cwd: dir }), + ); + const malformed = yield* Effect.flip( + listGrokSkills({ settings: { binaryPath: malformedPath }, cwd: dir }), + ); + + assert.equal(failed.operation, "run"); + assert.include(failed.detail, "code 7"); + assert.equal(malformed.operation, "decode"); + }), + ), + ); + + it.effect("times out a hung Grok inspect process", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-timeout-" }); + const binaryPath = path.join(dir, "grok-hanging"); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 60\n"); + yield* fs.chmod(binaryPath, 0o755); + + const fiber = yield* listGrokSkills({ + settings: { binaryPath }, + cwd: dir, + timeout: Duration.millis(5), + }).pipe(Effect.flip, Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(10)); + const error = yield* Fiber.join(fiber); + + assert.equal(error.operation, "timeout"); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/GrokSkillDiscovery.ts b/apps/server/src/provider/Layers/GrokSkillDiscovery.ts new file mode 100644 index 00000000000..b93feefeb08 --- /dev/null +++ b/apps/server/src/provider/Layers/GrokSkillDiscovery.ts @@ -0,0 +1,161 @@ +import { + type GrokSettings, + type ServerProviderSkill, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +export const GROK_SKILL_DISCOVERY_TIMEOUT_MS = 10_000; + +const GrokInspectSkillSource = Schema.Struct({ + type: TrimmedNonEmptyString, + path: TrimmedNonEmptyString, + name: Schema.optional(Schema.String), + plugin: Schema.optional(Schema.String), +}); + +const GrokInspectSkill = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + source: GrokInspectSkillSource, + userInvocable: Schema.optional(Schema.Boolean), + disabled: Schema.optional(Schema.Boolean), + enabled: Schema.optional(Schema.Boolean), + displayName: Schema.optional(Schema.String), + shortDescription: Schema.optional(Schema.String), +}); + +const GrokInspectOutput = Schema.Struct({ + skills: Schema.Array(GrokInspectSkill), +}); + +const GrokInspectOutputJson = Schema.fromJsonString(GrokInspectOutput); +const decodeGrokInspectOutputJson = Schema.decodeUnknownEffect(GrokInspectOutputJson); + +export class GrokSkillDiscoveryError extends Schema.TaggedErrorClass()( + "GrokSkillDiscoveryError", + { + operation: Schema.Literals(["run", "decode", "timeout"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Grok skill discovery failed during ${this.operation}: ${this.detail}`; + } +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function scopeFromSource(source: typeof GrokInspectSkillSource.Type): string | undefined { + const type = nonEmpty(source.type); + if (!type) return undefined; + if (type !== "plugin") return type; + const pluginName = nonEmpty(source.plugin) ?? nonEmpty(source.name); + return pluginName ? `plugin:${pluginName}` : type; +} + +export const parseGrokInspectSkills = Effect.fn("parseGrokInspectSkills")(function* ( + output: string, +): Effect.fn.Return, GrokSkillDiscoveryError> { + const parsed = yield* decodeGrokInspectOutputJson(output).pipe( + Effect.mapError( + (cause) => + new GrokSkillDiscoveryError({ + operation: "decode", + detail: "The `grok inspect --json` response did not match the expected schema.", + cause, + }), + ), + ); + + return parsed.skills + .filter((skill) => skill.userInvocable !== false) + .map((skill): ServerProviderSkill => { + const description = nonEmpty(skill.description); + const displayName = nonEmpty(skill.displayName); + const shortDescription = nonEmpty(skill.shortDescription); + const scope = scopeFromSource(skill.source); + return { + name: skill.name, + path: skill.source.path, + enabled: skill.enabled ?? skill.disabled !== true, + ...(description ? { description } : {}), + ...(scope ? { scope } : {}), + ...(displayName ? { displayName } : {}), + ...(shortDescription ? { shortDescription } : {}), + }; + }); +}); + +export const listGrokSkills = Effect.fn("listGrokSkills")(function* (input: { + readonly settings: Pick; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + readonly timeout?: Duration.Input; +}): Effect.fn.Return< + ReadonlyArray, + GrokSkillDiscoveryError, + ChildProcessSpawner.ChildProcessSpawner +> { + const binaryPath = input.settings.binaryPath || "grok"; + const timeout = input.timeout ?? Duration.millis(GROK_SKILL_DISCOVERY_TIMEOUT_MS); + const spawnCommand = yield* resolveSpawnCommand( + binaryPath, + ["--cwd", input.cwd, "inspect", "--json"], + input.environment ? { env: input.environment } : {}, + ).pipe( + Effect.mapError( + (cause) => + new GrokSkillDiscoveryError({ + operation: "run", + detail: "The Grok executable could not be resolved.", + cause, + }), + ), + ); + + const commandResult = yield* spawnAndCollect( + binaryPath, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + ...(input.environment ? { env: input.environment } : {}), + shell: spawnCommand.shell, + }), + ).pipe( + Effect.mapError( + (cause) => + new GrokSkillDiscoveryError({ + operation: "run", + detail: "The Grok inspect process could not be executed.", + cause, + }), + ), + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => + new GrokSkillDiscoveryError({ + operation: "timeout", + detail: `The Grok inspect process exceeded ${Duration.toMillis(timeout)}ms.`, + }), + }), + ); + + if (commandResult.code !== 0) { + return yield* new GrokSkillDiscoveryError({ + operation: "run", + detail: `The Grok inspect process exited with code ${commandResult.code}.`, + }); + } + + return yield* parseGrokInspectSkills(commandResult.stdout); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dbfa7faffea..42b9b4bea69 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -397,6 +397,8 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(grokSnapshot.driver).toBe(grokDriverKind); expect(grokSnapshot.enabled).toBe(false); expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + expect(grok?.listSkills).toBeDefined(); + expect(yield* grok!.listSkills!(process.cwd())).toEqual([]); const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; expect(openCodeSnapshot.instanceId).toBe(openCodeId); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..784b82da283 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -613,6 +613,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te skills: [], } as const satisfies ServerProvider; const refreshCalls = yield* Ref.make(0); + const listedSkillCwds = yield* Ref.make>([]); + const projectSkill = { + name: "project-review", + path: "/tmp/project/.codex/skills/project-review/SKILL.md", + enabled: true, + scope: "repo", + } as const; const instance = { instanceId: codexInstanceId, driverKind: codexDriver, @@ -635,6 +642,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], + listSkills: (cwd: string) => + Ref.update(listedSkillCwds, (cwds) => [...cwds, cwd]).pipe(Effect.as([projectSkill])), } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -664,6 +673,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); assert.strictEqual(yield* Ref.get(refreshCalls), 0); + assert.deepStrictEqual( + yield* registry.listSkills({ instanceId: codexInstanceId, cwd: "/tmp/project" }), + [projectSkill], + ); + assert.deepStrictEqual(yield* Ref.get(listedSkillCwds), ["/tmp/project"]); }).pipe(Effect.provide(runtimeServices)); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..f2f4f9cf376 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -486,6 +486,19 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const listSkills = Effect.fn("ProviderRegistry.listSkills")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!instance?.listSkills) { + return undefined; + } + return yield* instance.listSkills(input.cwd); + }); + /** * Diff the aggregator's live-source set against the current * `ProviderInstanceRegistry` and: @@ -688,6 +701,7 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + listSkills, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a..0adf91785f7 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProviderSkill, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -71,6 +72,9 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + readonly listSkills?: ( + cwd: string, + ) => Effect.Effect, ProviderDriverError>; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b30338..54ce1530563 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,16 +10,23 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, + ServerProviderSkill, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import type { ProviderDriverError } from "../Errors.ts"; export type ProviderMaintenanceActionKind = "update"; export interface ProviderRegistryShape { + readonly listSkills: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) => Effect.Effect | undefined, ProviderDriverError>; + /** * Read the latest provider snapshots for every configured instance. * Multiple snapshots may share the same `provider` kind (multiple diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce42..03265410633 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -189,6 +189,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + listSkills: () => Effect.sync(() => undefined), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b05900..3c15444f6aa 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + listSkills: () => Effect.sync(() => undefined), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..d1b005038ed 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -43,6 +43,8 @@ import { ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, + ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, @@ -286,6 +288,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], + [WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], @@ -1251,6 +1254,31 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect( + WS_METHODS.serverListProviderSkills, + Effect.gen(function* () { + const skills = yield* providerRegistry.listSkills(input); + if (!skills) { + return yield* new ServerProviderSkillsUnsupportedError({ + instanceId: input.instanceId, + cwd: input.cwd, + }); + } + return { skills }; + }).pipe( + Effect.mapError((cause) => + cause._tag === "ServerProviderSkillsUnsupportedError" + ? cause + : new ServerProviderSkillsError({ + instanceId: input.instanceId, + cwd: input.cwd, + cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 18579cda6d3..36af0efac59 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -344,6 +344,12 @@ class ComposerSkillNode extends DecoratorNode { /> ); } + + setSkillPresentation(skillLabel: string, skillDescription: string | null): void { + const writable = this.getWritable(); + writable.__skillLabel = skillLabel; + writable.__skillDescription = skillDescription; + } } function $createComposerSkillNode( @@ -354,6 +360,32 @@ function $createComposerSkillNode( return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); } +function $updateComposerSkillMetadata( + skillMetadata: ReadonlyMap, +): void { + const visit = (node: LexicalNode): void => { + if (node instanceof ComposerSkillNode) { + const metadata = skillMetadata.get(node.__skillName); + if (!metadata) { + return; + } + const { label, description } = metadata; + if (node.__skillLabel === label && node.__skillDescription === description) { + return; + } + node.setSkillPresentation(label, description); + return; + } + if ($isElementNode(node)) { + for (const child of node.getChildren()) { + visit(child); + } + } + }; + + visit($getRoot()); +} + function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -1460,12 +1492,21 @@ function ComposerPromptEditorInner({ isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; + const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; if (shouldRewriteEditorState) { $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + } else if (skillsChanged) { + // Refresh chip presentation without clearing Lexical state so an + // in-flight `$` skill probe cannot reset the focused composer. + $updateComposerSkillMetadata(skillMetadataRef.current); } - if (shouldRewriteEditorState || isFocused) { + const shouldSyncSelection = + shouldRewriteEditorState || + (isFocused && + (previousSnapshot.value !== value || + previousSnapshot.cursor !== normalizedCursor || + contextsChanged)); + if (shouldSyncSelection) { $setSelectionAtComposerOffset(normalizedCursor); } }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..a9684ff98d3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,6 +9,7 @@ import type { RuntimeMode, ScopedThreadRef, ServerProvider, + ServerProviderSkill, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -58,6 +59,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderSkills } from "../../state/queries"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -127,6 +129,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const EMPTY_PROVIDER_SKILLS: ReadonlyArray = []; const runtimeModeConfig: Record< RuntimeMode, @@ -935,6 +938,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerSkills = useProviderSkills({ + environmentId, + instanceId: selectedProviderStatus?.instanceId ?? null, + cwd: gitCwd, + enabled: composerTriggerKind === "skill", + }); + const providerSkillsTargetKey = `${environmentId ?? ""}\0${selectedProviderStatus?.instanceId ?? ""}\0${gitCwd ?? ""}`; + const [cachedProviderSkills, setCachedProviderSkills] = useState<{ + readonly targetKey: string; + readonly skills: ReadonlyArray; + } | null>(null); + useEffect(() => { + if (providerSkills.data) { + setCachedProviderSkills({ + targetKey: providerSkillsTargetKey, + skills: providerSkills.data.skills, + }); + } + }, [providerSkills.data, providerSkillsTargetKey]); + const composerProviderSkills = + providerSkills.data?.skills ?? + (cachedProviderSkills?.targetKey === providerSkillsTargetKey + ? cachedProviderSkills.skills + : selectedProviderStatus?.skills) ?? + EMPTY_PROVIDER_SKILLS; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -990,22 +1018,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(composerProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + composerProviderSkills, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1070,7 +1102,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && providerSkills.isPending && composerMenuItems.length === 0); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -2396,7 +2429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={composerProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..31e6d52a854 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -7,6 +7,7 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProviderInstanceId, ThreadId, VcsListRefsResult, VcsRef, @@ -214,6 +215,25 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }; } +export function useProviderSkills(target: { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; + readonly enabled: boolean; +}) { + return useEnvironmentQuery( + target.enabled && + target.environmentId !== null && + target.instanceId !== null && + target.cwd !== null + ? projectEnvironment.listProviderSkills({ + environmentId: target.environmentId, + input: { instanceId: target.instanceId, cwd: target.cwd }, + }) + : null, + ); +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc32154..b4cf2b6827b 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -55,6 +55,12 @@ export function createProjectEnvironmentAtoms( JSON.stringify([environmentId, input.projectId]), }; return { + listProviderSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:providers:list-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:search-entries", tag: WS_METHODS.projectsSearchEntries, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..0dd3df5556e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -122,6 +122,10 @@ import { ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, + ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, + ServerProviderSkillsInput, + ServerProviderSkillsResult, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, @@ -154,6 +158,9 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + // Provider metadata methods + serverListProviderSkills: "server.listProviderSkills", + // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -266,6 +273,16 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderSkillsInput, + success: ServerProviderSkillsResult, + error: Schema.Union([ + ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, + EnvironmentAuthorizationError, + ]), +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -683,6 +700,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, + WsServerListProviderSkillsRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpsertKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..add23a2293d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -91,6 +91,42 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderSkillsInput = Schema.Struct({ + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, +}); +export type ServerProviderSkillsInput = typeof ServerProviderSkillsInput.Type; + +export const ServerProviderSkillsResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderSkillsResult = typeof ServerProviderSkillsResult.Type; + +export class ServerProviderSkillsUnsupportedError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsUnsupportedError", + { + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Provider instance '${this.instanceId}' does not support project skill discovery`; + } +} + +export class ServerProviderSkillsError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsError", + { + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to list provider skills for instance '${this.instanceId}'`; + } +} + /** * Availability of a configured provider instance from the runtime's POV. * diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index f99d0b6654e..8f61275ca1a 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -36,6 +36,18 @@ describe("collectComposerInlineTokens", () => { expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); }); + it("collects trailing skill tokens from submitted prompts when requested", () => { + expect(collectComposerInlineTokens("Use $ui", { includeTrailingSkillToken: true })).toEqual([ + { + type: "skill", + value: "ui", + source: "$ui", + start: 4, + end: 7, + }, + ]); + }); + it("keeps the delimiter after a token outside its source range", () => { const text = "Inspect [package.json](package.json) next"; diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index aa5e67d6fc8..2927bf1e17f 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -16,9 +16,14 @@ export type ComposerInlineToken = export interface CollectComposerInlineTokensOptions { readonly preserveTrailingFrom?: ReadonlyArray; + /** Include a skill token at end-of-input. Composer editing leaves this off + * so partially typed names stay plain text; provider prompt preparation can + * enable it once the user has submitted the prompt. */ + readonly includeTrailingSkillToken?: boolean; } const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; +const SUBMITTED_SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; @@ -83,7 +88,10 @@ export function collectComposerInlineTokens( ): ReadonlyArray { const matches = collectMentionTokens(text); - for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const skillTokenRegex = options.includeTrailingSkillToken + ? SUBMITTED_SKILL_TOKEN_REGEX + : SKILL_TOKEN_REGEX; + for (const match of text.matchAll(skillTokenRegex)) { const fullMatch = match[0]; const prefix = match[1] ?? ""; const value = match[2] ?? ""; From aef47a356b61d76f003ac55f5450c93ada226d20 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 14 Jul 2026 21:32:30 -0400 Subject: [PATCH 2/5] Clear stale composer skill metadata --- apps/web/src/components/ComposerPromptEditor.tsx | 11 ++--------- .../src/components/composerSkillMetadata.test.ts | 15 +++++++++++++++ apps/web/src/components/composerSkillMetadata.ts | 11 +++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/composerSkillMetadata.test.ts create mode 100644 apps/web/src/components/composerSkillMetadata.ts diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 36af0efac59..f5f95701076 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -74,6 +74,7 @@ import { import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; +import { type ComposerSkillMetadata, resolveComposerSkillMetadata } from "./composerSkillMetadata"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; @@ -218,11 +219,6 @@ function resolveSkillDescription( return description || null; } -type ComposerSkillMetadata = { - label: string; - description: string | null; -}; - function skillMetadataByName( skills: ReadonlyArray, ): ReadonlyMap { @@ -365,10 +361,7 @@ function $updateComposerSkillMetadata( ): void { const visit = (node: LexicalNode): void => { if (node instanceof ComposerSkillNode) { - const metadata = skillMetadata.get(node.__skillName); - if (!metadata) { - return; - } + const metadata = resolveComposerSkillMetadata(node.__skillName, skillMetadata); const { label, description } = metadata; if (node.__skillLabel === label && node.__skillDescription === description) { return; diff --git a/apps/web/src/components/composerSkillMetadata.test.ts b/apps/web/src/components/composerSkillMetadata.test.ts new file mode 100644 index 00000000000..7618a7dbcaa --- /dev/null +++ b/apps/web/src/components/composerSkillMetadata.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveComposerSkillMetadata } from "./composerSkillMetadata"; + +describe("resolveComposerSkillMetadata", () => { + it("clears stale presentation when a skill is absent from refreshed metadata", () => { + const refreshedMetadata = new Map([ + ["other-skill", { label: "Other Skill", description: "Still available" }], + ]); + + expect(resolveComposerSkillMetadata("removed-skill", refreshedMetadata)).toEqual({ + label: "removed-skill", + description: null, + }); + }); +}); diff --git a/apps/web/src/components/composerSkillMetadata.ts b/apps/web/src/components/composerSkillMetadata.ts new file mode 100644 index 00000000000..21e1bfb73d9 --- /dev/null +++ b/apps/web/src/components/composerSkillMetadata.ts @@ -0,0 +1,11 @@ +export type ComposerSkillMetadata = { + label: string; + description: string | null; +}; + +export function resolveComposerSkillMetadata( + skillName: string, + skillMetadata: ReadonlyMap, +): ComposerSkillMetadata { + return skillMetadata.get(skillName) ?? { label: skillName, description: null }; +} From 202c168b15afd6be03a5775b1a577b08286bad68 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 14 Jul 2026 21:32:38 -0400 Subject: [PATCH 3/5] Keep composer skill fallback cwd-safe --- apps/web/src/components/chat/ChatComposer.tsx | 46 ++++--- apps/web/src/composerProviderSkills.test.ts | 119 ++++++++++++++++++ apps/web/src/composerProviderSkills.ts | 52 ++++++++ apps/web/src/state/queries.ts | 23 ++-- apps/web/src/state/query.ts | 7 +- 5 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/composerProviderSkills.test.ts create mode 100644 apps/web/src/composerProviderSkills.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a9684ff98d3..ae4d0195556 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,7 +9,6 @@ import type { RuntimeMode, ScopedThreadRef, ServerProvider, - ServerProviderSkill, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -127,9 +126,13 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; +import { + type CachedComposerProviderSkills, + getComposerProviderSkillsCacheEntry, + resolveComposerProviderSkills, +} from "../../composerProviderSkills"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; -const EMPTY_PROVIDER_SKILLS: ReadonlyArray = []; const runtimeModeConfig: Record< RuntimeMode, @@ -945,24 +948,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) enabled: composerTriggerKind === "skill", }); const providerSkillsTargetKey = `${environmentId ?? ""}\0${selectedProviderStatus?.instanceId ?? ""}\0${gitCwd ?? ""}`; - const [cachedProviderSkills, setCachedProviderSkills] = useState<{ - readonly targetKey: string; - readonly skills: ReadonlyArray; - } | null>(null); + const [cachedProviderSkills, setCachedProviderSkills] = + useState(null); useEffect(() => { - if (providerSkills.data) { - setCachedProviderSkills({ - targetKey: providerSkillsTargetKey, - skills: providerSkills.data.skills, - }); + const cacheEntry = getComposerProviderSkillsCacheEntry({ + targetKey: providerSkillsTargetKey, + discoveredSkills: providerSkills.data?.skills ?? null, + snapshotSkills: selectedProviderStatus?.skills, + discoveryUnsupported: providerSkills.isUnsupported, + }); + if (cacheEntry !== null) { + setCachedProviderSkills(cacheEntry); } - }, [providerSkills.data, providerSkillsTargetKey]); - const composerProviderSkills = - providerSkills.data?.skills ?? - (cachedProviderSkills?.targetKey === providerSkillsTargetKey - ? cachedProviderSkills.skills - : selectedProviderStatus?.skills) ?? - EMPTY_PROVIDER_SKILLS; + }, [ + providerSkills.data, + providerSkills.isUnsupported, + providerSkillsTargetKey, + selectedProviderStatus?.skills, + ]); + const composerProviderSkills = resolveComposerProviderSkills({ + targetKey: providerSkillsTargetKey, + discoveredSkills: providerSkills.data?.skills ?? null, + cachedSkills: cachedProviderSkills, + snapshotSkills: selectedProviderStatus?.skills, + discoveryUnsupported: providerSkills.isUnsupported, + }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; diff --git a/apps/web/src/composerProviderSkills.test.ts b/apps/web/src/composerProviderSkills.test.ts new file mode 100644 index 00000000000..e3c8e65574e --- /dev/null +++ b/apps/web/src/composerProviderSkills.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ServerProviderSkill } from "@t3tools/contracts"; + +import { + getComposerProviderSkillsCacheEntry, + resolveComposerProviderSkills, +} from "./composerProviderSkills"; + +const skill = (name: string): ServerProviderSkill => ({ + name, + path: `/tmp/${name}/SKILL.md`, + enabled: true, +}); + +describe("resolveComposerProviderSkills", () => { + it("uses the cwd-aware discovery result, including an empty result", () => { + const snapshotSkills = [skill("snapshot")]; + + expect( + resolveComposerProviderSkills({ + targetKey: "environment\0provider\0/project", + discoveredSkills: [], + cachedSkills: null, + snapshotSkills, + discoveryUnsupported: true, + }), + ).toEqual([]); + }); + + it("retains a last successful result only for the exact target key", () => { + const cachedSkills = { + targetKey: "environment\0provider\0/project-a", + skills: [skill("project-a")], + }; + + expect( + resolveComposerProviderSkills({ + targetKey: cachedSkills.targetKey, + discoveredSkills: null, + cachedSkills, + snapshotSkills: [skill("snapshot")], + discoveryUnsupported: false, + }), + ).toEqual(cachedSkills.skills); + + expect( + resolveComposerProviderSkills({ + targetKey: "environment\0provider\0/project-b", + discoveredSkills: null, + cachedSkills, + snapshotSkills: [skill("snapshot")], + discoveryUnsupported: false, + }), + ).toEqual([]); + }); + + it("uses snapshot skills only when project discovery is unsupported", () => { + const snapshotSkills = [skill("snapshot")]; + const baseInput = { + targetKey: "environment\0provider\0/project", + discoveredSkills: null, + cachedSkills: null, + snapshotSkills, + } as const; + + expect( + resolveComposerProviderSkills({ + ...baseInput, + discoveryUnsupported: false, + }), + ).toEqual([]); + expect( + resolveComposerProviderSkills({ + ...baseInput, + discoveryUnsupported: true, + }), + ).toEqual(snapshotSkills); + }); + + it("caches an unsupported-provider snapshot for the exact target", () => { + const snapshotSkills = [skill("snapshot")]; + const targetKey = "environment\0provider\0/project"; + + const cachedSkills = getComposerProviderSkillsCacheEntry({ + targetKey, + discoveredSkills: null, + snapshotSkills, + discoveryUnsupported: true, + }); + expect(cachedSkills).toEqual({ targetKey, skills: snapshotSkills }); + expect( + resolveComposerProviderSkills({ + targetKey, + discoveredSkills: null, + cachedSkills, + snapshotSkills, + // Closing the menu disables the query and clears its current error. + discoveryUnsupported: false, + }), + ).toEqual(snapshotSkills); + expect( + resolveComposerProviderSkills({ + targetKey: "environment\0provider\0another-project", + discoveredSkills: null, + cachedSkills, + snapshotSkills, + discoveryUnsupported: false, + }), + ).toEqual([]); + expect( + getComposerProviderSkillsCacheEntry({ + targetKey, + discoveredSkills: null, + snapshotSkills, + discoveryUnsupported: false, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/composerProviderSkills.ts b/apps/web/src/composerProviderSkills.ts new file mode 100644 index 00000000000..d78d9e490df --- /dev/null +++ b/apps/web/src/composerProviderSkills.ts @@ -0,0 +1,52 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; + +export interface CachedComposerProviderSkills { + readonly targetKey: string; + readonly skills: ReadonlyArray; +} + +const EMPTY_PROVIDER_SKILLS: ReadonlyArray = []; + +export function getComposerProviderSkillsCacheEntry(input: { + readonly targetKey: string; + readonly discoveredSkills: ReadonlyArray | null; + readonly snapshotSkills: ReadonlyArray | undefined; + readonly discoveryUnsupported: boolean; +}): CachedComposerProviderSkills | null { + if (input.discoveredSkills !== null) { + return { targetKey: input.targetKey, skills: input.discoveredSkills }; + } + if (input.discoveryUnsupported) { + return { + targetKey: input.targetKey, + skills: input.snapshotSkills ?? EMPTY_PROVIDER_SKILLS, + }; + } + return null; +} + +export function resolveComposerProviderSkills(input: { + readonly targetKey: string; + readonly discoveredSkills: ReadonlyArray | null; + readonly cachedSkills: CachedComposerProviderSkills | null; + readonly snapshotSkills: ReadonlyArray | undefined; + readonly discoveryUnsupported: boolean; +}): ReadonlyArray { + if (input.discoveredSkills !== null) { + return input.discoveredSkills; + } + + if (input.cachedSkills?.targetKey === input.targetKey) { + return input.cachedSkills.skills; + } + + // Provider snapshots are not cwd-aware. They are therefore only a safe + // fallback when the selected provider cannot perform project discovery at + // all; while a cwd-aware request is pending they may belong to another + // project or worktree. + if (input.discoveryUnsupported) { + return input.snapshotSkills ?? EMPTY_PROVIDER_SKILLS; + } + + return EMPTY_PROVIDER_SKILLS; +} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 31e6d52a854..45d1fe07df6 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -4,16 +4,18 @@ import { type ComposerPathSearchTarget, } from "@t3tools/client-runtime/state/threads"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; -import type { - EnvironmentId, - OrchestrationThread, - ProviderInstanceId, - ThreadId, - VcsListRefsResult, - VcsRef, +import { + type EnvironmentId, + type OrchestrationThread, + type ProviderInstanceId, + ServerProviderSkillsUnsupportedError, + type ThreadId, + type VcsListRefsResult, + type VcsRef, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -29,6 +31,7 @@ const COMPOSER_PATH_SEARCH_LIMIT = 80; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; +const isProviderSkillsUnsupportedError = Schema.is(ServerProviderSkillsUnsupportedError); export interface ThreadDetailView { readonly data: OrchestrationThread | null; @@ -221,7 +224,7 @@ export function useProviderSkills(target: { readonly cwd: string | null; readonly enabled: boolean; }) { - return useEnvironmentQuery( + const result = useEnvironmentQuery( target.enabled && target.environmentId !== null && target.instanceId !== null && @@ -232,6 +235,10 @@ export function useProviderSkills(target: { }) : null, ); + return { + ...result, + isUnsupported: result.failure !== null && isProviderSkillsUnsupportedError(result.failure), + }; } export function useCheckpointDiff( diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 2610f1724a0..2cdeb8a8857 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -7,9 +7,10 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals Atom.withLabel("web-environment-query:empty"), ); -export interface EnvironmentQueryView { +export interface EnvironmentQueryView { readonly data: A | null; readonly error: string | null; + readonly failure: E | null; readonly isPending: boolean; readonly refresh: () => void; } @@ -23,13 +24,15 @@ function formatError(cause: Cause.Cause): string { export function useEnvironmentQuery( atom: Atom.Atom> | null, -): EnvironmentQueryView { +): EnvironmentQueryView { const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; const result = useAtomValue(selectedAtom); const refresh = useAtomRefresh(selectedAtom); return { data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatError(result.cause) : null, + failure: + result._tag === "Failure" ? Option.getOrNull(Cause.findErrorOption(result.cause)) : null, isPending: atom !== null && result.waiting, refresh, }; From 764cb4040648a63a8d8b27ff19d824b4bad037ec Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 14 Jul 2026 21:33:30 -0400 Subject: [PATCH 4/5] Synchronize Grok model state before skill lookup --- .../src/provider/Layers/GrokAdapter.test.ts | 77 +++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 15 +++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 4bebacff0b6..1be8224d009 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -263,6 +263,83 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("retains a successful model switch when later skill discovery fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-model-switch-before-skill-failure"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-model-skill-failure-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + listSkills: () => + Effect.fail( + new ProviderDriverError({ + driver: "grok", + instanceId: "grok", + detail: "inspect failed", + }), + ), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "$review the change", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-mock-alt", + }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + const sessionsAfterFailure = yield* adapter.listSessions(); + assert.equal( + sessionsAfterFailure.find((session) => session.threadId === threadId)?.model, + "grok-mock-alt", + ); + + yield* adapter.sendTurn({ + threadId, + input: "continue without another model selection", + attachments: [], + }); + const sessionsAfterFollowUp = yield* adapter.listSessions(); + assert.equal( + sessionsAfterFollowUp.find((session) => session.threadId === threadId)?.model, + "grok-mock-alt", + ); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const selectedModelIds = requests + .filter((entry) => entry.method === "session/set_model") + .flatMap((entry) => { + const params = entry.params; + return typeof params === "object" && + params !== null && + "modelId" in params && + typeof params.modelId === "string" + ? [params.modelId] + : []; + }); + // The failed turn committed exactly one switch. The model-less follow-up + // must neither repeat that switch nor revert to the session's old model. + assert.deepStrictEqual(selectedModelIds, ["grok-mock-alt"]); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index b24aab78296..cb35fb43a0c 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -981,6 +981,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + // ACP has already committed the model switch at this point. Keep + // both the internal comparison state and the public snapshot in + // sync even if later prompt preparation (for example, skill + // discovery) fails. + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveGrokAcpBaseModelId(currentModelId) + : undefined; + if (displayModel) { + ctx.session = { ...ctx.session, model: displayModel }; + } const rawText = input.input?.trim(); const text = @@ -1044,10 +1055,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } - ctx.currentModelId = currentModelId; - const displayModel = currentModelId - ? resolveGrokAcpBaseModelId(currentModelId) - : undefined; for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { yield* Effect.yieldNow; } From 1982e2fefd205f13c94fbc4893f221b80e3a0967 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 14 Jul 2026 21:33:41 -0400 Subject: [PATCH 5/5] Ignore environment variables during Grok skill discovery --- .../src/provider/Layers/GrokAdapter.test.ts | 61 ++++++++++++++++++- .../server/src/provider/Layers/GrokAdapter.ts | 16 ++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 1be8224d009..701262a7c5e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -152,10 +152,30 @@ it("rewrites only exact enabled Grok skill references", () => { assert.equal( rewriteGrokSkillReferences( - "Use $review and keep $PATH plus $disabled then run $local:verify", + "Use $review and keep $PATH plus $NODE_ENV and $disabled then run $local:verify", skills, ), - "Use /review and keep $PATH plus $disabled then run /local:verify", + "Use /review and keep $PATH plus $NODE_ENV and $disabled then run /local:verify", + ); +}); + +it("rewrites an exact all-uppercase skill once skills have been discovered", () => { + assert.equal( + rewriteGrokSkillReferences("Use $review with $PATH", [ + { + name: "review", + path: "/skills/review/SKILL.md", + scope: "project", + enabled: true, + }, + { + name: "PATH", + path: "/skills/path/SKILL.md", + scope: "project", + enabled: true, + }, + ]), + "Use /review with /PATH", ); }); @@ -229,6 +249,43 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("bypasses discovery for shell-style environment variables", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-env-var-discovery-bypass"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const discoveryCalls = yield* Ref.make(0); + const adapter = yield* makeTestAdapter(wrapperPath, { + listSkills: () => + Ref.update(discoveryCalls, (count) => count + 1).pipe( + Effect.andThen( + Effect.fail( + new ProviderDriverError({ + driver: "grok", + instanceId: "grok", + detail: "inspect failed", + }), + ), + ), + ), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Print $PATH and $NODE_ENV", + attachments: [], + }); + + assert.equal(yield* Ref.get(discoveryCalls), 0); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("surfaces skill discovery failures as typed adapter errors", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-skill-discovery-failure"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index cb35fb43a0c..dd3ea58abb3 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -92,12 +92,26 @@ export interface GrokAdapterLiveOptions { ) => Effect.Effect, ProviderDriverError>; } +// Provider requests contain only flattened prompt text, so an inserted skill +// chip cannot be distinguished from manually typed shell syntax here. Grok's +// normal skill names are lowercase or qualified; reserve conventional +// all-uppercase identifiers for environment variables so `$PATH` does not run +// (or fail on) `grok inspect`. An all-uppercase skill can still be rewritten +// when discovery is triggered by another normal skill token in the prompt. +const ENVIRONMENT_VARIABLE_STYLE_TOKEN = /^[A-Z][A-Z0-9_]*$/; + function submittedSkillTokens(input: string) { return collectComposerInlineTokens(input, { includeTrailingSkillToken: true }).filter( (token) => token.type === "skill", ); } +function potentialGrokSkillTokens(input: string) { + return submittedSkillTokens(input).filter( + (token) => !ENVIRONMENT_VARIABLE_STYLE_TOKEN.test(token.value), + ); +} + export function rewriteGrokSkillReferences( input: string, skills: ReadonlyArray, @@ -995,7 +1009,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const rawText = input.input?.trim(); const text = - rawText && options?.listSkills && submittedSkillTokens(rawText).length > 0 + rawText && options?.listSkills && potentialGrokSkillTokens(rawText).length > 0 ? yield* options.listSkills(ctx.session.cwd ?? "").pipe( Effect.map((skills) => rewriteGrokSkillReferences(rawText, skills)), Effect.mapError(