From dec77e069e7915857bb7b4887e67e46275bcb5e5 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:12:46 +0900 Subject: [PATCH] add-model-to-persona-providers (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * takt: add-model-to-persona-providers * refactor: loadConfigを廃止しresolveConfigValueにキー単位解決を一元化 loadConfig()による一括マージを廃止し、resolveConfigValue()でキーごとに global/project/piece/envの優先順位を宣言的に解決する方式に移行。 providerOptionsの優先順位をglobal < piece < project < envに修正し、 sourceトラッキングでOptionsBuilderのマージ方向を制御する。 --- builtins/en/config.yaml | 13 +- builtins/ja/config.yaml | 13 +- docs/configuration.ja.md | 26 ++- docs/configuration.md | 26 ++- src/__tests__/addTask.test.ts | 5 + src/__tests__/catalog.test.ts | 10 - src/__tests__/config.test.ts | 8 +- .../engine-persona-providers.test.ts | 80 ++++++- src/__tests__/engine-provider-options.test.ts | 5 +- src/__tests__/global-pieceCategories.test.ts | 47 ++-- src/__tests__/globalConfig-defaults.test.ts | 90 +++++++- src/__tests__/opencode-config.test.ts | 4 +- src/__tests__/options-builder.test.ts | 5 +- .../pieceExecution-session-loading.test.ts | 2 +- src/__tests__/pipelineExecution.test.ts | 70 +++--- src/__tests__/provider-resolution.test.ts | 58 ++++- src/__tests__/runAllTasks-concurrency.test.ts | 7 + src/__tests__/summarize.test.ts | 132 +++++------ src/__tests__/taskExecution.test.ts | 15 +- src/core/models/index.ts | 1 - ...l-config.ts => persisted-global-config.ts} | 13 +- src/core/models/schemas.ts | 12 +- src/core/models/types.ts | 4 +- src/core/piece/engine/OptionsBuilder.ts | 27 ++- src/core/piece/provider-resolution.ts | 11 +- src/core/piece/types.ts | 8 +- src/features/tasks/execute/pieceExecution.ts | 6 +- src/features/tasks/execute/taskExecution.ts | 7 +- src/features/tasks/execute/types.ts | 8 +- src/index.ts | 3 - src/infra/config/global/globalConfig.ts | 36 ++- src/infra/config/loadConfig.ts | 131 ----------- src/infra/config/project/projectConfig.ts | 4 +- src/infra/config/project/resolvedSettings.ts | 31 +-- src/infra/config/resolutionCache.ts | 50 ++++ src/infra/config/resolveConfigValue.ts | 218 +++++++++++++++++- src/infra/config/resolvePieceConfigValue.ts | 9 +- src/infra/config/resolvedConfig.ts | 9 + src/infra/config/types.ts | 2 +- 39 files changed, 761 insertions(+), 445 deletions(-) rename src/core/models/{global-config.ts => persisted-global-config.ts} (93%) delete mode 100644 src/infra/config/loadConfig.ts create mode 100644 src/infra/config/resolutionCache.ts create mode 100644 src/infra/config/resolvedConfig.ts diff --git a/builtins/en/config.yaml b/builtins/en/config.yaml index 729e4bf..5beca2d 100644 --- a/builtins/en/config.yaml +++ b/builtins/en/config.yaml @@ -55,12 +55,17 @@ concurrency: 2 # Concurrent task execution for takt run (1-10) # ===================================== # Piece-related settings (global defaults) # ===================================== -# 1) Route provider per persona +# 1) Route provider/model per persona # persona_providers: -# coder: codex # Run coder persona on codex -# reviewer: claude # Run reviewer persona on claude +# coder: +# provider: codex # Run coder persona on Codex +# model: o3-mini # Use o3-mini model (optional) +# reviewer: +# provider: claude # Run reviewer persona on Claude -# 2) Provider options (global < project < piece) +# 2) Provider options +# Priority (for piece-capable keys such as provider/model/provider_options): +# global < piece < project < env # provider_options: # codex: # network_access: true # Allow network access for Codex diff --git a/builtins/ja/config.yaml b/builtins/ja/config.yaml index 57b596e..75d21bc 100644 --- a/builtins/ja/config.yaml +++ b/builtins/ja/config.yaml @@ -55,12 +55,17 @@ concurrency: 2 # takt run の同時実行数(1-10) # ===================================== # ピースにも関わる設定(global defaults) # ===================================== -# 1) ペルソナ単位でプロバイダーを切り替える +# 1) ペルソナ単位でプロバイダー・モデルを切り替える # persona_providers: -# coder: codex # coderペルソナはcodexで実行 -# reviewer: claude # reviewerペルソナはclaudeで実行 +# coder: +# provider: codex # coderペルソナはcodexで実行 +# model: o3-mini # 使用モデル(省略可) +# reviewer: +# provider: claude # reviewerペルソナはclaudeで実行 -# 2) provider 固有オプション(global < project < piece) +# 2) provider 固有オプション +# 優先順位(provider/model/provider_options 等の piece 対応キー): +# global < piece < project < env # provider_options: # codex: # network_access: true # Codex実行時のネットワークアクセス許可 diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md index 260a7ed..6c69337 100644 --- a/docs/configuration.ja.md +++ b/docs/configuration.ja.md @@ -34,11 +34,14 @@ interactive_preview_movements: 3 # インタラクティブモードでの move # - gradle # .runtime/ に Gradle キャッシュ/設定を準備 # - node # .runtime/ に npm キャッシュを準備 -# persona ごとの provider 上書き(省略可) -# piece を複製せずに特定の persona を別の provider にルーティング +# persona ごとの provider / model 上書き(省略可) +# piece を複製せずに特定の persona を別の provider / model にルーティング # persona_providers: -# coder: codex # coder を Codex で実行 -# ai-antipattern-reviewer: claude # レビュアーは Claude のまま +# coder: +# provider: codex # coder を Codex で実行 +# model: o3-mini # 使用モデル(省略可) +# ai-antipattern-reviewer: +# provider: claude # レビュアーは Claude のまま # provider 固有のパーミッションプロファイル(省略可) # 優先順位: プロジェクト上書き > グローバル上書き > プロジェクトデフォルト > グローバルデフォルト > required_permission_mode(下限) @@ -97,7 +100,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move | `verbose` | boolean | - | 詳細出力モード | | `minimal_output` | boolean | `false` | AI 出力を抑制(CI 向け) | | `runtime` | object | - | ランタイム環境デフォルト(例: `prepare: [gradle, node]`) | -| `persona_providers` | object | - | persona ごとの provider 上書き(例: `coder: codex`) | +| `persona_providers` | object | - | persona ごとの provider / model 上書き(例: `coder: { provider: codex, model: o3-mini }`) | | `provider_options` | object | - | グローバルな provider 固有オプション | | `provider_profiles` | object | - | provider 固有のパーミッションプロファイル | | `anthropic_api_key` | string | - | Claude 用 Anthropic API キー | @@ -286,16 +289,21 @@ movement の `required_permission_mode` は最低限の下限を設定します ### Persona Provider -piece を複製せずに、特定の persona を別の provider にルーティングできます。 +piece を複製せずに、特定の persona を別の provider や model にルーティングできます。 ```yaml # ~/.takt/config.yaml persona_providers: - coder: codex # coder persona を Codex で実行 - ai-antipattern-reviewer: claude # レビュアーは Claude のまま + coder: + provider: codex # coder persona を Codex で実行 + model: o3-mini # 使用モデル(省略可) + ai-antipattern-reviewer: + provider: claude # レビュアーは Claude のまま ``` -これにより、単一の piece 内で provider を混在させることができます。persona 名は movement 定義の `persona` キーに対してマッチされます。 +`provider` と `model` はいずれも省略可能です。`model` の解決優先度: movement YAML の `model` > `persona_providers[persona].model` > グローバル `model`。 + +これにより、単一の piece 内で provider や model を混在させることができます。persona 名は movement 定義の `persona` キーに対してマッチされます。 ## Piece カテゴリ diff --git a/docs/configuration.md b/docs/configuration.md index a6dbf50..7c72576 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,11 +34,14 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, # - gradle # Prepare Gradle cache/config in .runtime/ # - node # Prepare npm cache in .runtime/ -# Per-persona provider overrides (optional) -# Route specific personas to different providers without duplicating pieces +# Per-persona provider/model overrides (optional) +# Route specific personas to different providers and models without duplicating pieces # persona_providers: -# coder: codex # Run coder on Codex -# ai-antipattern-reviewer: claude # Keep reviewers on Claude +# coder: +# provider: codex # Run coder on Codex +# model: o3-mini # Use o3-mini model (optional) +# ai-antipattern-reviewer: +# provider: claude # Keep reviewers on Claude # Provider-specific permission profiles (optional) # Priority: project override > global override > project default > global default > required_permission_mode (floor) @@ -97,7 +100,7 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, | `verbose` | boolean | - | Verbose output mode | | `minimal_output` | boolean | `false` | Suppress AI output (for CI) | | `runtime` | object | - | Runtime environment defaults (e.g., `prepare: [gradle, node]`) | -| `persona_providers` | object | - | Per-persona provider overrides (e.g., `coder: codex`) | +| `persona_providers` | object | - | Per-persona provider/model overrides (e.g., `coder: { provider: codex, model: o3-mini }`) | | `provider_options` | object | - | Global provider-specific options | | `provider_profiles` | object | - | Provider-specific permission profiles | | `anthropic_api_key` | string | - | Anthropic API key for Claude | @@ -286,16 +289,21 @@ The `required_permission_mode` on a movement sets the minimum floor. If the reso ### Persona Providers -Route specific personas to different providers without duplicating pieces: +Route specific personas to different providers and models without duplicating pieces: ```yaml # ~/.takt/config.yaml persona_providers: - coder: codex # Run coder persona on Codex - ai-antipattern-reviewer: claude # Keep reviewers on Claude + coder: + provider: codex # Run coder persona on Codex + model: o3-mini # Use o3-mini model (optional) + ai-antipattern-reviewer: + provider: claude # Keep reviewers on Claude ``` -This allows mixing providers within a single piece. The persona name is matched against the `persona` key in the movement definition. +Both `provider` and `model` are optional. `model` resolution priority: movement YAML `model` > `persona_providers[persona].model` > global `model`. + +This allows mixing providers and models within a single piece. The persona name is matched against the `persona` key in the movement definition. ## Piece Categories diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 966353e..889b362 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -34,6 +34,11 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ determinePiece: vi.fn(), })); +vi.mock('../infra/task/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + summarizeTaskName: vi.fn().mockResolvedValue('test-task'), +})); + vi.mock('../infra/github/issue.js', () => ({ isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), resolveIssueTask: vi.fn(), diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts index 514ea9d..cb41c50 100644 --- a/src/__tests__/catalog.test.ts +++ b/src/__tests__/catalog.test.ts @@ -20,16 +20,6 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: () => ({}), })); -vi.mock('../infra/config/loadConfig.js', () => ({ - loadConfig: () => ({ - global: { - language: 'en', - enableBuiltinPieces: true, - }, - project: {}, - }), -})); - const mockLogError = vi.fn(); const mockInfo = vi.fn(); vi.mock('../shared/ui/index.js', () => ({ diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 525ae7f..db6b723 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -35,9 +35,9 @@ import { getLanguage, loadProjectConfig, isVerboseMode, + resolveConfigValue, invalidateGlobalConfigCache, } from '../infra/config/index.js'; -import { loadConfig } from '../infra/config/loadConfig.js'; describe('getBuiltinPiece', () => { it('should return builtin piece when it exists in resources', () => { @@ -472,7 +472,7 @@ describe('analytics config resolution', () => { }); }); - it('should merge analytics as project > global in loadConfig', () => { + it('should merge analytics as project > global in resolveConfigValue', () => { const globalConfigDir = process.env.TAKT_CONFIG_DIR!; mkdirSync(globalConfigDir, { recursive: true }); writeFileSync(join(globalConfigDir, 'config.yaml'), [ @@ -492,8 +492,8 @@ describe('analytics config resolution', () => { ' retention_days: 14', ].join('\n')); - const config = loadConfig(testDir); - expect(config.analytics).toEqual({ + const analytics = resolveConfigValue(testDir, 'analytics'); + expect(analytics).toEqual({ enabled: true, eventsPath: '/tmp/project-analytics', retentionDays: 14, diff --git a/src/__tests__/engine-persona-providers.test.ts b/src/__tests__/engine-persona-providers.test.ts index 2766186..fd61047 100644 --- a/src/__tests__/engine-persona-providers.test.ts +++ b/src/__tests__/engine-persona-providers.test.ts @@ -1,10 +1,10 @@ /** - * Tests for persona_providers config-level provider override. + * Tests for persona_providers config-level provider/model override. * - * Verifies movement-level provider resolution for stepProvider: + * Verifies movement-level provider/model resolution for stepProvider/stepModel: * 1. Movement YAML provider (highest) - * 2. persona_providers[personaDisplayName] - * 3. CLI provider (lowest) + * 2. persona_providers[personaDisplayName].provider / .model + * 3. CLI provider / model (lowest) */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -46,7 +46,7 @@ describe('PieceEngine persona_providers override', () => { applyDefaultMocks(); }); - it('should use persona_providers when movement has no provider and persona matches', async () => { + it('should use persona_providers.provider when movement has no provider and persona matches', async () => { const movement = makeMovement('implement', { personaDisplayName: 'coder', rules: [makeRule('done', 'COMPLETE')], @@ -66,7 +66,7 @@ describe('PieceEngine persona_providers override', () => { const engine = new PieceEngine(config, '/tmp/project', 'test task', { projectCwd: '/tmp/project', provider: 'claude', - personaProviders: { coder: 'codex' }, + personaProviders: { coder: { provider: 'codex' } }, }); await engine.run(); @@ -96,7 +96,7 @@ describe('PieceEngine persona_providers override', () => { const engine = new PieceEngine(config, '/tmp/project', 'test task', { projectCwd: '/tmp/project', provider: 'claude', - personaProviders: { coder: 'codex' }, + personaProviders: { coder: { provider: 'codex' } }, }); await engine.run(); @@ -127,7 +127,7 @@ describe('PieceEngine persona_providers override', () => { const engine = new PieceEngine(config, '/tmp/project', 'test task', { projectCwd: '/tmp/project', provider: 'mock', - personaProviders: { coder: 'codex' }, + personaProviders: { coder: { provider: 'codex' } }, }); await engine.run(); @@ -194,7 +194,7 @@ describe('PieceEngine persona_providers override', () => { const engine = new PieceEngine(config, '/tmp/project', 'test task', { projectCwd: '/tmp/project', provider: 'claude', - personaProviders: { coder: 'codex' }, + personaProviders: { coder: { provider: 'codex' } }, }); await engine.run(); @@ -207,4 +207,66 @@ describe('PieceEngine persona_providers override', () => { expect(calls[1][2].provider).toBe('claude'); expect(calls[1][2].stepProvider).toBe('codex'); }); + + it('should use persona_providers.model as stepModel when step.model is undefined', async () => { + const movement = makeMovement('implement', { + personaDisplayName: 'coder', + rules: [makeRule('done', 'COMPLETE')], + }); + const config: PieceConfig = { + name: 'persona-model-test', + movements: [movement], + initialMovement: 'implement', + maxMovements: 1, + }; + + mockRunAgentSequence([ + makeResponse({ persona: movement.persona, content: 'done' }), + ]); + mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]); + + const engine = new PieceEngine(config, '/tmp/project', 'test task', { + projectCwd: '/tmp/project', + provider: 'claude', + model: 'global-model', + personaProviders: { coder: { provider: 'codex', model: 'o3-mini' } }, + }); + + await engine.run(); + + const options = vi.mocked(runAgent).mock.calls[0][2]; + expect(options.stepProvider).toBe('codex'); + expect(options.stepModel).toBe('o3-mini'); + }); + + it('should fallback to input.model when persona_providers.model is not set', async () => { + const movement = makeMovement('implement', { + personaDisplayName: 'coder', + rules: [makeRule('done', 'COMPLETE')], + }); + const config: PieceConfig = { + name: 'persona-model-fallback', + movements: [movement], + initialMovement: 'implement', + maxMovements: 1, + }; + + mockRunAgentSequence([ + makeResponse({ persona: movement.persona, content: 'done' }), + ]); + mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]); + + const engine = new PieceEngine(config, '/tmp/project', 'test task', { + projectCwd: '/tmp/project', + provider: 'claude', + model: 'global-model', + personaProviders: { coder: { provider: 'codex' } }, + }); + + await engine.run(); + + const options = vi.mocked(runAgent).mock.calls[0][2]; + expect(options.stepProvider).toBe('codex'); + expect(options.stepModel).toBe('global-model'); + }); }); diff --git a/src/__tests__/engine-provider-options.test.ts b/src/__tests__/engine-provider-options.test.ts index c17b3af..e8d88ba 100644 --- a/src/__tests__/engine-provider-options.test.ts +++ b/src/__tests__/engine-provider-options.test.ts @@ -54,7 +54,7 @@ describe('PieceEngine provider_options resolution', () => { } }); - it('should merge provider_options in order: global < project < movement', async () => { + it('should merge provider_options in order: global < piece/movement < project', async () => { const movement = makeMovement('implement', { providerOptions: { codex: { networkAccess: false }, @@ -78,6 +78,7 @@ describe('PieceEngine provider_options resolution', () => { engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, provider: 'claude', + providerOptionsSource: 'project', providerOptions: { codex: { networkAccess: true }, claude: { sandbox: { allowUnsandboxedCommands: false } }, @@ -89,7 +90,7 @@ describe('PieceEngine provider_options resolution', () => { const options = vi.mocked(runAgent).mock.calls[0]?.[2]; expect(options?.providerOptions).toEqual({ - codex: { networkAccess: false }, + codex: { networkAccess: true }, opencode: { networkAccess: true }, claude: { sandbox: { diff --git a/src/__tests__/global-pieceCategories.test.ts b/src/__tests__/global-pieceCategories.test.ts index 642759f..a07c9cf 100644 --- a/src/__tests__/global-pieceCategories.test.ts +++ b/src/__tests__/global-pieceCategories.test.ts @@ -7,32 +7,20 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const loadConfigMock = vi.hoisted(() => vi.fn()); +const resolvedState = vi.hoisted(() => ({ value: {} as Record })); vi.mock('../infra/config/paths.js', () => ({ getGlobalConfigDir: () => '/tmp/.takt', })); -vi.mock('../infra/config/loadConfig.js', () => ({ - loadConfig: loadConfigMock, -})); - vi.mock('../infra/config/resolvePieceConfigValue.js', () => ({ resolvePieceConfigValue: (_projectDir: string, key: string) => { - const loaded = loadConfigMock() as Record>; - const global = loaded?.global ?? {}; - const project = loaded?.project ?? {}; - const merged: Record = { ...global, ...project }; - return merged[key]; + return resolvedState.value[key]; }, resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => { - const loaded = loadConfigMock() as Record>; - const global = loaded?.global ?? {}; - const project = loaded?.project ?? {}; - const merged: Record = { ...global, ...project }; const result: Record = {}; for (const key of keys) { - result[key] = merged[key]; + result[key] = resolvedState.value[key]; } return result; }, @@ -49,15 +37,12 @@ function createTempCategoriesPath(): string { describe('getPieceCategoriesPath', () => { beforeEach(() => { - loadConfigMock.mockReset(); + resolvedState.value = {}; }); it('should return configured path when pieceCategoriesFile is set', () => { // Given - loadConfigMock.mockReturnValue({ - global: { pieceCategoriesFile: '/custom/piece-categories.yaml' }, - project: {}, - }); + resolvedState.value = { pieceCategoriesFile: '/custom/piece-categories.yaml' }; // When const path = getPieceCategoriesPath(process.cwd()); @@ -68,7 +53,7 @@ describe('getPieceCategoriesPath', () => { it('should return default path when pieceCategoriesFile is not set', () => { // Given - loadConfigMock.mockReturnValue({ global: {}, project: {} }); + resolvedState.value = {}; // When const path = getPieceCategoriesPath(process.cwd()); @@ -79,9 +64,11 @@ describe('getPieceCategoriesPath', () => { it('should rethrow when global config loading fails', () => { // Given - loadConfigMock.mockImplementation(() => { - throw new Error('invalid global config'); - }); + resolvedState.value = new Proxy({}, { + get() { + throw new Error('invalid global config'); + }, + }) as Record; // When / Then expect(() => getPieceCategoriesPath(process.cwd())).toThrow('invalid global config'); @@ -92,7 +79,7 @@ describe('resetPieceCategories', () => { const tempRoots: string[] = []; beforeEach(() => { - loadConfigMock.mockReset(); + resolvedState.value = {}; }); afterEach(() => { @@ -106,10 +93,7 @@ describe('resetPieceCategories', () => { // Given const categoriesPath = createTempCategoriesPath(); tempRoots.push(dirname(dirname(categoriesPath))); - loadConfigMock.mockReturnValue({ - global: { pieceCategoriesFile: categoriesPath }, - project: {}, - }); + resolvedState.value = { pieceCategoriesFile: categoriesPath }; // When resetPieceCategories(process.cwd()); @@ -125,10 +109,7 @@ describe('resetPieceCategories', () => { const categoriesDir = dirname(categoriesPath); const tempRoot = dirname(categoriesDir); tempRoots.push(tempRoot); - loadConfigMock.mockReturnValue({ - global: { pieceCategoriesFile: categoriesPath }, - project: {}, - }); + resolvedState.value = { pieceCategoriesFile: categoriesPath }; mkdirSync(categoriesDir, { recursive: true }); writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8'); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index d34f896..b63c5e4 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -42,7 +42,7 @@ describe('loadGlobalConfig', () => { expect(config.logLevel).toBe('info'); expect(config.provider).toBe('claude'); expect(config.model).toBeUndefined(); - expect(config.debug).toBeUndefined(); + expect(config.verbose).toBeUndefined(); expect(config.pipeline).toBeUndefined(); }); @@ -451,8 +451,11 @@ describe('loadGlobalConfig', () => { [ 'language: en', 'persona_providers:', - ' coder: codex', - ' reviewer: claude', + ' coder:', + ' provider: codex', + ' reviewer:', + ' provider: claude', + ' model: claude-3-5-sonnet-latest', ].join('\n'), 'utf-8', ); @@ -460,8 +463,29 @@ describe('loadGlobalConfig', () => { const config = loadGlobalConfig(); expect(config.personaProviders).toEqual({ - coder: 'codex', - reviewer: 'claude', + coder: { provider: 'codex' }, + reviewer: { provider: 'claude', model: 'claude-3-5-sonnet-latest' }, + }); + }); + + it('should load persona_providers with model only (no provider)', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + [ + 'language: en', + 'persona_providers:', + ' coder:', + ' model: o3-mini', + ].join('\n'), + 'utf-8', + ); + + const config = loadGlobalConfig(); + + expect(config.personaProviders).toEqual({ + coder: { model: 'o3-mini' }, }); }); @@ -471,12 +495,28 @@ describe('loadGlobalConfig', () => { writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); - config.personaProviders = { coder: 'codex' }; + config.personaProviders = { coder: { provider: 'codex', model: 'o3-mini' } }; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); - expect(reloaded.personaProviders).toEqual({ coder: 'codex' }); + expect(reloaded.personaProviders).toEqual({ coder: { provider: 'codex', model: 'o3-mini' } }); + }); + + it('should normalize legacy string format to object format', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\npersona_providers:\n coder: codex\n', + 'utf-8', + ); + + const config = loadGlobalConfig(); + + expect(config.personaProviders).toEqual({ + coder: { provider: 'codex' }, + }); }); it('should have undefined personaProviders by default', () => { @@ -497,6 +537,42 @@ describe('loadGlobalConfig', () => { const reloaded = loadGlobalConfig(); expect(reloaded.personaProviders).toBeUndefined(); }); + + it('should throw when persona entry has codex provider with Claude model alias', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\npersona_providers:\n coder:\n provider: codex\n model: opus\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/Claude model alias/); + }); + + it('should throw when persona entry has opencode provider without model', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\npersona_providers:\n reviewer:\n provider: opencode\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/requires model/); + }); + + it('should not throw when persona entry has opencode provider with compatible model', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\npersona_providers:\n coder:\n provider: opencode\n model: opencode/big-pickle\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).not.toThrow(); + }); }); describe('runtime', () => { diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index 6e181f6..31582ab 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -19,9 +19,9 @@ describe('Schemas accept opencode provider', () => { it('should accept opencode in GlobalConfigSchema persona_providers field', () => { const result = GlobalConfigSchema.parse({ - persona_providers: { coder: 'opencode' }, + persona_providers: { coder: { provider: 'opencode' } }, }); - expect(result.persona_providers).toEqual({ coder: 'opencode' }); + expect(result.persona_providers).toEqual({ coder: { provider: 'opencode' } }); }); it('should accept opencode_api_key in GlobalConfigSchema', () => { diff --git a/src/__tests__/options-builder.test.ts b/src/__tests__/options-builder.test.ts index c9e2998..5f14d37 100644 --- a/src/__tests__/options-builder.test.ts +++ b/src/__tests__/options-builder.test.ts @@ -68,7 +68,7 @@ describe('OptionsBuilder.buildBaseOptions', () => { expect(options.permissionMode).toBe('edit'); }); - it('merges provider options with precedence: global < project < movement', () => { + it('merges provider options with precedence: global < movement < project', () => { const step = createMovement({ providerOptions: { codex: { networkAccess: false }, @@ -76,6 +76,7 @@ describe('OptionsBuilder.buildBaseOptions', () => { }, }); const builder = createBuilder(step, { + providerOptionsSource: 'project', providerOptions: { codex: { networkAccess: true }, claude: { sandbox: { allowUnsandboxedCommands: true } }, @@ -86,7 +87,7 @@ describe('OptionsBuilder.buildBaseOptions', () => { const options = builder.buildBaseOptions(step); expect(options.providerOptions).toEqual({ - codex: { networkAccess: false }, + codex: { networkAccess: true }, opencode: { networkAccess: true }, claude: { sandbox: { diff --git a/src/__tests__/pieceExecution-session-loading.test.ts b/src/__tests__/pieceExecution-session-loading.test.ts index daae53d..4ce1699 100644 --- a/src/__tests__/pieceExecution-session-loading.test.ts +++ b/src/__tests__/pieceExecution-session-loading.test.ts @@ -248,7 +248,7 @@ describe('executePiece session loading', () => { projectCwd: '/tmp/project', provider: 'codex', model: 'gpt-5', - personaProviders: { coder: 'opencode' }, + personaProviders: { coder: { provider: 'opencode' } }, }); const mockInfo = vi.mocked(info); diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index c44937a..373c501 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -31,11 +31,10 @@ vi.mock('../features/tasks/index.js', () => ({ executeTask: mockExecuteTask, })); -// Mock loadGlobalConfig -const mockLoadGlobalConfig = vi.fn(); -vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal>()), - loadGlobalConfig: mockLoadGlobalConfig, -})); +const mockResolveConfigValues = vi.fn(); +vi.mock('../infra/config/index.js', () => ({ + resolveConfigValues: mockResolveConfigValues, +})); // Mock execFileSync for git operations const mockExecFileSync = vi.fn(); @@ -68,18 +67,13 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ const { executePipeline } = await import('../features/pipeline/index.js'); describe('executePipeline', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Default: git operations succeed - mockExecFileSync.mockReturnValue('abc1234\n'); - // Default: no pipeline config - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - }); - }); + beforeEach(() => { + vi.clearAllMocks(); + // Default: git operations succeed + mockExecFileSync.mockReturnValue('abc1234\n'); + // Default: no pipeline config + mockResolveConfigValues.mockReturnValue({ pipeline: undefined }); + }); it('should return exit code 2 when neither --issue nor --task is specified', async () => { const exitCode = await executePipeline({ @@ -311,15 +305,11 @@ describe('executePipeline', () => { describe('PipelineConfig template expansion', () => { it('should use commit_message_template when configured', async () => { - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - pipeline: { - commitMessageTemplate: 'fix: {title} (#{issue})', - }, - }); + mockResolveConfigValues.mockReturnValue({ + pipeline: { + commitMessageTemplate: 'fix: {title} (#{issue})', + }, + }); mockFetchIssue.mockReturnValueOnce({ number: 42, @@ -347,15 +337,11 @@ describe('executePipeline', () => { }); it('should use default_branch_prefix when configured', async () => { - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - pipeline: { - defaultBranchPrefix: 'feat/', - }, - }); + mockResolveConfigValues.mockReturnValue({ + pipeline: { + defaultBranchPrefix: 'feat/', + }, + }); mockFetchIssue.mockReturnValueOnce({ number: 10, @@ -383,15 +369,11 @@ describe('executePipeline', () => { }); it('should use pr_body_template when configured for PR creation', async () => { - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - pipeline: { - prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}', - }, - }); + mockResolveConfigValues.mockReturnValue({ + pipeline: { + prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}', + }, + }); mockFetchIssue.mockReturnValueOnce({ number: 50, diff --git a/src/__tests__/provider-resolution.test.ts b/src/__tests__/provider-resolution.test.ts index fa60189..bec21ac 100644 --- a/src/__tests__/provider-resolution.test.ts +++ b/src/__tests__/provider-resolution.test.ts @@ -7,7 +7,7 @@ describe('resolveMovementProviderModel', () => { const result = resolveMovementProviderModel({ step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' }, provider: 'claude', - personaProviders: { coder: 'opencode' }, + personaProviders: { coder: { provider: 'opencode' } }, }); // When: provider/model を解決する @@ -15,16 +15,16 @@ describe('resolveMovementProviderModel', () => { expect(result.provider).toBe('codex'); }); - it('should use personaProviders when step.provider is undefined', () => { + 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: 'opencode' }, + personaProviders: { reviewer: { provider: 'opencode' } }, }); // When: provider/model を解決する - // Then: personaProviders の値が使われる + // Then: personaProviders の provider が使われる expect(result.provider).toBe('opencode'); }); @@ -33,7 +33,7 @@ describe('resolveMovementProviderModel', () => { const result = resolveMovementProviderModel({ step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' }, provider: 'mock', - personaProviders: { reviewer: 'codex' }, + personaProviders: { reviewer: { provider: 'codex' } }, }); // When: provider/model を解決する @@ -54,11 +54,12 @@ describe('resolveMovementProviderModel', () => { expect(result.provider).toBeUndefined(); }); - it('should prefer step.model over input.model', () => { - // Given: step.model と input.model が両方指定されている + it('should prefer step.model over personaProviders.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 を解決する @@ -66,15 +67,54 @@ describe('resolveMovementProviderModel', () => { expect(result.model).toBe('step-model'); }); - it('should fallback to input.model when step.model is undefined', () => { - // Given: step.model が未定義で input.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'); + }); }); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 77e10fe..f299aa7 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -40,6 +40,13 @@ vi.mock('../infra/config/index.js', () => ({ } return result; }, + resolveConfigValueWithSource: (_projectDir: string, key: string) => { + const raw = mockLoadConfigRaw() as Record; + const config = ('global' in raw && 'project' in raw) + ? { ...raw.global as Record, ...raw.project as Record } + : { ...raw, piece: 'default', provider: 'claude', verbose: false }; + return { value: config[key], source: 'project' }; + }, })); const mockLoadConfig = mockLoadConfigRaw; diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts index db39382..e4107d1 100644 --- a/src/__tests__/summarize.test.ts +++ b/src/__tests__/summarize.test.ts @@ -4,14 +4,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('../infra/providers/index.js', () => ({ - getProvider: vi.fn(), -})); - -vi.mock('../infra/config/global/globalConfig.js', () => ({ - loadGlobalConfig: vi.fn(), - getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), -})); +vi.mock('../infra/providers/index.js', () => ({ + getProvider: vi.fn(), +})); + +vi.mock('../infra/config/index.js', () => ({ + resolveConfigValues: vi.fn(), +})); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), @@ -22,30 +21,27 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ }), })); -import { getProvider } from '../infra/providers/index.js'; -import { loadGlobalConfig } from '../infra/config/global/globalConfig.js'; -import { summarizeTaskName } from '../infra/task/summarize.js'; - -const mockGetProvider = vi.mocked(getProvider); -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); +import { getProvider } from '../infra/providers/index.js'; +import { resolveConfigValues } from '../infra/config/index.js'; +import { summarizeTaskName } from '../infra/task/summarize.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockResolveConfigValues = vi.mocked(resolveConfigValues); const mockProviderCall = vi.fn(); const mockProvider = { setup: () => ({ call: mockProviderCall }), }; -beforeEach(() => { - vi.clearAllMocks(); - mockGetProvider.mockReturnValue(mockProvider); - mockLoadGlobalConfig.mockReturnValue({ - language: 'ja', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - model: undefined, - branchNameStrategy: 'ai', - }); -}); +beforeEach(() => { + vi.clearAllMocks(); + mockGetProvider.mockReturnValue(mockProvider); + mockResolveConfigValues.mockReturnValue({ + provider: 'claude', + model: undefined, + branchNameStrategy: 'ai', + }); +}); describe('summarizeTaskName', () => { it('should return AI-generated slug for task name', async () => { @@ -166,14 +162,11 @@ describe('summarizeTaskName', () => { it('should use provider from config.yaml', async () => { // Given: config has codex provider with branchNameStrategy: 'ai' - mockLoadGlobalConfig.mockReturnValue({ - language: 'ja', - defaultPiece: 'default', - logLevel: 'info', - provider: 'codex', - model: 'gpt-4', - branchNameStrategy: 'ai', - }); + mockResolveConfigValues.mockReturnValue({ + provider: 'codex', + model: 'gpt-4', + branchNameStrategy: 'ai', + }); mockProviderCall.mockResolvedValue({ persona: 'summarizer', status: 'done', @@ -228,9 +221,9 @@ describe('summarizeTaskName', () => { it('should throw error when config load fails', async () => { // Given: config loading throws error - mockLoadGlobalConfig.mockImplementation(() => { - throw new Error('Config not found'); - }); + mockResolveConfigValues.mockImplementation(() => { + throw new Error('Config not found'); + }); // When/Then await expect(summarizeTaskName('test', { cwd: '/project' })).rejects.toThrow('Config not found'); @@ -257,14 +250,11 @@ describe('summarizeTaskName', () => { it('should use romaji by default', async () => { // Given: branchNameStrategy is not set (undefined) - mockLoadGlobalConfig.mockReturnValue({ - language: 'ja', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - model: undefined, - branchNameStrategy: undefined, - }); + mockResolveConfigValues.mockReturnValue({ + provider: 'claude', + model: undefined, + branchNameStrategy: undefined, + }); // When: useLLM not specified, branchNameStrategy not set const result = await summarizeTaskName('test task', { cwd: '/project' }); @@ -276,14 +266,11 @@ describe('summarizeTaskName', () => { it('should use AI when branchNameStrategy is ai', async () => { // Given: branchNameStrategy is 'ai' - mockLoadGlobalConfig.mockReturnValue({ - language: 'ja', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - model: undefined, - branchNameStrategy: 'ai', - }); + mockResolveConfigValues.mockReturnValue({ + provider: 'claude', + model: undefined, + branchNameStrategy: 'ai', + }); mockProviderCall.mockResolvedValue({ persona: 'summarizer', status: 'done', @@ -301,14 +288,11 @@ describe('summarizeTaskName', () => { it('should use romaji when branchNameStrategy is romaji', async () => { // Given: branchNameStrategy is 'romaji' - mockLoadGlobalConfig.mockReturnValue({ - language: 'ja', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - model: undefined, - branchNameStrategy: 'romaji', - }); + mockResolveConfigValues.mockReturnValue({ + provider: 'claude', + model: undefined, + branchNameStrategy: 'romaji', + }); // When const result = await summarizeTaskName('test task', { cwd: '/project' }); @@ -320,14 +304,11 @@ describe('summarizeTaskName', () => { it('should respect explicit useLLM option over config', async () => { // Given: branchNameStrategy is 'romaji' but useLLM is explicitly true - mockLoadGlobalConfig.mockReturnValue({ - language: 'ja', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - model: undefined, - branchNameStrategy: 'romaji', - }); + mockResolveConfigValues.mockReturnValue({ + provider: 'claude', + model: undefined, + branchNameStrategy: 'romaji', + }); mockProviderCall.mockResolvedValue({ persona: 'summarizer', status: 'done', @@ -345,14 +326,11 @@ describe('summarizeTaskName', () => { it('should respect explicit useLLM false over config with ai strategy', async () => { // Given: branchNameStrategy is 'ai' but useLLM is explicitly false - mockLoadGlobalConfig.mockReturnValue({ - language: 'ja', - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - model: undefined, - branchNameStrategy: 'ai', - }); + mockResolveConfigValues.mockReturnValue({ + provider: 'claude', + model: undefined, + branchNameStrategy: 'ai', + }); // When: useLLM is explicitly false const result = await summarizeTaskName('test task', { cwd: '/project', useLLM: false }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 0e26514..c254faf 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -5,12 +5,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { TaskInfo } from '../infra/task/index.js'; -const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } = +const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockResolveConfigValueWithSource, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } = vi.hoisted(() => ({ mockResolveTaskExecution: vi.fn(), mockExecutePiece: vi.fn(), mockLoadPieceByIdentifier: vi.fn(), mockResolvePieceConfigValues: vi.fn(), + mockResolveConfigValueWithSource: vi.fn(), mockBuildTaskResult: vi.fn(), mockPersistTaskResult: vi.fn(), mockPersistTaskError: vi.fn(), @@ -40,6 +41,7 @@ vi.mock('../infra/config/index.js', () => ({ loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), isPiecePath: () => false, resolvePieceConfigValues: (...args: unknown[]) => mockResolvePieceConfigValues(...args), + resolveConfigValueWithSource: (...args: unknown[]) => mockResolveConfigValueWithSource(...args), })); vi.mock('../shared/ui/index.js', () => ({ @@ -90,14 +92,17 @@ describe('executeAndCompleteTask', () => { model: undefined, personaProviders: {}, providerProfiles: {}, - providerOptions: { - claude: { sandbox: { allowUnsandboxedCommands: true } }, - }, notificationSound: true, notificationSoundEvents: {}, concurrency: 1, taskPollIntervalMs: 500, }); + mockResolveConfigValueWithSource.mockReturnValue({ + value: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }, + source: 'project', + }); mockBuildTaskResult.mockReturnValue({ success: true }); mockResolveTaskExecution.mockResolvedValue({ execCwd: '/project', @@ -136,11 +141,13 @@ describe('executeAndCompleteTask', () => { taskDisplayLabel?: string; taskPrefix?: string; providerOptions?: unknown; + providerOptionsSource?: string; }; expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.providerOptions).toEqual({ claude: { sandbox: { allowUnsandboxedCommands: true } }, }); + expect(pieceExecutionOptions?.providerOptionsSource).toBe('project'); }); }); diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 8a07a10..fd9682c 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -30,7 +30,6 @@ export type { ObservabilityConfig, Language, PipelineConfig, - GlobalConfig, ProjectConfig, ProviderProfileName, ProviderPermissionProfile, diff --git a/src/core/models/global-config.ts b/src/core/models/persisted-global-config.ts similarity index 93% rename from src/core/models/global-config.ts rename to src/core/models/persisted-global-config.ts index fb774aa..4722d64 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -5,6 +5,11 @@ import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js'; import type { ProviderPermissionProfiles } from './provider-profiles.js'; +export interface PersonaProviderEntry { + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; + model?: string; +} + /** Custom agent configuration */ export interface CustomAgentConfig { name: string; @@ -60,8 +65,8 @@ export interface NotificationSoundEventsConfig { runAbort?: boolean; } -/** Global configuration for takt */ -export interface GlobalConfig { +/** Persisted global configuration for ~/.takt/config.yaml */ +export interface PersistedGlobalConfig { language: Language; logLevel: 'debug' | 'info' | 'warn' | 'error'; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; @@ -94,8 +99,8 @@ export interface GlobalConfig { bookmarksFile?: string; /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ pieceCategoriesFile?: string; - /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ - personaProviders?: Record; + /** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */ + personaProviders?: Record; /** Global provider-specific options (lowest priority) */ providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index f01b982..226d379 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -359,6 +359,11 @@ export const PieceConfigRawSchema = z.object({ interactive_mode: InteractiveModeSchema.optional(), }); +export const PersonaProviderEntrySchema = z.object({ + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), + model: z.string().optional(), +}); + /** Custom agent configuration schema */ export const CustomAgentConfigSchema = z.object({ name: z.string().min(1), @@ -443,8 +448,11 @@ export const GlobalConfigSchema = z.object({ bookmarks_file: z.string().optional(), /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ piece_categories_file: z.string().optional(), - /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ - persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(), + /** Per-persona provider and model overrides. */ + persona_providers: z.record(z.string(), z.union([ + z.enum(['claude', 'codex', 'opencode', 'mock']), + PersonaProviderEntrySchema, + ])).optional(), /** Global provider-specific options (lowest priority) */ provider_options: MovementProviderOptionsSchema, /** Provider-specific permission profiles */ diff --git a/src/core/models/types.ts b/src/core/models/types.ts index c68197d..1b98b79 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -61,10 +61,10 @@ export type { // Configuration types (global and project) export type { + PersonaProviderEntry, CustomAgentConfig, ObservabilityConfig, Language, PipelineConfig, - GlobalConfig, ProjectConfig, -} from './global-config.js'; +} from './persisted-global-config.js'; diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 8f99282..089d70a 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -29,6 +29,17 @@ function mergeProviderOptions( return Object.keys(result).length > 0 ? result : undefined; } +function resolveMovementProviderOptions( + source: 'env' | 'project' | 'global' | 'default' | undefined, + resolvedConfigOptions: MovementProviderOptions | undefined, + movementOptions: MovementProviderOptions | undefined, +): MovementProviderOptions | undefined { + if (source === 'env' || source === 'project') { + return mergeProviderOptions(movementOptions, resolvedConfigOptions); + } + return mergeProviderOptions(resolvedConfigOptions, movementOptions); +} + export class OptionsBuilder { constructor( private readonly engineOptions: PieceEngineOptions, @@ -53,11 +64,8 @@ export class OptionsBuilder { model: this.engineOptions.model, personaProviders: this.engineOptions.personaProviders, }); - - const resolvedProviderForPermissions = - this.engineOptions.provider - ?? resolved.provider - ?? 'claude'; + const resolvedProvider = resolved.provider ?? this.engineOptions.provider ?? 'claude'; + const resolvedModel = resolved.model ?? this.engineOptions.model; return { cwd: this.getCwd(), @@ -65,16 +73,17 @@ export class OptionsBuilder { personaPath: step.personaPath, provider: this.engineOptions.provider, model: this.engineOptions.model, - stepProvider: resolved.provider, - stepModel: resolved.model, + stepProvider: resolvedProvider, + stepModel: resolvedModel, permissionMode: resolveMovementPermissionMode({ movementName: step.name, requiredPermissionMode: step.requiredPermissionMode, - provider: resolvedProviderForPermissions, + provider: resolvedProvider, projectProviderProfiles: this.engineOptions.providerProfiles, globalProviderProfiles: DEFAULT_PROVIDER_PERMISSION_PROFILES, }), - providerOptions: mergeProviderOptions( + providerOptions: resolveMovementProviderOptions( + this.engineOptions.providerOptionsSource, this.engineOptions.providerOptions, step.providerOptions, ), diff --git a/src/core/piece/provider-resolution.ts b/src/core/piece/provider-resolution.ts index 6561c80..5364b10 100644 --- a/src/core/piece/provider-resolution.ts +++ b/src/core/piece/provider-resolution.ts @@ -1,12 +1,12 @@ import type { PieceMovement } from '../models/types.js'; - -export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; +import type { PersonaProviderEntry } from '../models/persisted-global-config.js'; +import type { ProviderType } from './types.js'; export interface MovementProviderModelInput { step: Pick; provider?: ProviderType; model?: string; - personaProviders?: Record; + personaProviders?: Record; } export interface MovementProviderModelOutput { @@ -15,10 +15,11 @@ export interface MovementProviderModelOutput { } export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput { + const personaEntry = input.personaProviders?.[input.step.personaDisplayName]; return { provider: input.step.provider - ?? input.personaProviders?.[input.step.personaDisplayName] + ?? personaEntry?.provider ?? input.provider, - model: input.step.model ?? input.model, + model: input.step.model ?? personaEntry?.model ?? input.model, }; } diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index f3ae155..1cd2639 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -7,10 +7,12 @@ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; +import type { PersonaProviderEntry } from '../models/persisted-global-config.js'; import type { ProviderPermissionProfiles } from '../models/provider-profiles.js'; import type { MovementProviderOptions } from '../models/piece-types.js'; export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; +export type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default'; export interface StreamInitEventData { model: string; @@ -182,8 +184,10 @@ export interface PieceEngineOptions { model?: string; /** Resolved provider options */ providerOptions?: MovementProviderOptions; - /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ - personaProviders?: Record; + /** Source layer for resolved provider options */ + providerOptionsSource?: ProviderOptionsSource; + /** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */ + personaProviders?: Record; /** Resolved provider permission profiles */ providerProfiles?: ProviderPermissionProfiles; /** Enable interactive-only rules and user-input transitions */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 5f54919..1fdce46 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -467,6 +467,7 @@ export async function executePiece( provider: options.provider, model: options.model, providerOptions: options.providerOptions, + providerOptionsSource: options.providerOptionsSource, personaProviders: options.personaProviders, providerProfiles: options.providerProfiles, interactive: interactiveUserInput, @@ -547,8 +548,9 @@ export async function executePiece( model: options.model, personaProviders: options.personaProviders, }); - const movementProvider = resolved.provider ?? currentProvider; - const movementModel = resolved.model ?? globalConfig.model ?? '(default)'; + const movementProvider = resolved.provider ?? 'claude'; + const resolvedModel = resolved.model; + const movementModel = resolvedModel ?? '(default)'; currentMovementProvider = movementProvider; currentMovementModel = movementModel; providerEventLogger.setMovement(step.name); diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 6ebdf4f..e630c13 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -2,7 +2,7 @@ * Task execution logic */ -import { loadPieceByIdentifier, isPiecePath, resolvePieceConfigValues } from '../../../infra/config/index.js'; +import { loadPieceByIdentifier, isPiecePath, resolveConfigValueWithSource, resolvePieceConfigValues } from '../../../infra/config/index.js'; import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js'; import { header, @@ -66,16 +66,17 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise; + /** Source layer for resolved provider options */ + providerOptionsSource?: ProviderOptionsSource; + /** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */ + personaProviders?: Record; /** Resolved provider permission profiles */ providerProfiles?: ProviderPermissionProfiles; /** Enable interactive user input during step transitions */ diff --git a/src/index.ts b/src/index.ts index e4d138b..ea3f3e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,9 +29,6 @@ export { isPiecePath, } from './infra/config/loaders/index.js'; export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js'; -export { - loadConfig, -} from './infra/config/loadConfig.js'; export { saveProjectConfig, updateProjectConfig, diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 1d17dc8..9a712e3 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -9,13 +9,15 @@ import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constant import { isAbsolute } from 'node:path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { GlobalConfigSchema } from '../../../core/models/index.js'; -import type { GlobalConfig, Language } from '../../../core/models/index.js'; +import type { Language } from '../../../core/models/index.js'; +import type { PersistedGlobalConfig, PersonaProviderEntry } from '../../../core/models/persisted-global-config.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { getGlobalConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js'; import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js'; +import { invalidateAllResolvedConfigCache } from '../resolutionCache.js'; /** Claude-specific model aliases that are not valid for other providers */ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); @@ -56,7 +58,6 @@ function validateCodexCliPath(pathValue: string, sourceName: 'TAKT_CODEX_CLI_PAT return trimmed; } -/** Validate that provider and model are compatible */ function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { if (!provider) return; @@ -80,6 +81,19 @@ function validateProviderModelCompatibility(provider: string | undefined, model: } } +function normalizePersonaProviders( + raw: Record | PersonaProviderEntry> | undefined, +): Record | undefined { + if (!raw) return undefined; + return Object.fromEntries( + Object.entries(raw).map(([persona, entry]) => { + const normalized: PersonaProviderEntry = typeof entry === 'string' ? { provider: entry } : entry; + validateProviderModelCompatibility(normalized.provider, normalized.model); + return [persona, normalized]; + }), + ); +} + function normalizeProviderProfiles( raw: Record }> | undefined, ): ProviderPermissionProfiles | undefined { @@ -114,7 +128,7 @@ function denormalizeProviderProfiles( */ export class GlobalConfigManager { private static instance: GlobalConfigManager | null = null; - private cachedConfig: GlobalConfig | null = null; + private cachedConfig: PersistedGlobalConfig | null = null; private constructor() {} @@ -136,7 +150,7 @@ export class GlobalConfigManager { } /** Load global configuration (cached) */ - load(): GlobalConfig { + load(): PersistedGlobalConfig { if (this.cachedConfig !== null) { return this.cachedConfig; } @@ -156,7 +170,7 @@ export class GlobalConfigManager { applyGlobalConfigEnvOverrides(rawConfig); const parsed = GlobalConfigSchema.parse(rawConfig); - const config: GlobalConfig = { + const config: PersistedGlobalConfig = { language: parsed.language, logLevel: parsed.log_level, provider: parsed.provider, @@ -186,7 +200,7 @@ export class GlobalConfigManager { minimalOutput: parsed.minimal_output, bookmarksFile: parsed.bookmarks_file, pieceCategoriesFile: parsed.piece_categories_file, - personaProviders: parsed.persona_providers, + personaProviders: normalizePersonaProviders(parsed.persona_providers as Record | PersonaProviderEntry> | undefined), providerOptions: normalizeProviderOptions(parsed.provider_options), providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record }> | undefined), runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0 @@ -213,7 +227,7 @@ export class GlobalConfigManager { } /** Save global configuration to disk and invalidate cache */ - save(config: GlobalConfig): void { + save(config: PersistedGlobalConfig): void { const configPath = getGlobalConfigPath(); const raw: Record = { language: config.language, @@ -338,18 +352,20 @@ export class GlobalConfigManager { } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); + invalidateAllResolvedConfigCache(); } } export function invalidateGlobalConfigCache(): void { GlobalConfigManager.getInstance().invalidateCache(); + invalidateAllResolvedConfigCache(); } -export function loadGlobalConfig(): GlobalConfig { +export function loadGlobalConfig(): PersistedGlobalConfig { return GlobalConfigManager.getInstance().load(); } -export function saveGlobalConfig(config: GlobalConfig): void { +export function saveGlobalConfig(config: PersistedGlobalConfig): void { GlobalConfigManager.getInstance().save(config); } @@ -434,7 +450,7 @@ export function resolveCodexCliPath(): string | undefined { return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); } - let config: GlobalConfig; + let config: PersistedGlobalConfig; try { config = loadGlobalConfig(); } catch { diff --git a/src/infra/config/loadConfig.ts b/src/infra/config/loadConfig.ts deleted file mode 100644 index 01f2afb..0000000 --- a/src/infra/config/loadConfig.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { GlobalConfig } from '../../core/models/index.js'; -import type { MovementProviderOptions } from '../../core/models/piece-types.js'; -import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; -import type { AnalyticsConfig } from '../../core/models/global-config.js'; -import { loadGlobalConfig } from './global/globalConfig.js'; -import { loadProjectConfig } from './project/projectConfig.js'; -import { envVarNameFromPath } from './env/config-env-overrides.js'; - -export interface LoadedConfig extends GlobalConfig { - piece: string; - provider: NonNullable; - verbose: boolean; - providerOptions?: MovementProviderOptions; - providerProfiles?: ProviderPermissionProfiles; -} - -export function loadConfig(projectDir: string): LoadedConfig { - const global = loadGlobalConfig(); - const project = loadProjectConfig(projectDir); - const provider = (project.provider ?? global.provider ?? 'claude') as NonNullable; - - return { - ...global, - piece: project.piece ?? 'default', - provider, - autoPr: project.auto_pr ?? global.autoPr, - draftPr: project.draft_pr ?? global.draftPr, - model: resolveModel(global, provider), - verbose: resolveVerbose(project.verbose, global.verbose), - analytics: mergeAnalytics(global.analytics, project.analytics), - providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions), - providerProfiles: mergeProviderProfiles(global.providerProfiles, project.providerProfiles), - }; -} - -function resolveModel(global: GlobalConfig, provider: GlobalConfig['provider']): string | undefined { - if (!global.model) return undefined; - const globalProvider = global.provider ?? 'claude'; - const resolvedProvider = provider ?? 'claude'; - if (globalProvider !== resolvedProvider) return undefined; - return global.model; -} - -function resolveVerbose(projectVerbose: boolean | undefined, globalVerbose: boolean | undefined): boolean { - const envVerbose = loadEnvBooleanSetting('verbose'); - if (envVerbose !== undefined) return envVerbose; - if (projectVerbose !== undefined) return projectVerbose; - if (globalVerbose !== undefined) return globalVerbose; - return false; -} - -function loadEnvBooleanSetting(configKey: string): boolean | undefined { - const envKey = envVarNameFromPath(configKey); - const raw = process.env[envKey]; - if (raw === undefined) return undefined; - - const normalized = raw.trim().toLowerCase(); - if (normalized === 'true') return true; - if (normalized === 'false') return false; - - throw new Error(`${envKey} must be one of: true, false`); -} - -function mergeProviderOptions( - globalOptions: MovementProviderOptions | undefined, - projectOptions: MovementProviderOptions | undefined, -): MovementProviderOptions | undefined { - if (!globalOptions && !projectOptions) return undefined; - - const result: MovementProviderOptions = {}; - if (globalOptions?.codex || projectOptions?.codex) { - result.codex = { ...globalOptions?.codex, ...projectOptions?.codex }; - } - if (globalOptions?.opencode || projectOptions?.opencode) { - result.opencode = { ...globalOptions?.opencode, ...projectOptions?.opencode }; - } - if (globalOptions?.claude?.sandbox || projectOptions?.claude?.sandbox) { - result.claude = { - sandbox: { - ...globalOptions?.claude?.sandbox, - ...projectOptions?.claude?.sandbox, - }, - }; - } - - return Object.keys(result).length > 0 ? result : undefined; -} - -function mergeAnalytics( - globalAnalytics: AnalyticsConfig | undefined, - projectAnalytics: AnalyticsConfig | undefined, -): AnalyticsConfig | undefined { - if (!globalAnalytics && !projectAnalytics) return undefined; - - const merged: AnalyticsConfig = { - enabled: projectAnalytics?.enabled ?? globalAnalytics?.enabled, - eventsPath: projectAnalytics?.eventsPath ?? globalAnalytics?.eventsPath, - retentionDays: projectAnalytics?.retentionDays ?? globalAnalytics?.retentionDays, - }; - - if (merged.enabled === undefined && merged.eventsPath === undefined && merged.retentionDays === undefined) { - return undefined; - } - return merged; -} - -function mergeProviderProfiles( - globalProfiles: ProviderPermissionProfiles | undefined, - projectProfiles: ProviderPermissionProfiles | undefined, -): ProviderPermissionProfiles | undefined { - if (!globalProfiles && !projectProfiles) return undefined; - - const merged: ProviderPermissionProfiles = { ...(globalProfiles ?? {}) }; - for (const [provider, profile] of Object.entries(projectProfiles ?? {})) { - const key = provider as keyof ProviderPermissionProfiles; - const existing = merged[key]; - if (!existing) { - merged[key] = profile; - continue; - } - merged[key] = { - defaultPermissionMode: profile.defaultPermissionMode, - movementPermissionOverrides: { - ...(existing.movementPermissionOverrides ?? {}), - ...(profile.movementPermissionOverrides ?? {}), - }, - }; - } - - return Object.keys(merged).length > 0 ? merged : undefined; -} diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index 34c5bef..a1fe8bf 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -10,9 +10,10 @@ import { parse, stringify } from 'yaml'; import { copyProjectResourcesToDir } from '../../resources/index.js'; import type { ProjectLocalConfig } from '../types.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; -import type { AnalyticsConfig } from '../../../core/models/global-config.js'; +import type { AnalyticsConfig } from '../../../core/models/persisted-global-config.js'; import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js'; +import { invalidateResolvedConfigCache } from '../resolutionCache.js'; export type { ProjectLocalConfig } from '../types.js'; @@ -154,6 +155,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); + invalidateResolvedConfigCache(projectDir); } /** diff --git a/src/infra/config/project/resolvedSettings.ts b/src/infra/config/project/resolvedSettings.ts index e514c5d..b777524 100644 --- a/src/infra/config/project/resolvedSettings.ts +++ b/src/infra/config/project/resolvedSettings.ts @@ -1,32 +1,5 @@ -import { envVarNameFromPath } from '../env/config-env-overrides.js'; -import { loadConfig } from '../loadConfig.js'; - -function resolveValue( - envValue: T | undefined, - localValue: T | undefined, - globalValue: T | undefined, - defaultValue: T, -): T { - if (envValue !== undefined) return envValue; - if (localValue !== undefined) return localValue; - if (globalValue !== undefined) return globalValue; - return defaultValue; -} - -function loadEnvBooleanSetting(configKey: string): boolean | undefined { - const envKey = envVarNameFromPath(configKey); - const raw = process.env[envKey]; - if (raw === undefined) return undefined; - - const normalized = raw.trim().toLowerCase(); - if (normalized === 'true') return true; - if (normalized === 'false') return false; - - throw new Error(`${envKey} must be one of: true, false`); -} +import { resolveConfigValue } from '../resolveConfigValue.js'; export function isVerboseMode(projectDir: string): boolean { - const envValue = loadEnvBooleanSetting('verbose'); - const config = loadConfig(projectDir); - return resolveValue(envValue, undefined, config.verbose, false); + return resolveConfigValue(projectDir, 'verbose'); } diff --git a/src/infra/config/resolutionCache.ts b/src/infra/config/resolutionCache.ts new file mode 100644 index 0000000..0c3dfae --- /dev/null +++ b/src/infra/config/resolutionCache.ts @@ -0,0 +1,50 @@ +import { resolve } from 'node:path'; +import type { ProjectLocalConfig } from './types.js'; +import type { ConfigParameterKey } from './resolvedConfig.js'; + +const projectConfigCache = new Map(); +const resolvedValueCache = new Map(); + +function normalizeProjectDir(projectDir: string): string { + return resolve(projectDir); +} + +function resolvedValueKey(projectDir: string, key: ConfigParameterKey): string { + return `${normalizeProjectDir(projectDir)}::${key}`; +} + +export function getCachedProjectConfig(projectDir: string): ProjectLocalConfig | undefined { + return projectConfigCache.get(normalizeProjectDir(projectDir)); +} + +export function setCachedProjectConfig(projectDir: string, config: ProjectLocalConfig): void { + projectConfigCache.set(normalizeProjectDir(projectDir), config); +} + +export function hasCachedResolvedValue(projectDir: string, key: ConfigParameterKey): boolean { + return resolvedValueCache.has(resolvedValueKey(projectDir, key)); +} + +export function getCachedResolvedValue(projectDir: string, key: ConfigParameterKey): unknown { + return resolvedValueCache.get(resolvedValueKey(projectDir, key)); +} + +export function setCachedResolvedValue(projectDir: string, key: ConfigParameterKey, value: unknown): void { + resolvedValueCache.set(resolvedValueKey(projectDir, key), value); +} + +export function invalidateResolvedConfigCache(projectDir: string): void { + const normalizedProjectDir = normalizeProjectDir(projectDir); + projectConfigCache.delete(normalizedProjectDir); + const prefix = `${normalizedProjectDir}::`; + for (const key of resolvedValueCache.keys()) { + if (key.startsWith(prefix)) { + resolvedValueCache.delete(key); + } + } +} + +export function invalidateAllResolvedConfigCache(): void { + projectConfigCache.clear(); + resolvedValueCache.clear(); +} diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts index 8f7d4f9..7cc3448 100644 --- a/src/infra/config/resolveConfigValue.ts +++ b/src/infra/config/resolveConfigValue.ts @@ -1,22 +1,230 @@ -import { loadConfig, type LoadedConfig } from './loadConfig.js'; +import { loadGlobalConfig } from './global/globalConfig.js'; +import { loadProjectConfig } from './project/projectConfig.js'; +import { envVarNameFromPath } from './env/config-env-overrides.js'; +import { + getCachedProjectConfig, + getCachedResolvedValue, + hasCachedResolvedValue, + setCachedProjectConfig, + setCachedResolvedValue, +} from './resolutionCache.js'; +import type { ConfigParameterKey, LoadedConfig } from './resolvedConfig.js'; -export type ConfigParameterKey = keyof LoadedConfig; +export type { ConfigParameterKey } from './resolvedConfig.js'; +export { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js'; + +export interface PieceContext { + provider?: LoadedConfig['provider']; + model?: LoadedConfig['model']; + providerOptions?: LoadedConfig['providerOptions']; +} + +export interface ResolveConfigOptions { + pieceContext?: PieceContext; +} + +export type ConfigValueSource = 'env' | 'project' | 'piece' | 'global' | 'default'; + +export interface ResolvedConfigValue { + value: LoadedConfig[K]; + source: ConfigValueSource; +} + +type ResolutionLayer = 'local' | 'piece' | 'global'; +interface ResolutionRule { + layers: readonly ResolutionLayer[]; + defaultValue?: LoadedConfig[K]; + mergeMode?: 'analytics'; + pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined; +} + +function loadProjectConfigCached(projectDir: string) { + const cached = getCachedProjectConfig(projectDir); + if (cached !== undefined) { + return cached; + } + const loaded = loadProjectConfig(projectDir); + setCachedProjectConfig(projectDir, loaded); + return loaded; +} + +const DEFAULT_RULE: ResolutionRule = { + layers: ['local', 'global'], +}; + +const PROVIDER_OPTIONS_ENV_PATHS = [ + 'provider_options', + 'provider_options.codex.network_access', + 'provider_options.opencode.network_access', + 'provider_options.claude.sandbox.allow_unsandboxed_commands', + 'provider_options.claude.sandbox.excluded_commands', +] as const; + +const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule }> = { + piece: { layers: ['local', 'global'], defaultValue: 'default' }, + provider: { + layers: ['local', 'piece', 'global'], + defaultValue: 'claude', + pieceValue: (pieceContext) => pieceContext?.provider, + }, + model: { + layers: ['local', 'piece', 'global'], + pieceValue: (pieceContext) => pieceContext?.model, + }, + providerOptions: { + layers: ['local', 'piece', 'global'], + pieceValue: (pieceContext) => pieceContext?.providerOptions, + }, + autoPr: { layers: ['local', 'global'] }, + draftPr: { layers: ['local', 'global'] }, + analytics: { layers: ['local', 'global'], mergeMode: 'analytics' }, + verbose: { layers: ['local', 'global'], defaultValue: false }, +}; + +function resolveAnalyticsMerged( + project: ReturnType, + global: ReturnType, +): LoadedConfig['analytics'] { + const localAnalytics = project.analytics; + const globalAnalytics = global.analytics; + + const enabled = localAnalytics?.enabled ?? globalAnalytics?.enabled; + const eventsPath = localAnalytics?.eventsPath ?? globalAnalytics?.eventsPath; + const retentionDays = localAnalytics?.retentionDays ?? globalAnalytics?.retentionDays; + + if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) { + return undefined; + } + return { enabled, eventsPath, retentionDays }; +} + +function resolveAnalyticsSource( + project: ReturnType, + global: ReturnType, +): ConfigValueSource { + if (project.analytics !== undefined) return 'project'; + if (global.analytics !== undefined) return 'global'; + return 'default'; +} + +function getLocalLayerValue( + project: ReturnType, + key: K, +): LoadedConfig[K] | undefined { + switch (key) { + case 'piece': + return project.piece as LoadedConfig[K] | undefined; + case 'provider': + return project.provider as LoadedConfig[K] | undefined; + case 'autoPr': + return project.auto_pr as LoadedConfig[K] | undefined; + case 'draftPr': + return project.draft_pr as LoadedConfig[K] | undefined; + case 'verbose': + return project.verbose as LoadedConfig[K] | undefined; + case 'analytics': + return project.analytics as LoadedConfig[K] | undefined; + case 'providerOptions': + return project.providerOptions as LoadedConfig[K] | undefined; + case 'providerProfiles': + return project.providerProfiles as LoadedConfig[K] | undefined; + default: + return undefined; + } +} + +function getGlobalLayerValue( + global: ReturnType, + key: K, +): LoadedConfig[K] | undefined { + return global[key as keyof typeof global] as LoadedConfig[K] | undefined; +} + +function resolveByRegistry( + key: K, + project: ReturnType, + global: ReturnType, + options: ResolveConfigOptions | undefined, +): ResolvedConfigValue { + const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule; + if (rule.mergeMode === 'analytics') { + return { + value: resolveAnalyticsMerged(project, global) as LoadedConfig[K], + source: resolveAnalyticsSource(project, global), + }; + } + + for (const layer of rule.layers) { + let value: LoadedConfig[K] | undefined; + if (layer === 'local') { + value = getLocalLayerValue(project, key); + } else if (layer === 'piece') { + value = rule.pieceValue?.(options?.pieceContext); + } else { + value = getGlobalLayerValue(global, key); + } + if (value !== undefined) { + if (layer === 'local') { + if (key === 'providerOptions' && hasProviderOptionsEnvOverride()) { + return { value, source: 'env' }; + } + return { value, source: 'project' }; + } + if (layer === 'piece') { + return { value, source: 'piece' }; + } + return { value, source: 'global' }; + } + } + + return { value: rule.defaultValue as LoadedConfig[K], source: 'default' }; +} + +function hasProviderOptionsEnvOverride(): boolean { + return PROVIDER_OPTIONS_ENV_PATHS.some((path) => process.env[envVarNameFromPath(path)] !== undefined); +} + +function resolveUncachedConfigValue( + projectDir: string, + key: K, + options?: ResolveConfigOptions, +): ResolvedConfigValue { + const project = loadProjectConfigCached(projectDir); + const global = loadGlobalConfig(); + return resolveByRegistry(key, project, global, options); +} + +export function resolveConfigValueWithSource( + projectDir: string, + key: K, + options?: ResolveConfigOptions, +): ResolvedConfigValue { + const resolved = resolveUncachedConfigValue(projectDir, key, options); + if (!options?.pieceContext) { + setCachedResolvedValue(projectDir, key, resolved.value); + } + return resolved; +} export function resolveConfigValue( projectDir: string, key: K, + options?: ResolveConfigOptions, ): LoadedConfig[K] { - return loadConfig(projectDir)[key]; + if (!options?.pieceContext && hasCachedResolvedValue(projectDir, key)) { + return getCachedResolvedValue(projectDir, key) as LoadedConfig[K]; + } + return resolveConfigValueWithSource(projectDir, key, options).value; } export function resolveConfigValues( projectDir: string, keys: readonly K[], + options?: ResolveConfigOptions, ): Pick { - const config = loadConfig(projectDir); const result = {} as Pick; for (const key of keys) { - result[key] = config[key]; + result[key] = resolveConfigValue(projectDir, key, options); } return result; } diff --git a/src/infra/config/resolvePieceConfigValue.ts b/src/infra/config/resolvePieceConfigValue.ts index 98b0375..a01fa4b 100644 --- a/src/infra/config/resolvePieceConfigValue.ts +++ b/src/infra/config/resolvePieceConfigValue.ts @@ -1,17 +1,20 @@ import type { ConfigParameterKey } from './resolveConfigValue.js'; import { resolveConfigValue, resolveConfigValues } from './resolveConfigValue.js'; -import type { LoadedConfig } from './loadConfig.js'; +import type { ResolveConfigOptions } from './resolveConfigValue.js'; +import type { LoadedConfig } from './resolvedConfig.js'; export function resolvePieceConfigValue( projectDir: string, key: K, + options?: ResolveConfigOptions, ): LoadedConfig[K] { - return resolveConfigValue(projectDir, key); + return resolveConfigValue(projectDir, key, options); } export function resolvePieceConfigValues( projectDir: string, keys: readonly K[], + options?: ResolveConfigOptions, ): Pick { - return resolveConfigValues(projectDir, keys); + return resolveConfigValues(projectDir, keys, options); } diff --git a/src/infra/config/resolvedConfig.ts b/src/infra/config/resolvedConfig.ts new file mode 100644 index 0000000..820dc73 --- /dev/null +++ b/src/infra/config/resolvedConfig.ts @@ -0,0 +1,9 @@ +import type { PersistedGlobalConfig } from '../../core/models/persisted-global-config.js'; + +export interface LoadedConfig extends Omit { + piece: string; + provider: NonNullable; + verbose: boolean; +} + +export type ConfigParameterKey = keyof LoadedConfig; diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 159f52c..eb039b5 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -4,7 +4,7 @@ import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; -import type { AnalyticsConfig } from '../../core/models/global-config.js'; +import type { AnalyticsConfig } from '../../core/models/persisted-global-config.js'; /** Project configuration stored in .takt/config.yaml */ export interface ProjectLocalConfig {