mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
* fix(source-control): use Codex's configured model by default Source Control AI pinned Codex to gpt-5.5, which Codex retires on 2026-10-14. Any path that still resolves to that slug would then break commit-message and PR-field generation. Follow the Antigravity precedent (#21606): add a "Config default" entry for Codex, make it the default, and omit --model when it is selected so `codex exec` uses the model from the user's Codex config or Codex's own default. Explicit model choices still pass --model. Older remote servers would still build `--model default`, so advertise git.codex-configured-model.v1 and have clients refuse the sentinel for servers without it, the same way Antigravity is gated. The gate now covers both agents, and the two capabilities live in their own module because protocol-version.ts is at the max-lines limit. Written with AI assistance (Claude Code). Fixes #24481 * fix(source-control): honor -m, repo overrides and low effort for Codex Review on #24495 found three gaps in the configured-model change. The remote compatibility gate only recognized --model, so a recipe passing Codex's -m short flag was rejected on older servers. The gate also ignored the repository's per-operation model override that the server applies. And moving Codex to Config default dropped the low reasoning effort the pinned model used, which would change cost and latency for users whose Codex config sets a higher effort. * fix(source-control): match gate repo and model checks to the server Repo ids can repeat across hosts, so the configured-model gate now reads the repo row for the worktree's host instead of the first id match, the same row the server applies. A recipe passing --model default or -m default no longer counts as an explicit model, since an older server still forwards that sentinel to the Codex CLI. * fix(source-control): read only the worktree host's repo row in the gate A worktree that names its own host must not fall back to the runtime host's repo row, since the server applies the row for the worktree's host. Also cover an environment id that needs URL encoding. * fix(source-control): default Codex to GPT-5.6 Terra low --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
805 lines
26 KiB
TypeScript
805 lines
26 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
COMMIT_MESSAGE_AGENT_SPECS,
|
|
CUSTOM_AGENT_ID,
|
|
getCommitMessageAgentCapability,
|
|
getCommitMessageAgentSpec,
|
|
getCommitMessageModel,
|
|
isCustomAgentId,
|
|
listCommitMessageAgentCapabilities,
|
|
listCommitMessageAgentIds,
|
|
resolveCommitMessageAgentChoice
|
|
} from './commit-message-agent-spec'
|
|
import {
|
|
COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS,
|
|
parseAntigravityModels,
|
|
parseClaudeModels,
|
|
parseCodexModels,
|
|
parseCursorModels,
|
|
parseLineModels,
|
|
parsePiModels
|
|
} from './commit-message-model-parsers'
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
|
|
it('exposes the installed local agents as commit-message agents', () => {
|
|
const ids = listCommitMessageAgentIds().sort()
|
|
expect(ids).toEqual([
|
|
'amp',
|
|
'antigravity',
|
|
'claude',
|
|
'codex',
|
|
'copilot',
|
|
'cursor',
|
|
'dsh',
|
|
'jcode',
|
|
'kimi',
|
|
'muse',
|
|
'omp',
|
|
'opencode',
|
|
'opencode2',
|
|
'pi'
|
|
])
|
|
})
|
|
|
|
it('uses the expected defaults for core agents', () => {
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('sonnet')
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.codex?.defaultModelId).toBe('gpt-5.6-terra')
|
|
expect(getCommitMessageModel('codex', 'gpt-5.6-terra')?.defaultThinkingLevel).toBe('low')
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.pi?.defaultModelId).toBe('default')
|
|
})
|
|
|
|
it('uses --prompt (not Claude --print) for Kimi non-interactive generation', () => {
|
|
// Why: kimi-code 0.31+ rejects --print; non-interactive mode is --prompt/-p (#11669).
|
|
const spec = COMMIT_MESSAGE_AGENT_SPECS.kimi
|
|
expect(spec).toBeDefined()
|
|
expect(spec!.promptDelivery).toBe('argv')
|
|
const args = spec!.buildArgs({
|
|
prompt: 'Name a branch for adding login',
|
|
model: 'kimi-code/kimi-for-coding',
|
|
thinkingLevel: 'on'
|
|
})
|
|
expect(args).toContain('--prompt')
|
|
expect(args).not.toContain('--print')
|
|
// Why: with argv delivery the prompt is the value of --prompt.
|
|
const promptIndex = args.indexOf('--prompt')
|
|
expect(promptIndex).toBeGreaterThanOrEqual(0)
|
|
expect(args[promptIndex + 1]).toBe('Name a branch for adding login')
|
|
expect(args).toContain('--quiet')
|
|
expect(args).toContain('--thinking')
|
|
expect(args).toEqual(expect.arrayContaining(['--model', 'kimi-code/kimi-for-coding']))
|
|
})
|
|
|
|
it('uses Muse exec for non-interactive Source Control AI generation', () => {
|
|
const spec = COMMIT_MESSAGE_AGENT_SPECS.muse
|
|
expect(spec).toBeDefined()
|
|
expect(spec?.promptDelivery).toBe('argv')
|
|
expect(spec?.buildArgs({ prompt: 'Write a concise commit message', model: 'default' })).toEqual(
|
|
[
|
|
'exec',
|
|
'--no-session-log',
|
|
'--approval-mode',
|
|
'never',
|
|
'--disable-sandbox',
|
|
'--disable-shell',
|
|
'--disable-write',
|
|
'--disable-web-tools',
|
|
'--',
|
|
'Write a concise commit message'
|
|
]
|
|
)
|
|
})
|
|
|
|
it('uses the provider-qualified Kimi model id accepted by the CLI', () => {
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.kimi?.models.map((m) => m.id)).toEqual([
|
|
'default',
|
|
'kimi-code/kimi-for-coding'
|
|
])
|
|
})
|
|
|
|
it('maps Kimi thinking off and omission to distinct argv', () => {
|
|
const spec = COMMIT_MESSAGE_AGENT_SPECS.kimi!
|
|
const offArgs = spec.buildArgs({ prompt: 'PROMPT', model: 'default', thinkingLevel: 'off' })
|
|
const defaultArgs = spec.buildArgs({ prompt: 'PROMPT', model: 'default' })
|
|
|
|
expect(offArgs).toContain('--no-thinking')
|
|
expect(offArgs).not.toContain('--thinking')
|
|
expect(defaultArgs).not.toContain('--thinking')
|
|
expect(defaultArgs).not.toContain('--no-thinking')
|
|
})
|
|
|
|
it('omits Kimi --model for the config default and an empty model', () => {
|
|
const spec = COMMIT_MESSAGE_AGENT_SPECS.kimi!
|
|
|
|
for (const model of ['default', '']) {
|
|
expect(spec.buildArgs({ prompt: 'PROMPT', model })).not.toContain('--model')
|
|
}
|
|
})
|
|
|
|
it('lists Copilot hosted CLI models even when account policy filters the picker', () => {
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.defaultModelId).toBe('gpt-5.4')
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.models.map((m) => m.id)).toEqual([
|
|
'auto',
|
|
'claude-haiku-4.5',
|
|
'claude-sonnet-4.5',
|
|
'claude-sonnet-4.6',
|
|
'claude-opus-4.5',
|
|
'claude-opus-4.6',
|
|
'claude-opus-4.6-fast',
|
|
'claude-opus-4.7',
|
|
'gpt-4.1',
|
|
'gpt-5-mini',
|
|
'gpt-5.2',
|
|
'gpt-5.2-codex',
|
|
'gpt-5.3-codex',
|
|
'gpt-5.4',
|
|
'gpt-5.4-mini',
|
|
'gpt-5.5'
|
|
])
|
|
})
|
|
|
|
it('treats disabled default agents as unavailable for implicit Source Control AI choices', () => {
|
|
expect(resolveCommitMessageAgentChoice(null, 'codex', ['codex'])).toBe('claude')
|
|
expect(resolveCommitMessageAgentChoice(null, null, ['claude'])).toBeNull()
|
|
expect(resolveCommitMessageAgentChoice('codex', null, ['codex'])).toBe('codex')
|
|
})
|
|
|
|
it('gives every model with thinking levels a valid default', () => {
|
|
for (const spec of Object.values(COMMIT_MESSAGE_AGENT_SPECS)) {
|
|
if (!spec) {
|
|
continue
|
|
}
|
|
for (const model of spec.models) {
|
|
if (model.thinkingLevels) {
|
|
expect(model.defaultThinkingLevel).toBeDefined()
|
|
expect(model.thinkingLevels.some((l) => l.id === model.defaultThinkingLevel)).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('exposes thinking levels on the Spark variant (it accepts model_reasoning_effort)', () => {
|
|
const spark = getCommitMessageModel('codex', 'gpt-5.3-codex-spark')
|
|
expect(spark).toBeDefined()
|
|
expect(spark?.thinkingLevels?.map((l) => l.id)).toEqual(['low', 'medium', 'high', 'xhigh'])
|
|
expect(spark?.defaultThinkingLevel).toBe('low')
|
|
})
|
|
|
|
it('omits thinking levels on Claude Haiku (non-reasoning model)', () => {
|
|
const haiku = getCommitMessageModel('claude', 'haiku')
|
|
expect(haiku).toBeDefined()
|
|
expect(haiku?.thinkingLevels).toBeUndefined()
|
|
expect(haiku?.defaultThinkingLevel).toBeUndefined()
|
|
})
|
|
|
|
it('identifies the custom sentinel via isCustomAgentId', () => {
|
|
expect(isCustomAgentId(CUSTOM_AGENT_ID)).toBe(true)
|
|
expect(isCustomAgentId('claude')).toBe(false)
|
|
expect(isCustomAgentId('codex')).toBe(false)
|
|
expect(isCustomAgentId(null)).toBe(false)
|
|
expect(isCustomAgentId(undefined)).toBe(false)
|
|
})
|
|
|
|
it('does not list "custom" alongside preset agent ids', () => {
|
|
expect(listCommitMessageAgentIds()).not.toContain(CUSTOM_AGENT_ID)
|
|
})
|
|
|
|
it('orders Codex models by version descending to match the official picker', () => {
|
|
const ids = COMMIT_MESSAGE_AGENT_SPECS.codex?.models.map((m) => m.id)
|
|
expect(ids).toEqual([
|
|
'gpt-5.6-terra',
|
|
'gpt-5.5',
|
|
'gpt-5.4',
|
|
'gpt-5.4-mini',
|
|
'gpt-5.3-codex',
|
|
'gpt-5.3-codex-spark',
|
|
'gpt-5.2'
|
|
])
|
|
})
|
|
|
|
it('exposes UI capabilities without spawn details', () => {
|
|
const capabilities = listCommitMessageAgentCapabilities()
|
|
expect(capabilities.map((capability) => capability.id)).toContain('opencode')
|
|
const codex = getCommitMessageAgentCapability('codex')
|
|
expect(codex).toMatchObject({
|
|
id: 'codex',
|
|
label: 'Codex',
|
|
modelSource: 'dynamic',
|
|
defaultModelId: 'gpt-5.6-terra'
|
|
})
|
|
expect(codex).not.toHaveProperty('binary')
|
|
expect(codex).not.toHaveProperty('buildArgs')
|
|
})
|
|
})
|
|
|
|
describe('buildArgs (Claude)', () => {
|
|
const spec = getCommitMessageAgentSpec('claude')!
|
|
|
|
it('passes -p, output format, and model on every call', () => {
|
|
const args = spec.buildArgs({ prompt: '', model: 'haiku' })
|
|
expect(args).toEqual([
|
|
'-p',
|
|
'--output-format',
|
|
'text',
|
|
'--model',
|
|
'haiku',
|
|
'--permission-mode',
|
|
'plan'
|
|
])
|
|
})
|
|
|
|
it('appends --effort when a thinking level is supplied', () => {
|
|
const args = spec.buildArgs({
|
|
prompt: '',
|
|
model: 'sonnet',
|
|
thinkingLevel: 'high'
|
|
})
|
|
expect(args).toEqual([
|
|
'-p',
|
|
'--output-format',
|
|
'text',
|
|
'--model',
|
|
'sonnet',
|
|
'--permission-mode',
|
|
'plan',
|
|
'--effort',
|
|
'high'
|
|
])
|
|
})
|
|
|
|
it('omits --effort when thinkingLevel is not provided', () => {
|
|
const args = spec.buildArgs({ prompt: '', model: 'opus' })
|
|
expect(args).not.toContain('--effort')
|
|
})
|
|
})
|
|
|
|
describe('buildArgs (Jcode)', () => {
|
|
const spec = getCommitMessageAgentSpec('jcode')!
|
|
|
|
it('builds a jcode run argv with the model and prompt', () => {
|
|
const args = spec.buildArgs({ prompt: 'name this branch', model: 'claude-haiku-4-5' })
|
|
expect(args).toEqual([
|
|
'--no-update',
|
|
'--quiet',
|
|
'--no-selfdev',
|
|
'--tool-profile',
|
|
'none',
|
|
'--model',
|
|
'claude-haiku-4-5',
|
|
'run',
|
|
'--json',
|
|
'name this branch'
|
|
])
|
|
})
|
|
|
|
it('omits --model for the config-default choice', () => {
|
|
const args = spec.buildArgs({ prompt: 'name this branch', model: 'default' })
|
|
expect(args).toEqual([
|
|
'--no-update',
|
|
'--quiet',
|
|
'--no-selfdev',
|
|
'--tool-profile',
|
|
'none',
|
|
'run',
|
|
'--json',
|
|
'name this branch'
|
|
])
|
|
})
|
|
|
|
it('exposes no tools to a prompt that is a staged patch', () => {
|
|
// Why: the prompt is attacker-influenced text, and jcode's default profile exposes
|
|
// shell/read/write/MCP. Every sibling generator is already read-only.
|
|
const args = spec.buildArgs({ prompt: 'name this branch', model: 'default' })
|
|
expect(args.slice(args.indexOf('--tool-profile'), args.indexOf('--tool-profile') + 2)).toEqual([
|
|
'--tool-profile',
|
|
'none'
|
|
])
|
|
expect(args.indexOf('--tool-profile')).toBeLessThan(args.indexOf('run'))
|
|
})
|
|
|
|
it('keeps every jcode flag ahead of the subcommand', () => {
|
|
// Why: --no-update/--quiet/--no-selfdev are jcode global options; clap only
|
|
// accepts them before `run`, and --model rides the same position so the argv
|
|
// has one shape rather than two.
|
|
const args = spec.buildArgs({ prompt: 'name this branch', model: 'claude-haiku-4-5' })
|
|
const runIndex = args.indexOf('run')
|
|
expect(runIndex).toBeGreaterThan(0)
|
|
expect(args.slice(0, runIndex).every((arg) => arg.startsWith('--') || arg !== 'run')).toBe(true)
|
|
expect(args.slice(runIndex)).toEqual(['run', '--json', 'name this branch'])
|
|
})
|
|
|
|
it('discovers models from `jcode model list`', () => {
|
|
expect(spec.modelSource).toBe('dynamic')
|
|
expect(spec.modelDiscovery?.binary).toBe('jcode')
|
|
expect(spec.modelDiscovery?.args).toEqual(['--no-update', '--quiet', 'model', 'list'])
|
|
// Real `jcode model list` output: one bare id per line.
|
|
expect(
|
|
spec.modelDiscovery?.parse('claude-opus-5-5\nclaude-haiku-4-5\ngemini-2.5-pro\n')
|
|
).toEqual([
|
|
{ id: 'claude-opus-5-5', label: 'Claude Opus 5 5' },
|
|
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4 5' },
|
|
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }
|
|
])
|
|
})
|
|
|
|
it('defaults the model to the jcode config default', () => {
|
|
expect(spec.defaultModelId).toBe('default')
|
|
})
|
|
})
|
|
|
|
describe('model discovery parsers', () => {
|
|
it('parses Claude list_models output into commit-message models', () => {
|
|
const stdout = `${JSON.stringify({
|
|
type: 'control_response',
|
|
response: {
|
|
subtype: 'success',
|
|
request_id: 'orca-model-discovery',
|
|
response: {
|
|
models: [
|
|
{
|
|
value: 'default',
|
|
displayName: 'Default (recommended)',
|
|
supportsEffort: true,
|
|
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
|
},
|
|
{
|
|
value: 'opus[1m]',
|
|
displayName: 'Opus (1M context)',
|
|
description: 'Opus 5 with 1M context · $5/$25 per Mtok',
|
|
supportsEffort: true,
|
|
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
supportsFastMode: true
|
|
},
|
|
{ value: 'haiku', displayName: 'Haiku' }
|
|
]
|
|
}
|
|
}
|
|
})}\n`
|
|
expect(parseClaudeModels(stdout)).toEqual([
|
|
{
|
|
id: 'opus[1m]',
|
|
label: 'Opus (1M context)',
|
|
description: 'Opus 5 with 1M context · $5/$25 per Mtok',
|
|
thinkingLevels: [
|
|
{ id: 'low', label: 'Low' },
|
|
{ id: 'medium', label: 'Medium' },
|
|
{ id: 'high', label: 'High' },
|
|
{ id: 'xhigh', label: 'Extra High' },
|
|
{ id: 'max', label: 'Max' }
|
|
],
|
|
defaultThinkingLevel: 'low',
|
|
supportsFastMode: true
|
|
},
|
|
{ id: 'haiku', label: 'Haiku' }
|
|
])
|
|
})
|
|
|
|
it('returns no Claude models when the CLI lacks list_models so the seed stays', () => {
|
|
expect(
|
|
parseClaudeModels(
|
|
'{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('declares stdin-driven dynamic discovery for Claude', () => {
|
|
const discovery = COMMIT_MESSAGE_AGENT_SPECS.claude?.modelDiscovery
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.modelSource).toBe('dynamic')
|
|
expect(discovery?.binary).toBe('claude')
|
|
expect(discovery?.args).toEqual([
|
|
'-p',
|
|
'--input-format',
|
|
'stream-json',
|
|
'--output-format',
|
|
'stream-json',
|
|
'--verbose'
|
|
])
|
|
const payload = JSON.parse(discovery?.stdinPayload ?? '') as {
|
|
type?: string
|
|
request?: { subtype?: string }
|
|
}
|
|
expect(payload.type).toBe('control_request')
|
|
expect(payload.request?.subtype).toBe('list_models')
|
|
expect(discovery?.stdinPayload?.endsWith('\n')).toBe(true)
|
|
})
|
|
|
|
it('parses Codex model JSON', () => {
|
|
expect(
|
|
parseCodexModels(
|
|
JSON.stringify({
|
|
models: [
|
|
{
|
|
slug: 'gpt-5.5',
|
|
display_name: 'GPT-5.5',
|
|
default_reasoning_level: 'low',
|
|
supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }]
|
|
}
|
|
]
|
|
})
|
|
)
|
|
).toEqual([
|
|
{
|
|
id: 'gpt-5.5',
|
|
label: 'GPT-5.5',
|
|
thinkingLevels: [
|
|
{ id: 'low', label: 'Low' },
|
|
{ id: 'high', label: 'High' }
|
|
],
|
|
defaultThinkingLevel: 'low'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('rejects excessive Codex model nesting before JSON.parse', () => {
|
|
const parseSpy = vi.spyOn(JSON, 'parse')
|
|
const depth = COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS.nestingDepth + 1
|
|
try {
|
|
expect(parseCodexModels(`${'['.repeat(depth)}0${']'.repeat(depth)}`)).toEqual([])
|
|
expect(parseSpy).not.toHaveBeenCalled()
|
|
} finally {
|
|
parseSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('parses one-model-per-line output', () => {
|
|
expect(parseLineModels('opencode/gpt-5.4-mini\n\nopenai/gpt-5.5\n').map((m) => m.id)).toEqual([
|
|
'opencode/gpt-5.4-mini',
|
|
'openai/gpt-5.5'
|
|
])
|
|
})
|
|
|
|
it('parses Pi model table output with provider-qualified ids', () => {
|
|
const output = [
|
|
'provider model context max-out thinking images',
|
|
'github-copilot gpt-5.4-mini 400K 128K yes yes',
|
|
'github-copilot gpt-4o 128K 4.1K no yes'
|
|
].join('\n')
|
|
|
|
expect(parsePiModels(output)).toEqual([
|
|
{
|
|
id: 'github-copilot/gpt-5.4-mini',
|
|
label: 'Github Copilot GPT 5.4 Mini',
|
|
thinkingLevels: [
|
|
{ id: 'off', label: 'Off' },
|
|
{ id: 'low', label: 'Low' },
|
|
{ id: 'medium', label: 'Medium' },
|
|
{ id: 'high', label: 'High' },
|
|
{ id: 'xhigh', label: 'Extra High' }
|
|
],
|
|
defaultThinkingLevel: 'low'
|
|
},
|
|
{
|
|
id: 'github-copilot/gpt-4o',
|
|
label: 'Github Copilot GPT 4O'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('parses Cursor model output', () => {
|
|
expect(parseCursorModels('auto - Auto\ngpt-5.2 - GPT-5.2\n')).toEqual([
|
|
{ id: 'auto', label: 'Auto' },
|
|
{
|
|
id: 'gpt-5.2',
|
|
label: 'GPT-5.2',
|
|
thinkingLevels: [
|
|
{ id: 'low', label: 'Low' },
|
|
{ id: 'medium', label: 'Medium' },
|
|
{ id: 'high', label: 'High' },
|
|
{ id: 'xhigh', label: 'Extra High' }
|
|
],
|
|
defaultThinkingLevel: 'low'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('parses Antigravity model output', () => {
|
|
const output = [
|
|
'Gemini 3.5 Flash (Medium)',
|
|
'Gemini 3.5 Flash (High)',
|
|
'Gemini 3.5 Flash (Low)',
|
|
'Gemini 3.1 Pro (Low)',
|
|
'Gemini 3.1 Pro (High)',
|
|
'Claude Sonnet 4.6 (Thinking)',
|
|
'Claude Opus 4.6 (Thinking)',
|
|
'GPT-OSS 120B (Medium)'
|
|
].join('\n')
|
|
|
|
expect(parseAntigravityModels(output)).toEqual([
|
|
{ id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' },
|
|
{ id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' },
|
|
{ id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' },
|
|
{ id: 'Gemini 3.1 Pro (Low)', label: 'Gemini 3.1 Pro (Low)' },
|
|
{ id: 'Gemini 3.1 Pro (High)', label: 'Gemini 3.1 Pro (High)' },
|
|
{ id: 'Claude Sonnet 4.6 (Thinking)', label: 'Claude Sonnet 4.6 (Thinking)' },
|
|
{ id: 'Claude Opus 4.6 (Thinking)', label: 'Claude Opus 4.6 (Thinking)' },
|
|
{ id: 'GPT-OSS 120B (Medium)', label: 'GPT-OSS 120B (Medium)' }
|
|
])
|
|
})
|
|
|
|
it('parses CRLF-heavy dynamic model outputs without full line-array splitting', () => {
|
|
const splitSpy = vi.spyOn(String.prototype, 'split')
|
|
const noise = 'ignored model with spaces\r\n'.repeat(10_000)
|
|
const blankNoise = '\r\n'.repeat(10_000)
|
|
|
|
expect(parseLineModels(`${noise}opencode/gpt-5.4-mini\r\nopenai/gpt-5.5\r\n`)).toEqual([
|
|
{
|
|
id: 'opencode/gpt-5.4-mini',
|
|
label: 'Opencode GPT 5.4 Mini',
|
|
thinkingLevels: [
|
|
{ id: 'low', label: 'Low' },
|
|
{ id: 'medium', label: 'Medium' },
|
|
{ id: 'high', label: 'High' },
|
|
{ id: 'xhigh', label: 'Extra High' }
|
|
],
|
|
defaultThinkingLevel: 'low'
|
|
},
|
|
{
|
|
id: 'openai/gpt-5.5',
|
|
label: 'Openai GPT 5.5',
|
|
thinkingLevels: [
|
|
{ id: 'low', label: 'Low' },
|
|
{ id: 'medium', label: 'Medium' },
|
|
{ id: 'high', label: 'High' },
|
|
{ id: 'xhigh', label: 'Extra High' }
|
|
],
|
|
defaultThinkingLevel: 'low'
|
|
}
|
|
])
|
|
expect(
|
|
parsePiModels(
|
|
`${noise}provider model context max-out thinking images\r\ngithub-copilot gpt-5.4-mini 400K 128K yes yes\r\n`
|
|
)[0]?.id
|
|
).toBe('github-copilot/gpt-5.4-mini')
|
|
expect(parseCursorModels(`${noise}auto - Auto\r\ngpt-5.2 - GPT-5.2\r\n`)).toHaveLength(2)
|
|
expect(parseAntigravityModels(`${blankNoise}Gemini 3.5 Flash (Medium)\r\n`)).toEqual([
|
|
{ id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }
|
|
])
|
|
|
|
const usedFullLineSplit = splitSpy.mock.calls.some(
|
|
([separator]) =>
|
|
(typeof separator === 'string' && separator === '\n') ||
|
|
(separator instanceof RegExp && separator.source === '\\r?\\n')
|
|
)
|
|
const usedWhitespaceFieldSplit = splitSpy.mock.calls.some(
|
|
([separator]) => separator instanceof RegExp && separator.source === '\\s+'
|
|
)
|
|
expect(usedFullLineSplit).toBe(false)
|
|
expect(usedWhitespaceFieldSplit).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('buildArgs (Codex)', () => {
|
|
const spec = getCommitMessageAgentSpec('codex')!
|
|
|
|
it('runs `codex exec` without passing the prompt via argv', () => {
|
|
const args = spec.buildArgs({
|
|
prompt: 'PROMPT',
|
|
model: 'gpt-5.4-mini'
|
|
})
|
|
expect(args[0]).toBe('exec')
|
|
expect(args).toEqual([
|
|
'exec',
|
|
'--ephemeral',
|
|
'--skip-git-repo-check',
|
|
'-s',
|
|
'read-only',
|
|
'--model',
|
|
'gpt-5.4-mini'
|
|
])
|
|
expect(args).toContain('--model')
|
|
expect(args).not.toContain('PROMPT')
|
|
expect(spec.promptDelivery).toBe('stdin')
|
|
})
|
|
|
|
it('emits -c model_reasoning_effort=<level> when thinking level is supplied', () => {
|
|
const args = spec.buildArgs({
|
|
prompt: 'PROMPT',
|
|
model: 'gpt-5.4',
|
|
thinkingLevel: 'medium'
|
|
})
|
|
expect(args).toContain('-c')
|
|
expect(args).toContain('model_reasoning_effort=medium')
|
|
})
|
|
|
|
it('omits the -c flag when no thinking level is supplied', () => {
|
|
const args = spec.buildArgs({ prompt: 'PROMPT', model: 'gpt-5.4-mini' })
|
|
expect(args).not.toContain('-c')
|
|
})
|
|
})
|
|
|
|
describe('buildArgs (OpenCode)', () => {
|
|
const spec = getCommitMessageAgentSpec('opencode')!
|
|
|
|
it('runs `opencode run` without passing the prompt via argv', () => {
|
|
const prompt = `PROMPT ${'x'.repeat(1024)}`
|
|
const args = spec.buildArgs({
|
|
prompt,
|
|
model: 'opencode/deepseek-v4-flash-free'
|
|
})
|
|
|
|
expect(args).toEqual([
|
|
'run',
|
|
'--model',
|
|
'opencode/deepseek-v4-flash-free',
|
|
'--agent',
|
|
'build',
|
|
'--format',
|
|
'json'
|
|
])
|
|
expect(args).not.toContain(prompt)
|
|
expect(args).not.toContain('')
|
|
expect(spec.promptDelivery).toBe('stdin')
|
|
})
|
|
|
|
it('emits --variant <level> when thinking level is supplied', () => {
|
|
const args = spec.buildArgs({
|
|
prompt: 'PROMPT',
|
|
model: 'opencode/gpt-5.4-mini',
|
|
thinkingLevel: 'high'
|
|
})
|
|
|
|
expect(args).toEqual([
|
|
'run',
|
|
'--model',
|
|
'opencode/gpt-5.4-mini',
|
|
'--agent',
|
|
'build',
|
|
'--format',
|
|
'json',
|
|
'--variant',
|
|
'high'
|
|
])
|
|
})
|
|
|
|
it('omits --variant when no thinking level is supplied', () => {
|
|
const args = spec.buildArgs({
|
|
prompt: 'PROMPT',
|
|
model: 'opencode/gpt-5.4-mini'
|
|
})
|
|
|
|
expect(args).not.toContain('--variant')
|
|
})
|
|
})
|
|
|
|
describe('buildArgs (OpenCode 2)', () => {
|
|
const spec = getCommitMessageAgentSpec('opencode2')!
|
|
|
|
it('runs `opencode2 run` with stdin delivery', () => {
|
|
const prompt = `PROMPT ${'x'.repeat(1024)}`
|
|
const args = spec.buildArgs({
|
|
prompt,
|
|
model: 'opencode/deepseek-v4-flash-free'
|
|
})
|
|
|
|
expect(args).toEqual([
|
|
'run',
|
|
'--model',
|
|
'opencode/deepseek-v4-flash-free',
|
|
'--agent',
|
|
'build',
|
|
'--format',
|
|
'json'
|
|
])
|
|
expect(args).not.toContain(prompt)
|
|
expect(args).not.toContain('')
|
|
expect(spec.promptDelivery).toBe('stdin')
|
|
})
|
|
|
|
it('inlines the thinking variant as model#variant (v1 --variant is removed in v2)', () => {
|
|
const args = spec.buildArgs({
|
|
prompt: 'PROMPT',
|
|
model: 'opencode/gpt-5.4-mini',
|
|
thinkingLevel: 'high'
|
|
})
|
|
|
|
expect(args).toEqual([
|
|
'run',
|
|
'--model',
|
|
'opencode/gpt-5.4-mini#high',
|
|
'--agent',
|
|
'build',
|
|
'--format',
|
|
'json'
|
|
])
|
|
expect(args).not.toContain('--variant')
|
|
})
|
|
})
|
|
|
|
describe('buildArgs (Antigravity)', () => {
|
|
const spec = getCommitMessageAgentSpec('antigravity')!
|
|
|
|
it('runs agy with the prompt attached to --print, then --sandbox and --model flags', () => {
|
|
const args = spec.buildArgs({
|
|
prompt: 'real commit prompt',
|
|
model: 'Gemini 3.5 Flash (Medium)'
|
|
})
|
|
expect(args).toEqual([
|
|
'--print=real commit prompt',
|
|
'--sandbox',
|
|
'--model',
|
|
'Gemini 3.5 Flash (Medium)'
|
|
])
|
|
expect(spec.promptDelivery).toBe('argv')
|
|
})
|
|
|
|
it('binds a leading-dash prompt to --print instead of letting it parse as an option', () => {
|
|
const args = spec.buildArgs({ prompt: '-fix: something', model: 'Gemini 3.5 Flash (Medium)' })
|
|
expect(args[0]).toBe('--print=-fix: something')
|
|
})
|
|
|
|
// Why: pins argv construction only. Real agy 1.2.1 separately rejects a --print value
|
|
// that exactly matches a registered flag name (its own heuristic, independent of this
|
|
// fix) — verified `agy --print=--sandbox` still errors there. Real prompts are never
|
|
// literally a bare flag name, so this doesn't affect actual generation.
|
|
it('still glues a prompt that collides with a flag name onto --print', () => {
|
|
const args = spec.buildArgs({ prompt: '--sandbox', model: 'Gemini 3.5 Flash (Medium)' })
|
|
expect(args[0]).toBe('--print=--sandbox')
|
|
})
|
|
|
|
it('uses dynamic model discovery via agy models', () => {
|
|
expect(spec.modelSource).toBe('dynamic')
|
|
expect(spec.modelDiscovery?.binary).toBe('agy')
|
|
expect(spec.modelDiscovery?.args).toEqual(['models'])
|
|
})
|
|
|
|
it('uses the configured CLI model instead of a bundled model that can retire', () => {
|
|
expect(spec.defaultModelId).toBe('default')
|
|
expect(
|
|
spec.buildArgs({ prompt: 'Generate a commit message', model: spec.defaultModelId })
|
|
).toEqual(['--print=Generate a commit message', '--sandbox'])
|
|
})
|
|
|
|
it('passes only a nonempty requested effort', () => {
|
|
expect(spec.buildArgs({ prompt: 'P', model: 'default', thinkingLevel: '' })).not.toContain(
|
|
'--effort'
|
|
)
|
|
expect(spec.buildArgs({ prompt: 'P', model: 'default', thinkingLevel: 'high' })).toEqual([
|
|
'--print=P',
|
|
'--sandbox',
|
|
'--effort',
|
|
'high'
|
|
])
|
|
})
|
|
|
|
it('parses current tab-separated IDs without treating progress text as a model', () => {
|
|
expect(
|
|
parseAntigravityModels(
|
|
[
|
|
'Fetching available models...',
|
|
'id\tLabel',
|
|
'gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)',
|
|
'claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)',
|
|
'gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)',
|
|
''
|
|
].join('\r\n')
|
|
)
|
|
).toEqual([
|
|
{ id: 'gemini-3.8-flash-medium', label: 'Gemini 3.8 Flash (Medium)' },
|
|
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (Thinking)' }
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('Pi Source Control AI model selection', () => {
|
|
it('leaves provider selection to Pi for the config default', () => {
|
|
const args = getCommitMessageAgentSpec('pi')!.buildArgs({
|
|
prompt: 'Name a branch',
|
|
model: 'default'
|
|
})
|
|
expect(args).not.toContain('--model')
|
|
})
|
|
|
|
it('passes an explicit discovered Pi model through', () => {
|
|
const args = getCommitMessageAgentSpec('pi')!.buildArgs({
|
|
prompt: 'Name a branch',
|
|
model: 'openai-codex/gpt-5.5'
|
|
})
|
|
const modelFlagIndex = args.indexOf('--model')
|
|
expect(modelFlagIndex).toBeGreaterThanOrEqual(0)
|
|
expect(args[modelFlagIndex + 1]).toBe('openai-codex/gpt-5.5')
|
|
})
|
|
})
|