mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
* feat(agents): add first-class Muse Code harness Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage. Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com> Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com> Co-authored-by: John Cusack <johncusackccm@gmail.com> Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com> * test(agents): cover Muse remote hook registration * test(agents): cover Muse hook and source-control contracts * test(agents): exclude Muse hook metadata from script mode check * test(agents): keep Muse skill picker coverage stable * test(ai-vault): include Muse in every-agent fixture * test(mobile): repin Muse agent icon closure * fix(muse): detect questions and approvals from structured Muse signals Muse 1.3 fires no hook for request_user_input, so a pending question left the pane "working". Its internal reminder subagents also post hooks with their own session ids (even after Stop), which surfaced "tool failed" rows and flipped finished panes back to working. - Read pending questions from Muse's session log (user_input_prompt_requested/settled) via the existing transcript poll, now generalized from Codex subagents to Muse on main and relay. - Drop child-session hooks (SubagentStart ids, or turn_id === session_id). - Treat Notification permission_prompt as the approval wait; PermissionRequest also fires for auto-approved calls, so it only caches the approval card. - Ignore Notification copy as the prompt; poll replays are not new prompts or turn boundaries. - Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook. * perf(muse): parse only question events from the session log Most Muse session-log lines are large model/tool records. Filter raw lines by the user_input_prompt_ marker before JSON.parse via an optional readJsonlCursor line filter. * fix(muse): unwrap batched log records and scope questions to the live turn Review follow-ups: question events inside retained_frame batches were skipped, and a question left open by a crash or interrupt stayed pending for the pane's life. Share the history scanner's retained_frame unwrapper, and only report a pending question whose run_id matches the hook turn_id. * refactor(muse): drop type assertion in retained_frame unwrap * fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy --------- Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
734 lines
23 KiB
TypeScript
734 lines
23 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
COMMIT_MESSAGE_AGENT_SPECS,
|
|
CUSTOM_AGENT_ID,
|
|
DEFAULT_COMMIT_MESSAGE_AGENT_ID,
|
|
getCommitMessageAgentCapability,
|
|
getCommitMessageAgentSpec,
|
|
getCommitMessageModelCapability,
|
|
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',
|
|
'kimi',
|
|
'muse',
|
|
'omp',
|
|
'opencode',
|
|
'opencode2',
|
|
'pi'
|
|
])
|
|
})
|
|
|
|
it('uses the strongest available defaults for core agents', () => {
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('sonnet')
|
|
expect(COMMIT_MESSAGE_AGENT_SPECS.codex?.defaultModelId).toBe('gpt-5.5')
|
|
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('defaults the agent picker to Claude', () => {
|
|
expect(DEFAULT_COMMIT_MESSAGE_AGENT_ID).toBe('claude')
|
|
})
|
|
|
|
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.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.5'
|
|
})
|
|
expect(codex).not.toHaveProperty('binary')
|
|
expect(codex).not.toHaveProperty('buildArgs')
|
|
expect(getCommitMessageModelCapability('codex', 'gpt-5.4-mini')?.thinkingLevels).toBeDefined()
|
|
})
|
|
})
|
|
|
|
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('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',
|
|
'default'
|
|
])
|
|
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',
|
|
'default',
|
|
'--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',
|
|
'default'
|
|
])
|
|
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',
|
|
'default'
|
|
])
|
|
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')
|
|
})
|
|
})
|