1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 collectComposerModelMentions,
5 formatComposerModelMention,
6 rankComposerModelMentions,
7} from "./composerModelMentions.ts";
8
9const models = [
10 { id: "traex:gpt", name: "GPT-6.1", driverKind: "codex", providerDisplayName: "Traex" },
11 {
12 id: "opencode:traex-gpt",
13 name: "GPT-6.1",
14 subProvider: "Traex",
15 driverKind: "opencode",
16 providerDisplayName: "OpenCode",
17 },
18 {
19 id: "opencode:opus",
20 name: "Claude Opus 5.5",
21 subProvider: "Anthropic",
22 driverKind: "opencode",
23 providerDisplayName: "OpenCode",
24 },
25 {
26 id: "claude:opus",
27 name: "Claude Opus 5.5",
28 driverKind: "claudeAgent",
29 providerDisplayName: "Claude",
30 },
31];
32
33function rank(query: string): string[] {
34 return rankComposerModelMentions(models, query, (model) => model).map((model) => model.id);
35}
36
37describe("rankComposerModelMentions", () => {
38 it.each(["", "-", "---", ":/-/:"])(
39 "offers no models for a bare or separator-only @%s",
40 (query) => {
41 expect(rank(query)).toEqual([]);
42 },
43 );
44
45 it("finds a model under every provider that serves it", () => {
46 expect(rank("gpt").toSorted()).toEqual(["opencode:traex-gpt", "traex:gpt"]);
47 });
48
49 it("splits words a mention joins with :, / or -", () => {
50 expect(rank("opencode:traex")).toEqual(["opencode:traex-gpt"]);
51 expect(rank("opencode/gpt")).toEqual(["opencode:traex-gpt"]);
52 expect(rank("opencode-gpt")).toEqual(["opencode:traex-gpt"]);
53 });
54
55 it("keeps a hyphenated model name whole when it matches", () => {
56 expect(rank("gpt-6.1").toSorted()).toEqual(["opencode:traex-gpt", "traex:gpt"]);
57 });
58
59 it.each(["src", "g", "agent", "pus"])(
60 "does not offer models whose words only contain @%s",
61 (query) => {
62 expect(rank(query)).toEqual([]);
63 },
64 );
65});
66
67describe("collectComposerModelMentions", () => {
68 it("reads back what formatComposerModelMention writes, inside prose", () => {
69 const text = `use ${formatComposerModelMention("opencode", "anthropic/opus")}, then model:codex/gpt-5.4.`;
70 expect(collectComposerModelMentions(text)).toEqual([
71 {
72 instanceId: "opencode",
73 model: "anthropic/opus",
74 source: "model:opencode/anthropic/opus",
75 start: 4,
76 end: 33,
77 },
78 { instanceId: "codex", model: "gpt-5.4", source: "model:codex/gpt-5.4", start: 40, end: 59 },
79 ]);
80 });
81
82 it.each([
83 ["(model:codex/gpt-6.1).", ["codex/gpt-6.1"]],
84 ['"model:codex/gpt-6.1"', ["codex/gpt-6.1"]],
85 ["model:codex/gpt-6.1,model:claudeAgent/sonnet", ["codex/gpt-6.1", "claudeAgent/sonnet"]],
86 ["model:codex/gpt-6.1's output", ["codex/gpt-6.1"]],
87 ["model:opencode/foo(bar)", ["opencode/foo(bar)"]],
88 ["(model:codex/GPT-6.1-Sol[1m]).", ["codex/GPT-6.1-Sol[1m]"]],
89 ["model:codex/gpt-6.1" + ")".repeat(128), ["codex/gpt-6.1"]],
90 ])("bounds the reference in %s", (text, expected) => {
91 const { mentions, rescans } = (() => {
92 using split = vi.spyOn(String.prototype, "split");
93 const mentions = collectComposerModelMentions(text);
94 return { mentions, rescans: split.mock.calls.length };
95 })();
96 expect(mentions.map(({ instanceId, model }) => `${instanceId}/${model}`)).toEqual(expected);
97 expect(rescans).toBeLessThanOrEqual(expected.length * 4);
98 });
99
100 it.each(["remodel:codex/gpt", "model:codex", "model:/gpt", "model:codex/.", "model:1x/gpt"])(
101 "ignores %s",
102 (text) => {
103 expect(collectComposerModelMentions(text)).toEqual([]);
104 },
105 );
106});