mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
5621979a46
* feat(models): add session-only model selection * fix(models): use trailing session scope option * test(models): satisfy session scope lint * fix(models): reject duplicate model options * fix(models): clarify default and session scope * fix(models): require complete session option tokens * fix(models): report configured default dispatch * fix(models): keep directive handler within lint limit * fix(models): parse model options in either order * fix(models): apply session scope to aliases * fix(models): align alias scope with reply routing * fix(discord): surface model selection scope in picker * fix(models): preserve mixed-text model selection * fix(models): centralize command selection ownership * fix(models): align session scope lifecycle * fix(models): preserve command and auth ownership * fixup! fix(models): preserve command and auth ownership * fix(auth): preserve scoped CLI provider discovery * test(models): align result and cron fixtures * test(models): nest result timing metadata * fix(discord): narrow silent dispatch results * fix(transcript): preserve admitted turn identity * fix(context-engine): fence the admitted transcript turn * fix(context-engine): stabilize plugin compatibility contract * chore(plugin-sdk): refresh context engine API baseline * chore(plugin-sdk): use Linux context engine API baseline * fix(context-engine): align fallback ownership * fix(fallback): scope auth skip cache by profile * fix(context-engine): settle only accepted fallback turns * refactor(sessions): issue canonical turn admissions * refactor(context-engine): own logical turn advancement * fix(context-engine): settle cron fallback winners * fix(models): align picker and fallback transactions * fix(delivery): notify block admission after queueing * fix(sessions): preserve canonical admission receipts * chore(plugin-sdk): refresh API baseline hash * fix(context-engine): commit accepted turns durably * fix(context-engine): validate durable host transitions * fix(context-engine): preserve fallback turn ownership * fix(context-engine): preserve queued turn order * fix(models): preserve fallback retry ownership * fix(context-engine): enforce durable transcript anchors * fix(runtime): close fallback persistence gaps * fix(context-engine): preflight fallback harnesses * chore(plugin-sdk): use Linux API baseline * fix(context-engine): drain durable commits before reads * fix(models): scope harness auth failures by profile * fix(codex): fence legacy transcript history * fix(commands): honor suppressed directive interpretation * chore(runtime): remove unused branch exports * test(context-engine): derive private outbox payload type * fix(context-engine): apply durable drain degradation * fix(context-engine): recover durable turn intents * fix(context-engine): settle durable turn intents * refactor(context-engine): satisfy branch quality gates * fix(context-engine): close durable recovery gaps * fix(discord): preserve dropped model command outcome * test(copilot): keep journal fixture types local * fix(auto-reply): preserve model alias provenance * fix: close model scope review gaps * fix(models): close review-found scope leaks * fix(review): satisfy branch line budgets * fix(agents): preserve context engine turn facts * fix(agents): finalize silent context turns * fix(context-engine): preserve compatibility window * test(agents): cover both harness preparations * fix(context-engine): retain blocked turn advancements * fix(models): parse compact runtime options * fix(telegram): report runtime resets accurately * fix(models): isolate automatic auth failure skips * fix(context-engine): project commit turn host params --------- Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
388 lines
15 KiB
TypeScript
388 lines
15 KiB
TypeScript
/** Tests model reference formatting and parsing helpers used by auto-reply. */
|
|
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.source).toBe("model");
|
|
expect(result.rawModel).toBe("gpt-5");
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it("extracts a session-only selection with -s", () => {
|
|
const result = extractModelDirective("/model anthropic/claude-opus-4-6 -s");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("anthropic/claude-opus-4-6");
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it("extracts a session-only default reset with --session", () => {
|
|
const result = extractModelDirective("/model default --session");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("default");
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it.each(["-slow", "--sessional"])(
|
|
"does not treat partial session option %s as session-only",
|
|
(option) => {
|
|
const result = extractModelDirective(`/model anthropic/claude-opus-4-6 ${option}`);
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("anthropic/claude-opus-4-6");
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe(option);
|
|
},
|
|
);
|
|
|
|
it("keeps here as a model name and preserves following message text", () => {
|
|
const result = extractModelDirective("please /model here continue");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("here");
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe("please continue");
|
|
});
|
|
|
|
it("parses a leading -s as a model-less session option", () => {
|
|
const result = extractModelDirective("/model -s opus");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBeUndefined();
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("opus");
|
|
});
|
|
|
|
it.each(["-s", "--session"])("parses model-less session option %s", (option) => {
|
|
const result = extractModelDirective(`/model ${option}`);
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBeUndefined();
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it.each(["--runtime codex", "runtime=codex", "harness=codex"])(
|
|
"parses model-less runtime option %s",
|
|
(option) => {
|
|
const result = extractModelDirective(`/model ${option}`);
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBeUndefined();
|
|
expect(result.rawRuntime).toBe("codex");
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe("");
|
|
},
|
|
);
|
|
|
|
it("does not consume a reserved option as a missing runtime value", () => {
|
|
const result = extractModelDirective("/model --runtime --session");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBeUndefined();
|
|
expect(result.rawRuntime).toBeUndefined();
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe("--runtime --session");
|
|
});
|
|
|
|
it("does not treat /models as a /model directive", () => {
|
|
const result = extractModelDirective("/models gpt-5");
|
|
expect(result.hasDirective).toBe(false);
|
|
expect(result.rawModel).toBeUndefined();
|
|
expect(result.cleaned).toBe("/models gpt-5");
|
|
});
|
|
|
|
it("does not parse /models as a /model directive (no args)", () => {
|
|
const result = extractModelDirective("/models");
|
|
expect(result.hasDirective).toBe(false);
|
|
expect(result.cleaned).toBe("/models");
|
|
});
|
|
|
|
it("extracts /model with provider/model format", () => {
|
|
const result = extractModelDirective("/model anthropic/claude-opus-4-6");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("anthropic/claude-opus-4-6");
|
|
});
|
|
|
|
it.each([
|
|
"--runtime claude-cli -s",
|
|
"-s --runtime claude-cli",
|
|
"runtime= claude-cli -s",
|
|
"runtime=claude-cli -s",
|
|
"-s runtime= claude-cli",
|
|
"-s runtime=claude-cli",
|
|
"harness= claude-cli -s",
|
|
"harness=claude-cli -s",
|
|
"-s harness= claude-cli",
|
|
"-s harness=claude-cli",
|
|
])("extracts runtime and session options from %s", (options) => {
|
|
const result = extractModelDirective(`/model anthropic/claude-opus-4-7 ${options}`);
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("anthropic/claude-opus-4-7");
|
|
expect(result.rawRuntime).toBe("claude-cli");
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it("preserves duplicate runtime and session options for validation", () => {
|
|
const runtime = extractModelDirective(
|
|
"/model openai/gpt-5.6-luna --runtime codex --runtime acp",
|
|
);
|
|
expect(runtime.rawRuntime).toBe("codex");
|
|
expect(runtime.cleaned).toBe("--runtime acp");
|
|
|
|
const session = extractModelDirective("/model openai/gpt-5.6-luna -s -s");
|
|
expect(session.sessionOnly).toBe(true);
|
|
expect(session.cleaned).toBe("-s");
|
|
});
|
|
|
|
it("keeps partial runtime option names as ordinary text", () => {
|
|
const result = extractModelDirective("/model openai/gpt-5.6-luna runtime-extra=codex");
|
|
expect(result.rawModel).toBe("openai/gpt-5.6-luna");
|
|
expect(result.rawRuntime).toBeUndefined();
|
|
expect(result.cleaned).toBe("runtime-extra=codex");
|
|
});
|
|
|
|
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("keeps OpenRouter preset paths that include @ in the model name", () => {
|
|
const result = extractModelDirective("/model openrouter/@preset/kimi-2-5");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("openrouter/@preset/kimi-2-5");
|
|
expect(result.rawProfile).toBeUndefined();
|
|
});
|
|
|
|
it("still allows profile overrides after OpenRouter preset paths", () => {
|
|
const result = extractModelDirective("/model openrouter/@preset/kimi-2-5@work");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("openrouter/@preset/kimi-2-5");
|
|
expect(result.rawProfile).toBe("work");
|
|
});
|
|
|
|
it("keeps Cloudflare @cf path segments inside model ids", () => {
|
|
const result = extractModelDirective("/model openai/@cf/openai/gpt-oss-20b");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("openai/@cf/openai/gpt-oss-20b");
|
|
expect(result.rawProfile).toBeUndefined();
|
|
});
|
|
|
|
it("allows profile overrides after Cloudflare @cf path segments", () => {
|
|
const result = extractModelDirective("/model openai/@cf/openai/gpt-oss-20b@cf:default");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("openai/@cf/openai/gpt-oss-20b");
|
|
expect(result.rawProfile).toBe("cf:default");
|
|
});
|
|
|
|
it("keeps LM Studio @iq* quant suffixes inside model ids", () => {
|
|
const result = extractModelDirective("/model lmstudio/qwen3.6-27b@iq3_xxs");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("lmstudio/qwen3.6-27b@iq3_xxs");
|
|
expect(result.rawProfile).toBeUndefined();
|
|
});
|
|
|
|
it("allows profile overrides after LM Studio @iq* quant suffixes", () => {
|
|
const result = extractModelDirective("/model lmstudio/qwen3.6-27b@iq3_xxs@work");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("lmstudio/qwen3.6-27b@iq3_xxs");
|
|
expect(result.rawProfile).toBe("work");
|
|
});
|
|
|
|
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.source).toBe("alias");
|
|
expect(result.rawModel).toBe("gpt");
|
|
expect(result.rawRuntime).toBeUndefined();
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it.each(["-s", "--session"])("applies alias session scope from %s", (option) => {
|
|
const result = extractModelDirective(`/gpt ${option}`, {
|
|
aliases: ["gpt", "sonnet", "opus"],
|
|
});
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("gpt");
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it.each(["--runtime codex", "runtime=codex", "harness=codex"])(
|
|
"applies runtime-only alias option %s",
|
|
(option) => {
|
|
const result = extractModelDirective(`/gpt ${option}`, {
|
|
aliases: ["gpt"],
|
|
});
|
|
expect(result.rawModel).toBe("gpt");
|
|
expect(result.rawRuntime).toBe("codex");
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe("");
|
|
},
|
|
);
|
|
|
|
it.each(["--runtime codex -s", "-s --runtime codex"])(
|
|
"applies runtime and session alias options from %s",
|
|
(options) => {
|
|
const result = extractModelDirective(`/gpt ${options}`, {
|
|
aliases: ["gpt"],
|
|
});
|
|
expect(result.rawModel).toBe("gpt");
|
|
expect(result.rawRuntime).toBe("codex");
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
},
|
|
);
|
|
|
|
it("recognizes alias options after an optional colon", () => {
|
|
const result = extractModelDirective("/gpt: --session", {
|
|
aliases: ["gpt", "sonnet", "opus"],
|
|
});
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("gpt");
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
it("preserves duplicate alias runtime and session options for validation", () => {
|
|
const runtime = extractModelDirective("/gpt --runtime codex --runtime acp", {
|
|
aliases: ["gpt"],
|
|
});
|
|
expect(runtime.rawRuntime).toBe("codex");
|
|
expect(runtime.cleaned).toBe("--runtime acp");
|
|
|
|
const session = extractModelDirective("/gpt -s --session", {
|
|
aliases: ["gpt"],
|
|
});
|
|
expect(session.sessionOnly).toBe(true);
|
|
expect(session.cleaned).toBe("--session");
|
|
});
|
|
|
|
it.each(["-slow", "--sessional"])(
|
|
"does not treat partial alias session option %s as session-only",
|
|
(option) => {
|
|
const result = extractModelDirective(`/gpt ${option}`, {
|
|
aliases: ["gpt"],
|
|
});
|
|
expect(result.rawModel).toBe("gpt");
|
|
expect(result.sessionOnly).toBe(false);
|
|
expect(result.cleaned).toBe(option);
|
|
},
|
|
);
|
|
|
|
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("attributes a literal /model directive when alias text follows it", () => {
|
|
const result = extractModelDirective("/model status /gpt", {
|
|
aliases: ["gpt"],
|
|
});
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.source).toBe("model");
|
|
expect(result.rawModel).toBe("status");
|
|
expect(result.cleaned).toBe("/gpt");
|
|
});
|
|
|
|
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("absorbs path-like segments when /model includes extra slashes", () => {
|
|
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.cleaned).toBe("thats not");
|
|
});
|
|
|
|
it("handles alias with special regex characters", () => {
|
|
const result = extractModelDirective("/test.alias --session", {
|
|
aliases: ["test.alias"],
|
|
});
|
|
expect(result.hasDirective).toBe(true);
|
|
expect(result.rawModel).toBe("test.alias");
|
|
expect(result.sessionOnly).toBe(true);
|
|
expect(result.cleaned).toBe("");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|