From fbd9305f3da52866a0ad58a3e26c38b403445d09 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 16 Apr 2026 14:19:55 -0700 Subject: [PATCH 1/3] feat(settings): /settings page skeleton + agent config (#554) Adds Phase 5.1 settings UI: - Backend: settings_v2 router with GET/PUT /api/v2/settings, AgentSettings models, EnvironmentConfig.max_cost_usd + agent_type_models fields persisted to .codeframe/config.yaml. - Frontend: /settings page with Tabs (Agent functional; API Keys / PROOF9 / Workspace stubs), settingsApi client, Settings sidebar entry. Closes #554 --- codeframe/core/config.py | 4 + codeframe/ui/models.py | 42 +++ codeframe/ui/routers/settings_v2.py | 106 ++++++ codeframe/ui/server.py | 2 + tests/ui/test_settings_v2.py | 201 +++++++++++ .../components/settings/SettingsPage.test.tsx | 169 +++++++++ web-ui/src/app/settings/page.tsx | 330 ++++++++++++++++++ web-ui/src/components/layout/AppSidebar.tsx | 2 + web-ui/src/lib/api.ts | 21 ++ web-ui/src/types/index.ts | 14 + 10 files changed, 891 insertions(+) create mode 100644 codeframe/ui/routers/settings_v2.py create mode 100644 tests/ui/test_settings_v2.py create mode 100644 web-ui/src/__tests__/components/settings/SettingsPage.test.tsx create mode 100644 web-ui/src/app/settings/page.tsx diff --git a/codeframe/core/config.py b/codeframe/core/config.py index da475fe8..87c4a637 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -163,6 +163,10 @@ class EnvironmentConfig: # LLM provider config (workspace-level default; overridden by env vars and CLI flags) llm: Optional[LLMConfig] = None + # Settings page (issue #554) — UI-managed agent settings + max_cost_usd: Optional[float] = None + agent_type_models: dict[str, str] = dataclass_field(default_factory=dict) + def validate(self) -> list[str]: """Validate configuration values. diff --git a/codeframe/ui/models.py b/codeframe/ui/models.py index bd5e4c69..058d32e9 100644 --- a/codeframe/ui/models.py +++ b/codeframe/ui/models.py @@ -901,3 +901,45 @@ class ErrorResponse(BaseModel): ) detail: str = Field(..., description="Error message describing what went wrong") + + +# ============================================================================ +# Settings (issue #554) +# ============================================================================ + + +AGENT_TYPES = ("claude_code", "codex", "opencode", "react") + + +class AgentTypeModelConfig(BaseModel): + """Default model for a single agent type.""" + + agent_type: str = Field( + ..., description="One of: claude_code, codex, opencode, react" + ) + default_model: str = Field( + default="", + description="Model identifier (e.g. 'claude-opus-4', 'gpt-4o'); empty string means unset", + ) + + +class AgentSettings(BaseModel): + """Agent settings shared by GET response and PUT request.""" + + agent_models: List[AgentTypeModelConfig] = Field( + ..., description="Default model per agent type" + ) + max_turns: int = Field( + default=20, gt=0, description="Maximum turns per task (must be > 0)" + ) + max_cost_usd: Optional[float] = Field( + default=None, ge=0, description="Maximum cost per task in USD" + ) + + +class AgentSettingsResponse(AgentSettings): + """Response shape for GET /api/v2/settings.""" + + +class UpdateAgentSettingsRequest(AgentSettings): + """Request shape for PUT /api/v2/settings.""" diff --git a/codeframe/ui/routers/settings_v2.py b/codeframe/ui/routers/settings_v2.py new file mode 100644 index 00000000..61bc89b8 --- /dev/null +++ b/codeframe/ui/routers/settings_v2.py @@ -0,0 +1,106 @@ +"""V2 Settings router — agent settings managed via the web UI. + +Reads/writes a flat AgentSettings shape persisted in +.codeframe/config.yaml via load_environment_config / save_environment_config. + +Routes: + GET /api/v2/settings - Load agent settings (returns defaults if missing) + PUT /api/v2/settings - Save agent settings (merges into existing config) +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request + +from codeframe.core.config import ( + EnvironmentConfig, + load_environment_config, + save_environment_config, +) +from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard +from codeframe.ui.dependencies import get_v2_workspace +from codeframe.ui.models import ( + AGENT_TYPES, + AgentSettingsResponse, + AgentTypeModelConfig, + UpdateAgentSettingsRequest, +) +from codeframe.ui.response_models import ErrorCodes, api_error + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v2/settings", tags=["settings"]) + + +def _config_to_response(config: EnvironmentConfig) -> AgentSettingsResponse: + """Map an EnvironmentConfig to the flat AgentSettings response shape.""" + saved_models = config.agent_type_models or {} + agent_models = [ + AgentTypeModelConfig( + agent_type=agent_type, + default_model=saved_models.get(agent_type, ""), + ) + for agent_type in AGENT_TYPES + ] + return AgentSettingsResponse( + agent_models=agent_models, + max_turns=config.agent_budget.max_iterations, + max_cost_usd=config.max_cost_usd, + ) + + +@router.get("", response_model=AgentSettingsResponse) +@rate_limit_standard() +async def get_settings( + request: Request, + workspace: Workspace = Depends(get_v2_workspace), +) -> AgentSettingsResponse: + """Load agent settings for the workspace. + + Returns defaults if no .codeframe/config.yaml exists. + """ + try: + config = load_environment_config(workspace.repo_path) or EnvironmentConfig() + return _config_to_response(config) + except Exception as e: + logger.error(f"Failed to load settings: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=api_error( + "Failed to load settings", ErrorCodes.EXECUTION_FAILED, str(e) + ), + ) + + +@router.put("", response_model=AgentSettingsResponse) +@rate_limit_standard() +async def update_settings( + request: Request, + body: UpdateAgentSettingsRequest, + workspace: Workspace = Depends(get_v2_workspace), +) -> AgentSettingsResponse: + """Save agent settings. + + Merges into existing EnvironmentConfig so unrelated fields + (package_manager, test_framework, etc.) are preserved. + """ + try: + config = load_environment_config(workspace.repo_path) or EnvironmentConfig() + + config.agent_budget.max_iterations = body.max_turns + config.max_cost_usd = body.max_cost_usd + config.agent_type_models = { + entry.agent_type: entry.default_model for entry in body.agent_models + } + + save_environment_config(workspace.repo_path, config) + return _config_to_response(config) + except Exception as e: + logger.error(f"Failed to save settings: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=api_error( + "Failed to save settings", ErrorCodes.EXECUTION_FAILED, str(e) + ), + ) diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index 081d6065..e6287277 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -35,6 +35,7 @@ review_v2, schedule_v2, session_chat_ws, + settings_v2, terminal_ws, streaming_v2, tasks_v2, @@ -496,6 +497,7 @@ async def test_broadcast(message: dict, project_id: int = None): app.include_router(proof_v2.router) # /api/v2/proof app.include_router(review_v2.router) # /api/v2/review app.include_router(schedule_v2.router) # /api/v2/schedule +app.include_router(settings_v2.router) # /api/v2/settings app.include_router(streaming_v2.router) # /api/v2/tasks/{id}/stream (SSE) app.include_router(tasks_v2.router) # /api/v2/tasks app.include_router(templates_v2.router) # /api/v2/templates diff --git a/tests/ui/test_settings_v2.py b/tests/ui/test_settings_v2.py new file mode 100644 index 00000000..ddb6ad69 --- /dev/null +++ b/tests/ui/test_settings_v2.py @@ -0,0 +1,201 @@ +"""Integration tests for settings_v2 router. + +Tests: +- GET /api/v2/settings returns defaults for empty workspace +- PUT /api/v2/settings persists new settings +- GET after PUT returns the saved settings (round-trip) +- PUT merges into existing EnvironmentConfig without losing other fields +""" + +import shutil +import tempfile +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def test_workspace(): + """Create a temporary workspace for testing.""" + temp_dir = Path(tempfile.mkdtemp()) + workspace_path = temp_dir / "test_workspace" + workspace_path.mkdir(parents=True, exist_ok=True) + + from codeframe.core.workspace import create_or_load_workspace + + workspace = create_or_load_workspace(workspace_path) + + yield workspace + + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def test_client(test_workspace): + """Create a FastAPI TestClient with settings_v2 router and workspace override.""" + from codeframe.ui.routers import settings_v2 + from codeframe.ui.dependencies import get_v2_workspace + + app = FastAPI() + app.include_router(settings_v2.router) + + def get_test_workspace(): + return test_workspace + + app.dependency_overrides[get_v2_workspace] = get_test_workspace + + client = TestClient(app) + client.workspace = test_workspace + yield client + + +class TestSettingsV2Get: + """Tests for GET /api/v2/settings.""" + + def test_returns_defaults_when_no_config(self, test_client): + """GET returns default settings for a workspace with no config file.""" + response = test_client.get("/api/v2/settings") + assert response.status_code == 200 + data = response.json() + + # Maps to EnvironmentConfig.agent_budget.max_iterations (default 100) + assert data["max_turns"] == 100 + assert data["max_cost_usd"] is None + + agent_types = {entry["agent_type"] for entry in data["agent_models"]} + assert agent_types == {"claude_code", "codex", "opencode", "react"} + + for entry in data["agent_models"]: + assert entry["default_model"] == "" + + def test_returns_existing_config(self, test_client, test_workspace): + """GET returns saved settings from .codeframe/config.yaml.""" + from codeframe.core.config import ( + EnvironmentConfig, + save_environment_config, + ) + + config = EnvironmentConfig() + config.max_cost_usd = 5.50 + config.agent_type_models = {"claude_code": "claude-opus-4"} + config.agent_budget.max_iterations = 42 + save_environment_config(test_workspace.repo_path, config) + + response = test_client.get("/api/v2/settings") + assert response.status_code == 200 + data = response.json() + + assert data["max_turns"] == 42 + assert data["max_cost_usd"] == 5.50 + cc_entry = next( + e for e in data["agent_models"] if e["agent_type"] == "claude_code" + ) + assert cc_entry["default_model"] == "claude-opus-4" + + +class TestSettingsV2Put: + """Tests for PUT /api/v2/settings.""" + + def test_put_persists_settings(self, test_client, test_workspace): + """PUT saves settings to .codeframe/config.yaml.""" + body = { + "agent_models": [ + {"agent_type": "claude_code", "default_model": "claude-sonnet-4"}, + {"agent_type": "codex", "default_model": "gpt-4o"}, + {"agent_type": "opencode", "default_model": ""}, + {"agent_type": "react", "default_model": "claude-opus-4"}, + ], + "max_turns": 30, + "max_cost_usd": 10.0, + } + + response = test_client.put("/api/v2/settings", json=body) + assert response.status_code == 200 + data = response.json() + assert data["max_turns"] == 30 + assert data["max_cost_usd"] == 10.0 + + # Verify persisted to disk + from codeframe.core.config import load_environment_config + + loaded = load_environment_config(test_workspace.repo_path) + assert loaded is not None + assert loaded.agent_budget.max_iterations == 30 + assert loaded.max_cost_usd == 10.0 + assert loaded.agent_type_models["claude_code"] == "claude-sonnet-4" + assert loaded.agent_type_models["codex"] == "gpt-4o" + assert loaded.agent_type_models["react"] == "claude-opus-4" + + def test_put_round_trip(self, test_client): + """GET after PUT returns the saved settings.""" + body = { + "agent_models": [ + {"agent_type": "claude_code", "default_model": "claude-opus-4"}, + {"agent_type": "codex", "default_model": ""}, + {"agent_type": "opencode", "default_model": ""}, + {"agent_type": "react", "default_model": ""}, + ], + "max_turns": 25, + "max_cost_usd": 7.5, + } + put_resp = test_client.put("/api/v2/settings", json=body) + assert put_resp.status_code == 200 + + get_resp = test_client.get("/api/v2/settings") + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["max_turns"] == 25 + assert data["max_cost_usd"] == 7.5 + cc = next(e for e in data["agent_models"] if e["agent_type"] == "claude_code") + assert cc["default_model"] == "claude-opus-4" + + def test_put_preserves_unrelated_config(self, test_client, test_workspace): + """PUT does not destroy other EnvironmentConfig fields like package_manager.""" + from codeframe.core.config import ( + EnvironmentConfig, + save_environment_config, + load_environment_config, + ) + + # Pre-existing config with non-default fields + existing = EnvironmentConfig() + existing.package_manager = "poetry" + existing.test_framework = "jest" + save_environment_config(test_workspace.repo_path, existing) + + body = { + "agent_models": [ + {"agent_type": "claude_code", "default_model": "claude-opus-4"}, + {"agent_type": "codex", "default_model": ""}, + {"agent_type": "opencode", "default_model": ""}, + {"agent_type": "react", "default_model": ""}, + ], + "max_turns": 50, + "max_cost_usd": None, + } + response = test_client.put("/api/v2/settings", json=body) + assert response.status_code == 200 + + loaded = load_environment_config(test_workspace.repo_path) + assert loaded.package_manager == "poetry" + assert loaded.test_framework == "jest" + assert loaded.agent_budget.max_iterations == 50 + + def test_put_validates_max_turns_positive(self, test_client): + """PUT rejects max_turns <= 0.""" + body = { + "agent_models": [ + {"agent_type": "claude_code", "default_model": ""}, + {"agent_type": "codex", "default_model": ""}, + {"agent_type": "opencode", "default_model": ""}, + {"agent_type": "react", "default_model": ""}, + ], + "max_turns": 0, + "max_cost_usd": None, + } + response = test_client.put("/api/v2/settings", json=body) + assert response.status_code == 422 diff --git a/web-ui/src/__tests__/components/settings/SettingsPage.test.tsx b/web-ui/src/__tests__/components/settings/SettingsPage.test.tsx new file mode 100644 index 00000000..c9d9df7a --- /dev/null +++ b/web-ui/src/__tests__/components/settings/SettingsPage.test.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import useSWR from 'swr'; +import { toast } from 'sonner'; + +import SettingsPage from '@/app/settings/page'; +import { settingsApi } from '@/lib/api'; +import * as storage from '@/lib/workspace-storage'; +import type { AgentSettings } from '@/types'; + +// ── Mocks ──────────────────────────────────────────────────────────────── + +jest.mock('swr'); +jest.mock('@/lib/workspace-storage', () => ({ + getSelectedWorkspacePath: jest.fn(), +})); +jest.mock('@/lib/api', () => ({ + settingsApi: { + get: jest.fn(), + update: jest.fn(), + }, +})); +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('next/link', () => { + const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ); + MockLink.displayName = 'MockLink'; + return MockLink; +}); + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockGetWorkspace = storage.getSelectedWorkspacePath as jest.MockedFunction< + typeof storage.getSelectedWorkspacePath +>; +const mockUpdate = settingsApi.update as jest.MockedFunction; + +// ── Helpers ────────────────────────────────────────────────────────────── + +const WORKSPACE = '/home/user/project'; + +const SAMPLE_SETTINGS: AgentSettings = { + agent_models: [ + { agent_type: 'claude_code', default_model: 'claude-opus-4' }, + { agent_type: 'codex', default_model: '' }, + { agent_type: 'opencode', default_model: '' }, + { agent_type: 'react', default_model: '' }, + ], + max_turns: 25, + max_cost_usd: 5.0, +}; + +function mockSWR(data: AgentSettings | undefined, mutate = jest.fn()) { + mockUseSWR.mockReturnValue({ + data, + error: undefined, + isLoading: data === undefined, + mutate, + } as unknown as ReturnType); + return mutate; +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe('SettingsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('without workspace', () => { + it('shows workspace prompt when no workspace selected', () => { + mockGetWorkspace.mockReturnValue(null); + mockSWR(undefined); + render(); + expect(screen.getByText(/No workspace selected/i)).toBeInTheDocument(); + }); + }); + + describe('with workspace', () => { + beforeEach(() => { + mockGetWorkspace.mockReturnValue(WORKSPACE); + }); + + it('renders all four tabs', () => { + mockSWR(SAMPLE_SETTINGS); + render(); + expect(screen.getByRole('tab', { name: 'Agent' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'API Keys' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'PROOF9' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Workspace' })).toBeInTheDocument(); + }); + + it('loads settings into form on mount', async () => { + mockSWR(SAMPLE_SETTINGS); + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/Max turns per task/i)).toHaveValue(25); + }); + expect(screen.getByLabelText(/Max cost per task/i)).toHaveValue(5); + }); + + it('saves edited settings via the API', async () => { + const mutate = mockSWR(SAMPLE_SETTINGS); + mockUpdate.mockResolvedValue({ ...SAMPLE_SETTINGS, max_turns: 30 }); + + render(); + + const maxTurnsInput = await screen.findByLabelText(/Max turns per task/i); + fireEvent.change(maxTurnsInput, { target: { value: '30' } }); + + const saveButton = screen.getByRole('button', { name: /Save/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + expect(mockUpdate).toHaveBeenCalledWith( + WORKSPACE, + expect.objectContaining({ max_turns: 30 }) + ); + expect(toast.success).toHaveBeenCalledWith('Settings saved'); + expect(mutate).toHaveBeenCalled(); + }); + + it('discards changes back to last loaded data', async () => { + mockSWR(SAMPLE_SETTINGS); + render(); + + const maxTurnsInput = await screen.findByLabelText(/Max turns per task/i); + fireEvent.change(maxTurnsInput, { target: { value: '99' } }); + expect(maxTurnsInput).toHaveValue(99); + + const discardButton = screen.getByRole('button', { name: /Discard/i }); + fireEvent.click(discardButton); + + await waitFor(() => { + expect(maxTurnsInput).toHaveValue(25); + }); + expect(toast.info).toHaveBeenCalledWith('Changes discarded'); + }); + + it('exposes triggers for the three stub tabs', () => { + mockSWR(SAMPLE_SETTINGS); + render(); + + // Stub tabs are present even though their panels mount lazily in radix. + ['API Keys', 'PROOF9', 'Workspace'].forEach((name) => { + expect(screen.getByRole('tab', { name })).toBeInTheDocument(); + }); + }); + + it('shows error message when SWR errors', () => { + mockUseSWR.mockReturnValue({ + data: undefined, + error: new Error('boom'), + isLoading: false, + mutate: jest.fn(), + } as unknown as ReturnType); + render(); + expect(screen.getByText(/Failed to load settings/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/web-ui/src/app/settings/page.tsx b/web-ui/src/app/settings/page.tsx new file mode 100644 index 00000000..95e828da --- /dev/null +++ b/web-ui/src/app/settings/page.tsx @@ -0,0 +1,330 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import useSWR from 'swr'; +import { toast } from 'sonner'; + +import { settingsApi } from '@/lib/api'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import type { AgentSettings, AgentTypeKey, ApiError } from '@/types'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const AGENT_TYPE_LABELS: Record = { + claude_code: 'Claude Code', + codex: 'Codex', + opencode: 'OpenCode', + react: 'ReAct', +}; + +const MODEL_OPTIONS_BY_AGENT: Record = { + claude_code: ['claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4'], + codex: ['gpt-4o', 'gpt-4o-mini', 'o3', 'o3-mini'], + opencode: ['claude-opus-4', 'claude-sonnet-4', 'gpt-4o'], + react: ['claude-opus-4', 'claude-sonnet-4', 'gpt-4o', 'qwen2.5-coder:7b'], +}; + +const UNSET_MODEL_VALUE = '__unset__'; + +export default function SettingsPage() { + const [workspacePath, setWorkspacePath] = useState(null); + const [workspaceReady, setWorkspaceReady] = useState(false); + + useEffect(() => { + setWorkspacePath(getSelectedWorkspacePath()); + setWorkspaceReady(true); + }, []); + + const swrKey = workspacePath ? ['settings', workspacePath] : null; + const { data, error, isLoading, mutate } = useSWR( + swrKey, + () => settingsApi.get(workspacePath!) + ); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (data) { + setDraft({ + ...data, + agent_models: data.agent_models.map((m) => ({ ...m })), + }); + } + }, [data]); + + if (!workspaceReady) return null; + + if (!workspacePath) { + return ( +
+
+

Settings

+
+

+ No workspace selected. Use the sidebar to return to{' '} + + Workspace + {' '} + and select a project. +

+
+
+
+ ); + } + + const updateAgentModel = (agentType: AgentTypeKey, model: string) => { + if (!draft) return; + setDraft({ + ...draft, + agent_models: draft.agent_models.map((entry) => + entry.agent_type === agentType + ? { ...entry, default_model: model } + : entry + ), + }); + }; + + const handleSave = async () => { + if (!draft || !workspacePath) return; + setSaving(true); + try { + const saved = await settingsApi.update(workspacePath, draft); + await mutate(saved, { revalidate: false }); + toast.success('Settings saved'); + } catch (err) { + const apiError = err as ApiError; + toast.error(apiError.detail || 'Failed to save settings'); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + if (!data) return; + setDraft({ + ...data, + agent_models: data.agent_models.map((m) => ({ ...m })), + }); + toast.info('Changes discarded'); + }; + + return ( +
+
+

Settings

+ + + + Agent + API Keys + PROOF9 + Workspace + + + +
+

Agent Settings

+

+ Default model per agent type, plus per-task limits. +

+ + {isLoading && !draft ? ( +

Loading…

+ ) : error ? ( +

+ Failed to load settings. +

+ ) : draft ? ( + + setDraft({ ...draft, max_turns: v }) + } + onMaxCostChange={(v) => + setDraft({ ...draft, max_cost_usd: v }) + } + onSave={handleSave} + onDiscard={handleDiscard} + saving={saving} + /> + ) : ( + + )} +
+
+ + +
+

API Keys

+ +
+
+ + +
+

PROOF9

+ +
+
+ + +
+

Workspace

+ +
+
+
+
+
+ ); +} + +function ComingSoon() { + return

Coming soon

; +} + +interface AgentSettingsFormProps { + draft: AgentSettings; + onModelChange: (agentType: AgentTypeKey, model: string) => void; + onMaxTurnsChange: (value: number) => void; + onMaxCostChange: (value: number | null) => void; + onSave: () => void; + onDiscard: () => void; + saving: boolean; +} + +function AgentSettingsForm({ + draft, + onModelChange, + onMaxTurnsChange, + onMaxCostChange, + onSave, + onDiscard, + saving, +}: AgentSettingsFormProps) { + return ( +
+
+

Default model per agent type

+
+ {draft.agent_models.map((entry) => { + const label = AGENT_TYPE_LABELS[entry.agent_type as AgentTypeKey] ?? entry.agent_type; + const options = MODEL_OPTIONS_BY_AGENT[entry.agent_type as AgentTypeKey] ?? []; + const selectValue = entry.default_model || UNSET_MODEL_VALUE; + return ( +
+ + +
+ ); + })} +
+
+ +
+
+ + { + const v = Number.parseInt(e.target.value, 10); + if (!Number.isNaN(v)) onMaxTurnsChange(v); + }} + /> +
+
+ + { + const raw = e.target.value; + if (raw === '') { + onMaxCostChange(null); + return; + } + const v = Number.parseFloat(raw); + if (!Number.isNaN(v)) onMaxCostChange(v); + }} + /> +
+
+ +
+ + +
+
+ ); +} diff --git a/web-ui/src/components/layout/AppSidebar.tsx b/web-ui/src/components/layout/AppSidebar.tsx index f56c5203..72289b60 100644 --- a/web-ui/src/components/layout/AppSidebar.tsx +++ b/web-ui/src/components/layout/AppSidebar.tsx @@ -14,6 +14,7 @@ import { GitBranchIcon, CheckmarkCircle01Icon, Add01Icon, + Settings01Icon, } from '@hugeicons/react'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; import { blockersApi, sessionsApi } from '@/lib/api'; @@ -36,6 +37,7 @@ const NAV_ITEMS: NavItem[] = [ { href: '/blockers', label: 'Blockers', icon: Alert02Icon, enabled: true }, { href: '/review', label: 'Review', icon: GitBranchIcon, enabled: true }, { href: '/proof', label: 'Proof', icon: CheckmarkCircle01Icon, enabled: true }, + { href: '/settings', label: 'Settings', icon: Settings01Icon, enabled: true }, ]; export function AppSidebar() { diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index ab1d0992..5ceba938 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -59,6 +59,7 @@ import type { SessionListResponse, SessionCreateRequest, ChatMessage, + AgentSettings, } from '@/types'; // FastAPI validation error format @@ -802,4 +803,24 @@ export const sessionsApi = { }, }; +// Settings API methods (issue #554) +export const settingsApi = { + get: async (workspacePath: string): Promise => { + const response = await api.get('/api/v2/settings', { + params: { workspace_path: workspacePath }, + }); + return response.data; + }, + + update: async ( + workspacePath: string, + body: AgentSettings + ): Promise => { + const response = await api.put('/api/v2/settings', body, { + params: { workspace_path: workspacePath }, + }); + return response.data; + }, +}; + export default api; diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index c06c7ddb..75885667 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -560,3 +560,17 @@ export interface AgentChatState { // Proof evidence table sort types export type ProofEvidenceSortCol = 'gate' | 'result' | 'run_id' | 'timestamp' | 'artifact'; export type SortDir = 'asc' | 'desc'; + +// Settings types — mirrors codeframe/ui/models.py +export type AgentTypeKey = 'claude_code' | 'codex' | 'opencode' | 'react'; + +export interface AgentTypeModelConfig { + agent_type: AgentTypeKey; + default_model: string; +} + +export interface AgentSettings { + agent_models: AgentTypeModelConfig[]; + max_turns: number; + max_cost_usd: number | null; +} From 3cf0b8be8f1d7e17ea9256753e739743f5016741 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 16 Apr 2026 14:32:27 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(settings):=20address=20PR=20review=20(#?= =?UTF-8?q?587)=20=E2=80=94=20input=20safety=20+=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentType is now a Literal in the Pydantic model so unknown agent_type values fail validation up front (claude #2, coderabbit #2). - PUT no longer persists empty default_model strings — those are semantically equivalent to "key not present" via the response builder and only added YAML noise (claude #3). - Null-guard for legacy YAML where agent_budget was hand-removed/nulled (claude #1) — both GET (defaults) and PUT (re-create budget) paths. - EnvironmentConfig.validate() now rejects negative max_cost_usd and unknown agent_type_models keys (coderabbit #1). - AgentSettings.max_turns Pydantic default raised from 20 → 100 to match EnvironmentConfig.agent_budget.max_iterations default (coderabbit #3). --- codeframe/core/config.py | 11 ++++++ codeframe/ui/models.py | 15 ++++--- codeframe/ui/routers/settings_v2.py | 14 ++++++- tests/core/test_environment_config.py | 14 +++++++ tests/ui/test_settings_v2.py | 56 +++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/codeframe/core/config.py b/codeframe/core/config.py index 87c4a637..453bfe7c 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -221,6 +221,17 @@ def validate(self) -> list[str]: if budget.stall_timeout_s < 0: errors.append("agent_budget.stall_timeout_s must be >= 0 (0 = disabled)") + if self.max_cost_usd is not None and self.max_cost_usd < 0: + errors.append("max_cost_usd must be >= 0") + + valid_agent_types = {"claude_code", "codex", "opencode", "react"} + invalid_agent_types = sorted(set(self.agent_type_models) - valid_agent_types) + if invalid_agent_types: + errors.append( + "agent_type_models contains unsupported agent types: " + + ", ".join(invalid_agent_types) + ) + return errors def get_install_command(self, package: str) -> str: diff --git a/codeframe/ui/models.py b/codeframe/ui/models.py index 058d32e9..19d34892 100644 --- a/codeframe/ui/models.py +++ b/codeframe/ui/models.py @@ -6,7 +6,7 @@ from enum import Enum from pydantic import BaseModel, Field, model_validator, ConfigDict -from typing import Optional, List +from typing import Literal, Optional, List class SourceType(str, Enum): @@ -908,13 +908,14 @@ class ErrorResponse(BaseModel): # ============================================================================ -AGENT_TYPES = ("claude_code", "codex", "opencode", "react") +AgentType = Literal["claude_code", "codex", "opencode", "react"] +AGENT_TYPES: tuple[AgentType, ...] = ("claude_code", "codex", "opencode", "react") class AgentTypeModelConfig(BaseModel): """Default model for a single agent type.""" - agent_type: str = Field( + agent_type: AgentType = Field( ..., description="One of: claude_code, codex, opencode, react" ) default_model: str = Field( @@ -924,13 +925,17 @@ class AgentTypeModelConfig(BaseModel): class AgentSettings(BaseModel): - """Agent settings shared by GET response and PUT request.""" + """Agent settings shared by GET response and PUT request. + + Defaults match `EnvironmentConfig.agent_budget.max_iterations` so that a + fresh workspace round-trips its real defaults through GET. + """ agent_models: List[AgentTypeModelConfig] = Field( ..., description="Default model per agent type" ) max_turns: int = Field( - default=20, gt=0, description="Maximum turns per task (must be > 0)" + default=100, gt=0, description="Maximum turns per task (must be > 0)" ) max_cost_usd: Optional[float] = Field( default=None, ge=0, description="Maximum cost per task in USD" diff --git a/codeframe/ui/routers/settings_v2.py b/codeframe/ui/routers/settings_v2.py index 61bc89b8..5da52920 100644 --- a/codeframe/ui/routers/settings_v2.py +++ b/codeframe/ui/routers/settings_v2.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from codeframe.core.config import ( + AgentBudgetConfig, EnvironmentConfig, load_environment_config, save_environment_config, @@ -43,9 +44,11 @@ def _config_to_response(config: EnvironmentConfig) -> AgentSettingsResponse: ) for agent_type in AGENT_TYPES ] + # Guard against legacy YAML where agent_budget may have been removed/nulled. + budget = config.agent_budget or AgentBudgetConfig() return AgentSettingsResponse( agent_models=agent_models, - max_turns=config.agent_budget.max_iterations, + max_turns=budget.max_iterations, max_cost_usd=config.max_cost_usd, ) @@ -87,11 +90,18 @@ async def update_settings( """ try: config = load_environment_config(workspace.repo_path) or EnvironmentConfig() + if config.agent_budget is None: + config.agent_budget = AgentBudgetConfig() config.agent_budget.max_iterations = body.max_turns config.max_cost_usd = body.max_cost_usd + # Skip empty model strings — they're equivalent to "key not present" + # in _config_to_response, so persisting them just adds yaml noise. + # AgentType Literal in the model already rejects unknown agent_type values. config.agent_type_models = { - entry.agent_type: entry.default_model for entry in body.agent_models + entry.agent_type: entry.default_model + for entry in body.agent_models + if entry.default_model } save_environment_config(workspace.repo_path, config) diff --git a/tests/core/test_environment_config.py b/tests/core/test_environment_config.py index 6c1e319a..eca618cb 100644 --- a/tests/core/test_environment_config.py +++ b/tests/core/test_environment_config.py @@ -108,6 +108,20 @@ def test_multiple_validation_errors(self): errors = config.validate() assert len(errors) == 3 + def test_invalid_max_cost_usd(self): + """Negative max_cost_usd is rejected.""" + config = EnvironmentConfig(max_cost_usd=-1.0) + errors = config.validate() + assert any("max_cost_usd" in e for e in errors) + + def test_invalid_agent_type_in_models(self): + """Unknown agent_type keys in agent_type_models are rejected.""" + config = EnvironmentConfig( + agent_type_models={"claude_code": "claude-opus-4", "evil_bot": "x"} + ) + errors = config.validate() + assert any("evil_bot" in e for e in errors) + class TestEnvironmentConfigCommands: """Tests for command generation.""" diff --git a/tests/ui/test_settings_v2.py b/tests/ui/test_settings_v2.py index ddb6ad69..e1cf3452 100644 --- a/tests/ui/test_settings_v2.py +++ b/tests/ui/test_settings_v2.py @@ -199,3 +199,59 @@ def test_put_validates_max_turns_positive(self, test_client): } response = test_client.put("/api/v2/settings", json=body) assert response.status_code == 422 + + def test_put_rejects_unknown_agent_type(self, test_client): + """Pydantic Literal rejects agent_types outside the supported set.""" + body = { + "agent_models": [ + {"agent_type": "evil_bot", "default_model": "anything"}, + ], + "max_turns": 20, + "max_cost_usd": None, + } + response = test_client.put("/api/v2/settings", json=body) + assert response.status_code == 422 + + def test_put_skips_empty_model_strings(self, test_client, test_workspace): + """PUT does not persist empty default_model entries to the YAML.""" + body = { + "agent_models": [ + {"agent_type": "claude_code", "default_model": "claude-opus-4"}, + {"agent_type": "codex", "default_model": ""}, + {"agent_type": "opencode", "default_model": ""}, + {"agent_type": "react", "default_model": ""}, + ], + "max_turns": 20, + "max_cost_usd": None, + } + response = test_client.put("/api/v2/settings", json=body) + assert response.status_code == 200 + + from codeframe.core.config import load_environment_config + + loaded = load_environment_config(test_workspace.repo_path) + # Only the non-empty entry is persisted. + assert loaded.agent_type_models == {"claude_code": "claude-opus-4"} + + def test_get_handles_null_agent_budget(self, test_client, test_workspace): + """GET tolerates legacy YAML that drops or nulls agent_budget.""" + from codeframe.core.config import ( + EnvironmentConfig, + save_environment_config, + ) + + config = EnvironmentConfig() + save_environment_config(test_workspace.repo_path, config) + # Manually break agent_budget to simulate a hand-edited legacy file. + import yaml + + config_path = test_workspace.repo_path / ".codeframe" / "config.yaml" + with open(config_path) as f: + data = yaml.safe_load(f) + data["agent_budget"] = None + with open(config_path, "w") as f: + yaml.dump(data, f) + + response = test_client.get("/api/v2/settings") + assert response.status_code == 200 + assert response.json()["max_turns"] == 100 # AgentBudgetConfig default From 0a2e60a312d1335526d66a310947e41b5b6c0e7b Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 16 Apr 2026 14:37:20 -0700 Subject: [PATCH 3/3] docs(settings): note /settings page shipped (#554) --- CLAUDE.md | 2 +- docs/PRODUCT_ROADMAP.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 23342a26..f3fb6935 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ At all times: `codeframe --help` works, Golden Path stubs can run, no breaking r ### Phase 3 Web UI (actively developed — not legacy) Next.js 16 App Router, TypeScript, Shadcn/UI, Tailwind CSS, Hugeicons, XTerm.js, WebSocket + SSE. -Shipped pages: `/`, `/prd`, `/tasks`, `/execution`, `/execution/[taskId]`, `/blockers`, `/proof`, `/proof/[req_id]`, `/review`, `/sessions`, `/sessions/[id]`. +Shipped pages: `/`, `/prd`, `/tasks`, `/execution`, `/execution/[taskId]`, `/blockers`, `/proof`, `/proof/[req_id]`, `/review`, `/sessions`, `/sessions/[id]`, `/settings`. Testing: `cd web-ui && npm test` must pass; `npm run build` must succeed. The `frontend-tests` CI job enforces this on every PR. diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md index 2b377235..078c6a78 100644 --- a/docs/PRODUCT_ROADMAP.md +++ b/docs/PRODUCT_ROADMAP.md @@ -192,7 +192,7 @@ These are items that were considered and excluded because they do not serve the | 3.5C | Glitch capture UI | ✅ Complete | #568, #569 | | 4A | PR status + PROOF9 merge gate | ❌ Not started | — | | 4B | Post-merge glitch capture loop | ❌ Not started | — | -| 5.1 | Settings page | ❌ Not started | #554–556 | +| 5.1 | Settings page (skeleton + agent config) | 🟡 In progress (#554 done; #555–556 next) | #554–556 | | 5.2 | Cost analytics | ❌ Not started | #557–558 | | 5.3 | Async notifications | ❌ Not started | #559–560 | | 5.4 | PRD stress-test web UI | ❌ Not started | #561–562 |