diff --git a/.cursor/skills/model-selection/SKILL.md b/.cursor/skills/model-selection/SKILL.md index aca56d19..07fb379e 100644 --- a/.cursor/skills/model-selection/SKILL.md +++ b/.cursor/skills/model-selection/SKILL.md @@ -4,34 +4,59 @@ description: >- Escolhe o modelo (e o nível de raciocínio/effort) com melhor custo/valor para cada classe de tarefa do Teqo, e despacha subagentes já no modelo certo via o parâmetro `model` da tool Task. Caminho feliz único: Composer 2.5 ↔ - Grok 4.5 (effort) ↔ Kimi K3 Low (exec bipartida). Usar no início de - implementação, ao despachar subagentes, antes de /simplify, ou quando o - usuário perguntar "qual modelo usar". + Grok 4.5 (effort low|medium|high) ↔ Kimi K3 Low (exec bipartida). Usar no + início de implementação, ao despachar subagentes, antes de /simplify, ou + quando o usuário perguntar "qual modelo usar". --- # Seleção de modelo × effort -Dois eixos, nesta ordem: **(1) classe da tarefa → modelo**, **(2) custo de uma falha → effort**. Preço e contexto compram capacidade; effort compra deliberação. +Dois eixos, nesta ordem: **(1) classe da tarefa → família de modelo**, **(2) se Grok → effort**. Preço e contexto compram capacidade; effort compra deliberação. **Não pule o eixo 2** — High não é o default automático só porque a Issue saiu de Composer. -## Tabela canônica (2026-07-31) +## Tabela canônica (2026-08-01) -| Prioridade | Modelo | Quando | -| ---------- | ------ | ------ | -| 1 (default) | **Composer 2.5** (`composer-2.5`, sem `-fast`) | Features, chores simples, fixes localizados, docs leves — sempre que couber | -| 2 | **Grok 4.5** (`cursor-grok-4.5-*`) | Effort por complexidade (abaixo) | -| 3 | **Kimi K3 Low** (`kimi-k3-low`) | Só na **fase de execução** de issues bipartidas (refactor grande / simplify / migrations+RBAC de blast radius alto), depois de um plano fechado | +| Prioridade | Família | Slug(s) no frontmatter `model:` | Quando | +| ---------- | ------- | -------------------------------- | ------ | +| 1 (default) | **Composer 2.5** | `composer-2.5` | Features, chores simples, fixes localizados, docs leves — sempre que couber | +| 2 | **Grok 4.5** | `cursor-grok-4.5-low` \| `cursor-grok-4.5-medium` \| `cursor-grok-4.5-high` | Precisa de deliberação além do Composer (abaixo) | +| 3 | **Kimi K3 Low** | `kimi-k3-low` | Só na **fase de execução** de issues bipartidas (refactor grande / simplify / migrations+RBAC de blast radius alto), depois de um plano fechado | -**Removidos** desta skill (não usar como 1ª escolha nem fallback documentado): Kimi K2.7, Haiku, GLM, Gemini, Luna. Se a API Cloud rejeitar um slug, o pool já faz fallback para `composer-2.5` no código — não documentar outros modelos aqui. +**Removidos** desta skill (não usar como 1ª escolha nem fallback documentado): Kimi K2.7, Haiku, GLM, Gemini, Luna. O pool mapeia os slugs acima para a API Cloud (`grok-4.5`+`effort`, `kimi-k3`+`reasoning`); slug desconhecido → fallback `composer-2.5`. -### Effort (Grok 4.5) +### Quando sair do Composer → Grok (eixo 1) -| Effort | Uso | -| ------ | --- | -| Low | Bookkeeping, Prettier, merges, rebase, chores mecânicos | -| Medium | Discovery / análise moderada | -| **High** | **Default para issues complexas** (multi-domínio, design não óbvio) | +Suba para Grok só se **pelo menos um** for verdade: -Slug Task de referência: `cursor-grok-4.5-high` (High). Para Low/Medium na sessão principal, use o seletor de modelo/effort do produto — o pool resolve o id da Issue via `resolvePoolModel`. +- design / approach **não óbvio** (várias opções plausíveis, trade-offs reais); +- **discovery** de produto ou síntese de evidência (entrevistas, jargão, ilhas novas); +- **multi-domínio** (auth+schema+UI, Consent/LGPD, paradigm/skills com julgamento); +- critique visual / harvest de guardrails onde o critério é qualitativo. + +Se o caminho está no plano e é execução mecânica → **fique em Composer** (ou Kimi na exec bipartida). + +### Effort Grok (eixo 2) — escolha explícita + +Depois de escolher Grok, **obrigatório** pickar um effort. Escreva o slug completo na Issue (`model: cursor-grok-4.5-…`). + +| Effort | Slug (`model:`) | Nome no produto / Task | Quando | +| ------ | --------------- | ---------------------- | ------ | +| **Low** | `cursor-grok-4.5-low` | Cursor Grok 4.5 · Effort Low | Já é Grok-class, mas o trabalho é **mecânico com leve julgamento**: reescrever skills/docs após decisão travada, cutover de strings/base branch, bookkeeping que Composer erra por falta de contexto do paradigma. **Não** use Low para Prettier/merge puro — isso é Composer. | +| **Medium** | `cursor-grok-4.5-medium` | Cursor Grok 4.5 · Effort Medium | **Discovery / análise moderada**, critique/polish visual, harvest de padrões → guardrail, glossário a partir de evidência, chores de processo com trade-offs claros. Default quando Grok cabe e **não** é multi-domínio de alto risco. | +| **High** | `cursor-grok-4.5-high` | Cursor Grok 4.5 · Effort High | **Multi-domínio ou design não óbvio** com custo de falha alto: fundações (auth/sessão/schema), Consent/LGPD, plan half de bipartição, arquitetura nova. | + +**Proibido:** sufixo `-fast` em qualquer slug (`composer-2.5-fast`, `cursor-grok-4.5-high-fast`, …). O pool **não** envia `fast=true`; se a Issue trouxer `-fast`, o resolver remove o sufixo, resolve a base e emite warn. + +**Heurística anti-viés:** se você ia marcar High por hábito, pergunte “a falha é cara **e** o desenho é aberto?”. Se só uma das duas → Medium (ou Low). Se nenhuma → Composer. + +### Pool × API Cloud + +| Slug na Issue | API `model.id` | params | +| ------------- | -------------- | ------ | +| `cursor-grok-4.5-low` | `grok-4.5` | `effort=low` | +| `cursor-grok-4.5-medium` | `grok-4.5` | `effort=medium` | +| `cursor-grok-4.5-high` | `grok-4.5` | `effort=high` | +| `kimi-k3-low` | `kimi-k3` | `reasoning=low` | +| `composer-2.5` | `composer-2.5` | — | ## Issues muito complexas (bipartir) @@ -45,24 +70,22 @@ Não despachar Kimi K3 Low sem plano fechado na Issue de plan. ## Regras 1. **Composer 2.5 é o default.** Se a tarefa cabe nele, não suba de modelo. -2. **Grok High para complexidade**, não para volume mecânico. -3. **Kimi K3 Low só na exec bipartida** (ou quando a Issue já declara `kimi-k3-low` após um plan). -4. **Nunca economizar em:** migrations (história congelada), access control (`overrideAccess: false`), Consent/LGPD — se for uma Issue só disso e complexa, bipartir (plan Grok High → exec K3). -5. **Usuário pediu modelo específico:** a escolha dele vence; avise uma vez se houver desperdício claro. +2. **Grok exige effort explícito** (`-low` / `-medium` / `-high` no slug). Não deixe `cursor-grok-4.5` sem sufixo; não use High como coringa. +3. **Nunca `-fast`.** Nem em Issue, nem em `Task.model`, nem sob pedido — recusar e usar o slug sem `-fast`. +4. **Kimi K3 Low só na exec bipartida** (ou quando a Issue já declara `kimi-k3-low` após um plan). +5. **Nunca economizar em:** migrations (história congelada), access control (`overrideAccess: false`), Consent/LGPD — se for uma Issue só disso e complexa, bipartir (plan Grok High → exec K3). +6. **Usuário pediu modelo específico:** a escolha dele vence (exceto `-fast`, sempre recusado); avise uma vez se houver desperdício claro. ## Como aplicar -- **Subagentes (`Task.model`):** só estes slugs nesta skill: - - `composer-2.5` - - `cursor-grok-4.5-high` - - `kimi-k3-low` - - `inherit` só quando a classe do subagente é a mesma da sessão +- **Frontmatter da Issue / pool:** use exatamente os slugs da tabela (incl. `-low`/`-medium`/`-high`). +- **Subagentes (`Task.model`):** prefira o slug da Issue quando o enum do produto listar (`composer-2.5`, `cursor-grok-4.5-high`, `kimi-k3-low`, …). **Nunca** escolha variantes `-fast` do enum. Se a Issue é `-low`/`-medium` e o enum não tem o slug, use `inherit` (sessão já no effort certo) ou `cursor-grok-4.5-high` só se a subtarefa for a parte difícil — **não** “promova” a Issue inteira para High. - **Sessão principal:** o agente não troca o próprio modelo. Se o atual não for o da tabela, diga em uma linha e siga. -- **Pool:** `POOL_DEFAULT_MODEL_SLUG` = `composer-2.5`; `resolvePoolModel` cai para Composer se o slug da Issue for desconhecido. +- **Pool:** `POOL_DEFAULT_MODEL_SLUG` = `composer-2.5`; `resolvePoolModel` mapeia os slugs canônicos → API. ## Gates por fluxo -- **`/simplify`:** herda o modelo da sessão. Preferir sessão em Kimi K3 Low **só** quando houver Issue de exec bipartida; caso contrário Composer ou Grok High conforme complexidade. -- **`work-issue`:** verifica `model:` da Issue (assimétrico: sessão mais fraca → informa e segue; mais forte → informa e pausa). Ausente → aplica esta tabela. -- **`plan-issue`:** sugere via esta tabela; em issues muito complexas, registra o par plan/exec. -- **`agent-pool` smoke:** Composer default; Grok High / Kimi K3 Low em Issues de teste bipartidas. +- **`/simplify`:** herda o modelo da sessão. Preferir sessão em Kimi K3 Low **só** quando houver Issue de exec bipartida; caso contrário Composer ou Grok (effort pela tabela) conforme complexidade. +- **`work-issue`:** verifica `model:` da Issue (assimétrico: sessão mais fraca → informa e segue; mais forte → informa e pausa). Ausente → aplica esta tabela **incluindo** o effort Grok. +- **`plan-issue`:** sugere via esta tabela (slug completo com effort); em issues muito complexas, registra o par plan/exec. +- **`agent-pool` smoke:** Composer default; Grok com effort explícito / Kimi K3 Low em Issues de teste bipartidas. diff --git a/.cursor/skills/plan-issue/SKILL.md b/.cursor/skills/plan-issue/SKILL.md index 15e89b0c..8f704d33 100644 --- a/.cursor/skills/plan-issue/SKILL.md +++ b/.cursor/skills/plan-issue/SKILL.md @@ -61,7 +61,7 @@ Ordem topológica intra-lote (o plano do dependente cita o ID do dependido, que Aplique a skill `model-selection` (tabela canônica) por item e registre a sugestão no cabeçalho do plano (`Model:`) e no resumo do gate. Caminho feliz único: - `composer-2.5` — default (features, chores simples, fixes localizados, docs leves) -- `cursor-grok-4.5-high` — issues complexas / discovery (effort High); Low/Medium via seletor para bookkeeping +- `cursor-grok-4.5-low` | `cursor-grok-4.5-medium` | `cursor-grok-4.5-high` — Grok com **effort explícito** (ver model-selection: Low = mecânico com julgamento; Medium = discovery/análise; High = multi-domínio / falha cara) - `kimi-k3-low` — **só** fase de execução de issues bipartidas (refactor / simplify / migration+RBAC de blast radius alto) **Issues muito complexas:** registre **duas** Issues encadeadas — `{id}-plan` (`model: cursor-grok-4.5-high`, entregável = plano + critérios de aceite) e `{id}`/`{id}-exec` (`model: kimi-k3-low`, `depends: [{id}-plan]`). O valor vai ao frontmatter `model:` — propriedade da Issue, verificada (não recalculada) por `work-issue`. diff --git a/.cursor/skills/plan-issue/plan-template.md b/.cursor/skills/plan-issue/plan-template.md index 83faf038..1bf9ae2d 100644 --- a/.cursor/skills/plan-issue/plan-template.md +++ b/.cursor/skills/plan-issue/plan-template.md @@ -12,7 +12,7 @@ Status: rascunho Atualizado em: Issue: # (preencher após `pnpm agent:register`; enquanto não registrado: "—") Priority: -Model: +Model: Impeccable: — Appetite: Responsável: — diff --git a/.cursor/skills/work-issue/SKILL.md b/.cursor/skills/work-issue/SKILL.md index 7884d7e1..72ca936a 100644 --- a/.cursor/skills/work-issue/SKILL.md +++ b/.cursor/skills/work-issue/SKILL.md @@ -99,7 +99,7 @@ Leia a propriedade `model:` do brief e compare com o modelo da sessão atual pel - Propriedade **ausente** → aplique `model-selection` uma vez e registre a escolha na Issue (`gh issue edit ` no body, frontmatter `model:`). - Issue `{id}-exec` / `kimi-k3-low` sem a dep de plan `done` → **pare** (não execute sem plano fechado). -Subagentes despachados via `Task` saem **no modelo da propriedade** quando couber (`Task.model` ∈ `composer-2.5` | `cursor-grok-4.5-high` | `kimi-k3-low`). +Subagentes despachados via `Task` saem **no modelo da propriedade** quando couber (`Task.model` ∈ `composer-2.5` | `cursor-grok-4.5-high` | `kimi-k3-low`; se a Issue for `-low`/`-medium` e o enum não listar, `inherit` — ver model-selection). ## Passo 3 — Freshness audit (enxuto) diff --git a/docs/plans/agent-pool-orchestrator.md b/docs/plans/agent-pool-orchestrator.md index 86ed3202..2f97c082 100644 --- a/docs/plans/agent-pool-orchestrator.md +++ b/docs/plans/agent-pool-orchestrator.md @@ -220,8 +220,9 @@ Parâmetros (só relevantes ao backoff de retry de tick): `spawnRetryBackoff = p - **Namespace:** os slugs do repo (Task tool) e os da Cloud API são tabelas irmãs, não idênticas. O spike (Fase 0) despeja `GET /v1/models` e codifica o mapeamento explícito em `scripts/lib/agent-pool-models.mjs`: `Map` com entradas conhecidas - (`composer-2.5` → id composer 2.5 da API, `composer-2.5-fast` → mesmo id + `params:[{id:'fast',value:'true'}]`, - `cursor-grok-4.5-high`, `kimi-k3-low`, `gemini-3.1-pro`, …). + (`composer-2.5` → id composer 2.5 da API, + `cursor-grok-4.5-{low,medium,high}` → `grok-4.5` + `effort`, + `kimi-k3-low` → `kimi-k3` + `reasoning=low`; **sem** variantes `-fast`). - **Fallback:** `model:` ausente, desconhecido ou rejeitado pela API → **`composer-2.5`** (pool "Cursor Models", custo incluído — regra 1 da model-selection). Warn no sumário do tick. - Validação: o tick consulta `GET /v1/models` uma vez por tick (cache em memória do processo) @@ -319,8 +320,8 @@ ou deixar bloqueada. - Derivação de ativos (fixtures de Issues + runs): occupied / freed-by-done / waiting-auto-merge / failure-path / circuit breaker (2ª falha → blocked permanente). - Cálculo de `gap` e cap hard (nunca spawna além de `maxSlots` mesmo com fila longa). -- Model mapping: slug válido → id Cloud; `-fast` → params; ausente/inválido → `composer-2.5`; - slug fora do `GET /v1/models` → fallback + warn. +- Model mapping: slug válido → id Cloud (+ effort/reasoning); `-fast` **proibido** (strip + warn); + ausente/inválido → `composer-2.5`; slug fora do `GET /v1/models` → fallback + warn. - Prompt: contém `#N`, instrução de não-claim, `gh pr create --base stage`, `Closes #N`. - State: parse/serialize de variables; defaults. diff --git a/scripts/agent-pool.mjs b/scripts/agent-pool.mjs index 45f8869e..baaa43bd 100644 --- a/scripts/agent-pool.mjs +++ b/scripts/agent-pool.mjs @@ -272,7 +272,10 @@ const spawnPoolWorker = async ({ entry, workerUuid, models }) => { { event: 'spawn', agentId: agent.id, runId: run.id, url: agent.url }, `Worker do pool em execução: ${agent.url}`, ) - log(` spawn #${entry.issue.number} → ${agent.url} (modelo ${chosen.model.id})`) + const modelLabel = chosen.model.params?.length + ? `${chosen.model.id}(${chosen.model.params.map((param) => `${param.id}=${param.value}`).join(',')})` + : chosen.model.id + log(` spawn #${entry.issue.number} → ${agent.url} (modelo ${modelLabel})`) } catch (error) { if (error instanceof CursorApiError && error.status === 409) { const existing = await getCursorAgent(agentId) diff --git a/scripts/lib/agent-pool-models.mjs b/scripts/lib/agent-pool-models.mjs index a69faf2c..513004f6 100644 --- a/scripts/lib/agent-pool-models.mjs +++ b/scripts/lib/agent-pool-models.mjs @@ -1,32 +1,89 @@ /** - * Issue `model:` slug → Cursor Cloud `model.id` resolution - * (docs/plans/agent-pool-orchestrator.md §5). + * Issue `model:` slug → Cursor Cloud `model.id` (+ params) resolution + * (docs/plans/agent-pool-orchestrator.md §5; skill model-selection). * * Two namespaces meet here: the repo's model-selection slugs (frontmatter * `model:`) and the Cloud API ids. The live table (`GET /v1/models`) is the - * authority — a slug resolves by exact id or alias, the repo `-fast` suffix - * maps to the model's `fast` param when the table says it exists, and - * anything else falls back to composer-2.5 (pool "Cursor Models", included - * cost — model-selection rule 1). The fallback never crashes a tick. + * authority. + * + * Repo slugs (Issue frontmatter / pool / skills) — **no `-fast`**: + * - `composer-2.5` + * - `cursor-grok-4.5-low` | `cursor-grok-4.5-medium` | `cursor-grok-4.5-high` + * → API `grok-4.5` + `effort` + * - `kimi-k3-low` (also `kimi-k3-high` / `kimi-k3-max`) → API `kimi-k3` + `reasoning` + * + * A slug ending in `-fast` is rejected (stripped + warn, then resolve the + * base). Anything else unknown falls back to composer-2.5. The fallback + * never crashes a tick. */ export const POOL_DEFAULT_MODEL_SLUG = 'composer-2.5' -const FAST_SUFFIX = '-fast' +const GROK_EFFORT_SLUG = /^cursor-grok-4\.5-(low|medium|high)$/ +const KIMI_REASONING_SLUG = /^kimi-k3-(low|high|max)$/ const findModel = (models, slug) => models.find((model) => model.id === slug || (model.aliases ?? []).includes(slug)) ?? null +const paramSupportsValue = (model, paramId, value) => { + const param = (model.parameters ?? []).find((entry) => entry.id === paramId) + if (!param) return false + const values = param.values + if (!Array.isArray(values) || values.length === 0) return true + return values.some((entry) => entry.value === value) +} + +/** + * Resolve a canonical (non-fast) slug against the live table. + * @returns {{ model: { id: string, params?: Array<{ id: string, value: string }> }, ok: true } | { ok: false }} + */ +const resolveCanonical = (slug, apiModels) => { + const grokMatch = GROK_EFFORT_SLUG.exec(slug) + if (grokMatch) { + const effort = grokMatch[1] + const base = + findModel(apiModels, 'grok-4.5') ?? + findModel(apiModels, 'cursor-grok-4.5') ?? + (apiModels.length === 0 ? { id: 'grok-4.5', parameters: [{ id: 'effort' }] } : null) + if (base && paramSupportsValue(base, 'effort', effort)) { + return { + ok: true, + model: { id: base.id, params: [{ id: 'effort', value: effort }] }, + } + } + return { ok: false } + } + + const kimiMatch = KIMI_REASONING_SLUG.exec(slug) + if (kimiMatch) { + const reasoning = kimiMatch[1] + const base = + findModel(apiModels, 'kimi-k3') ?? + (apiModels.length === 0 ? { id: 'kimi-k3', parameters: [{ id: 'reasoning' }] } : null) + if (base && paramSupportsValue(base, 'reasoning', reasoning)) { + return { + ok: true, + model: { id: base.id, params: [{ id: 'reasoning', value: reasoning }] }, + } + } + return { ok: false } + } + + const direct = findModel(apiModels, slug) + if (direct) return { ok: true, model: { id: direct.id } } + return { ok: false } +} + /** * @param {unknown} issueModel frontmatter `model:` value - * @param {Array<{ id: string, aliases?: string[], parameters?: Array<{ id: string }> }>} [apiModels] live /v1/models items + * @param {Array<{ id: string, aliases?: string[], parameters?: Array<{ id: string, values?: Array<{ value: string }> }> }>} [apiModels] live /v1/models items * @returns {{ model: { id: string, params?: Array<{ id: string, value: string }> }, requested: string | null, usedFallback: boolean, warn?: string }} */ export const resolvePoolModel = (issueModel, apiModels = []) => { const requested = typeof issueModel === 'string' && issueModel.trim() !== '' ? issueModel.trim() : null - const fallback = () => { + const fallback = (warn) => { const known = findModel(apiModels, POOL_DEFAULT_MODEL_SLUG) const tableNote = apiModels.length === 0 ? ' (tabela /v1/models indisponível)' : '' return { @@ -35,7 +92,9 @@ export const resolvePoolModel = (issueModel, apiModels = []) => { usedFallback: requested !== null, ...(requested ? { - warn: `modelo "${requested}" fora da tabela Cloud — fallback ${POOL_DEFAULT_MODEL_SLUG}${tableNote}`, + warn: + warn ?? + `modelo "${requested}" fora da tabela Cloud — fallback ${POOL_DEFAULT_MODEL_SLUG}${tableNote}`, } : {}), } @@ -43,20 +102,27 @@ export const resolvePoolModel = (issueModel, apiModels = []) => { if (!requested) return fallback() - if (requested.endsWith(FAST_SUFFIX)) { - const base = findModel(apiModels, requested.slice(0, -FAST_SUFFIX.length)) - const supportsFast = (base?.parameters ?? []).some((param) => param.id === 'fast') - if (base && supportsFast) { - return { - model: { id: base.id, params: [{ id: 'fast', value: 'true' }] }, - requested, - usedFallback: false, - } + let slug = requested + /** @type {string | undefined} */ + let fastWarn + if (slug.endsWith('-fast')) { + slug = slug.slice(0, -'-fast'.length) + fastWarn = `sufixo -fast não é permitido (pedido "${requested}") — resolvendo como "${slug}"` + } + + const resolved = resolveCanonical(slug, apiModels) + if (resolved.ok) { + return { + model: resolved.model, + requested, + usedFallback: false, + ...(fastWarn ? { warn: fastWarn } : {}), } } - const direct = findModel(apiModels, requested) - if (direct) return { model: { id: direct.id }, requested, usedFallback: false } - - return fallback() + return fallback( + fastWarn + ? `${fastWarn}; base também inválida — fallback ${POOL_DEFAULT_MODEL_SLUG}` + : undefined, + ) } diff --git a/tests/unit/agentPoolModels.unit.spec.ts b/tests/unit/agentPoolModels.unit.spec.ts index a15738f6..8124bff1 100644 --- a/tests/unit/agentPoolModels.unit.spec.ts +++ b/tests/unit/agentPoolModels.unit.spec.ts @@ -5,40 +5,82 @@ import { describe, expect, it } from 'vitest' import { POOL_DEFAULT_MODEL_SLUG, resolvePoolModel } from '../../scripts/lib/agent-pool-models.mjs' const API_MODELS = [ - { id: 'composer-2.5', aliases: ['composer-latest'], parameters: [{ id: 'fast' }] }, - { id: 'cursor-grok-4.5-high' }, - { id: 'kimi-k3-low' }, + { + id: 'composer-2.5', + aliases: ['composer-latest'], + parameters: [{ id: 'fast', values: [{ value: 'false' }, { value: 'true' }] }], + }, + { + id: 'grok-4.5', + parameters: [ + { + id: 'effort', + values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }], + }, + { id: 'fast', values: [{ value: 'false' }, { value: 'true' }] }, + ], + }, + { + id: 'kimi-k3', + parameters: [ + { + id: 'reasoning', + values: [{ value: 'low' }, { value: 'high' }, { value: 'max' }], + }, + ], + }, ] describe('resolvePoolModel', () => { - it('resolves a declared slug by exact id', () => { + it('resolves composer by exact id and alias', () => { + expect(resolvePoolModel('composer-2.5', API_MODELS)).toEqual({ + model: { id: 'composer-2.5' }, + requested: 'composer-2.5', + usedFallback: false, + }) + expect(resolvePoolModel('composer-latest', API_MODELS).model).toEqual({ id: 'composer-2.5' }) + }) + + it.each([ + ['cursor-grok-4.5-low', 'low'], + ['cursor-grok-4.5-medium', 'medium'], + ['cursor-grok-4.5-high', 'high'], + ] as const)('maps %s → grok-4.5 effort=%s', (slug, effort) => { + expect(resolvePoolModel(slug, API_MODELS)).toEqual({ + model: { id: 'grok-4.5', params: [{ id: 'effort', value: effort }] }, + requested: slug, + usedFallback: false, + }) + }) + + it('maps kimi-k3-low → kimi-k3 reasoning=low', () => { expect(resolvePoolModel('kimi-k3-low', API_MODELS)).toEqual({ - model: { id: 'kimi-k3-low' }, + model: { id: 'kimi-k3', params: [{ id: 'reasoning', value: 'low' }] }, requested: 'kimi-k3-low', usedFallback: false, }) }) - it('resolves by alias', () => { - const result = resolvePoolModel('composer-latest', API_MODELS) - expect(result.model).toEqual({ id: 'composer-2.5' }) - expect(result.usedFallback).toBe(false) - }) + it('rejects -fast: strips suffix, resolves base, warns', () => { + const composerFast = resolvePoolModel('composer-2.5-fast', API_MODELS) + expect(composerFast.model).toEqual({ id: 'composer-2.5' }) + expect(composerFast.usedFallback).toBe(false) + expect(composerFast.warn).toContain('-fast') - it('maps the repo -fast suffix to the fast param when the table supports it', () => { - const result = resolvePoolModel('composer-2.5-fast', API_MODELS) - expect(result.model).toEqual({ - id: 'composer-2.5', - params: [{ id: 'fast', value: 'true' }], + const grokFast = resolvePoolModel('cursor-grok-4.5-high-fast', API_MODELS) + expect(grokFast.model).toEqual({ + id: 'grok-4.5', + params: [{ id: 'effort', value: 'high' }], }) - expect(result.usedFallback).toBe(false) + expect(grokFast.warn).toContain('-fast') + expect(grokFast.model.params?.some((param) => param.id === 'fast')).toBeFalsy() }) - it('falls back when the base of a -fast slug lacks the fast param', () => { - const result = resolvePoolModel('kimi-k3-low-fast', API_MODELS) + it('falls back when -fast base is also unknown', () => { + const result = resolvePoolModel('modelo-inventado-fast', API_MODELS) expect(result.model).toEqual({ id: POOL_DEFAULT_MODEL_SLUG }) expect(result.usedFallback).toBe(true) - expect(result.warn).toContain('kimi-k3-low-fast') + expect(result.warn).toContain('-fast') }) it('falls back to composer-2.5 with a warn for unknown slugs', () => { @@ -58,7 +100,19 @@ describe('resolvePoolModel', () => { } }) - it('still resolves when the live table is unavailable (degraded mode)', () => { + it('still emits canonical Cloud shapes for known slugs when the table is empty', () => { + expect(resolvePoolModel('cursor-grok-4.5-medium', [])).toEqual({ + model: { id: 'grok-4.5', params: [{ id: 'effort', value: 'medium' }] }, + requested: 'cursor-grok-4.5-medium', + usedFallback: false, + }) + expect(resolvePoolModel('kimi-k3-low', []).model).toEqual({ + id: 'kimi-k3', + params: [{ id: 'reasoning', value: 'low' }], + }) + }) + + it('falls back for unknown slugs when the live table is unavailable', () => { const result = resolvePoolModel('qualquer-coisa', []) expect(result.model).toEqual({ id: POOL_DEFAULT_MODEL_SLUG }) expect(result.warn).toContain('/v1/models')