fix: refine model directive handling

This commit is contained in:
Peter Steinberger
2026-01-22 00:26:48 +00:00
parent 429a2d7849
commit 2b254a9b39
6 changed files with 55 additions and 30 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.clawd.bot
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo. - Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. - BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387) - Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
## 2026.1.22 ## 2026.1.22

View File

@@ -114,10 +114,10 @@ describe("extractModelDirective", () => {
}); });
describe("edge cases", () => { describe("edge cases", () => {
it("preserves spacing when /model is followed by a path segment", () => { it("absorbs path-like segments when /model includes extra slashes", () => {
const result = extractModelDirective("thats not /model gpt-5/tmp/hello"); const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
expect(result.hasDirective).toBe(true); expect(result.hasDirective).toBe(true);
expect(result.cleaned).toBe("thats not /hello"); expect(result.cleaned).toBe("thats not");
}); });
it("handles alias with special regex characters", () => { it("handles alias with special regex characters", () => {

View File

@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false }; if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match( const modelMatch = body.match(
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i, /(?:^|\s)\/models?(?=$|\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);

View File

@@ -84,9 +84,9 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Model listing moved.");
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("openai/gpt-4.1-mini"); expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -115,9 +115,9 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(text).toContain("openai/gpt-4.1-mini"); expect(text).toContain("More: /model status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -150,10 +150,9 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Model listing moved.");
expect(text).toContain("openai/gpt-4.1-mini"); expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("minimax/MiniMax-M2.1"); expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("xai/grok-4");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -202,9 +201,9 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Model listing moved.");
expect(text).toContain("openai/gpt-4.1-mini"); expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("minimax/MiniMax-M2.1"); expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -231,6 +230,7 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved.");
expect(text).not.toContain("missing (missing)"); expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });

View File

@@ -211,10 +211,9 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(text).toContain("openai/gpt-4.1-mini"); expect(text).toContain("More: /model status");
expect(text).not.toContain("claude-sonnet-4-1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -341,16 +341,41 @@ export function resolveModelSelectionFromDirective(params: {
if (resolved.selection) { if (resolved.selection) {
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`; const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
return { const rawHasSlash = raw.includes("/");
errorText: [ const shouldAutoSelect = (() => {
`Unrecognized model: ${raw}`, if (!rawHasSlash) return true;
"", const slash = raw.indexOf("/");
`Did you mean: ${suggestion}`, if (slash <= 0) return true;
`Try: /model ${suggestion}`, const rawProvider = normalizeProviderId(raw.slice(0, slash));
"", const rawFragment = raw
"Browse: /models or /models <provider>", .slice(slash + 1)
].join("\n"), .trim()
}; .toLowerCase();
if (!rawFragment) return false;
const resolvedProvider = normalizeProviderId(resolved.selection.provider);
if (rawProvider !== resolvedProvider) return false;
const resolvedModel = resolved.selection.model.toLowerCase();
return (
resolvedModel.startsWith(rawFragment) ||
resolvedModel.includes(rawFragment) ||
rawFragment.startsWith(resolvedModel)
);
})();
if (shouldAutoSelect) {
modelSelection = resolved.selection;
} else {
return {
errorText: [
`Unrecognized model: ${raw}`,
"",
`Did you mean: ${suggestion}`,
`Try: /model ${suggestion}`,
"",
"Browse: /models or /models <provider>",
].join("\n"),
};
}
} }
} }