From 787e588783d74ce997903de258150a46feef62da Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Feb 2026 17:06:28 +0000 Subject: [PATCH] feat: claude-code model dropdown, richer progress reports, and fixes - Add Claude Code model constants (models.ts) and expose via tRPC query - Replace free-text model input with dropdown when backend is claude-code (defaults form, global agent config, project agent config) - Improve resolveClaudeModel() with known model ID check and fallback warning - Enrich progress reports with tool details, agent text snippets, and completed task summaries (progressModel/progressMonitor) - Add onTaskCompleted to ProgressReporter, handle task_notification in SDK stream - Migrate GitHub getCheckSuiteStatus from Checks API to Actions API (workflow runs + jobs) for fine-grained PAT compatibility - Simplify seed-prompts to update-first approach - Fix pre-existing test failures: update GitHub client tests for Actions API - Fix cognitive complexity lint warning by extracting processTaskNotification Co-Authored-By: Claude Opus 4.6 --- src/api/routers/agentConfigs.ts | 7 +- src/backends/claude-code/index.ts | 43 +++++++-- src/backends/claude-code/models.ts | 9 ++ src/backends/progressModel.ts | 95 ++++++++++++------- src/backends/progressMonitor.ts | 53 ++++++++++- src/backends/types.ts | 1 + src/github/client.ts | 50 +++++++--- tests/unit/backends/claude-code.test.ts | 59 +++++++++++- tests/unit/github/client.test.ts | 92 ++++++++++++------ tools/seed-prompts.ts | 25 ++--- .../projects/project-agent-configs.tsx | 7 +- .../settings/agent-config-form-dialog.tsx | 8 +- web/src/components/settings/defaults-form.tsx | 8 +- web/src/components/settings/model-field.tsx | 48 ++++++++++ 14 files changed, 386 insertions(+), 119 deletions(-) create mode 100644 src/backends/claude-code/models.ts create mode 100644 web/src/components/settings/model-field.tsx diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index ad1eda92..abbbebfa 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 { validateTemplate } from '../../agents/prompts/index.js'; +import { CLAUDE_CODE_MODELS } from '../../backends/claude-code/models.js'; import { getDb } from '../../db/client.js'; import { loadPartials } from '../../db/repositories/partialsRepository.js'; import { @@ -11,7 +12,7 @@ import { updateAgentConfig, } from '../../db/repositories/settingsRepository.js'; import { agentConfigs, projects } from '../../db/schema/index.js'; -import { protectedProcedure, router } from '../trpc.js'; +import { protectedProcedure, publicProcedure, router } from '../trpc.js'; async function validatePromptIfPresent(prompt: string | null | undefined) { if (!prompt) return; @@ -26,6 +27,10 @@ async function validatePromptIfPresent(prompt: string | null | undefined) { } export const agentConfigsRouter = router({ + claudeCodeModels: publicProcedure.query(() => { + return CLAUDE_CODE_MODELS; + }), + list: protectedProcedure .input(z.object({ projectId: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index 5e3a87e7..c2fc737f 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -8,6 +8,7 @@ import type { SDKStatusMessage, SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk'; +import { logger } from '../../utils/logging.js'; import type { AgentBackend, AgentBackendInput, @@ -16,6 +17,7 @@ import type { ToolManifest, } from '../types.js'; import { buildHooks } from './hooks.js'; +import { CLAUDE_CODE_MODEL_IDS, DEFAULT_CLAUDE_CODE_MODEL } from './models.js'; /** * Build prompt guidance for CASCADE-specific CLI tools. @@ -83,10 +85,15 @@ export function buildSystemPrompt(systemPrompt: string, tools: ToolManifest[]): * The Claude Code SDK expects Anthropic model IDs. */ export function resolveClaudeModel(cascadeModel: string): string { + if (CLAUDE_CODE_MODEL_IDS.includes(cascadeModel)) return cascadeModel; if (cascadeModel.startsWith('claude-')) return cascadeModel; if (cascadeModel.startsWith('anthropic:')) return cascadeModel.replace('anthropic:', ''); - // Fallback for non-Claude models configured in CASCADE - return 'claude-sonnet-4-5-20250929'; + // Non-Claude model configured for Claude Code backend — warn and fall back + logger.warn('Non-Claude model configured for Claude Code backend, falling back to default', { + configured: cascadeModel, + fallback: DEFAULT_CLAUDE_CODE_MODEL, + }); + return DEFAULT_CLAUDE_CODE_MODEL; } /** @@ -289,6 +296,28 @@ function buildResult( return { success, output, cost, error, prUrl }; } +/** + * Process a task_notification system message: log and report completed tasks. + */ +function processTaskNotification( + sysMsg: { [key: string]: unknown }, + input: AgentBackendInput, +): void { + const taskMsg = sysMsg as unknown as { + task_id: string; + status: string; + summary: string; + }; + if (taskMsg.status === 'completed' && input.progressReporter.onTaskCompleted) { + input.progressReporter.onTaskCompleted(taskMsg.task_id, '', taskMsg.summary); + } + input.logWriter('INFO', 'Task notification', { + taskId: taskMsg.task_id, + status: taskMsg.status, + summary: taskMsg.summary, + }); +} + /** * Claude Code SDK backend for CASCADE. * @@ -357,10 +386,12 @@ export class ClaudeCodeBackend implements AgentBackend { await input.progressReporter.onIteration(turnCount, input.maxIterations); processAssistantMessage(assistantMsg, turnCount, input); } else if (message.type === 'system') { - processSystemMessage( - message as { subtype: string; [key: string]: unknown }, - input.logWriter, - ); + const sysMsg = message as { subtype: string; [key: string]: unknown }; + if (sysMsg.subtype === 'task_notification') { + processTaskNotification(sysMsg, input); + } else { + processSystemMessage(sysMsg, input.logWriter); + } } else if (message.type === 'result') { resultMessage = message as SDKResultMessage; } diff --git a/src/backends/claude-code/models.ts b/src/backends/claude-code/models.ts new file mode 100644 index 00000000..a937213b --- /dev/null +++ b/src/backends/claude-code/models.ts @@ -0,0 +1,9 @@ +export const CLAUDE_CODE_MODELS = [ + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, + { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }, +] as const; + +export const CLAUDE_CODE_MODEL_IDS: string[] = CLAUDE_CODE_MODELS.map((m) => m.value); + +export const DEFAULT_CLAUDE_CODE_MODEL = 'claude-sonnet-4-5-20250929'; diff --git a/src/backends/progressModel.ts b/src/backends/progressModel.ts index 9a4495e4..d0da9acf 100644 --- a/src/backends/progressModel.ts +++ b/src/backends/progressModel.ts @@ -16,42 +16,73 @@ export interface ProgressContext { iteration: number; maxIterations: number; todos: Todo[]; - recentToolCalls: { name: string; timestamp: number }[]; + recentToolCalls: { name: string; detail?: string; timestamp: number }[]; + recentTextSnippets?: { text: string; timestamp: number }[]; + completedTasks?: { subject: string; summary: string; timestamp: number }[]; } -const PROGRESS_SYSTEM_PROMPT = `You are a progress reporter for an AI coding agent called CASCADE. Write a brief, informative progress update based on the agent's current state. Be concise (3-5 sentences max). Focus on what has been accomplished, what's currently in progress, and what remains. Use markdown formatting. Start with a bold header like "**implementation agent progress** (X min)". Do not include a progress bar — the system adds that separately.`; +const PROGRESS_SYSTEM_PROMPT = `You are a progress reporter for an AI coding agent called CASCADE. Write a brief, informative progress update based on the agent's current state. Be concise (3-5 sentences max). Focus on what has been accomplished, what's currently in progress, and what remains. Synthesize the agent's own commentary, tool call details (file paths, commands), and completed task summaries into a coherent narrative — do not just list tool names. Use markdown formatting. Start with a bold header like "**implementation agent progress** (X min)". Do not include a progress bar — the system adds that separately.`; function formatProgressUserPrompt(context: ProgressContext): string { - const { agentType, taskDescription, elapsedMinutes, iteration, todos, recentToolCalls } = context; - - const todoLines = - todos.length > 0 - ? todos - .map((t) => { - const icon = t.status === 'done' ? '✅' : t.status === 'in_progress' ? '🔄' : '⬜'; - return `${icon} ${t.content}`; - }) - .join('\n') - : '(no todos)'; - - const recentActivity = - recentToolCalls.length > 0 - ? recentToolCalls - .slice(-10) - .map((tc) => tc.name) - .join(', ') - : '(no recent activity)'; - - return `Agent: ${agentType} -Task: ${taskDescription.slice(0, 500)} -Time elapsed: ${Math.round(elapsedMinutes)} minutes -Iterations: ${iteration} - -## Todo List -${todoLines} - -## Recent Activity -${recentActivity}`; + const { + agentType, + taskDescription, + elapsedMinutes, + iteration, + todos, + recentToolCalls, + recentTextSnippets, + completedTasks, + } = context; + + const sections: string[] = [ + `Agent: ${agentType}`, + `Task: ${taskDescription.slice(0, 500)}`, + `Time elapsed: ${Math.round(elapsedMinutes)} minutes`, + `Iterations: ${iteration}`, + ]; + + // Todos — only include if there are any (avoids noise for claude-code backend) + if (todos.length > 0) { + const todoLines = todos + .map((t) => { + const icon = t.status === 'done' ? '✅' : t.status === 'in_progress' ? '🔄' : '⬜'; + return `${icon} ${t.content}`; + }) + .join('\n'); + sections.push(`\n## Todo List\n${todoLines}`); + } + + // Recent activity with tool details (file paths, commands) + if (recentToolCalls.length > 0) { + const activityLines = recentToolCalls + .slice(-10) + .map((tc) => (tc.detail ? `${tc.name}: ${tc.detail}` : tc.name)) + .join('\n'); + sections.push(`\n## Recent Activity\n${activityLines}`); + } + + // Agent commentary — what the LLM said it's doing + if (recentTextSnippets && recentTextSnippets.length > 0) { + const commentaryLines = recentTextSnippets + .slice(-5) + .map((s) => s.text) + .join('\n---\n'); + sections.push(`\n## Agent Commentary\n${commentaryLines}`); + } + + // Completed tasks — subagent task summaries + if (completedTasks && completedTasks.length > 0) { + const taskLines = completedTasks + .map((t) => { + const label = t.subject ? `**${t.subject}**: ` : ''; + return `- ${label}${t.summary}`; + }) + .join('\n'); + sections.push(`\n## Completed Tasks\n${taskLines}`); + } + + return sections.join('\n'); } /** diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index c8635551..272dd4c9 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -33,9 +33,30 @@ export interface ProgressMonitorConfig { } const RING_BUFFER_MAX = 20; +const TEXT_SNIPPETS_MAX = 10; +const COMPLETED_TASKS_MAX = 5; + +/** + * Extract a meaningful detail string from tool call params. + * Returns file paths, commands, or search patterns — the most useful + * context for progress reporting. + */ +function summarizeToolParams(_toolName: string, params?: Record): string { + if (!params) return ''; + if (params.file_path) return String(params.file_path); + if (params.filePath) return String(params.filePath); + if (params.command) return String(params.command).slice(0, 100); + if (params.pattern) { + const detail = String(params.pattern); + return params.path ? `${detail} in ${params.path}` : detail; + } + return ''; +} export class ProgressMonitor implements ProgressReporter { - private recentToolCalls: { name: string; timestamp: number }[] = []; + private recentToolCalls: { name: string; detail?: string; timestamp: number }[] = []; + private recentTextSnippets: { text: string; timestamp: number }[] = []; + private completedTasks: { subject: string; summary: string; timestamp: number }[] = []; private currentIteration = 0; private maxIterations = 0; private startTime = Date.now(); @@ -52,7 +73,12 @@ export class ProgressMonitor implements ProgressReporter { } onToolCall(toolName: string, params?: Record): void { - this.recentToolCalls.push({ name: toolName, timestamp: Date.now() }); + const detail = summarizeToolParams(toolName, params); + this.recentToolCalls.push({ + name: toolName, + detail: detail || undefined, + timestamp: Date.now(), + }); if (this.recentToolCalls.length > RING_BUFFER_MAX) { this.recentToolCalls.shift(); } @@ -60,9 +86,30 @@ export class ProgressMonitor implements ProgressReporter { } onText(content: string): void { + if (content.trim()) { + this.recentTextSnippets.push({ + text: content.slice(0, 200), + timestamp: Date.now(), + }); + if (this.recentTextSnippets.length > TEXT_SNIPPETS_MAX) { + this.recentTextSnippets.shift(); + } + } this.config.logWriter('INFO', 'Agent text output', { length: content.length }); } + onTaskCompleted(taskId: string, subject: string, summary: string): void { + this.completedTasks.push({ + subject, + summary: summary.slice(0, 300), + timestamp: Date.now(), + }); + if (this.completedTasks.length > COMPLETED_TASKS_MAX) { + this.completedTasks.shift(); + } + this.config.logWriter('INFO', 'Task completed', { taskId, subject }); + } + // ── Lifecycle ── start(): void { @@ -99,6 +146,8 @@ export class ProgressMonitor implements ProgressReporter { maxIterations: this.maxIterations, todos, recentToolCalls: [...this.recentToolCalls], + recentTextSnippets: [...this.recentTextSnippets], + completedTasks: [...this.completedTasks], }; let summary: string; diff --git a/src/backends/types.ts b/src/backends/types.ts index 3feeec93..4fd2e5cc 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -37,6 +37,7 @@ export interface ProgressReporter { onIteration(iteration: number, maxIterations: number): Promise; onToolCall(toolName: string, params?: Record): void; onText(content: string): void; + onTaskCompleted?(taskId: string, subject: string, summary: string): void; } /** diff --git a/src/github/client.ts b/src/github/client.ts index d3fe0b56..69b5dc75 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -257,29 +257,51 @@ export const githubClient = { }, async getCheckSuiteStatus(owner: string, repo: string, ref: string): Promise { - logger.debug('Fetching check runs for ref', { owner, repo, ref }); - const { data } = await getClient().checks.listForRef({ + logger.debug('Fetching workflow runs for ref', { owner, repo, ref }); + const client = getClient(); + + // Use Actions API (workflow runs + jobs) instead of Checks API, + // because fine-grained PATs cannot access the Checks API. + const { data: runsData } = await client.actions.listWorkflowRunsForRepo({ owner, repo, - ref, + head_sha: ref, per_page: 100, }); - const checkRuns = data.check_runs.map((cr) => ({ - name: cr.name, - status: cr.status, - conclusion: cr.conclusion, - })); + // Fetch jobs for each workflow run to get per-job granularity + const jobResults = await Promise.all( + runsData.workflow_runs.map((run) => + client.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: run.id, + per_page: 100, + }), + ), + ); - // All checks pass if every completed check has success/skipped/neutral conclusion - const allPassing = checkRuns.every( - (cr) => - cr.status === 'completed' && - (cr.conclusion === 'success' || cr.conclusion === 'skipped' || cr.conclusion === 'neutral'), + const checkRuns = jobResults.flatMap(({ data }) => + data.jobs.map((job) => ({ + name: job.name, + status: job.status, + conclusion: job.conclusion, + })), ); + // All checks pass if every completed check has success/skipped/neutral conclusion + const allPassing = + checkRuns.length > 0 && + checkRuns.every( + (cr) => + cr.status === 'completed' && + (cr.conclusion === 'success' || + cr.conclusion === 'skipped' || + cr.conclusion === 'neutral'), + ); + return { - totalCount: data.total_count, + totalCount: checkRuns.length, checkRuns, allPassing, }; diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index d9e048b7..ceb97a02 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -5,6 +5,10 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ query: vi.fn(), })); +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -19,7 +23,13 @@ import { ensureOnboardingFlag, resolveClaudeModel, } from '../../../src/backends/claude-code/index.js'; +import { + CLAUDE_CODE_MODELS, + CLAUDE_CODE_MODEL_IDS, + DEFAULT_CLAUDE_CODE_MODEL, +} from '../../../src/backends/claude-code/models.js'; import type { AgentBackendInput, ToolManifest } from '../../../src/backends/types.js'; +import { logger } from '../../../src/utils/logging.js'; const mockQuery = vi.mocked(query); @@ -137,9 +147,35 @@ describe('buildSystemPrompt', () => { }); }); +describe('CLAUDE_CODE_MODELS constants', () => { + it('contains three models', () => { + expect(CLAUDE_CODE_MODELS).toHaveLength(3); + }); + + it('has value/label pairs', () => { + for (const m of CLAUDE_CODE_MODELS) { + expect(m.value).toBeTruthy(); + expect(m.label).toBeTruthy(); + } + }); + + it('CLAUDE_CODE_MODEL_IDS matches model values', () => { + expect(CLAUDE_CODE_MODEL_IDS).toEqual(CLAUDE_CODE_MODELS.map((m) => m.value)); + }); + + it('DEFAULT_CLAUDE_CODE_MODEL is a known model ID', () => { + expect(CLAUDE_CODE_MODEL_IDS).toContain(DEFAULT_CLAUDE_CODE_MODEL); + }); +}); + describe('resolveClaudeModel', () => { - it('passes through claude-* models', () => { + it('passes through known Claude Code model IDs', () => { + expect(resolveClaudeModel('claude-opus-4-6')).toBe('claude-opus-4-6'); expect(resolveClaudeModel('claude-sonnet-4-5-20250929')).toBe('claude-sonnet-4-5-20250929'); + expect(resolveClaudeModel('claude-haiku-4-5-20251001')).toBe('claude-haiku-4-5-20251001'); + }); + + it('passes through other claude-* models', () => { expect(resolveClaudeModel('claude-opus-4-20250514')).toBe('claude-opus-4-20250514'); }); @@ -149,11 +185,26 @@ describe('resolveClaudeModel', () => { ); }); - it('falls back to sonnet for non-Claude models', () => { + it('falls back to default for non-Claude models', () => { expect(resolveClaudeModel('openrouter:google/gemini-3-flash-preview')).toBe( - 'claude-sonnet-4-5-20250929', + DEFAULT_CLAUDE_CODE_MODEL, ); - expect(resolveClaudeModel('gpt-4o')).toBe('claude-sonnet-4-5-20250929'); + expect(resolveClaudeModel('gpt-4o')).toBe(DEFAULT_CLAUDE_CODE_MODEL); + }); + + it('logs a warning when falling back', () => { + vi.mocked(logger.warn).mockClear(); + resolveClaudeModel('gpt-4o'); + expect(logger.warn).toHaveBeenCalledWith( + 'Non-Claude model configured for Claude Code backend, falling back to default', + { configured: 'gpt-4o', fallback: DEFAULT_CLAUDE_CODE_MODEL }, + ); + }); + + it('does not warn for valid Claude models', () => { + vi.mocked(logger.warn).mockClear(); + resolveClaudeModel('claude-sonnet-4-5-20250929'); + expect(logger.warn).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/github/client.test.ts b/tests/unit/github/client.test.ts index fe893223..e5e12cce 100644 --- a/tests/unit/github/client.test.ts +++ b/tests/unit/github/client.test.ts @@ -22,6 +22,11 @@ const mockChecks = { listForRef: vi.fn(), }; +const mockActions = { + listWorkflowRunsForRepo: vi.fn(), + listJobsForWorkflowRun: vi.fn(), +}; + const mockRepos = { getBranch: vi.fn(), }; @@ -35,6 +40,7 @@ vi.mock('@octokit/rest', () => ({ pulls: mockPulls, issues: mockIssues, checks: mockChecks, + actions: mockActions, repos: mockRepos, users: mockUsers, })), @@ -397,15 +403,26 @@ describe('githubClient', () => { }); describe('getCheckSuiteStatus', () => { + function mockWorkflowRuns( + runs: { id: number }[], + jobsMap: Record, + ) { + mockActions.listWorkflowRunsForRepo.mockResolvedValue({ + data: { workflow_runs: runs }, + }); + mockActions.listJobsForWorkflowRun.mockImplementation(({ run_id }: { run_id: number }) => { + return Promise.resolve({ + data: { jobs: jobsMap[run_id] ?? [] }, + }); + }); + } + it('returns status with all passing', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 2, - check_runs: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'success' }, - ], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test', status: 'completed', conclusion: 'success' }, + ], }); const result = await withGitHubToken('test-token', () => @@ -418,14 +435,11 @@ describe('githubClient', () => { }); it('returns allPassing false when some checks fail', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 2, - check_runs: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'failure' }, - ], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test', status: 'completed', conclusion: 'failure' }, + ], }); const result = await withGitHubToken('test-token', () => @@ -436,14 +450,11 @@ describe('githubClient', () => { }); it('treats skipped and neutral as passing', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 2, - check_runs: [ - { name: 'lint', status: 'completed', conclusion: 'skipped' }, - { name: 'test', status: 'completed', conclusion: 'neutral' }, - ], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [ + { name: 'lint', status: 'completed', conclusion: 'skipped' }, + { name: 'test', status: 'completed', conclusion: 'neutral' }, + ], }); const result = await withGitHubToken('test-token', () => @@ -454,11 +465,8 @@ describe('githubClient', () => { }); it('returns allPassing false when checks are still in_progress', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 1, - check_runs: [{ name: 'test', status: 'in_progress', conclusion: null }], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [{ name: 'test', status: 'in_progress', conclusion: null }], }); const result = await withGitHubToken('test-token', () => @@ -467,6 +475,32 @@ describe('githubClient', () => { expect(result.allPassing).toBe(false); }); + + it('returns allPassing false when no workflow runs exist', async () => { + mockWorkflowRuns([], {}); + + const result = await withGitHubToken('test-token', () => + githubClient.getCheckSuiteStatus('owner', 'repo', 'sha123'), + ); + + expect(result.allPassing).toBe(false); + expect(result.totalCount).toBe(0); + }); + + it('aggregates jobs across multiple workflow runs', async () => { + mockWorkflowRuns([{ id: 1 }, { id: 2 }], { + 1: [{ name: 'lint', status: 'completed', conclusion: 'success' }], + 2: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + + const result = await withGitHubToken('test-token', () => + githubClient.getCheckSuiteStatus('owner', 'repo', 'sha123'), + ); + + expect(result.allPassing).toBe(true); + expect(result.totalCount).toBe(2); + expect(result.checkRuns).toHaveLength(2); + }); }); describe('getPRDiff', () => { diff --git a/tools/seed-prompts.ts b/tools/seed-prompts.ts index e70261f1..93d4da70 100644 --- a/tools/seed-prompts.ts +++ b/tools/seed-prompts.ts @@ -34,23 +34,16 @@ async function seedTemplates() { for (const agentType of agentTypes) { const content = getRawTemplate(agentType); - // Check if a global config row already exists for this agent type - const [existing] = await db - .select({ id: agentConfigs.id }) - .from(agentConfigs) - .where( - and( - eq(agentConfigs.agentType, agentType), - isNull(agentConfigs.projectId), - isNull(agentConfigs.orgId), - ), - ); + // Update-first approach: try updating existing non-project row, insert if none affected. + // The unique constraint uq_agent_configs_global is on (agent_type) WHERE project_id IS NULL, + // so we match any row with this agent_type and no project (regardless of org_id). + const updated = await db + .update(agentConfigs) + .set({ prompt: content, updatedAt: new Date() }) + .where(and(eq(agentConfigs.agentType, agentType), isNull(agentConfigs.projectId))) + .returning({ id: agentConfigs.id }); - if (existing) { - await db - .update(agentConfigs) - .set({ prompt: content, updatedAt: new Date() }) - .where(eq(agentConfigs.id, existing.id)); + if (updated.length > 0) { console.log(` Updated: ${agentType}`); } else { await db.insert(agentConfigs).values({ diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 5f981653..eb7e51aa 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -1,3 +1,4 @@ +import { ModelField } from '@/components/settings/model-field.js'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; @@ -206,11 +207,11 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {
- setModel(e.target.value)} - placeholder="Optional" + onChange={setModel} + backend={agentBackend} />
diff --git a/web/src/components/settings/agent-config-form-dialog.tsx b/web/src/components/settings/agent-config-form-dialog.tsx index 3164111b..efe262e3 100644 --- a/web/src/components/settings/agent-config-form-dialog.tsx +++ b/web/src/components/settings/agent-config-form-dialog.tsx @@ -1,3 +1,4 @@ +import { ModelField } from '@/components/settings/model-field.js'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; @@ -99,12 +100,7 @@ export function AgentConfigFormDialog({ open, onOpenChange, config }: AgentConfi
- setModel(e.target.value)} - placeholder="Optional" - /> +
diff --git a/web/src/components/settings/defaults-form.tsx b/web/src/components/settings/defaults-form.tsx index 3951ced8..a639c236 100644 --- a/web/src/components/settings/defaults-form.tsx +++ b/web/src/components/settings/defaults-form.tsx @@ -1,3 +1,4 @@ +import { ModelField } from '@/components/settings/model-field.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { @@ -67,12 +68,7 @@ export function DefaultsForm() {
- setModel(e.target.value)} - placeholder="e.g. claude-sonnet-4-5-20250929" - /> +
diff --git a/web/src/components/settings/model-field.tsx b/web/src/components/settings/model-field.tsx new file mode 100644 index 00000000..54079a48 --- /dev/null +++ b/web/src/components/settings/model-field.tsx @@ -0,0 +1,48 @@ +import { Input } from '@/components/ui/input.js'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select.js'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; + +interface ModelFieldProps { + value: string; + onChange: (value: string) => void; + backend: string; + id?: string; +} + +export function ModelField({ value, onChange, backend, id }: ModelFieldProps) { + const modelsQuery = useQuery(trpc.agentConfigs.claudeCodeModels.queryOptions()); + + if (backend === 'claude-code') { + return ( + + ); + } + + return ( + onChange(e.target.value)} + placeholder="Optional" + /> + ); +}