diff --git a/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts b/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts index 8ec84f98b..ac93c2f9a 100644 --- a/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts +++ b/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts @@ -109,4 +109,40 @@ describe("AgentSession custom message queueing", () => { assert.equal(followUps[0]?.role, "custom"); assert.equal((followUps[0] as any).content, "after the current run"); }); + + it("executes_slash_command_after_input_hook_transforms_chat_text", async () => { + const session = await createSession(); + const agent = (session as any).agent as Agent & { + prompt: (message: AgentMessage) => Promise; + }; + let agentPrompted = false; + let commandArgs: string | undefined; + agent.prompt = async () => { + agentPrompted = true; + }; + (session as any)._extensionRunner = { + hasHandlers: (event: string) => event === "input", + emitInput: async () => ({ + action: "transform", + text: "/queue", + images: undefined, + }), + getCommand: (name: string) => + name === "queue" + ? { + name: "queue", + handler: async (args: string) => { + commandArgs = args; + }, + } + : undefined, + createCommandContext: () => ({}), + emitError: () => undefined, + }; + + await session.prompt("show my queue"); + + assert.equal(commandArgs, ""); + assert.equal(agentPrompted, false); + }); }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 94dffcd19..bd9f79671 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1223,6 +1223,16 @@ export class AgentSession { currentImages = inputResult.images ?? currentImages; } } + if ( + expandPromptTemplates && + currentText !== text && + currentText.startsWith("/") + ) { + const handled = await this._tryExecuteExtensionCommand(currentText); + if (handled) { + return; + } + } // Expand skill commands (/skill:name args) and prompt templates (/template args) let expandedText = currentText; diff --git a/src/resources/extensions/sf/chat-command-router.js b/src/resources/extensions/sf/chat-command-router.js new file mode 100644 index 000000000..5bc061bd4 --- /dev/null +++ b/src/resources/extensions/sf/chat-command-router.js @@ -0,0 +1,119 @@ +const ROUTES = [ + { + command: "help", + patterns: [/\b(help|what can i do|commands?)\b/i], + }, + { + command: "queue", + patterns: [ + /\b(show|open|view|list|see|what'?s in|what is in)\b.*\bqueue\b/i, + /\bqueue\b.*\b(show|open|view|list)\b/i, + ], + }, + { + command: "status", + patterns: [ + /\b(show|open|view|check|what'?s|what is|where are we)\b.*\b(status|progress|dashboard)\b/i, + /\bhow far\b/i, + ], + }, + { + command: "history", + patterns: [/\b(show|open|view|list)\b.*\b(history|what happened)\b/i], + }, + { + command: "logs", + patterns: [/\b(show|open|view|tail|read)\b.*\b(logs?|debug logs?)\b/i], + }, + { + command: "forensics", + patterns: [/\b(run|open|show|start)\b.*\bforensics?\b/i], + }, + { + command: "visualize", + patterns: [/\b(open|show|view)\b.*\b(visuali[sz]e|visuali[sz]er|graph)\b/i], + }, + { + command: "doctor", + patterns: [/\b(run|open|check)\b.*\b(doctor|health|diagnostics?)\b/i], + }, + { + command: "repair", + patterns: [/\b(run|start|switch to)\b.*\brepair\b/i], + }, + { + command: "autonomous", + patterns: [ + /\b(start|run|go|continue|resume)\b.*\b(autonomous|autonomy|auto mode)\b/i, + /\bkeep going\b/i, + ], + }, + { + command: "next", + patterns: [/\b(run|do|take|start)\b.*\b(next step|next unit|one step)\b/i], + }, + { + command: "discuss", + patterns: [ + /\b(start|open|enter)\b.*\b(discuss|discussion|planning|plan mode)\b/i, + /\b(let'?s|lets)\b.*\b(discuss|plan)\b/i, + ], + }, + { + command: "quick", + patterns: [/\b(run|do|use)\b.*\bquick\b/i, /^quickly?\b/i], + preserveArgs: true, + }, + { + command: "capture", + patterns: [/\b(capture|remember|note)\b.*\b(this|that|idea|thought)\b/i], + preserveArgs: true, + }, + { + command: "triage", + patterns: [/\b(run|start|apply|do)\b.*\btriage\b/i], + }, + { + command: "pause", + patterns: [/\b(pause|pause autonomous|take a break)\b/i], + }, + { + command: "stop", + patterns: [/\b(stop|halt)\b.*\b(autonomous|run|sf)\b/i], + }, + { + command: "undo", + patterns: [/\b(undo|revert|roll back)\b.*\b(last|previous|change|step)\b/i], + }, + { + command: "skip", + patterns: [/\bskip\b.*\b(task|unit|this)\b/i], + preserveArgs: true, + }, +]; + +function normalizeArgs(text) { + return text.replace(/\s+/g, " ").trim(); +} + +export function routeChatCommand(text) { + const input = normalizeArgs(text); + if (!input || input.startsWith("/") || input.startsWith("!")) return null; + if (input.length > 240) return null; + + for (const route of ROUTES) { + if (!route.patterns.some((pattern) => pattern.test(input))) continue; + const args = route.preserveArgs ? input : ""; + return args ? `/${route.command} ${args}` : `/${route.command}`; + } + return null; +} + +export function registerChatCommandRouter(pi) { + pi.on("input", (event) => { + if (event.images?.length > 0) return { action: "continue" }; + const routed = routeChatCommand(event.text); + if (!routed) return { action: "continue" }; + return { action: "transform", text: routed, images: event.images }; + }); +} diff --git a/src/resources/extensions/sf/index.js b/src/resources/extensions/sf/index.js index 308c734f4..2530ba461 100644 --- a/src/resources/extensions/sf/index.js +++ b/src/resources/extensions/sf/index.js @@ -1,4 +1,5 @@ import { getErrorMessage } from "./error-utils.js"; + export { clearPendingGate, getPendingGate, @@ -20,6 +21,10 @@ export default async function registerExtension(pi) { // tools, hooks) fails — e.g. due to a Windows-specific import error. const { registerSFCommands } = await import("./commands/index.js"); registerSFCommands(pi); + const { registerChatCommandRouter } = await import( + "./chat-command-router.js" + ); + registerChatCommandRouter(pi); // Register steerable autonomous extension for Copilot Auto-style controls const { default: steerableAutonomousExtension } = await import( diff --git a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs index 2888904c9..f1c433f77 100644 --- a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs +++ b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "vitest"; import guardrails from "../../guardrails/index.js"; +import { routeChatCommand } from "../chat-command-router.js"; import { DIRECT_SF_COMMAND_NAMES, getSfArgumentCompletions, @@ -74,6 +75,14 @@ test("help_keyword_routes_natural_language_to_public_commands", () => { assert.doesNotMatch(messages[0], /\/parallel\b/); }); +test("chat_command_router_maps_clear_chat_intent_to_public_workflow_commands", () => { + assert.equal(routeChatCommand("I want to see my queue"), "/queue"); + assert.equal(routeChatCommand("start autonomous mode"), "/autonomous"); + assert.equal(routeChatCommand("let's discuss the architecture"), "/discuss"); + assert.equal(routeChatCommand("fix the login bug"), null); + assert.equal(routeChatCommand("/queue"), null); +}); + test("direct command completions strip the already typed command name", () => { assert.deepEqual(getSfTopLevelCommandCompletions("autonomous", "--"), [ {