mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* fix(commit-message): keep Windows paths intact in agent command overrides `tokenizeCustomCommandTemplate` applies POSIX backslash-escape rules on every platform. On Windows `\` is the path separator, so a native absolute path in an agent command override is silently destroyed: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -> C:WindowsSystem32WindowsPowerShellv1.0powershell.exe which is then reported as not found on PATH. The agent *startup* path already routes Windows shells to the Windows tokenizer, but the commit-message AI path still calls the generic tokenizer directly, so overrides, extra CLI args and custom commands there are all affected. The tokenizer gains an explicit `'escape' | 'literal'` mode rather than reading `process.platform`, because the same template can be parsed on one host and executed on another. `'escape'` stays the default, so POSIX behaviour — where `foo\ bar` is deliberately one token — is unchanged. `'literal'` is selected only where the command provably runs on native Windows: a LOCAL target, on win32, with no WSL distro. A WSL target runs a Linux binary inside the distro, and a remote target runs on a host whose platform this process cannot see; both keep POSIX escaping. Fixes #11375 * test: pin the platform decision for literal-backslash parsing commandBackslashMode is the only place that reads the platform, so it is where this can be wrong in the direction that matters — applying Windows rules to a command that will actually run under a POSIX shell. WSL and remote targets are pinned explicitly; both were previously untested.
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
buildCommitPrompt,
|
|
cleanGeneratedCommitMessage,
|
|
excerptAgentFailureOutput,
|
|
planCustomCommand,
|
|
STAGED_DIFF_BYTE_BUDGET,
|
|
tokenizeCustomCommandTemplate,
|
|
truncateDiffForPrompt
|
|
} from './commit-message-prompt'
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
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 = `${'line\n'.repeat(STAGED_DIFF_BYTE_BUDGET / 5 + 100)}`
|
|
const result = truncateDiffForPrompt(oversized)
|
|
expect(result.length).toBeLessThan(oversized.length)
|
|
expect(result).toMatch(/diff truncated, \d+ bytes omitted/)
|
|
})
|
|
|
|
it('clips on a line boundary so the diff is never cut mid-line', () => {
|
|
const diff = `${'keep this line\n'.repeat(40)}`
|
|
const result = truncateDiffForPrompt(diff, 95)
|
|
const body = result.split('\n...(diff truncated')[0]
|
|
// Every retained line is whole.
|
|
for (const line of body.split('\n').filter(Boolean)) {
|
|
expect(line).toBe('keep this line')
|
|
}
|
|
})
|
|
|
|
it('keeps clipped output within a tight custom budget', () => {
|
|
const files = Array.from(
|
|
{ length: 20 },
|
|
(_, i) => `diff --git a/file-${i}.txt b/file-${i}.txt\n${'+x\n'.repeat(200)}`
|
|
).join('')
|
|
const result = truncateDiffForPrompt(files, 120)
|
|
|
|
expect(result.length).toBeLessThanOrEqual(120)
|
|
})
|
|
|
|
it('shares the budget fairly so a huge file does not starve a small one', () => {
|
|
const hugeFile = `diff --git a/data.jsonl b/data.jsonl\n${'+x\n'.repeat(5000)}`
|
|
const smallFile = 'diff --git a/src/app.ts b/src/app.ts\n+const meaningful = true\n'
|
|
const result = truncateDiffForPrompt(`${hugeFile}${smallFile}`, 1_000)
|
|
|
|
// The small, human-authored change survives instead of being cut off.
|
|
expect(result).toContain('a/src/app.ts')
|
|
expect(result).toContain('const meaningful = true')
|
|
// The huge file is clipped, not the small one.
|
|
expect(result).toMatch(/diff truncated, \d+ 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('cleans large fenced CRLF output without regex-wide normalization', () => {
|
|
const replaceSpy = vi.spyOn(String.prototype, 'replace')
|
|
const matchSpy = vi.spyOn(String.prototype, 'match')
|
|
const fence = '```'
|
|
const raw = `\r\n${fence}text\r\nfeat: large output\r\n${'body line\r\n'.repeat(10_000)}${fence}\r\n`
|
|
|
|
const result = cleanGeneratedCommitMessage(raw)
|
|
|
|
expect(result.startsWith('feat: large output\nbody line')).toBe(true)
|
|
expect(result.endsWith('body line')).toBe(true)
|
|
expect(result).not.toContain('\r\n')
|
|
const usedCrlfReplace = replaceSpy.mock.calls.some(
|
|
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r\\n'
|
|
)
|
|
const usedFenceMatch = matchSpy.mock.calls.some(
|
|
([pattern]) => pattern instanceof RegExp && pattern.source.includes('[\\s\\S]')
|
|
)
|
|
expect(usedCrlfReplace).toBe(false)
|
|
expect(usedFenceMatch).toBe(false)
|
|
})
|
|
|
|
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('excerptAgentFailureOutput', () => {
|
|
// Real Codex failure shape: config preamble first, operative ERROR line last.
|
|
const codexErrorLine =
|
|
'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."}}'
|
|
const codexStderr = [
|
|
'--------',
|
|
'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',
|
|
codexErrorLine
|
|
].join('\n')
|
|
|
|
it('excerpts both ends so a tail-anchored Codex error stays visible', () => {
|
|
expect(excerptAgentFailureOutput('', codexStderr)).toBe(
|
|
`-------- workdir: C:\\Storage\\Projects\\bagplanner … ${codexErrorLine.slice(0, 130).trimEnd()}…`
|
|
)
|
|
})
|
|
|
|
// Real pi 0.80.6 auth failure: primary line and remedy first, doc paths last.
|
|
const piAuthStderr = [
|
|
'No API key found for github-copilot.',
|
|
'',
|
|
'Use /login to log into a provider via OAuth or API key. See:',
|
|
' /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/providers.md',
|
|
' /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/models.md'
|
|
].join('\n')
|
|
|
|
it('keeps a head-anchored pi auth failure visible', () => {
|
|
expect(excerptAgentFailureOutput('', piAuthStderr)).toBe(
|
|
'No API key found for github-copilot. Use /login to log into a provider via OAuth or API key. See: … /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/models.md'
|
|
)
|
|
})
|
|
|
|
it('prefers stderr and never excerpts an echoed prompt from stdout', () => {
|
|
expect(
|
|
excerptAgentFailureOutput(
|
|
'You are generating a single git commit message for /secret/repo',
|
|
'No API key found for openai.'
|
|
)
|
|
).toBe('No API key found for openai.')
|
|
})
|
|
|
|
it('falls back to stdout when stderr is blank', () => {
|
|
expect(excerptAgentFailureOutput('Not logged in · Please run /login', ' \n')).toBe(
|
|
'Not logged in · Please run /login'
|
|
)
|
|
})
|
|
|
|
it('returns null when both streams are blank', () => {
|
|
expect(excerptAgentFailureOutput(' \n\t', '')).toBeNull()
|
|
})
|
|
|
|
it('joins up to three lines without an ellipsis', () => {
|
|
expect(excerptAgentFailureOutput('', 'one\ntwo\nthree\n')).toBe('one two three')
|
|
})
|
|
|
|
it('does not parse or unwrap JSON payloads', () => {
|
|
expect(excerptAgentFailureOutput('', '401: {"message":"Invalid API key provided"}')).toBe(
|
|
'401: {"message":"Invalid API key provided"}'
|
|
)
|
|
})
|
|
|
|
it('strips ANSI colors and OSC titles', () => {
|
|
const esc = String.fromCharCode(27)
|
|
const bel = String.fromCharCode(7)
|
|
expect(
|
|
excerptAgentFailureOutput(
|
|
'',
|
|
`${esc}]0;pi${bel}${esc}[91mError: no payment method${esc}[0m\n`
|
|
)
|
|
).toBe('Error: no payment method')
|
|
})
|
|
|
|
it('treats bare `\\r` progress frames as line boundaries', () => {
|
|
expect(excerptAgentFailureOutput('', 'Fetching 50%\rFetching 100%\rConnection error.')).toBe(
|
|
'Fetching 50% Fetching 100% Connection error.'
|
|
)
|
|
})
|
|
|
|
it('handles CRLF output', () => {
|
|
expect(excerptAgentFailureOutput('', 'one\r\ntwo\r\n')).toBe('one two')
|
|
})
|
|
|
|
it('collapses repeated retry lines instead of echoing them twice', () => {
|
|
expect(excerptAgentFailureOutput('', 'Retrying request…\n'.repeat(10))).toBe(
|
|
'Retrying request… Retrying request…'
|
|
)
|
|
})
|
|
|
|
it('truncates an overlong single line to the persistence budget', () => {
|
|
const line = `Error: ${'m'.repeat(300)}`
|
|
expect(excerptAgentFailureOutput('', line)).toBe(`Error: ${'m'.repeat(233)}…`)
|
|
})
|
|
|
|
it('reads the head and tail windows of oversized multi-line output', () => {
|
|
const stderr = `first line\n${'filler line\n'.repeat(3000)}last: operative error`
|
|
expect(excerptAgentFailureOutput('', stderr)).toBe(
|
|
'first line filler line … last: operative error'
|
|
)
|
|
})
|
|
|
|
it('bounds the excerpt for a giant single-line stream', () => {
|
|
expect(excerptAgentFailureOutput('', 'x'.repeat(20_000))).toBe(`${'x'.repeat(100)}…`)
|
|
})
|
|
})
|
|
|
|
describe('tokenizeCustomCommandTemplate', () => {
|
|
it('splits on whitespace', () => {
|
|
const r = tokenizeCustomCommandTemplate('claude -p')
|
|
expect(r).toEqual({ ok: true, tokens: ['claude', '-p'], spans: expect.any(Array) })
|
|
})
|
|
|
|
it('groups double-quoted segments with spaces', () => {
|
|
const r = tokenizeCustomCommandTemplate('claude --msg "hello world"')
|
|
expect(r).toEqual({
|
|
ok: true,
|
|
tokens: ['claude', '--msg', 'hello world'],
|
|
spans: expect.any(Array)
|
|
})
|
|
})
|
|
|
|
it('groups single-quoted segments verbatim', () => {
|
|
const r = tokenizeCustomCommandTemplate(`agent --json '{"k":"v"}'`)
|
|
expect(r).toEqual({
|
|
ok: true,
|
|
tokens: ['agent', '--json', '{"k":"v"}'],
|
|
spans: expect.any(Array)
|
|
})
|
|
})
|
|
|
|
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"'],
|
|
spans: expect.any(Array)
|
|
})
|
|
})
|
|
|
|
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'], spans: expect.any(Array) })
|
|
})
|
|
|
|
it('always reports one span per token', () => {
|
|
for (const source of ['claude -p', 'claude --msg "hello world"', 'foo a"b"c', ' \t ']) {
|
|
const r = tokenizeCustomCommandTemplate(source)
|
|
expect(r.ok && r.spans.length).toBe(r.ok && r.tokens.length)
|
|
}
|
|
})
|
|
|
|
it('reports source spans covering each raw token including quotes', () => {
|
|
const source = 'claude --msg "hello world"'
|
|
const r = tokenizeCustomCommandTemplate(source)
|
|
expect(r).toEqual({
|
|
ok: true,
|
|
tokens: ['claude', '--msg', 'hello world'],
|
|
spans: [
|
|
{ start: 0, end: 6, divergesFromShell: false },
|
|
{ start: 7, end: 12, divergesFromShell: false },
|
|
{ start: 13, end: 26, divergesFromShell: false }
|
|
]
|
|
})
|
|
if (r.ok) {
|
|
expect(r.spans.map(({ start, end }) => source.slice(start, end))).toEqual([
|
|
'claude',
|
|
'--msg',
|
|
'"hello world"'
|
|
])
|
|
}
|
|
})
|
|
|
|
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: [], spans: [] })
|
|
})
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Windows command overrides keep native path separators (#11375)', () => {
|
|
const WINDOWS_PATH = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
|
|
|
it('eats backslashes under the POSIX default, which is what broke Windows paths', () => {
|
|
// Pinned as the reason 'literal' exists, not as desired behavior.
|
|
const posix = tokenizeCustomCommandTemplate(WINDOWS_PATH)
|
|
|
|
expect(posix.ok && posix.tokens).toEqual([
|
|
'C:WindowsSystem32WindowsPowerShellv1.0powershell.exe'
|
|
])
|
|
})
|
|
|
|
it('keeps a native absolute path intact in literal mode', () => {
|
|
const literal = tokenizeCustomCommandTemplate(WINDOWS_PATH, 'literal')
|
|
|
|
expect(literal.ok && literal.tokens).toEqual([WINDOWS_PATH])
|
|
})
|
|
|
|
it('still splits on whitespace and honours quotes in literal mode', () => {
|
|
const quoted = tokenizeCustomCommandTemplate(
|
|
'"C:\\Program Files\\Git\\bin\\bash.exe" --login -i',
|
|
'literal'
|
|
)
|
|
|
|
expect(quoted.ok && quoted.tokens).toEqual([
|
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
'--login',
|
|
'-i'
|
|
])
|
|
})
|
|
|
|
it('leaves a trailing backslash alone instead of swallowing the delimiter', () => {
|
|
const trailing = tokenizeCustomCommandTemplate('C:\\tools\\ --flag', 'literal')
|
|
|
|
expect(trailing.ok && trailing.tokens).toEqual(['C:\\tools\\', '--flag'])
|
|
})
|
|
|
|
it('keeps POSIX escaping the default so `foo\\ bar` stays one token', () => {
|
|
const posix = tokenizeCustomCommandTemplate('/usr/local/my\\ agent/bin --flag')
|
|
|
|
expect(posix.ok && posix.tokens).toEqual(['/usr/local/my agent/bin', '--flag'])
|
|
})
|
|
|
|
it('substitutes {prompt} as one argument with a Windows binary path', () => {
|
|
const plan = planCustomCommand(
|
|
`${WINDOWS_PATH} -Command {prompt}`,
|
|
'write a commit message',
|
|
'literal'
|
|
)
|
|
|
|
expect(plan.ok && plan.binary).toBe(WINDOWS_PATH)
|
|
expect(plan.ok && plan.args).toEqual(['-Command', 'write a commit message'])
|
|
})
|
|
})
|