fix(commands): harden model alias parsing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user