From 1a55c5ebb2a7a2ca3b112a63a3bb6ed1d949971a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 08:23:41 -0700 Subject: [PATCH 1/2] Add tests for / slash-command routing and the path-swallowing regression Covers CL-6499: /jimmy should route to the agent named jimmy via the existing workflow-command plugin, an unknown command should name what IS available, and a plain message that merely starts with '/' (e.g. a path) must never be misread as a command attempt. --- packages/chat/test/commands.test.ts | 55 ++++++++++++++++++- packages/commands/test/dispatch.test.ts | 36 +++++++++++- packages/commands/test/grammar.test.ts | 12 ++++ .../commands/test/workflow-commands.test.ts | 42 +++++++++++++- 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/packages/chat/test/commands.test.ts b/packages/chat/test/commands.test.ts index d3c65f021..38e75e111 100644 --- a/packages/chat/test/commands.test.ts +++ b/packages/chat/test/commands.test.ts @@ -83,16 +83,67 @@ describe("workbench command dispatch", () => { }; expect(body.command).toEqual({ type: "message", - text: "Unknown command: /nope", + text: + "Unknown command: /nope. No agent commands are available in this " + + "workbench yet.", }); // Only the result reaches the timeline; the raw "/nope some args" // never does. expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual([ - "Unknown command: /nope", + "Unknown command: /nope. No agent commands are available in this " + + "workbench yet.", ]); }); + test("a path-shaped message starting with '/' is posted normally, never swallowed as a command", async () => { + const registry = createCommandRegistry(); + const deps = buildDeps({ commands: registry }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "workbench", + }); + + const response = await sendText(app, workbench.id, "/usr/local/bin"); + expect(response.status).toBe(201); + const body = (await response.json()) as Record; + expect(body["command"]).toBeUndefined(); + expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual([ + "/usr/local/bin", + ]); + }); + + test("/ invokes that agent directly with the rest of the line as its message — CL-6499", async () => { + const platform = fakePlatform({ + invitable: [{ id: "wfd_jimmy", name: "jimmy", description: "Jimmy" }], + }); + const deps = buildWorkflowCommandDeps(platform); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "workbench", + }); + + const response = await sendText( + app, + workbench.id, + "/jimmy throw me a gif for shipping code", + ); + expect(response.status).toBe(201); + const body = (await response.json()) as { + command: { type: string; handle: string }; + }; + expect(body.command.type).toBe("workflow-started"); + expect(body.command.handle).toBe("jimmy"); + expect(platform.launchInviteCalls).toHaveLength(1); + + const delivered = platform.sentMail.find( + (mail) => mail.workbenchId === "ins_invited1", + ); + expect(delivered?.content.content).toContain( + "throw me a gif for shipping code", + ); + }); + test("a registered slash command runs its handler with the parsed args", async () => { const registry = createCommandRegistry(); let seenArgs: string | undefined; diff --git a/packages/commands/test/dispatch.test.ts b/packages/commands/test/dispatch.test.ts index 863930d9b..36849b629 100644 --- a/packages/commands/test/dispatch.test.ts +++ b/packages/commands/test/dispatch.test.ts @@ -14,14 +14,46 @@ describe("dispatchSlashCommand", () => { expect(await dispatchSlashCommand(registry, "hello", CTX)).toBeUndefined(); }); - test("unknown command dispatches a loud message", async () => { + test("unknown command with no commands registered names none as available", async () => { const registry = createCommandRegistry(); expect(await dispatchSlashCommand(registry, "/nope", CTX)).toEqual({ type: "message", - text: "Unknown command: /nope", + text: + "Unknown command: /nope. No agent commands are available in this " + + "workbench yet.", }); }); + test("unknown command names the commands that ARE available", async () => { + const registry = createCommandRegistry(); + registry.registerCommand({ + name: "jimmy", + description: "Starts Jimmy", + handler: () => ({ type: "noop" }), + }); + registry.registerCommand({ + name: "scout", + description: "Starts Scout", + handler: () => ({ type: "noop" }), + }); + expect(await dispatchSlashCommand(registry, "/nope", CTX)).toEqual({ + type: "message", + text: "Unknown command: /nope. Available: /jimmy, /scout.", + }); + }); + + test("a path-shaped message (not a command) is never swallowed as one", async () => { + const registry = createCommandRegistry(); + registry.registerCommand({ + name: "jimmy", + description: "Starts Jimmy", + handler: () => ({ type: "noop" }), + }); + expect( + await dispatchSlashCommand(registry, "/usr/local/bin", CTX), + ).toBeUndefined(); + }); + test("runs the resolved command's handler with the parsed args and context", async () => { const registry = createCommandRegistry(); let seenArgs: string | undefined; diff --git a/packages/commands/test/grammar.test.ts b/packages/commands/test/grammar.test.ts index f18c700b3..7c27fd9ab 100644 --- a/packages/commands/test/grammar.test.ts +++ b/packages/commands/test/grammar.test.ts @@ -35,6 +35,18 @@ describe("parseSlashCommand", () => { expect(parseSlashCommand("/")).toBeUndefined(); expect(parseSlashCommand("/ hello")).toBeUndefined(); }); + + test("undefined for a path-shaped leading slash — never swallows a plain message that happens to start with '/'", () => { + expect(parseSlashCommand("/usr/local/bin")).toBeUndefined(); + expect(parseSlashCommand("/usr/local/bin is on my PATH")).toBeUndefined(); + }); + + test("still parses a hyphenated command name", () => { + expect(parseSlashCommand("/code-reviewer take a look")).toEqual({ + name: "code-reviewer", + args: "take a look", + }); + }); }); describe("parseAtCommand", () => { diff --git a/packages/commands/test/workflow-commands.test.ts b/packages/commands/test/workflow-commands.test.ts index 89f22b8cf..2b0579529 100644 --- a/packages/commands/test/workflow-commands.test.ts +++ b/packages/commands/test/workflow-commands.test.ts @@ -82,7 +82,47 @@ describe("createWorkflowCommandPlugin", () => { const result = await dispatchSlashCommand(registry, "/nonexistent", CTX); expect(result).toEqual({ type: "message", - text: "Unknown command: /nonexistent", + text: + "Unknown command: /nonexistent. No agent commands are available " + + "in this workbench yet.", + }); + }); + + test("any agent present in the room gets a command automatically — /jimmy routes to the definition named jimmy", async () => { + const registry = createCommandRegistry(); + const startCalls: unknown[] = []; + registry.registerCommandPlugin( + createWorkflowCommandPlugin({ + listInvitableDefinitions: async () => [ + { id: "def-jimmy", name: "jimmy" }, + ], + startWorkflow: async (input) => { + startCalls.push(input); + return { handle: "jimmy", address: "ins_jimmy@tenant.test" }; + }, + }), + ); + + const result = await dispatchSlashCommand( + registry, + "/jimmy throw me a gif for shipping code", + CTX, + ); + + expect(startCalls).toEqual([ + { + tenantId: CTX.tenantId, + principalId: CTX.principalId, + workbenchId: CTX.workbenchId, + definitionId: "def-jimmy", + args: "throw me a gif for shipping code", + }, + ]); + expect(result).toEqual({ + type: "workflow-started", + definitionId: "def-jimmy", + address: "ins_jimmy@tenant.test", + handle: "jimmy", }); }); }); From 8971e559a029f06026c4516a2f90f01c5dfdc38c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 08:23:49 -0700 Subject: [PATCH 2/2] Fix command grammar to stop swallowing plain slash-prefixed messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message that merely starts with '/' — a path, a URL — used to be parsed as a command name (spaces-only split, no shape check) and answered with a raw 'Unknown command' error instead of posting normally. Command names are now constrained to the same [\w-]+ shape the composer's own slash popover already commits to. Also makes the unknown-command message name what commands ARE available (built-ins plus every invitable agent's own workflow command) instead of a bare error, since every invitable agent already gets a / command for free via createWorkflowCommandPlugin — this is how '/jimmy hello' invokes Jimmy directly today, with no new command system needed. --- packages/commands/src/dispatch.ts | 30 +++++++++++++++++++++++++++--- packages/commands/src/grammar.ts | 11 ++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/commands/src/dispatch.ts b/packages/commands/src/dispatch.ts index 0e4caf504..7b40f1892 100644 --- a/packages/commands/src/dispatch.ts +++ b/packages/commands/src/dispatch.ts @@ -15,8 +15,30 @@ import type { CommandResult, } from "./registry"; -function unknownCommandResult(name: string, prefix: "/" | "@"): CommandResult { - return { type: "message", text: `Unknown command: ${prefix}${name}` }; +/** + * Names what IS available rather than answering a miss with a bare + * error: every command this tenant actually has right now (built-ins + * plus every invitable agent's own workflow command), so a mistyped + * `/jimmi` tells the sender what to try instead of leaving them to + * guess. + */ +async function unknownCommandResult( + registry: CommandRegistry, + name: string, + prefix: "/" | "@", + ctx: CommandContext, +): Promise { + const available = await registry.listCommands(ctx.tenantId); + const suffix = + available.length === 0 + ? "No agent commands are available in this workbench yet." + : `Available: ${available + .map((command) => `${prefix}${command.name}`) + .join(", ")}.`; + return { + type: "message", + text: `Unknown command: ${prefix}${name}. ${suffix}`, + }; } async function runParsed( @@ -26,7 +48,9 @@ async function runParsed( ctx: CommandContext, ): Promise { const command = await registry.getCommand(parsed.name, ctx.tenantId); - if (command === undefined) return unknownCommandResult(parsed.name, prefix); + if (command === undefined) { + return unknownCommandResult(registry, parsed.name, prefix, ctx); + } return command.handler(parsed.args, ctx); } diff --git a/packages/commands/src/grammar.ts b/packages/commands/src/grammar.ts index b459fb767..72496472a 100644 --- a/packages/commands/src/grammar.ts +++ b/packages/commands/src/grammar.ts @@ -11,6 +11,15 @@ export interface ParsedCommand { readonly args: string; } +// A command name is a bare word — letters, digits, underscore, hyphen — +// the same shape the composer's own `activeSlashQuery` already commits +// to before it ever opens the popover. Anything else (a path like +// "/usr/local/bin", a URL, plain punctuation) is not a command attempt +// at all, so `parseWithPrefix` returns `undefined` for it rather than +// naming it an "unknown command" — that misfire used to swallow an +// ordinary message and answer it with a command error instead. +const NAME_PATTERN = /^[\w-]+$/; + function parseWithPrefix( text: string, prefix: string, @@ -19,7 +28,7 @@ function parseWithPrefix( const rest = text.slice(prefix.length); const spaceIndex = rest.indexOf(" "); const name = spaceIndex === -1 ? rest : rest.slice(0, spaceIndex); - if (name === "") return undefined; + if (!NAME_PATTERN.test(name)) return undefined; const args = spaceIndex === -1 ? "" : rest.slice(spaceIndex + 1).trim(); return { name, args }; }