diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index ce8a31b51985..2311bb35a407 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -4,7 +4,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills, hasGrokSkillMention, rewriteGrokSkillMentions } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); @@ -172,3 +172,32 @@ describe("discoverGrokSkills", () => { }); }); }); + +describe("rewriteGrokSkillMentions", () => { + it("rewrites only discovered skill mentions into Grok slash invocations", () => { + const names = new Set(["poteto-mode"]); + expect(hasGrokSkillMention("please $poteto-mode this")).toBe(true); + expect(rewriteGrokSkillMentions("please $poteto-mode this", names)).toBe( + "please /poteto-mode this", + ); + expect(rewriteGrokSkillMentions("use $poteto-mode, keep $HOME and 5$poteto-mode", names)).toBe( + "use $poteto-mode, keep $HOME and 5$poteto-mode", + ); + expect(rewriteGrokSkillMentions("please $review this", names)).toBe("please $review this"); + expect(hasGrokSkillMention("pay $20 tomorrow")).toBe(false); + expect(rewriteGrokSkillMentions("pay $20 tomorrow", new Set(["20"]))).toBe("pay $20 tomorrow"); + }); + + it("rewrites currency-prefixed skill mentions into Grok slash invocations", () => { + const names = new Set(["review", "2spec"]); + for (const symbol of ["€", "£", "¥"]) { + expect(hasGrokSkillMention(`please ${symbol}review this`)).toBe(true); + expect(rewriteGrokSkillMentions(`${symbol}review then ${symbol}2spec this`, names)).toBe( + "/review then /2spec this", + ); + const money = `${symbol}20 ${symbol}20k`; + expect(hasGrokSkillMention(money)).toBe(false); + expect(rewriteGrokSkillMentions(money, names)).toBe(money); + } + }); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index 36391e3d2008..905a015109fa 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -25,6 +25,9 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { spawnAndCollect } from "../providerSnapshot.ts"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; +const GROK_SKILL_MENTION_PATTERN = + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; +const HAS_GROK_SKILL_MENTION_PATTERN = new RegExp(GROK_SKILL_MENTION_PATTERN.source, "u"); class GrokSkillsProbeError extends Schema.TaggedError()( "GrokSkillsProbeError", @@ -149,3 +152,14 @@ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( } return skills; }); + +/** Grok invokes skills with `/name`; T3 composers insert `$name`. */ +export function hasGrokSkillMention(prompt: string): boolean { + return HAS_GROK_SKILL_MENTION_PATTERN.test(prompt); +} + +export function rewriteGrokSkillMentions(prompt: string, skillNames: ReadonlySet): string { + return prompt.replace(GROK_SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => + skillNames.has(name) ? `${prefix}/${name}` : match, + ); +} diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index ecd73af72dbe..943f65fe16d3 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -54,6 +54,52 @@ async function makeMockGrokWrapper(extraEnv?: Record) { }); } +async function makeMockGrokWrapperWithSkills(options: { + readonly skills: ReadonlyArray; + /** `inspect --json` fails until this flag file exists, then reports the skills. */ + readonly inspectOkPath: string; + /** When set, `inspect --json` writes this file, then waits for `inspectOkPath`. */ + readonly inspectHangPath?: string; + readonly extraEnv?: Record; +}) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-skills-")); + return writeFakeCli({ + directory: dir, + name: "fake-grok", + env: options.extraEnv ?? {}, + source: [ + 'import { readFileSync as readInspectFlag, writeFileSync } from "node:fs";', + 'import { pathToFileURL } from "node:url";', + "const args = process.argv.slice(2);", + 'if (args[0] === "inspect" && args[1] === "--json") {', + ...(options.inspectHangPath + ? [ + ` writeFileSync(${JSON.stringify(options.inspectHangPath)}, "started", "utf8");`, + " await new Promise((resolve) => {", + " const timer = setInterval(() => {", + " try {", + ` readInspectFlag(${JSON.stringify(options.inspectOkPath)}, "utf8");`, + " clearInterval(timer);", + " resolve(undefined);", + " } catch {}", + " }, 20);", + " });", + ] + : []), + " try {", + ` readInspectFlag(${JSON.stringify(options.inspectOkPath)}, "utf8");`, + " } catch {", + " process.exit(1);", + " }", + ` process.stdout.write(${JSON.stringify(JSON.stringify({ skills: options.skills }))});`, + " process.exit(0);", + "}", + `await import(pathToFileURL(${JSON.stringify(mockAgentPath)}).href);`, + "", + ].join("\n"), + }); +} + function waitForFileContent( filePath: string, attempts = 40, @@ -363,6 +409,158 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("sends skills in Grok's native form and retries discovery after failure", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-skill-dispatch"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-skill-dispatch-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const inspectOkPath = NodePath.join(tempDir, "inspect-ok"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapperWithSkills({ + skills: [ + { + name: "review", + description: "Review the change.", + source: { type: "user", path: "/mock/.grok/skills/review/SKILL.md" }, + userInvocable: true, + }, + { + name: "always-approve", + description: "Collides with the built-in.", + source: { type: "user", path: "/mock/.grok/skills/always-approve/SKILL.md" }, + userInvocable: true, + }, + ], + inspectOkPath, + extraEnv: { T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + // Discovery fails while the flag file is missing: the turn still + // succeeds and the mention stays literal. + yield* adapter.sendTurn({ threadId, input: "please $review this" }); + // Discovery succeeds now: the mention is rewritten and cached. + yield* Effect.promise(() => NodeFSP.writeFile(inspectOkPath, "ok", "utf8")); + yield* adapter.sendTurn({ threadId, input: "please $review this" }); + // A skill mention must not lower into the blocked built-in. + const blocked = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "$always-approve now" }), + ); + assert.include(blocked.message, "/always-approve"); + yield* adapter.stopSession(threadId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const prompts = requests + .filter((request) => request.method === "session/prompt") + .map( + (request) => (request.params as { prompt: Array<{ type: string; text: string }> }).prompt, + ); + assert.equal(prompts.length, 2); + assert.deepEqual(prompts[0]?.[0], { type: "text", text: "please $review this" }); + assert.deepEqual(prompts[1]?.[0], { type: "text", text: "please /review this" }); + }), + ); + + it.effect("lets Stop cancel a steered prompt while skill discovery is pending", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-stop-during-skill-discovery"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-stop-skill-discovery-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const inspectHangPath = NodePath.join(tempDir, "inspect-started"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapperWithSkills({ + skills: [], + inspectOkPath: NodePath.join(tempDir, "inspect-ok"), + inspectHangPath, + extraEnv: { + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hang until stopped" }) + .pipe(Effect.forkChild); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); + const steerFiber = yield* adapter + .sendTurn({ threadId, input: "$review this instead" }) + .pipe(Effect.forkChild); + yield* waitForFileContent(inspectHangPath, 80); + + // The probe never exits; Stop must not queue behind its 4s timeout. + yield* adapter.interruptTurn(threadId).pipe(Effect.timeout("1 second")); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/cancel"'); + + yield* Fiber.interrupt(steerFiber); + yield* Fiber.interrupt(firstSendTurnFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("does not start a turn when Stop lands during skill discovery", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-stop-before-skill-turn"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-stop-before-skill-turn-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const inspectOkPath = NodePath.join(tempDir, "inspect-ok"); + const inspectHangPath = NodePath.join(tempDir, "inspect-started"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapperWithSkills({ + skills: [ + { + name: "review", + description: "Review the change.", + source: { type: "user", path: "/mock/.grok/skills/review/SKILL.md" }, + userInvocable: true, + }, + ], + inspectOkPath, + inspectHangPath, + extraEnv: { T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendFiber = yield* adapter + .sendTurn({ threadId, input: "$review this" }) + .pipe(Effect.forkChild); + yield* waitForFileContent(inspectHangPath, 80); + yield* adapter.interruptTurn(threadId).pipe(Effect.timeout("1 second")); + yield* Effect.promise(() => NodeFSP.writeFile(inspectOkPath, "ok", "utf8")); + yield* Fiber.await(sendFiber).pipe(Effect.timeout("3 seconds")); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(requests.some((request) => request.method === "session/prompt")); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + 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 bec0fc39faa0..d91305e7955a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -83,6 +83,11 @@ import { XAiAskUserQuestionRequest, XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; +import { + discoverGrokSkills, + hasGrokSkillMention, + rewriteGrokSkillMentions, +} from "../Drivers/GrokSkills.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -90,6 +95,7 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri const PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; +const ALWAYS_APPROVE_COMMAND = /^\/always-approve(?:\s|$)/i; const NANOS_PER_MILLI = 1_000_000n; // ACP does not expose Grok's private `streaming_reasoning` phase. Once it has // emitted standard ACP progress, ten silent minutes is long enough to avoid @@ -152,6 +158,9 @@ interface GrokSessionContext { /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */ planModeActive: boolean; activeTurnId: TurnId | undefined; + grokSkillNames: ReadonlySet | undefined; + /** Bumped by every Stop, so a send that awaited outside the lock sees it. */ + stopRequests: number; /** Turns already interrupted; late prompt RPCs must not resurrect them. */ interruptedTurnIds: Set; /** Number of sendTurn prompts currently in flight or being prepared. @@ -1300,6 +1309,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte lastKnownProposedPlanTurnId: undefined, planModeActive: false, activeTurnId: undefined, + grokSkillNames: undefined, + stopRequests: 0, interruptedTurnIds: new Set(), promptsInFlight: 0, promptEpoch: 0, @@ -1521,7 +1532,37 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const sendTurn: GrokAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - if (/^\/always-approve(?:\s|$)/i.test(input.input?.trim() ?? "")) { + // Skill discovery can take seconds, so it runs outside the thread + // lock: interruptTurn needs that lock to cancel a steered prompt. + const rawText = input.input?.trim(); + const sessionCtx = sessions.get(input.threadId); + let grokSkillNames = sessionCtx?.grokSkillNames; + let stopsBeforeDiscovery: number | undefined; + if (sessionCtx && rawText && hasGrokSkillMention(rawText) && grokSkillNames === undefined) { + stopsBeforeDiscovery = sessionCtx.stopRequests; + const skills = yield* discoverGrokSkills( + grokSettings, + options?.environment ?? hostEnvironment, + sessionCtx.session.cwd, + ).pipe( + Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.orElseSucceed(() => undefined), + ); + // Cache only on success: a failed probe must not poison the + // session, so the next skill mention retries discovery. + if (skills !== undefined) { + grokSkillNames = new Set( + skills.filter((skill) => skill.enabled).map((skill) => skill.name), + ); + sessionCtx.grokSkillNames = grokSkillNames; + } + } + const text = + rawText && grokSkillNames ? rewriteGrokSkillMentions(rawText, grokSkillNames) : rawText; + // Checked after lowering so a skill named `always-approve` cannot + // reach the built-in either. + if (text && ALWAYS_APPROVE_COMMAND.test(text)) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", @@ -1547,6 +1588,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; + // A Stop during discovery had no turn to mark yet; a replaced + // session did not produce the catalog the rewrite used. + const sameSession = ctx === sessionCtx; + if ( + sameSession && + stopsBeforeDiscovery !== undefined && + ctx.stopRequests !== stopsBeforeDiscovery + ) { + ctx.interruptedTurnIds.add(turnId); + } + const turnText = sameSession ? text : rawText; // New turn: do not fall back to a previous turn's plan.md body when // exit_plan_mode omits planContent. if (steeringTurnId === undefined) { @@ -1572,7 +1624,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "reasoningEffort", ); - const text = input.input?.trim(); // Grok ingests images only. Generic files reach the agent // through the path line ProviderService puts in the prompt. const imagePromptParts = yield* Effect.forEach( @@ -1609,7 +1660,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); const promptParts: Array = [ - ...(text ? [{ type: "text" as const, text }] : []), + ...(turnText ? [{ type: "text" as const, text: turnText }] : []), ...imagePromptParts, ]; @@ -1641,7 +1692,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte : undefined; // ACP slash commands must receive only their own arguments. const runtimeInstructions = - text && /^\/[^\s/]+(?:\s|$)/.test(text) + turnText && /^\/[^\s/]+(?:\s|$)/.test(turnText) ? undefined : buildRuntimeInstructions({ harness: "Grok", @@ -2029,6 +2080,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return { _tag: "Ignore" as const }; } const interruptedTurnId = turnId ?? activeTurnId; + ctx.stopRequests += 1; if (interruptedTurnId !== undefined) { ctx.interruptedTurnIds.add(interruptedTurnId); }