@zephyrdeng/pi-review 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Pure, testable /rv argument-completion logic.
3
+ *
4
+ * Avoids pi types so tokenizer + preset ordering can be unit-tested.
5
+ * Completion `value` must include the typed head (pi-tui replaces the whole arg).
6
+ */
7
+
8
+ import { type RvLocale, rvUi } from "./rv-locale.js";
9
+ import { semanticCompletionItems, SEMANTIC_PHRASES } from "./rv-semantic.js";
10
+ import {
11
+ loadReviewModelPriorities,
12
+ primaryPresetForProfile,
13
+ rankModelsWithPresets,
14
+ resolvePresetOrderedModels,
15
+ resolveReviewProfile,
16
+ type ReviewModelPriorities,
17
+ type ReviewProfile,
18
+ } from "./rv-model-priorities.js";
19
+
20
+ export type AutocompleteItem = {
21
+ value: string;
22
+ label: string;
23
+ description?: string;
24
+ };
25
+
26
+ export const RV_MODE_VALUES = ["code", "plan", "challenge"] as const;
27
+ export const THINKING_LEVELS = [
28
+ "off",
29
+ "minimal",
30
+ "low",
31
+ "medium",
32
+ "high",
33
+ "xhigh",
34
+ ] as const;
35
+ export const RV_FLAGS = [
36
+ "--mode",
37
+ "--model",
38
+ "--thinking",
39
+ "--keep-session",
40
+ "--no-stream",
41
+ "--continue",
42
+ ] as const;
43
+
44
+ export const MODE_HINTS: Record<string, string> = {
45
+ code: "Code / diff / MR review",
46
+ plan: "Architecture or plan review",
47
+ challenge: "Adversarial plan review",
48
+ };
49
+
50
+ export const FLAG_HINTS: Record<string, string> = {
51
+ "--mode": "Review mode: code | plan | challenge",
52
+ "--model": "provider/model[:thinking]",
53
+ "--thinking": "off | minimal | low | medium | high | xhigh",
54
+ "--keep-session": "Keep session for /rv --continue follow-up",
55
+ "--no-stream": "Buffer output until exit (not default)",
56
+ "--continue": "Resume an existing review session",
57
+ };
58
+
59
+ /** Minimal model metadata we need; converted from the host's Model registry. */
60
+ export interface ModelInfo {
61
+ provider: string;
62
+ id: string;
63
+ /** `${provider}/${id}` */
64
+ label: string;
65
+ name: string;
66
+ reasoning: boolean;
67
+ contextWindow: number;
68
+ /** Lowercased thinking levels the model actually supports; empty if non-reasoning. */
69
+ thinkingLevels: string[];
70
+ }
71
+
72
+ export interface ReviewSignals {
73
+ mode: string;
74
+ hasTarget: boolean;
75
+ targetExt?: string;
76
+ /** Provider of the host session's primary model, for cross-vendor bonus. */
77
+ primaryProvider?: string;
78
+ }
79
+
80
+ export interface CompletionDeps {
81
+ models?: ModelInfo[];
82
+ primaryProvider?: string;
83
+ priorities?: ReviewModelPriorities;
84
+ locale?: RvLocale;
85
+ }
86
+
87
+ function localeOf(deps: CompletionDeps): RvLocale {
88
+ return deps.locale ?? "en";
89
+ }
90
+
91
+ function modeFromSemanticHead(head: string[]): string | undefined {
92
+ const joined = head.join(" ").toLowerCase();
93
+ for (const row of SEMANTIC_PHRASES) {
94
+ if (!row.apply.mode) continue;
95
+ for (const p of row.phrases) {
96
+ if (joined.includes(p.toLowerCase())) return row.apply.mode;
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function getPriorities(deps: CompletionDeps): ReviewModelPriorities {
103
+ return deps.priorities ?? loadReviewModelPriorities();
104
+ }
105
+
106
+ export function reviewProfileForSignals(sig: ReviewSignals): ReviewProfile {
107
+ return resolveReviewProfile(sig.mode, sig.targetExt);
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Tokenizer
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export interface TokenizedArgs {
115
+ /** Fully typed tokens before the in-progress tail. */
116
+ head: string[];
117
+ /** Raw head tokens (quotes preserved) for safe reconstruction. */
118
+ rawHead: string[];
119
+ /** The token currently being typed (may be ""). */
120
+ tail: string;
121
+ /** Last token of `head`, or null. Used to detect value-position flags. */
122
+ prev: string | null;
123
+ }
124
+
125
+ const TOKEN_RE = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g;
126
+
127
+ export function tokenizeArgPrefix(prefix: string): TokenizedArgs {
128
+ const lastSpace = prefix.lastIndexOf(" ");
129
+ if (lastSpace === -1) {
130
+ return { head: [], rawHead: [], tail: prefix, prev: null };
131
+ }
132
+ const headRaw = prefix.slice(0, lastSpace);
133
+ const tail = prefix.slice(lastSpace + 1);
134
+ const matches = headRaw.match(TOKEN_RE) ?? [];
135
+ const rawHead = matches.slice();
136
+ const head = matches.map((t) => t.replace(/^['"]|['"]$/g, ""));
137
+ const prev = head.length > 0 ? head[head.length - 1] : null;
138
+ return { head, rawHead, tail, prev };
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Signal extraction
143
+ // ---------------------------------------------------------------------------
144
+
145
+
146
+ function extOf(token: string): string | undefined {
147
+ const cleaned = token.replace(/^['"]|['"]$/g, "").replace(/^@/, "");
148
+ const m = cleaned.match(/\.([a-zA-Z0-9]+)$/);
149
+ return m ? m[1].toLowerCase() : undefined;
150
+ }
151
+
152
+ export function extractSignals(head: string[], deps: CompletionDeps): ReviewSignals {
153
+ const target = head.find((t) => t.startsWith("@"));
154
+ const modeIdx = head.indexOf("--mode");
155
+ const mode =
156
+ (modeIdx >= 0 ? head[modeIdx + 1] : undefined) ?? modeFromSemanticHead(head) ?? "code";
157
+ return {
158
+ mode,
159
+ hasTarget: Boolean(target),
160
+ targetExt: target ? extOf(target) : undefined,
161
+ primaryProvider: deps.primaryProvider,
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Model ordering: registry + preset priorities (no LLM)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export function rankModelsForReview(
170
+ models: ModelInfo[],
171
+ sig: ReviewSignals,
172
+ priorities?: ReviewModelPriorities,
173
+ ): ModelInfo[] {
174
+ const profile = reviewProfileForSignals(sig);
175
+ return rankModelsWithPresets(models, profile, priorities ?? loadReviewModelPriorities());
176
+ }
177
+
178
+ function presetDescription(
179
+ presetLabel: string,
180
+ profile: ReviewProfile,
181
+ presetRank: number | undefined,
182
+ locale: RvLocale,
183
+ ): string {
184
+ if (presetRank === undefined) return "";
185
+ const ui = rvUi(locale);
186
+ return `${ui.presetTier(presetRank)} · ${profile} · ${presetLabel}`;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Fuzzy filtering (subsequence match, case-insensitive)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export function fuzzyMatch(text: string, query: string): boolean {
194
+ if (!query) return true;
195
+ const t = text.toLowerCase();
196
+ const q = query.toLowerCase();
197
+ let i = 0;
198
+ for (const ch of t) {
199
+ if (ch === q[i]) i++;
200
+ if (i === q.length) return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ function filterList(list: readonly string[], query: string): string[] {
206
+ if (!query) return [...list];
207
+ const prefixed = list.filter((item) => item.startsWith(query));
208
+ if (prefixed.length) return prefixed;
209
+ // Only fall back to subsequence matching when nothing starts with the query.
210
+ return list.filter((item) => fuzzyMatch(item, query));
211
+ }
212
+
213
+ function uniqueFlat(lists: string[][]): string[] {
214
+ const seen = new Set<string>();
215
+ const out: string[] = [];
216
+ for (const list of lists) {
217
+ for (const v of list) {
218
+ if (!seen.has(v)) {
219
+ seen.add(v);
220
+ out.push(v);
221
+ }
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Completion builders
229
+ // ---------------------------------------------------------------------------
230
+
231
+ function wrap(
232
+ candidates: string[],
233
+ headPrefix: string,
234
+ hints?: Record<string, string>,
235
+ ): AutocompleteItem[] | null {
236
+ if (candidates.length === 0) return null;
237
+ return candidates.map((c) => ({
238
+ value: `${headPrefix}${c}`,
239
+ label: c,
240
+ description: hints?.[c],
241
+ }));
242
+ }
243
+
244
+ function thinkingDescription(level: string, sig: ReviewSignals, locale: RvLocale): string {
245
+ const recommended = sig.mode === "code" ? "high" : "xhigh";
246
+ const star = level === recommended ? rvUi(locale).thinkingSuggested : "";
247
+ const map: Record<string, string> = {
248
+ off: "No thinking (fastest)",
249
+ minimal: "Minimal thinking",
250
+ low: "Low effort",
251
+ medium: "Medium effort",
252
+ high: "High effort (common for review)",
253
+ xhigh: "Max effort (plan/challenge)",
254
+ };
255
+ return `${star}${map[level] ?? level}`;
256
+ }
257
+
258
+ function contextLabel(ctx: number): string {
259
+ if (ctx >= 1_000_000) return "1M+ ctx";
260
+ if (ctx >= 1000) return `${Math.round(ctx / 1000)}k ctx`;
261
+ return `${ctx} ctx`;
262
+ }
263
+
264
+ function modelItems(
265
+ models: ModelInfo[],
266
+ sig: ReviewSignals,
267
+ headPrefix: string,
268
+ tail: string,
269
+ deps: CompletionDeps,
270
+ ): AutocompleteItem[] | null {
271
+ const priorities = getPriorities(deps);
272
+ const profile = reviewProfileForSignals(sig);
273
+ const presetRows = resolvePresetOrderedModels(models, profile, priorities);
274
+ const presetByLabel = new Map(presetRows.map((p) => [p.model.label, p]));
275
+ const ranked = rankModelsWithPresets(models, profile, priorities);
276
+ const items = ranked.map((m) => {
277
+ const preset = presetByLabel.get(m.label);
278
+ const tag = profile;
279
+ return {
280
+ value: `${headPrefix}${m.label}`,
281
+ label: m.label,
282
+ description: [
283
+ preset ? presetDescription(preset.presetLabel, profile, preset.presetRank, localeOf(deps)) : "",
284
+ tag,
285
+ m.reasoning ? "reasoning" : "",
286
+ contextLabel(m.contextWindow),
287
+ preset?.thinking ? `preset thinking ${preset.thinking}` : "",
288
+ ]
289
+ .filter(Boolean)
290
+ .join(" · "),
291
+ };
292
+ });
293
+ // Contiguous matching for model ids (subsequence is too loose for ids like
294
+ // "claude-opus" which would match "ope" via o-p...-e).
295
+ const q = tail;
296
+ const filtered = items.filter((it) => {
297
+ const label = it.label;
298
+ return (
299
+ label.startsWith(q) ||
300
+ label.includes(q) ||
301
+ // also allow matching by bare id / provider segment
302
+ label.split("/")[1]?.startsWith(q) ||
303
+ label.split("/")[0]?.startsWith(q)
304
+ );
305
+ });
306
+ return filtered.length ? filtered : null;
307
+ }
308
+
309
+ function thinkingSuffixItems(
310
+ tail: string,
311
+ models: ModelInfo[],
312
+ headPrefix: string,
313
+ sig: ReviewSignals,
314
+ deps: CompletionDeps,
315
+ ): AutocompleteItem[] | null {
316
+ const colonIdx = tail.lastIndexOf(":");
317
+ const modelPart = tail.slice(0, colonIdx);
318
+ const levelPart = tail.slice(colonIdx + 1);
319
+ if (!modelPart.includes("/")) return null;
320
+
321
+ const exact = models.filter((m) => m.label === modelPart);
322
+ const pool = exact.length
323
+ ? exact
324
+ : models.filter((m) => m.label.startsWith(modelPart) || m.id.startsWith(modelPart));
325
+ if (pool.length === 0) return null;
326
+
327
+ const levels = uniqueFlat(pool.map((m) => m.thinkingLevels));
328
+ const filtered = filterList(levels, levelPart);
329
+ if (filtered.length === 0) return null;
330
+ return filtered.map((level) => ({
331
+ value: `${headPrefix}${modelPart}:${level}`,
332
+ label: level,
333
+ description: thinkingDescription(level, sig, localeOf(deps)),
334
+ }));
335
+ }
336
+
337
+ function thinkingSuffixForModel(m: ModelInfo, want?: string): string {
338
+ const lvl =
339
+ want && m.thinkingLevels.includes(want)
340
+ ? want
341
+ : want && m.thinkingLevels.includes("high")
342
+ ? "high"
343
+ : m.thinkingLevels.includes("xhigh")
344
+ ? "xhigh"
345
+ : m.thinkingLevels.includes("high")
346
+ ? "high"
347
+ : (m.thinkingLevels[0] ?? "");
348
+ return lvl ? `:${lvl}` : "";
349
+ }
350
+
351
+ function sceneTemplates(
352
+ models: ModelInfo[],
353
+ sig: ReviewSignals,
354
+ headPrefix: string,
355
+ tail: string,
356
+ deps: CompletionDeps,
357
+ ): AutocompleteItem[] {
358
+ const priorities = getPriorities(deps);
359
+ const codePrimary = primaryPresetForProfile(models, "code", priorities);
360
+ const planPrimary = primaryPresetForProfile(models, "plan", priorities);
361
+ const frontendPrimary = primaryPresetForProfile(models, "frontend", priorities);
362
+
363
+ const ui = rvUi(localeOf(deps));
364
+ const templates: { cmd: string; label: string }[] = [];
365
+ if (codePrimary) {
366
+ const m = codePrimary.model;
367
+ templates.push({
368
+ cmd: `${ui.modeCode} --model ${m.label}${thinkingSuffixForModel(m, codePrimary.thinking ?? "xhigh")} @`,
369
+ label: ui.codePreset,
370
+ });
371
+ }
372
+ if (frontendPrimary) {
373
+ const m = frontendPrimary.model;
374
+ templates.push({
375
+ cmd: `${ui.modeCode} --model ${m.label}${thinkingSuffixForModel(m, frontendPrimary.thinking)} @`,
376
+ label: ui.frontendPreset,
377
+ });
378
+ }
379
+ if (planPrimary) {
380
+ const m = planPrimary.model;
381
+ templates.push({
382
+ cmd: `${ui.modePlan} --model ${m.label}${thinkingSuffixForModel(m, planPrimary.thinking ?? "high")} @`,
383
+ label: ui.planPreset,
384
+ });
385
+ templates.push({
386
+ cmd: `${ui.modeChallenge} ${ui.keepSession} --model ${m.label}${thinkingSuffixForModel(m, "xhigh")} @`,
387
+ label: ui.challengePreset,
388
+ });
389
+ }
390
+
391
+ return templates
392
+ .filter((t) => fuzzyMatch(t.label, tail) || t.cmd.includes(tail))
393
+ .map((t) => ({
394
+ value: `${headPrefix}${t.cmd}`,
395
+ label: t.label,
396
+ description: t.cmd,
397
+ }));
398
+ }
399
+
400
+ function flagItems(head: string[], tail: string, headPrefix: string): AutocompleteItem[] | null {
401
+ // Don't re-suggest boolean flags already present.
402
+ const present = new Set(head);
403
+ const candidates = filterList(RV_FLAGS, tail).filter((f) => {
404
+ if (f === "--keep-session" || f === "--no-stream") return !present.has(f);
405
+ return true;
406
+ });
407
+ return wrap(candidates, headPrefix, FLAG_HINTS);
408
+ }
409
+
410
+ function topLevelCompletions(
411
+ head: string[],
412
+ tail: string,
413
+ deps: CompletionDeps,
414
+ headPrefix: string,
415
+ sig: ReviewSignals,
416
+ ): AutocompleteItem[] | null {
417
+ const items: AutocompleteItem[] = [];
418
+
419
+ // Scene templates (only at top level, before any target/mode chosen is fine).
420
+ if (deps.models?.length) {
421
+ items.push(...sceneTemplates(deps.models, sig, headPrefix, tail, deps));
422
+ }
423
+
424
+ items.push(...semanticCompletionItems(localeOf(deps), tail, headPrefix, head));
425
+
426
+ const ui = rvUi(localeOf(deps));
427
+ if (fuzzyMatch(ui.modelsKeyword, tail) || ui.modelsKeyword.startsWith(tail) || fuzzyMatch("models", tail)) {
428
+ items.push({
429
+ value: `${headPrefix}${ui.listModels}`,
430
+ label: ui.listModels,
431
+ description: ui.listModelsDesc,
432
+ });
433
+ }
434
+
435
+ if (tail.startsWith("--")) {
436
+ const flags = flagItems(head, tail, headPrefix);
437
+ if (flags) items.push(...flags);
438
+ }
439
+
440
+ return items.length ? items : null;
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Public entry point
445
+ // ---------------------------------------------------------------------------
446
+
447
+ export function buildRvCompletions(
448
+ prefix: string,
449
+ deps: CompletionDeps,
450
+ ): AutocompleteItem[] | null {
451
+ const { head, rawHead, tail, prev } = tokenizeArgPrefix(prefix);
452
+ const joinedHead = rawHead.join(" ");
453
+ const headPrefix = joinedHead ? `${joinedHead} ` : "";
454
+ const sig = extractSignals(head, deps);
455
+
456
+ // 1) --model value position (model list or :thinking suffix).
457
+ if (prev === "--model") {
458
+ const colonIdx = tail.lastIndexOf(":");
459
+ if (colonIdx !== -1 && tail.slice(0, colonIdx).includes("/")) {
460
+ const items = thinkingSuffixItems(tail, deps.models ?? [], headPrefix, sig, deps);
461
+ return items;
462
+ }
463
+ return modelItems(deps.models ?? [], sig, headPrefix, tail, deps);
464
+ }
465
+
466
+ // 2) --mode value position.
467
+ if (prev === "--mode") {
468
+ return wrap(filterList(RV_MODE_VALUES, tail), headPrefix, MODE_HINTS);
469
+ }
470
+
471
+ // 3) --thinking value position.
472
+ if (prev === "--thinking") {
473
+ return wrap(filterList(THINKING_LEVELS, tail), headPrefix);
474
+ }
475
+
476
+ // 4) --continue value position: defer (no static list; handler owns it).
477
+ if (prev === "--continue") return null;
478
+
479
+ // 5) Flag completion (tail starts with "--").
480
+ if (tail.startsWith("--")) {
481
+ return flagItems(head, tail, headPrefix);
482
+ }
483
+
484
+ // 6) File attachment in progress → defer to built-in file completion.
485
+ if (tail.startsWith("@")) return null;
486
+
487
+ // 7) Top-level: scene templates + `models` + flags.
488
+ return topLevelCompletions(head, tail, deps, headPrefix, sig);
489
+ }
@@ -0,0 +1,17 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { detectRvLocale } from "./rv-locale.js";
4
+
5
+ describe("detectRvLocale", () => {
6
+ it("returns zh when recent messages are mostly Chinese", () => {
7
+ assert.equal(detectRvLocale(["帮我看一下这个方案", "好的"]), "zh");
8
+ });
9
+
10
+ it("returns en for English-only samples", () => {
11
+ assert.equal(detectRvLocale(["Please review this diff", "Thanks"]), "en");
12
+ });
13
+
14
+ it("defaults to en when empty", () => {
15
+ assert.equal(detectRvLocale([]), "en");
16
+ });
17
+ });
@@ -0,0 +1,81 @@
1
+ /** Lightweight locale for /rv UI strings (no LLM). */
2
+
3
+ export type RvLocale = "en" | "zh";
4
+
5
+ const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/;
6
+
7
+ export function detectRvLocale(textSamples: string[]): RvLocale {
8
+ let cjk = 0;
9
+ let latin = 0;
10
+ for (const sample of textSamples) {
11
+ const t = sample.slice(0, 2000);
12
+ for (const ch of t) {
13
+ if (CJK_RE.test(ch)) cjk++;
14
+ else if (/[a-zA-Z]/.test(ch)) latin++;
15
+ }
16
+ }
17
+ if (cjk === 0 && latin === 0) return "en";
18
+ return cjk >= latin * 0.15 ? "zh" : "en";
19
+ }
20
+
21
+ export type RvUiStrings = {
22
+ modelsKeyword: string;
23
+ modelsDesc: string;
24
+ modeCode: string;
25
+ modePlan: string;
26
+ modeChallenge: string;
27
+ keepSession: string;
28
+ keepSessionDesc: string;
29
+ listModels: string;
30
+ listModelsDesc: string;
31
+ codePreset: string;
32
+ frontendPreset: string;
33
+ planPreset: string;
34
+ challengePreset: string;
35
+ suggested: string;
36
+ presetTier: (n: number) => string;
37
+ thinkingSuggested: string;
38
+ };
39
+
40
+ const UI: Record<RvLocale, RvUiStrings> = {
41
+ en: {
42
+ modelsKeyword: "models",
43
+ modelsDesc: "List pi-review models only",
44
+ modeCode: "code review",
45
+ modePlan: "plan review",
46
+ modeChallenge: "challenge review",
47
+ keepSession: "keep session for follow-up",
48
+ keepSessionDesc: "Persist review session for /rv --continue",
49
+ listModels: "list models",
50
+ listModelsDesc: "Show provider/model catalog",
51
+ codePreset: "Code review (preset)",
52
+ frontendPreset: "Frontend / multimodal review (preset)",
53
+ planPreset: "Plan review (preset)",
54
+ challengePreset: "Challenge review (preset)",
55
+ suggested: "Suggested",
56
+ presetTier: (n) => (n === 0 ? "Suggested" : `Preset #${n + 1}`),
57
+ thinkingSuggested: "Suggested ",
58
+ },
59
+ zh: {
60
+ modelsKeyword: "models",
61
+ modelsDesc: "仅列出 pi-review 可用模型",
62
+ modeCode: "代码审核",
63
+ modePlan: "方案审核",
64
+ modeChallenge: "对抗性审核",
65
+ keepSession: "保留会话可追问",
66
+ keepSessionDesc: "保留 review 会话,便于 /rv --continue",
67
+ listModels: "查看模型列表",
68
+ listModelsDesc: "列出当前可用 provider/model",
69
+ codePreset: "代码审核(推荐配置)",
70
+ frontendPreset: "前端 / 多模态审核(推荐)",
71
+ planPreset: "方案 / 架构审核(推荐)",
72
+ challengePreset: "对抗性方案审核(可追问)",
73
+ suggested: "推荐",
74
+ presetTier: (n) => (n === 0 ? "推荐" : `预设 #${n + 1}`),
75
+ thinkingSuggested: "推荐 ",
76
+ },
77
+ };
78
+
79
+ export function rvUi(locale: RvLocale): RvUiStrings {
80
+ return UI[locale];
81
+ }
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ DEFAULT_REVIEW_MODEL_PRIORITIES,
5
+ matchPresetEntry,
6
+ rankModelsWithPresets,
7
+ resolveReviewProfile,
8
+ type PresetModelEntry,
9
+ } from "./rv-model-priorities.js";
10
+ import type { ModelInfo } from "./rv-completions.js";
11
+
12
+ function m(provider: string, id: string, extra?: Partial<ModelInfo>): ModelInfo {
13
+ return {
14
+ provider,
15
+ id,
16
+ label: `${provider}/${id}`,
17
+ name: id,
18
+ reasoning: true,
19
+ contextWindow: 200_000,
20
+ thinkingLevels: ["high", "xhigh"],
21
+ ...extra,
22
+ };
23
+ }
24
+
25
+ describe("resolveReviewProfile", () => {
26
+ it("uses plan profile for plan/challenge mode", () => {
27
+ assert.equal(resolveReviewProfile("plan", "ts"), "plan");
28
+ assert.equal(resolveReviewProfile("challenge", "md"), "plan");
29
+ });
30
+
31
+ it("uses frontend profile for vue/svelte/css targets", () => {
32
+ assert.equal(resolveReviewProfile("code", "vue"), "frontend");
33
+ assert.equal(resolveReviewProfile("code", "css"), "frontend");
34
+ });
35
+
36
+ it("defaults to code profile", () => {
37
+ assert.equal(resolveReviewProfile("code", "ts"), "code");
38
+ });
39
+ });
40
+
41
+ describe("matchPresetEntry", () => {
42
+ const registry = [
43
+ m("openai", "gpt-5.5"),
44
+ m("zhipu", "glm-5.2-chat"),
45
+ m("moonshot", "kimi-k2.7-preview"),
46
+ m("moonshot", "kimi-k2.5"),
47
+ m("anthropic", "claude-opus-4-8"),
48
+ m("anthropic", "claude-opus-4-6"),
49
+ m("deepseek", "deepseek-v4-pro"),
50
+ ];
51
+
52
+ it("picks newest kimi when versionPrefer 2.7", () => {
53
+ const entry: PresetModelEntry = { idContains: "kimi", versionPrefer: "2.7" };
54
+ const hit = matchPresetEntry(registry, entry);
55
+ assert.ok(hit);
56
+ assert.match(hit!.id, /2\.7|k2\.7/i);
57
+ });
58
+
59
+ it("ranks gpt-5.5 first for code presets", () => {
60
+ const ordered = rankModelsWithPresets(registry, "code", DEFAULT_REVIEW_MODEL_PRIORITIES);
61
+ assert.equal(ordered[0].id, "gpt-5.5");
62
+ assert.equal(ordered[1].id, "glm-5.2-chat");
63
+ });
64
+
65
+ it("ranks claude-opus-4-8 before older opus for plan", () => {
66
+ const ordered = rankModelsWithPresets(registry, "plan", DEFAULT_REVIEW_MODEL_PRIORITIES);
67
+ assert.equal(ordered[0].id, "claude-opus-4-8");
68
+ });
69
+ });