mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
* fix(naming): keep the built-in branch-name prompt general The shipped auto-rename prompt no longer hard-codes style rules (word count, kebab-case, no prefixes). Users can already override naming via Source Control AI instructions and the branch-name command template; a prescriptive default fought those overrides. Git-safe sanitization still runs after generation. * fix(naming): preserve branch prompt overrides * chore(skills): refresh rc.2 manifest version
1898 lines
56 KiB
TypeScript
1898 lines
56 KiB
TypeScript
/* eslint-disable max-lines -- Why: local/remote generation, cancellation, and
|
|
env propagation share subprocess mocks; splitting would obscure the
|
|
cross-path invariants these tests protect. */
|
|
import { exec, spawn } from 'node:child_process'
|
|
import type * as ChildProcess from 'node:child_process'
|
|
import { EventEmitter } from 'node:events'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { getDefaultSettings } from '../../shared/constants'
|
|
import { sourceControlAiSettingsFromLegacy } from '../../shared/source-control-ai'
|
|
import type { GlobalSettings } from '../../shared/types'
|
|
import {
|
|
cancelGenerateCommitMessageLocal,
|
|
cancelGeneratePullRequestFieldsLocal,
|
|
discoverCommitMessageModelsLocal,
|
|
discoverCommitMessageModelsRemote,
|
|
generateBranchNameFromContext,
|
|
generateCommitMessageFromContext,
|
|
generatePullRequestFieldsFromContext,
|
|
resolveCommitMessageSettings,
|
|
trimGeneratedCommitMessage
|
|
} from './commit-message-text-generation'
|
|
|
|
vi.mock('child_process', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof ChildProcess>()
|
|
return {
|
|
...actual,
|
|
exec: vi.fn((_command, callback) => {
|
|
if (typeof callback === 'function') {
|
|
callback(null, '', '')
|
|
}
|
|
return new actual.ChildProcess()
|
|
}),
|
|
spawn: vi.fn(actual.spawn)
|
|
}
|
|
})
|
|
|
|
const execMock = vi.mocked(exec)
|
|
const spawnMock = vi.mocked(spawn)
|
|
|
|
type MockDiscoveryChild = EventEmitter & {
|
|
pid: number
|
|
kill: ReturnType<typeof vi.fn>
|
|
stdout: EventEmitter
|
|
stderr: EventEmitter
|
|
stdin: { end: ReturnType<typeof vi.fn> }
|
|
}
|
|
|
|
function createMockDiscoveryChild(): MockDiscoveryChild {
|
|
const child = new EventEmitter() as MockDiscoveryChild
|
|
child.pid = 123
|
|
child.kill = vi.fn()
|
|
child.stdout = new EventEmitter()
|
|
child.stderr = new EventEmitter()
|
|
child.stdin = { end: vi.fn() }
|
|
return child
|
|
}
|
|
|
|
function syncSourceControlAiFromLegacy(settings: GlobalSettings): void {
|
|
settings.sourceControlAi = sourceControlAiSettingsFromLegacy(settings.commitMessageAi)
|
|
}
|
|
|
|
function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T {
|
|
const original = process.platform
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
|
try {
|
|
return fn()
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
|
}
|
|
}
|
|
|
|
function expectChildTerminated(child: { pid: number; kill: ReturnType<typeof vi.fn> }): void {
|
|
if (process.platform === 'win32') {
|
|
expect(execMock).toHaveBeenCalledWith(`taskkill /pid ${child.pid} /T /F`, expect.any(Function))
|
|
expect(child.kill).not.toHaveBeenCalled()
|
|
return
|
|
}
|
|
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
execMock.mockClear()
|
|
spawnMock.mockClear()
|
|
})
|
|
|
|
describe('resolveCommitMessageSettings', () => {
|
|
it('falls back when a dynamic persisted model was not discovered', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.enableGitHubAttribution = true
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'codex',
|
|
selectedModelByAgent: { codex: 'retired-model' },
|
|
selectedThinkingByModel: {},
|
|
customPrompt: 'Use Conventional Commits.',
|
|
customAgentCommand: ''
|
|
}
|
|
settings.sourceControlAi = undefined
|
|
|
|
const result = resolveCommitMessageSettings(settings)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'codex',
|
|
model: 'gpt-5.5',
|
|
thinkingLevel: 'low',
|
|
customPrompt: 'Use Conventional Commits.',
|
|
commandInputTemplate: '{basePrompt}\n\nUse Conventional Commits.'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('falls back from stale Claude version ids to the CLI alias default', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'claude',
|
|
selectedModelByAgent: { claude: 'claude-sonnet-4-6' },
|
|
selectedThinkingByModel: { sonnet: 'low' },
|
|
customPrompt: '',
|
|
customAgentCommand: ''
|
|
}
|
|
syncSourceControlAiFromLegacy(settings)
|
|
|
|
const result = resolveCommitMessageSettings(settings)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'claude',
|
|
model: 'sonnet',
|
|
thinkingLevel: 'low'
|
|
}
|
|
})
|
|
})
|
|
|
|
it("uses the user's default agent when the AI setting has no explicit agent", () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.defaultTuiAgent = 'codex'
|
|
|
|
const result = resolveCommitMessageSettings(settings)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'codex',
|
|
model: 'gpt-5.5',
|
|
thinkingLevel: 'low'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('preserves dynamic persisted models that were discovered by the CLI', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'cursor',
|
|
selectedModelByAgent: { cursor: 'gpt-5.2' },
|
|
discoveredModelsByAgent: {
|
|
cursor: [
|
|
{
|
|
id: 'gpt-5.2',
|
|
label: 'GPT 5.2',
|
|
thinkingLevels: [{ id: 'xhigh', label: 'Extra High' }],
|
|
defaultThinkingLevel: 'xhigh'
|
|
}
|
|
]
|
|
},
|
|
selectedThinkingByModel: { 'gpt-5.2': 'xhigh' },
|
|
customPrompt: '',
|
|
customAgentCommand: ''
|
|
}
|
|
syncSourceControlAiFromLegacy(settings)
|
|
|
|
const result = resolveCommitMessageSettings(settings)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'cursor',
|
|
model: 'gpt-5.2',
|
|
thinkingLevel: 'xhigh'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('uses host-scoped discovered models for SSH worktrees', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'cursor',
|
|
selectedModelByAgent: { cursor: 'auto' },
|
|
selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-only' } },
|
|
discoveredModelsByAgent: { cursor: [{ id: 'auto', label: 'Auto' }] },
|
|
discoveredModelsByAgentByHost: {
|
|
'ssh:conn-1': { cursor: [{ id: 'remote-only', label: 'Remote Only' }] }
|
|
},
|
|
selectedThinkingByModel: {},
|
|
customPrompt: '',
|
|
customAgentCommand: ''
|
|
}
|
|
syncSourceControlAiFromLegacy(settings)
|
|
|
|
const result = resolveCommitMessageSettings(settings, 'ssh:conn-1')
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'cursor',
|
|
model: 'remote-only'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('falls back to the model default thinking level when a persisted level is stale', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'codex',
|
|
selectedModelByAgent: { codex: 'gpt-5.4-mini' },
|
|
selectedThinkingByModel: { 'gpt-5.4-mini': 'turbo' },
|
|
customPrompt: '',
|
|
customAgentCommand: ''
|
|
}
|
|
syncSourceControlAiFromLegacy(settings)
|
|
|
|
const result = resolveCommitMessageSettings(settings)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'codex',
|
|
model: 'gpt-5.4-mini',
|
|
thinkingLevel: 'low'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('passes the per-agent command override into non-interactive planning', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.agentCmdOverrides.codex = 'npx codex'
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'codex',
|
|
selectedModelByAgent: { codex: 'gpt-5.4-mini' },
|
|
selectedThinkingByModel: {},
|
|
customPrompt: '',
|
|
customAgentCommand: ''
|
|
}
|
|
syncSourceControlAiFromLegacy(settings)
|
|
|
|
const result = resolveCommitMessageSettings(settings)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'codex',
|
|
agentCommandOverride: 'npx codex'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('falls back when persisted thinking belongs to an undiscovered dynamic model', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'cursor',
|
|
selectedModelByAgent: { cursor: 'gpt-5.2' },
|
|
selectedThinkingByModel: { 'gpt-5.2': 'xhigh' },
|
|
customPrompt: '',
|
|
customAgentCommand: ''
|
|
}
|
|
syncSourceControlAiFromLegacy(settings)
|
|
|
|
const result = resolveCommitMessageSettings(settings)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
params: {
|
|
agentId: 'cursor',
|
|
model: 'auto'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('requires a non-empty custom command for custom agents', () => {
|
|
const settings = getDefaultSettings('/tmp')
|
|
settings.commitMessageAi = {
|
|
enabled: true,
|
|
agentId: 'custom',
|
|
selectedModelByAgent: {},
|
|
selectedThinkingByModel: {},
|
|
customPrompt: '',
|
|
customAgentCommand: ' '
|
|
}
|
|
syncSourceControlAiFromLegacy(settings)
|
|
|
|
expect(resolveCommitMessageSettings(settings)).toEqual({
|
|
ok: false,
|
|
error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('discoverCommitMessageModelsLocal', () => {
|
|
it('returns static catalog models without spawning for static agents', async () => {
|
|
const result = await discoverCommitMessageModelsLocal('amp', undefined)
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
defaultModelId: 'smart'
|
|
})
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('discovers dynamic models through the agent CLI', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = discoverCommitMessageModelsLocal('cursor', undefined)
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('auto - Auto\ngpt-5.2 - GPT-5.2\n'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
defaultModelId: 'auto',
|
|
models: [
|
|
{ id: 'auto', label: 'Auto' },
|
|
{ id: 'gpt-5.2', label: 'GPT-5.2' }
|
|
]
|
|
})
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'cursor-agent',
|
|
['--list-models'],
|
|
expect.objectContaining({ windowsHide: true })
|
|
)
|
|
})
|
|
|
|
it('discovers dynamic models through the configured agent command override', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = discoverCommitMessageModelsLocal('cursor', undefined, 'npx cursor-agent')
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('auto - Auto\n'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
defaultModelId: 'auto'
|
|
})
|
|
if (process.platform === 'win32') {
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
expect.stringMatching(/cmd\.exe$/i),
|
|
['/d', '/c', expect.stringMatching(/npx\.cmd$/i), 'cursor-agent', '--list-models'],
|
|
expect.objectContaining({ windowsHide: true })
|
|
)
|
|
} else {
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'npx',
|
|
['cursor-agent', '--list-models'],
|
|
expect.objectContaining({ windowsHide: true })
|
|
)
|
|
}
|
|
})
|
|
|
|
it('discovers dynamic models through the selected WSL distro login shell', async () => {
|
|
await withPlatform('win32', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = discoverCommitMessageModelsLocal('cursor', undefined, undefined, {
|
|
cwd: 'C:\\repo',
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('auto - Auto\n'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
defaultModelId: 'auto'
|
|
})
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
['-d', 'Ubuntu', '--', 'sh', '-lc', expect.any(String)],
|
|
expect.objectContaining({
|
|
cwd: undefined,
|
|
windowsHide: true
|
|
})
|
|
)
|
|
const shellCommand = spawnMock.mock.calls[0]?.[1]?.[5] as string
|
|
expect(shellCommand).toContain('getent passwd')
|
|
expect(shellCommand).toContain('/mnt/c/repo')
|
|
expect(shellCommand).toContain("'cursor-agent'")
|
|
expect(shellCommand).toContain('--list-models')
|
|
})
|
|
})
|
|
|
|
it('falls back to static models when dynamic discovery returns no parseable models', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = discoverCommitMessageModelsLocal('pi', undefined)
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('provider model\n'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
defaultModelId: 'github-copilot/gpt-5.4-mini',
|
|
models: [{ id: 'github-copilot/gpt-5.4-mini' }]
|
|
})
|
|
})
|
|
|
|
it('parses Pi model discovery from stderr when the CLI exits successfully', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = discoverCommitMessageModelsLocal('pi', undefined)
|
|
|
|
listeners.get('stderr:data')?.(
|
|
Buffer.from(
|
|
[
|
|
'provider model context max-out thinking images',
|
|
'github-copilot gpt-5.4-mini 400K 128K yes yes',
|
|
'openai-codex gpt-5.5 272K 128K yes yes'
|
|
].join('\n')
|
|
)
|
|
)
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
defaultModelId: 'github-copilot/gpt-5.4-mini',
|
|
models: [{ id: 'github-copilot/gpt-5.4-mini' }, { id: 'openai-codex/gpt-5.5' }]
|
|
})
|
|
})
|
|
|
|
it('settles and detaches model discovery when timeout kill is ignored', async () => {
|
|
vi.useFakeTimers()
|
|
const child = createMockDiscoveryChild()
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
try {
|
|
const pending = discoverCommitMessageModelsLocal('cursor', undefined)
|
|
const assertion = expect(pending).resolves.toMatchObject({
|
|
success: false,
|
|
error: 'Cursor model discovery timed out after 60s.'
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000)
|
|
|
|
await assertion
|
|
expectChildTerminated(child)
|
|
expect(child.stdout.listenerCount('data')).toBe(0)
|
|
expect(child.stderr.listenerCount('data')).toBe(0)
|
|
expect(child.listenerCount('error')).toBe(0)
|
|
expect(child.listenerCount('close')).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('settles and detaches model discovery when output exceeds the limit', async () => {
|
|
const child = createMockDiscoveryChild()
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = discoverCommitMessageModelsLocal('cursor', undefined)
|
|
|
|
child.stdout.emit('data', Buffer.alloc(4 * 1024 * 1024 + 1))
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: false,
|
|
error: 'Cursor returned too much model data.'
|
|
})
|
|
expectChildTerminated(child)
|
|
expect(child.stdout.listenerCount('data')).toBe(0)
|
|
expect(child.stderr.listenerCount('data')).toBe(0)
|
|
expect(child.listenerCount('error')).toBe(0)
|
|
expect(child.listenerCount('close')).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('generateCommitMessageFromContext', () => {
|
|
it('discovers dynamic models through a remote execution plan', async () => {
|
|
const execute = vi.fn(async (plan, cwd, timeoutMs) => {
|
|
expect(plan).toEqual({
|
|
binary: 'npx',
|
|
args: ['cursor-agent', '--list-models'],
|
|
stdinPayload: null,
|
|
label: 'Cursor'
|
|
})
|
|
expect(cwd).toBe('/remote/repo')
|
|
expect(timeoutMs).toBe(60_000)
|
|
return {
|
|
stdout: 'auto - Auto\ngpt-5.2 - GPT-5.2\n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
}
|
|
})
|
|
|
|
const result = await discoverCommitMessageModelsRemote(
|
|
'cursor',
|
|
'/remote/repo',
|
|
execute,
|
|
'npx cursor-agent'
|
|
)
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
defaultModelId: 'auto',
|
|
models: [
|
|
{ id: 'auto', label: 'Auto' },
|
|
{ id: 'gpt-5.2', label: 'GPT-5.2' }
|
|
]
|
|
})
|
|
})
|
|
|
|
it('reports remote model discovery spawn failures with remote install guidance', async () => {
|
|
const result = await discoverCommitMessageModelsRemote('cursor', '/remote/repo', async () => ({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: null,
|
|
timedOut: false,
|
|
spawnError: 'ENOENT'
|
|
}))
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'cursor-agent not found on the remote PATH. Install Cursor there.'
|
|
})
|
|
})
|
|
|
|
it('uses a prepared remote execution plan instead of running git on the remote side', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent --message {prompt}'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async (plan, cwd, timeoutMs) => {
|
|
expect(cwd).toBe('/repo')
|
|
expect(timeoutMs).toBe(60_000)
|
|
expect(plan.binary).toBe('agent')
|
|
expect(plan.args).toHaveLength(2)
|
|
expect(plan.args[0]).toBe('--message')
|
|
expect(plan.args[1]).toContain('Staged files:\nM\tREADME.md')
|
|
return {
|
|
stdout: 'Add README note.\n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
message: 'Add README note',
|
|
agentLabel: 'agent'
|
|
})
|
|
})
|
|
|
|
it('exposes raw CLI failure output only after path sanitization', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: 'You are generating a single git commit message for /secret/repo',
|
|
stderr: 'raw failure output with /Users/thebr/My Repo/secret/file.ts',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent CLI command failed with code 1: raw failure output with [path]'
|
|
})
|
|
})
|
|
|
|
it('formats nonzero exit failures with extracted sanitized CLI details', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: 'ERROR: fatal: C:\\Users\\Brennan Doe\\secret\\file.ts failed',
|
|
stderr: '',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent CLI command failed with code 1: ERROR: fatal: [path] failed'
|
|
})
|
|
})
|
|
|
|
it('redacts UNC paths in CLI failure details', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: 'ERROR: failed at \\\\server\\share\\Brennan Repo\\secret\\file.ts',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent CLI command failed with code 1: ERROR: failed at [path]'
|
|
})
|
|
})
|
|
|
|
it('reports an empty result with the stderr excerpt when exit 0 produces no stdout', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: '\u001b[91m\u001b[1mError: \u001b[0mNo payment method',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent returned an empty message. CLI output: Error: No payment method'
|
|
})
|
|
})
|
|
|
|
it('surfaces pi auth failure detail end-to-end through the adjusted path sanitizer', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: [
|
|
'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'),
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error:
|
|
'Pi CLI command failed with code 1: No API key found for github-copilot. Use /login to log into a provider via OAuth or API key. See: … [path]'
|
|
})
|
|
})
|
|
|
|
it('preserves slash-commands while redacting multi-segment paths in failure detail', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: 'ERROR: run /login then check /Users/name/repo',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent CLI command failed with code 1: ERROR: run /login then check [path]'
|
|
})
|
|
})
|
|
|
|
it('redacts a filesystem path embedded in a pi HTTP 401 payload', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: '401: {"message":"Invalid key loaded from /Users/name/.config/pi/auth.json"}',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'Pi CLI command failed with code 1: 401: {"message":"Invalid key loaded from [path]"}'
|
|
})
|
|
})
|
|
|
|
it('redacts a Windows drive path with JSON-escaped backslashes in a payload', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: '401: {"message":"Invalid key loaded from C:\\\\Users\\\\name\\\\auth.json"}',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'Pi CLI command failed with code 1: 401: {"message":"Invalid key loaded from [path]"}'
|
|
})
|
|
})
|
|
|
|
it('keeps a scheme:// remedy URL intact while still redacting paths', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr:
|
|
'401: Visit https://console.anthropic.com/settings/keys then check /Users/name/.config/pi/auth.json',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error:
|
|
'Pi CLI command failed with code 1: 401: Visit https://console.anthropic.com/settings/keys then check [path]'
|
|
})
|
|
})
|
|
|
|
it('redacts key=/path shapes in provider bodies', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: '401: {"message":"rejected credential_path=/Users/name/.config/pi/auth.json"}',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'Pi CLI command failed with code 1: 401: {"message":"rejected credential_path=[path]"}'
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'comma-prefixed list path',
|
|
'401: {"message":"candidate list a,/Users/name/creds rejected"}',
|
|
'Pi CLI command failed with code 1: 401: {"message":"candidate list a,[path] rejected"}'
|
|
],
|
|
[
|
|
'non-drive colon-prefixed path',
|
|
'401: {"message":"slot 1:/Users/name/alt failed"}',
|
|
'Pi CLI command failed with code 1: 401: {"message":"slot 1:[path] failed"}'
|
|
]
|
|
])('redacts a %s in provider bodies', async (_shape, stderr, expected) => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr,
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({ success: false, error: expected })
|
|
})
|
|
|
|
it('passes the live colonless 400 gateway payload through the sanitizer unmangled', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr:
|
|
'400 {"type":"error","error":{"type":"invalid_request_error","message":"Third-party apps now draw from your extra usage, not your plan limits. Add more at claude.ai/settings/usage and keep going."},"request_id":"req_011CcsZLJ5ZiLLNvpxcxDuU4"}',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
if (result.success) {
|
|
throw new Error('expected a failure result')
|
|
}
|
|
expect(result.error.startsWith('Pi CLI command failed with code 1: 400 {"type":"error"')).toBe(
|
|
true
|
|
)
|
|
// The bare domain link survives path redaction so the remedy stays usable.
|
|
expect(result.error).toContain('claude.ai/settings/usage')
|
|
expect(result.error.length).toBeLessThanOrEqual(300)
|
|
})
|
|
|
|
it('preserves the structured subject and body when formatting the final response', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: 'Update README.\n\n- Explain the generated commit-message flow\n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
message: 'Update README\n\n- Explain the generated commit-message flow',
|
|
agentLabel: 'agent'
|
|
})
|
|
})
|
|
|
|
it('reports empty remote commit-message output as an empty message', async () => {
|
|
let operation = ''
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async (_plan, _cwd, _timeoutMs, requestedOperation) => {
|
|
operation = requestedOperation
|
|
return {
|
|
stdout: ' \n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(operation).toBe('commit-message')
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent returned an empty message.'
|
|
})
|
|
})
|
|
|
|
it('sanitizes remote execution transport failures', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => {
|
|
throw new Error('relay disconnected while reading /secret/repo')
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error:
|
|
'agent could not be reached on the remote PATH. Try again after the SSH connection recovers.'
|
|
})
|
|
})
|
|
|
|
it('caps local agent output before buffering unbounded data', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
|
|
listeners.get('stdout:data')?.(Buffer.alloc(4 * 1024 * 1024 + 1))
|
|
listeners.get('close')?.(null)
|
|
|
|
await expect(pending).resolves.toEqual({
|
|
success: false,
|
|
error:
|
|
'agent CLI command produced too much output. Check the agent CLI configuration and try again.'
|
|
})
|
|
expectChildTerminated(child)
|
|
})
|
|
|
|
it('passes prepared provider environment to local agent subprocesses', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'orca-test-agent-nope'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo',
|
|
env: { ...process.env, CODEX_HOME: '/managed/codex-home' }
|
|
}
|
|
)
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('Add README note\n'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
message: 'Add README note'
|
|
})
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'orca-test-agent-nope',
|
|
[],
|
|
expect.objectContaining({
|
|
env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' })
|
|
})
|
|
)
|
|
})
|
|
|
|
it('routes WSL local commit generation through the selected distro login shell', async () => {
|
|
await withPlatform('win32', async () => {
|
|
process.env.ORCA_HOST_ONLY_SECRET = 'do-not-leak'
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent --mode fast'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: 'C:\\repo',
|
|
wslDistro: 'Ubuntu 24.04',
|
|
env: { ...process.env, CODEX_HOME: '/home/tester/.codex' }
|
|
}
|
|
)
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('Update README\n'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
message: 'Update README'
|
|
})
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
['-d', 'Ubuntu 24.04', '--', 'sh', '-lc', expect.any(String)],
|
|
expect.objectContaining({
|
|
cwd: undefined,
|
|
windowsHide: true,
|
|
env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' })
|
|
})
|
|
)
|
|
const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv
|
|
expect(spawnEnv.ORCA_HOST_ONLY_SECRET).toBeUndefined()
|
|
const shellCommand = spawnMock.mock.calls[0]?.[1]?.[5] as string
|
|
expect(shellCommand).toContain('getent passwd')
|
|
expect(shellCommand).toContain('exec "\\$_orca_wsl_shell" -ilc')
|
|
expect(shellCommand).toContain('/mnt/c/repo')
|
|
expect(shellCommand).toContain("'agent'")
|
|
expect(shellCommand).toContain('--mode')
|
|
expect(shellCommand).toContain('fast')
|
|
})
|
|
})
|
|
|
|
it('keeps local commit-message and pull-request cancellation lanes separate', async () => {
|
|
const children: {
|
|
pid: number
|
|
kill: ReturnType<typeof vi.fn>
|
|
listeners: Map<string, (value: unknown) => void>
|
|
}[] = []
|
|
spawnMock.mockImplementation(() => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123 + children.length,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
children.push({ pid: child.pid, kill: child.kill, listeners })
|
|
return child as never
|
|
})
|
|
|
|
const commit = generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
const pullRequest = generatePullRequestFieldsFromContext(
|
|
{
|
|
branch: 'feature/pr-fields',
|
|
base: 'main',
|
|
branchChangedByPreparation: false,
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false,
|
|
commitSummary: '- feat: update README',
|
|
changeSummary: 'M\tREADME.md',
|
|
patch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
|
|
cancelGenerateCommitMessageLocal('/repo')
|
|
|
|
expectChildTerminated(children[0]!)
|
|
expect(children[1]?.kill).not.toHaveBeenCalled()
|
|
|
|
children[0]?.listeners.get('close')?.(null)
|
|
const pullRequestStdout = children[1]?.listeners.get('stdout:data')
|
|
pullRequestStdout?.(
|
|
Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}')
|
|
)
|
|
children[1]?.listeners.get('close')?.(0)
|
|
|
|
await expect(commit).resolves.toEqual({
|
|
success: false,
|
|
error: 'Generation canceled.',
|
|
canceled: true
|
|
})
|
|
await expect(pullRequest).resolves.toMatchObject({
|
|
success: true,
|
|
fields: {
|
|
base: 'main',
|
|
title: 'Update README',
|
|
body: 'Details',
|
|
draft: false
|
|
}
|
|
})
|
|
|
|
cancelGeneratePullRequestFieldsLocal('/repo')
|
|
expect(children[1]?.kill).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps local pull-request cancellation from stopping commit-message generation', async () => {
|
|
const children: {
|
|
pid: number
|
|
kill: ReturnType<typeof vi.fn>
|
|
listeners: Map<string, (value: unknown) => void>
|
|
}[] = []
|
|
spawnMock.mockImplementation(() => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123 + children.length,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
children.push({ pid: child.pid, kill: child.kill, listeners })
|
|
return child as never
|
|
})
|
|
|
|
const commit = generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
const pullRequest = generatePullRequestFieldsFromContext(
|
|
{
|
|
branch: 'feature/pr-fields',
|
|
base: 'main',
|
|
branchChangedByPreparation: false,
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false,
|
|
commitSummary: '- feat: update README',
|
|
changeSummary: 'M\tREADME.md',
|
|
patch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
|
|
cancelGeneratePullRequestFieldsLocal('/repo')
|
|
|
|
expect(children[0]?.kill).not.toHaveBeenCalled()
|
|
expectChildTerminated(children[1]!)
|
|
|
|
const commitStdout = children[0]?.listeners.get('stdout:data')
|
|
commitStdout?.(Buffer.from('Update README\n'))
|
|
children[0]?.listeners.get('close')?.(0)
|
|
children[1]?.listeners.get('close')?.(null)
|
|
|
|
await expect(commit).resolves.toEqual({
|
|
success: true,
|
|
message: 'Update README',
|
|
agentLabel: 'agent'
|
|
})
|
|
await expect(pullRequest).resolves.toEqual({
|
|
success: false,
|
|
error: 'Generation canceled.',
|
|
canceled: true,
|
|
branchChangedByPreparation: false
|
|
})
|
|
})
|
|
|
|
it('reports empty remote pull-request field output as empty details', async () => {
|
|
let operation = ''
|
|
const result = await generatePullRequestFieldsFromContext(
|
|
{
|
|
branch: 'feature/pr-fields',
|
|
base: 'main',
|
|
branchChangedByPreparation: false,
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false,
|
|
commitSummary: '- feat: update README',
|
|
changeSummary: 'M\tREADME.md',
|
|
patch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async (_plan, _cwd, _timeoutMs, requestedOperation) => {
|
|
operation = requestedOperation
|
|
return {
|
|
stdout: ' \n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(operation).toBe('pull-request-fields')
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent returned an empty details.',
|
|
branchChangedByPreparation: false
|
|
})
|
|
})
|
|
|
|
it('reports branch changes when pull request field output cannot be parsed', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
spawnMock.mockReturnValue({
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
} as never)
|
|
|
|
const pullRequest = generatePullRequestFieldsFromContext(
|
|
{
|
|
branch: 'feature/pr-fields',
|
|
base: 'main',
|
|
branchChangedByPreparation: true,
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false,
|
|
commitSummary: '- feat: update README',
|
|
changeSummary: 'M\tREADME.md',
|
|
patch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('not json'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pullRequest).resolves.toEqual({
|
|
success: false,
|
|
error: 'Generated pull request details could not be parsed.',
|
|
branchChangedByPreparation: true
|
|
})
|
|
})
|
|
|
|
it('reports branch changes when pull request generation is canceled', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pullRequest = generatePullRequestFieldsFromContext(
|
|
{
|
|
branch: 'feature/pr-fields',
|
|
base: 'main',
|
|
branchChangedByPreparation: true,
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false,
|
|
commitSummary: '- feat: update README',
|
|
changeSummary: 'M\tREADME.md',
|
|
patch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
|
|
cancelGeneratePullRequestFieldsLocal('/repo')
|
|
listeners.get('close')?.(null)
|
|
|
|
expectChildTerminated(child)
|
|
await expect(pullRequest).resolves.toEqual({
|
|
success: false,
|
|
error: 'Generation canceled.',
|
|
canceled: true,
|
|
branchChangedByPreparation: true
|
|
})
|
|
})
|
|
|
|
it('settles local commit-message cancellation even when the killed child does not close', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const removeListener = (key: string, callback: (value: unknown) => void): void => {
|
|
if (listeners.get(key) === callback) {
|
|
listeners.delete(key)
|
|
}
|
|
}
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: {
|
|
on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)),
|
|
off: vi.fn((event, callback) => removeListener(`stdout:${event}`, callback))
|
|
},
|
|
stderr: {
|
|
on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)),
|
|
off: vi.fn((event, callback) => removeListener(`stderr:${event}`, callback))
|
|
},
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback)),
|
|
off: vi.fn((event, callback) => removeListener(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
const outcomePromise = pending.then((result) =>
|
|
!result.success && result.canceled ? 'canceled' : 'other'
|
|
)
|
|
|
|
cancelGenerateCommitMessageLocal('/repo')
|
|
expectChildTerminated(child)
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
|
|
|
|
expect(outcome).toBe('canceled')
|
|
expect(listeners.has('stdout:data')).toBe(false)
|
|
expect(listeners.has('stderr:data')).toBe(false)
|
|
expect(listeners.has('error')).toBe(false)
|
|
expect(listeners.has('close')).toBe(false)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('routes Windows batch-script agent commands through cmd.exe', async () => {
|
|
const originalComSpec = process.env.ComSpec
|
|
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
|
|
try {
|
|
await withPlatform('win32', async () => {
|
|
const listeners = new Map<string, (value: unknown) => void>()
|
|
const child = {
|
|
pid: 123,
|
|
kill: vi.fn(),
|
|
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
|
|
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
|
|
stdin: { end: vi.fn() },
|
|
on: vi.fn((event, callback) => listeners.set(event, callback))
|
|
}
|
|
spawnMock.mockReturnValue(child as never)
|
|
|
|
const pending = generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'C:/tools/agent.cmd'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: 'C:\\repo'
|
|
}
|
|
)
|
|
|
|
listeners.get('stdout:data')?.(Buffer.from('Update README\n'))
|
|
listeners.get('close')?.(0)
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
success: true,
|
|
message: 'Update README'
|
|
})
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'C:\\Windows\\System32\\cmd.exe',
|
|
['/d', '/c', 'C:/tools/agent.cmd'],
|
|
expect.objectContaining({
|
|
cwd: 'C:\\repo',
|
|
windowsHide: true
|
|
})
|
|
)
|
|
})
|
|
} finally {
|
|
if (originalComSpec === undefined) {
|
|
delete process.env.ComSpec
|
|
} else {
|
|
process.env.ComSpec = originalComSpec
|
|
}
|
|
}
|
|
})
|
|
|
|
it('rejects unsafe argv prompts for Windows batch-script agent commands', async () => {
|
|
await withPlatform('win32', async () => {
|
|
const result = await generateCommitMessageFromContext(
|
|
{
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello & goodbye'
|
|
},
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'C:/tools/agent.cmd {prompt}'
|
|
},
|
|
{
|
|
kind: 'local',
|
|
cwd: 'C:\\repo'
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error:
|
|
'C:/tools/agent.cmd cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.'
|
|
})
|
|
expect(spawnMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('generateBranchNameFromContext', () => {
|
|
it('sanitizes remote agent output into a short branch slug', async () => {
|
|
const result = await generateBranchNameFromContext(
|
|
{ firstPrompt: 'Fix login flow' },
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '"Fix/Login Flow now please"\n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
slug: 'fix-login-flow-now',
|
|
agentLabel: 'agent'
|
|
})
|
|
})
|
|
|
|
it('fails when remote agent output sanitizes to an empty branch slug', async () => {
|
|
const result = await generateBranchNameFromContext(
|
|
{ firstPrompt: 'Fix login flow' },
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '!!! ___\n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'Generated branch name was empty after sanitization.',
|
|
failureOutput: { label: 'agent', exitCode: 0, stdout: '!!! ___', stderr: '' }
|
|
})
|
|
})
|
|
|
|
it('carries the full CLI output on failures for the local on-demand view', async () => {
|
|
const result = await generateBranchNameFromContext(
|
|
{ firstPrompt: 'Fix login flow' },
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: 'partial',
|
|
stderr: 'No API key found for github-copilot.',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
if (result.success) {
|
|
throw new Error('expected a failure result')
|
|
}
|
|
expect(result.failureOutput).toEqual({
|
|
label: 'Pi',
|
|
exitCode: 1,
|
|
stdout: 'partial',
|
|
stderr: 'No API key found for github-copilot.'
|
|
})
|
|
})
|
|
|
|
it('does not persist stdout-only branch failure detail that may echo the prompt', async () => {
|
|
const result = await generateBranchNameFromContext(
|
|
{ firstPrompt: 'Customer secret in the first prompt' },
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: 'Customer secret in the first prompt',
|
|
stderr: '',
|
|
exitCode: 1,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: 'agent CLI command failed with code 1.',
|
|
failureOutput: {
|
|
label: 'agent',
|
|
exitCode: 1,
|
|
stdout: 'Customer secret in the first prompt',
|
|
stderr: ''
|
|
}
|
|
})
|
|
})
|
|
|
|
it('describes a signal-terminated generator without a null exit code', async () => {
|
|
const result = await generateBranchNameFromContext(
|
|
{ firstPrompt: 'Fix login flow' },
|
|
{
|
|
agentId: 'pi',
|
|
model: 'github-copilot/gpt-5.5'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async () => ({
|
|
stdout: '',
|
|
stderr: 'Process killed by host',
|
|
exitCode: null,
|
|
timedOut: false
|
|
})
|
|
}
|
|
)
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: 'Pi CLI command was terminated before exiting: Process killed by host'
|
|
})
|
|
})
|
|
|
|
it('keeps branch-name guidance first without dropping the output contract', async () => {
|
|
let prompt = ''
|
|
await generateBranchNameFromContext(
|
|
{ firstPrompt: 'Fix login flow' },
|
|
{
|
|
agentId: 'custom',
|
|
model: '',
|
|
customAgentCommand: 'agent',
|
|
customPrompt: 'Prefer auth terminology.',
|
|
commandInputTemplate: 'Prefer auth terminology.\n\n{basePrompt}'
|
|
},
|
|
{
|
|
kind: 'remote',
|
|
cwd: '/repo',
|
|
missingBinaryLocation: 'remote PATH',
|
|
execute: async (plan) => {
|
|
prompt = plan.stdinPayload ?? ''
|
|
return {
|
|
stdout: 'fix-login-flow\n',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
timedOut: false
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(prompt.startsWith('Prefer auth terminology.')).toBe(true)
|
|
expect(prompt).toContain('Prefer auth terminology.')
|
|
expect(prompt).not.toContain('Additional user prompt:')
|
|
expect(prompt).toContain('Generate a short git branch name')
|
|
expect(prompt).toContain('Output ONLY the branch name on a single line')
|
|
})
|
|
})
|
|
|
|
describe('trimGeneratedCommitMessage', () => {
|
|
it('removes trailing whitespace from generated messages', () => {
|
|
const message = trimGeneratedCommitMessage('Update docs\n\n')
|
|
|
|
expect(message).toBe('Update docs')
|
|
})
|
|
})
|