feat(PBI-67/ST-1298): job-config resolver + kind-default-matrix
Nieuwe centrale resolver `resolveJobConfig(job, product, task?)` die per ClaudeJob bepaalt welk model + thinking-budget + permission-mode + max_turns + allowed_tools de worker moet gebruiken. Override-cascade (eerste match wint): task.requires_opus → job.requested_* → product.preferred_* → kind-default Kind-defaults: IDEA_GRILL sonnet-4-6 thinking 12k plan IDEA_MAKE_PLAN opus-4-7 thinking 24k plan PLAN_CHAT sonnet-4-6 thinking 6k plan (max 5 turns) TASK_IMPLEMENTATION sonnet-4-6 thinking 6k bypassPermissions SPRINT_IMPLEMENTATION sonnet-4-6 thinking 6k bypassPermissions 19 unit tests (alle 5 kinds × cascade-niveaus). Geen externe deps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
85a95e5bba
commit
070c039740
2 changed files with 217 additions and 0 deletions
97
__tests__/job-config.test.ts
Normal file
97
__tests__/job-config.test.ts
Normal file
|
|
@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
120
src/lib/job-config.ts
Normal file
120
src/lib/job-config.ts
Normal file
|
|
@ -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<string, JobConfig> = {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue