mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* fix(claude): keep Orca's background Claude runs off the user's MCP servers and hooks The model picker and / menu listings, chat naming, and commit message, PR, branch and Jira text generation each start a short Claude run. Every one but the / probe loaded the user's MCP servers and hooks, so each run started every server (one cut off a Notion OAuth refresh and lost the sign-in) and fired SessionStart hooks. Every background run now passes --strict-mcp-config and disableAllHooks. A user's own inline --settings merges into the one settings value Claude keeps, and their --mcp-config still loads. When Claude refuses strict mode because an enterprise MCP config is present (which already keeps user servers out), the run is retried once without the flag. Tracked in STA-11095 and #30411. * fix: centralize background Claude generation and retry policy * fix: rebuild Claude background retries from generated arguments * test: include branch in generation retry fixture * fix: preserve user settings arguments in background generation * test: define retry fixture streams across runtimes
809 lines
26 KiB
TypeScript
809 lines
26 KiB
TypeScript
import { claudeBackgroundArgs } from './claude-background-run-isolation'
|
|
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',
|
|
...claudeBackgroundArgs()
|
|
])
|
|
})
|
|
|
|
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',
|
|
...claudeBackgroundArgs()
|
|
])
|
|
})
|
|
|
|
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',
|
|
...claudeBackgroundArgs()
|
|
])
|
|
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')
|
|
})
|
|
})
|