Skip to content

Commit 66794ac

Browse files
frankbriaTest User
andauthored
feat(settings): /settings page skeleton + agent config (#554) (#587)
* 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 * fix(settings): address PR review (#587) — input safety + validation - 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). * docs(settings): note /settings page shipped (#554) --------- Co-authored-by: Test User <test@example.com>
1 parent 429bf11 commit 66794ac

13 files changed

Lines changed: 990 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ At all times: `codeframe --help` works, Golden Path stubs can run, no breaking r
8484
### Phase 3 Web UI (actively developed — not legacy)
8585
Next.js 16 App Router, TypeScript, Shadcn/UI, Tailwind CSS, Hugeicons, XTerm.js, WebSocket + SSE.
8686

87-
Shipped pages: `/`, `/prd`, `/tasks`, `/execution`, `/execution/[taskId]`, `/blockers`, `/proof`, `/proof/[req_id]`, `/review`, `/sessions`, `/sessions/[id]`.
87+
Shipped pages: `/`, `/prd`, `/tasks`, `/execution`, `/execution/[taskId]`, `/blockers`, `/proof`, `/proof/[req_id]`, `/review`, `/sessions`, `/sessions/[id]`, `/settings`.
8888

8989
Testing: `cd web-ui && npm test` must pass; `npm run build` must succeed. The `frontend-tests` CI job enforces this on every PR.
9090

codeframe/core/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ class EnvironmentConfig:
163163
# LLM provider config (workspace-level default; overridden by env vars and CLI flags)
164164
llm: Optional[LLMConfig] = None
165165

166+
# Settings page (issue #554) — UI-managed agent settings
167+
max_cost_usd: Optional[float] = None
168+
agent_type_models: dict[str, str] = dataclass_field(default_factory=dict)
169+
166170
def validate(self) -> list[str]:
167171
"""Validate configuration values.
168172
@@ -217,6 +221,17 @@ def validate(self) -> list[str]:
217221
if budget.stall_timeout_s < 0:
218222
errors.append("agent_budget.stall_timeout_s must be >= 0 (0 = disabled)")
219223

224+
if self.max_cost_usd is not None and self.max_cost_usd < 0:
225+
errors.append("max_cost_usd must be >= 0")
226+
227+
valid_agent_types = {"claude_code", "codex", "opencode", "react"}
228+
invalid_agent_types = sorted(set(self.agent_type_models) - valid_agent_types)
229+
if invalid_agent_types:
230+
errors.append(
231+
"agent_type_models contains unsupported agent types: "
232+
+ ", ".join(invalid_agent_types)
233+
)
234+
220235
return errors
221236

222237
def get_install_command(self, package: str) -> str:

codeframe/ui/models.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from enum import Enum
88
from pydantic import BaseModel, Field, model_validator, ConfigDict
9-
from typing import Optional, List
9+
from typing import Literal, Optional, List
1010

1111

1212
class SourceType(str, Enum):
@@ -901,3 +901,50 @@ class ErrorResponse(BaseModel):
901901
)
902902

903903
detail: str = Field(..., description="Error message describing what went wrong")
904+
905+
906+
# ============================================================================
907+
# Settings (issue #554)
908+
# ============================================================================
909+
910+
911+
AgentType = Literal["claude_code", "codex", "opencode", "react"]
912+
AGENT_TYPES: tuple[AgentType, ...] = ("claude_code", "codex", "opencode", "react")
913+
914+
915+
class AgentTypeModelConfig(BaseModel):
916+
"""Default model for a single agent type."""
917+
918+
agent_type: AgentType = Field(
919+
..., description="One of: claude_code, codex, opencode, react"
920+
)
921+
default_model: str = Field(
922+
default="",
923+
description="Model identifier (e.g. 'claude-opus-4', 'gpt-4o'); empty string means unset",
924+
)
925+
926+
927+
class AgentSettings(BaseModel):
928+
"""Agent settings shared by GET response and PUT request.
929+
930+
Defaults match `EnvironmentConfig.agent_budget.max_iterations` so that a
931+
fresh workspace round-trips its real defaults through GET.
932+
"""
933+
934+
agent_models: List[AgentTypeModelConfig] = Field(
935+
..., description="Default model per agent type"
936+
)
937+
max_turns: int = Field(
938+
default=100, gt=0, description="Maximum turns per task (must be > 0)"
939+
)
940+
max_cost_usd: Optional[float] = Field(
941+
default=None, ge=0, description="Maximum cost per task in USD"
942+
)
943+
944+
945+
class AgentSettingsResponse(AgentSettings):
946+
"""Response shape for GET /api/v2/settings."""
947+
948+
949+
class UpdateAgentSettingsRequest(AgentSettings):
950+
"""Request shape for PUT /api/v2/settings."""
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""V2 Settings router — agent settings managed via the web UI.
2+
3+
Reads/writes a flat AgentSettings shape persisted in
4+
.codeframe/config.yaml via load_environment_config / save_environment_config.
5+
6+
Routes:
7+
GET /api/v2/settings - Load agent settings (returns defaults if missing)
8+
PUT /api/v2/settings - Save agent settings (merges into existing config)
9+
"""
10+
11+
import logging
12+
13+
from fastapi import APIRouter, Depends, HTTPException, Request
14+
15+
from codeframe.core.config import (
16+
AgentBudgetConfig,
17+
EnvironmentConfig,
18+
load_environment_config,
19+
save_environment_config,
20+
)
21+
from codeframe.core.workspace import Workspace
22+
from codeframe.lib.rate_limiter import rate_limit_standard
23+
from codeframe.ui.dependencies import get_v2_workspace
24+
from codeframe.ui.models import (
25+
AGENT_TYPES,
26+
AgentSettingsResponse,
27+
AgentTypeModelConfig,
28+
UpdateAgentSettingsRequest,
29+
)
30+
from codeframe.ui.response_models import ErrorCodes, api_error
31+
32+
logger = logging.getLogger(__name__)
33+
34+
router = APIRouter(prefix="/api/v2/settings", tags=["settings"])
35+
36+
37+
def _config_to_response(config: EnvironmentConfig) -> AgentSettingsResponse:
38+
"""Map an EnvironmentConfig to the flat AgentSettings response shape."""
39+
saved_models = config.agent_type_models or {}
40+
agent_models = [
41+
AgentTypeModelConfig(
42+
agent_type=agent_type,
43+
default_model=saved_models.get(agent_type, ""),
44+
)
45+
for agent_type in AGENT_TYPES
46+
]
47+
# Guard against legacy YAML where agent_budget may have been removed/nulled.
48+
budget = config.agent_budget or AgentBudgetConfig()
49+
return AgentSettingsResponse(
50+
agent_models=agent_models,
51+
max_turns=budget.max_iterations,
52+
max_cost_usd=config.max_cost_usd,
53+
)
54+
55+
56+
@router.get("", response_model=AgentSettingsResponse)
57+
@rate_limit_standard()
58+
async def get_settings(
59+
request: Request,
60+
workspace: Workspace = Depends(get_v2_workspace),
61+
) -> AgentSettingsResponse:
62+
"""Load agent settings for the workspace.
63+
64+
Returns defaults if no .codeframe/config.yaml exists.
65+
"""
66+
try:
67+
config = load_environment_config(workspace.repo_path) or EnvironmentConfig()
68+
return _config_to_response(config)
69+
except Exception as e:
70+
logger.error(f"Failed to load settings: {e}", exc_info=True)
71+
raise HTTPException(
72+
status_code=500,
73+
detail=api_error(
74+
"Failed to load settings", ErrorCodes.EXECUTION_FAILED, str(e)
75+
),
76+
)
77+
78+
79+
@router.put("", response_model=AgentSettingsResponse)
80+
@rate_limit_standard()
81+
async def update_settings(
82+
request: Request,
83+
body: UpdateAgentSettingsRequest,
84+
workspace: Workspace = Depends(get_v2_workspace),
85+
) -> AgentSettingsResponse:
86+
"""Save agent settings.
87+
88+
Merges into existing EnvironmentConfig so unrelated fields
89+
(package_manager, test_framework, etc.) are preserved.
90+
"""
91+
try:
92+
config = load_environment_config(workspace.repo_path) or EnvironmentConfig()
93+
if config.agent_budget is None:
94+
config.agent_budget = AgentBudgetConfig()
95+
96+
config.agent_budget.max_iterations = body.max_turns
97+
config.max_cost_usd = body.max_cost_usd
98+
# Skip empty model strings — they're equivalent to "key not present"
99+
# in _config_to_response, so persisting them just adds yaml noise.
100+
# AgentType Literal in the model already rejects unknown agent_type values.
101+
config.agent_type_models = {
102+
entry.agent_type: entry.default_model
103+
for entry in body.agent_models
104+
if entry.default_model
105+
}
106+
107+
save_environment_config(workspace.repo_path, config)
108+
return _config_to_response(config)
109+
except Exception as e:
110+
logger.error(f"Failed to save settings: {e}", exc_info=True)
111+
raise HTTPException(
112+
status_code=500,
113+
detail=api_error(
114+
"Failed to save settings", ErrorCodes.EXECUTION_FAILED, str(e)
115+
),
116+
)

codeframe/ui/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
review_v2,
3636
schedule_v2,
3737
session_chat_ws,
38+
settings_v2,
3839
terminal_ws,
3940
streaming_v2,
4041
tasks_v2,
@@ -496,6 +497,7 @@ async def test_broadcast(message: dict, project_id: int = None):
496497
app.include_router(proof_v2.router) # /api/v2/proof
497498
app.include_router(review_v2.router) # /api/v2/review
498499
app.include_router(schedule_v2.router) # /api/v2/schedule
500+
app.include_router(settings_v2.router) # /api/v2/settings
499501
app.include_router(streaming_v2.router) # /api/v2/tasks/{id}/stream (SSE)
500502
app.include_router(tasks_v2.router) # /api/v2/tasks
501503
app.include_router(templates_v2.router) # /api/v2/templates

docs/PRODUCT_ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ These are items that were considered and excluded because they do not serve the
192192
| 3.5C | Glitch capture UI | ✅ Complete | #568, #569 |
193193
| 4A | PR status + PROOF9 merge gate | ❌ Not started ||
194194
| 4B | Post-merge glitch capture loop | ❌ Not started ||
195-
| 5.1 | Settings page | ❌ Not started | #554–556 |
195+
| 5.1 | Settings page (skeleton + agent config) | 🟡 In progress (#554 done; #555–556 next) | #554–556 |
196196
| 5.2 | Cost analytics | ❌ Not started | #557–558 |
197197
| 5.3 | Async notifications | ❌ Not started | #559–560 |
198198
| 5.4 | PRD stress-test web UI | ❌ Not started | #561–562 |

tests/core/test_environment_config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ def test_multiple_validation_errors(self):
108108
errors = config.validate()
109109
assert len(errors) == 3
110110

111+
def test_invalid_max_cost_usd(self):
112+
"""Negative max_cost_usd is rejected."""
113+
config = EnvironmentConfig(max_cost_usd=-1.0)
114+
errors = config.validate()
115+
assert any("max_cost_usd" in e for e in errors)
116+
117+
def test_invalid_agent_type_in_models(self):
118+
"""Unknown agent_type keys in agent_type_models are rejected."""
119+
config = EnvironmentConfig(
120+
agent_type_models={"claude_code": "claude-opus-4", "evil_bot": "x"}
121+
)
122+
errors = config.validate()
123+
assert any("evil_bot" in e for e in errors)
124+
111125

112126
class TestEnvironmentConfigCommands:
113127
"""Tests for command generation."""

0 commit comments

Comments
 (0)