feat: expand ai commit agent support (#1928)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
parent
363ddb5ef6
commit
03b889512b
|
|
@ -29,6 +29,8 @@ const {
|
|||
listWorktreesMock,
|
||||
resolveCommitMessageSettingsMock,
|
||||
generateCommitMessageFromContextMock,
|
||||
discoverCommitMessageModelsLocalMock,
|
||||
discoverCommitMessageModelsRemoteMock,
|
||||
cancelGenerateCommitMessageLocalMock,
|
||||
getSshFilesystemProviderMock,
|
||||
getSshGitProviderMock
|
||||
|
|
@ -58,6 +60,8 @@ const {
|
|||
listWorktreesMock: vi.fn(),
|
||||
resolveCommitMessageSettingsMock: vi.fn(),
|
||||
generateCommitMessageFromContextMock: vi.fn(),
|
||||
discoverCommitMessageModelsLocalMock: vi.fn(),
|
||||
discoverCommitMessageModelsRemoteMock: vi.fn(),
|
||||
cancelGenerateCommitMessageLocalMock: vi.fn(),
|
||||
getSshFilesystemProviderMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
|
|
@ -129,6 +133,8 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
|||
vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
resolveCommitMessageSettings: resolveCommitMessageSettingsMock,
|
||||
generateCommitMessageFromContext: generateCommitMessageFromContextMock,
|
||||
discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock,
|
||||
discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock,
|
||||
cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock
|
||||
}))
|
||||
|
||||
|
|
@ -205,6 +211,8 @@ describe('registerFilesystemHandlers', () => {
|
|||
listWorktreesMock,
|
||||
resolveCommitMessageSettingsMock,
|
||||
generateCommitMessageFromContextMock,
|
||||
discoverCommitMessageModelsLocalMock,
|
||||
discoverCommitMessageModelsRemoteMock,
|
||||
cancelGenerateCommitMessageLocalMock,
|
||||
getSshFilesystemProviderMock,
|
||||
getSshGitProviderMock
|
||||
|
|
@ -960,7 +968,7 @@ describe('registerFilesystemHandlers', () => {
|
|||
stagedSummary: 'M\tREADME.md',
|
||||
stagedPatch: '+hello'
|
||||
}
|
||||
const params = { agentId: 'claude', model: 'claude-haiku-4-5' }
|
||||
const params = { agentId: 'claude', model: 'haiku' }
|
||||
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
||||
getStagedCommitContextMock.mockResolvedValue(context)
|
||||
generateCommitMessageFromContextMock.mockResolvedValue({
|
||||
|
|
@ -1000,6 +1008,89 @@ describe('registerFilesystemHandlers', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('passes per-agent command overrides into local model discovery', async () => {
|
||||
discoverCommitMessageModelsLocalMock.mockResolvedValue({
|
||||
success: true,
|
||||
capability: {
|
||||
id: 'codex',
|
||||
label: 'Codex',
|
||||
modelSource: 'dynamic',
|
||||
defaultModelId: 'gpt-5.5',
|
||||
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }]
|
||||
},
|
||||
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }],
|
||||
defaultModelId: 'gpt-5.5'
|
||||
})
|
||||
const storeWithOverride = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
workspaceDir: WORKSPACE_DIR,
|
||||
agentCmdOverrides: { codex: 'npx codex' }
|
||||
})
|
||||
}
|
||||
|
||||
registerFilesystemHandlers(storeWithOverride as never)
|
||||
|
||||
await handlers.get('git:discoverCommitMessageModels')!(null, { agentId: 'codex' })
|
||||
|
||||
expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
undefined,
|
||||
'npx codex'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes SSH model discovery through the remote git provider', async () => {
|
||||
discoverCommitMessageModelsRemoteMock.mockResolvedValue({
|
||||
success: true,
|
||||
capability: {
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
modelSource: 'dynamic',
|
||||
defaultModelId: 'auto',
|
||||
models: [{ id: 'auto', label: 'Auto' }]
|
||||
},
|
||||
models: [{ id: 'auto', label: 'Auto' }],
|
||||
defaultModelId: 'auto'
|
||||
})
|
||||
const executeCommitMessagePlan = vi.fn()
|
||||
getSshGitProviderMock.mockReturnValue({ executeCommitMessagePlan })
|
||||
const storeWithOverride = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
workspaceDir: WORKSPACE_DIR,
|
||||
agentCmdOverrides: { cursor: 'npx cursor-agent' }
|
||||
})
|
||||
}
|
||||
|
||||
registerFilesystemHandlers(storeWithOverride as never)
|
||||
|
||||
await handlers.get('git:discoverCommitMessageModels')!(null, {
|
||||
agentId: 'cursor',
|
||||
worktreePath: '/remote/repo',
|
||||
connectionId: 'conn-1'
|
||||
})
|
||||
|
||||
expect(discoverCommitMessageModelsRemoteMock).toHaveBeenCalledWith(
|
||||
'cursor',
|
||||
'/remote/repo',
|
||||
expect.any(Function),
|
||||
'npx cursor-agent'
|
||||
)
|
||||
const execute = discoverCommitMessageModelsRemoteMock.mock.calls[0]?.[2] as (
|
||||
plan: unknown,
|
||||
cwd: string,
|
||||
timeoutMs: number
|
||||
) => Promise<unknown>
|
||||
await execute({ binary: 'cursor-agent', args: ['--list-models'] }, '/remote/repo', 60_000)
|
||||
expect(executeCommitMessagePlan).toHaveBeenCalledWith(
|
||||
{ binary: 'cursor-agent', args: ['--list-models'] },
|
||||
'/remote/repo',
|
||||
60_000
|
||||
)
|
||||
expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('generates an SSH commit message using remote staged context and relay execution', async () => {
|
||||
const context = {
|
||||
branch: 'main',
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import type {
|
|||
GitStatusResult,
|
||||
MarkdownDocument,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
SearchResult,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
||||
import {
|
||||
|
|
@ -49,9 +50,12 @@ import { getHistory } from '../git/history'
|
|||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
discoverCommitMessageModelsLocal,
|
||||
discoverCommitMessageModelsRemote,
|
||||
generateCommitMessageFromContext,
|
||||
generatePullRequestFieldsFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
type DiscoverCommitMessageModelsResult,
|
||||
type GenerateCommitMessageResult,
|
||||
type GeneratePullRequestFieldsResult
|
||||
} from '../text-generation/commit-message-text-generation'
|
||||
|
|
@ -60,6 +64,7 @@ import { getUpstreamStatus } from '../git/upstream'
|
|||
import { gitFetch, gitPull, gitPush } from '../git/remote'
|
||||
import { checkIgnoredPaths } from '../git/check-ignored-paths'
|
||||
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
||||
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
||||
import { validateGitPushTarget } from '../git/push-target-validation'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
|
|
@ -623,7 +628,8 @@ export function registerFilesystemHandlers(
|
|||
connectionId?: string
|
||||
}
|
||||
): Promise<GenerateCommitMessageResult> => {
|
||||
const resolvedSettings = resolveCommitMessageSettings(store.getSettings())
|
||||
const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null)
|
||||
const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey)
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
|
|
@ -701,6 +707,44 @@ export function registerFilesystemHandlers(
|
|||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:discoverCommitMessageModels',
|
||||
async (
|
||||
_event,
|
||||
args: { agentId: string; worktreePath?: string; connectionId?: string }
|
||||
): Promise<DiscoverCommitMessageModelsResult> => {
|
||||
const agentId = args.agentId
|
||||
const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent]
|
||||
if (args.connectionId) {
|
||||
if (!args.worktreePath) {
|
||||
return { success: false, error: 'Missing worktree path for remote model discovery.' }
|
||||
}
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No git provider for connection "${args.connectionId}"`
|
||||
}
|
||||
}
|
||||
return discoverCommitMessageModelsRemote(
|
||||
agentId as TuiAgent,
|
||||
args.worktreePath,
|
||||
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
|
||||
agentCommandOverride
|
||||
)
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, commitMessageAgentEnv)
|
||||
if (!localEnv.ok) {
|
||||
return { success: false, error: localEnv.error }
|
||||
}
|
||||
return discoverCommitMessageModelsLocal(
|
||||
agentId as TuiAgent,
|
||||
localEnv.env,
|
||||
agentCommandOverride
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:generatePullRequestFields',
|
||||
async (
|
||||
|
|
@ -714,7 +758,8 @@ export function registerFilesystemHandlers(
|
|||
connectionId?: string
|
||||
}
|
||||
): Promise<GeneratePullRequestFieldsResult> => {
|
||||
const resolvedSettings = resolveCommitMessageSettings(store.getSettings())
|
||||
const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null)
|
||||
const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey)
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runt
|
|||
const mocks = vi.hoisted(() => ({
|
||||
getStagedCommitContext: vi.fn(),
|
||||
generateCommitMessageFromContext: vi.fn(),
|
||||
resolveCommitMessageSettings: vi.fn()
|
||||
resolveCommitMessageSettings: vi.fn(),
|
||||
getSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/status', async () => ({
|
||||
|
|
@ -26,6 +27,10 @@ vi.mock('../text-generation/commit-message-text-generation', async () => ({
|
|||
resolveCommitMessageSettings: mocks.resolveCommitMessageSettings
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getSshGitProvider
|
||||
}))
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
|
||||
|
|
@ -55,6 +60,7 @@ describe('RuntimeGitCommands', () => {
|
|||
mocks.getStagedCommitContext.mockReset()
|
||||
mocks.generateCommitMessageFromContext.mockReset()
|
||||
mocks.resolveCommitMessageSettings.mockReset()
|
||||
mocks.getSshGitProvider.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -109,6 +115,12 @@ describe('RuntimeGitCommands', () => {
|
|||
message: 'docs: update readme'
|
||||
})
|
||||
|
||||
expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
commitMessageAi: { enabled: true, agentId: 'codex' }
|
||||
}),
|
||||
'local'
|
||||
)
|
||||
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
||||
context,
|
||||
params,
|
||||
|
|
@ -119,4 +131,56 @@ describe('RuntimeGitCommands', () => {
|
|||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves remote commit-message settings against the SSH host cache', async () => {
|
||||
const worktreePath = '/remote/repo'
|
||||
const context = {
|
||||
branch: 'main',
|
||||
stagedSummary: 'M\tREADME.md',
|
||||
stagedPatch: '+hello'
|
||||
}
|
||||
const params = { agentId: 'cursor', model: 'remote-model' }
|
||||
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
||||
mocks.generateCommitMessageFromContext.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'docs: update remote readme'
|
||||
})
|
||||
const provider = {
|
||||
getStagedCommitContext: vi.fn().mockResolvedValue(context),
|
||||
executeCommitMessagePlan: vi.fn()
|
||||
}
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree(worktreePath),
|
||||
connectionId: 'conn-1'
|
||||
}),
|
||||
getRuntimeSettings: () =>
|
||||
({
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: 'cursor',
|
||||
selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } }
|
||||
}
|
||||
}) as unknown as GlobalSettings
|
||||
})
|
||||
|
||||
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
|
||||
success: true,
|
||||
message: 'docs: update remote readme'
|
||||
})
|
||||
|
||||
expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'ssh:conn-1'
|
||||
)
|
||||
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
||||
context,
|
||||
params,
|
||||
expect.objectContaining({
|
||||
kind: 'remote',
|
||||
cwd: worktreePath
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import type {
|
|||
GitUpstreamStatus,
|
||||
GitWorktreeInfo,
|
||||
GlobalSettings,
|
||||
TuiAgent,
|
||||
Worktree
|
||||
} from '../../shared/types'
|
||||
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
|
||||
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
|
|
@ -42,9 +44,12 @@ import { checkIgnoredPaths } from '../git/check-ignored-paths'
|
|||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
discoverCommitMessageModelsLocal,
|
||||
discoverCommitMessageModelsRemote,
|
||||
generateCommitMessageFromContext,
|
||||
generatePullRequestFieldsFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
type DiscoverCommitMessageModelsResult,
|
||||
type GenerateCommitMessageResult,
|
||||
type GeneratePullRequestFieldsResult
|
||||
} from '../text-generation/commit-message-text-generation'
|
||||
|
|
@ -57,7 +62,9 @@ import { gitExecFileAsync } from '../git/runner'
|
|||
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
type RuntimeCommitMessageSettingsOverride = Partial<
|
||||
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
|
||||
>
|
||||
> & {
|
||||
commitMessageDiscoveryHostKey?: string
|
||||
}
|
||||
|
||||
function normalizeRuntimeGitRelativePath(filePath: string): string {
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
|
|
@ -333,15 +340,21 @@ export class RuntimeGitCommands {
|
|||
worktreeSelector: string,
|
||||
settingsOverride?: RuntimeCommitMessageSettingsOverride
|
||||
): Promise<GenerateCommitMessageResult> {
|
||||
const resolvedSettings = resolveCommitMessageSettings({
|
||||
...this.host.getRuntimeSettings(),
|
||||
...settingsOverride
|
||||
})
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const discoveryHostKey =
|
||||
settingsOverride?.commitMessageDiscoveryHostKey ??
|
||||
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
|
||||
const resolvedSettings = resolveCommitMessageSettings(
|
||||
{
|
||||
...this.host.getRuntimeSettings(),
|
||||
...settingsOverride
|
||||
},
|
||||
discoveryHostKey
|
||||
)
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -408,15 +421,21 @@ export class RuntimeGitCommands {
|
|||
input: { base: string; title: string; body: string; draft: boolean },
|
||||
settingsOverride?: RuntimeCommitMessageSettingsOverride
|
||||
): Promise<GeneratePullRequestFieldsResult> {
|
||||
const resolvedSettings = resolveCommitMessageSettings({
|
||||
...this.host.getRuntimeSettings(),
|
||||
...settingsOverride
|
||||
})
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const discoveryHostKey =
|
||||
settingsOverride?.commitMessageDiscoveryHostKey ??
|
||||
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
|
||||
const resolvedSettings = resolveCommitMessageSettings(
|
||||
{
|
||||
...this.host.getRuntimeSettings(),
|
||||
...settingsOverride
|
||||
},
|
||||
discoveryHostKey
|
||||
)
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId && !provider) {
|
||||
return {
|
||||
|
|
@ -478,6 +497,41 @@ export class RuntimeGitCommands {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
async discoverRuntimeCommitMessageModels(
|
||||
worktreeSelector: string,
|
||||
agentId: string,
|
||||
settingsOverride?: Pick<RuntimeCommitMessageSettingsOverride, 'agentCmdOverrides'>
|
||||
): Promise<DiscoverCommitMessageModelsResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const typedAgentId = agentId as TuiAgent
|
||||
const agentCommandOverride =
|
||||
settingsOverride?.agentCmdOverrides?.[typedAgentId] ??
|
||||
this.host.getRuntimeSettings().agentCmdOverrides?.[typedAgentId]
|
||||
if (target.connectionId) {
|
||||
const provider = getSshGitProvider(target.connectionId)
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No git provider for connection "${target.connectionId}"`
|
||||
}
|
||||
}
|
||||
return discoverCommitMessageModelsRemote(
|
||||
typedAgentId,
|
||||
target.worktree.path,
|
||||
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
|
||||
agentCommandOverride
|
||||
)
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
typedAgentId,
|
||||
this.host.getCommitMessageAgentEnvironment?.()
|
||||
)
|
||||
if (!localEnv.ok) {
|
||||
return { success: false, error: localEnv.error }
|
||||
}
|
||||
return discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride)
|
||||
}
|
||||
|
||||
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
|
|
|
|||
|
|
@ -1694,6 +1694,8 @@ export class OrcaRuntimeService {
|
|||
)
|
||||
generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] =
|
||||
this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands)
|
||||
discoverRuntimeCommitMessageModels: RuntimeGitCommands['discoverRuntimeCommitMessageModels'] =
|
||||
this.gitCommands.discoverRuntimeCommitMessageModels.bind(this.gitCommands)
|
||||
cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] =
|
||||
this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands)
|
||||
generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] =
|
||||
|
|
|
|||
|
|
@ -78,10 +78,22 @@ export const GitCommit = WorktreeSelector.extend({
|
|||
.pipe(z.string().min(1, 'Missing commit message'))
|
||||
})
|
||||
|
||||
const CommitMessageModelCapability = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
defaultThinkingLevel: z.string().optional()
|
||||
})
|
||||
|
||||
const CommitMessageAiSettings = z.object({
|
||||
enabled: z.boolean(),
|
||||
agentId: z.string().nullable(),
|
||||
selectedModelByAgent: z.record(z.string(), z.string()),
|
||||
selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(),
|
||||
discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(),
|
||||
discoveredModelsByAgentByHost: z
|
||||
.record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability)))
|
||||
.optional(),
|
||||
selectedThinkingByModel: z.record(z.string(), z.string()),
|
||||
customPrompt: z.string(),
|
||||
customAgentCommand: z.string()
|
||||
|
|
@ -90,7 +102,13 @@ const CommitMessageAiSettings = z.object({
|
|||
export const GitGenerateCommitMessage = WorktreeSelector.extend({
|
||||
commitMessageAi: CommitMessageAiSettings.optional(),
|
||||
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
|
||||
enableGitHubAttribution: z.boolean().optional()
|
||||
enableGitHubAttribution: z.boolean().optional(),
|
||||
commitMessageDiscoveryHostKey: z.string().optional()
|
||||
})
|
||||
|
||||
export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({
|
||||
agentId: z.string().min(1, 'Missing agent id'),
|
||||
agentCmdOverrides: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
|
||||
export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
|
||||
|
|
|
|||
|
|
@ -191,6 +191,11 @@ describe('git RPC methods', () => {
|
|||
generateRuntimeCommitMessage: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, message: 'feat: test' }),
|
||||
discoverRuntimeCommitMessageModels: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
models: [{ id: 'auto', label: 'Auto' }],
|
||||
defaultModelId: 'auto'
|
||||
}),
|
||||
cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }),
|
||||
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
|
||||
|
|
@ -201,6 +206,13 @@ describe('git RPC methods', () => {
|
|||
makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('git.generateCommitMessage', { worktree: 'id:wt-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.discoverCommitMessageModels', {
|
||||
worktree: 'id:wt-1',
|
||||
agentId: 'cursor',
|
||||
agentCmdOverrides: { cursor: 'cursor-agent' }
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' })
|
||||
)
|
||||
|
|
@ -221,6 +233,9 @@ describe('git RPC methods', () => {
|
|||
|
||||
expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test')
|
||||
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.discoverRuntimeCommitMessageModels).toHaveBeenCalledWith('id:wt-1', 'cursor', {
|
||||
agentCmdOverrides: { cursor: 'cursor-agent' }
|
||||
})
|
||||
expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' })
|
||||
expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' })
|
||||
|
|
@ -231,6 +246,15 @@ describe('git RPC methods', () => {
|
|||
enabled: true,
|
||||
agentId: 'codex',
|
||||
selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' },
|
||||
selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } },
|
||||
discoveredModelsByAgent: {
|
||||
cursor: [{ id: 'local-model', label: 'Local Model' }]
|
||||
},
|
||||
discoveredModelsByAgentByHost: {
|
||||
'ssh:conn-1': {
|
||||
cursor: [{ id: 'remote-model', label: 'Remote Model' }]
|
||||
}
|
||||
},
|
||||
selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' },
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
|
|
@ -247,14 +271,16 @@ describe('git RPC methods', () => {
|
|||
worktree: 'id:wt-1',
|
||||
commitMessageAi,
|
||||
agentCmdOverrides,
|
||||
enableGitHubAttribution: true
|
||||
enableGitHubAttribution: true,
|
||||
commitMessageDiscoveryHostKey: 'runtime:env-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', {
|
||||
commitMessageAi,
|
||||
agentCmdOverrides,
|
||||
enableGitHubAttribution: true
|
||||
enableGitHubAttribution: true,
|
||||
commitMessageDiscoveryHostKey: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
GitCommit,
|
||||
GitCommitCompare,
|
||||
GitCommitDiff,
|
||||
GitDiscoverCommitMessageModels,
|
||||
GitDiff,
|
||||
GitFilePath,
|
||||
GitGenerateCommitMessage,
|
||||
|
|
@ -127,7 +128,8 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
if (
|
||||
params.commitMessageAi === undefined &&
|
||||
params.agentCmdOverrides === undefined &&
|
||||
params.enableGitHubAttribution === undefined
|
||||
params.enableGitHubAttribution === undefined &&
|
||||
params.commitMessageDiscoveryHostKey === undefined
|
||||
) {
|
||||
return runtime.generateRuntimeCommitMessage(params.worktree)
|
||||
}
|
||||
|
|
@ -142,10 +144,27 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
: {}),
|
||||
...(params.enableGitHubAttribution !== undefined
|
||||
? { enableGitHubAttribution: params.enableGitHubAttribution }
|
||||
: {}),
|
||||
...(params.commitMessageDiscoveryHostKey !== undefined
|
||||
? { commitMessageDiscoveryHostKey: params.commitMessageDiscoveryHostKey }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.discoverCommitMessageModels',
|
||||
params: GitDiscoverCommitMessageModels,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.discoverRuntimeCommitMessageModels(
|
||||
params.worktree,
|
||||
params.agentId,
|
||||
params.agentCmdOverrides !== undefined
|
||||
? {
|
||||
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
|
||||
}
|
||||
: {}
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.cancelGenerateCommitMessage',
|
||||
params: WorktreeSelector,
|
||||
|
|
@ -165,7 +184,8 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
if (
|
||||
params.commitMessageAi === undefined &&
|
||||
params.agentCmdOverrides === undefined &&
|
||||
params.enableGitHubAttribution === undefined
|
||||
params.enableGitHubAttribution === undefined &&
|
||||
params.commitMessageDiscoveryHostKey === undefined
|
||||
) {
|
||||
return runtime.generateRuntimePullRequestFields(params.worktree, input)
|
||||
}
|
||||
|
|
@ -180,6 +200,9 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
: {}),
|
||||
...(params.enableGitHubAttribution !== undefined
|
||||
? { enableGitHubAttribution: params.enableGitHubAttribution }
|
||||
: {}),
|
||||
...(params.commitMessageDiscoveryHostKey !== undefined
|
||||
? { commitMessageDiscoveryHostKey: params.commitMessageDiscoveryHostKey }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
Object.assign(process.env, originalEnv)
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function makeHome(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-commit-env-'))
|
||||
tempDirs.push(dir)
|
||||
process.env.HOME = dir
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('prepareLocalCommitMessageAgentEnv', () => {
|
||||
it('hydrates OpenCode config dir from shell startup files for headless generation', async () => {
|
||||
const home = makeHome()
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
writeFileSync(join(home, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n')
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
OPENCODE_CONFIG_DIR: join(home, 'company/opencode')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the original OpenCode config root over inherited PTY overlays', async () => {
|
||||
process.env.OPENCODE_CONFIG_DIR = '/tmp/orca-opencode-overlay'
|
||||
process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/Users/tester/company/opencode'
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
OPENCODE_CONFIG_DIR: '/Users/tester/company/opencode'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates Pi agent dir from shell startup files for headless generation', async () => {
|
||||
const home = makeHome()
|
||||
delete process.env.PI_CODING_AGENT_DIR
|
||||
writeFileSync(join(home, '.zshrc'), 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n')
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('pi', undefined)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
PI_CODING_AGENT_DIR: join(home, '.config/pi-agent')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the original Pi agent root over inherited PTY overlays', async () => {
|
||||
process.env.PI_CODING_AGENT_DIR = '/tmp/orca-pi-overlay'
|
||||
process.env.ORCA_PI_SOURCE_AGENT_DIR = '/Users/tester/.pi/agent'
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('pi', undefined)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
PI_CODING_AGENT_DIR: '/Users/tester/.pi/agent'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not synthesize env for agents without shell-scoped auth or config roots', async () => {
|
||||
makeHome()
|
||||
|
||||
await expect(prepareLocalCommitMessageAgentEnv('cursor', undefined)).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to inherited env when managed account resolvers are unavailable', async () => {
|
||||
await expect(prepareLocalCommitMessageAgentEnv('codex', undefined)).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
await expect(prepareLocalCommitMessageAgentEnv('claude', undefined)).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
|
||||
|
||||
export type CommitMessageAgentEnvironmentResolvers = {
|
||||
prepareForCodexLaunch?: () => string | null
|
||||
|
|
@ -16,10 +17,46 @@ function cloneProcessEnv(): Record<string, string> {
|
|||
return env
|
||||
}
|
||||
|
||||
function readInheritedOrShellEnvVar(name: string, sourceName?: string): string | undefined {
|
||||
return (
|
||||
(sourceName ? process.env[sourceName] : undefined) ??
|
||||
process.env[name] ??
|
||||
readShellStartupEnvVar(name, process.env.HOME, process.env.SHELL)
|
||||
)
|
||||
}
|
||||
|
||||
function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.ProcessEnv } | null {
|
||||
const configVar =
|
||||
agentId === 'opencode' ? 'OPENCODE_CONFIG_DIR' : agentId === 'pi' ? 'PI_CODING_AGENT_DIR' : null
|
||||
if (!configVar) {
|
||||
return null
|
||||
}
|
||||
const sourceVar =
|
||||
agentId === 'opencode'
|
||||
? 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
|
||||
: agentId === 'pi'
|
||||
? 'ORCA_PI_SOURCE_AGENT_DIR'
|
||||
: undefined
|
||||
|
||||
const value = readInheritedOrShellEnvVar(configVar, sourceVar)
|
||||
if (!value) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Why: GUI-launched Orca may not inherit shell startup exports, but these
|
||||
// vars point the headless CLI at the user's auth/config root. Nested Orca
|
||||
// launches inherit PTY overlays, so prefer ORCA_*_SOURCE_* when present.
|
||||
return { ok: true, env: { ...cloneProcessEnv(), [configVar]: value } }
|
||||
}
|
||||
|
||||
export async function prepareLocalCommitMessageAgentEnv(
|
||||
agentId: string,
|
||||
resolvers: CommitMessageAgentEnvironmentResolvers | undefined
|
||||
): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> {
|
||||
const shellConfigEnv = prepareShellConfigDirEnv(agentId)
|
||||
if (shellConfigEnv) {
|
||||
return shellConfigEnv
|
||||
}
|
||||
if (!resolvers) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { getDefaultSettings } from '../../shared/constants'
|
|||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
discoverCommitMessageModelsLocal,
|
||||
discoverCommitMessageModelsRemote,
|
||||
generateCommitMessageFromContext,
|
||||
generatePullRequestFieldsFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
|
|
@ -39,7 +41,7 @@ beforeEach(() => {
|
|||
})
|
||||
|
||||
describe('resolveCommitMessageSettings', () => {
|
||||
it('falls back to the agent default model when a persisted model is stale', () => {
|
||||
it('falls back when a dynamic persisted model was not discovered', () => {
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
settings.enableGitHubAttribution = true
|
||||
settings.commitMessageAi = {
|
||||
|
|
@ -64,6 +66,29 @@ describe('resolveCommitMessageSettings', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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: ''
|
||||
}
|
||||
|
||||
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'
|
||||
|
|
@ -80,6 +105,66 @@ describe('resolveCommitMessageSettings', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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: ''
|
||||
}
|
||||
|
||||
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: ''
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
|
@ -126,6 +211,28 @@ describe('resolveCommitMessageSettings', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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: ''
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
|
@ -144,7 +251,185 @@ describe('resolveCommitMessageSettings', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
['cursor-agent', '--list-models'],
|
||||
expect.objectContaining({ windowsHide: true })
|
||||
)
|
||||
})
|
||||
|
||||
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' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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(
|
||||
{
|
||||
|
|
@ -247,6 +532,37 @@ describe('generateCommitMessageFromContext', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('treats empty stdout plus an error on stderr as an agent failure', 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 failed. Check the agent CLI configuration and try again.'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the structured subject and body when formatting the final response', async () => {
|
||||
const result = await generateCommitMessageFromContext(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,12 +24,16 @@ import {
|
|||
getCommitMessageAgentSpec,
|
||||
getCommitMessageModel,
|
||||
isCustomAgentId,
|
||||
resolveCommitMessageAgentChoice
|
||||
resolveCommitMessageAgentChoice,
|
||||
type CommitMessageAgentCapability,
|
||||
type CommitMessageModelCapability
|
||||
} from '../../shared/commit-message-agent-spec'
|
||||
import {
|
||||
planAgentBinary,
|
||||
planCommitMessageGeneration,
|
||||
type CommitMessagePlan
|
||||
} from '../../shared/commit-message-plan'
|
||||
import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key'
|
||||
import { resolveCliCommand } from '../codex-cli/command'
|
||||
import {
|
||||
getSpawnArgsForWindows,
|
||||
|
|
@ -53,6 +57,15 @@ export type GenerateCommitMessageResult =
|
|||
| { success: true; message: string; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
||||
export type DiscoverCommitMessageModelsResult =
|
||||
| {
|
||||
success: true
|
||||
capability: CommitMessageAgentCapability
|
||||
models: CommitMessageModelCapability[]
|
||||
defaultModelId: string
|
||||
}
|
||||
| { success: false; error: string }
|
||||
|
||||
export type GeneratePullRequestFieldsResult =
|
||||
| { success: true; fields: GeneratedPullRequestFields; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
|
@ -92,7 +105,8 @@ export function trimGeneratedCommitMessage(message: string): string {
|
|||
}
|
||||
|
||||
export function resolveCommitMessageSettings(
|
||||
settings: GlobalSettings
|
||||
settings: GlobalSettings,
|
||||
discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
): ResolveCommitMessageSettingsResult {
|
||||
const config = settings.commitMessageAi
|
||||
if (!config?.enabled) {
|
||||
|
|
@ -134,9 +148,19 @@ export function resolveCommitMessageSettings(
|
|||
return { ok: false, error: `Agent "${agentId}" does not support AI commit messages.` }
|
||||
}
|
||||
|
||||
const persistedModelId = config.selectedModelByAgent[agentId] ?? spec.defaultModelId
|
||||
const hostSelectedModels = config.selectedModelByAgentByHost?.[discoveryHostKey]
|
||||
const legacySelectedModels =
|
||||
discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY ? config.selectedModelByAgent : undefined
|
||||
const persistedModelId =
|
||||
hostSelectedModels?.[agentId] ?? legacySelectedModels?.[agentId] ?? spec.defaultModelId
|
||||
const discoveredModels =
|
||||
config.discoveredModelsByAgentByHost?.[discoveryHostKey]?.[agentId] ??
|
||||
(discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
? (config.discoveredModelsByAgent?.[agentId] ?? [])
|
||||
: [])
|
||||
const model =
|
||||
getCommitMessageModel(agentId, persistedModelId) ??
|
||||
spec.models.find((candidate) => candidate.id === persistedModelId) ??
|
||||
discoveredModels.find((candidate) => candidate.id === persistedModelId) ??
|
||||
getCommitMessageModel(agentId, spec.defaultModelId)
|
||||
if (!model) {
|
||||
return { ok: false, error: `No model is available for ${spec.label}.` }
|
||||
|
|
@ -176,6 +200,251 @@ function userFacingUnsafeWindowsBatchArgs(label: string): string {
|
|||
return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.`
|
||||
}
|
||||
|
||||
function toModelDiscoveryCapability(
|
||||
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
|
||||
models = spec.models,
|
||||
defaultModelId = spec.defaultModelId
|
||||
): Extract<DiscoverCommitMessageModelsResult, { success: true }> {
|
||||
return {
|
||||
success: true,
|
||||
capability: {
|
||||
id: spec.id,
|
||||
label: spec.label,
|
||||
modelSource: spec.modelSource,
|
||||
defaultModelId,
|
||||
models
|
||||
},
|
||||
models,
|
||||
defaultModelId
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeModelDiscoveryOutput(
|
||||
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
code: number | null
|
||||
): DiscoverCommitMessageModelsResult {
|
||||
if (code !== 0) {
|
||||
const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
|
||||
console.error('[commit-message] Model discovery failed:', {
|
||||
label: spec.label,
|
||||
exitCode: code,
|
||||
safeDetail,
|
||||
stdout,
|
||||
stderr
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `${spec.label} model discovery failed. Check the agent CLI configuration and try again.`
|
||||
}
|
||||
}
|
||||
let models = spec.modelDiscovery?.parse(stdout) ?? []
|
||||
if (models.length === 0 && stderr.trim()) {
|
||||
// Why: Pi currently writes its successful `--list-models` table to stderr,
|
||||
// so exit code 0 must still allow stderr-backed discovery.
|
||||
models = spec.modelDiscovery?.parse(stderr) ?? []
|
||||
}
|
||||
if (models.length === 0) {
|
||||
if (spec.models.length > 0) {
|
||||
console.warn('[commit-message] Model discovery returned no models; using static fallback:', {
|
||||
label: spec.label
|
||||
})
|
||||
return toModelDiscoveryCapability(spec, spec.models, spec.defaultModelId)
|
||||
}
|
||||
return { success: false, error: `${spec.label} returned no available models.` }
|
||||
}
|
||||
const defaultModelId = models.some((model) => model.id === spec.defaultModelId)
|
||||
? spec.defaultModelId
|
||||
: models[0].id
|
||||
return toModelDiscoveryCapability(spec, models, defaultModelId)
|
||||
}
|
||||
|
||||
function planModelDiscovery(
|
||||
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
|
||||
agentCommandOverride?: string
|
||||
): { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } {
|
||||
const modelDiscovery = spec.modelDiscovery
|
||||
if (!modelDiscovery) {
|
||||
return { ok: false, error: `${spec.label} does not support dynamic model discovery.` }
|
||||
}
|
||||
const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride)
|
||||
if (!command.ok) {
|
||||
return command
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
binary: command.binary,
|
||||
args: [...command.prefixArgs, ...modelDiscovery.args],
|
||||
stdinPayload: null,
|
||||
label: spec.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverCommitMessageModelsLocal(
|
||||
agentId: TuiAgent,
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
agentCommandOverride?: string
|
||||
): Promise<DiscoverCommitMessageModelsResult> {
|
||||
const spec = getCommitMessageAgentSpec(agentId)
|
||||
if (!spec) {
|
||||
return { success: false, error: `Agent "${agentId}" does not support AI commit messages.` }
|
||||
}
|
||||
|
||||
if (spec.modelSource === 'static' || !spec.modelDiscovery) {
|
||||
return toModelDiscoveryCapability(spec)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let child: ChildProcess
|
||||
const spawnEnv = env ?? process.env
|
||||
try {
|
||||
const planned = planModelDiscovery(spec, agentCommandOverride)
|
||||
if (!planned.ok) {
|
||||
resolve({ success: false, error: planned.error })
|
||||
return
|
||||
}
|
||||
const resolvedBinary =
|
||||
process.platform === 'win32'
|
||||
? resolveCliCommand(planned.plan.binary, {
|
||||
pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null
|
||||
})
|
||||
: planned.plan.binary
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args)
|
||||
child = spawn(spawnCmd, spawnArgs, {
|
||||
env: spawnEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[commit-message] Failed to spawn model discovery:', error)
|
||||
resolve({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery could not be started. Check the agent CLI configuration and try again.`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let outputLimitExceeded = false
|
||||
let settled = false
|
||||
const finish = (result: DiscoverCommitMessageModelsResult): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
killProcessTree(child)
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
||||
})
|
||||
}, GENERATION_TIMEOUT_MS)
|
||||
|
||||
const onData = (chunk: Buffer, append: (text: string) => void): void => {
|
||||
if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) {
|
||||
outputLimitExceeded = true
|
||||
killProcessTree(child)
|
||||
return
|
||||
}
|
||||
append(chunk.toString('utf-8'))
|
||||
}
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stdout += text)))
|
||||
child.stderr?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stderr += text)))
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.modelDiscovery?.binary ?? spec.binary} not found on PATH. Install ${spec.label} to discover models.`
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery failed to start. Check the agent CLI configuration and try again.`
|
||||
})
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (outputLimitExceeded) {
|
||||
finish({ success: false, error: `${spec.label} returned too much model data.` })
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
||||
return
|
||||
}
|
||||
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function discoverCommitMessageModelsRemote(
|
||||
agentId: TuiAgent,
|
||||
cwd: string,
|
||||
execute: (
|
||||
plan: CommitMessagePlan,
|
||||
cwd: string,
|
||||
timeoutMs: number
|
||||
) => Promise<RemoteCommitMessageExecResult>,
|
||||
agentCommandOverride?: string
|
||||
): Promise<DiscoverCommitMessageModelsResult> {
|
||||
const spec = getCommitMessageAgentSpec(agentId)
|
||||
if (!spec) {
|
||||
return { success: false, error: `Agent "${agentId}" does not support AI commit messages.` }
|
||||
}
|
||||
if (spec.modelSource === 'static' || !spec.modelDiscovery) {
|
||||
return toModelDiscoveryCapability(spec)
|
||||
}
|
||||
const planned = planModelDiscovery(spec, agentCommandOverride)
|
||||
if (!planned.ok) {
|
||||
return { success: false, error: planned.error }
|
||||
}
|
||||
let result: RemoteCommitMessageExecResult
|
||||
try {
|
||||
result = await execute(planned.plan, cwd, GENERATION_TIMEOUT_MS)
|
||||
} catch (error) {
|
||||
console.error('[commit-message] Remote model discovery request failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: `${spec.label} model discovery could not be reached on the remote PATH. Try again after the SSH connection recovers.`
|
||||
}
|
||||
}
|
||||
if (result.spawnError) {
|
||||
if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) {
|
||||
return { success: false, error: userFacingUnsafeWindowsBatchArgs(spec.label) }
|
||||
}
|
||||
if (/ENOENT/i.test(result.spawnError)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${planned.plan.binary} not found on the remote PATH. Install ${spec.label} there.`
|
||||
}
|
||||
}
|
||||
console.error('[commit-message] Remote model discovery spawn failed:', result.spawnError)
|
||||
return {
|
||||
success: false,
|
||||
error: `${spec.label} model discovery could not be started on the remote PATH. Check the agent command there and try again.`
|
||||
}
|
||||
}
|
||||
if (result.canceled) {
|
||||
return { success: false, error: 'Model discovery canceled.' }
|
||||
}
|
||||
if (result.timedOut) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
||||
}
|
||||
}
|
||||
return finalizeModelDiscoveryOutput(spec, result.stdout, result.stderr, result.exitCode)
|
||||
}
|
||||
|
||||
// Why: on Windows, npm-installed CLIs like `claude` and `codex` are usually
|
||||
// `.cmd` shims. We route those through cmd.exe so Node can launch them, and
|
||||
// `child.kill()` would only terminate the wrapper. `taskkill /T /F` walks the
|
||||
|
|
@ -361,6 +630,18 @@ function finalizeFromAgentOutput(args: {
|
|||
}
|
||||
const cleaned = cleanGeneratedCommitMessage(stdout)
|
||||
if (!cleaned) {
|
||||
const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
|
||||
if (safeDetail) {
|
||||
console.error('[commit-message] Generator returned no stdout but reported an error:', {
|
||||
label,
|
||||
exitCode: code,
|
||||
safeDetail,
|
||||
stdout,
|
||||
stderr
|
||||
})
|
||||
finalize({ success: false, error: userFacingAgentFailure(label) })
|
||||
return
|
||||
}
|
||||
finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` })
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ import type {
|
|||
RuntimeSyncWindowGraph,
|
||||
RuntimeTerminalDriverState
|
||||
} from '../shared/runtime-types'
|
||||
import type {
|
||||
CommitMessageAgentCapability,
|
||||
CommitMessageModelCapability
|
||||
} from '../shared/commit-message-agent-spec'
|
||||
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
|
||||
import type { SkillDiscoveryResult } from '../shared/skills'
|
||||
import type {
|
||||
|
|
@ -1481,6 +1485,19 @@ export type PreloadApi = {
|
|||
| { success: true; message: string; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
>
|
||||
discoverCommitMessageModels: (args: {
|
||||
agentId: string
|
||||
worktreePath?: string
|
||||
connectionId?: string
|
||||
}) => Promise<
|
||||
| {
|
||||
success: true
|
||||
capability: CommitMessageAgentCapability
|
||||
models: CommitMessageModelCapability[]
|
||||
defaultModelId: string
|
||||
}
|
||||
| { success: false; error: string }
|
||||
>
|
||||
cancelGenerateCommitMessage: (args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
|
|
|
|||
|
|
@ -2029,6 +2029,11 @@ const api = {
|
|||
worktreePath: string
|
||||
connectionId?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('git:generateCommitMessage', args),
|
||||
discoverCommitMessageModels: (args: {
|
||||
agentId: string
|
||||
worktreePath?: string
|
||||
connectionId?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('git:discoverCommitMessageModels', args),
|
||||
cancelGenerateCommitMessage: (args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import AgentCombobox from './AgentCombobox'
|
||||
|
||||
describe('AgentCombobox', () => {
|
||||
it('keeps enough trigger width for GitHub Copilot when callers pass min-w-0', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AgentCombobox
|
||||
agents={AGENT_CATALOG}
|
||||
value="copilot"
|
||||
onValueChange={vi.fn()}
|
||||
triggerClassName="h-9 w-full min-w-0"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('GitHub Copilot')
|
||||
expect(markup).toContain('!min-w-[260px]')
|
||||
expect(markup).toContain('flex-1')
|
||||
})
|
||||
})
|
||||
|
|
@ -42,6 +42,7 @@ type AgentComboboxProps = {
|
|||
}
|
||||
|
||||
const BLANK_VALUE = '__none__'
|
||||
const TRIGGER_MIN_WIDTH_CLASS = '!min-w-[260px]'
|
||||
|
||||
type ItemRenderArgs = {
|
||||
key: string
|
||||
|
|
@ -239,18 +240,21 @@ export default function AgentCombobox({
|
|||
aria-expanded={open}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
className={cn(
|
||||
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
|
||||
triggerClassName
|
||||
// Why: callers sometimes pass `min-w-0` for grid layouts, but
|
||||
// the compact trigger still needs room for "GitHub Copilot".
|
||||
'h-8 justify-between px-3 text-xs font-normal',
|
||||
triggerClassName,
|
||||
TRIGGER_MIN_WIDTH_CLASS
|
||||
)}
|
||||
data-agent-combobox-root="true"
|
||||
>
|
||||
{selectedAgent ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<AgentIcon agent={selectedAgent.id} />
|
||||
<span className="truncate">{selectedAgent.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Terminal className="size-3.5" />
|
||||
<span className="truncate">Blank Terminal</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export function useEditorPanelContentState({
|
|||
const compareAgainstHead = file.mode === 'edit'
|
||||
const key = inFlightDiffKey(
|
||||
{ ...file, diffSource: effectiveDiffSource },
|
||||
gitScope,
|
||||
gitScope ?? undefined,
|
||||
compareAgainstHead
|
||||
)
|
||||
let pending = inFlightDiffReads.get(key)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { CommitMessageAiSettings, GlobalSettings } from '../../../../shared/types'
|
||||
import {
|
||||
getCommitMessageModelDiscoveryHostKey,
|
||||
getCommitMessageModelDiscoveryHostKeyForScope
|
||||
} from '../../../../shared/commit-message-host-key'
|
||||
import { useAppStore } from '../../store'
|
||||
import { CommitMessageAiPane } from './CommitMessageAiPane'
|
||||
import {
|
||||
CommitMessageAiPane,
|
||||
getCommitMessageSettingsPaneDiscoveryHostKey,
|
||||
mergeDiscoveredModelsIntoCommitMessageConfig
|
||||
} from './CommitMessageAiPane'
|
||||
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
|
||||
|
||||
function renderPane(settings: GlobalSettings): string {
|
||||
|
|
@ -68,6 +76,24 @@ describe('CommitMessageAiPane', () => {
|
|||
expect(markup).toContain('Saved')
|
||||
})
|
||||
|
||||
it('keeps the agent and model selectors aligned for long labels', () => {
|
||||
const markup = renderPane(
|
||||
buildSettings({
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: 'copilot',
|
||||
selectedModelByAgent: { copilot: 'gpt-5.5' },
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(markup.match(/w-\[260px\]/g)).toHaveLength(2)
|
||||
expect(markup.match(/shrink-0/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('renders custom command settings for custom agents', () => {
|
||||
const markup = renderPane(
|
||||
buildSettings({
|
||||
|
|
@ -90,7 +116,7 @@ describe('CommitMessageAiPane', () => {
|
|||
it('shows an unconfigured state when the default agent is unsupported', () => {
|
||||
const markup = renderPane(
|
||||
buildSettings({
|
||||
defaultTuiAgent: 'gemini',
|
||||
defaultTuiAgent: 'aider',
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
|
|
@ -103,12 +129,31 @@ describe('CommitMessageAiPane', () => {
|
|||
)
|
||||
|
||||
expect(markup).toContain('Not configured')
|
||||
expect(markup).toContain('Your default agent is Gemini')
|
||||
expect(markup).toContain('Choose Claude, Codex, or Custom')
|
||||
expect(markup).toContain('Your default agent is Aider')
|
||||
expect(markup).toContain('Choose a supported agent or Custom')
|
||||
expect(markup).not.toContain('Which model the selected agent uses')
|
||||
expect(markup).not.toContain('Thinking effort')
|
||||
})
|
||||
|
||||
it('shows Gemini as coming soon instead of a selectable generator', () => {
|
||||
const markup = renderPane(
|
||||
buildSettings({
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: 'gemini',
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(markup).toContain('Gemini')
|
||||
expect(markup).toContain('Gemini commit message generation is coming soon')
|
||||
expect(markup).not.toContain('Which model the selected agent uses')
|
||||
})
|
||||
|
||||
it('keeps custom command discoverable in settings search metadata', () => {
|
||||
const customCommandEntry = COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES.find(
|
||||
(entry) => entry.title === 'Custom command'
|
||||
|
|
@ -118,4 +163,85 @@ describe('CommitMessageAiPane', () => {
|
|||
expect.arrayContaining(['custom', 'command', 'ollama'])
|
||||
)
|
||||
})
|
||||
|
||||
it('merges discovered models without clobbering newer settings fields', () => {
|
||||
const config: CommitMessageAiSettings = {
|
||||
enabled: true,
|
||||
agentId: 'cursor',
|
||||
selectedModelByAgent: { cursor: 'stale-model', codex: 'gpt-5.5' },
|
||||
selectedThinkingByModel: { 'gpt-5.5': 'low' },
|
||||
customPrompt: 'Use Conventional Commits.',
|
||||
customAgentCommand: '',
|
||||
discoveredModelsByAgent: {}
|
||||
}
|
||||
|
||||
const merged = mergeDiscoveredModelsIntoCommitMessageConfig(
|
||||
config,
|
||||
'cursor',
|
||||
[{ id: 'auto', label: 'Auto' }],
|
||||
'auto'
|
||||
)
|
||||
|
||||
expect(merged.customPrompt).toBe('Use Conventional Commits.')
|
||||
expect(merged.agentId).toBe('cursor')
|
||||
expect(merged.selectedModelByAgent).toEqual({
|
||||
cursor: 'auto',
|
||||
codex: 'gpt-5.5'
|
||||
})
|
||||
expect(merged.discoveredModelsByAgent?.cursor).toEqual([{ id: 'auto', label: 'Auto' }])
|
||||
expect(merged.discoveredModelsByAgentByHost?.local?.cursor).toEqual([
|
||||
{ id: 'auto', label: 'Auto' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps SSH discovered models out of the legacy local cache', () => {
|
||||
const config: CommitMessageAiSettings = {
|
||||
enabled: true,
|
||||
agentId: 'cursor',
|
||||
selectedModelByAgent: { cursor: 'auto' },
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: '',
|
||||
discoveredModelsByAgent: { cursor: [{ id: 'auto', label: 'Auto' }] },
|
||||
selectedModelByAgentByHost: {},
|
||||
discoveredModelsByAgentByHost: {}
|
||||
}
|
||||
|
||||
const merged = mergeDiscoveredModelsIntoCommitMessageConfig(
|
||||
config,
|
||||
'cursor',
|
||||
[{ id: 'remote-only', label: 'Remote Only' }],
|
||||
'remote-only',
|
||||
'ssh:conn-1'
|
||||
)
|
||||
|
||||
expect(merged.selectedModelByAgent.cursor).toBe('auto')
|
||||
expect(merged.discoveredModelsByAgent?.cursor).toEqual([{ id: 'auto', label: 'Auto' }])
|
||||
expect(merged.selectedModelByAgentByHost?.['ssh:conn-1']?.cursor).toBe('remote-only')
|
||||
expect(merged.discoveredModelsByAgentByHost?.['ssh:conn-1']?.cursor).toEqual([
|
||||
{ id: 'remote-only', label: 'Remote Only' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keys model discovery cache by execution host', () => {
|
||||
expect(getCommitMessageModelDiscoveryHostKey(null)).toBe('local')
|
||||
expect(getCommitMessageModelDiscoveryHostKey('ssh-1')).toBe('ssh:ssh-1')
|
||||
expect(getCommitMessageModelDiscoveryHostKey(undefined)).toBe('unknown')
|
||||
expect(getCommitMessageModelDiscoveryHostKeyForScope('runtime:env-1')).toBe('runtime:env-1')
|
||||
expect(getCommitMessageModelDiscoveryHostKeyForScope('ssh-1')).toBe('ssh:ssh-1')
|
||||
})
|
||||
|
||||
it('keeps local active worktree discovery scoped to local, not unknown', () => {
|
||||
expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), null, true)).toBe('local')
|
||||
expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), undefined, true)).toBe(
|
||||
'unknown'
|
||||
)
|
||||
expect(
|
||||
getCommitMessageSettingsPaneDiscoveryHostKey(
|
||||
buildSettings({ activeRuntimeEnvironmentId: 'env-1' }),
|
||||
null,
|
||||
true
|
||||
)
|
||||
).toBe('runtime:env-1')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
a SearchableSetting block, and splitting the pane across files would scatter
|
||||
the ~6 conditional render branches without making any of them clearer. */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { RefreshCw, Terminal } from 'lucide-react'
|
||||
import type { CommitMessageAiSettings, GlobalSettings, TuiAgent } from '../../../../shared/types'
|
||||
import {
|
||||
CUSTOM_AGENT_ID,
|
||||
|
|
@ -15,11 +15,21 @@ import {
|
|||
type CommitMessageModelCapability
|
||||
} from '../../../../shared/commit-message-agent-spec'
|
||||
import { CUSTOM_PROMPT_PLACEHOLDER } from '../../../../shared/commit-message-prompt'
|
||||
import {
|
||||
getCommitMessageModelDiscoveryHostKeyForScope,
|
||||
LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
} from '../../../../shared/commit-message-host-key'
|
||||
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import {
|
||||
discoverRuntimeCommitMessageModels,
|
||||
getRuntimeGitScope
|
||||
} from '../../runtime/runtime-git-client'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useActiveWorktree } from '../../store/selectors'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
|
||||
|
|
@ -34,12 +44,24 @@ const EMPTY_SETTINGS: CommitMessageAiSettings = {
|
|||
enabled: false,
|
||||
agentId: null,
|
||||
selectedModelByAgent: {},
|
||||
discoveredModelsByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
}
|
||||
|
||||
type ModelDiscoveryState = {
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
hostKey: string
|
||||
models: CommitMessageModelCapability[]
|
||||
defaultModelId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const UNCONFIGURED_AGENT_SELECT_VALUE = ''
|
||||
const COMING_SOON_COMMIT_MESSAGE_AGENTS: readonly { id: TuiAgent; label: string }[] = [
|
||||
{ id: 'gemini', label: 'Gemini' }
|
||||
]
|
||||
|
||||
function readSettings(settings: GlobalSettings): CommitMessageAiSettings {
|
||||
return settings.commitMessageAi ?? EMPTY_SETTINGS
|
||||
|
|
@ -49,11 +71,23 @@ function agentLabel(agentId: TuiAgent, capability: CommitMessageAgentCapability)
|
|||
return AGENT_CATALOG.find((a) => a.id === agentId)?.label ?? capability.label
|
||||
}
|
||||
|
||||
function readSelectedModelId(
|
||||
config: CommitMessageAiSettings,
|
||||
hostKey: string,
|
||||
agentId: TuiAgent
|
||||
): string | undefined {
|
||||
return (
|
||||
config.selectedModelByAgentByHost?.[hostKey]?.[agentId] ??
|
||||
(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY ? config.selectedModelByAgent[agentId] : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSelectedModel(
|
||||
config: CommitMessageAiSettings,
|
||||
capability: CommitMessageAgentCapability
|
||||
capability: CommitMessageAgentCapability,
|
||||
hostKey: string
|
||||
): CommitMessageModelCapability {
|
||||
const persisted = config.selectedModelByAgent[capability.id]
|
||||
const persisted = readSelectedModelId(config, hostKey, capability.id)
|
||||
if (persisted) {
|
||||
const found = capability.models.find((m) => m.id === persisted)
|
||||
if (found) {
|
||||
|
|
@ -78,6 +112,91 @@ function resolveSelectedThinking(
|
|||
return model.defaultThinkingLevel
|
||||
}
|
||||
|
||||
export function mergeDiscoveredModelsIntoCommitMessageConfig(
|
||||
config: CommitMessageAiSettings,
|
||||
agentId: TuiAgent,
|
||||
models: CommitMessageModelCapability[],
|
||||
defaultModelId: string,
|
||||
hostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
): CommitMessageAiSettings {
|
||||
const hostSelectedModels = config.selectedModelByAgentByHost?.[hostKey] ?? {}
|
||||
const persisted = readSelectedModelId(config, hostKey, agentId)
|
||||
const nextModelId = models.some((model) => model.id === persisted) ? persisted : defaultModelId
|
||||
const nextHostSelectedModels =
|
||||
nextModelId && nextModelId !== persisted
|
||||
? {
|
||||
...hostSelectedModels,
|
||||
[agentId]: nextModelId
|
||||
}
|
||||
: hostSelectedModels
|
||||
const nextHostDiscoveredModels = {
|
||||
...config.discoveredModelsByAgentByHost?.[hostKey],
|
||||
[agentId]: models
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
...(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
? {
|
||||
discoveredModelsByAgent: {
|
||||
...config.discoveredModelsByAgent,
|
||||
[agentId]: models
|
||||
},
|
||||
selectedModelByAgent:
|
||||
nextModelId && nextModelId !== persisted
|
||||
? {
|
||||
...config.selectedModelByAgent,
|
||||
[agentId]: nextModelId
|
||||
}
|
||||
: config.selectedModelByAgent
|
||||
}
|
||||
: {}),
|
||||
discoveredModelsByAgentByHost: {
|
||||
...config.discoveredModelsByAgentByHost,
|
||||
[hostKey]: nextHostDiscoveredModels
|
||||
},
|
||||
selectedModelByAgentByHost: {
|
||||
...config.selectedModelByAgentByHost,
|
||||
[hostKey]: nextHostSelectedModels
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectModelForHost(
|
||||
config: CommitMessageAiSettings,
|
||||
hostKey: string,
|
||||
agentId: TuiAgent,
|
||||
modelId: string
|
||||
): Pick<CommitMessageAiSettings, 'selectedModelByAgent' | 'selectedModelByAgentByHost'> {
|
||||
const hostSelectedModels = config.selectedModelByAgentByHost?.[hostKey] ?? {}
|
||||
return {
|
||||
selectedModelByAgent:
|
||||
hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
? {
|
||||
...config.selectedModelByAgent,
|
||||
[agentId]: modelId
|
||||
}
|
||||
: config.selectedModelByAgent,
|
||||
selectedModelByAgentByHost: {
|
||||
...config.selectedModelByAgentByHost,
|
||||
[hostKey]: {
|
||||
...hostSelectedModels,
|
||||
[agentId]: modelId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCommitMessageSettingsPaneDiscoveryHostKey(
|
||||
settings: GlobalSettings,
|
||||
activeConnectionId: string | null | undefined,
|
||||
hasActiveWorktree: boolean
|
||||
): string {
|
||||
const runtimeScope = hasActiveWorktree
|
||||
? getRuntimeGitScope(settings, activeConnectionId)
|
||||
: activeConnectionId
|
||||
return getCommitMessageModelDiscoveryHostKeyForScope(runtimeScope)
|
||||
}
|
||||
|
||||
export function CommitMessageAiPane({
|
||||
settings,
|
||||
updateSettings,
|
||||
|
|
@ -85,7 +204,19 @@ export function CommitMessageAiPane({
|
|||
customPromptDiscardSignal
|
||||
}: CommitMessageAiPaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const activeConnectionId = getConnectionId(activeWorktree?.id ?? null)
|
||||
const discoveryHostKey = getCommitMessageSettingsPaneDiscoveryHostKey(
|
||||
settings,
|
||||
activeConnectionId,
|
||||
Boolean(activeWorktree?.id)
|
||||
)
|
||||
const config = readSettings(settings)
|
||||
const latestConfigRef = useRef(config)
|
||||
latestConfigRef.current = config
|
||||
const [modelDiscoveryByAgent, setModelDiscoveryByAgent] = useState<
|
||||
Partial<Record<TuiAgent, ModelDiscoveryState>>
|
||||
>({})
|
||||
const persistedCustomPrompt = config.customPrompt
|
||||
const [customPromptDraft, setCustomPromptDraft] = useState(persistedCustomPrompt)
|
||||
const [isSavingCustomPrompt, setIsSavingCustomPrompt] = useState(false)
|
||||
|
|
@ -119,9 +250,36 @@ export function CommitMessageAiPane({
|
|||
[onCustomPromptDirtyChange]
|
||||
)
|
||||
|
||||
const agentCapabilities = useMemo(listCommitMessageAgentCapabilities, [])
|
||||
const baseAgentCapabilities = useMemo(listCommitMessageAgentCapabilities, [])
|
||||
const agentCapabilities = useMemo(
|
||||
() =>
|
||||
baseAgentCapabilities.map((capability) => {
|
||||
const discovery = modelDiscoveryByAgent[capability.id]
|
||||
if (
|
||||
capability.modelSource !== 'dynamic' ||
|
||||
discovery?.status !== 'ready' ||
|
||||
discovery.hostKey !== discoveryHostKey
|
||||
) {
|
||||
return capability
|
||||
}
|
||||
return {
|
||||
...capability,
|
||||
models: discovery.models,
|
||||
defaultModelId: discovery.defaultModelId ?? capability.defaultModelId
|
||||
}
|
||||
}),
|
||||
[baseAgentCapabilities, discoveryHostKey, modelDiscoveryByAgent]
|
||||
)
|
||||
const resolvedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent)
|
||||
const activeAgentSelectValue = resolvedAgentId ?? UNCONFIGURED_AGENT_SELECT_VALUE
|
||||
const unsupportedSelectedAgent =
|
||||
config.agentId &&
|
||||
!isCustomAgentId(config.agentId) &&
|
||||
!getCommitMessageAgentCapability(config.agentId)
|
||||
? config.agentId
|
||||
: null
|
||||
const activeAgentSelectValue = unsupportedSelectedAgent
|
||||
? UNCONFIGURED_AGENT_SELECT_VALUE
|
||||
: (resolvedAgentId ?? UNCONFIGURED_AGENT_SELECT_VALUE)
|
||||
const unsupportedDefaultAgent =
|
||||
resolvedAgentId === null &&
|
||||
!config.agentId &&
|
||||
|
|
@ -133,18 +291,139 @@ export function CommitMessageAiPane({
|
|||
? (AGENT_CATALOG.find((a) => a.id === unsupportedDefaultAgent)?.label ??
|
||||
unsupportedDefaultAgent)
|
||||
: null
|
||||
const unsupportedSelectedAgentIsComingSoon = COMING_SOON_COMMIT_MESSAGE_AGENTS.some(
|
||||
(agent) => agent.id === unsupportedSelectedAgent
|
||||
)
|
||||
const unsupportedSelectedAgentLabel = unsupportedSelectedAgent
|
||||
? (COMING_SOON_COMMIT_MESSAGE_AGENTS.find((a) => a.id === unsupportedSelectedAgent)?.label ??
|
||||
AGENT_CATALOG.find((a) => a.id === unsupportedSelectedAgent)?.label ??
|
||||
unsupportedSelectedAgent)
|
||||
: null
|
||||
const isCustom = isCustomAgentId(resolvedAgentId)
|
||||
const activeCapability =
|
||||
resolvedAgentId && !isCustomAgentId(resolvedAgentId)
|
||||
? getCommitMessageAgentCapability(resolvedAgentId)
|
||||
: undefined
|
||||
const activeModel = activeCapability ? resolveSelectedModel(config, activeCapability) : null
|
||||
const activeAgentId = resolvedAgentId && !isCustom ? resolvedAgentId : null
|
||||
const activeCapability = activeAgentId
|
||||
? (agentCapabilities.find((capability) => capability.id === activeAgentId) ??
|
||||
getCommitMessageAgentCapability(activeAgentId))
|
||||
: undefined
|
||||
const activeModel = activeCapability
|
||||
? resolveSelectedModel(config, activeCapability, discoveryHostKey)
|
||||
: null
|
||||
const activeThinking = activeModel ? resolveSelectedThinking(config, activeModel) : undefined
|
||||
const rawActiveDiscovery = activeAgentId ? modelDiscoveryByAgent[activeAgentId] : undefined
|
||||
const activeDiscovery =
|
||||
rawActiveDiscovery?.hostKey === discoveryHostKey ? rawActiveDiscovery : undefined
|
||||
|
||||
const writeConfig = (patch: Partial<CommitMessageAiSettings>): void => {
|
||||
updateSettings({ commitMessageAi: { ...config, ...patch } })
|
||||
}
|
||||
|
||||
const refreshModels = async (agentId: TuiAgent): Promise<void> => {
|
||||
const capability =
|
||||
agentCapabilities.find((candidate) => candidate.id === agentId) ??
|
||||
getCommitMessageAgentCapability(agentId)
|
||||
if (!capability || capability.modelSource !== 'dynamic') {
|
||||
return
|
||||
}
|
||||
setModelDiscoveryByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: {
|
||||
status: 'loading',
|
||||
hostKey: discoveryHostKey,
|
||||
models:
|
||||
prev[agentId]?.hostKey === discoveryHostKey
|
||||
? (prev[agentId]?.models ?? capability.models)
|
||||
: capability.models
|
||||
}
|
||||
}))
|
||||
try {
|
||||
const result = await discoverRuntimeCommitMessageModels(
|
||||
{
|
||||
settings,
|
||||
worktreeId: activeWorktree?.id,
|
||||
worktreePath: activeWorktree?.path ?? '',
|
||||
connectionId: activeConnectionId ?? undefined
|
||||
},
|
||||
agentId
|
||||
)
|
||||
if (!result.success) {
|
||||
setModelDiscoveryByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: {
|
||||
status: 'error',
|
||||
hostKey: discoveryHostKey,
|
||||
models:
|
||||
prev[agentId]?.hostKey === discoveryHostKey
|
||||
? (prev[agentId]?.models ?? capability.models)
|
||||
: capability.models,
|
||||
error: result.error
|
||||
}
|
||||
}))
|
||||
return
|
||||
}
|
||||
setModelDiscoveryByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: {
|
||||
status: 'ready',
|
||||
hostKey: discoveryHostKey,
|
||||
models: result.models,
|
||||
defaultModelId: result.defaultModelId
|
||||
}
|
||||
}))
|
||||
const latestConfig = latestConfigRef.current
|
||||
updateSettings({
|
||||
commitMessageAi: mergeDiscoveredModelsIntoCommitMessageConfig(
|
||||
latestConfig,
|
||||
agentId,
|
||||
result.models,
|
||||
result.defaultModelId,
|
||||
discoveryHostKey
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
setModelDiscoveryByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: {
|
||||
status: 'error',
|
||||
hostKey: discoveryHostKey,
|
||||
models:
|
||||
prev[agentId]?.hostKey === discoveryHostKey
|
||||
? (prev[agentId]?.models ?? capability.models)
|
||||
: capability.models,
|
||||
error: error instanceof Error ? error.message : 'Failed to discover models'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!config.enabled ||
|
||||
isCustom ||
|
||||
!activeCapability ||
|
||||
activeCapability.modelSource !== 'dynamic'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const discovery = modelDiscoveryByAgent[activeCapability.id]
|
||||
if (
|
||||
discovery?.hostKey === discoveryHostKey &&
|
||||
(discovery.status === 'loading' || discovery.status === 'ready')
|
||||
) {
|
||||
return
|
||||
}
|
||||
void refreshModels(activeCapability.id)
|
||||
// Why: auto-refresh should run once when a dynamic agent becomes active.
|
||||
// Including the discovery map would retry immediately after an error and
|
||||
// turn a visible CLI failure into a request loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
activeCapability?.id,
|
||||
activeCapability?.modelSource,
|
||||
config.enabled,
|
||||
discoveryHostKey,
|
||||
isCustom
|
||||
])
|
||||
|
||||
const onToggleEnabled = (): void => {
|
||||
const next = !config.enabled
|
||||
if (!next) {
|
||||
|
|
@ -164,13 +443,23 @@ export function CommitMessageAiPane({
|
|||
const seedCapability = isCustomAgentId(seedAgentId)
|
||||
? undefined
|
||||
: getCommitMessageAgentCapability(seedAgentId)
|
||||
const seedModel = seedCapability ? resolveSelectedModel(config, seedCapability) : null
|
||||
const seedModel = seedCapability
|
||||
? resolveSelectedModel(config, seedCapability, discoveryHostKey)
|
||||
: null
|
||||
const seedThinking = seedModel ? resolveSelectedThinking(config, seedModel) : undefined
|
||||
|
||||
const nextSelectedModelByAgent = { ...config.selectedModelByAgent }
|
||||
if (seedCapability && !nextSelectedModelByAgent[seedCapability.id]) {
|
||||
nextSelectedModelByAgent[seedCapability.id] = seedCapability.defaultModelId
|
||||
}
|
||||
const selectedModelPatch = seedCapability
|
||||
? selectModelForHost(
|
||||
config,
|
||||
discoveryHostKey,
|
||||
seedCapability.id,
|
||||
readSelectedModelId(config, discoveryHostKey, seedCapability.id) ??
|
||||
seedCapability.defaultModelId
|
||||
)
|
||||
: {
|
||||
selectedModelByAgent: config.selectedModelByAgent,
|
||||
selectedModelByAgentByHost: config.selectedModelByAgentByHost
|
||||
}
|
||||
const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
|
||||
if (seedModel && seedThinking && !nextSelectedThinkingByModel[seedModel.id]) {
|
||||
nextSelectedThinkingByModel[seedModel.id] = seedThinking
|
||||
|
|
@ -178,7 +467,7 @@ export function CommitMessageAiPane({
|
|||
writeConfig({
|
||||
enabled: true,
|
||||
agentId: seedAgentId,
|
||||
selectedModelByAgent: nextSelectedModelByAgent,
|
||||
...selectedModelPatch,
|
||||
selectedThinkingByModel: nextSelectedThinkingByModel
|
||||
})
|
||||
}
|
||||
|
|
@ -195,11 +484,17 @@ export function CommitMessageAiPane({
|
|||
if (!capability) {
|
||||
return
|
||||
}
|
||||
const nextSelectedModelByAgent = { ...config.selectedModelByAgent }
|
||||
if (!nextSelectedModelByAgent[capability.id]) {
|
||||
nextSelectedModelByAgent[capability.id] = capability.defaultModelId
|
||||
}
|
||||
const newModel = resolveSelectedModel({ ...config, agentId: capability.id }, capability)
|
||||
const selectedModelPatch = selectModelForHost(
|
||||
config,
|
||||
discoveryHostKey,
|
||||
capability.id,
|
||||
readSelectedModelId(config, discoveryHostKey, capability.id) ?? capability.defaultModelId
|
||||
)
|
||||
const newModel = resolveSelectedModel(
|
||||
{ ...config, ...selectedModelPatch, agentId: capability.id },
|
||||
capability,
|
||||
discoveryHostKey
|
||||
)
|
||||
const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
|
||||
if (
|
||||
newModel.thinkingLevels &&
|
||||
|
|
@ -210,7 +505,7 @@ export function CommitMessageAiPane({
|
|||
}
|
||||
writeConfig({
|
||||
agentId: capability.id,
|
||||
selectedModelByAgent: nextSelectedModelByAgent,
|
||||
...selectedModelPatch,
|
||||
selectedThinkingByModel: nextSelectedThinkingByModel
|
||||
})
|
||||
}
|
||||
|
|
@ -227,10 +522,12 @@ export function CommitMessageAiPane({
|
|||
if (!model) {
|
||||
return
|
||||
}
|
||||
const nextSelectedModelByAgent = {
|
||||
...config.selectedModelByAgent,
|
||||
[activeCapability.id]: model.id
|
||||
}
|
||||
const selectedModelPatch = selectModelForHost(
|
||||
config,
|
||||
discoveryHostKey,
|
||||
activeCapability.id,
|
||||
model.id
|
||||
)
|
||||
const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
|
||||
if (
|
||||
model.thinkingLevels &&
|
||||
|
|
@ -240,7 +537,7 @@ export function CommitMessageAiPane({
|
|||
nextSelectedThinkingByModel[model.id] = model.defaultThinkingLevel
|
||||
}
|
||||
writeConfig({
|
||||
selectedModelByAgent: nextSelectedModelByAgent,
|
||||
...selectedModelPatch,
|
||||
selectedThinkingByModel: nextSelectedThinkingByModel
|
||||
})
|
||||
}
|
||||
|
|
@ -321,7 +618,7 @@ export function CommitMessageAiPane({
|
|||
matchesSettingsSearch(searchQuery, {
|
||||
title: 'Agent',
|
||||
description: 'Which agent to invoke when generating a commit message.',
|
||||
keywords: ['agent', 'claude', 'codex']
|
||||
keywords: ['agent', 'claude', 'codex', 'opencode', 'gemini', 'cursor']
|
||||
})
|
||||
) {
|
||||
sections.push(
|
||||
|
|
@ -329,7 +626,7 @@ export function CommitMessageAiPane({
|
|||
key="agent"
|
||||
title="Agent"
|
||||
description="Which agent to invoke when generating a commit message."
|
||||
keywords={['agent', 'claude', 'codex']}
|
||||
keywords={['agent', 'claude', 'codex', 'opencode', 'gemini', 'cursor']}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
|
|
@ -342,7 +639,7 @@ export function CommitMessageAiPane({
|
|||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Select value={activeAgentSelectValue} onValueChange={onAgentChange}>
|
||||
<SelectTrigger size="sm" className="h-8 text-xs w-[180px]">
|
||||
<SelectTrigger size="sm" className="h-8 w-[260px] shrink-0 text-xs">
|
||||
<SelectValue placeholder="Not configured" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -357,6 +654,17 @@ export function CommitMessageAiPane({
|
|||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
{COMING_SOON_COMMIT_MESSAGE_AGENTS.filter(
|
||||
(agent) => !agentCapabilities.some((capability) => capability.id === agent.id)
|
||||
).map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id} disabled className="cursor-not-allowed">
|
||||
<span className="flex items-center gap-2">
|
||||
<AgentIcon agent={agent.id} size={14} />
|
||||
<span>{agent.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground">Coming soon</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM_AGENT_ID} className="cursor-pointer">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5" />
|
||||
|
|
@ -368,7 +676,15 @@ export function CommitMessageAiPane({
|
|||
{unsupportedDefaultAgentLabel ? (
|
||||
<p className="max-w-[260px] text-right text-[11px] text-muted-foreground">
|
||||
Your default agent is {unsupportedDefaultAgentLabel}, which does not support commit
|
||||
message generation yet. Choose Claude, Codex, or Custom.
|
||||
message generation yet. Choose a supported agent or Custom.
|
||||
</p>
|
||||
) : null}
|
||||
{unsupportedSelectedAgentLabel ? (
|
||||
<p className="max-w-[260px] text-right text-[11px] text-muted-foreground">
|
||||
{unsupportedSelectedAgentIsComingSoon
|
||||
? `${unsupportedSelectedAgentLabel} commit message generation is coming soon.`
|
||||
: `${unsupportedSelectedAgentLabel} does not support commit message generation yet.`}{' '}
|
||||
Choose a supported agent or Custom.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -444,22 +760,42 @@ export function CommitMessageAiPane({
|
|||
<div className="space-y-0.5">
|
||||
<Label>Model</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to the strongest available model for the selected agent. Pick a smaller one if
|
||||
you prefer lower latency or cost.
|
||||
{activeCapability.modelSource === 'dynamic'
|
||||
? 'Refreshes from the selected CLI when the CLI exposes model discovery.'
|
||||
: 'This agent does not expose model discovery, so Orca uses a manual catalog.'}
|
||||
</p>
|
||||
{activeDiscovery?.status === 'error' && (
|
||||
<p className="text-xs text-destructive">{activeDiscovery.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCapability.modelSource === 'dynamic' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshModels(activeCapability.id)}
|
||||
disabled={activeDiscovery?.status === 'loading'}
|
||||
title="Refresh models"
|
||||
aria-label="Refresh models"
|
||||
className="inline-flex size-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${activeDiscovery?.status === 'loading' ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<Select value={activeModel.id} onValueChange={onModelChange}>
|
||||
<SelectTrigger size="sm" className="h-8 w-[260px] shrink-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeCapability.models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} className="cursor-pointer">
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={activeModel.id} onValueChange={onModelChange}>
|
||||
<SelectTrigger size="sm" className="h-8 text-xs w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeCapability.models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} className="cursor-pointer">
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
bulkStageRuntimeGitPaths,
|
||||
cancelRuntimeGenerateCommitMessage,
|
||||
commitRuntimeGit,
|
||||
discoverRuntimeCommitMessageModels,
|
||||
generateRuntimeCommitMessage,
|
||||
getRuntimeGitDiff,
|
||||
getRuntimeGitHistory,
|
||||
|
|
@ -27,6 +28,7 @@ const gitBulkDiscard = vi.fn()
|
|||
const gitCommit = vi.fn()
|
||||
const gitPush = vi.fn()
|
||||
const gitGenerateCommitMessage = vi.fn()
|
||||
const gitDiscoverCommitMessageModels = vi.fn()
|
||||
const gitCancelGenerateCommitMessage = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
|
@ -43,6 +45,7 @@ beforeEach(() => {
|
|||
gitCommit.mockReset()
|
||||
gitPush.mockReset()
|
||||
gitGenerateCommitMessage.mockReset()
|
||||
gitDiscoverCommitMessageModels.mockReset()
|
||||
gitCancelGenerateCommitMessage.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
|
|
@ -62,6 +65,7 @@ beforeEach(() => {
|
|||
commit: gitCommit,
|
||||
push: gitPush,
|
||||
generateCommitMessage: gitGenerateCommitMessage,
|
||||
discoverCommitMessageModels: gitDiscoverCommitMessageModels,
|
||||
cancelGenerateCommitMessage: gitCancelGenerateCommitMessage
|
||||
},
|
||||
runtime: { call: runtimeCall },
|
||||
|
|
@ -313,7 +317,7 @@ describe('runtime git client', () => {
|
|||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
|
||||
selector: 'env-1',
|
||||
method: 'git.generateCommitMessage',
|
||||
params: { worktree: 'wt-1' },
|
||||
params: { worktree: 'wt-1', commitMessageDiscoveryHostKey: 'runtime:env-1' },
|
||||
timeoutMs: 75_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
|
||||
|
|
@ -365,9 +369,37 @@ describe('runtime git client', () => {
|
|||
worktree: 'wt-1',
|
||||
commitMessageAi,
|
||||
agentCmdOverrides,
|
||||
enableGitHubAttribution: true
|
||||
enableGitHubAttribution: true,
|
||||
commitMessageDiscoveryHostKey: 'runtime:env-1'
|
||||
},
|
||||
timeoutMs: 75_000
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers commit-message models through the active runtime', async () => {
|
||||
const agentCmdOverrides = { cursor: 'cursor-agent' }
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { success: true, models: [{ id: 'auto', label: 'Auto' }], defaultModelId: 'auto' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
await discoverRuntimeCommitMessageModels(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1', agentCmdOverrides },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
'cursor'
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'git.discoverCommitMessageModels',
|
||||
params: { worktree: 'wt-1', agentId: 'cursor', agentCmdOverrides },
|
||||
timeoutMs: 75_000
|
||||
})
|
||||
expect(gitDiscoverCommitMessageModels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import type {
|
|||
GitUpstreamStatus,
|
||||
GlobalSettings
|
||||
} from '../../../shared/types'
|
||||
import type {
|
||||
CommitMessageAgentCapability,
|
||||
CommitMessageModelCapability
|
||||
} from '../../../shared/commit-message-agent-spec'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
|
||||
|
|
@ -29,6 +34,15 @@ export type RuntimeGeneratePullRequestFieldsResult =
|
|||
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
|
||||
Partial<Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>>
|
||||
|
||||
type RuntimeDiscoverCommitMessageModelsResult =
|
||||
| {
|
||||
success: true
|
||||
capability: CommitMessageAgentCapability
|
||||
models: CommitMessageModelCapability[]
|
||||
defaultModelId: string
|
||||
}
|
||||
| { success: false; error: string }
|
||||
|
||||
export type RuntimeGitContext = {
|
||||
settings: RuntimeGitSettings | null | undefined
|
||||
worktreeId: string | null | undefined
|
||||
|
|
@ -37,13 +51,17 @@ export type RuntimeGitContext = {
|
|||
}
|
||||
|
||||
function getRuntimeCommitMessageSettings(
|
||||
settings: RuntimeGitSettings | null | undefined
|
||||
settings: RuntimeGitSettings | null | undefined,
|
||||
connectionId?: string
|
||||
): Partial<
|
||||
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
|
||||
> {
|
||||
> & {
|
||||
commitMessageDiscoveryHostKey?: string
|
||||
} {
|
||||
if (!settings) {
|
||||
return {}
|
||||
}
|
||||
const scope = getRuntimeGitScope(settings, connectionId)
|
||||
return {
|
||||
...(settings.commitMessageAi !== undefined
|
||||
? { commitMessageAi: settings.commitMessageAi }
|
||||
|
|
@ -53,14 +71,15 @@ function getRuntimeCommitMessageSettings(
|
|||
: {}),
|
||||
...(settings.enableGitHubAttribution !== undefined
|
||||
? { enableGitHubAttribution: settings.enableGitHubAttribution }
|
||||
: {})
|
||||
: {}),
|
||||
commitMessageDiscoveryHostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope)
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeGitScope(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
connectionId: string | undefined
|
||||
): string | undefined {
|
||||
connectionId: string | null | undefined
|
||||
): string | null | undefined {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId
|
||||
}
|
||||
|
|
@ -362,7 +381,33 @@ export async function generateRuntimeCommitMessage(
|
|||
'git.generateCommitMessage',
|
||||
{
|
||||
worktree: context.worktreeId,
|
||||
...getRuntimeCommitMessageSettings(context.settings)
|
||||
...getRuntimeCommitMessageSettings(context.settings, context.connectionId)
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function discoverRuntimeCommitMessageModels(
|
||||
context: RuntimeGitContext,
|
||||
agentId: string
|
||||
): Promise<RuntimeDiscoverCommitMessageModelsResult> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
return window.api.git.discoverCommitMessageModels({
|
||||
agentId,
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId
|
||||
}) as Promise<RuntimeDiscoverCommitMessageModelsResult>
|
||||
}
|
||||
return callRuntimeRpc<RuntimeDiscoverCommitMessageModelsResult>(
|
||||
target,
|
||||
'git.discoverCommitMessageModels',
|
||||
{
|
||||
worktree: context.worktreeId,
|
||||
agentId,
|
||||
...(context.settings?.agentCmdOverrides
|
||||
? { agentCmdOverrides: context.settings.agentCmdOverrides }
|
||||
: {})
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
|
|
@ -405,7 +450,7 @@ export async function generateRuntimePullRequestFields(
|
|||
{
|
||||
worktree: context.worktreeId,
|
||||
...input,
|
||||
...getRuntimeCommitMessageSettings(context.settings)
|
||||
...getRuntimeCommitMessageSettings(context.settings, context.connectionId)
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -586,6 +586,10 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
|
|||
success: false,
|
||||
error: 'Commit message generation is unavailable in the web client.'
|
||||
}),
|
||||
discoverCommitMessageModels: async () => ({
|
||||
success: false,
|
||||
error: 'Commit message model discovery is unavailable in the web client.'
|
||||
}),
|
||||
cancelGenerateCommitMessage: () => Promise.resolve(),
|
||||
generatePullRequestFields: async () => ({
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -9,33 +9,67 @@ import {
|
|||
getCommitMessageModel,
|
||||
isCustomAgentId,
|
||||
listCommitMessageAgentCapabilities,
|
||||
listCommitMessageAgentIds
|
||||
listCommitMessageAgentIds,
|
||||
parseCodexModels,
|
||||
parseCursorModels,
|
||||
parseLineModels,
|
||||
parsePiModels
|
||||
} from './commit-message-agent-spec'
|
||||
|
||||
describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
|
||||
it('exposes Claude and Codex as the v1 agents', () => {
|
||||
it('exposes the installed local agents as commit-message agents', () => {
|
||||
const ids = listCommitMessageAgentIds().sort()
|
||||
expect(ids).toEqual(['claude', 'codex'])
|
||||
expect(ids).toEqual(['amp', 'claude', 'codex', 'copilot', 'cursor', 'kimi', 'opencode', 'pi'])
|
||||
})
|
||||
|
||||
it('uses the smartest model as the default for each agent', () => {
|
||||
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('claude-opus-4-7')
|
||||
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('github-copilot/gpt-5.4-mini')
|
||||
})
|
||||
|
||||
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('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('defaults every model with thinking levels to "low"', () => {
|
||||
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).toBe('low')
|
||||
expect(model.thinkingLevels.some((l) => l.id === 'low')).toBe(true)
|
||||
expect(model.defaultThinkingLevel).toBeDefined()
|
||||
expect(model.thinkingLevels.some((l) => l.id === model.defaultThinkingLevel)).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -48,8 +82,8 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
|
|||
expect(spark?.defaultThinkingLevel).toBe('low')
|
||||
})
|
||||
|
||||
it('omits thinking levels on Claude Haiku 4.5 (non-reasoning model)', () => {
|
||||
const haiku = getCommitMessageModel('claude', 'claude-haiku-4-5')
|
||||
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()
|
||||
|
|
@ -81,11 +115,12 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
|
|||
|
||||
it('exposes UI capabilities without spawn details', () => {
|
||||
const capabilities = listCommitMessageAgentCapabilities()
|
||||
expect(capabilities.map((capability) => capability.id).sort()).toEqual(['claude', 'codex'])
|
||||
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')
|
||||
|
|
@ -98,14 +133,22 @@ describe('buildArgs (Claude)', () => {
|
|||
const spec = getCommitMessageAgentSpec('claude')!
|
||||
|
||||
it('passes -p, output format, and model on every call', () => {
|
||||
const args = spec.buildArgs({ prompt: '', model: 'claude-haiku-4-5' })
|
||||
expect(args).toEqual(['-p', '--output-format', 'text', '--model', 'claude-haiku-4-5'])
|
||||
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: 'claude-sonnet-4-6',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'high'
|
||||
})
|
||||
expect(args).toEqual([
|
||||
|
|
@ -113,18 +156,100 @@ describe('buildArgs (Claude)', () => {
|
|||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
'claude-sonnet-4-6',
|
||||
'sonnet',
|
||||
'--permission-mode',
|
||||
'plan',
|
||||
'--effort',
|
||||
'high'
|
||||
])
|
||||
})
|
||||
|
||||
it('omits --effort when thinkingLevel is not provided', () => {
|
||||
const args = spec.buildArgs({ prompt: '', model: 'claude-opus-4-7' })
|
||||
const args = spec.buildArgs({ prompt: '', model: 'opus' })
|
||||
expect(args).not.toContain('--effort')
|
||||
})
|
||||
})
|
||||
|
||||
describe('model discovery parsers', () => {
|
||||
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('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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildArgs (Codex)', () => {
|
||||
const spec = getCommitMessageAgentSpec('codex')!
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { TuiAgent } from './types'
|
||||
|
||||
/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */
|
||||
|
||||
// Why: this file is the source of truth for non-interactive agent invocation
|
||||
// (commit-message generation). It is intentionally separate from
|
||||
// `tui-agent-config.ts`, which describes interactive PTY launching — mixing
|
||||
|
|
@ -27,6 +29,14 @@ export type CommitMessageAgentSpec = {
|
|||
/** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */
|
||||
promptDelivery: 'argv' | 'stdin'
|
||||
buildArgs: (params: { prompt: string; model: string; thinkingLevel?: string }) => string[]
|
||||
/** Whether the model list is static or discovered from the agent CLI. */
|
||||
modelSource: 'static' | 'dynamic'
|
||||
/** Command used by the main process to discover models when modelSource is dynamic. */
|
||||
modelDiscovery?: {
|
||||
binary: string
|
||||
args: string[]
|
||||
parse: (stdout: string) => CommitMessageModel[]
|
||||
}
|
||||
models: CommitMessageModel[]
|
||||
defaultModelId: string
|
||||
}
|
||||
|
|
@ -41,10 +51,159 @@ export type CommitMessageModelCapability = {
|
|||
export type CommitMessageAgentCapability = {
|
||||
id: TuiAgent
|
||||
label: string
|
||||
modelSource: 'static' | 'dynamic'
|
||||
models: CommitMessageModelCapability[]
|
||||
defaultModelId: string
|
||||
}
|
||||
|
||||
const BASIC_THINKING_LEVELS: ThinkingLevel[] = [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' }
|
||||
]
|
||||
|
||||
const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' }
|
||||
]
|
||||
|
||||
const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' },
|
||||
{ id: 'max', label: 'Max' }
|
||||
]
|
||||
|
||||
function labelFromModelId(id: string): string {
|
||||
return id
|
||||
.split(/[/-]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (/^gpt$/i.test(part)) {
|
||||
return 'GPT'
|
||||
}
|
||||
return part.length <= 3 && /^\d/.test(part)
|
||||
? part.toUpperCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] {
|
||||
const seen = new Set<string>()
|
||||
return models.filter((model) => {
|
||||
if (!model.id || seen.has(model.id)) {
|
||||
return false
|
||||
}
|
||||
seen.add(model.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function withOpenAiThinking(
|
||||
id: string
|
||||
): Pick<CommitMessageModel, 'thinkingLevels' | 'defaultThinkingLevel'> {
|
||||
return /(?:gpt-5|codex)/i.test(id)
|
||||
? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' }
|
||||
: {}
|
||||
}
|
||||
|
||||
export function parseCodexModels(stdout: string): CommitMessageModel[] {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
models?: {
|
||||
slug?: string
|
||||
display_name?: string
|
||||
supported_reasoning_levels?: { effort?: string }[]
|
||||
default_reasoning_level?: string
|
||||
}[]
|
||||
}
|
||||
return uniqueModels(
|
||||
(parsed.models ?? [])
|
||||
.filter((model) => model.slug && model.display_name)
|
||||
.map((model) => ({
|
||||
id: model.slug!,
|
||||
label: model.display_name!,
|
||||
...(model.supported_reasoning_levels?.length
|
||||
? {
|
||||
thinkingLevels: model.supported_reasoning_levels
|
||||
.map((level) => level.effort)
|
||||
.filter((effort): effort is string => Boolean(effort))
|
||||
.map((effort) => ({
|
||||
id: effort,
|
||||
label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort)
|
||||
})),
|
||||
defaultThinkingLevel: model.default_reasoning_level ?? 'low'
|
||||
}
|
||||
: {})
|
||||
}))
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLineModels(stdout: string): CommitMessageModel[] {
|
||||
return uniqueModels(
|
||||
stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.includes(' '))
|
||||
.map((id) => ({
|
||||
id,
|
||||
label: labelFromModelId(id),
|
||||
...withOpenAiThinking(id)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export function parsePiModels(stdout: string): CommitMessageModel[] {
|
||||
return uniqueModels(
|
||||
stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.filter((parts) => parts.length >= 6 && parts[0] !== 'provider')
|
||||
.map((parts) => {
|
||||
const [provider, model, , , thinking] = parts
|
||||
const id = `${provider}/${model}`
|
||||
return {
|
||||
id,
|
||||
label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`,
|
||||
...(thinking === 'yes'
|
||||
? {
|
||||
thinkingLevels: [
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' }
|
||||
],
|
||||
defaultThinkingLevel: 'low'
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function parseCursorModels(stdout: string): CommitMessageModel[] {
|
||||
return uniqueModels(
|
||||
stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.map((line) => /^([^\s]+)\s+-\s+(.+)$/.exec(line))
|
||||
.filter((match): match is RegExpExecArray => Boolean(match))
|
||||
.map((match) => ({
|
||||
id: match[1],
|
||||
label: match[2].replace(/\s+\((?:default|current)\)$/i, ''),
|
||||
...withOpenAiThinking(match[1])
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageAgentSpec>> = {
|
||||
claude: {
|
||||
id: 'claude',
|
||||
|
|
@ -59,42 +218,32 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
|
|||
'text',
|
||||
'--model',
|
||||
model,
|
||||
'--permission-mode',
|
||||
'plan',
|
||||
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
|
||||
],
|
||||
modelSource: 'static',
|
||||
models: [
|
||||
{
|
||||
// Why: Haiku 4.5 is a non-reasoning model — `claude --effort` rejects
|
||||
// any value for it. Omit thinkingLevels so the UI hides the dropdown
|
||||
// and the buildArgs path skips passing --effort entirely.
|
||||
id: 'claude-haiku-4-5',
|
||||
label: 'Haiku 4.5'
|
||||
// Why: Claude Code aliases track the account/provider's supported
|
||||
// model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex.
|
||||
id: 'haiku',
|
||||
label: 'Haiku'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6',
|
||||
label: 'Sonnet 4.6',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' },
|
||||
{ id: 'max', label: 'Max' }
|
||||
],
|
||||
id: 'sonnet',
|
||||
label: 'Sonnet',
|
||||
thinkingLevels: CLAUDE_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-7',
|
||||
label: 'Opus 4.7',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' },
|
||||
{ id: 'max', label: 'Max' }
|
||||
],
|
||||
id: 'opus',
|
||||
label: 'Opus',
|
||||
thinkingLevels: CLAUDE_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
}
|
||||
],
|
||||
defaultModelId: 'claude-opus-4-7'
|
||||
defaultModelId: 'sonnet'
|
||||
},
|
||||
codex: {
|
||||
id: 'codex',
|
||||
|
|
@ -116,51 +265,37 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
|
|||
model,
|
||||
...(thinkingLevel ? ['-c', `model_reasoning_effort=${thinkingLevel}`] : [])
|
||||
],
|
||||
modelSource: 'dynamic',
|
||||
modelDiscovery: {
|
||||
binary: 'codex',
|
||||
args: ['debug', 'models'],
|
||||
parse: parseCodexModels
|
||||
},
|
||||
// Why: ordered to match the official `codex` model picker — descending
|
||||
// by version so the frontier model lands on top and legacy models trail.
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
label: 'GPT-5.5',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' }
|
||||
],
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
label: 'GPT-5.4',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' }
|
||||
],
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-mini',
|
||||
label: 'GPT-5.4 Mini',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' }
|
||||
],
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.3-codex',
|
||||
label: 'GPT-5.3 Codex',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' }
|
||||
],
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
|
|
@ -170,27 +305,267 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
|
|||
// tier, not the effort flag.
|
||||
id: 'gpt-5.3-codex-spark',
|
||||
label: 'GPT-5.3 Codex Spark',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' }
|
||||
],
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
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' }
|
||||
],
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
}
|
||||
],
|
||||
defaultModelId: 'gpt-5.5'
|
||||
},
|
||||
opencode: {
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
binary: 'opencode',
|
||||
promptDelivery: 'argv',
|
||||
buildArgs: ({ prompt, model, thinkingLevel }) => [
|
||||
'run',
|
||||
'--model',
|
||||
model,
|
||||
'--agent',
|
||||
'build',
|
||||
'--format',
|
||||
'default',
|
||||
...(thinkingLevel ? ['--variant', thinkingLevel] : []),
|
||||
prompt
|
||||
],
|
||||
modelSource: 'dynamic',
|
||||
modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels },
|
||||
models: [
|
||||
{
|
||||
// Why: OpenCode's hosted GPT models can require workspace billing even
|
||||
// when `opencode models` lists them. This free model is available in
|
||||
// discovery and works as a usable out-of-the-box default.
|
||||
id: 'opencode/deepseek-v4-flash-free',
|
||||
label: 'OpenCode DeepSeek V4 Flash Free'
|
||||
},
|
||||
{
|
||||
id: 'opencode/gpt-5.4-mini',
|
||||
label: 'OpenCode GPT 5.4 Mini',
|
||||
...withOpenAiThinking('gpt-5.4-mini')
|
||||
}
|
||||
],
|
||||
defaultModelId: 'opencode/deepseek-v4-flash-free'
|
||||
},
|
||||
pi: {
|
||||
id: 'pi',
|
||||
label: 'Pi',
|
||||
binary: 'pi',
|
||||
promptDelivery: 'stdin',
|
||||
buildArgs: ({ model, thinkingLevel }) => [
|
||||
'--print',
|
||||
'--no-session',
|
||||
'--no-tools',
|
||||
'--no-extensions',
|
||||
'--no-skills',
|
||||
'--no-context-files',
|
||||
'--mode',
|
||||
'text',
|
||||
'--model',
|
||||
model,
|
||||
...(thinkingLevel ? ['--thinking', thinkingLevel] : [])
|
||||
],
|
||||
modelSource: 'dynamic',
|
||||
modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels },
|
||||
models: [
|
||||
{
|
||||
// Why: Pi commonly authenticates through GitHub Copilot locally; using
|
||||
// that provider avoids selecting a raw OpenAI model when no key exists.
|
||||
id: 'github-copilot/gpt-5.4-mini',
|
||||
label: 'Github Copilot GPT 5.4 Mini',
|
||||
...withOpenAiThinking('gpt-5.4-mini')
|
||||
}
|
||||
],
|
||||
defaultModelId: 'github-copilot/gpt-5.4-mini'
|
||||
},
|
||||
amp: {
|
||||
id: 'amp',
|
||||
label: 'Amp',
|
||||
binary: 'amp',
|
||||
promptDelivery: 'stdin',
|
||||
buildArgs: ({ model, thinkingLevel }) => [
|
||||
'--execute',
|
||||
'--archive',
|
||||
'--no-notifications',
|
||||
'--no-ide',
|
||||
'--no-jetbrains',
|
||||
'--mode',
|
||||
model,
|
||||
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
|
||||
],
|
||||
modelSource: 'static',
|
||||
models: [
|
||||
{ id: 'smart', label: 'Smart' },
|
||||
{ id: 'rush', label: 'Rush' },
|
||||
{
|
||||
id: 'large',
|
||||
label: 'Large',
|
||||
thinkingLevels: BASIC_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'deep',
|
||||
label: 'Deep',
|
||||
thinkingLevels: BASIC_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
}
|
||||
],
|
||||
defaultModelId: 'smart'
|
||||
},
|
||||
cursor: {
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
binary: 'cursor-agent',
|
||||
promptDelivery: 'argv',
|
||||
buildArgs: ({ prompt, model }) => [
|
||||
'--print',
|
||||
'--mode',
|
||||
'ask',
|
||||
'--trust',
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
model,
|
||||
prompt
|
||||
],
|
||||
modelSource: 'dynamic',
|
||||
modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels },
|
||||
models: [{ id: 'auto', label: 'Auto' }],
|
||||
defaultModelId: 'auto'
|
||||
},
|
||||
kimi: {
|
||||
id: 'kimi',
|
||||
label: 'Kimi',
|
||||
binary: 'kimi',
|
||||
promptDelivery: 'stdin',
|
||||
buildArgs: ({ model, thinkingLevel }) => [
|
||||
'--print',
|
||||
'--quiet',
|
||||
...(model && model !== 'default' ? ['--model', model] : []),
|
||||
...(thinkingLevel === 'on'
|
||||
? ['--thinking']
|
||||
: thinkingLevel === 'off'
|
||||
? ['--no-thinking']
|
||||
: [])
|
||||
],
|
||||
modelSource: 'static',
|
||||
models: [
|
||||
{ id: 'default', label: 'Config default' },
|
||||
{
|
||||
// Why: Kimi resolves its managed model by provider/model; bare model
|
||||
// names are rejected by the CLI with "LLM not set".
|
||||
id: 'kimi-code/kimi-for-coding',
|
||||
label: 'Kimi K2.6',
|
||||
thinkingLevels: [
|
||||
{ id: 'on', label: 'On' },
|
||||
{ id: 'off', label: 'Off' }
|
||||
],
|
||||
defaultThinkingLevel: 'on'
|
||||
}
|
||||
],
|
||||
defaultModelId: 'default'
|
||||
},
|
||||
copilot: {
|
||||
id: 'copilot',
|
||||
label: 'GitHub Copilot',
|
||||
binary: 'copilot',
|
||||
promptDelivery: 'argv',
|
||||
buildArgs: ({ prompt, model, thinkingLevel }) => [
|
||||
'--prompt',
|
||||
prompt,
|
||||
'--silent',
|
||||
'--stream',
|
||||
'off',
|
||||
'--no-custom-instructions',
|
||||
'--model',
|
||||
model,
|
||||
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
|
||||
],
|
||||
modelSource: 'static',
|
||||
// Why: Copilot CLI's picker is policy-filtered per account/org. Keep the
|
||||
// full hosted CLI catalog here so users can select models enabled for them.
|
||||
models: [
|
||||
{ id: 'auto', label: 'Auto' },
|
||||
{
|
||||
id: 'claude-haiku-4.5',
|
||||
label: 'Claude Haiku 4.5'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4.5',
|
||||
label: 'Claude Sonnet 4.5'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4.6',
|
||||
label: 'Claude Sonnet 4.6'
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.5',
|
||||
label: 'Claude Opus 4.5'
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.6',
|
||||
label: 'Claude Opus 4.6'
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.6-fast',
|
||||
label: 'Claude Opus 4.6 Fast'
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.7',
|
||||
label: 'Claude Opus 4.7'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
label: 'GPT-4.1'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-mini',
|
||||
label: 'GPT-5 Mini',
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
label: 'GPT-5.2',
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2-codex',
|
||||
label: 'GPT-5.2 Codex',
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.3-codex',
|
||||
label: 'GPT-5.3 Codex',
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
label: 'GPT-5.4',
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-mini',
|
||||
label: 'GPT-5.4 Mini',
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
label: 'GPT-5.5',
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
}
|
||||
],
|
||||
defaultModelId: 'gpt-5.4'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +606,16 @@ export function getCommitMessageModel(
|
|||
agentId: TuiAgent,
|
||||
modelId: string
|
||||
): CommitMessageModel | undefined {
|
||||
return getCommitMessageAgentSpec(agentId)?.models.find((m) => m.id === modelId)
|
||||
const spec = getCommitMessageAgentSpec(agentId)
|
||||
const model = spec?.models.find((m) => m.id === modelId)
|
||||
if (model || !spec || spec.modelSource !== 'dynamic' || modelId.trim().length === 0) {
|
||||
return model
|
||||
}
|
||||
return {
|
||||
id: modelId,
|
||||
label: labelFromModelId(modelId),
|
||||
...withOpenAiThinking(modelId)
|
||||
}
|
||||
}
|
||||
|
||||
function toCommitMessageAgentCapability(
|
||||
|
|
@ -240,6 +624,7 @@ function toCommitMessageAgentCapability(
|
|||
return {
|
||||
id: spec.id,
|
||||
label: spec.label,
|
||||
modelSource: spec.modelSource,
|
||||
defaultModelId: spec.defaultModelId,
|
||||
// Why: renderer/settings should consume provider capabilities, not the
|
||||
// spawn contract. Copy the model metadata so future dynamic probes can
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local'
|
||||
export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown'
|
||||
export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:'
|
||||
|
||||
export function getCommitMessageModelDiscoveryHostKey(
|
||||
connectionId: string | null | undefined
|
||||
): string {
|
||||
if (connectionId === undefined) {
|
||||
return UNKNOWN_COMMIT_MESSAGE_HOST_KEY
|
||||
}
|
||||
return connectionId ? `ssh:${connectionId}` : LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
}
|
||||
|
||||
export function getCommitMessageModelDiscoveryHostKeyForScope(
|
||||
scope: string | null | undefined
|
||||
): string {
|
||||
if (scope === undefined) {
|
||||
return UNKNOWN_COMMIT_MESSAGE_HOST_KEY
|
||||
}
|
||||
if (!scope) {
|
||||
return LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
}
|
||||
if (scope.startsWith(RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX)) {
|
||||
return scope
|
||||
}
|
||||
return getCommitMessageModelDiscoveryHostKey(scope)
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ describe('planCommitMessageGeneration', () => {
|
|||
const result = planCommitMessageGeneration(
|
||||
{
|
||||
agentId: 'claude',
|
||||
model: 'claude-sonnet-4-6',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'high'
|
||||
},
|
||||
'PROMPT'
|
||||
|
|
@ -16,13 +16,86 @@ describe('planCommitMessageGeneration', () => {
|
|||
ok: true,
|
||||
plan: {
|
||||
binary: 'claude',
|
||||
args: ['-p', '--output-format', 'text', '--model', 'claude-sonnet-4-6', '--effort', 'high'],
|
||||
args: [
|
||||
'-p',
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
'sonnet',
|
||||
'--permission-mode',
|
||||
'plan',
|
||||
'--effort',
|
||||
'high'
|
||||
],
|
||||
stdinPayload: 'PROMPT',
|
||||
label: 'Claude'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('plans OpenCode run with prompt in argv and model variant', () => {
|
||||
const result = planCommitMessageGeneration(
|
||||
{
|
||||
agentId: 'opencode',
|
||||
model: 'opencode/gpt-5.4-mini',
|
||||
thinkingLevel: 'high'
|
||||
},
|
||||
'PROMPT'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
binary: 'opencode',
|
||||
args: [
|
||||
'run',
|
||||
'--model',
|
||||
'opencode/gpt-5.4-mini',
|
||||
'--agent',
|
||||
'build',
|
||||
'--format',
|
||||
'default',
|
||||
'--variant',
|
||||
'high',
|
||||
'PROMPT'
|
||||
],
|
||||
stdinPayload: null,
|
||||
label: 'OpenCode'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('allows discovered dynamic models that are not in the seed catalog', () => {
|
||||
const result = planCommitMessageGeneration(
|
||||
{
|
||||
agentId: 'cursor',
|
||||
model: 'gpt-5.2',
|
||||
thinkingLevel: 'xhigh'
|
||||
},
|
||||
'PROMPT'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
binary: 'cursor-agent',
|
||||
args: [
|
||||
'--print',
|
||||
'--mode',
|
||||
'ask',
|
||||
'--trust',
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
'gpt-5.2',
|
||||
'PROMPT'
|
||||
],
|
||||
stdinPayload: null,
|
||||
label: 'Cursor'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('plans Codex exec as non-interactive read-only generation with the prompt on stdin only', () => {
|
||||
const result = planCommitMessageGeneration(
|
||||
{
|
||||
|
|
@ -87,7 +160,7 @@ describe('planCommitMessageGeneration', () => {
|
|||
const result = planCommitMessageGeneration(
|
||||
{
|
||||
agentId: 'claude',
|
||||
model: 'claude-haiku-4-5',
|
||||
model: 'haiku',
|
||||
agentCommandOverride: 'claude "unterminated'
|
||||
},
|
||||
'PROMPT'
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export type CommitMessagePlanResult =
|
|||
| { ok: true; plan: CommitMessagePlan }
|
||||
| { ok: false; error: string }
|
||||
|
||||
function planAgentBinary(
|
||||
export function planAgentBinary(
|
||||
defaultBinary: string,
|
||||
commandOverride: string | undefined
|
||||
): { ok: true; binary: string; prefixArgs: string[] } | { ok: false; error: string } {
|
||||
|
|
@ -91,13 +91,13 @@ export function planCommitMessageGeneration(
|
|||
return { ok: false, error: `Model "${input.model}" is not available for ${spec.label}.` }
|
||||
}
|
||||
if (input.thinkingLevel) {
|
||||
if (!model.thinkingLevels) {
|
||||
if (!model.thinkingLevels && spec.modelSource !== 'dynamic') {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Model "${model.label}" does not support a thinking effort level.`
|
||||
}
|
||||
}
|
||||
if (!model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) {
|
||||
if (model.thinkingLevels && !model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Thinking level "${input.thinkingLevel}" is not valid for ${model.label}.`
|
||||
|
|
|
|||
|
|
@ -73,6 +73,13 @@ describe('cleanGeneratedCommitMessage', () => {
|
|||
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('')
|
||||
})
|
||||
|
|
@ -111,6 +118,32 @@ describe('extractAgentErrorMessage', () => {
|
|||
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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -66,9 +66,17 @@ export function cleanGeneratedCommitMessage(raw: string): string {
|
|||
text = fenced[1].trim()
|
||||
}
|
||||
|
||||
// Why: some CLIs format a one-shot answer as a list item even when the
|
||||
// prompt asks for raw text; a Git subject should not carry that marker.
|
||||
text = text.replace(/^(\s*)(?:[-*•●]\s+|\d+[.)]\s+)/, '$1').trim()
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function stripAnsiControlSequences(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '')
|
||||
}
|
||||
|
||||
export const CUSTOM_PROMPT_PLACEHOLDER = '{prompt}'
|
||||
|
||||
export type TokenizeCustomCommandResult =
|
||||
|
|
@ -195,7 +203,7 @@ export function planCustomCommand(template: string, prompt: string): CustomComma
|
|||
// out the real message so the user sees something legible instead of a
|
||||
// dump of the agent's runtime state.
|
||||
export function extractAgentErrorMessage(stdout: string, stderr: string): string | null {
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
const combined = stripAnsiControlSequences(`${stdout}\n${stderr}`)
|
||||
const lines = combined.split(/\r?\n/)
|
||||
|
||||
// Pass 1: look for an `ERROR:`/`Error:` line carrying a JSON payload.
|
||||
|
|
@ -203,7 +211,7 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string
|
|||
// error wins when an agent prints multiple.
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]
|
||||
const match = /^\s*(?:ERROR|Error)\s*:\s*(.+)$/.exec(line)
|
||||
const match = /^\s*(?:ERROR|Error(?:\s+during\s+[^:]+)?)\s*:\s*(.+)$/i.exec(line)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -227,5 +235,18 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string
|
|||
}
|
||||
}
|
||||
|
||||
const compact = combined.replace(/([A-Za-z])\r?\n\s*([A-Za-z_])/g, '$1$2').replace(/\s+/g, ' ')
|
||||
const errorCodeMatch = /\bError code:\s*\d+\s*-\s*(.+)$/i.exec(compact)
|
||||
if (errorCodeMatch) {
|
||||
const payload = errorCodeMatch[1].trim()
|
||||
const messageMatch = /['"]message['"]\s*:\s*['"]([^'"]+)['"]/i.exec(payload)
|
||||
if (messageMatch?.[1]?.trim()) {
|
||||
return messageMatch[1].trim()
|
||||
}
|
||||
if (payload.length > 0) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- Why: default persisted settings live in one schema-shaped object so migrations and tests compare against one source of truth. */
|
||||
import type {
|
||||
GlobalSettings,
|
||||
NotificationSettings,
|
||||
|
|
@ -275,6 +276,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
enabled: true,
|
||||
agentId: null,
|
||||
selectedModelByAgent: {},
|
||||
discoveredModelsByAgent: {},
|
||||
selectedModelByAgentByHost: {},
|
||||
discoveredModelsByAgentByHost: {},
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
|
|
|
|||
|
|
@ -1654,12 +1654,27 @@ export type GlobalSettings = {
|
|||
voice?: VoiceSettings
|
||||
}
|
||||
|
||||
export type CommitMessageAiModelCapability = {
|
||||
id: string
|
||||
label: string
|
||||
thinkingLevels?: { id: string; label: string }[]
|
||||
defaultThinkingLevel?: string
|
||||
}
|
||||
|
||||
export type CommitMessageAiSettings = {
|
||||
enabled: boolean
|
||||
/** A TuiAgent id, the literal `'custom'` for a user-supplied command, or null. */
|
||||
agentId: TuiAgent | 'custom' | null
|
||||
/** Per-agent: switching agents preserves the previously-picked model. */
|
||||
selectedModelByAgent: Partial<Record<TuiAgent, string>>
|
||||
/** Host-scoped model selections; dynamic agents can expose different models per SSH target. */
|
||||
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
|
||||
/** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */
|
||||
discoveredModelsByAgent?: Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>
|
||||
/** Host-scoped dynamic model discovery cache. */
|
||||
discoveredModelsByAgentByHost?: Partial<
|
||||
Record<string, Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>>
|
||||
>
|
||||
/** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */
|
||||
selectedThinkingByModel: Record<string, string>
|
||||
/** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue