From 82efcf501113478d4548414b0e6e403956e0cf6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 22:52:42 +0000 Subject: [PATCH 1/2] test(grok): leading picked $skill reaches Grok as /skill Takeover tests for #20371: one adapter-boundary test drives the Grok adapter with a fake `grok inspect` catalog and asserts the outgoing session/prompt for leading, non-leading, and unknown skill mentions. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpAdapterV2.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 1a0c6caf1f925..12d1a0846bbe5 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -53,6 +53,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; @@ -3169,6 +3170,89 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // Oracle: #13457. Grok expands only a leading `/name`, so a picked leading `$name` + // must reach it in that form; anything else keeps the user's text. + it.live.each([ + { text: "$deploy ship it", sent: "/deploy ship it", native: true }, + { text: "ship $deploy", sent: "ship $deploy", native: false }, + { text: "$other ship it", sent: "$other ship it", native: false }, + ])("Grok sends $text with a picked skill as $sent", ({ text, sent, native }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const hostPlatform = yield* HostProcess.Platform; + // Stands in for `grok inspect --json`, the catalog behind the `$` picker. + const catalog = { skills: [{ name: "deploy", source: { path: "/s/deploy/SKILL.md" } }] }; + const grokPath = writeFakeCli({ + directory: yield* fileSystem.makeTempDirectoryScoped(), + name: "grok", + source: `process.stdout.write(${JSON.stringify(`${JSON.stringify(catalog)}\n`)});\n`, + platform: hostPlatform, + }); + const protocolEvents = yield* Queue.unbounded(); + const instanceId = ProviderInstanceId.make("grok-skill-mention"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: { ...DEFAULT_GROK_SETTINGS, binaryPath: grokPath }, + // The fake CLI needs `node` on its PATH. + environment: { ...process.env }, + hostPlatform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe( + Effect.flatMap(makeXAiPromptCompletionRuntime), + ), + }); + const threadId = ThreadId.make("grok-skill-mention"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-skill-mention"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + messageText: text, + }), + ); + const prompt = yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); + const blocks = rawProtocolPromptTextBlocks(Option.getOrThrow(prompt)); + if (native) { + // Same shape as a typed native command: one bare text block, no T3 instructions. + assert.deepEqual(blocks, [{ type: "text", text: sent }]); + } else { + assert.isTrue(blocks.some((block) => block.text.includes(sent))); + assert.isFalse(blocks.some((block) => block.text.trimStart().startsWith("/"))); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // Oracle: #15888 KillTask reports. Command completion must release the turn even // when the CLI keeps the prompt RPC and process alive. Kill calls stay visible. it.live.each([ From 84febd77a02b1ac445826b1e642364439ea352f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 22:56:42 +0000 Subject: [PATCH 2/2] fix(grok): a leading picked $skill reaches Grok as /skill Grok expands only a leading `/name`, but the `$` picker inserts `$name`. ACP flavors can now rewrite the user's text before slash-command detection; Grok uses it to turn a leading mention of an enabled `grok inspect` skill into `/name`. Other text and other ACP providers are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/provider-acp/src/server/adapter.ts | 23 +++++++++++++++++-- packages/provider-grok/src/server/adapter.ts | 19 +++++++++++++++ .../provider-grok/src/server/skills.test.ts | 15 ++++++++++-- packages/provider-grok/src/server/skills.ts | 12 ++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 01a729cd7c9e5..7d4438a64df85 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -225,6 +225,14 @@ class AcpModelSelectionError extends Schema.TaggedError( export interface AcpAdapterV2Flavor { readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; + /** + * Runs before attachment paths, T3 instructions and native slash-command detection, + * so a rewrite can turn the user's text into a native command. + */ + readonly rewriteUserText?: ( + text: string, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) => Effect.Effect; readonly driver: ProviderDriverKind; readonly capabilities: OrchestrationV2ProviderCapabilities; readonly clientCapabilitiesMeta?: NonNullable< @@ -7162,12 +7170,23 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } satisfies T3AcpInstructionState; const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); const compactionMarker = (yield* Ref.get(compactionMarkers)).get(sessionId); + const userText = + flavor.rewriteUserText === undefined + ? turnInput.message.text + : yield* flavor + .rewriteUserText(turnInput.message.text, turnInput) + .pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), + ); const messageText = providerMessageTextWithAttachmentPaths({ - text: turnInput.message.text, + text: userText, attachments: turnInput.message.attachments, resolveAttachmentPath: host.resolveAttachmentPath, }); - const isNativeSlashCommand = turnInput.message.text.trimStart().startsWith("/"); + const isNativeSlashCommand = userText.trimStart().startsWith("/"); const text = t3AcpPromptWithInstructions({ prompt: messageText, state: instructionState, diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 87ccbde77f2b1..e919874c46547 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -4,6 +4,7 @@ import { isXAiTaskCompletedWakeNotification, xAiRateLimitedErrorCode, } from "./xaiAcpExtension.ts"; +import { discoverGrokSkills, leadingGrokSkillMention } from "./skills.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; import { @@ -251,6 +252,24 @@ export function makeGrokAcpAdapterFlavor( // still accepts image content blocks (verified with real screenshots). supportsImagePrompts: true, supportsCompaction: true, + // Grok expands only a leading `/name`, while the `$` picker inserts `$name` from the + // `grok inspect` catalog. Ask that catalog again so other `$` text is left alone. + rewriteUserText: (text, turnInput) => { + const mention = leadingGrokSkillMention(text); + if (mention === undefined) return Effect.succeed(text); + return discoverGrokSkills( + options.settings, + options.environment, + turnInput.runtimePolicy.cwd ?? undefined, + ).pipe( + Effect.map((skills) => + skills.some((skill) => skill.enabled && skill.name === mention.name) + ? `/${mention.name}${mention.rest}` + : text, + ), + Effect.orElseSucceed(() => text), + ); + }, resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), getModelContextWindow: (selection, session) => { if (selection.instanceId !== options.instanceId) return undefined; diff --git a/packages/provider-grok/src/server/skills.test.ts b/packages/provider-grok/src/server/skills.test.ts index 069dfa7597aef..f84940ecaee6a 100644 --- a/packages/provider-grok/src/server/skills.test.ts +++ b/packages/provider-grok/src/server/skills.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import type * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { discoverGrokSkills } from "./skills.ts"; +import { discoverGrokSkills, leadingGrokSkillMention } from "./skills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); @@ -199,3 +199,14 @@ describe("discoverGrokSkills", () => { }); }); }); + +describe("leadingGrokSkillMention", () => { + it.each([ + { text: " €deploy\nship it", mention: { name: "deploy", rest: "\nship it" } }, + { text: "$deploy", mention: { name: "deploy", rest: "" } }, + { text: "$25 off", mention: undefined }, + { text: "$deploy, then ship", mention: undefined }, + ])("reads $text", ({ text, mention }) => { + assert.deepEqual(leadingGrokSkillMention(text), mention); + }); +}); diff --git a/packages/provider-grok/src/server/skills.ts b/packages/provider-grok/src/server/skills.ts index f39d005f7321a..cfc69727b3e00 100644 --- a/packages/provider-grok/src/server/skills.ts +++ b/packages/provider-grok/src/server/skills.ts @@ -9,12 +9,24 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/process/ChildProcess"; +import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { grokProbeEnvironment } from "./probeEnvironment.ts"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; +// The composer's chip rule, anchored to the message start, so `$25` never names a skill. +const LEADING_SKILL_MENTION = new RegExp(`^\\s*${SKILL_MENTION_PATTERN.source}`, "u"); + +/** A message opening with a mention, such as `deploy` and ` ship it` for `$deploy ship it`. */ +export function leadingGrokSkillMention( + text: string, +): { readonly name: string; readonly rest: string } | undefined { + const match = LEADING_SKILL_MENTION.exec(text); + if (match?.[2] === undefined) return undefined; + return { name: match[2], rest: text.slice(match[0].length) }; +} class GrokSkillsProbeError extends Schema.TaggedError()( "GrokSkillsProbeError",