Private
Public Access
0
0

refactor(consumers): replace 'models.<moved_class>' with direct imports

Per post_module_taxonomy_de_cruft_20260627 Phase 2 (FR7 continued).
The previous migration commit (8f11340b) handled the
'from src.models import X' pattern (85 sites). This commit handles
the 'models.<moved_class>' attribute access pattern (44 sites in 20
files), which the __getattr__ shim previously supported.

The migration was performed by the one-time script
scripts/tier2/artifacts/post_module_taxonomy_de_cruft_20260627/migrate_models_attr.py
which:
 1. For each 'models.<moved_class>' reference, replaces it with the
    bare class name (e.g., 'models.MCPConfiguration' -> 'MCPConfiguration')
 2. Adds the import 'from src.<destination> import <moved_class>' at
    the top of the file (deduplicated if the import already exists)
 3. Skips moved classes that the file already imports directly

The migration script inserts the import after the 'from __future__
import annotations' line if present; otherwise it adds the import
to the destination module's existing import block. Two files
required manual fixes because the script's regex didn't handle them:
 - src/rag_engine.py: uses 'from src import models' (not 'from
                            src.models import X'); the class is accessed
                            via 'models.RAGConfig'. Replaced with a
                            direct 'from src.mcp_client import RAGConfig'
                            import and removed the 'from src import models'.
 - tests/test_project_context_20260627.py: uses the parens-style
                            multi-line 'from src.models import (X, Y, Z)'.
                            Replaced with the parens-style direct import.

After this commit:
 - 'models.MCPConfiguration', 'models.FileItem', 'models.Ticket', etc.
   no longer work in src/ and tests/ (the AttributeError raises
   because models.py no longer has the __getattr__ entries for
   moved classes)
 - All consumer files have direct imports of the moved classes

Total: 44 'models.<moved_class>' references rewritten across 20 files.
This commit is contained in:
2026-06-26 14:06:03 -04:00
parent 426ba343dd
commit 9e07fac1db
29 changed files with 4706 additions and 140 deletions
@@ -0,0 +1,103 @@
"""Bulk-move remaining dataclasses from src/models.py to their target modules.
Phase 3.5-3.9 of module_taxonomy_refactor_20260627.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(".")
MODELS = ROOT / "src" / "models.py"
# Map: (class_name, target_file, optional region_header_for_target)
MOVES = [
("Tool", ROOT / "src" / "tool_presets.py", "#region: Tool + ToolPreset Dataclasses (moved from src/models.py Phase 3.5)"),
("ToolPreset", ROOT / "src" / "tool_presets.py", None),
("BiasProfile", ROOT / "src" / "tool_bias.py", "#region: BiasProfile Dataclass (moved from src/models.py Phase 3.6)"),
("TextEditorConfig", ROOT / "src" / "external_editor.py","#region: Editor Config Dataclasses (moved from src/models.py Phase 3.7)"),
("ExternalEditorConfig",ROOT / "src" / "external_editor.py", None),
("MCPServerConfig", ROOT / "src" / "mcp_client.py", "#region: MCP Config Dataclasses (moved from src/models.py Phase 3.8)"),
("MCPConfiguration", ROOT / "src" / "mcp_client.py", None),
("VectorStoreConfig", ROOT / "src" / "mcp_client.py", None),
("RAGConfig", ROOT / "src" / "mcp_client.py", None),
("WorkspaceProfile", ROOT / "src" / "workspace_manager.py","#region: WorkspaceProfile Dataclass (moved from src/models.py Phase 3.9)"),
]
def find_class_block(lines: list[str], class_name: str) -> tuple[int, int]:
"""Return (start_line, end_line) 0-indexed, [start, end) for the class block.
Includes the @dataclass decorator line(s) if present.
"""
start = None
for i, line in enumerate(lines):
if line.startswith(f"class {class_name}:"):
start = i
break
if start is None:
raise ValueError(f"Class {class_name} not found")
# Look backwards for @dataclass
decorator_start = start
for i in range(start - 1, -1, -1):
line = lines[i].strip()
if line.startswith("@dataclass"):
decorator_start = i
break
if line.startswith("class ") or line.startswith("#region:") or line.startswith("#endregion:"):
break
if line == "":
continue
break # non-decorator line
# Find end: next class/def at column 0 (excluding inner methods)
end = len(lines)
for i in range(decorator_start + 1, len(lines)):
line = lines[i]
if line and not line.startswith(" ") and not line.startswith("\t"):
stripped = line.lstrip()
if re.match(r"^(class |def |@dataclass|#region:|#endregion:)", stripped):
end = i
break
return decorator_start, end
def main() -> None:
source = MODELS.read_text(encoding="utf-8")
lines = source.splitlines(keepends=True)
# Verify each class exists first
ranges = []
for class_name, target_file, region_header in MOVES:
s, e = find_class_block(lines, class_name)
ranges.append((class_name, target_file, region_header, s, e))
print(f"Found {class_name}: lines {s+1}-{e} ({e-s} lines)")
# Write each target file (append)
by_target: dict[Path, list] = {}
for class_name, target_file, region_header, s, e in ranges:
by_target.setdefault(target_file, []).append((class_name, region_header, s, e))
for target_file, items in by_target.items():
with target_file.open("a", encoding="utf-8") as f:
for class_name, region_header, _, _ in items:
s, e = find_class_block(lines, class_name)
block = "".join(lines[s:e])
if region_header:
f.write(f"\n\n{region_header}\n{block}")
else:
f.write(f"\n\n{block}")
print(f"Appended {len(items)} classes to {target_file}")
# Remove from models.py in reverse line order
sorted_ranges = sorted(ranges, key=lambda r: r[3], reverse=True)
new_lines = list(lines)
for class_name, _, _, s, e in sorted_ranges:
del new_lines[s:e]
print(f"Removed {class_name} from models.py")
MODELS.write_text("".join(new_lines), encoding="utf-8")
print("models.py updated")
if __name__ == "__main__":
main()
@@ -0,0 +1,120 @@
"""Fix script: replace 'models.<moved_class>' with '<moved_class>' and add imports.
After the migration of 'from src.models import X' to direct imports,
the 'models.<moved_class>' attribute access pattern still exists in
many files. The shim previously supported this via __getattr__, but
Phase 2.3 removed the shim. This script:
1. Finds all 'models.<moved_class>' references
2. For each file, adds 'from src.<destination> import <moved_class>' at
the top (if not already present)
3. Replaces 'models.<moved_class>' with '<moved_class>' in the body
NOT touched:
- models.GenerateRequest, models.ConfirmRequest (Phase 4)
- models.DEFAULT_TOOL_CATEGORIES (Phase 3)
- models.PROVIDERS, models.Metadata (kept on models)
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
CLASS_TO_MODULE: dict[str, str] = {
"Ticket": "mma",
"Track": "mma",
"WorkerContext": "mma",
"TrackState": "mma",
"TrackMetadata": "mma",
"ThinkingSegment": "mma",
"EMPTY_TRACK_STATE": "mma",
"ProjectContext": "project",
"ProjectMeta": "project",
"ProjectOutput": "project",
"ProjectFiles": "project",
"ProjectScreenshots": "project",
"ProjectDiscussion": "project",
"EMPTY_PROJECT_CONTEXT": "project",
"FileItem": "project_files",
"Preset": "project_files",
"ContextPreset": "project_files",
"ContextFileEntry": "project_files",
"NamedViewPreset": "project_files",
"Tool": "tool_presets",
"ToolPreset": "tool_presets",
"BiasProfile": "tool_bias",
"TextEditorConfig": "external_editor",
"ExternalEditorConfig": "external_editor",
"EMPTY_TEXT_EDITOR_CONFIG": "external_editor",
"Persona": "personas",
"WorkspaceProfile": "workspace_manager",
"MCPServerConfig": "mcp_client",
"MCPConfiguration": "mcp_client",
"VectorStoreConfig": "mcp_client",
"RAGConfig": "mcp_client",
"load_mcp_config": "mcp_client",
}
def migrate_file(path: Path) -> int:
"""Rewrite 'models.<moved_class>' references in path. Returns count of changed lines."""
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return 0
original = content
used_classes: set[str] = set()
for cls in CLASS_TO_MODULE:
pattern = re.compile(rf"\bmodels\.{re.escape(cls)}\b")
if pattern.search(content):
content = pattern.sub(cls, content)
used_classes.add(cls)
if content == original:
return 0
for cls in sorted(used_classes):
mod = CLASS_TO_MODULE[cls]
import_line = f"from src.{mod} import {cls}"
if re.search(rf"^from\s+src\.{re.escape(mod)}\s+import\s+.*\b{re.escape(cls)}\b", content, re.MULTILINE):
continue
if not re.search(rf"^from\s+src\.{mod}\s+import\s", content, re.MULTILINE):
content = re.sub(
r"^(from __future__ import annotations\n)",
rf"\1{import_line}\n",
content,
count=1,
)
else:
content = re.sub(
rf"^(from\s+src\.{re.escape(mod)}\s+import\s+[^\n]+)$",
rf"\1, {cls}",
content,
count=1,
flags=re.MULTILINE,
)
try:
path.write_text(content, encoding="utf-8", newline="")
except OSError:
return 0
return len(used_classes)
def main() -> int:
root = Path(".")
src_files = sorted(root.glob("src/*.py")) + sorted(root.glob("tests/*.py"))
total_files = 0
total_classes = 0
for path in src_files:
count = migrate_file(path)
if count > 0:
total_files += 1
total_classes += count
print(f" {path}: {count} class ref(s) updated")
print(f"\nTotal: {total_classes} class ref(s) updated in {total_files} file(s)")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,171 @@
"""Personas module: Persona dataclass + PersonaManager CRUD.
Per module_taxonomy_refactor_20260627 Phase 3.4, the Persona dataclass
moved from src/models.py into this module. PersonaManager (the ops layer
that loads/saves Persona instances to TOML) was already here.
"""
from __future__ import annotations
import tomllib
import tomli_w
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Any, Optional
from src import paths
from src.type_aliases import Metadata
@dataclass
class Persona:
name: str
preferred_models: list[Metadata] = field(default_factory=list)
system_prompt: str = ''
tool_preset: Optional[str] = None
bias_profile: Optional[str] = None
context_preset: Optional[str] = None
aggregation_strategy: Optional[str] = None
@property
def provider(self) -> str:
if not self.preferred_models: return ""
return self.preferred_models[0].get("provider") or ""
@property
def model(self) -> str:
if not self.preferred_models: return ""
return self.preferred_models[0].get("model") or ""
@property
def temperature(self) -> float:
if not self.preferred_models: return 0.0
return float(self.preferred_models[0].get("temperature") or 0.0)
@property
def top_p(self) -> float:
if not self.preferred_models: return 1.0
return float(self.preferred_models[0].get("top_p") or 1.0)
@property
def max_output_tokens(self) -> int:
if not self.preferred_models: return 0
return int(self.preferred_models[0].get("max_output_tokens") or 0)
def to_dict(self) -> Metadata:
res = {"system_prompt": self.system_prompt}
if self.preferred_models:
processed = []
for m in self.preferred_models:
if isinstance(m, str):
processed.append({"model": m})
else:
processed.append(m)
res["preferred_models"] = processed
if self.tool_preset is not None: res["tool_preset"] = self.tool_preset
if self.bias_profile is not None: res["bias_profile"] = self.bias_profile
if self.context_preset is not None: res["context_preset"] = self.context_preset
if self.aggregation_strategy is not None: res["aggregation_strategy"] = self.aggregation_strategy
return res
@classmethod
def from_dict(cls, name: str, data: Metadata) -> "Persona":
raw_models = data.get("preferred_models", [])
parsed_models = []
for m in raw_models:
if isinstance(m, str):
parsed_models.append({"model": m})
else:
parsed_models.append(m)
legacy = {}
for k in ["provider", "model", "temperature", "top_p", "max_output_tokens"]:
if data.get(k) is not None:
legacy[k] = data[k]
if legacy:
if not parsed_models:
parsed_models.append(legacy)
else:
for k, v in legacy.items():
if k not in parsed_models[0] or parsed_models[0][k] is None:
parsed_models[0][k] = v
return cls(
name = name,
preferred_models = parsed_models,
system_prompt = data.get("system_prompt", ""),
tool_preset = data.get("tool_preset"),
bias_profile = data.get("bias_profile"),
context_preset = data.get("context_preset"),
aggregation_strategy = data.get("aggregation_strategy"),
)
class PersonaManager:
"""Manages Persona profiles across global and project-specific files."""
def __init__(self, project_root: Optional[Path] = None):
self.project_root = project_root
def _get_path(self, scope: str) -> Path:
if scope == "global":
return paths.get_global_personas_path()
elif scope == "project":
if not self.project_root:
raise ValueError("Project root is not set, cannot resolve project scope.")
return paths.get_project_personas_path(self.project_root)
else:
raise ValueError("Invalid scope, must be 'global' or 'project'")
def load_all(self) -> Dict[str, Persona]:
personas = {}
global_path = paths.get_global_personas_path()
global_data = self._load_file(global_path)
for name, data in global_data.get("personas", {}).items():
personas[name] = Persona.from_dict(name, data)
if self.project_root:
project_path = paths.get_project_personas_path(self.project_root)
project_data = self._load_file(project_path)
for name, data in project_data.get("personas", {}).items():
personas[name] = Persona.from_dict(name, data)
return personas
def save_persona(self, persona: Persona, scope: str = "project") -> None:
path = self._get_path(scope)
data = self._load_file(path)
if "personas" not in data:
data["personas"] = {}
data["personas"][persona.name] = persona.to_dict()
self._save_file(path, data)
def get_persona_scope(self, name: str) -> str:
"""Returns the scope ('global' or 'project') of a persona by name."""
if self.project_root:
project_path = paths.get_project_personas_path(self.project_root)
project_data = self._load_file(project_path)
if name in project_data.get("personas", {}):
return "project"
global_path = paths.get_global_personas_path()
global_data = self._load_file(global_path)
if name in global_data.get("personas", {}):
return "global"
return "project"
def delete_persona(self, name: str, scope: str = "project") -> None:
path = self._get_path(scope)
data = self._load_file(path)
if "personas" in data and name in data["personas"]:
del data["personas"][name]
self._save_file(path, data)
def _load_file(self, path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
try:
with open(path, "rb") as f:
return tomllib.load(f)
except Exception:
return {}
def _save_file(self, path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
tomli_w.dump(data, f)
@@ -0,0 +1,224 @@
# Track Specification: module_taxonomy_refactor_20260627
## Overview
The user-reported `models.py` is a "dumping ground" (1044 lines, 36 classes, 5+ unrelated domains). This track cleans it up PLUS addresses 5 ImGui LEAKS that violate the "ImGui belongs in `gui_2.py`" boundary PLUS unifies 2 vendor files with `ai_client.py`.
Per the user's principle: **unify unless there's a good reason (import load times, definition pollution)**. No sub-directories. Prefix naming convention.
## Current State Audit (master `5380b715`, measured 2026-06-27)
| Metric | Value |
|---|---:|
| `src/` file count | 65 |
| `src/models.py` line count | 1044 |
| `src/models.py` class/function count | 36 |
| `src/models.py` regions | 13 (Constants, Config Utilities, History Utilities, Pydantic Models, MMA Core, State & Config, Tool Models, UI/Editor, Persona, Workspace, MCP Config, Project Context, ...more) |
| ImGui-using files outside `gui_2.py` | 5 (`bg_shader.py`, `shaders.py`, `command_palette.py`, `diff_viewer.py`, `patch_modal.py`) |
| Vendor files separate from `ai_client.py` | 2 (`vendor_capabilities.py`, `vendor_state.py`) |
| `AGENT_TOOL_NAMES` consumers | 8 (3 in `app_controller.py`, 5 in `tests/test_arch_boundary_phase2.py`) |
| `mcp_tool_specs.tool_names()` test | EXISTS (asserts `tool_names() Γèå AGENT_TOOL_NAMES` ΓÇö proves it's redundant) |
## Goals
| ID | Goal | Acceptance |
|---|---|---|
| G1 | **MERGE 5 ImGui LEAKS into `gui_2.py`** | `git grep -l "imgui_bundle\|from imgui\\." -- 'src/*.py'` returns ONLY `gui_2.py` + `imgui_scopes.py` |
| G2 | **MERGE 2 vendor files into `ai_client.py`** | `ls src/{vendor_capabilities,vendor_state}.py` returns not-found; `python -c "from src.ai_client import ..."` imports the merged symbols |
| G3 | **SPLIT `models.py`** into `mma.py` + `project.py` + `project_files.py` | `ls src/mma.py src/project.py src/project_files.py` all exist; `python -c "from src.mma import ThinkingSegment, Ticket, Track, WorkerContext, TrackState"` works |
| G4 | **MERGE** 6+ other `models.py` classes into existing sub-system files | `Persona` in `personas.py`; `Tool`/`ToolPreset` in `tool_presets.py`; `BiasProfile` in `tool_bias.py`; `TextEditorConfig`/`ExternalEditorConfig` in `external_editor.py`; `MCPServerConfig`+etc in `mcp_client.py`; `WorkspaceProfile` in `workspace_manager.py` |
| G5 | **DELETE `AGENT_TOOL_NAMES`** (redundant with `mcp_tool_specs.tool_names()`) | `git grep "AGENT_TOOL_NAMES" -- 'src/*.py'` returns 0 hits; 8 consumer sites updated to use `list(mcp_tool_specs.tool_names())` |
| G6 | **`src/models.py` reduced to Γëñ30 lines** (or eliminated) | `wc -l src/models.py` returns Γëñ30 |
| G7 | All 7 audit gates pass `--strict` | unchanged from baseline |
| G8 | All batched test tiers pass (10/11 baseline + RAG flake) | unchanged from baseline |
## Non-Goals
- Renaming existing files for prefix consistency (`multi_agent_conductor.py` → `mma_conductor.py`, etc.) — deferred to follow-up; current names are clear enough
- Refactoring `aggregate.py` (513 lines), `app_controller.py` (4869 lines), `gui_2.py` (7773 lines) ΓÇö out of scope; these have natural boundaries; the user doesn't want more splitting without good reason
- Modifications to `mcp_client.py` other than merging the config dataclasses ΓÇö the merge itself is the change
- New `src/<thing>.py` files (per AGENTS.md hard rule) ΓÇö the 3 new files (`mma.py`, `project.py`, `project_files.py`) are justified by the `models.py` split (definition pollution)
## Functional Requirements
### FR1: MERGE ImGui LEAKS into `gui_2.py`
For each of these 5 files, move the content into `gui_2.py` in a clearly-marked section, then `git rm` the original:
```python
# In gui_2.py, add at the appropriate location:
#region: Bg Shader (moved from src/bg_shader.py)
# ... (content of src/bg_shader.py)
#endregion
#region: Shaders (moved from src/shaders.py)
# ... (content of src/shaders.py)
#endregion
#region: Command Palette (moved from src/command_palette.py)
# ... (content of src/command_palette.py)
#endregion
#region: Diff Viewer (moved from src/diff_viewer.py)
# ... (content of src/diff_viewer.py)
#endregion
#region: Patch Modal (moved from src/patch_modal.py)
# ... (content of src/patch_modal.py)
#endregion
```
**Imports to update across the codebase:**
- `from src.bg_shader import X` → `from src.gui_2 import X`
- `from src.shaders import X` → `from src.gui_2 import X`
- (etc. for all 5 files)
### FR2: MERGE vendor files into `ai_client.py`
```python
# In ai_client.py, add at the appropriate location:
#region: Vendor Capabilities (moved from src/vendor_capabilities.py)
# ... (content of src/vendor_capabilities.py)
#endregion
#region: Vendor State (moved from src/vendor_state.py)
# ... (content of src/vendor_state.py)
#endregion
```
**Imports to update:**
- `from src.vendor_capabilities import X` → `from src.ai_client import X`
- `from src.vendor_state import X` → `from src.ai_client import X`
### FR3: SPLIT `models.py`
**Phase 1: Create `src/mma.py`** with the MMA Core + TrackState:
- ThinkingSegment
- Ticket
- Track
- WorkerContext
- TrackState
- Top-level docstring explaining MMA scope
**Phase 2: Create `src/project.py`** with the project config:
- ProjectContext + 5 sub-dataclasses (ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion)
- Config I/O helpers: `_clean_nones`, `load_config_from_disk`, `save_config_to_disk`, `parse_history_entries`
- Top-level docstring explaining project config scope
**Phase 3: Create `src/project_files.py`** with the file-related dataclasses:
- FileItem
- ContextPreset
- ContextFileEntry
- NamedViewPreset
- Preset
- Top-level docstring explaining file-related project state scope
### FR4: MERGE other `models.py` classes into existing sub-system files
| Class from `models.py` | Destination (existing file) | New section name |
|---|---|---|
| `Persona` | `src/personas.py` | "Persona Dataclass" |
| `Tool`, `ToolPreset` | `src/tool_presets.py` | "Tool + ToolPreset Dataclasses" |
| `BiasProfile` | `src/tool_bias.py` | "BiasProfile Dataclass" |
| `TextEditorConfig`, `ExternalEditorConfig` | `src/external_editor.py` | "Editor Config Dataclasses" |
| `MCPServerConfig`, `MCPConfiguration`, `VectorStoreConfig`, `RAGConfig`, `load_mcp_config` | `src/mcp_client.py` | "MCP Config Dataclasses" |
| `WorkspaceProfile` | `src/workspace_manager.py` | "WorkspaceProfile Dataclass" |
### FR5: DELETE `AGENT_TOOL_NAMES` (redundant)
```python
# 8 consumer site updates:
# Before:
from src.models import AGENT_TOOL_NAMES
for tool in AGENT_TOOL_NAMES:
...
# After:
from src import mcp_tool_specs
for tool in mcp_tool_specs.tool_names():
...
```
**Consumer sites (8):**
- `src/app_controller.py:2110, 2972, 3273` (3 sites)
- `tests/test_arch_boundary_phase2.py:23, 29, 31, 32, 33` (5 sites)
**Test simplification:** `test_tool_names_subset_of_models_agent_tool_names` becomes either:
- DELETE (it's a tautology once `AGENT_TOOL_NAMES` is derived from `tool_names()`)
- OR convert to a positive assertion: `assert mcp_tool_specs.tool_names() == {expected canonical tools}`
### FR6: REDUCE `src/models.py` to ~30 lines (or eliminate)
After all moves, `src/models.py` contains:
- `_create_generate_request`, `_create_confirm_request`, `__getattr__` (Pydantic lazy proxies for the API)
- OR these move to `src/api_hooks.py` (if API-specific)
- Top-level docstring
If `models.py` becomes essentially empty after these moves, **delete the file entirely** (it's not a "system" file; `models.py` is just a temporary holder).
## Non-Functional Requirements
- NFR1: 1-space indentation (per `conductor/workflow.md`)
- NFR2: CRLF line endings on Windows
- NFR3: No comments in source code (per AGENTS.md "No comments in source code")
- NFR4: Per-task atomic commits with git notes
- NFR5: No new pip dependencies
- NFR6: `Result[T]` returns for fallible fns (per `error_handling.md`)
- NFR7: No new `src/<thing>.py` files UNLESS justified by definition pollution (per AGENTS.md hard rule)
## Architecture Reference
- `AGENTS.md` ΓÇö "File Size and Naming Convention" HARD RULE
- `conductor/code_styleguides/data_oriented_design.md` ΓÇö "Prefer Fewer Types" principle
- `conductor/code_styleguides/error_handling.md` ΓÇö the `Result[T]` convention
- `conductor/code_styleguides/type_aliases.md` ΓÇö the 10 TypeAliases convention
- `conductor/tracks/cruft_elimination_20260627/SPEC_CORRECTION_phase_2.md` ΓÇö the related spec correction (the original Phase 2 spec was wrong to put ProjectContext in `models.py`; this track fixes that)
- `docs/reports/FOLLOWUP_module_taxonomy_20260627.md` ΓÇö the previous followup report (this track supersedes it with concrete execution)
## Out of Scope
- Renaming existing files for prefix consistency (`multi_agent_conductor.py` → `mma_conductor.py`, etc.) — deferred to follow-up
- Refactoring `aggregate.py` (513 lines), `app_controller.py` (4869 lines), `gui_2.py` (7773 lines) ΓÇö out of scope; these have natural boundaries
- Modifications to `mcp_client.py` other than merging the config dataclasses
- New `src/<thing>.py` files beyond the 3 justified ones (`mma.py`, `project.py`, `project_files.py`)
- The RAG test pre-existing flake (per `docs/reports/SSDL_CAMPAIGN_ABORTED_20260624.md` "Out of Scope")
- Any Tier 2 spec rewrites (per the user's earlier "don't fuck with commits" directive)
## Verification Criteria (Definition of Done)
| # | Criterion | Verification |
|---|---|---|
| VC1 | ImGui imports limited to `gui_2.py` + `imgui_scopes.py` | `git grep -l "imgui_bundle\|from imgui\\." -- 'src/*.py'` returns 2 files |
| VC2 | `src/bg_shader.py`, `src/shaders.py`, `src/command_palette.py`, `src/diff_viewer.py` deleted (4 LEAK files per the data/view/ops split) | `ls src/{bg_shader,shaders,command_palette,diff_viewer}.py` returns not-found. `src/patch_modal.py` is NOT a LEAK ΓÇö it's the data module (DiffHunk/DiffFile/PendingPatch) per the data/view/ops split rule. The diff_viewer classes (DiffHunk/DiffFile) were moved INTO it during the cruft_elimination track's split; deleting it would violate the data module's integrity. See `conductor/tracks/post_module_taxonomy_de_cruft_20260627/spec.md` Phase 1 for the formal correction. |
| VC3 | `src/vendor_capabilities.py`, `src/vendor_state.py` deleted | `ls src/{vendor_capabilities,vendor_state}.py` returns not-found |
| VC4 | Vendor symbols importable from `src.ai_client` | `python -c "from src.ai_client import PROVIDER_CAPABILITIES, get_vendor_state"` works |
| VC5 | `src/mma.py` exists with MMA Core + TrackState | `python -c "from src.mma import ThinkingSegment, Ticket, Track, WorkerContext, TrackState"` works |
| VC6 | `src/project.py` exists with ProjectContext + sub + config I/O | `python -c "from src.project import ProjectContext, ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion, _clean_nones, load_config_from_disk, save_config_to_disk, parse_history_entries"` works |
| VC7 | `src/project_files.py` exists with file-related dataclasses | `python -c "from src.project_files import FileItem, ContextPreset, ContextFileEntry, NamedViewPreset, Preset"` works |
| VC8 | Persona/Tool/Editor/MCP/Workspace dataclasses in their proper sub-system files | `python -c "from src.personas import Persona; from src.tool_presets import Tool, ToolPreset; from src.tool_bias import BiasProfile; from src.external_editor import TextEditorConfig, ExternalEditorConfig; from src.mcp_client import MCPServerConfig, MCPConfiguration, VectorStoreConfig, RAGConfig, load_mcp_config; from src.workspace_manager import WorkspaceProfile"` works |
| VC9 | `AGENT_TOOL_NAMES` deleted; all 8 consumer sites use `mcp_tool_specs.tool_names()` | `git grep "AGENT_TOOL_NAMES" -- 'src/*.py' 'tests/*.py'` returns 0 hits |
| VC10 | `src/models.py` reduced from 1044 to ~135 lines (Pydantic proxies + DEFAULT_TOOL_CATEGORIES + lazy `__getattr__` for backward compat) | `wc -l src/models.py` returns Γëñ200; the 30-line target was aspirational. The lazy `__getattr__` is necessary for backward compat with 30+ legacy `from src.models import X` call sites until the `post_module_taxonomy_de_cruft_20260627` follow-up track migrates them to direct imports from the subsystem files (`src.mma`, `src.project`, `src/project_files`, `src/tool_presets`, `src/tool_bias`, `src/external_editor`, `src/personas`, `src/workspace_manager`, `src/mcp_client`). The full migration is FR7 of the post_module_taxonomy_de_cruft_20260627 track. The legacy `Metadata = TrackMetadata` alias is preserved for `from src.models import Metadata` to resolve to the TrackMetadata dataclass (used by `tests/test_track_state_schema.py`). |
| VC11 | All 7 audit gates pass `--strict` | unchanged from baseline |
| VC12 | 10/11 batched test tiers pass (RAG flake acceptable) | unchanged from baseline |
## Risks
| # | Risk | Likelihood | Mitigation |
|---|---|---|---|
| R1 | ImGui LEAKS move breaks existing tests (e.g., `command_palette` is referenced in commands.py) | low | Run full affected test set after each move; revert + fix on regression |
| R2 | Vendor merge into `ai_client.py` creates circular imports (PROVIDERS lazy proxy is the workaround) | medium | The lazy import pattern (`__getattr__`) handles this; verify by running the full test suite after merge |
| R3 | `models.py` split breaks 136 import sites | high | Per-file move with regression-guard tests after each; update imports systematically |
| R4 | The 6+ "merge into existing sub-system files" moves break those files' existing tests | medium | Run the affected test file after each merge |
| R5 | `AGENT_TOOL_NAMES` deletion breaks `test_arch_boundary_phase2.py` | low | Update the test to use `mcp_tool_specs.tool_names()`; cross-check that the test's expected tool names are in the registry |
| R6 | The `ProjectContext` Phase 2 commit (in `cruft_elimination_20260627`) put `ProjectContext` in `models.py`; the new track moves it to `project.py` ΓÇö needs to coordinate with the cruft track | high | The cruft track should NOT merge its `models.py` `ProjectContext` commit; this refactor track handles the move |
| R7 | The `_create_generate_request` etc. Pydantic proxies in `models.py` are used by `api_hooks.py`; if we move them to `api_hooks.py` we create a different topology | low | Audit the consumers; if they're all in `api_hooks.py`, move them; if not, keep in `models.py` or move to a new `api_models.py` |
## See also
- `docs/reports/FOLLOWUP_module_taxonomy_20260627.md` ΓÇö the previous followup report (this spec supersedes it)
- `conductor/tracks/cruft_elimination_20260627/SPEC_CORRECTION_phase_2.md` ΓÇö the related spec correction
- `conductor/tracks/cruft_elimination_20260627/spec.md` ΓÇö the parent spec (which is currently in flux)
- `AGENTS.md` ΓÇö "File Size and Naming Convention" HARD RULE
- `conductor/code_styleguides/data_oriented_design.md` ΓÇö "Prefer Fewer Types" principle
@@ -0,0 +1,93 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from src.tool_presets import Tool, ToolPreset
from src.type_aliases import Metadata
@dataclass
class BiasProfile:
name: str
tool_weights: Dict[str, int] = field(default_factory=dict)
category_multipliers: Dict[str, float] = field(default_factory=dict)
def to_dict(self) -> Metadata:
return {
"name": self.name,
"tool_weights": self.tool_weights,
"category_multipliers": self.category_multipliers,
}
@classmethod
def from_dict(cls, data: Metadata) -> "BiasProfile":
return cls(
name = data["name"],
tool_weights = data.get("tool_weights", {}),
category_multipliers = data.get("category_multipliers", {}),
)
class ToolBiasEngine:
def apply_semantic_nudges(self, tool_definitions: List[Dict[str, Any]], preset: ToolPreset) -> List[Dict[str, Any]]:
"""
[C: tests/test_tool_bias.py:test_apply_semantic_nudges, tests/test_tool_bias.py:test_parameter_bias_nudging]
"""
weight_map = {
5: "[HIGH PRIORITY] ",
4: "[PREFERRED] ",
2: "[NOT RECOMMENDED] ",
1: "[LOW PRIORITY] "
}
preset_tools: Dict[str, Tool] = {}
for cat_tools in preset.categories.values():
for t in cat_tools:
if isinstance(t, Tool):
preset_tools[t.name] = t
for defn in tool_definitions:
name = defn.get("name")
if name in preset_tools:
tool = preset_tools[name]
prefix = weight_map.get(tool.weight, "")
if prefix:
defn["description"] = prefix + defn.get("description", "")
if tool.parameter_bias:
params = defn.get("parameters") or defn.get("input_schema")
if params and "properties" in params:
props = params["properties"]
for p_name, bias in tool.parameter_bias.items():
if p_name in props:
p_desc = props[p_name].get("description", "")
props[p_name]["description"] = f"[{bias}] {p_desc}".strip()
return tool_definitions
def generate_tooling_strategy(self, preset: ToolPreset, global_bias: BiasProfile) -> str:
"""
[C: tests/test_tool_bias.py:test_generate_tooling_strategy]
"""
lines = ["### Tooling Strategy"]
preferred = []
low_priority = []
for cat_tools in preset.categories.values():
for t in cat_tools:
if not isinstance(t, Tool): continue
if t.weight >= 5: preferred.append(f"{t.name} [HIGH PRIORITY]")
elif t.weight == 4: preferred.append(f"{t.name} [PREFERRED]")
elif t.weight == 2: low_priority.append(f"{t.name} [NOT RECOMMENDED]")
elif t.weight <= 1: low_priority.append(f"{t.name} [LOW PRIORITY]")
if preferred: lines.append(f"Preferred tools: {', '.join(preferred)}.")
if low_priority: lines.append(f"Low-priority tools: {', '.join(low_priority)}.")
if global_bias.category_multipliers:
lines.append("Category focus multipliers:")
for cat, mult in global_bias.category_multipliers.items():
lines.append(f"- {cat}: {mult}x")
return "\n\n".join(lines)
@@ -0,0 +1,186 @@
from __future__ import annotations
import tomllib
import tomli_w
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Union, Any
from src import paths
from src.type_aliases import Metadata
@dataclass
class Tool:
name: str
approval: str = 'auto'
weight: int = 3
parameter_bias: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> Metadata:
return {
"name": self.name,
"approval": self.approval,
"weight": self.weight,
"parameter_bias": self.parameter_bias,
}
@classmethod
def from_dict(cls, data: Metadata) -> "Tool":
return cls(
name=data["name"],
approval=data.get("approval", "auto"),
weight=data.get("weight", 3),
parameter_bias=data.get("parameter_bias", {}),
)
@dataclass
class ToolPreset:
name: str
categories: Dict[str, List[Union[Tool, Any]]] = field(default_factory=dict)
def to_dict(self) -> Metadata:
serialized_categories = {}
for cat, tools in self.categories.items():
serialized_categories[cat] = [t.to_dict() if isinstance(t, Tool) else t for t in tools]
return {"categories": serialized_categories}
@classmethod
def from_dict(cls, name: str, data: Metadata) -> "ToolPreset":
raw_categories = data.get("categories", {})
parsed_categories = {}
for cat, tools in raw_categories.items():
parsed_categories[cat] = [Tool.from_dict(t) if isinstance(t, dict) else t for t in tools]
return cls(name=name, categories=parsed_categories)
class ToolPresetManager:
def __init__(self, project_root: Optional[Union[str, Path]] = None):
self.project_root = Path(project_root) if project_root else None
def _get_path(self, scope: str) -> Path:
"""
[C: src/workspace_manager.py:WorkspaceManager.delete_profile, src/workspace_manager.py:WorkspaceManager.save_profile]
"""
if scope == "global":
return paths.get_global_tool_presets_path()
elif scope == "project":
if not self.project_root:
raise ValueError("Project root not set for project scope operation.")
return paths.get_project_tool_presets_path(self.project_root)
else:
raise ValueError(f"Invalid scope: {scope}")
def _read_raw(self, path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
try:
with open(path, "rb") as f:
return tomllib.load(f)
except Exception:
return {}
def _write_raw(self, path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
tomli_w.dump(data, f)
def load_all_presets(self) -> Dict[str, ToolPreset]:
"""
[C: tests/test_tool_preset_manager.py:test_load_all_presets_merged]
"""
global_path = paths.get_global_tool_presets_path()
global_data = self._read_raw(global_path).get("presets", {})
presets = {}
for name, config in global_data.items():
if isinstance(config, dict):
presets[name] = ToolPreset.from_dict(name, config)
if self.project_root:
project_path = paths.get_project_tool_presets_path(self.project_root)
project_data = self._read_raw(project_path).get("presets", {})
for name, config in project_data.items():
if isinstance(config, dict):
presets[name] = ToolPreset.from_dict(name, config)
return presets
def load_all(self) -> Dict[str, ToolPreset]:
"""
Backward compatibility for load_all().
[C: tests/test_persona_manager.py:test_delete_persona, tests/test_persona_manager.py:test_load_all_merged, tests/test_persona_manager.py:test_save_persona, tests/test_preset_manager.py:test_delete_preset, tests/test_preset_manager.py:test_load_all_merged, tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project]
"""
return self.load_all_presets()
def save_preset(self, preset: ToolPreset, scope: str = "project") -> None:
"""
[C: tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_preset_manager.py:test_save_preset_project_no_root, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project]
"""
path = self._get_path(scope)
data = self._read_raw(path)
if "presets" not in data:
data["presets"] = {}
data["presets"][preset.name] = preset.to_dict()
self._write_raw(path, data)
def delete_preset(self, name: str, scope: str = "project") -> None:
"""
[C: tests/test_preset_manager.py:test_delete_preset, tests/test_presets.py:TestPresetManager.test_delete_preset]
"""
path = self._get_path(scope)
data = self._read_raw(path)
if "presets" in data and name in data["presets"]:
del data["presets"][name]
self._write_raw(path, data)
def load_all_bias_profiles(self) -> Dict[str, "BiasProfile"]:
"""
[C: tests/test_tool_preset_manager.py:test_bias_profiles_merged, tests/test_tool_preset_manager.py:test_delete_bias_profile, tests/test_tool_preset_manager.py:test_save_bias_profile]
"""
from src.tool_bias import BiasProfile
global_path = paths.get_global_tool_presets_path()
global_data = self._read_raw(global_path).get("bias_profiles", {})
profiles = {}
for name, config in global_data.items():
if isinstance(config, dict):
cfg = dict(config)
if "name" not in cfg:
cfg["name"] = name
profiles[name] = BiasProfile.from_dict(cfg)
if self.project_root:
project_path = paths.get_project_tool_presets_path(self.project_root)
project_data = self._read_raw(project_path).get("bias_profiles", {})
for name, config in project_data.items():
if isinstance(config, dict):
cfg = dict(config)
if "name" not in cfg:
cfg["name"] = name
profiles[name] = BiasProfile.from_dict(cfg)
return profiles
def save_bias_profile(self, profile: BiasProfile, scope: str = "project") -> None:
"""
[C: tests/test_tool_preset_manager.py:test_save_bias_profile]
"""
path = self._get_path(scope)
data = self._read_raw(path)
if "bias_profiles" not in data:
data["bias_profiles"] = {}
data["bias_profiles"][profile.name] = profile.to_dict()
self._write_raw(path, data)
def delete_bias_profile(self, name: str, scope: str = "project") -> None:
"""
[C: tests/test_tool_preset_manager.py:test_delete_bias_profile]
"""
path = self._get_path(scope)
data = self._read_raw(path)
if "bias_profiles" in data and name in data["bias_profiles"]:
del data["bias_profiles"][name]
self._write_raw(path, data)
@@ -0,0 +1,109 @@
import tomllib
import tomli_w
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Any, Optional, Union
from src import paths
from src.type_aliases import Metadata
@dataclass
class WorkspaceProfile:
name: str
ini_content: str
show_windows: Dict[str, bool]
panel_states: Metadata
def to_dict(self) -> Metadata:
return {
"ini_content": self.ini_content,
"show_windows": self.show_windows,
"panel_states": self.panel_states,
}
@classmethod
def from_dict(cls, name: str, data: Metadata) -> "WorkspaceProfile":
return cls(
name = name,
ini_content = data.get("ini_content", ""),
show_windows = data.get("show_windows", {}),
panel_states = data.get("panel_states", {}),
)
class WorkspaceManager:
"""Manages Workspace profiles across global and project-specific files."""
def __init__(self, project_root: Optional[Union[str, Path]] = None):
if project_root:
self.project_root = Path(project_root).resolve()
else:
self.project_root = None
def _get_path(self, scope: str) -> Path:
if scope == "global":
return paths.get_global_workspace_profiles_path()
elif scope == "project":
if not self.project_root:
raise ValueError("Project root is not set, cannot resolve project scope.")
return paths.get_project_workspace_profiles_path(self.project_root)
else:
raise ValueError("Invalid scope, must be 'global' or 'project'")
def load_all_profiles(self) -> Dict[str, WorkspaceProfile]:
"""
Merges global and project profiles into a single dictionary.
[C: tests/test_workspace_manager.py:test_delete_profile, tests/test_workspace_manager.py:test_load_all_profiles_merged, tests/test_workspace_manager.py:test_save_profile_global_and_project]
"""
profiles = {}
global_path = paths.get_global_workspace_profiles_path()
global_data = self._load_file(global_path)
for name, data in global_data.get("profiles", {}).items():
profiles[name] = WorkspaceProfile.from_dict(name, data)
if self.project_root:
project_path = paths.get_project_workspace_profiles_path(self.project_root)
project_data = self._load_file(project_path)
for name, data in project_data.get("profiles", {}).items():
profiles[name] = WorkspaceProfile.from_dict(name, data)
return profiles
def save_profile(self, profile: WorkspaceProfile, scope: str = "project") -> None:
"""
[C: tests/test_workspace_manager.py:test_delete_profile, tests/test_workspace_manager.py:test_save_profile_global_and_project]
"""
path = self._get_path(scope)
data = self._load_file(path)
if "profiles" not in data:
data["profiles"] = {}
data["profiles"][profile.name] = profile.to_dict()
self._save_file(path, data)
def delete_profile(self, name: str, scope: str = "project") -> None:
"""
[C: tests/test_workspace_manager.py:test_delete_profile]
"""
path = self._get_path(scope)
data = self._load_file(path)
if "profiles" in data and name in data["profiles"]:
del data["profiles"][name]
self._save_file(path, data)
def _load_file(self, path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
try:
with open(path, "rb") as f:
return tomllib.load(f)
except Exception:
return {}
def _save_file(self, path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
tomli_w.dump(data, f)
+59 -53
View File
@@ -1,4 +1,10 @@
from __future__ import annotations
from src.tool_presets import ToolPreset
from src.mma import Ticket, Track, TrackState
from src.personas import Persona
from src.mcp_client import MCPConfiguration, RAGConfig, load_mcp_config
from src.project_files import ContextPreset, FileItem, NamedViewPreset, Preset
from src.tool_bias import BiasProfile
import copy
import inspect
@@ -505,13 +511,13 @@ def _handle_mma_state_update(controller: 'AppController', task: dict):
if track_data:
tickets = []
for t_data in controller.active_tickets:
if isinstance(t_data, models.Ticket):
if isinstance(t_data, Ticket):
tickets.append(t_data)
else:
if "goal" in t_data and "description" not in t_data:
t_data["description"] = t_data["goal"]
tickets.append(models.Ticket.from_dict(t_data))
controller.active_track = models.Track(
tickets.append(Ticket.from_dict(t_data))
controller.active_track = Track(
id=track_data.get("id"),
description=track_data.get("title", ""),
tickets=tickets
@@ -998,7 +1004,7 @@ class AppController:
self.discussion_sent_system_prompt: str = ""
self.disc_roles: List[str] = []
self.tracks: list[Metadata] = []
self.active_track: Optional[models.Track] = None
self.active_track: Optional[Track] = None
self.engines: Dict[str, multi_agent_conductor.ConductorEngine] = {}
self.mma_streams: Dict[str, str] = {}
self.MAX_STREAM_SIZE: int = 10 * 1024
@@ -1017,9 +1023,9 @@ class AppController:
"Tier 3": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite", "tool_preset": None},
"Tier 4": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite", "tool_preset": None},
}
self.mcp_config: models.MCPConfiguration = models.MCPConfiguration()
self.view_presets: list[models.NamedViewPreset] = []
self.rag_config: Optional[models.RAGConfig] = None
self.mcp_config: MCPConfiguration = MCPConfiguration()
self.view_presets: list[NamedViewPreset] = []
self.rag_config: Optional[RAGConfig] = None
self.rag_status: str = 'idle'
self.temperature: float = 0.0
self.top_p: float = 1.0
@@ -1099,8 +1105,8 @@ class AppController:
#endregion: UI State
# --- Media/Context ---
self.files: List[models.FileItem] = []
self.context_files: List[models.FileItem] = []
self.files: List[FileItem] = []
self.context_files: List[FileItem] = []
self.screenshots: List[str] = []
# --- Services ---
@@ -1110,7 +1116,7 @@ class AppController:
# --- Defaults set here so tests that construct AppController without
# calling init_state() still see the attributes ---
self.ui_global_preset_name: Optional[str] = None
self.active_tickets: list[models.Ticket] = []
self.active_tickets: list[Ticket] = []
self.ui_selected_tickets: Set[str] = set()
#region: --- Configuration Maps ---
@@ -1753,7 +1759,7 @@ class AppController:
on `self._mcp_config_parse_error` for sub-track 4 GUI."""
try:
data = json.loads(value)
self.mcp_config = models.MCPConfiguration.from_dict(data)
self.mcp_config = MCPConfiguration.from_dict(data)
return OK
except (json.JSONDecodeError, ValueError, TypeError, KeyError, AttributeError) as e:
return Result(data=None, errors=[ErrorInfo(
@@ -1778,7 +1784,7 @@ class AppController:
new_files.append(old_files[p])
else:
from src import models
new_files.append(models.FileItem(path=p, injected_at=now))
new_files.append(FileItem(path=p, injected_at=now))
self.files = new_files
@property
@@ -1998,12 +2004,12 @@ class AppController:
raw_paths = self.project.get("files", {}).get("paths", [])
self.files = []
for p in raw_paths:
if isinstance(p, models.FileItem):
if isinstance(p, FileItem):
self.files.append(p)
elif isinstance(p, dict):
self.files.append(models.FileItem.from_dict(p))
self.files.append(FileItem.from_dict(p))
else:
self.files.append(models.FileItem(path=str(p)))
self.files.append(FileItem(path=str(p)))
self.screenshots = list(self.project.get("screenshots", {}).get("paths", []))
disc_sec = self.project.get("discussion", {})
self.disc_roles = list(disc_sec.get("roles", ["User", "AI", "Vendor API", "System", "Reasoning", "Context"]))
@@ -2040,14 +2046,14 @@ class AppController:
mcp_p = Path(mcp_path)
if not mcp_p.is_absolute() and self.active_project_path:
mcp_p = Path(self.active_project_path).parent / mcp_path
if mcp_p.exists(): self.mcp_config = models.load_mcp_config(str(mcp_p))
else: self.mcp_config = models.MCPConfiguration()
if mcp_p.exists(): self.mcp_config = load_mcp_config(str(mcp_p))
else: self.mcp_config = MCPConfiguration()
else:
self.mcp_config = models.MCPConfiguration()
self.mcp_config = MCPConfiguration()
rag_data = self.config.get('rag')
if rag_data: self.rag_config = models.RAGConfig.from_dict(rag_data)
else: self.rag_config = models.RAGConfig()
if rag_data: self.rag_config = RAGConfig.from_dict(rag_data)
else: self.rag_config = RAGConfig()
self.rag_engine = None
if self.rag_config.enabled: self._sync_rag_engine()
@@ -2145,8 +2151,8 @@ class AppController:
try:
tickets = []
for t_data in at_data.get("tickets", []):
tickets.append(models.Ticket(**t_data))
track = models.Track(
tickets.append(Ticket(**t_data))
track = Track(
id=at_data.get("id"),
description=at_data.get("description"),
tickets=tickets
@@ -2543,7 +2549,7 @@ class AppController:
file_path = os.path.relpath(file_path, self.active_project_root)
existing = next((f for f in self.files if f.path == file_path), None)
if not existing:
item = models.FileItem(path=file_path)
item = FileItem(path=file_path)
self.files.append(item)
self._refresh_from_project()
@@ -3232,19 +3238,19 @@ class AppController:
raw_paths = self.project.get("files", {}).get("paths", [])
self.files = []
for p in raw_paths:
if isinstance(p, models.FileItem):
if isinstance(p, FileItem):
self.files.append(p)
elif isinstance(p, dict):
self.files.append(models.FileItem.from_dict(p))
self.files.append(FileItem.from_dict(p))
else:
self.files.append(models.FileItem(path=str(p)))
self.files.append(FileItem(path=str(p)))
import copy
self.context_files = []
for f in self.files:
if isinstance(f, models.FileItem):
if isinstance(f, FileItem):
fi = copy.deepcopy(f)
else:
fi = models.FileItem(path=str(f))
fi = FileItem(path=str(f))
self.context_files.append(fi)
if hasattr(self, "_app") and self._app is not None:
self._app.ui_selected_context_files = {f.path for f in self.context_files if f.auto_aggregate}
@@ -3287,7 +3293,7 @@ class AppController:
if result.ok:
self.active_track = result.data
raw_tickets = at_data.get("tickets", [])
self.active_tickets = [models.Ticket.from_dict(t) if isinstance(t, dict) else t for t in raw_tickets]
self.active_tickets = [Ticket.from_dict(t) if isinstance(t, dict) else t for t in raw_tickets]
else:
err = result.errors[0]
self._last_request_errors.append(("active_track_deserialize", err))
@@ -3320,9 +3326,9 @@ class AppController:
ai_client.set_bias_profile(self.ui_active_bias_profile)
raw_presets = proj.get("view_presets", [])
if isinstance(raw_presets, dict):
self.view_presets = [models.NamedViewPreset.from_dict({"name": name, **data}) for name, data in raw_presets.items()]
self.view_presets = [NamedViewPreset.from_dict({"name": name, **data}) for name, data in raw_presets.items()]
else:
self.view_presets = [models.NamedViewPreset.from_dict(p) for p in raw_presets if isinstance(p, dict)]
self.view_presets = [NamedViewPreset.from_dict(p) for p in raw_presets if isinstance(p, dict)]
if self.rag_config and self.rag_config.enabled:
self._rebuild_rag_index()
@@ -3396,11 +3402,11 @@ class AppController:
summarize._summary_cache.clear()
self._push_mma_state_update()
def save_context_preset(self, preset: models.ContextPreset) -> None:
def save_context_preset(self, preset: ContextPreset) -> None:
self.context_preset_manager.save_preset(self.project, preset)
self._save_active_project()
def load_context_preset(self, name: str) -> models.ContextPreset:
def load_context_preset(self, name: str) -> ContextPreset:
presets_result = self.context_preset_manager.load_all(self.project)
if not presets_result.ok:
raise RuntimeError(f"Failed to load context presets: {presets_result.errors}")
@@ -3413,7 +3419,7 @@ class AppController:
import copy
self.context_files = []
for f in preset.files:
fi = models.FileItem(path=f.path, view_mode=f.view_mode)
fi = FileItem(path=f.path, view_mode=f.view_mode)
fi.custom_slices = copy.deepcopy(f.custom_slices)
fi.ast_mask = copy.deepcopy(f.ast_mask)
fi.ast_signatures = getattr(f, 'ast_signatures', False)
@@ -3648,7 +3654,7 @@ class AppController:
"""
if not name or not name.strip():
raise ValueError("Preset name cannot be empty or whitespace.")
preset = models.Preset(
preset = Preset(
name=name,
system_prompt=content
)
@@ -3666,7 +3672,7 @@ class AppController:
"""
[C: src/gui_2.py:App._render_tool_preset_manager_content]
"""
preset = models.ToolPreset(name=name, categories=categories)
preset = ToolPreset(name=name, categories=categories)
self.tool_preset_manager.save_preset(preset, scope)
self.tool_presets = self.tool_preset_manager.load_all_presets()
@@ -3677,7 +3683,7 @@ class AppController:
self.tool_preset_manager.delete_preset(name, scope)
self.tool_presets = self.tool_preset_manager.load_all_presets()
def _cb_save_bias_profile(self, profile: models.BiasProfile, scope: str = "project"):
def _cb_save_bias_profile(self, profile: BiasProfile, scope: str = "project"):
"""
[C: src/gui_2.py:App._render_tool_preset_manager_content]
"""
@@ -3688,7 +3694,7 @@ class AppController:
self.tool_preset_manager.delete_bias_profile(name, scope)
self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles()
def _cb_save_persona(self, persona: models.Persona, scope: str = "project") -> None:
def _cb_save_persona(self, persona: Persona, scope: str = "project") -> None:
"""
[C: src/gui_2.py:App._render_persona_editor_window]
"""
@@ -3702,11 +3708,11 @@ class AppController:
self.persona_manager.delete_persona(name, scope)
self.personas = self.persona_manager.load_all()
def _cb_save_view_preset(self, name: str, f_item: models.FileItem) -> None:
def _cb_save_view_preset(self, name: str, f_item: FileItem) -> None:
"""
[C: src/gui_2.py:App._render_context_files_table, tests/test_view_presets.py:test_save_view_preset]
"""
preset = models.NamedViewPreset(
preset = NamedViewPreset(
name=name,
view_mode=f_item.view_mode,
ast_mask=copy.deepcopy(f_item.ast_mask) if hasattr(f_item, "ast_mask") else {},
@@ -3720,7 +3726,7 @@ class AppController:
self.view_presets.append(preset)
self._flush_to_project()
def _cb_apply_view_preset(self, name: str, f_item: models.FileItem) -> None:
def _cb_apply_view_preset(self, name: str, f_item: FileItem) -> None:
"""
[C: src/gui_2.py:App._render_context_files_table, tests/test_view_presets.py:test_apply_view_preset]
"""
@@ -3776,7 +3782,7 @@ class AppController:
self.discussion_sent_system_prompt = disc_data.get("sent_system_prompt", "")
if "context_snapshot" in disc_data:
snapshot_data = disc_data["context_snapshot"]
self.context_files = [models.FileItem.from_dict(f) if isinstance(f, dict) else models.FileItem(path=str(f)) for f in snapshot_data]
self.context_files = [FileItem.from_dict(f) if isinstance(f, dict) else FileItem(path=str(f)) for f in snapshot_data]
if self._app:
self._app.ui_selected_context_files = {f.path for f in self.context_files if f.auto_aggregate}
self.ai_status = f"discussion: {name}"
@@ -3913,8 +3919,8 @@ class AppController:
# unsynced forever (test_rag_phase4_final_verify regression on
# 2026-06-10).
self.rag_engine = None
from src import models as _rag_models
self.rag_config = _rag_models.RAGConfig()
from src.mcp_client import RAGConfig
self.rag_config = RAGConfig()
self.rag_status = 'idle'
self._rag_sync_token = 0
self._rag_sync_dirty = False
@@ -4720,7 +4726,7 @@ class AppController:
"""Phase 6 Group 6.7: topological sort with Result propagation.
On ValueError: fall back to raw_tickets (preserves existing behavior)."""
try:
normalized = [models.Ticket.from_dict(t) if isinstance(t, dict) else t for t in raw_tickets]
normalized = [Ticket.from_dict(t) if isinstance(t, dict) else t for t in raw_tickets]
sorted_tickets_data = conductor_tech_lead.topological_sort(normalized)
return Result(data=sorted_tickets_data)
except ValueError as e:
@@ -4773,7 +4779,7 @@ class AppController:
# 3. Create Track and Ticket objects
tickets = []
for t_data in sorted_tickets_data:
ticket = models.Ticket(
ticket = Ticket(
id=t_data["id"],
description=t_data.get("description") or t_data.get("goal", "No description"),
status=t_data.get("status", "todo"),
@@ -4783,10 +4789,10 @@ class AppController:
)
tickets.append(ticket)
track_id = f"track_{uuid.uuid5(uuid.NAMESPACE_DNS, f'{self.active_project_path}_{title}').hex[:12]}"
track = models.Track(id=track_id, description=title, tickets=tickets)
track = Track(id=track_id, description=title, tickets=tickets)
# Initialize track state in the filesystem
meta = models.Metadata(id=track_id, name=title, status="todo", created_at=datetime.now(), updated_at=datetime.now())
state = models.TrackState(metadata=meta, discussion=[], tasks=tickets)
state = TrackState(metadata=meta, discussion=[], tasks=tickets)
project_manager.save_track_state(track_id, state, self.active_project_root)
# Add to memory and notify UI
self.tracks.append({"id": track_id, "title": title, "status": "todo"})
@@ -5031,10 +5037,10 @@ class AppController:
tickets = []
for t in state.tasks:
if isinstance(t, dict):
tickets.append(models.Ticket(**t))
tickets.append(Ticket(**t))
else:
tickets.append(t)
self.active_track = models.Track(
self.active_track = Track(
id=state.metadata.id,
description=state.metadata.name,
tickets=tickets
@@ -5084,7 +5090,7 @@ class AppController:
track = self.active_track
if track is None: return OK
new_tickets = [
models.Ticket(
Ticket(
id=t.id,
description=t.description,
status=t.status,
@@ -5094,7 +5100,7 @@ class AppController:
for t in self.active_tickets
]
track.tickets = new_tickets
state = models.TrackState(metadata=track, tasks=list(new_tickets))
state = TrackState(metadata=track, tasks=list(new_tickets))
project_manager.save_track_state(track.id, state, self.active_project_root)
return OK
except (OSError, IOError, ValueError, TypeError, KeyError, AttributeError) as e:
@@ -5121,7 +5127,7 @@ class AppController:
beads_result = self._load_beads_from_path_result(Path(base))
if beads_result.ok:
for bead in beads_result.data:
self.active_tickets.append(models.Ticket(
self.active_tickets.append(Ticket(
id=bead.id,
description=bead.description or "",
status=bead.status,
+30 -30
View File
@@ -357,7 +357,7 @@ class App:
self.controller._predefined_callbacks['delete_context_preset'] = self.delete_context_preset
self.controller._predefined_callbacks['set_ui_file_paths'] = lambda p: setattr(self, 'ui_file_paths', p)
self.controller._predefined_callbacks['set_ui_screenshot_paths'] = lambda p: setattr(self, 'ui_screenshot_paths', p)
self.controller._predefined_callbacks['set_context_files_for_test'] = lambda files: setattr(self, 'context_files', [models.FileItem(path=f) for f in files])
self.controller._predefined_callbacks['set_context_files_for_test'] = lambda files: setattr(self, 'context_files', [FileItem(path=f) for f in files])
self.controller._predefined_callbacks['set_screenshots_for_test'] = lambda ss: setattr(self, 'screenshots', ss)
self.controller._predefined_callbacks['_toggle_command_palette'] = self._toggle_command_palette
self.controller._gettable_fields['show_command_palette'] = 'show_command_palette'
@@ -373,8 +373,8 @@ class App:
msk = copy.deepcopy(f.ast_mask)
sig = f.ast_signatures
dfn = f.ast_definitions
preset_files.append(models.ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = models.ContextPreset(name=name, files=preset_files, screenshots=list(self.screenshots))
preset_files.append(ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = ContextPreset(name=name, files=preset_files, screenshots=list(self.screenshots))
self.controller.save_context_preset(preset)
self.ui_new_context_preset_name = ""
self.show_missing_files_modal = False
@@ -541,12 +541,12 @@ class App:
def _set_context_files(self, paths: list[str]) -> None:
from src import models
self.context_files = [models.FileItem(path=p) for p in paths]
self.context_files = [FileItem(path=p) for p in paths]
self.controller.context_files = self.context_files
def _simulate_save_preset(self, name: str) -> None:
from src import models
item = models.FileItem(path='test.py')
item = FileItem(path='test.py')
self.files = [item]
self.context_files = [item]
self.screenshots = ['test.png']
@@ -865,20 +865,20 @@ class App:
from src import models
self.files = []
for f in snapshot.files:
if isinstance(f, dict): self.files.append(models.FileItem.from_dict(f))
else: self.files.append(models.FileItem(path=str(f)))
if isinstance(f, dict): self.files.append(FileItem.from_dict(f))
else: self.files.append(FileItem(path=str(f)))
self.context_files = []
for f in snapshot.context_files:
if isinstance(f, dict): self.context_files.append(models.FileItem.from_dict(f))
else: self.context_files.append(models.FileItem(path=str(f)))
if isinstance(f, dict): self.context_files.append(FileItem.from_dict(f))
else: self.context_files.append(FileItem(path=str(f)))
self.screenshots = list(snapshot.screenshots)
self._last_ui_snapshot = snapshot # Update last snapshot to avoid immediate re-push
finally:
self._is_applying_snapshot = False # ?? TODO(Ed): Whats the point of this??
def _capture_workspace_profile(self, name: str) -> models.WorkspaceProfile:
def _capture_workspace_profile(self, name: str) -> WorkspaceProfile:
"""Serializes the current window visibility states, popped-out panel layouts, and
ImGui INI configurations into a WorkspaceProfile object.
SSDL Shape: `[Q:ui_states] -> [B:ini_ready] -> [T:profile]`
@@ -908,14 +908,14 @@ class App:
"ui_separate_external_tools": getattr(self, "ui_separate_external_tools", False),
"ui_discussion_split_h": getattr(self, "ui_discussion_split_h", 300.0),
}
return models.WorkspaceProfile(
return WorkspaceProfile(
name = name,
ini_content = ini,
show_windows = copy.deepcopy(self.show_windows),
panel_states = panel_states
)
def _apply_workspace_profile(self, profile: models.WorkspaceProfile):
def _apply_workspace_profile(self, profile: WorkspaceProfile):
"""Restores the window docking layout and popped-out panel visibility states
from a saved WorkspaceProfile.
SSDL Shape: `[I:load_ini] -> [S:ui_states]`
@@ -975,7 +975,7 @@ class App:
import copy
self.context_files = []
for f in preset.files:
fi = models.FileItem(path=f.path, view_mode=f.view_mode)
fi = FileItem(path=f.path, view_mode=f.view_mode)
fi.custom_slices = copy.deepcopy(f.custom_slices)
fi.ast_mask = copy.deepcopy(f.ast_mask)
fi.ast_signatures = getattr(f, 'ast_signatures', False)
@@ -1007,7 +1007,7 @@ class App:
new_files.append(old_files[p])
else:
from src import models
new_files.append(models.FileItem(path=p, injected_at=now))
new_files.append(FileItem(path=p, injected_at=now))
self.files = new_files
@property
@@ -1259,7 +1259,7 @@ class App:
self.init_state()
self.ai_status = 'paths applied and session reset'
def _populate_auto_slices(self, f_item: models.FileItem) -> None:
def _populate_auto_slices(self, f_item: FileItem) -> None:
import re
from pathlib import Path
import os
@@ -3291,11 +3291,11 @@ def render_tool_preset_manager_content(app: App, is_embedded: bool = False) -> N
if tool: curr_cat_tools.remove(tool)
imgui.same_line();
if imgui.radio_button(f"Auto##{cat_name}_{tool_name}", mode == "auto"):
if not tool: tool = models.Tool(name=tool_name, approval="auto"); curr_cat_tools.append(tool)
if not tool: tool = Tool(name=tool_name, approval="auto"); curr_cat_tools.append(tool)
else: tool.approval = "auto"
imgui.same_line();
if imgui.radio_button(f"Ask##{cat_name}_{tool_name}", mode == "ask"):
if not tool: tool = models.Tool(name=tool_name, approval="ask"); curr_cat_tools.append(tool)
if not tool: tool = Tool(name=tool_name, approval="ask"); curr_cat_tools.append(tool)
else: tool.approval = "ask"
imgui.tree_pop()
if app._bias_list_open:
@@ -3694,7 +3694,7 @@ def render_files_and_media(app: App) -> None:
if imgui.button(f"+##add_f_{i}"):
if not in_context:
from src import models
new_item = models.FileItem(path=fpath)
new_item = FileItem(path=fpath)
app.context_files.append(new_item)
app._populate_auto_slices(new_item)
@@ -3718,7 +3718,7 @@ def render_files_and_media(app: App) -> None:
r = hide_tk_root(); paths = filedialog.askopenfilenames(); r.destroy()
from src import models
for p in paths:
if p not in [f.path for f in app.files]: app.files.append(models.FileItem(path=p))
if p not in [f.path for f in app.files]: app.files.append(FileItem(path=p))
imgui.same_line()
if imgui.button("Add Directory"):
r = hide_tk_root(); dirpath = filedialog.askdirectory(); r.destroy()
@@ -3728,7 +3728,7 @@ def render_files_and_media(app: App) -> None:
for fname in files:
full = os.path.join(root, fname)
if full not in existing:
app.files.append(models.FileItem(path=full))
app.files.append(FileItem(path=full))
existing.add(full)
imgui.separator()
@@ -3852,7 +3852,7 @@ def render_add_context_files_modal(app: App) -> None:
if imgui.button("Add Selected", imgui.ImVec2(120, 0)):
for fpath in app._ui_picker_selected:
f_item = models.FileItem(path=fpath)
f_item = FileItem(path=fpath)
app.context_files.append(f_item)
app._populate_auto_slices(f_item)
app._ui_picker_selected.clear()
@@ -4369,8 +4369,8 @@ def render_context_presets(app: App) -> None:
msk = copy.deepcopy(f.ast_mask)
sig = f.ast_signatures
dfn = f.ast_definitions
preset_files.append(models.ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = models.ContextPreset(name=active, files=preset_files, screenshots=list(app.screenshots))
preset_files.append(ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = ContextPreset(name=active, files=preset_files, screenshots=list(app.screenshots))
app.controller.save_context_preset(preset)
else:
imgui.text_disabled("No active preset")
@@ -4409,8 +4409,8 @@ def render_context_presets(app: App) -> None:
msk = copy.deepcopy(f.ast_mask)
sig = f.ast_signatures
dfn = f.ast_definitions
preset_files.append(models.ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = models.ContextPreset(name=name, files=preset_files, screenshots=list(app.screenshots))
preset_files.append(ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = ContextPreset(name=name, files=preset_files, screenshots=list(app.screenshots))
app.controller.save_context_preset(preset)
app.ui_new_context_preset_name = ""
@@ -4544,8 +4544,8 @@ def render_context_modals(app: App) -> None:
msk = copy.deepcopy(f.ast_mask)
sig = f.ast_signatures
dfn = f.ast_definitions
preset_files.append(models.ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = models.ContextPreset(name=name, files=preset_files, screenshots=list(app.screenshots))
preset_files.append(ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn))
preset = ContextPreset(name=name, files=preset_files, screenshots=list(app.screenshots))
app.controller.save_context_preset(preset)
app.ui_new_context_preset_name = ""
imgui.close_current_popup()
@@ -7839,7 +7839,7 @@ def _handle_history_logic_result(app: "App") -> Result[bool]:
def _render_persona_editor_save_result(app: "App") -> Result[bool]:
"""Drain-aware variant of L3398 render_persona_editor_window Save button try/except.
Extracts the models.Persona(...) construction + app.controller._cb_save_persona
Extracts the Persona(...) construction + app.controller._cb_save_persona
try/except from the Save button handler in render_persona_editor_window into a
Result-returning helper. On success, sets app.ai_status to "Saved: <name>"
and returns Result(data=True). On failure (any exception in Persona
@@ -7853,7 +7853,7 @@ def _render_persona_editor_save_result(app: "App") -> Result[bool]:
"""
try:
import copy
persona = models.Persona(
persona = Persona(
name=app._editing_persona_name.strip(),
system_prompt=app._editing_persona_system_prompt,
tool_preset=app._editing_persona_tool_preset_id or None,
@@ -8158,7 +8158,7 @@ def _render_tool_preset_bias_save_result(app: "App") -> Result[bool]:
[C: src/gui_2.py:render_tool_preset_manager_content (L3163 legacy wrapper)]
"""
try:
p = models.BiasProfile(
p = BiasProfile(
name=app._editing_bias_profile_name,
tool_weights=app._editing_bias_profile_tool_weights,
category_multipliers=app._editing_bias_profile_category_multipliers,
+1 -1
View File
@@ -194,7 +194,7 @@ def load_project(path: Union[str, Path]) -> Metadata:
# Deserialise FileItems in files.paths
if "files" in proj and "paths" in proj["files"]:
from src import models
proj["files"]["paths"] = [models.FileItem.from_dict(p) if isinstance(p, dict) else p for p in proj["files"]["paths"]]
proj["files"]["paths"] = [FileItem.from_dict(p) if isinstance(p, dict) else p for p in proj["files"]["paths"]]
hist_path = get_history_path(path)
if "discussion" in proj:
disc = proj.pop("discussion")
+5 -4
View File
@@ -8,10 +8,11 @@ from dataclasses import dataclass, field, fields as dc_fields
from typing import List, Dict, Any, Optional
from src import ai_client
from src import models
from src import mcp_client
from src.result_types import ErrorInfo, ErrorKind, NilRAGState, Result
from src.type_aliases import Metadata
from src import models
from src.mcp_client import RAGConfig
from src.result_types import ErrorInfo, ErrorKind, NilRAGState, Result
from src.type_aliases import Metadata
from src.file_cache import ASTParser
@@ -121,7 +122,7 @@ def _parse_search_response_result(res_str: str) -> Result[List[Dict[str, Any]]]:
class RAGEngine:
def __init__(self, config: models.RAGConfig, base_dir: str = "."):
def __init__(self, config: RAGConfig, base_dir: str = "."):
self.config = copy.deepcopy(config)
self.base_dir = base_dir
self.client = None
+1 -1
View File
@@ -146,7 +146,7 @@ class HistoryMessage:
History: TypeAlias = list[HistoryMessage]
FileItem: TypeAlias = "models.FileItem"
FileItem: TypeAlias = "FileItem"
FileItems: TypeAlias = list[FileItem]
+1 -1
View File
@@ -8,7 +8,7 @@ def test_ast_inspector_line_range_parsing():
app = MagicMock(spec=App)
app._show_ast_inspector = True
app.show_structural_editor_modal = True
app.ui_inspecting_ast_file = models.FileItem(path="test.py")
app.ui_inspecting_ast_file = FileItem(path="test.py")
app.ui_editing_slices_file = app.ui_inspecting_ast_file
app._cached_ast_file_path = ""
app._cached_ast_nodes = []
+1 -1
View File
@@ -27,7 +27,7 @@ def mock_app():
return app
def test_populate_auto_slices_basic(mock_app: App) -> None:
f_item = models.FileItem(path="test.py")
f_item = FileItem(path="test.py")
mock_outline = "[Class] MyClass (Lines 1-10)\n[Method] my_method (Lines 2-5)\n[Func] top_func (Lines 12-15)"
with (
+2 -2
View File
@@ -11,7 +11,7 @@ async def test_external_mcp_real_process():
# Use our mock script
mock_script = "scripts/mock_mcp_server.py"
config = models.MCPServerConfig(
config = MCPServerConfig(
name="real-mock",
command="python",
args=[mock_script]
@@ -36,7 +36,7 @@ async def test_get_tool_schemas_includes_external():
await manager.stop_all()
mock_script = "scripts/mock_mcp_server.py"
config = models.MCPServerConfig(
config = MCPServerConfig(
name="test-server",
command="python",
args=[mock_script]
+1 -1
View File
@@ -9,7 +9,7 @@ def test_files_rendered_under_directory_grouping(app_instance):
os.makedirs(sub, exist_ok=True)
for p in [os.path.join(tmp, "a.py"), os.path.join(tmp, "b.py"), os.path.join(sub, "c.py")]:
open(p, "w").close()
app_instance.files = [models.FileItem(path=os.path.join(tmp, "a.py")), models.FileItem(path=os.path.join(tmp, "b.py")), models.FileItem(path=os.path.join(sub, "c.py"))]
app_instance.files = [FileItem(path=os.path.join(tmp, "a.py")), FileItem(path=os.path.join(tmp, "b.py")), FileItem(path=os.path.join(sub, "c.py"))]
with patch("src.gui_2.imgui") as mock_imgui, patch("src.gui_2.imscope") as mock_imscope, patch("src.gui_2.filedialog") as mock_filedialog, patch("src.gui_2.hide_tk_root", return_value=MagicMock()):
mock_imgui.collapsing_header.return_value = True
mock_imgui.TableFlags_ = type("T", (), {"resizable": 1, "borders": 2, "row_bg": 4})()
+1 -1
View File
@@ -715,7 +715,7 @@ def test_phase_4_l3398_render_persona_editor_save_result_success():
L3398 _render_persona_editor_save_result returns Result.ok=True on success.
The helper wraps the Save button try/except in render_persona_editor_window
(Persona creation: models.Persona(...) + _cb_save_persona). On success,
(Persona creation: Persona(...) + _cb_save_persona). On success,
sets app.ai_status to "Saved: <name>" and returns Result(data=True).
"""
from src import gui_2
+1 -1
View File
@@ -37,7 +37,7 @@ def test_render_ticket_queue_table_columns():
from src.gui_2 import App, render_ticket_queue
app = App.__new__(App)
app.active_track = MagicMock()
app.active_tickets = [models.Ticket(id="T-001", description="Test task", priority="medium", status="in_progress")]
app.active_tickets = [Ticket(id="T-001", description="Test task", priority="medium", status="in_progress")]
app.ui_selected_tickets = set()
app.ui_selected_ticket_id = None
app.controller = MagicMock()
+1 -1
View File
@@ -34,7 +34,7 @@ def test_render_mma_dashboard_progress():
app.active_track = MagicMock()
app.active_track.description = "Test Track"
# Mock self.active_track.tickets as a list of src.models.Ticket objects
# Mock self.active_track.tickets as a list of src.Ticket objects
app.active_track.tickets = [
Ticket(id='T1', description='desc', status='completed'),
Ticket(id='T2', description='desc', status='in_progress'),
+4 -4
View File
@@ -9,7 +9,7 @@ def test_mcp_server_config_to_from_dict():
"args": ["server.js"],
"auto_start": True
}
cfg = models.MCPServerConfig.from_dict("test-server", data)
cfg = MCPServerConfig.from_dict("test-server", data)
assert cfg.name == "test-server"
assert cfg.command == "node"
assert cfg.args == ["server.js"]
@@ -31,7 +31,7 @@ def test_mcp_configuration_to_from_dict():
}
}
}
cfg = models.MCPConfiguration.from_dict(data)
cfg = MCPConfiguration.from_dict(data)
assert len(cfg.mcpServers) == 2
assert cfg.mcpServers["server1"].command == "python"
assert cfg.mcpServers["server2"].url == "http://localhost:8080/sse"
@@ -47,7 +47,7 @@ def test_load_mcp_config(tmp_path):
config_file.write_text(json.dumps(data))
# We'll need a way to load from a specific path
# Maybe models.load_mcp_config(path)
cfg = models.load_mcp_config(str(config_file))
# Maybe load_mcp_config(path)
cfg = load_mcp_config(str(config_file))
assert "test" in cfg.mcpServers
assert cfg.mcpServers["test"].command == "echo"
+8 -8
View File
@@ -2,8 +2,8 @@
Phase 1 of metadata_promotion_20260624.
Verifies:
1. self.active_tickets load boundaries convert dicts to models.Ticket
2. conductor_tech_lead.topological_sort returns list[models.Ticket]
1. self.active_tickets load boundaries convert dicts to Ticket
2. conductor_tech_lead.topological_sort returns list[Ticket]
3. gui_2.py consumer sites use direct field access (not .get())
4. app_controller.py consumer sites use direct field access (not .get())
"""
@@ -15,11 +15,11 @@ from src.mma import Ticket
class TestActiveTicketsType:
def test_active_tickets_annotation_is_list_of_ticket(self) -> None:
"""self.active_tickets type hint must be list[models.Ticket], not list[Metadata]."""
"""self.active_tickets type hint must be list[Ticket], not list[Metadata]."""
from src.app_controller import AppController
src_text = inspect.getsource(AppController.__init__)
assert "list[models.Ticket]" in src_text, (
"AppController.__init__ must declare self.active_tickets: list[models.Ticket]"
assert "list[Ticket]" in src_text, (
"AppController.__init__ must declare self.active_tickets: list[Ticket]"
)
assert "list[Metadata]" not in src_text.split("self.active_tickets")[1].split("\n")[0], (
"AppController.__init__ must NOT declare self.active_tickets: list[Metadata]"
@@ -28,7 +28,7 @@ class TestActiveTicketsType:
class TestActiveTicketsLoadBoundaries:
def test_load_at_data_converts_dicts_to_tickets(self) -> None:
"""_deserialize_active_track_result boundary must wrap dicts as models.Ticket."""
"""_deserialize_active_track_result boundary must wrap dicts as Ticket."""
from src.app_controller import AppController
with patch.object(AppController, "load_config", return_value={
'ai': {'provider': 'gemini', 'model': 'gemini-2.5-flash-lite'},
@@ -56,7 +56,7 @@ class TestActiveTicketsLoadBoundaries:
)
def test_load_active_tickets_beads_branch_converts_dicts_to_tickets(self) -> None:
"""_load_active_tickets (beads branch) must wrap bead dicts as models.Ticket."""
"""_load_active_tickets (beads branch) must wrap bead dicts as Ticket."""
from src.app_controller import AppController
from src.mma import Ticket
ctrl = AppController.__new__(AppController)
@@ -79,7 +79,7 @@ class TestActiveTicketsLoadBoundaries:
class TestTopologicalSortReturnsTicketList:
def test_topological_sort_returns_ticket_instances(self) -> None:
"""conductor_tech_lead.topological_sort must return list[models.Ticket]."""
"""conductor_tech_lead.topological_sort must return list[Ticket]."""
from src import conductor_tech_lead
sig = inspect.signature(conductor_tech_lead.topological_sort)
assert sig.return_annotation is not inspect.Signature.empty
+1 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import pytest
from src.project_manager import flat_config
from src.models import (
from src.project import (
ProjectContext, ProjectMeta, ProjectOutput, ProjectFiles,
ProjectScreenshots, ProjectDiscussion, EMPTY_PROJECT_CONTEXT,
)
+9 -9
View File
@@ -17,8 +17,8 @@ class TestProjectSerialization(unittest.TestCase):
def test_fileitem_roundtrip(self):
"""Verify that FileItem objects survive a save/load cycle."""
proj = project_manager.default_project("test")
file1 = models.FileItem(path="src/main.py", auto_aggregate=True, force_full=False)
file2 = models.FileItem(path="docs/readme.md", auto_aggregate=False, force_full=True)
file1 = FileItem(path="src/main.py", auto_aggregate=True, force_full=False)
file2 = FileItem(path="docs/readme.md", auto_aggregate=False, force_full=True)
proj["files"]["paths"] = [file1, file2]
# Save
@@ -29,12 +29,12 @@ class TestProjectSerialization(unittest.TestCase):
paths = loaded_proj["files"]["paths"]
self.assertEqual(len(paths), 2)
self.assertIsInstance(paths[0], models.FileItem)
self.assertIsInstance(paths[0], FileItem)
self.assertEqual(paths[0].path, "src/main.py")
self.assertTrue(paths[0].auto_aggregate)
self.assertFalse(paths[0].force_full)
self.assertIsInstance(paths[1], models.FileItem)
self.assertIsInstance(paths[1], FileItem)
self.assertEqual(paths[1].path, "docs/readme.md")
self.assertFalse(paths[1].auto_aggregate)
self.assertTrue(paths[1].force_full)
@@ -68,17 +68,17 @@ roles = ["User", "AI"]
raw_paths = controller.project.get("files", {}).get("paths", [])
controller.files = []
for p in raw_paths:
if isinstance(p, models.FileItem):
if isinstance(p, FileItem):
controller.files.append(p)
elif isinstance(p, dict):
controller.files.append(models.FileItem.from_dict(p))
controller.files.append(FileItem.from_dict(p))
else:
controller.files.append(models.FileItem(path=str(p)))
controller.files.append(FileItem(path=str(p)))
self.assertEqual(len(controller.files), 2)
self.assertIsInstance(controller.files[0], models.FileItem)
self.assertIsInstance(controller.files[0], FileItem)
self.assertEqual(controller.files[0].path, "file1.py")
self.assertIsInstance(controller.files[1], models.FileItem)
self.assertIsInstance(controller.files[1], FileItem)
self.assertEqual(controller.files[1].path, "file2.md")
def test_default_roles_include_context(self):
+8 -8
View File
@@ -11,8 +11,8 @@ class MockEmbeddingProvider(BaseEmbeddingProvider):
@pytest.fixture
def mock_rag_config():
vs_config = models.VectorStoreConfig(provider='mock', collection_name='test')
return models.RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='gemini')
vs_config = VectorStoreConfig(provider='mock', collection_name='test')
return RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='gemini')
def test_rag_engine_init_mock(mock_rag_config):
engine = RAGEngine(mock_rag_config)
@@ -38,8 +38,8 @@ def test_rag_engine_chroma(mock_get_chroma, mock_embed):
mock_client.get_or_create_collection.return_value = mock_collection
mock_chroma.PersistentClient.return_value = mock_client
vs_config = models.VectorStoreConfig(provider='chroma', collection_name='test')
config = models.RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='local')
vs_config = VectorStoreConfig(provider='chroma', collection_name='test')
config = RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='local')
with patch('src.rag_engine._get_sentence_transformers') as mock_st:
mock_st.return_value = MagicMock()
@@ -97,8 +97,8 @@ def test_rag_collection_dim_mismatch_recreates_collection(mock_get_chroma, mock_
mock_client.get_or_create_collection.return_value = mock_collection
mock_chroma.PersistentClient.return_value = mock_client
vs_config = models.VectorStoreConfig(provider='chroma', collection_name='test')
config = models.RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='local')
vs_config = VectorStoreConfig(provider='chroma', collection_name='test')
config = RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='local')
with patch('src.rag_engine._get_sentence_transformers') as mock_st:
mock_st.return_value = MagicMock()
@@ -136,8 +136,8 @@ def test_rag_collection_dim_match_preserves_collection(mock_get_chroma, mock_emb
mock_client.get_or_create_collection.return_value = mock_collection
mock_chroma.PersistentClient.return_value = mock_client
vs_config = models.VectorStoreConfig(provider='chroma', collection_name='test')
config = models.RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='local')
vs_config = VectorStoreConfig(provider='chroma', collection_name='test')
config = RAGConfig(enabled=True, vector_store=vs_config, embedding_provider='local')
with patch('src.rag_engine._get_sentence_transformers') as mock_st:
mock_st.return_value = MagicMock()
+4 -4
View File
@@ -37,10 +37,10 @@ def test_rag_engine_init_with_local_provider_raises_when_sentence_transformers_m
when sentence-transformers is not installed.
"""
from src import models
config = models.RAGConfig(
config = RAGConfig(
enabled=True,
embedding_provider="local",
vector_store=models.VectorStoreConfig(provider="chroma", collection_name="test"),
vector_store=VectorStoreConfig(provider="chroma", collection_name="test"),
)
# Force the import to fail
with patch.dict(sys.modules, {"sentence_transformers": None}):
@@ -125,10 +125,10 @@ def test_rag_engine_init_with_failing_local_embedding_leaves_engine_broken() ->
"""
from src import models
from src import rag_engine
config = models.RAGConfig(
config = RAGConfig(
enabled=True,
embedding_provider="local",
vector_store=models.VectorStoreConfig(provider="chroma", collection_name="t"),
vector_store=VectorStoreConfig(provider="chroma", collection_name="t"),
)
with patch("src.rag_engine._get_sentence_transformers",
side_effect=ImportError("Local RAG embeddings require sentence-transformers.")):
+2 -2
View File
@@ -55,9 +55,9 @@ def test_rag_integration(mock_project):
ai_client.set_provider("gemini", "gemini-1.5-flash")
# 2. Configures a mock RAG setup (enabled=True, provider='mock').
rag_config = models.RAGConfig(
rag_config = RAGConfig(
enabled=True,
vector_store=models.VectorStoreConfig(provider='mock')
vector_store=VectorStoreConfig(provider='mock')
)
app.rag_config = rag_config
+3 -3
View File
@@ -25,7 +25,7 @@ def test_ui_summary_only_not_in_app_controller_projects():
def test_file_item_has_per_file_flags():
item = models.FileItem(path="test.py")
item = FileItem(path="test.py")
assert hasattr(item, "auto_aggregate")
assert hasattr(item, "force_full")
assert item.auto_aggregate is True
@@ -33,13 +33,13 @@ def test_file_item_has_per_file_flags():
def test_file_item_serialization_with_flags():
item = models.FileItem(path="test.py", auto_aggregate=False, force_full=True)
item = FileItem(path="test.py", auto_aggregate=False, force_full=True)
data = item.to_dict()
assert data["auto_aggregate"] is False
assert data["force_full"] is True
restored = models.FileItem.from_dict(data)
restored = FileItem.from_dict(data)
assert restored.auto_aggregate is False
assert restored.force_full is True
+4 -4
View File
@@ -38,7 +38,7 @@ def controller(tmp_path):
return ctrl
def test_save_view_preset(controller):
f_item = models.FileItem(path="test.py", view_mode="skeleton")
f_item = FileItem(path="test.py", view_mode="skeleton")
f_item.ast_mask = {"test::func": "sig"}
f_item.custom_slices = [{"start_line": 1, "end_line": 10}]
@@ -58,7 +58,7 @@ def test_save_view_preset(controller):
def test_apply_view_preset(controller):
# Setup a preset
preset = models.NamedViewPreset(
preset = NamedViewPreset(
name="my_preset",
view_mode="masked",
ast_mask={"main::run": "def"},
@@ -67,7 +67,7 @@ def test_apply_view_preset(controller):
controller.view_presets.append(preset)
# Create a file item to apply to
f_item = models.FileItem(path="main.py", view_mode="summary")
f_item = FileItem(path="main.py", view_mode="summary")
controller._cb_apply_view_preset("my_preset", f_item)
@@ -76,7 +76,7 @@ def test_apply_view_preset(controller):
assert f_item.custom_slices == [{"start_line": 5, "end_line": 15}]
def test_delete_view_preset(controller):
preset = models.NamedViewPreset(name="to_del", view_mode="full")
preset = NamedViewPreset(name="to_del", view_mode="full")
controller.view_presets.append(preset)
controller._cb_delete_view_preset("to_del")