diff --git a/apps/web/src/__tests__/slash-command-node.test.ts b/apps/web/src/__tests__/slash-command-node.test.ts index 711b51b3d..0eb5155d2 100644 --- a/apps/web/src/__tests__/slash-command-node.test.ts +++ b/apps/web/src/__tests__/slash-command-node.test.ts @@ -7,6 +7,7 @@ import { $isSlashCommandNode, } from "@/components/chat/lexical/SlashCommandNode"; import { extractComposerMessage } from "@/components/chat/lexical/cursor-utils"; +import { writeComposerContent } from "@/features/conversation/composer/draft/composer-editor-content"; function createTestEditor() { return createEditor({ @@ -138,4 +139,62 @@ describe("SlashCommandNode", () => { }], }); }); + + it("serializes a stored command path into the extracted mention", () => { + const editor = createTestEditor(); + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createSlashCommandNode("deploy", "skill", { + providerId: "cursor", + kind: "skill", + nativeId: "deploy", + }, "C:/repo/.cursor/skills/deploy/SKILL.md")); + $getRoot().append(paragraph); + }, + { discrete: true }, + ); + + expect(extractComposerMessage(editor)).toEqual({ + text: "/deploy", + mentions: [{ + id: "command:skill:deploy", + kind: "command", + label: "deploy", + namespace: "skill", + capabilityIdentity: { + providerId: "cursor", + kind: "skill", + nativeId: "deploy", + }, + path: "C:/repo/.cursor/skills/deploy/SKILL.md", + range: { start: 0, end: 7 }, + }], + }); + }); + + it("round-trips a command mention path through writeComposerContent", async () => { + const editor = createTestEditor(); + writeComposerContent(editor, "/deploy now", [{ + id: "command:skill:deploy", + kind: "command", + label: "deploy", + namespace: "skill", + path: "C:/repo/.cursor/skills/deploy/SKILL.md", + range: { start: 0, end: 7 }, + }]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(extractComposerMessage(editor)).toEqual({ + text: "/deploy now", + mentions: [{ + id: "command:skill:deploy", + kind: "command", + label: "deploy", + namespace: "skill", + path: "C:/repo/.cursor/skills/deploy/SKILL.md", + range: { start: 0, end: 7 }, + }], + }); + }); }); diff --git a/apps/web/src/components/chat/lexical/SlashCommandNode.tsx b/apps/web/src/components/chat/lexical/SlashCommandNode.tsx index 15c9effe6..7d099eb79 100644 --- a/apps/web/src/components/chat/lexical/SlashCommandNode.tsx +++ b/apps/web/src/components/chat/lexical/SlashCommandNode.tsx @@ -33,6 +33,7 @@ export interface SerializedSlashCommandNode extends SerializedLexicalNode { readonly commandName: string; readonly namespace: SlashCommandNamespace; readonly capabilityIdentity?: ProviderCapabilityIdentity; + readonly path?: string; } /** Valid namespace values for deserialisation fallback. */ @@ -67,6 +68,7 @@ export class SlashCommandNode extends DecoratorNode { __commandName: string; __namespace: SlashCommandNamespace; __capabilityIdentity?: ProviderCapabilityIdentity; + __path?: string; static getType(): string { return "slash-command"; @@ -77,6 +79,7 @@ export class SlashCommandNode extends DecoratorNode { node.__commandName, node.__namespace, node.__capabilityIdentity, + node.__path, node.__key, ); } @@ -85,12 +88,14 @@ export class SlashCommandNode extends DecoratorNode { commandName: string, namespace: SlashCommandNamespace, capabilityIdentity?: ProviderCapabilityIdentity, + path?: string, key?: NodeKey, ) { super(key); this.__commandName = commandName; this.__namespace = namespace; this.__capabilityIdentity = capabilityIdentity; + this.__path = path; } // -- Accessors ------------------------------------------------------------ @@ -107,6 +112,10 @@ export class SlashCommandNode extends DecoratorNode { return this.getLatest().__capabilityIdentity; } + getPath(): string | undefined { + return this.getLatest().__path; + } + // -- Behavior ------------------------------------------------------------- isInline(): boolean { @@ -137,6 +146,7 @@ export class SlashCommandNode extends DecoratorNode { commandName: this.__commandName, namespace: this.__namespace, ...(this.__capabilityIdentity ? { capabilityIdentity: this.__capabilityIdentity } : {}), + ...(this.__path ? { path: this.__path } : {}), version: 1, }; } @@ -154,6 +164,7 @@ export class SlashCommandNode extends DecoratorNode { serializedNode.commandName, ns, identity.success ? identity.data : undefined, + serializedNode.path, ); } @@ -178,8 +189,9 @@ export function $createSlashCommandNode( commandName: string, namespace: SlashCommandNamespace, capabilityIdentity?: ProviderCapabilityIdentity, + path?: string, ): SlashCommandNode { - return new SlashCommandNode(commandName, namespace, capabilityIdentity); + return new SlashCommandNode(commandName, namespace, capabilityIdentity, path); } /** Type guard: returns true when the node is a SlashCommandNode. */ diff --git a/apps/web/src/components/chat/lexical/SlashCommandPlugin.tsx b/apps/web/src/components/chat/lexical/SlashCommandPlugin.tsx index a74a15abb..58b5034cb 100644 --- a/apps/web/src/components/chat/lexical/SlashCommandPlugin.tsx +++ b/apps/web/src/components/chat/lexical/SlashCommandPlugin.tsx @@ -147,10 +147,11 @@ export function insertSlashCommandNode( commandName: string, namespace: SlashCommandNamespace, capabilityIdentity?: ProviderCapabilityIdentity, + path?: string, ): void { replaceActiveSlashTrigger( editor, - () => $createSlashCommandNode(commandName, namespace, capabilityIdentity), + () => $createSlashCommandNode(commandName, namespace, capabilityIdentity, path), ); } diff --git a/apps/web/src/components/chat/lexical/cursor-utils.ts b/apps/web/src/components/chat/lexical/cursor-utils.ts index 32faf6b51..2d00b4aef 100644 --- a/apps/web/src/components/chat/lexical/cursor-utils.ts +++ b/apps/web/src/components/chat/lexical/cursor-utils.ts @@ -45,6 +45,7 @@ export function extractComposerMessage(editor: LexicalEditor): ExtractedComposer ...(child.getCapabilityIdentity() ? { capabilityIdentity: child.getCapabilityIdentity() } : {}), + ...(child.getPath() ? { path: child.getPath() } : {}), range: { start, end: text.length }, }); } else { diff --git a/apps/web/src/components/chat/useSlashCommand.ts b/apps/web/src/components/chat/useSlashCommand.ts index ff33f0cad..036f9641b 100644 --- a/apps/web/src/components/chat/useSlashCommand.ts +++ b/apps/web/src/components/chat/useSlashCommand.ts @@ -30,6 +30,8 @@ export interface Command { capabilityKind: ProviderCapabilityKind | "mcode"; nativeId: string; mentionPath?: string; + /** Filesystem path of the backing skill/command file, when the catalog knows it. */ + path?: string; identity?: ProviderCapabilityIdentity; /** Discovery scope of the backing skill entry; used to tag project-local duplicates. */ source?: SkillSource; @@ -106,6 +108,7 @@ function toCommand(entry: ProviderCapabilityEntry): Command | null { return { ...base, namespace: "command", + path: entry.path, }; } // Compat-prefixed names like `claude:prototype` are ordinary skills, not @@ -114,6 +117,7 @@ function toCommand(entry: ProviderCapabilityEntry): Command | null { ...base, source: entry.source, namespace: entry.source === "plugin" ? "plugin" : "skill", + path: entry.path, }; } diff --git a/apps/web/src/features/conversation/composer/Composer.tsx b/apps/web/src/features/conversation/composer/Composer.tsx index 18c07aa18..7fb6e5e75 100644 --- a/apps/web/src/features/conversation/composer/Composer.tsx +++ b/apps/web/src/features/conversation/composer/Composer.tsx @@ -598,7 +598,7 @@ export function Composer({ if (cmd.action) { removeSlashCommandTrigger(editorRef.current); } else if (!insertSelectedPluginMention(editorRef.current, cmd)) { - insertSlashCommandNode(editorRef.current, cmd.name, cmd.namespace, cmd.identity); + insertSlashCommandNode(editorRef.current, cmd.name, cmd.namespace, cmd.identity, cmd.path); } } }, [editorRef, slashCommand]); diff --git a/apps/web/src/features/conversation/composer/draft/composer-editor-content.ts b/apps/web/src/features/conversation/composer/draft/composer-editor-content.ts index 0e47b3fe0..2936d01e8 100644 --- a/apps/web/src/features/conversation/composer/draft/composer-editor-content.ts +++ b/apps/web/src/features/conversation/composer/draft/composer-editor-content.ts @@ -55,6 +55,7 @@ export function writeComposerContent( mention.label, mention.namespace, mention.capabilityIdentity, + mention.path, ), ); cursor = mention.range.end; diff --git a/apps/web/src/features/conversation/messages/selection/comment-editor-primitives.tsx b/apps/web/src/features/conversation/messages/selection/comment-editor-primitives.tsx index 4d844d54e..e7380a6b9 100644 --- a/apps/web/src/features/conversation/messages/selection/comment-editor-primitives.tsx +++ b/apps/web/src/features/conversation/messages/selection/comment-editor-primitives.tsx @@ -183,7 +183,7 @@ export function CommentEditorComposer({ if (!editor) return; slashCommand.onSelect(command, () => {}); if (!insertSelectedPluginMention(editor, command)) { - insertSlashCommandNode(editor, command.name, command.namespace, command.identity); + insertSlashCommandNode(editor, command.name, command.namespace, command.identity, command.path); } }, [editorRef, slashCommand]); diff --git a/packages/contracts/src/models/__tests__/mention.test.ts b/packages/contracts/src/models/__tests__/mention.test.ts index 829d4a042..ff9dbdc57 100644 --- a/packages/contracts/src/models/__tests__/mention.test.ts +++ b/packages/contracts/src/models/__tests__/mention.test.ts @@ -22,6 +22,35 @@ describe("MessageMentionSchema", () => { }); }); + it("preserves a command mention path for file-linked invocations", () => { + const result = MessageMentionSchema().safeParse({ + id: "command:skill:deploy", + kind: "command", + label: "deploy", + namespace: "skill", + path: "C:/repo/.agents/skills/deploy/SKILL.md", + range: { start: 0, end: 7 }, + }); + + expect(result.success).toBe(true); + expect(result.data).toMatchObject({ + path: "C:/repo/.agents/skills/deploy/SKILL.md", + }); + }); + + it("rejects command mention paths with control characters", () => { + const result = MessageMentionSchema().safeParse({ + id: "command:skill:deploy", + kind: "command", + label: "deploy", + namespace: "skill", + path: "C:/repo/.agents/skills/\ndeploy/SKILL.md", + range: { start: 0, end: 7 }, + }); + + expect(result.success).toBe(false); + }); + it("rejects unknown command namespaces", () => { const result = MessageMentionSchema().safeParse({ id: "command:unknown:deploy", diff --git a/packages/contracts/src/models/mention.ts b/packages/contracts/src/models/mention.ts index 23686af9b..59c8dc1e3 100644 --- a/packages/contracts/src/models/mention.ts +++ b/packages/contracts/src/models/mention.ts @@ -60,6 +60,7 @@ export const MessageMentionSchema = lazySchema(() => z.discriminatedUnion("kind" kind: z.literal("command"), namespace: z.enum(["skill", "mcode", "plugin", "command"]), capabilityIdentity: ProviderCapabilityIdentitySchema().optional(), + path: MentionPathSchema.optional(), }), ])); diff --git a/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts b/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts index d029576a2..639909dde 100644 --- a/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts +++ b/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts @@ -1617,6 +1617,45 @@ describe("CodexProvider first turn on new session", () => { expect(sendTurnMock.mock.calls[0][0]).toEqual([{ type: "text", text: "/goal clear" }]); }); + it("links a selected slash command to its backing file when no native channel matches", async () => { + const provider = makeProvider(); + + await provider.sendTurn({ + turnId: "test-turn", + turnExecutionId: "test-execution", + sessionId: "mcode-command-link", + workspaceId: "workspace-test", + threadId: "command-link", + message: "/deploy staging", + mentions: [{ + id: "command:skill:deploy", + kind: "command", + label: "deploy", + namespace: "skill", + capabilityIdentity: { + providerId: "codex", + kind: "skill", + nativeId: "deploy", + }, + path: "C:\\skills\\deploy\\SKILL.md", + range: { start: 0, end: 7 }, + }], + cwd: process.cwd(), + model: "gpt-5.4", + interactionMode: "build", + providerOptions: {}, + permissionMode: "auto", + }); + + for (let i = 0; i < 20 && sendTurnMock.mock.calls.length === 0; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(sendTurnMock.mock.calls[0][0]).toEqual([ + { type: "text", text: "[/deploy](C:\\skills\\deploy\\SKILL.md) staging" }, + ]); + }); + it("runs side-channel handoff turns at low effort", async () => { const provider = makeProvider(); diff --git a/packages/providers/src/private/codex/codex-input-mapper.ts b/packages/providers/src/private/codex/codex-input-mapper.ts index d3f606b8c..51d0618b3 100644 --- a/packages/providers/src/private/codex/codex-input-mapper.ts +++ b/packages/providers/src/private/codex/codex-input-mapper.ts @@ -186,20 +186,26 @@ async function resolveCodexSlashInvocation( ): Promise<{ text: string; skillItem?: TurnInputPart }> { const slash = parseCodexSlashInvocation(message); if (!slash) return { text: message }; - const selectedIdentity = selectedCommandIdentity(message, slash.requestedName, mentions); + const selectedMention = selectedCommandMention(message, slash.requestedName, mentions); + const selectedIdentity = selectedMention?.capabilityIdentity; const candidates = matchingSkillCandidates(skills, slash.requestedName); const selected = selectedIdentity ? candidates.find((item) => matchesCodexCapabilityIdentity(item, selectedIdentity)) : undefined; const promptCommand = selectPromptCommand(candidates, selected, selectedIdentity, slash.requestedName); if (promptCommand) return { text: await expandCodexPromptCommand(promptCommand, slash.args) }; const skill = selectSkill(candidates, selected, selectedIdentity); - return skill ? codexSkillInvocation(skill, slash.args) : { text: message }; + if (skill) return codexSkillInvocation(skill, slash.args); + // Neither native channel matched: link the backing file so the agent can read it. + if (selectedMention?.path) { + return { text: `[/${slash.requestedName}](${selectedMention.path})${slash.args ? ` ${slash.args}` : ""}` }; + } + return { text: message }; } -function selectedCommandIdentity( +function selectedCommandMention( message: string, requestedName: string, mentions: readonly MessageMention[], -): ProviderCapabilityIdentity | undefined { +): Extract | undefined { const leadingSpace = message.length - message.trimStart().length; const commandEnd = leadingSpace + requestedName.length + 1; return mentions.find((mention): mention is Extract => ( @@ -208,7 +214,7 @@ function selectedCommandIdentity( && mention.range.start === leadingSpace && mention.range.end === commandEnd && mention.capabilityIdentity?.providerId === "codex" - ))?.capabilityIdentity; + )); } function matchingSkillCandidates(skills: readonly SkillInfo[], requestedName: string): SkillInfo[] { diff --git a/packages/providers/src/private/cursor/cursor-provider.ts b/packages/providers/src/private/cursor/cursor-provider.ts index 24bfbea7b..bc7765edb 100644 --- a/packages/providers/src/private/cursor/cursor-provider.ts +++ b/packages/providers/src/private/cursor/cursor-provider.ts @@ -744,6 +744,7 @@ export class CursorProvider model: context.req.model, resume: context.resume, attachments: context.req.attachments, + mentions: context.req.mentions, turnId: context.req.turnId, turnExecutionId: context.req.turnExecutionId, deliveryAttempt: context.routing.deliveryAttempt, diff --git a/packages/providers/src/private/cursor/instructions/__tests__/cursor-prompt.test.ts b/packages/providers/src/private/cursor/instructions/__tests__/cursor-prompt.test.ts index a83dc0fc1..7dbc794d0 100644 --- a/packages/providers/src/private/cursor/instructions/__tests__/cursor-prompt.test.ts +++ b/packages/providers/src/private/cursor/instructions/__tests__/cursor-prompt.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { buildCursorPrompt, readCursorUserInstructions } from "../cursor-prompt.js"; +import { + buildCursorPrompt, + readCursorUserInstructions, + rewriteCursorCommandMentionsAsLinks, +} from "../cursor-prompt.js"; describe("cursor-prompt", () => { describe("readCursorUserInstructions", () => { @@ -70,4 +74,47 @@ describe("cursor-prompt", () => { expect(prompt.endsWith("\n\ndo work")).toBe(true); }); }); + + describe("rewriteCursorCommandMentionsAsLinks", () => { + const commandMention = (overrides: Record) => ({ + id: "command:skill:deploy", + kind: "command" as const, + label: "deploy", + namespace: "skill" as const, + range: { start: 0, end: 7 }, + ...overrides, + }); + + it("links a command mention to its backing file", () => { + expect( + rewriteCursorCommandMentionsAsLinks("/deploy now", [ + commandMention({ path: "/x/SKILL.md" }), + ]), + ).toBe("[/deploy](/x/SKILL.md) now"); + }); + + it("rewrites multiple command mentions without shifting earlier ranges", () => { + const message = "/build /deploy now"; + expect( + rewriteCursorCommandMentionsAsLinks(message, [ + commandMention({ label: "build", path: "/x/build/SKILL.md", range: { start: 0, end: 6 } }), + commandMention({ path: "/x/deploy/SKILL.md", range: { start: 7, end: 14 } }), + ]), + ).toBe("[/build](/x/build/SKILL.md) [/deploy](/x/deploy/SKILL.md) now"); + }); + + it("leaves command mentions without a path verbatim", () => { + expect( + rewriteCursorCommandMentionsAsLinks("/deploy now", [commandMention({})]), + ).toBe("/deploy now"); + }); + + it("leaves a stale range verbatim when the text no longer matches", () => { + expect( + rewriteCursorCommandMentionsAsLinks("/deploy now", [ + commandMention({ path: "/x/SKILL.md", range: { start: 1, end: 8 } }), + ]), + ).toBe("/deploy now"); + }); + }); }); diff --git a/packages/providers/src/private/cursor/instructions/cursor-prompt.ts b/packages/providers/src/private/cursor/instructions/cursor-prompt.ts index a252213d5..f14f442d5 100644 --- a/packages/providers/src/private/cursor/instructions/cursor-prompt.ts +++ b/packages/providers/src/private/cursor/instructions/cursor-prompt.ts @@ -8,7 +8,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import type { AttachmentMeta } from "@mcode/contracts"; +import type { AttachmentMeta, MessageMention } from "@mcode/contracts"; import { isVirtualBrowserContextAttachment } from "@mcode/contracts"; /** @@ -59,3 +59,28 @@ export function buildCursorPrompt( lines.push(message); return lines.join("\n\n"); } + +/** + * Rewrites selected slash-command mentions as `[/name](path)` markdown links so + * Cursor, which has no native skill-invocation channel, receives the backing + * file the command refers to. Mentions whose stored range no longer matches the + * `/label` text (stale draft state) are left verbatim. + */ +export function rewriteCursorCommandMentionsAsLinks( + message: string, + mentions: readonly MessageMention[] = [], +): string { + let text = message; + const linked = mentions + .filter((mention): mention is Extract => + mention.kind === "command" && mention.path !== undefined) + // Descending order keeps earlier mention ranges valid while splicing. + .sort((a, b) => b.range.start - a.range.start); + for (const mention of linked) { + const expected = `/${mention.label}`; + const { start, end } = mention.range; + if (start < 0 || end > text.length || text.slice(start, end) !== expected) continue; + text = `${text.slice(0, start)}[${expected}](${mention.path})${text.slice(end)}`; + } + return text; +} diff --git a/packages/providers/src/private/cursor/runtime/cursor-turn-executor.ts b/packages/providers/src/private/cursor/runtime/cursor-turn-executor.ts index 438118210..f32d1ef13 100644 --- a/packages/providers/src/private/cursor/runtime/cursor-turn-executor.ts +++ b/packages/providers/src/private/cursor/runtime/cursor-turn-executor.ts @@ -1,14 +1,17 @@ import type { ClientSideConnection } from "@agentclientprotocol/sdk"; import { logger } from "@mcode/shared"; import { AgentEventType } from "@mcode/contracts"; -import type { AgentEvent, AttachmentMeta } from "@mcode/contracts"; +import type { AgentEvent, AttachmentMeta, MessageMention } from "@mcode/contracts"; import type { CursorProviderPorts } from "../../../factory-types.js"; import { buildCursorAcpPromptBlocks } from "../instructions/cursor-acp-prompt.js"; import { buildCursorAgentGuidanceMarkdown, formatCursorSkillsAndCommandsForPrompt, } from "../instructions/cursor-agent-guidance.js"; -import { readCursorUserInstructions } from "../instructions/cursor-prompt.js"; +import { + readCursorUserInstructions, + rewriteCursorCommandMentionsAsLinks, +} from "../instructions/cursor-prompt.js"; import { resolveCursorStickyInstructionBlob } from "../instructions/cursor-acp-sticky-instructions.js"; import { buildCursorAcpContinueAfterDisconnectPrompt, @@ -42,6 +45,7 @@ export interface CursorTurnExecutorOptions { model: string; resume: boolean; attachments?: AttachmentMeta[]; + mentions?: readonly MessageMention[]; turnId: string; turnExecutionId: string; deliveryAttempt: number; @@ -75,7 +79,8 @@ export class CursorTurnExecutor { /** Runs a turn and emits its terminal events. */ async run(entry: CursorSessionState, opts: CursorTurnExecutorOptions): Promise { - const { message, model, resume, attachments, turnId, turnExecutionId, deliveryAttempt } = opts; + const { model, resume, attachments, turnId, turnExecutionId, deliveryAttempt } = opts; + const message = rewriteCursorCommandMentionsAsLinks(opts.message, opts.mentions); const cursorCfg = this.deps.settings.get().provider.cursor; const execution: CursorTurnExecutionState = { currentEntry: entry,