diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts new file mode 100644 index 000000000..f4dd64221 --- /dev/null +++ b/src/auto-reply/model.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { extractModelDirective } from "./model.js"; + +describe("extractModelDirective", () => { + describe("basic /model command", () => { + it("extracts /model with argument", () => { + const result = extractModelDirective("/model gpt-5"); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("gpt-5"); + expect(result.cleaned).toBe(""); + }); + + it("extracts /model with provider/model format", () => { + const result = extractModelDirective("/model anthropic/claude-opus-4-5"); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("anthropic/claude-opus-4-5"); + }); + + it("extracts /model with profile override", () => { + const result = extractModelDirective("/model gpt-5@myprofile"); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("gpt-5"); + expect(result.rawProfile).toBe("myprofile"); + }); + + it("returns no directive for plain text", () => { + const result = extractModelDirective("hello world"); + expect(result.hasDirective).toBe(false); + expect(result.cleaned).toBe("hello world"); + }); + }); + + describe("alias shortcuts", () => { + it("recognizes /gpt as model directive when alias is configured", () => { + const result = extractModelDirective("/gpt", { aliases: ["gpt", "sonnet", "opus"] }); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("gpt"); + expect(result.cleaned).toBe(""); + }); + + it("recognizes /sonnet as model directive", () => { + const result = extractModelDirective("/sonnet", { aliases: ["gpt", "sonnet", "opus"] }); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("sonnet"); + }); + + it("recognizes alias mid-message", () => { + const result = extractModelDirective("switch to /opus please", { + aliases: ["opus"], + }); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("opus"); + expect(result.cleaned).toBe("switch to please"); + }); + + it("is case-insensitive for aliases", () => { + const result = extractModelDirective("/GPT", { aliases: ["gpt"] }); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("GPT"); + }); + + it("does not match alias without leading slash", () => { + const result = extractModelDirective("gpt is great", { aliases: ["gpt"] }); + expect(result.hasDirective).toBe(false); + }); + + it("does not match unknown aliases", () => { + const result = extractModelDirective("/unknown", { aliases: ["gpt", "sonnet"] }); + expect(result.hasDirective).toBe(false); + expect(result.cleaned).toBe("/unknown"); + }); + + it("prefers /model over alias when both present", () => { + const result = extractModelDirective("/model haiku", { aliases: ["gpt"] }); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("haiku"); + }); + + it("handles empty aliases array", () => { + const result = extractModelDirective("/gpt", { aliases: [] }); + expect(result.hasDirective).toBe(false); + }); + + it("handles undefined aliases", () => { + const result = extractModelDirective("/gpt"); + expect(result.hasDirective).toBe(false); + }); + }); + + describe("edge cases", () => { + it("handles alias with special regex characters", () => { + const result = extractModelDirective("/test.alias", { + aliases: ["test.alias"], + }); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("test.alias"); + }); + + it("does not match partial alias", () => { + const result = extractModelDirective("/gpt-turbo", { aliases: ["gpt"] }); + expect(result.hasDirective).toBe(false); + }); + + it("handles empty body", () => { + const result = extractModelDirective("", { aliases: ["gpt"] }); + expect(result.hasDirective).toBe(false); + expect(result.cleaned).toBe(""); + }); + + it("handles undefined body", () => { + const result = extractModelDirective(undefined, { aliases: ["gpt"] }); + expect(result.hasDirective).toBe(false); + }); + }); +}); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index c2bd3ecb3..e7a063ee4 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -31,7 +31,10 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { defaultRuntime } from "../runtime.js"; import { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand } from "./command-detection.js"; -import { shouldHandleTextCommands } from "./commands-registry.js"; +import { + listChatCommands, + shouldHandleTextCommands, +} from "./commands-registry.js"; import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; @@ -312,9 +315,15 @@ export async function getReplyFromConfig( rawDrop: undefined, hasQueueOptions: false, }); + const reservedCommands = new Set( + listChatCommands().flatMap((cmd) => + cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()), + ), + ); const configuredAliases = Object.values(cfg.agent?.models ?? {}) .map((entry) => entry.alias) - .filter((alias): alias is string => Boolean(alias)); + .filter((alias): alias is string => Boolean(alias)) + .filter((alias) => !reservedCommands.has(alias.toLowerCase())); let parsedDirectives = parseInlineDirectives(rawBody, { modelAliases: configuredAliases, });