From 98ce37d3395309f2e755cb468452a929f54223d0 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:25:12 +0200 Subject: [PATCH 1/6] fix(server): Grok rewrites $skill mentions into /skill invocations --- .../src/provider/Drivers/GrokSkills.test.ts | 31 ++++++++++++++++- .../server/src/provider/Drivers/GrokSkills.ts | 14 ++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 34 ++++++++++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) 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.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index bec0fc39faa0..a7202756af13 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"; @@ -152,6 +157,7 @@ interface GrokSessionContext { /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */ planModeActive: boolean; activeTurnId: TurnId | undefined; + grokSkillNames: ReadonlySet | undefined; /** Turns already interrupted; late prompt RPCs must not resurrect them. */ interruptedTurnIds: Set; /** Number of sendTurn prompts currently in flight or being prepared. @@ -1300,6 +1306,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte lastKnownProposedPlanTurnId: undefined, planModeActive: false, activeTurnId: undefined, + grokSkillNames: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, promptEpoch: 0, @@ -1572,7 +1579,32 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "reasoningEffort", ); - const text = input.input?.trim(); + const rawText = input.input?.trim(); + let grokSkillNames = ctx.grokSkillNames; + if (rawText && hasGrokSkillMention(rawText) && grokSkillNames === undefined) { + const skills = yield* discoverGrokSkills( + grokSettings, + options?.environment ?? hostEnvironment, + ctx.session.cwd, + ).pipe( + Effect.tapError((cause) => + Effect.logDebug("Grok skill discovery failed.", { cause }), + ), + Effect.orElseSucceed(() => []), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), + ); + grokSkillNames = new Set( + skills.filter((skill) => skill.enabled).map((skill) => skill.name), + ); + ctx.grokSkillNames = grokSkillNames; + } + const text = + rawText && grokSkillNames + ? rewriteGrokSkillMentions(rawText, grokSkillNames) + : rawText; // Grok ingests images only. Generic files reach the agent // through the path line ProviderService puts in the prompt. const imagePromptParts = yield* Effect.forEach( From 2ec622037c85b0cddf835ba791464458a1453346 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:40:24 +0200 Subject: [PATCH 2/6] fix(server): Grok retries skill discovery after failure, test sendTurn wiring --- .../src/provider/Layers/GrokAdapter.test.ts | 79 +++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 14 ++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index ecd73af72dbe..37caf4076dc4 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -54,6 +54,36 @@ 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; + 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 } from "node:fs";', + 'import { pathToFileURL } from "node:url";', + "const args = process.argv.slice(2);", + 'if (args[0] === "inspect" && args[1] === "--json") {', + " 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 +393,55 @@ 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, + }, + ], + 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" }); + 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("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 a7202756af13..82eac1ee662b 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -1590,16 +1590,20 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause }), ), - Effect.orElseSucceed(() => []), Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner, ), + Effect.orElseSucceed(() => undefined), ); - grokSkillNames = new Set( - skills.filter((skill) => skill.enabled).map((skill) => skill.name), - ); - ctx.grokSkillNames = grokSkillNames; + // 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), + ); + ctx.grokSkillNames = grokSkillNames; + } } const text = rawText && grokSkillNames From 7acbc7424b8bb4cc1e39934cee9161d470ca2c30 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:52:18 +0200 Subject: [PATCH 3/6] fix(server): Grok skill mentions cannot lower into /always-approve Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Layers/GrokAdapter.test.ts | 11 ++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 20 +++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 37caf4076dc4..9f6e150f43a9 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -410,6 +410,12 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 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 }, @@ -429,6 +435,11 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { // 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 diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 82eac1ee662b..e2247e2b7030 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -95,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 @@ -1526,14 +1527,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }).pipe(Effect.scoped), ); + const alwaysApproveRejected = () => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Change permissions with T3's permission selector instead of /always-approve.", + }); + const sendTurn: GrokAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - if (/^\/always-approve(?:\s|$)/i.test(input.input?.trim() ?? "")) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: "Change permissions with T3's permission selector instead of /always-approve.", - }); + if (ALWAYS_APPROVE_COMMAND.test(input.input?.trim() ?? "")) { + return yield* alwaysApproveRejected(); } const prepared = yield* withThreadLock( input.threadId, @@ -1609,6 +1613,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte rawText && grokSkillNames ? rewriteGrokSkillMentions(rawText, grokSkillNames) : rawText; + // A skill named `always-approve` would lower to the built-in. + if (text && ALWAYS_APPROVE_COMMAND.test(text)) { + return yield* alwaysApproveRejected(); + } // Grok ingests images only. Generic files reach the agent // through the path line ProviderService puts in the prompt. const imagePromptParts = yield* Effect.forEach( From 27259d21323e3dc2ce9c55f75bdd9ee3ce0d6e68 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:59:55 +0200 Subject: [PATCH 4/6] fix(server): Grok skill discovery no longer blocks Stop behind the thread lock Co-Authored-By: Claude Opus 5.5 --- .../server/src/provider/Layers/GrokAdapter.ts | 77 ++++++++----------- 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index e2247e2b7030..fe67377b6d1a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -1527,17 +1527,42 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }).pipe(Effect.scoped), ); - const alwaysApproveRejected = () => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: "Change permissions with T3's permission selector instead of /always-approve.", - }); - const sendTurn: GrokAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - if (ALWAYS_APPROVE_COMMAND.test(input.input?.trim() ?? "")) { - return yield* alwaysApproveRejected(); + // 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; + if (sessionCtx && rawText && hasGrokSkillMention(rawText) && grokSkillNames === undefined) { + 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", + detail: "Change permissions with T3's permission selector instead of /always-approve.", + }); } const prepared = yield* withThreadLock( input.threadId, @@ -1583,40 +1608,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "reasoningEffort", ); - const rawText = input.input?.trim(); - let grokSkillNames = ctx.grokSkillNames; - if (rawText && hasGrokSkillMention(rawText) && grokSkillNames === undefined) { - const skills = yield* discoverGrokSkills( - grokSettings, - options?.environment ?? hostEnvironment, - ctx.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), - ); - ctx.grokSkillNames = grokSkillNames; - } - } - const text = - rawText && grokSkillNames - ? rewriteGrokSkillMentions(rawText, grokSkillNames) - : rawText; - // A skill named `always-approve` would lower to the built-in. - if (text && ALWAYS_APPROVE_COMMAND.test(text)) { - return yield* alwaysApproveRejected(); - } // Grok ingests images only. Generic files reach the agent // through the path line ProviderService puts in the prompt. const imagePromptParts = yield* Effect.forEach( From 7b75cfd54ccce24b313af08dc8bb52f2b25ab0b1 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:07:42 +0200 Subject: [PATCH 5/6] test(server): Stop cancels a steered Grok prompt while skill discovery is pending Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Layers/GrokAdapter.test.ts | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 9f6e150f43a9..9fc00531c6e4 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -58,6 +58,8 @@ 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 and then never exits. */ + readonly inspectHangPath?: string; readonly extraEnv?: Record; }) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-skills-")); @@ -66,10 +68,19 @@ async function makeMockGrokWrapperWithSkills(options: { name: "fake-grok", env: options.extraEnv ?? {}, source: [ - 'import { readFileSync as readInspectFlag } from "node:fs";', + '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");`, + " setInterval(() => {}, 1_000);", + " await new Promise(() => {});", + "}", + 'if (args[0] === "inspect" && args[1] === "--json") {', + ] + : []), " try {", ` readInspectFlag(${JSON.stringify(options.inspectOkPath)}, "utf8");`, " } catch {", @@ -453,6 +464,52 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + 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("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); From e33c2f7474d03f0d6f33e8fdf5dfc1c50de8c43b Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:24:57 +0200 Subject: [PATCH 6/6] fix(server): Stop during Grok skill discovery cancels the pending turn Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Layers/GrokAdapter.test.ts | 61 +++++++++++++++++-- .../server/src/provider/Layers/GrokAdapter.ts | 21 ++++++- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 9fc00531c6e4..943f65fe16d3 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -58,7 +58,7 @@ 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 and then never exits. */ + /** When set, `inspect --json` writes this file, then waits for `inspectOkPath`. */ readonly inspectHangPath?: string; readonly extraEnv?: Record; }) { @@ -75,10 +75,15 @@ async function makeMockGrokWrapperWithSkills(options: { ...(options.inspectHangPath ? [ ` writeFileSync(${JSON.stringify(options.inspectHangPath)}, "started", "utf8");`, - " setInterval(() => {}, 1_000);", - " await new Promise(() => {});", - "}", - 'if (args[0] === "inspect" && args[1] === "--json") {', + " await new Promise((resolve) => {", + " const timer = setInterval(() => {", + " try {", + ` readInspectFlag(${JSON.stringify(options.inspectOkPath)}, "utf8");`, + " clearInterval(timer);", + " resolve(undefined);", + " } catch {}", + " }, 20);", + " });", ] : []), " try {", @@ -510,6 +515,52 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }).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 fe67377b6d1a..d91305e7955a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -159,6 +159,8 @@ interface GrokSessionContext { 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. @@ -1308,6 +1310,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte planModeActive: false, activeTurnId: undefined, grokSkillNames: undefined, + stopRequests: 0, interruptedTurnIds: new Set(), promptsInFlight: 0, promptEpoch: 0, @@ -1534,7 +1537,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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, @@ -1583,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) { @@ -1644,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, ]; @@ -1676,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", @@ -2064,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); }