diff --git a/__tests__/job-config.test.ts b/__tests__/job-config.test.ts new file mode 100644 index 0000000..3a7af58 --- /dev/null +++ b/__tests__/job-config.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { getKindDefault, resolveJobConfig } from '../src/lib/job-config.js' + +const KIND_EXPECTED = { + IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15 }, + IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20 }, + PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'plan', max_turns: 5 }, + TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 }, + SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null }, +} as const + +describe('getKindDefault', () => { + for (const [kind, expected] of Object.entries(KIND_EXPECTED)) { + it(`returnt de juiste defaults voor ${kind}`, () => { + const cfg = getKindDefault(kind) + expect(cfg.model).toBe(expected.model) + expect(cfg.thinking_budget).toBe(expected.thinking_budget) + expect(cfg.permission_mode).toBe(expected.permission_mode) + expect(cfg.max_turns).toBe(expected.max_turns) + }) + } + + it('valt terug op een veilige fallback voor onbekende kinds', () => { + const cfg = getKindDefault('SOMETHING_NEW') + expect(cfg.model).toBe('claude-sonnet-4-6') + expect(cfg.permission_mode).toBe('default') + }) +}) + +describe('resolveJobConfig — geen overrides', () => { + for (const kind of Object.keys(KIND_EXPECTED)) { + it(`returnt kind-default voor ${kind} zonder overrides`, () => { + const cfg = resolveJobConfig({ kind }, {}) + expect(cfg).toEqual(getKindDefault(kind)) + }) + } +}) + +describe('resolveJobConfig — cascade', () => { + it('product.preferred_model overrult kind-default', () => { + const cfg = resolveJobConfig({ kind: 'TASK_IMPLEMENTATION' }, { preferred_model: 'claude-haiku-4-5-20251001' }) + expect(cfg.model).toBe('claude-haiku-4-5-20251001') + }) + + it('job.requested_model overrult product.preferred_model', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-opus-4-7' }, + { preferred_model: 'claude-haiku-4-5-20251001' }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('task.requires_opus overrult product.preferred_model', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_model: 'claude-sonnet-4-6' }, + { requires_opus: true }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('task.requires_opus overrult ook job.requested_model = haiku', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-haiku-4-5-20251001' }, + {}, + { requires_opus: true }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('job.requested_thinking_budget overrult kind-default', () => { + const cfg = resolveJobConfig({ kind: 'PLAN_CHAT', requested_thinking_budget: 1024 }, {}) + expect(cfg.thinking_budget).toBe(1024) + }) + + it('product.thinking_budget_default overrult kind-default', () => { + const cfg = resolveJobConfig({ kind: 'IDEA_GRILL' }, { thinking_budget_default: 0 }) + expect(cfg.thinking_budget).toBe(0) + }) + + it('product.preferred_permission_mode = acceptEdits overrult bypassPermissions voor TASK_IMPLEMENTATION', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_permission_mode: 'acceptEdits' }, + ) + expect(cfg.permission_mode).toBe('acceptEdits') + }) + + it('max_turns en allowed_tools blijven kind-default ook met product- en job-overrides (geen V1-cascade)', () => { + const cfg = resolveJobConfig( + { kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' }, + { preferred_model: 'claude-sonnet-4-6' }, + ) + expect(cfg.max_turns).toBe(15) + expect(cfg.allowed_tools).toEqual(['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion']) + }) +}) diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts new file mode 100644 index 0000000..c615de1 --- /dev/null +++ b/src/lib/job-config.ts @@ -0,0 +1,120 @@ +// PBI-67: model + mode-selectie per ClaudeJob-kind. +// +// Override-cascade (eerste match wint): +// 1. task.requires_opus === true → forceer Opus +// 2. job.requested_* (snapshot bij enqueue) +// 3. product.preferred_* +// 4. KIND_DEFAULTS hieronder + +export type ClaudeModel = + | 'claude-opus-4-7' + | 'claude-sonnet-4-6' + | 'claude-haiku-4-5-20251001' + +export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' + +export type JobConfig = { + model: ClaudeModel + thinking_budget: number // 0 = uit + permission_mode: PermissionMode + max_turns: number | null // null = onbegrensd + allowed_tools: string[] | null // null = alle +} + +export type JobInput = { + kind: string + requested_model?: string | null + requested_thinking_budget?: number | null + requested_permission_mode?: string | null +} + +export type ProductInput = { + preferred_model?: string | null + thinking_budget_default?: number | null + preferred_permission_mode?: string | null +} + +export type TaskInput = { + requires_opus?: boolean | null +} + +const KIND_DEFAULTS: Record = { + IDEA_GRILL: { + model: 'claude-sonnet-4-6', + thinking_budget: 12000, + permission_mode: 'plan', + max_turns: 15, + allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'], + }, + IDEA_MAKE_PLAN: { + model: 'claude-opus-4-7', + thinking_budget: 24000, + permission_mode: 'plan', + max_turns: 20, + allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write'], + }, + PLAN_CHAT: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'plan', + max_turns: 5, + allowed_tools: ['Read', 'Grep', 'AskUserQuestion'], + }, + TASK_IMPLEMENTATION: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'bypassPermissions', + max_turns: 50, + allowed_tools: null, + }, + SPRINT_IMPLEMENTATION: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'bypassPermissions', + max_turns: null, + allowed_tools: null, + }, +} + +const FALLBACK: JobConfig = { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'default', + max_turns: 50, + allowed_tools: null, +} + +export function getKindDefault(kind: string): JobConfig { + return KIND_DEFAULTS[kind] ?? FALLBACK +} + +// max_turns en allowed_tools blijven kind-default (geen product/task override +// in V1 — als de behoefte ontstaat, voeg analoge velden toe aan Product/Task). +export function resolveJobConfig( + job: JobInput, + product: ProductInput, + task?: TaskInput, +): JobConfig { + const base = getKindDefault(job.kind) + + const model = ( + task?.requires_opus + ? 'claude-opus-4-7' + : job.requested_model ?? product.preferred_model ?? base.model + ) as ClaudeModel + + const thinking_budget = + job.requested_thinking_budget ?? product.thinking_budget_default ?? base.thinking_budget + + const permission_mode = (job.requested_permission_mode ?? + product.preferred_permission_mode ?? + base.permission_mode) as PermissionMode + + return { + model, + thinking_budget, + permission_mode, + max_turns: base.max_turns, + allowed_tools: base.allowed_tools, + } +}