From 719c573b4e5e4c2f361b475dbb74ccf1e06cf95f Mon Sep 17 00:00:00 2001 From: Alin <41188223+al0x99@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:57:32 +0200 Subject: [PATCH] fix(cursor): preserve native slash command invocation --- .../provider/Drivers/CursorCommands.test.ts | 208 ++++++++++++++++++ .../src/provider/Drivers/CursorCommands.ts | 90 ++++++++ .../src/provider/Drivers/CursorDriver.ts | 68 ++++-- .../src/provider/Layers/CursorAdapter.ts | 26 ++- 4 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/provider/Drivers/CursorCommands.test.ts create mode 100644 apps/server/src/provider/Drivers/CursorCommands.ts diff --git a/apps/server/src/provider/Drivers/CursorCommands.test.ts b/apps/server/src/provider/Drivers/CursorCommands.test.ts new file mode 100644 index 000000000000..59464dbf3605 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorCommands.test.ts @@ -0,0 +1,208 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { CursorDriver } from "./CursorDriver.ts"; + +const cliSource = ` +import { createInterface } from "node:readline"; +if (!process.argv.includes("acp")) { + console.log(JSON.stringify({ cliVersion: "2026.09.02-c22c1a3", userEmail: "test@example.com" })); + process.exit(0); +} +const send = (value) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...value }) + "\\n"); +const commands = (availableCommands) => send({ method: "session/update", params: { + sessionId: "mock-session", update: { sessionUpdate: "available_commands_update", availableCommands } +}}); +for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + let result = {}; + if (request.method === "initialize") result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new") { + if (!process.env.T3_DELAY_COMMANDS) commands([{ name: "goal", description: "Pursue a goal", input: { hint: "objective" } }, + { name: "compact", description: "Native compact" }]); + result = { sessionId: "mock-session" }; + } + if (request.method === "session/prompt") { + if (request.params.prompt[0]?.text === "clear commands") commands([]); + const text = request.params.prompt.filter((part) => part.type === "text").map((part) => part.text).join("\\n"); + send({ method: "session/update", params: { sessionId: "mock-session", update: { + sessionUpdate: "agent_message_chunk", content: { type: "text", text } + }}}); + result = { stopReason: "end_turn" }; + } + if (request.method === "cursor/list_available_models") result = { models: [] }; + if (request.method === "session/set_config_option") result = { configOptions: [] }; + if (request.id !== undefined) send({ id: request.id, result }); +} +`; + +const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "cursor-commands-" }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Command discovery must not make HTTP requests")), + ), + ), +); + +const makeHarness = Effect.fn("makeCursorCommandsHarness")(function* (id: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settings = yield* ServerSettingsService; + yield* settings.updateSettings({ enableProviderUpdateChecks: false }); + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "cursor-command-catalog-" }); + const binaryPath = yield* Effect.sync(() => + writeFakeCli({ + directory, + name: "cursor-agent", + source: cliSource, + env: id === "cursor-delayed-commands" ? { T3_DELAY_COMMANDS: "1" } : {}, + }), + ); + const cwd = path.join(directory, "workspace"); + const skillRoot = path.join(cwd, ".agents", "skills", "local-skill"); + yield* fs.makeDirectory(skillRoot, { recursive: true }); + yield* fs.writeFileString( + path.join(skillRoot, "SKILL.md"), + "---\nname: local-skill\ndescription: Local skill\n---\nDo work.", + ); + const instance = yield* CursorDriver.create({ + instanceId: ProviderInstanceId.make(id), + displayName: undefined, + enabled: true, + environment: [{ name: "HOME", value: directory, sensitive: false }], + config: { ...CursorDriver.defaultConfig(), binaryPath }, + }); + const threadId = ThreadId.make(id); + yield* instance.snapshot.refresh; + const start = instance.adapter.startSession({ + threadId, + cwd, + provider: ProviderDriverKind.make("cursor"), + runtimeMode: "full-access", + }); + const prompt = (input: string) => instance.adapter.sendTurn({ threadId, input }); + return { instance, cwd, start, prompt }; +}); + +it.layer(testLayer)("Cursor command discovery", (it) => { + it.effect("keeps advertised slash commands intact for Cursor's command parser", () => + Effect.gen(function* () { + const { instance, start, prompt } = yield* makeHarness("cursor-command-input"); + yield* start; + const events = yield* instance.adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* prompt("/goal check invocation"); + const reply = (yield* Fiber.join(events)).find((event) => event.type === "content.delta"); + expect(reply?.payload.delta).toBe("/goal check invocation"); + }).pipe(Effect.scoped), + ); + it.effect("preserves a leading command before Cursor publishes its catalog", () => + Effect.gen(function* () { + const { instance, start, prompt } = yield* makeHarness("cursor-delayed-commands"); + yield* start; + const events = yield* instance.adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* prompt("/goal first turn before command metadata"); + const reply = (yield* Fiber.join(events)).find((event) => event.type === "content.delta"); + expect(reply?.payload.delta).toBe("/goal first turn before command metadata"); + }).pipe(Effect.scoped), + ); + it.effect("publishes ACP commands to the workspace catalog without losing local skills", () => + Effect.gen(function* () { + const { instance, cwd, start, prompt } = yield* makeHarness("cursor-commands"); + const updates = yield* instance.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.workspaceSnapshots?.some( + (workspace) => + workspace.cwd === cwd && + workspace.slashCommands.some((command) => command.name === "goal"), + ) ?? false, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* start; + yield* prompt("hello"); + const workspace = (yield* instance.snapshot.getSnapshot).workspaceSnapshots?.find( + (item) => item.cwd === cwd, + ); + expect(workspace?.slashCommands ?? []).toContainEqual({ + name: "goal", + description: "Pursue a goal", + input: { hint: "objective" }, + }); + expect(workspace?.skills.map((skill) => skill.name)).toContain("local-skill"); + expect(workspace?.slashCommands.filter((command) => command.name === "compact")).toHaveLength( + 1, + ); + expect((yield* instance.snapshotForCwd!(cwd)).slashCommands).toEqual( + workspace?.slashCommands, + ); + expect(yield* Fiber.join(updates)).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect("replaces native commands and isolates them from other workspaces and instances", () => + Effect.gen(function* () { + const first = yield* makeHarness("cursor-first"); + const second = yield* makeHarness("cursor-second"); + yield* first.instance.snapshotForCwd!(second.cwd); + yield* first.start; + yield* first.prompt("hello"); + expect( + (yield* first.instance.snapshot.getSnapshot).workspaceSnapshots + ?.find((workspace) => workspace.cwd === second.cwd) + ?.skills.map((skill) => skill.name), + ).toContain("local-skill"); + expect( + (yield* first.instance.snapshotForCwd!(first.cwd)).slashCommands.some( + (command) => command.name === "goal", + ), + ).toBe(true); + expect( + (yield* first.instance.snapshotForCwd!(second.cwd)).slashCommands.some( + (command) => command.name === "goal", + ), + ).toBe(false); + expect( + (yield* second.instance.snapshotForCwd!(first.cwd)).slashCommands.some( + (command) => command.name === "goal", + ), + ).toBe(false); + yield* first.prompt("clear commands"); + const cleared = yield* first.instance.snapshotForCwd!(first.cwd); + expect(cleared.slashCommands.map((command) => command.name)).toEqual(["compact"]); + expect(cleared.skills.map((skill) => skill.name)).toContain("local-skill"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Drivers/CursorCommands.ts b/apps/server/src/provider/Drivers/CursorCommands.ts new file mode 100644 index 000000000000..1c7e66f8dbff --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorCommands.ts @@ -0,0 +1,90 @@ +import type { ServerProvider, ServerProviderSlashCommand } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as AcpSchema from "effect-acp/schema"; + +import { discoverCursorSkills } from "./CursorSkills.ts"; + +type WorkspaceSnapshot = NonNullable[number]; +const MAX_WORKSPACE_SNAPSHOTS = 16; + +export const makeCursorCommandCatalog = Effect.fn("makeCursorCommandCatalog")(function* ( + environment: NodeJS.ProcessEnv, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaces = yield* SubscriptionRef.make>([]); + + const withCommands = Effect.fn("CursorCommandCatalog.withCommands")(function* ( + snapshot: ServerProvider, + ) { + const current = yield* SubscriptionRef.get(workspaces); + if (current.length === 0) return snapshot; + return { + ...snapshot, + workspaceSnapshots: current.map((workspace) => ({ + ...workspace, + slashCommands: [ + ...snapshot.slashCommands, + ...workspace.slashCommands.filter( + (command) => !snapshot.slashCommands.some((builtin) => builtin.name === command.name), + ), + ], + })), + } satisfies ServerProvider; + }); + + const onAvailableCommands = Effect.fn("CursorCommandCatalog.onAvailableCommands")(function* ( + cwd: string, + commands: ReadonlyArray, + ) { + const seen = new Set(); + const slashCommands = commands.flatMap((command): ServerProviderSlashCommand[] => { + const name = command.name.trim(); + if (!name || seen.has(name)) return []; + seen.add(name); + const description = command.description.trim(); + const hint = command.input?.hint.trim(); + return [ + { name, ...(description ? { description } : {}), ...(hint ? { input: { hint } } : {}) }, + ]; + }); + const skills = yield* discoverCursorSkills(cwd, environment).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(workspaces, (current) => + [ + ...current.filter((workspace) => workspace.cwd !== cwd), + { cwd, checkedAt, slashCommands, skills }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + ); + }); + + return { + onAvailableCommands, + recordSkills: Effect.fn("CursorCommandCatalog.recordSkills")(function* ( + cwd: string, + skills: ServerProvider["skills"], + ) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(workspaces, (current) => + [ + ...current.filter((workspace) => workspace.cwd !== cwd), + { + cwd, + checkedAt, + skills, + slashCommands: current.find((workspace) => workspace.cwd === cwd)?.slashCommands ?? [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + ); + }), + withCommands, + streamChanges: SubscriptionRef.changes(workspaces), + }; +}); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 5466af802e50..9d5db4fa443c 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -17,6 +17,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -54,6 +55,7 @@ import { type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; import { probeCursorSkills } from "./CursorSkills.ts"; +import { makeCursorCommandCatalog } from "./CursorCommands.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -130,11 +132,6 @@ export const CursorDriver: ProviderDriver = { ), ); - const adapter = yield* makeCursorAdapter(effectiveConfig, { - environment: processEnv, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - instanceId, - }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); @@ -151,7 +148,9 @@ export const CursorDriver: ProviderDriver = { ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); - const snapshot = yield* makeManagedServerProvider>({ + const baseSnapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, @@ -188,6 +187,23 @@ export const CursorDriver: ProviderDriver = { ), ); + const commands = yield* makeCursorCommandCatalog(processEnv); + const snapshot = { + ...baseSnapshot, + getSnapshot: baseSnapshot.getSnapshot.pipe(Effect.flatMap(commands.withCommands)), + refresh: baseSnapshot.refresh.pipe(Effect.flatMap(commands.withCommands)), + streamChanges: Stream.merge( + baseSnapshot.streamChanges, + commands.streamChanges.pipe(Stream.mapEffect(() => baseSnapshot.getSnapshot)), + ).pipe(Stream.mapEffect(commands.withCommands)), + }; + const adapter = yield* makeCursorAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + onAvailableCommands: commands.onAvailableCommands, + }); + return { instanceId, driverKind: DRIVER_KIND, @@ -199,22 +215,32 @@ export const CursorDriver: ProviderDriver = { snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - probeCursorSkills(cwd, processEnv).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to discover Cursor skills for '${cwd}'`, - cause, - }), - ), + : probeCursorSkills(cwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Cursor skills for '${cwd}'`, + cause, + }), + ), + Effect.flatMap((skills) => + Effect.gen(function* () { + yield* commands.recordSkills(cwd, skills); + const current = yield* snapshot.getSnapshot; + return { + ...current, + slashCommands: + current.workspaceSnapshots?.find((workspace) => workspace.cwd === cwd) + ?.slashCommands ?? current.slashCommands, + skills, + }; + }), ), - ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 1a77f964aac5..bae2c3ac7ac3 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -101,6 +101,10 @@ export interface CursorAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly onAvailableCommands?: ( + cwd: string, + commands: ReadonlyArray, + ) => Effect.Effect; /** * Selections are honored when `modelSelection.instanceId` matches this value. * Defaults to the legacy built-in instance id (`cursor`). @@ -798,6 +802,11 @@ export function makeCursorAdapter( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { switch (event._tag) { + case "AvailableCommandsUpdated": + yield* ( + options?.onAvailableCommands?.(cwd, event.availableCommands) ?? Effect.void + ); + return; case "EventStreamBarrier": yield* Deferred.succeed(event.acknowledge, undefined); return; @@ -983,6 +992,7 @@ export function makeCursorAdapter( const promptParts: Array = []; const rawPrompt = input.input?.trim() ?? ""; + let nativeSlashCommand = false; if (rawPrompt) { let cursorSkillNames = ctx.cursorSkillNames; if (hasCursorSkillMention(rawPrompt) && cursorSkillNames === undefined) { @@ -1003,6 +1013,7 @@ export function makeCursorAdapter( const prompt = cursorSkillNames ? rewriteCursorSkillMentions(rawPrompt, cursorSkillNames) : rawPrompt; + nativeSlashCommand = /^\/\S/u.test(prompt); promptParts.push({ type: "text", text: prompt }); } if (input.attachments && input.attachments.length > 0) { @@ -1050,15 +1061,20 @@ export function makeCursorAdapter( }); } - // ACP has no system-message field; keep runtime context separate from the user's text. + // Cursor joins text blocks before parsing slash commands. Extra context + // would become command arguments or prevent native command matching. const result = yield* ctx.acp .prompt({ prompt: [ ...promptParts, - { - type: "text", - text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), - }, + ...(nativeSlashCommand + ? [] + : [ + { + type: "text", + text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), + } satisfies EffectAcpSchema.ContentBlock, + ]), ], }) .pipe(