feat: route clear sf chat commands

This commit is contained in:
Mikael Hugo 2026-05-14 20:21:37 +02:00
parent ab1a1edcf9
commit 47867c1236
5 changed files with 179 additions and 0 deletions

View file

@ -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<void>;
};
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);
});
});

View file

@ -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;

View file

@ -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 };
});
}

View file

@ -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(

View file

@ -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", "--"), [
{