Files
orca/src/shared/commit-message-prompt.test.ts
T
2026-05-30 12:44:04 -07:00

237 lines
8.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildCommitPrompt,
cleanGeneratedCommitMessage,
extractAgentErrorMessage,
planCustomCommand,
STAGED_DIFF_BYTE_BUDGET,
tokenizeCustomCommandTemplate,
truncateDiffForPrompt
} from './commit-message-prompt'
describe('buildCommitPrompt', () => {
it('embeds the diff into the base prompt', () => {
const prompt = buildCommitPrompt('diff --git a/foo b/foo\n+hello', '')
expect(prompt).toContain('diff --git a/foo b/foo')
expect(prompt).toContain('+hello')
expect(prompt).toContain('First line: imperative mood')
})
it('appends a custom suffix when non-empty', () => {
const prompt = buildCommitPrompt('diff', 'Use Conventional Commits.')
expect(prompt).toContain('Additional user prompt:')
expect(prompt.endsWith('Use Conventional Commits.')).toBe(true)
})
it('does not append the suffix block for whitespace-only suffixes', () => {
const prompt = buildCommitPrompt('diff', ' \n ')
expect(prompt).not.toContain('Additional user prompt:')
})
})
describe('truncateDiffForPrompt', () => {
it('returns the diff unchanged when within budget', () => {
const diff = 'line\n'.repeat(10)
expect(truncateDiffForPrompt(diff)).toBe(diff)
})
it('truncates and appends a marker when over budget', () => {
const oversized = 'A'.repeat(STAGED_DIFF_BYTE_BUDGET + 100)
const result = truncateDiffForPrompt(oversized)
expect(result.length).toBeLessThan(oversized.length)
expect(result).toMatch(/diff truncated, 100 bytes omitted/)
})
it('honors a custom budget', () => {
const result = truncateDiffForPrompt('abcdefghij', 5)
expect(result.startsWith('abcde')).toBe(true)
expect(result).toMatch(/diff truncated, 5 bytes omitted/)
})
})
describe('cleanGeneratedCommitMessage', () => {
it('trims whitespace', () => {
expect(cleanGeneratedCommitMessage(' feat: hello \n')).toBe('feat: hello')
})
it('strips a single enclosing fenced code block', () => {
const raw = '```\nfeat: hello\n```'
expect(cleanGeneratedCommitMessage(raw)).toBe('feat: hello')
})
it('strips a fenced block with a language tag', () => {
const raw = '```text\nfix: bug\n```'
expect(cleanGeneratedCommitMessage(raw)).toBe('fix: bug')
})
it('drops a leading "Generating…" preamble line', () => {
const raw = 'Generating…\nfeat: hello world'
expect(cleanGeneratedCommitMessage(raw)).toBe('feat: hello world')
})
it('normalizes CRLF line endings', () => {
expect(cleanGeneratedCommitMessage('feat: a\r\nbody line\r\n')).toBe('feat: a\nbody line')
})
it('strips a leading list marker from the commit subject', () => {
expect(cleanGeneratedCommitMessage('● Add Copilot entry to agent results')).toBe(
'Add Copilot entry to agent results'
)
expect(cleanGeneratedCommitMessage('1. Add numbered entry')).toBe('Add numbered entry')
})
it('returns empty string when input is whitespace', () => {
expect(cleanGeneratedCommitMessage(' \n\t')).toBe('')
})
})
describe('extractAgentErrorMessage', () => {
it('returns the inner message from a Codex JSON error payload', () => {
const stderr = [
'--------',
'workdir: C:\\Storage\\Projects\\bagplanner',
'model: gpt-5.3-codex-spark',
'reasoning effort: medium',
'--------',
'user',
'You are generating a single git commit message...',
'hook: SessionStart',
'hook: SessionStart Completed',
'ERROR: {"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The \'gpt-5.3-codex-spark\' model is not supported when using Codex with a ChatGPT account."}}'
].join('\n')
expect(extractAgentErrorMessage('', stderr)).toBe(
"The 'gpt-5.3-codex-spark' model is not supported when using Codex with a ChatGPT account."
)
})
it('returns the payload for non-JSON error lines', () => {
const out = 'preamble line\nERROR: {bad json oops'
expect(extractAgentErrorMessage(out, '')).toBe('{bad json oops')
})
it('uses the last ERROR line when several are emitted', () => {
const out = ['ERROR: first failure', 'retry message', 'ERROR: second failure'].join('\n')
expect(extractAgentErrorMessage(out, '')).toBe('second failure')
})
it('matches an `Error:` line emitted on stdout', () => {
expect(extractAgentErrorMessage('Error: model unavailable\n', '')).toBe('model unavailable')
})
it('matches ANSI-colored `Error:` lines emitted by CLIs', () => {
expect(
extractAgentErrorMessage('', '\u001b[91m\u001b[1mError: \u001b[0mNo payment method\n')
).toBe('No payment method')
})
it('matches tool-specific `Error during ...:` lines', () => {
expect(
extractAgentErrorMessage(
'',
'Error during droid execution: Authentication failed. Please log into Factory.\n'
)
).toBe('Authentication failed. Please log into Factory.')
})
it('matches wrapped provider error-code payloads with quoted message fields', () => {
const stdout = [
"Error code: 401 - {'error': {'message': 'The API Key appears to be invalid or ma",
"y have expired. Please verify your credentials and try again.', 'type': 'invalid",
"_authentication_error'}}"
].join('\n')
expect(extractAgentErrorMessage(stdout, '')).toBe(
'The API Key appears to be invalid or may have expired. Please verify your credentials and try again.'
)
})
it('returns null when no ERROR line is present', () => {
expect(extractAgentErrorMessage('plain log\nmore log\n', '')).toBeNull()
})
it('returns the JSON payload `message` field when no nested error is set', () => {
const out = 'ERROR: {"message":"top-level only"}'
expect(extractAgentErrorMessage(out, '')).toBe('top-level only')
})
})
describe('tokenizeCustomCommandTemplate', () => {
it('splits on whitespace', () => {
const r = tokenizeCustomCommandTemplate('claude -p')
expect(r).toEqual({ ok: true, tokens: ['claude', '-p'] })
})
it('groups double-quoted segments with spaces', () => {
const r = tokenizeCustomCommandTemplate('claude --msg "hello world"')
expect(r).toEqual({ ok: true, tokens: ['claude', '--msg', 'hello world'] })
})
it('groups single-quoted segments verbatim', () => {
const r = tokenizeCustomCommandTemplate(`agent --json '{"k":"v"}'`)
expect(r).toEqual({ ok: true, tokens: ['agent', '--json', '{"k":"v"}'] })
})
it('honors backslash escapes inside double quotes', () => {
const r = tokenizeCustomCommandTemplate('claude --msg "she said \\"hi\\""')
expect(r).toEqual({ ok: true, tokens: ['claude', '--msg', 'she said "hi"'] })
})
it('keeps adjacent quoted/unquoted regions in one token (a"b"c → abc)', () => {
const r = tokenizeCustomCommandTemplate('foo a"b"c')
expect(r).toEqual({ ok: true, tokens: ['foo', 'abc'] })
})
it('returns an error for an unclosed quote', () => {
const r = tokenizeCustomCommandTemplate('claude --msg "no end')
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.error).toMatch(/unclosed/i)
}
})
it('returns an empty token list for whitespace-only input', () => {
const r = tokenizeCustomCommandTemplate(' \t ')
expect(r).toEqual({ ok: true, tokens: [] })
})
})
describe('planCustomCommand', () => {
it('routes prompt via stdin when {prompt} is absent', () => {
const r = planCustomCommand('claude -p', 'COMMIT MSG')
expect(r).toEqual({ ok: true, binary: 'claude', args: ['-p'], stdinPayload: 'COMMIT MSG' })
})
it('substitutes {prompt} as a whole token via argv', () => {
const r = planCustomCommand('codex exec {prompt}', 'PROMPT')
expect(r).toEqual({ ok: true, binary: 'codex', args: ['exec', 'PROMPT'], stdinPayload: null })
})
it('treats "{prompt}" identically to bare {prompt} (no shell, no double-quoting)', () => {
const a = planCustomCommand('codex exec {prompt}', 'PROMPT')
const b = planCustomCommand('codex exec "{prompt}"', 'PROMPT')
expect(a).toEqual(b)
})
it('substitutes {prompt} embedded inside a token', () => {
const r = planCustomCommand('agent --msg={prompt}', 'PROMPT')
expect(r).toEqual({
ok: true,
binary: 'agent',
args: ['--msg=PROMPT'],
stdinPayload: null
})
})
it('errors on empty templates', () => {
const r = planCustomCommand(' ', 'PROMPT')
expect(r.ok).toBe(false)
})
it('propagates tokenizer errors', () => {
const r = planCustomCommand('agent "unclosed', 'PROMPT')
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.error).toMatch(/unclosed/i)
}
})
})