fix(commands): harden model alias parsing

This commit is contained in:
Peter Steinberger
2026-01-07 19:58:23 +00:00
parent bb29a3ee3f
commit 7ce1f635cd
6 changed files with 51 additions and 8 deletions

View File

@@ -74,6 +74,7 @@
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
- Commands: allow `/<alias>` shorthand for `/model` using `agent.models.*.alias`, without shadowing built-ins. Thanks @azade-c for PR #393.
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.

View File

@@ -42,7 +42,7 @@ Text + native (when enabled):
- `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/model <name>`
- `/model <name>` (or `/<alias>` from `agent.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`)
Text-only:

View File

@@ -32,14 +32,18 @@ describe("extractModelDirective", () => {
describe("alias shortcuts", () => {
it("recognizes /gpt as model directive when alias is configured", () => {
const result = extractModelDirective("/gpt", { aliases: ["gpt", "sonnet", "opus"] });
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"] });
const result = extractModelDirective("/sonnet", {
aliases: ["gpt", "sonnet", "opus"],
});
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("sonnet");
});
@@ -60,18 +64,24 @@ describe("extractModelDirective", () => {
});
it("does not match alias without leading slash", () => {
const result = extractModelDirective("gpt is great", { aliases: ["gpt"] });
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"] });
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"] });
const result = extractModelDirective("/model haiku", {
aliases: ["gpt"],
});
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("haiku");
});

View File

@@ -17,7 +17,9 @@ export function extractModelDirective(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
);
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
const aliases = (options?.aliases ?? [])
.map((alias) => alias.trim())
.filter(Boolean);
const aliasMatch =
modelMatch || aliases.length === 0
? null

View File

@@ -144,6 +144,36 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("please now");
});
it("keeps reserved command aliases from matching after trimming", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/help",
From: "+1222",
To: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Help");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("errors on invalid queue options", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@@ -321,7 +321,7 @@ export async function getReplyFromConfig(
),
);
const configuredAliases = Object.values(cfg.agent?.models ?? {})
.map((entry) => entry.alias)
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
let parsedDirectives = parseInlineDirectives(rawBody, {