From be6ff57ef8df8c30a8b92cced9cea942d5d6716c Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 15 Mar 2026 18:45:46 +0000 Subject: [PATCH] feat(agent-configs): add per-agent engine settings to agent_configs table --- src/api/routers/agentConfigs.ts | 7 ++- .../0044_agent_config_engine_settings.sql | 4 ++ src/db/migrations/meta/_journal.json | 7 +++ src/db/repositories/agentConfigsRepository.ts | 11 +++- src/db/repositories/configMapper.ts | 6 +- src/db/schema/agentConfigs.ts | 4 +- tests/unit/api/routers/agentConfigs.test.ts | 62 +++++++++++++++++++ .../agentConfigsRepository.test.ts | 39 ++++++++++++ .../unit/db/repositories/configMapper.test.ts | 47 ++++++++++++++ 9 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 src/db/migrations/0044_agent_config_engine_settings.sql diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index 63c79416..f55fe034 100644 --- a/src/api/routers/agentConfigs.ts +++ b/src/api/routers/agentConfigs.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { getEngineCatalog, registerBuiltInEngines } from '../../backends/index.js'; +import { EngineSettingsSchema } from '../../config/engineSettings.js'; import { getDb } from '../../db/client.js'; import { createAgentConfig, @@ -35,6 +36,7 @@ export const agentConfigsRouter = router({ model: z.string().nullish(), maxIterations: z.number().int().positive().nullish(), agentEngine: z.string().nullish(), + engineSettings: EngineSettingsSchema.nullish(), maxConcurrency: z.number().int().positive().nullish(), }), ) @@ -48,6 +50,7 @@ export const agentConfigsRouter = router({ model: input.model, maxIterations: input.maxIterations, ...(input.agentEngine !== undefined ? { agentEngine: input.agentEngine } : {}), + ...(input.engineSettings !== undefined ? { engineSettings: input.engineSettings } : {}), ...(input.maxConcurrency !== undefined ? { maxConcurrency: input.maxConcurrency } : {}), }); }), @@ -60,6 +63,7 @@ export const agentConfigsRouter = router({ model: z.string().nullish(), maxIterations: z.number().int().positive().nullish(), agentEngine: z.string().nullish(), + engineSettings: EngineSettingsSchema.nullish(), maxConcurrency: z.number().int().positive().nullish(), }), ) @@ -76,10 +80,11 @@ export const agentConfigsRouter = router({ // Check project-scoped configs belong to user's org await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); - const { id, ...updates } = input; + const { id, engineSettings, ...updates } = input; await updateAgentConfig(id, { ...updates, ...(input.agentEngine !== undefined ? { agentEngine: input.agentEngine } : {}), + ...(engineSettings !== undefined ? { engineSettings } : {}), }); }), diff --git a/src/db/migrations/0044_agent_config_engine_settings.sql b/src/db/migrations/0044_agent_config_engine_settings.sql new file mode 100644 index 00000000..975edba8 --- /dev/null +++ b/src/db/migrations/0044_agent_config_engine_settings.sql @@ -0,0 +1,4 @@ +-- Add agent_engine_settings JSONB column to agent_configs table. +-- NULL means no per-agent engine settings override (use project-level settings). + +ALTER TABLE "agent_configs" ADD COLUMN IF NOT EXISTS "agent_engine_settings" jsonb; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 0838f5a6..ca813dae 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1778000000000, "tag": "0043_stats_composite_index", "breakpoints": false + }, + { + "idx": 44, + "version": "7", + "when": 1779000000000, + "tag": "0044_agent_config_engine_settings", + "breakpoints": false } ] } diff --git a/src/db/repositories/agentConfigsRepository.ts b/src/db/repositories/agentConfigsRepository.ts index b6a30002..e1765172 100644 --- a/src/db/repositories/agentConfigsRepository.ts +++ b/src/db/repositories/agentConfigsRepository.ts @@ -1,4 +1,5 @@ import { and, eq } from 'drizzle-orm'; +import type { EngineSettings } from '../../config/engineSettings.js'; import { getDb } from '../client.js'; import { agentConfigs } from '../schema/index.js'; @@ -17,6 +18,7 @@ export async function createAgentConfig(data: { model?: string | null; maxIterations?: number | null; agentEngine?: string | null; + engineSettings?: EngineSettings | null; maxConcurrency?: number | null; }) { const db = getDb(); @@ -28,6 +30,7 @@ export async function createAgentConfig(data: { model: data.model, maxIterations: data.maxIterations, agentEngine: data.agentEngine, + agentEngineSettings: data.engineSettings, maxConcurrency: data.maxConcurrency, }) .returning({ id: agentConfigs.id }); @@ -41,13 +44,19 @@ export async function updateAgentConfig( model?: string | null; maxIterations?: number | null; agentEngine?: string | null; + engineSettings?: EngineSettings | null; maxConcurrency?: number | null; }, ) { const db = getDb(); + const { engineSettings, ...rest } = updates; await db .update(agentConfigs) - .set({ ...updates, updatedAt: new Date() }) + .set({ + ...rest, + ...(engineSettings !== undefined ? { agentEngineSettings: engineSettings } : {}), + updatedAt: new Date(), + }) .where(eq(agentConfigs.id, id)); } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 44864ef0..1805c01e 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -41,6 +41,7 @@ export interface AgentConfigRow { model: string | null; maxIterations: number | null; agentEngine: string | null; + agentEngineSettings?: EngineSettings | null; } export interface IntegrationRow { @@ -133,16 +134,19 @@ export function buildAgentMaps(configs: AgentConfigRow[]): { models: Record; iterations: Record; engines: Record; + engineSettings: Record; } { const models: Record = {}; const iterations: Record = {}; const engines: Record = {}; + const engineSettings: Record = {}; for (const ac of configs) { if (ac.model) models[ac.agentType] = ac.model; if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; if (ac.agentEngine) engines[ac.agentType] = ac.agentEngine; + if (ac.agentEngineSettings != null) engineSettings[ac.agentType] = ac.agentEngineSettings; } - return { models, iterations, engines }; + return { models, iterations, engines, engineSettings }; } export function orUndefined>(obj: T): T | undefined { diff --git a/src/db/schema/agentConfigs.ts b/src/db/schema/agentConfigs.ts index 80343c0d..c2dd8aab 100644 --- a/src/db/schema/agentConfigs.ts +++ b/src/db/schema/agentConfigs.ts @@ -1,4 +1,5 @@ -import { integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import { integer, jsonb, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import type { EngineSettings } from '../../config/engineSettings.js'; import { projects } from './projects.js'; export const agentConfigs = pgTable( @@ -13,6 +14,7 @@ export const agentConfigs = pgTable( model: text('model'), maxIterations: integer('max_iterations'), agentEngine: text('agent_engine'), + agentEngineSettings: jsonb('agent_engine_settings').$type(), maxConcurrency: integer('max_concurrency'), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') diff --git a/tests/unit/api/routers/agentConfigs.test.ts b/tests/unit/api/routers/agentConfigs.test.ts index 4591b02a..3e9f4fdf 100644 --- a/tests/unit/api/routers/agentConfigs.test.ts +++ b/tests/unit/api/routers/agentConfigs.test.ts @@ -263,6 +263,68 @@ describe('agentConfigsRouter', () => { }); }); + describe('create with engineSettings', () => { + it('passes engineSettings null to repository when explicitly set to null', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockCreateAgentConfig.mockResolvedValue({ id: 22 }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.create({ + projectId: 'proj-1', + agentType: 'implementation', + engineSettings: null, + }); + + expect(mockCreateAgentConfig).toHaveBeenCalledWith( + expect.objectContaining({ + engineSettings: null, + }), + ); + }); + + it('omits engineSettings from repository call when not provided', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockCreateAgentConfig.mockResolvedValue({ id: 23 }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.create({ + projectId: 'proj-1', + agentType: 'implementation', + }); + + const callArg = mockCreateAgentConfig.mock.calls[0][0]; + expect(Object.hasOwn(callArg, 'engineSettings')).toBe(false); + }); + }); + + describe('update with engineSettings', () => { + it('passes engineSettings null to repository when explicitly set to null', async () => { + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockUpdateAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 11, engineSettings: null }); + + expect(mockUpdateAgentConfig).toHaveBeenCalledWith( + 11, + expect.objectContaining({ engineSettings: null }), + ); + }); + + it('omits engineSettings from repository call when not provided', async () => { + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockUpdateAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 11, model: 'new-model' }); + + const callArg = mockUpdateAgentConfig.mock.calls[0][1]; + expect(Object.hasOwn(callArg, 'engineSettings')).toBe(false); + }); + }); + describe('update with maxConcurrency', () => { it('passes maxConcurrency to repository when updating project-scoped config', async () => { // First call: find config diff --git a/tests/unit/db/repositories/agentConfigsRepository.test.ts b/tests/unit/db/repositories/agentConfigsRepository.test.ts index e02fcc1a..7bbf59cc 100644 --- a/tests/unit/db/repositories/agentConfigsRepository.test.ts +++ b/tests/unit/db/repositories/agentConfigsRepository.test.ts @@ -53,6 +53,24 @@ describe('agentConfigsRepository', () => { }), ); }); + + it('persists engineSettings when provided', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 43 }]); + const engineSettings = { 'claude-code': { maxThinkingTokens: 8000 } }; + + const result = await createAgentConfig({ + projectId: 'proj-1', + agentType: 'implementation', + engineSettings, + }); + + expect(result).toEqual({ id: 43 }); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + agentEngineSettings: engineSettings, + }), + ); + }); }); describe('updateAgentConfig', () => { @@ -67,6 +85,27 @@ describe('agentConfigsRepository', () => { expect(setArg.maxIterations).toBe(30); expect(setArg.updatedAt).toBeInstanceOf(Date); }); + + it('persists engineSettings when provided', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + const engineSettings = { codex: { sandboxMode: 'workspace-write' } }; + + await updateAgentConfig(42, { engineSettings }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.agentEngineSettings).toEqual(engineSettings); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); + + it('does not set agentEngineSettings when engineSettings is not provided', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateAgentConfig(42, { model: 'updated-model' }); + + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(Object.hasOwn(setArg, 'agentEngineSettings')).toBe(false); + }); }); describe('deleteAgentConfig', () => { diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index 79039716..b28cc83c 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -135,6 +135,53 @@ describe('buildAgentMaps', () => { expect(Object.keys(result.iterations)).toHaveLength(0); expect(Object.keys(result.engines)).toHaveLength(0); }); + + it('returns empty engineSettings map for empty input', () => { + const result = buildAgentMaps([]); + expect(result.engineSettings).toEqual({}); + }); + + it('maps engineSettings per agent type', () => { + const configs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'implementation', + model: null, + maxIterations: null, + agentEngine: 'claude-code', + agentEngineSettings: { 'claude-code': { maxThinkingTokens: 8000 } }, + }, + { + projectId: 'proj1', + agentType: 'review', + model: null, + maxIterations: null, + agentEngine: null, + agentEngineSettings: null, + }, + ]; + + const result = buildAgentMaps(configs); + expect(result.engineSettings).toEqual({ + implementation: { 'claude-code': { maxThinkingTokens: 8000 } }, + }); + }); + + it('skips null agentEngineSettings', () => { + const configs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'review', + model: null, + maxIterations: null, + agentEngine: null, + agentEngineSettings: null, + }, + ]; + + const result = buildAgentMaps(configs); + expect(Object.keys(result.engineSettings)).toHaveLength(0); + }); }); // ---------------------------------------------------------------------------