fix: unify agent provider/model resolution and remove custom agent overrides
This commit is contained in:
parent
551299dbf8
commit
644c318295
@ -78,8 +78,6 @@ agents:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
provider: claude # Optional: claude, codex, or opencode
|
||||
model: opus # Optional: model alias or full name
|
||||
```
|
||||
|
||||
### Agent Configuration Options
|
||||
@ -90,8 +88,6 @@ agents:
|
||||
| `prompt_file` | Path to Markdown prompt file |
|
||||
| `prompt` | Inline prompt text (alternative to `prompt_file`) |
|
||||
| `allowed_tools` | List of tools the agent can use |
|
||||
| `provider` | Provider override: `claude`, `codex`, or `opencode` |
|
||||
| `model` | Model override (alias or full name) |
|
||||
|
||||
### Available Tools
|
||||
|
||||
|
||||
@ -223,9 +223,8 @@ codex_cli_path: /usr/local/bin/codex
|
||||
各 movement で使用されるモデルは、次の優先順位(高い順)で解決されます。
|
||||
|
||||
1. **Piece movement の `model`** - piece YAML の movement 定義で指定
|
||||
2. **カスタムエージェントの `model`** - `.takt/agents.yaml` のエージェントレベルのモデル
|
||||
3. **グローバル設定の `model`** - `~/.takt/config.yaml` のデフォルトモデル
|
||||
4. **Provider デフォルト** - provider のビルトインデフォルトにフォールバック(Claude: `sonnet`、Codex: `codex`、OpenCode: provider デフォルト)
|
||||
2. **グローバル設定の `model`** - `~/.takt/config.yaml` のデフォルトモデル
|
||||
3. **Provider デフォルト** - provider のビルトインデフォルトにフォールバック(Claude: `sonnet`、Codex: `codex`、OpenCode: provider デフォルト)
|
||||
|
||||
### Provider 固有のモデルに関する注意
|
||||
|
||||
|
||||
@ -223,9 +223,8 @@ The path must be an absolute path to an executable file. `TAKT_CODEX_CLI_PATH` t
|
||||
The model used for each movement is resolved with the following priority order (highest first):
|
||||
|
||||
1. **Piece movement `model`** - Specified in the movement definition in piece YAML
|
||||
2. **Custom agent `model`** - Agent-level model in `.takt/agents.yaml`
|
||||
3. **Global config `model`** - Default model in `~/.takt/config.yaml`
|
||||
4. **Provider default** - Falls back to the provider's built-in default (Claude: `sonnet`, Codex: `codex`, OpenCode: provider default)
|
||||
2. **Global config `model`** - Default model in `~/.takt/config.yaml`
|
||||
3. **Provider default** - Falls back to the provider's built-in default (Claude: `sonnet`, Codex: `codex`, OpenCode: provider default)
|
||||
|
||||
### Provider-specific Model Notes
|
||||
|
||||
|
||||
@ -470,17 +470,6 @@ describe('CustomAgentConfigSchema', () => {
|
||||
expect(result.claude_agent).toBe('architect');
|
||||
});
|
||||
|
||||
it('should accept agent with provider override', () => {
|
||||
const config = {
|
||||
name: 'my-agent',
|
||||
prompt: 'You are a helpful assistant.',
|
||||
provider: 'codex',
|
||||
};
|
||||
|
||||
const result = CustomAgentConfigSchema.parse(config);
|
||||
expect(result.provider).toBe('codex');
|
||||
});
|
||||
|
||||
it('should reject agent without any prompt source', () => {
|
||||
const config = {
|
||||
name: 'my-agent',
|
||||
|
||||
@ -6,7 +6,6 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
GlobalConfigSchema,
|
||||
ProjectConfigSchema,
|
||||
CustomAgentConfigSchema,
|
||||
PieceMovementRawSchema,
|
||||
ParallelSubMovementRawSchema,
|
||||
} from '../core/models/index.js';
|
||||
@ -64,15 +63,6 @@ describe('Schemas accept opencode provider', () => {
|
||||
expect(() => ProjectConfigSchema.parse({ submodules: 'libs' })).toThrow();
|
||||
});
|
||||
|
||||
it('should accept opencode in CustomAgentConfigSchema', () => {
|
||||
const result = CustomAgentConfigSchema.parse({
|
||||
name: 'test',
|
||||
prompt: 'You are a test agent',
|
||||
provider: 'opencode',
|
||||
});
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should accept opencode in PieceMovementRawSchema', () => {
|
||||
const result = PieceMovementRawSchema.parse({
|
||||
name: 'test-movement',
|
||||
|
||||
@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
getProviderMock,
|
||||
loadConfigMock,
|
||||
loadCustomAgentsMock,
|
||||
loadAgentPromptMock,
|
||||
loadProjectConfigMock,
|
||||
loadGlobalConfigMock,
|
||||
loadTemplateMock,
|
||||
providerSetupMock,
|
||||
providerCallMock,
|
||||
@ -14,9 +15,10 @@ const {
|
||||
|
||||
return {
|
||||
getProviderMock: vi.fn(() => ({ setup: providerSetup })),
|
||||
loadConfigMock: vi.fn(),
|
||||
loadCustomAgentsMock: vi.fn(),
|
||||
loadAgentPromptMock: vi.fn(),
|
||||
loadProjectConfigMock: vi.fn(),
|
||||
loadGlobalConfigMock: vi.fn(),
|
||||
loadTemplateMock: vi.fn(),
|
||||
providerSetupMock: providerSetup,
|
||||
providerCallMock: providerCall,
|
||||
@ -28,21 +30,10 @@ vi.mock('../infra/providers/index.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
loadProjectConfig: loadProjectConfigMock,
|
||||
loadGlobalConfig: loadGlobalConfigMock,
|
||||
loadCustomAgents: loadCustomAgentsMock,
|
||||
loadAgentPrompt: loadAgentPromptMock,
|
||||
resolveConfigValues: (_projectDir: string, keys: readonly string[]) => {
|
||||
const loaded = loadConfigMock() as Record<string, unknown>;
|
||||
const global = (loaded.global ?? {}) as Record<string, unknown>;
|
||||
const project = (loaded.project ?? {}) as Record<string, unknown>;
|
||||
const provider = (project.provider ?? global.provider ?? 'claude') as string;
|
||||
const config: Record<string, unknown> = { ...global, ...project, provider, piece: project.piece ?? 'default', verbose: false };
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
result[key] = config[key];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompts/index.js', () => ({
|
||||
@ -56,120 +47,169 @@ describe('option resolution order', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
providerCallMock.mockResolvedValue({ content: 'ok' });
|
||||
loadConfigMock.mockReturnValue({ global: {}, project: {} });
|
||||
loadProjectConfigMock.mockReturnValue({});
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
loadCustomAgentsMock.mockReturnValue(new Map());
|
||||
loadAgentPromptMock.mockReturnValue('prompt');
|
||||
loadTemplateMock.mockReturnValue('template');
|
||||
});
|
||||
|
||||
it('should resolve provider in order: CLI > stepProvider > Config(project??global) > default', async () => {
|
||||
// Given
|
||||
loadConfigMock.mockReturnValue({
|
||||
project: { provider: 'opencode' },
|
||||
global: { provider: 'mock' },
|
||||
it('should resolve provider in order: CLI > stepProvider > local config > global config', async () => {
|
||||
loadProjectConfigMock.mockReturnValue({ provider: 'opencode' });
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
provider: 'mock',
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
// When: CLI provider が指定される
|
||||
await runAgent(undefined, 'task', {
|
||||
cwd: '/repo',
|
||||
provider: 'codex',
|
||||
stepProvider: 'claude',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(getProviderMock).toHaveBeenLastCalledWith('codex');
|
||||
|
||||
// When: CLI 指定なし(stepProvider が優先される)
|
||||
await runAgent(undefined, 'task', {
|
||||
cwd: '/repo',
|
||||
stepProvider: 'claude',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(getProviderMock).toHaveBeenLastCalledWith('claude');
|
||||
|
||||
// When: project なし → resolveConfigValues は global.provider を返す(フラットマージ)
|
||||
loadConfigMock.mockReturnValue({
|
||||
project: {},
|
||||
global: { provider: 'mock' },
|
||||
loadProjectConfigMock.mockReturnValue({});
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
provider: 'mock',
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
await runAgent(undefined, 'task', {
|
||||
cwd: '/repo',
|
||||
stepProvider: 'claude',
|
||||
});
|
||||
|
||||
// Then: stepProvider が global fallback より優先される
|
||||
expect(getProviderMock).toHaveBeenLastCalledWith('claude');
|
||||
|
||||
// When: stepProvider もなし → 同様に global.provider
|
||||
await runAgent(undefined, 'task', { cwd: '/repo' });
|
||||
|
||||
// Then
|
||||
expect(getProviderMock).toHaveBeenLastCalledWith('mock');
|
||||
});
|
||||
|
||||
it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => {
|
||||
// Given
|
||||
loadConfigMock.mockReturnValue({
|
||||
project: { provider: 'claude' },
|
||||
global: { provider: 'claude', model: 'global-model' },
|
||||
it('should apply persona provider override before local/global config', async () => {
|
||||
loadProjectConfigMock.mockReturnValue({ provider: 'opencode' });
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
provider: 'mock',
|
||||
personaProviders: {
|
||||
coder: { provider: 'claude' },
|
||||
},
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
// When: CLI model あり
|
||||
await runAgent(undefined, 'task', {
|
||||
await runAgent('coder', 'task', {
|
||||
cwd: '/repo',
|
||||
});
|
||||
|
||||
expect(getProviderMock).toHaveBeenLastCalledWith('claude');
|
||||
});
|
||||
|
||||
it('should resolve model in order: CLI > persona > step > local > global', async () => {
|
||||
loadProjectConfigMock.mockReturnValue({
|
||||
provider: 'claude',
|
||||
model: 'local-model',
|
||||
});
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
provider: 'claude',
|
||||
model: 'global-model',
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
personaProviders: {
|
||||
coder: { model: 'persona-model' },
|
||||
},
|
||||
});
|
||||
|
||||
await runAgent('coder', 'task', {
|
||||
cwd: '/repo',
|
||||
model: 'cli-model',
|
||||
stepModel: 'step-model',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(providerCallMock).toHaveBeenLastCalledWith(
|
||||
'task',
|
||||
expect.objectContaining({ model: 'cli-model' }),
|
||||
);
|
||||
|
||||
// When: CLI model なし
|
||||
await runAgent(undefined, 'task', {
|
||||
cwd: '/repo',
|
||||
stepModel: 'step-model',
|
||||
stepProvider: 'claude',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(providerCallMock).toHaveBeenLastCalledWith(
|
||||
'task',
|
||||
expect.objectContaining({ model: 'step-model' }),
|
||||
);
|
||||
|
||||
// When: stepModel なし
|
||||
await runAgent(undefined, 'task', { cwd: '/repo' });
|
||||
await runAgent('coder', 'task', {
|
||||
cwd: '/repo',
|
||||
stepProvider: 'claude',
|
||||
});
|
||||
expect(providerCallMock).toHaveBeenLastCalledWith(
|
||||
'task',
|
||||
expect.objectContaining({ model: 'persona-model' }),
|
||||
);
|
||||
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
provider: 'codex',
|
||||
model: 'global-model',
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
loadProjectConfigMock.mockReturnValue({
|
||||
provider: 'codex',
|
||||
});
|
||||
|
||||
await runAgent(undefined, 'task', {
|
||||
cwd: '/repo',
|
||||
stepProvider: 'codex',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(providerCallMock).toHaveBeenLastCalledWith(
|
||||
'task',
|
||||
expect.objectContaining({ model: 'global-model' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore global model when resolved provider does not match config provider', async () => {
|
||||
// Given: CLI provider overrides config provider, causing mismatch with config.model
|
||||
loadConfigMock.mockReturnValue({
|
||||
project: {},
|
||||
global: { provider: 'claude', model: 'global-model' },
|
||||
it('should ignore local/global model if resolved provider is not matching', async () => {
|
||||
loadProjectConfigMock.mockReturnValue({
|
||||
provider: 'claude',
|
||||
model: 'local-model',
|
||||
});
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
provider: 'mock',
|
||||
model: 'global-model',
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
// When: CLI provider='codex' overrides config provider='claude'
|
||||
// resolveModel compares config.provider ('claude') with resolvedProvider ('codex') → mismatch → model ignored
|
||||
await runAgent(undefined, 'task', { cwd: '/repo', provider: 'codex' });
|
||||
await runAgent(undefined, 'task', {
|
||||
cwd: '/repo',
|
||||
stepProvider: 'opencode',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(providerCallMock).toHaveBeenLastCalledWith(
|
||||
'task',
|
||||
expect.objectContaining({ model: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use providerOptions from piece(step) only', async () => {
|
||||
// Given
|
||||
it('should use providerOptions from piece/step only', async () => {
|
||||
const stepProviderOptions = {
|
||||
claude: {
|
||||
sandbox: {
|
||||
@ -178,55 +218,37 @@ describe('option resolution order', () => {
|
||||
},
|
||||
};
|
||||
|
||||
loadConfigMock.mockReturnValue({
|
||||
project: {
|
||||
provider: 'claude',
|
||||
},
|
||||
global: {
|
||||
provider: 'claude',
|
||||
providerOptions: {
|
||||
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await runAgent(undefined, 'task', {
|
||||
cwd: '/repo',
|
||||
provider: 'claude',
|
||||
providerOptions: stepProviderOptions,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(providerCallMock).toHaveBeenLastCalledWith(
|
||||
'task',
|
||||
expect.objectContaining({ providerOptions: stepProviderOptions }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom agent model and prompt when higher-priority values are absent', async () => {
|
||||
// Given: custom agent with provider/model, but no CLI/config override
|
||||
// Note: resolveConfigValues returns provider='claude' by default (loadConfig merges project ?? global ?? 'claude'),
|
||||
// so agentConfig.provider is not reached in resolveProvider (config.provider is always truthy).
|
||||
// However, custom agent model IS used because resolveModel checks agentConfig.model before config.
|
||||
const customAgents = new Map([
|
||||
['custom', { name: 'custom', prompt: 'agent prompt', provider: 'opencode', model: 'agent-model' }],
|
||||
]);
|
||||
loadCustomAgentsMock.mockReturnValue(customAgents);
|
||||
it('should ignore custom agent provider/model overrides', async () => {
|
||||
loadProjectConfigMock.mockReturnValue({ provider: 'claude', model: 'project-model' });
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
provider: 'mock',
|
||||
language: 'en',
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
loadCustomAgentsMock.mockReturnValue(new Map([
|
||||
['custom', { name: 'custom', prompt: 'agent prompt' }],
|
||||
]));
|
||||
|
||||
// When
|
||||
await runAgent('custom', 'task', { cwd: '/repo' });
|
||||
|
||||
// Then: provider falls back to config default ('claude'), not agentConfig.provider
|
||||
expect(getProviderMock).toHaveBeenLastCalledWith('claude');
|
||||
// Agent model is used (resolved before config.model in resolveModel)
|
||||
expect(providerCallMock).toHaveBeenLastCalledWith(
|
||||
'task',
|
||||
expect.objectContaining({ model: 'agent-model' }),
|
||||
);
|
||||
// Agent prompt is still used
|
||||
expect(providerSetupMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ systemPrompt: 'prompt' }),
|
||||
expect.objectContaining({ model: 'project-model' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveMovementProviderModel, resolveProviderModelCandidates } from '../core/piece/provider-resolution.js';
|
||||
import {
|
||||
resolveAgentProviderModel,
|
||||
resolveMovementProviderModel,
|
||||
resolveProviderModelCandidates,
|
||||
} from '../core/piece/provider-resolution.js';
|
||||
|
||||
describe('resolveProviderModelCandidates', () => {
|
||||
it('should resolve first defined provider and model independently', () => {
|
||||
@ -26,118 +30,313 @@ describe('resolveProviderModelCandidates', () => {
|
||||
|
||||
describe('resolveMovementProviderModel', () => {
|
||||
it('should prefer personaProviders.provider over step.provider when both are defined', () => {
|
||||
// Given: step.provider と personaProviders.provider が両方指定されている
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' },
|
||||
provider: 'claude',
|
||||
personaProviders: { coder: { provider: 'opencode' } },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: personaProviders.provider が step.provider を上書きする
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should use personaProviders.provider when step.provider is undefined', () => {
|
||||
// Given: step.provider が未定義で personaProviders に対応がある
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' },
|
||||
provider: 'claude',
|
||||
personaProviders: { reviewer: { provider: 'opencode' } },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: personaProviders の provider が使われる
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should fallback to input.provider when persona mapping is missing', () => {
|
||||
// Given: step.provider 未定義かつ persona マッピングが存在しない
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' },
|
||||
provider: 'mock',
|
||||
personaProviders: { reviewer: { provider: 'codex' } },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: input.provider が使われる
|
||||
expect(result.provider).toBe('mock');
|
||||
});
|
||||
|
||||
it('should return undefined provider when all provider candidates are missing', () => {
|
||||
// Given: provider の候補がすべて未定義
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'none' },
|
||||
provider: undefined,
|
||||
personaProviders: undefined,
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: provider は undefined になる
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer personaProviders.model over step.model and input.model', () => {
|
||||
// Given: step.model と personaProviders.model と input.model が指定されている
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' },
|
||||
model: 'input-model',
|
||||
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: personaProviders.model が step.model を上書きする
|
||||
expect(result.model).toBe('persona-model');
|
||||
});
|
||||
|
||||
it('should use personaProviders.model when step.model is undefined', () => {
|
||||
// Given: step.model が未定義で personaProviders.model が指定されている
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||
model: 'input-model',
|
||||
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: personaProviders.model が使われる
|
||||
expect(result.model).toBe('persona-model');
|
||||
});
|
||||
|
||||
it('should fallback to input.model when step.model and personaProviders.model are undefined', () => {
|
||||
// Given: step.model と personaProviders.model が未定義で input.model が指定されている
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||
model: 'input-model',
|
||||
personaProviders: { coder: { provider: 'codex' } },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: input.model が使われる
|
||||
expect(result.model).toBe('input-model');
|
||||
});
|
||||
|
||||
it('should return undefined model when all model candidates are missing', () => {
|
||||
// Given: model の候補がすべて未定義
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||
model: undefined,
|
||||
personaProviders: { coder: { provider: 'codex' } },
|
||||
});
|
||||
|
||||
// Then: model は undefined になる
|
||||
expect(result.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve provider from personaProviders entry with only model specified', () => {
|
||||
// Given: personaProviders エントリに provider が指定されていない(model のみ)
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||
provider: 'claude',
|
||||
personaProviders: { coder: { model: 'o3-mini' } },
|
||||
});
|
||||
|
||||
// Then: provider は input.provider、model は personaProviders.model になる
|
||||
expect(result.provider).toBe('claude');
|
||||
expect(result.model).toBe('o3-mini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentProviderModel', () => {
|
||||
it('should resolve provider in order: CLI > persona > movement > local > global', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
cliProvider: 'opencode',
|
||||
stepProvider: 'claude',
|
||||
localProvider: 'codex',
|
||||
globalProvider: 'claude',
|
||||
personaProviders: { coder: { provider: 'mock' } },
|
||||
personaDisplayName: 'coder',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should use persona override when no CLI provider is set', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
stepProvider: 'claude',
|
||||
localProvider: 'codex',
|
||||
globalProvider: 'claude',
|
||||
personaProviders: { coder: { provider: 'opencode', model: 'persona-model' } },
|
||||
personaDisplayName: 'coder',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('opencode');
|
||||
expect(result.model).toBe('persona-model');
|
||||
});
|
||||
|
||||
it('should fall back to movement provider when persona override is not configured', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
stepProvider: 'claude',
|
||||
localProvider: 'codex',
|
||||
globalProvider: 'claude',
|
||||
personaProviders: { reviewer: { provider: 'mock', model: 'o3-mini' } },
|
||||
personaDisplayName: 'coder',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('claude');
|
||||
});
|
||||
|
||||
it('should prefer local config provider/model over global config for same provider', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
localProvider: 'codex',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'codex',
|
||||
globalModel: 'global-model',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('codex');
|
||||
expect(result.model).toBe('local-model');
|
||||
});
|
||||
|
||||
it('should prefer global config when local config is not set', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
localProvider: undefined,
|
||||
globalProvider: 'claude',
|
||||
globalModel: 'global-model',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('claude');
|
||||
expect(result.model).toBe('global-model');
|
||||
});
|
||||
|
||||
it('should resolve model order: CLI > persona > movement > config candidate matching provider', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
cliModel: 'cli-model',
|
||||
stepModel: 'movement-model',
|
||||
localProvider: 'claude',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'codex',
|
||||
globalModel: 'global-model',
|
||||
cliProvider: 'codex',
|
||||
personaProviders: { coder: { model: 'persona-model' } },
|
||||
personaDisplayName: 'coder',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('codex');
|
||||
expect(result.model).toBe('cli-model');
|
||||
});
|
||||
|
||||
it('should use movement model when persona model is absent', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
stepModel: 'movement-model',
|
||||
localProvider: 'claude',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'codex',
|
||||
globalModel: 'global-model',
|
||||
personaProviders: { coder: { provider: 'opencode' } },
|
||||
personaDisplayName: 'coder',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('opencode');
|
||||
expect(result.model).toBe('movement-model');
|
||||
});
|
||||
|
||||
it('should apply local/ global model only when provider matches resolved provider', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
localProvider: 'claude',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'codex',
|
||||
globalModel: 'global-model',
|
||||
stepProvider: 'codex',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('codex');
|
||||
expect(result.model).toBe('global-model');
|
||||
});
|
||||
|
||||
it('should ignore local and global model when provider does not match', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
localProvider: 'codex',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'claude',
|
||||
globalModel: 'global-model',
|
||||
stepProvider: 'opencode',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('opencode');
|
||||
expect(result.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should combine persona and movement overrides in one run', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
cliProvider: 'codex',
|
||||
stepProvider: 'claude',
|
||||
stepModel: 'movement-model',
|
||||
localProvider: 'claude',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'mock',
|
||||
globalModel: 'global-model',
|
||||
cliModel: 'cli-model',
|
||||
personaProviders: {
|
||||
coder: {
|
||||
provider: 'mock',
|
||||
model: 'persona-model',
|
||||
},
|
||||
},
|
||||
personaDisplayName: 'coder',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('codex');
|
||||
expect(result.model).toBe('cli-model');
|
||||
});
|
||||
|
||||
it('should apply full priority chain when all layers are present', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
cliProvider: 'codex',
|
||||
cliModel: 'cli-model',
|
||||
personaProviders: {
|
||||
reviewer: {
|
||||
provider: 'mock',
|
||||
model: 'persona-model',
|
||||
},
|
||||
},
|
||||
personaDisplayName: 'reviewer',
|
||||
stepProvider: 'claude',
|
||||
stepModel: 'step-model',
|
||||
localProvider: 'opencode',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'claude',
|
||||
globalModel: 'global-model',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('codex');
|
||||
expect(result.model).toBe('cli-model');
|
||||
});
|
||||
|
||||
it('should apply full priority chain without cli overrides', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
personaProviders: {
|
||||
reviewer: {
|
||||
provider: 'mock',
|
||||
model: 'persona-model',
|
||||
},
|
||||
},
|
||||
personaDisplayName: 'reviewer',
|
||||
stepProvider: 'claude',
|
||||
stepModel: 'step-model',
|
||||
localProvider: 'opencode',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'claude',
|
||||
globalModel: 'global-model',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('mock');
|
||||
expect(result.model).toBe('persona-model');
|
||||
});
|
||||
|
||||
it('should keep model and provider priorities consistent for fallback path', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
stepProvider: 'claude',
|
||||
localProvider: 'codex',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'claude',
|
||||
globalModel: 'global-model',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('claude');
|
||||
expect(result.model).toBe('global-model');
|
||||
});
|
||||
|
||||
it('should keep model fallback after persona-only model when step model is absent', () => {
|
||||
const result = resolveAgentProviderModel({
|
||||
personaProviders: {
|
||||
reviewer: {
|
||||
model: 'persona-model',
|
||||
},
|
||||
},
|
||||
personaDisplayName: 'reviewer',
|
||||
stepProvider: 'claude',
|
||||
localProvider: 'codex',
|
||||
localModel: 'local-model',
|
||||
globalProvider: 'codex',
|
||||
globalModel: 'global-model',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('claude');
|
||||
expect(result.model).toBe('persona-model');
|
||||
});
|
||||
});
|
||||
|
||||
@ -67,7 +67,7 @@ vi.mock('../shared/i18n/index.js', () => ({
|
||||
getLabel: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js';
|
||||
import { executeAndCompleteTask, executeTask } from '../features/tasks/execute/taskExecution.js';
|
||||
|
||||
const createTask = (name: string): TaskInfo => ({
|
||||
name,
|
||||
@ -151,6 +151,54 @@ describe('executeAndCompleteTask', () => {
|
||||
expect(pieceExecutionOptions?.providerOptionsSource).toBe('project');
|
||||
});
|
||||
|
||||
it('should not pass config provider/model to executePiece when agent overrides are absent', async () => {
|
||||
// Given: project config contains provider/model, but overrides are omitted.
|
||||
const task = createTask('task-with-defaults');
|
||||
|
||||
// When
|
||||
await executeTask({
|
||||
task: task.content,
|
||||
cwd: '/project',
|
||||
projectCwd: '/project',
|
||||
pieceIdentifier: 'default',
|
||||
});
|
||||
|
||||
// Then: piece options should not force provider/model from taskExecution layer
|
||||
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
|
||||
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
expect(pieceExecutionOptions?.provider).toBeUndefined();
|
||||
expect(pieceExecutionOptions?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass agent overrides to executePiece when provided', async () => {
|
||||
// Given: overrides explicitly specified by caller.
|
||||
const task = createTask('task-with-overrides');
|
||||
|
||||
// When
|
||||
await executeTask({
|
||||
task: task.content,
|
||||
cwd: '/project',
|
||||
projectCwd: '/project',
|
||||
pieceIdentifier: 'default',
|
||||
agentOverrides: {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.3-codex',
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
|
||||
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
expect(pieceExecutionOptions?.provider).toBe('codex');
|
||||
expect(pieceExecutionOptions?.model).toBe('gpt-5.3-codex');
|
||||
});
|
||||
|
||||
it('should mark task as failed when PR creation fails', async () => {
|
||||
// Given: worktree mode with autoPr enabled, PR creation fails
|
||||
const task = createTask('task-with-pr-failure');
|
||||
|
||||
@ -4,10 +4,10 @@
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { basename, dirname } from 'node:path';
|
||||
import { loadCustomAgents, loadAgentPrompt, resolveConfigValues } from '../infra/config/index.js';
|
||||
import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js';
|
||||
import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js';
|
||||
import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js';
|
||||
import { resolveProviderModelCandidates } from '../core/piece/provider-resolution.js';
|
||||
import { resolveAgentProviderModel } from '../core/piece/provider-resolution.js';
|
||||
import { createLogger } from '../shared/utils/index.js';
|
||||
import { loadTemplate } from '../shared/prompts/index.js';
|
||||
import type { RunAgentOptions } from './types.js';
|
||||
@ -25,33 +25,31 @@ const log = createLogger('runner');
|
||||
export class AgentRunner {
|
||||
private static resolveProviderAndModel(
|
||||
cwd: string,
|
||||
personaDisplayName: string | undefined,
|
||||
options?: RunAgentOptions,
|
||||
agentConfig?: CustomAgentConfig,
|
||||
): { provider: ProviderType; model: string | undefined } {
|
||||
const config = resolveConfigValues(cwd, ['provider', 'model']);
|
||||
const resolvedProvider = resolveProviderModelCandidates([
|
||||
{ provider: options?.provider },
|
||||
{ provider: options?.stepProvider },
|
||||
{ provider: config.provider },
|
||||
{ provider: agentConfig?.provider },
|
||||
]).provider;
|
||||
const localConfig = loadProjectConfig(cwd);
|
||||
const globalConfig = loadGlobalConfig();
|
||||
|
||||
const resolvedProviderModel = resolveAgentProviderModel({
|
||||
personaDisplayName,
|
||||
cliProvider: options?.provider,
|
||||
cliModel: options?.model,
|
||||
stepProvider: options?.stepProvider,
|
||||
stepModel: options?.stepModel,
|
||||
personaProviders: globalConfig.personaProviders,
|
||||
localProvider: localConfig.provider,
|
||||
localModel: localConfig.model,
|
||||
globalProvider: globalConfig.provider,
|
||||
globalModel: globalConfig.model,
|
||||
});
|
||||
const resolvedProvider = resolvedProviderModel.provider;
|
||||
if (!resolvedProvider) {
|
||||
throw new Error('No provider configured. Set "provider" in ~/.takt/config.yaml');
|
||||
}
|
||||
|
||||
const configModel = config.provider === resolvedProvider
|
||||
? config.model
|
||||
: undefined;
|
||||
const resolvedModel = resolveProviderModelCandidates([
|
||||
{ model: options?.model },
|
||||
{ model: options?.stepModel },
|
||||
{ model: agentConfig?.model },
|
||||
{ model: configModel },
|
||||
]).model;
|
||||
|
||||
return {
|
||||
provider: resolvedProvider,
|
||||
model: resolvedModel,
|
||||
model: resolvedProviderModel.model,
|
||||
};
|
||||
}
|
||||
|
||||
@ -112,7 +110,7 @@ export class AgentRunner {
|
||||
task: string,
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const resolved = AgentRunner.resolveProviderAndModel(options.cwd, options, agentConfig);
|
||||
const resolved = AgentRunner.resolveProviderAndModel(options.cwd, agentConfig.name, options);
|
||||
const providerType = resolved.provider;
|
||||
const provider = getProvider(providerType);
|
||||
|
||||
@ -145,7 +143,7 @@ export class AgentRunner {
|
||||
permissionMode: options.permissionMode,
|
||||
});
|
||||
|
||||
const resolved = AgentRunner.resolveProviderAndModel(options.cwd, options);
|
||||
const resolved = AgentRunner.resolveProviderAndModel(options.cwd, personaName, options);
|
||||
const providerType = resolved.provider;
|
||||
const provider = getProvider(providerType);
|
||||
const callOptions = AgentRunner.buildCallOptions(resolved.model, options);
|
||||
|
||||
@ -18,8 +18,6 @@ export interface CustomAgentConfig {
|
||||
allowedTools?: string[];
|
||||
claudeAgent?: string;
|
||||
claudeSkill?: string;
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Observability configuration for runtime event logs */
|
||||
|
||||
@ -380,8 +380,6 @@ export const CustomAgentConfigSchema = z.object({
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
claude_agent: z.string().optional(),
|
||||
claude_skill: z.string().optional(),
|
||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||
model: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
|
||||
{ message: 'Agent must have prompt_file, prompt, claude_agent, or claude_skill' }
|
||||
|
||||
@ -130,7 +130,31 @@ export class LineTimeSliceBuffer {
|
||||
}
|
||||
|
||||
private isBoundary(ch: string): boolean {
|
||||
return /\s|[,.!?;:、。!?;:()\[\]{}]/u.test(ch);
|
||||
const boundaryChars = new Set([
|
||||
' ',
|
||||
'\n',
|
||||
'\t',
|
||||
',',
|
||||
'.',
|
||||
'!',
|
||||
'?',
|
||||
';',
|
||||
':',
|
||||
'、',
|
||||
'。',
|
||||
'!',
|
||||
'?',
|
||||
';',
|
||||
':',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
]);
|
||||
|
||||
return boundaryChars.has(ch);
|
||||
}
|
||||
|
||||
private clearTimer(key: string): void {
|
||||
|
||||
@ -19,6 +19,11 @@ export interface ProviderModelCandidate {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface ModelProviderCandidate {
|
||||
model?: string;
|
||||
provider?: ProviderType;
|
||||
}
|
||||
|
||||
export function resolveProviderModelCandidates(
|
||||
candidates: readonly ProviderModelCandidate[],
|
||||
): MovementProviderModelOutput {
|
||||
@ -40,6 +45,61 @@ export function resolveProviderModelCandidates(
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
export interface AgentProviderModelInput {
|
||||
cliProvider?: ProviderType;
|
||||
cliModel?: string;
|
||||
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||
personaDisplayName?: string;
|
||||
stepProvider?: ProviderType;
|
||||
stepModel?: string;
|
||||
localProvider?: ProviderType;
|
||||
localModel?: string;
|
||||
globalProvider?: ProviderType;
|
||||
globalModel?: string;
|
||||
}
|
||||
|
||||
export interface AgentProviderModelOutput {
|
||||
provider?: ProviderType;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
function resolveModelFromCandidates(
|
||||
candidates: readonly ModelProviderCandidate[],
|
||||
resolvedProvider: ProviderType | undefined,
|
||||
): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const { model, provider } = candidate;
|
||||
if (model === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (provider !== undefined && provider !== resolvedProvider) {
|
||||
continue;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveAgentProviderModel(input: AgentProviderModelInput): AgentProviderModelOutput {
|
||||
const personaEntry = input.personaProviders?.[input.personaDisplayName ?? ''];
|
||||
const provider = resolveProviderModelCandidates([
|
||||
{ provider: input.cliProvider },
|
||||
{ provider: personaEntry?.provider },
|
||||
{ provider: input.stepProvider },
|
||||
{ provider: input.localProvider },
|
||||
{ provider: input.globalProvider },
|
||||
]).provider;
|
||||
const model = resolveModelFromCandidates([
|
||||
{ model: input.cliModel },
|
||||
{ model: personaEntry?.model },
|
||||
{ model: input.stepModel },
|
||||
{ model: input.localModel, provider: input.localProvider },
|
||||
{ model: input.globalModel, provider: input.globalProvider },
|
||||
], provider);
|
||||
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput {
|
||||
const personaEntry = input.personaProviders?.[input.step.personaDisplayName];
|
||||
const provider = resolveProviderModelCandidates([
|
||||
|
||||
@ -62,19 +62,13 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
||||
movements: pieceConfig.movements.map((s: { name: string }) => s.name),
|
||||
});
|
||||
|
||||
const config = resolvePieceConfigValues(projectCwd, [
|
||||
'language',
|
||||
'provider',
|
||||
'model',
|
||||
'personaProviders',
|
||||
'providerProfiles',
|
||||
]);
|
||||
const config = resolvePieceConfigValues(projectCwd, ['language', 'personaProviders', 'providerProfiles']);
|
||||
const providerOptions = resolveConfigValueWithSource(projectCwd, 'providerOptions');
|
||||
return await executePiece(pieceConfig, task, cwd, {
|
||||
projectCwd,
|
||||
language: config.language,
|
||||
provider: agentOverrides?.provider ?? config.provider,
|
||||
model: agentOverrides?.model ?? config.model,
|
||||
provider: agentOverrides?.provider,
|
||||
model: agentOverrides?.model,
|
||||
providerOptions: providerOptions.value,
|
||||
providerOptionsSource: providerOptions.source === 'piece' ? 'global' : providerOptions.source,
|
||||
personaProviders: config.personaProviders,
|
||||
|
||||
@ -12,6 +12,8 @@ export interface ProjectLocalConfig {
|
||||
piece?: string;
|
||||
/** Provider selection for agent runtime */
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
/** Model selection for agent runtime */
|
||||
model?: string;
|
||||
/** Auto-create PR after worktree execution */
|
||||
autoPr?: boolean;
|
||||
/** Create PR as draft */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user