This commit is contained in:
2026-03-12 18:47:17 -04:00
parent 23943443e3
commit 19e7c94c2e
8 changed files with 65 additions and 46 deletions

View File

@@ -443,7 +443,7 @@ class AppController:
return return
target_path = self._inject_file_path target_path = self._inject_file_path
if not os.path.isabs(target_path): if not os.path.isabs(target_path):
target_path = os.path.join(self.ui_files_base_dir, target_path) target_path = os.path.join(self.active_project_root, target_path)
if not os.path.exists(target_path): if not os.path.exists(target_path):
self._inject_preview = "" self._inject_preview = ""
return return
@@ -1679,7 +1679,7 @@ class AppController:
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}") raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}")
user_msg = req.prompt user_msg = req.prompt
base_dir = self.ui_files_base_dir base_dir = self.active_project_root
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
ai_client.set_custom_system_prompt("\n\n".join(csp)) ai_client.set_custom_system_prompt("\n\n".join(csp))
temp = req.temperature if req.temperature is not None else self.temperature temp = req.temperature if req.temperature is not None else self.temperature
@@ -1787,7 +1787,7 @@ class AppController:
return { return {
"files": [f.get("path") if isinstance(f, dict) else str(f) for f in file_items], "files": [f.get("path") if isinstance(f, dict) else str(f) for f in file_items],
"screenshots": screenshots, "screenshots": screenshots,
"files_base_dir": self.ui_files_base_dir, "files_base_dir": self.active_project_root,
"markdown": md, "markdown": md,
"discussion": disc_text "discussion": disc_text
} }
@@ -1904,9 +1904,9 @@ class AppController:
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries = models.parse_history_entries(track_history, self.disc_roles) self.disc_entries = models.parse_history_entries(track_history, self.disc_roles)
self.preset_manager.project_root = Path(self.ui_files_base_dir) self.preset_manager.project_root = Path(self.active_project_root)
self.presets = self.preset_manager.load_all() self.presets = self.preset_manager.load_all()
self.tool_preset_manager.project_root = Path(self.ui_files_base_dir) self.tool_preset_manager.project_root = Path(self.active_project_root)
self.tool_presets = self.tool_preset_manager.load_all_presets() self.tool_presets = self.tool_preset_manager.load_all_presets()
self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles()
@@ -2032,7 +2032,7 @@ class AppController:
def _flush_disc_entries_to_project(self) -> None: def _flush_disc_entries_to_project(self) -> None:
history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries] history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries]
if self.active_track and self._track_discussion_active: if self.active_track and self._track_discussion_active:
project_manager.save_track_history(self.active_track.id, history_strings, self.ui_files_base_dir) project_manager.save_track_history(self.active_track.id, history_strings, self.active_project_root)
return return
disc_sec = self.project.setdefault("discussion", {}) disc_sec = self.project.setdefault("discussion", {})
discussions = disc_sec.setdefault("discussions", {}) discussions = disc_sec.setdefault("discussions", {})
@@ -2202,7 +2202,7 @@ class AppController:
file_path, definition, line = res file_path, definition, line = res
user_msg += f'\n\n[Definition: {symbol} from {file_path} (line {line})]\n```python\n{definition}\n```' user_msg += f'\n\n[Definition: {symbol} from {file_path} (line {line})]\n```python\n{definition}\n```'
base_dir = self.ui_files_base_dir base_dir = self.active_project_root
sys.stderr.write(f"[DEBUG] _do_generate success. Prompt: {user_msg[:50]}...\n") sys.stderr.write(f"[DEBUG] _do_generate success. Prompt: {user_msg[:50]}...\n")
sys.stderr.flush() sys.stderr.flush()
# Prepare event payload # Prepare event payload
@@ -2383,7 +2383,7 @@ class AppController:
sys.stderr.flush() sys.stderr.flush()
proj = project_manager.load_project(self.active_project_path) proj = project_manager.load_project(self.active_project_path)
flat = project_manager.flat_config(self.project) flat = project_manager.flat_config(self.project)
file_items = aggregate.build_file_items(Path(self.ui_files_base_dir), flat.get("files", {}).get("paths", [])) file_items = aggregate.build_file_items(Path(self.active_project_root), flat.get("files", {}).get("paths", []))
_t1_baseline = len(ai_client.get_comms_log()) _t1_baseline = len(ai_client.get_comms_log())
tracks = orchestrator_pm.generate_tracks(self.ui_epic_input, flat, file_items, history_summary=history) tracks = orchestrator_pm.generate_tracks(self.ui_epic_input, flat, file_items, history_summary=history)
@@ -2435,7 +2435,7 @@ class AppController:
for i, file_path in enumerate(files_to_scan): for i, file_path in enumerate(files_to_scan):
try: try:
self._set_status(f"Phase 2: Scanning files ({i+1}/{len(files_to_scan)})...") self._set_status(f"Phase 2: Scanning files ({i+1}/{len(files_to_scan)})...")
abs_path = Path(self.ui_files_base_dir) / file_path abs_path = Path(self.active_project_root) / file_path
if abs_path.exists() and abs_path.suffix == ".py": if abs_path.exists() and abs_path.suffix == ".py":
with open(abs_path, "r", encoding="utf-8") as f: with open(abs_path, "r", encoding="utf-8") as f:
code = f.read() code = f.read()
@@ -2537,7 +2537,7 @@ class AppController:
# Initialize track state in the filesystem # Initialize track state in the filesystem
meta = models.Metadata(id=track_id, name=title, status="todo", created_at=datetime.now(), updated_at=datetime.now()) 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 = models.TrackState(metadata=meta, discussion=[], tasks=tickets)
project_manager.save_track_state(track_id, state, self.ui_files_base_dir) project_manager.save_track_state(track_id, state, self.active_project_root)
# Add to memory and notify UI # Add to memory and notify UI
self.tracks.append({"id": track_id, "title": title, "status": "todo"}) self.tracks.append({"id": track_id, "title": title, "status": "todo"})
with self._pending_gui_tasks_lock: with self._pending_gui_tasks_lock:
@@ -2602,7 +2602,7 @@ class AppController:
file_path = data.get("file_path") file_path = data.get("file_path")
if file_path: if file_path:
if not os.path.isabs(file_path): if not os.path.isabs(file_path):
file_path = os.path.relpath(file_path, self.ui_files_base_dir) file_path = os.path.relpath(file_path, self.active_project_root)
existing = next((f for f in self.files if (f.path if hasattr(f, "path") else str(f)) == file_path), None) existing = next((f for f in self.files if (f.path if hasattr(f, "path") else str(f)) == file_path), None)
if not existing: if not existing:
item = models.FileItem(path=file_path) item = models.FileItem(path=file_path)
@@ -2630,7 +2630,7 @@ class AppController:
self._push_mma_state_update() self._push_mma_state_update()
def _cb_run_conductor_setup(self) -> None: def _cb_run_conductor_setup(self) -> None:
base = paths.get_conductor_dir() base = paths.get_conductor_dir(project_path=self.active_project_root)
if not base.exists(): if not base.exists():
self.ui_conductor_setup_summary = f"Error: {base}/ directory not found." self.ui_conductor_setup_summary = f"Error: {base}/ directory not found."
return return
@@ -2687,7 +2687,7 @@ class AppController:
# Sync active_tickets (list of dicts) back to active_track.tickets (list of models.Ticket objects) # Sync active_tickets (list of dicts) back to active_track.tickets (list of models.Ticket objects)
self.active_track.tickets = [models.Ticket.from_dict(t) for t in self.active_tickets] self.active_track.tickets = [models.Ticket.from_dict(t) for t in self.active_tickets]
# Save the state to disk # Save the state to disk
existing = project_manager.load_track_state(self.active_track.id, self.ui_files_base_dir) existing = project_manager.load_track_state(self.active_track.id, self.active_project_root)
meta = models.Metadata( meta = models.Metadata(
id=self.active_track.id, id=self.active_track.id,
name=self.active_track.description, name=self.active_track.description,
@@ -2700,5 +2700,5 @@ class AppController:
discussion=existing.discussion if existing else [], discussion=existing.discussion if existing else [],
tasks=self.active_track.tickets tasks=self.active_track.tickets
) )
project_manager.save_track_state(self.active_track.id, state, self.ui_files_base_dir) project_manager.save_track_state(self.active_track.id, state, self.active_project_root)

View File

@@ -102,6 +102,15 @@ class AsyncEventQueue:
""" """
return self._queue.get() return self._queue.get()
def empty(self) -> bool:
"""
Checks if the queue is empty.
Returns:
True if the queue is empty, False otherwise.
"""
return self._queue.empty()
def task_done(self) -> None: def task_done(self) -> None:
"""Signals that a formerly enqueued task is complete.""" """Signals that a formerly enqueued task is complete."""
self._queue.task_done() self._queue.task_done()

View File

@@ -2026,7 +2026,7 @@ def hello():
if changed: if changed:
if self._track_discussion_active: if self._track_discussion_active:
self._flush_disc_entries_to_project() self._flush_disc_entries_to_project()
history_strings = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir) history_strings = project_manager.load_track_history(self.active_track.id, self.active_project_root)
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries = models.parse_history_entries(history_strings, self.disc_roles) self.disc_entries = models.parse_history_entries(history_strings, self.disc_roles)
self.ai_status = f"track discussion: {self.active_track.id}" self.ai_status = f"track discussion: {self.active_track.id}"

View File

@@ -6,6 +6,7 @@ import os
import json import json
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from src import paths
def test_track_proposal_editing(app_instance): def test_track_proposal_editing(app_instance):
@@ -30,7 +31,7 @@ def test_track_proposal_editing(app_instance):
assert app_instance.proposed_tracks[0]['title'] == "New Title" assert app_instance.proposed_tracks[0]['title'] == "New Title"
def test_conductor_setup_scan(app_instance, tmp_path): def test_conductor_setup_scan(app_instance, tmp_path, monkeypatch):
""" """
Verifies that the conductor setup scan properly iterates through the conductor directory, Verifies that the conductor setup scan properly iterates through the conductor directory,
counts files and lines, and identifies active tracks. counts files and lines, and identifies active tracks.
@@ -44,6 +45,9 @@ def test_conductor_setup_scan(app_instance, tmp_path):
(cond_dir / "tracks").mkdir(exist_ok=True) (cond_dir / "tracks").mkdir(exist_ok=True)
(cond_dir / "tracks" / "track1").mkdir(exist_ok=True) (cond_dir / "tracks" / "track1").mkdir(exist_ok=True)
monkeypatch.setenv('SLOP_CONDUCTOR_DIR', str((tmp_path / 'conductor').resolve()))
paths.reset_resolved()
app_instance._cb_run_conductor_setup() app_instance._cb_run_conductor_setup()
# ANTI-SIMPLIFICATION: Assert that the summary output correctly counts files/lines/tracks # ANTI-SIMPLIFICATION: Assert that the summary output correctly counts files/lines/tracks

View File

@@ -58,6 +58,9 @@ def test_gui_updates_on_event(app_instance: App) -> None:
mock_stats = {"percentage": 50.0, "current": 500, "limit": 1000} mock_stats = {"percentage": 50.0, "current": 500, "limit": 1000}
app_instance.last_md = "mock_md" app_instance.last_md = "mock_md"
with patch('src.ai_client.get_token_stats', return_value=mock_stats): with patch('src.ai_client.get_token_stats', return_value=mock_stats):
# Drain the queue
while not app_instance.event_queue.empty():
app_instance.event_queue.get()
# Simulate receiving an event from the API client thread # Simulate receiving an event from the API client thread
app_instance._on_api_event(payload={"text": "test"}) app_instance._on_api_event(payload={"text": "test"})

View File

@@ -37,6 +37,10 @@ def test_user_request_integration_flow(mock_app: App) -> None:
disc_text="History", disc_text="History",
base_dir="." base_dir="."
) )
while not app.controller.event_queue.empty():
app.controller.event_queue.get()
# 2. Call the handler directly since start_services is mocked (no event loop thread) # 2. Call the handler directly since start_services is mocked (no event loop thread)
# But _handle_request_event itself puts a 'response' event in the queue. # But _handle_request_event itself puts a 'response' event in the queue.
# Our mock_app fixture mocks start_services, so _process_event_queue is NOT running. # Our mock_app fixture mocks start_services, so _process_event_queue is NOT running.
@@ -87,6 +91,10 @@ def test_user_request_error_handling(mock_app: App) -> None:
disc_text="", disc_text="",
base_dir="." base_dir="."
) )
while not app.controller.event_queue.empty():
app.controller.event_queue.get()
app.controller._handle_request_event(event) app.controller._handle_request_event(event)
# Manually consume from queue # Manually consume from queue

View File

@@ -54,7 +54,7 @@ def test_mma_dashboard_refresh(app_instance: Any) -> None:
assert len(app.tracks) == 2 assert len(app.tracks) == 2
assert app.tracks[0]["id"] == "track_1" assert app.tracks[0]["id"] == "track_1"
assert app.tracks[1]["id"] == "track_2" assert app.tracks[1]["id"] == "track_2"
mock_pm.get_all_tracks.assert_called_with(app.ui_files_base_dir) mock_pm.get_all_tracks.assert_called_with(app.active_project_root)
def test_mma_dashboard_initialization_refresh(app_instance: Any) -> None: def test_mma_dashboard_initialization_refresh(app_instance: Any) -> None:

View File

@@ -10,26 +10,31 @@ def reset_paths():
paths.reset_resolved() paths.reset_resolved()
def test_default_paths(): def test_default_paths():
assert paths.get_conductor_dir() == Path("conductor")
assert paths.get_logs_dir() == Path("logs/sessions")
assert paths.get_scripts_dir() == Path("scripts/generated")
# config path should now be an absolute path relative to src/paths.py
root_dir = Path(paths.__file__).resolve().parent.parent root_dir = Path(paths.__file__).resolve().parent.parent
assert paths.get_conductor_dir() == root_dir / "conductor"
assert paths.get_logs_dir() == root_dir / "logs/sessions"
assert paths.get_scripts_dir() == root_dir / "scripts/generated"
# config path should now be an absolute path relative to src/paths.py
assert paths.get_config_path() == root_dir / "config.toml" assert paths.get_config_path() == root_dir / "config.toml"
assert paths.get_tracks_dir() == Path("conductor/tracks") assert paths.get_tracks_dir() == root_dir / "conductor/tracks"
assert paths.get_archive_dir() == Path("conductor/archive") assert paths.get_archive_dir() == root_dir / "conductor/archive"
def test_env_var_overrides(monkeypatch): def test_env_var_overrides(tmp_path, monkeypatch):
monkeypatch.setenv("SLOP_CONDUCTOR_DIR", "custom_conductor") root_dir = Path(paths.__file__).resolve().parent.parent
monkeypatch.setenv("SLOP_LOGS_DIR", "custom_logs")
monkeypatch.setenv("SLOP_SCRIPTS_DIR", "custom_scripts")
assert paths.get_conductor_dir() == Path("custom_conductor") # Relative env var (resolved against root_dir)
assert paths.get_logs_dir() == Path("custom_logs") monkeypatch.setenv("SLOP_CONDUCTOR_DIR", "custom_conductor")
assert paths.get_scripts_dir() == Path("custom_scripts") assert paths.get_conductor_dir() == (root_dir / "custom_conductor").resolve()
assert paths.get_tracks_dir() == Path("custom_conductor/tracks")
paths.reset_resolved()
# Absolute env var
abs_logs = (tmp_path / "abs_logs").resolve()
monkeypatch.setenv("SLOP_LOGS_DIR", str(abs_logs))
assert paths.get_logs_dir() == abs_logs
def test_config_overrides(tmp_path, monkeypatch): def test_config_overrides(tmp_path, monkeypatch):
root_dir = Path(paths.__file__).resolve().parent.parent
config_file = tmp_path / "custom_config.toml" config_file = tmp_path / "custom_config.toml"
content = """ content = """
[paths] [paths]
@@ -40,20 +45,12 @@ scripts_dir = "cfg_scripts"
config_file.write_text(content) config_file.write_text(content)
monkeypatch.setenv("SLOP_CONFIG", str(config_file)) monkeypatch.setenv("SLOP_CONFIG", str(config_file))
# Need to update the _CONFIG_PATH in paths.py since it's set at import assert paths.get_conductor_dir() == (root_dir / "cfg_conductor").resolve()
# Actually, the get_config_path() uses _CONFIG_PATH which is Path(os.environ.get("SLOP_CONFIG", "config.toml")) assert paths.get_logs_dir() == root_dir / "cfg_logs"
# But it's defined at module level. Let's see if we can reload it or if monkeypatching early enough works. assert paths.get_scripts_dir() == root_dir / "cfg_scripts"
# In src/paths.py: _CONFIG_PATH: Path = Path(os.environ.get("SLOP_CONFIG", "config.toml"))
# This is set when src.paths is first imported.
# For the test to work, we might need to manually set paths._CONFIG_PATH or reload the module.
# paths._CONFIG_PATH = config_file # No longer needed
assert paths.get_conductor_dir() == Path("cfg_conductor")
assert paths.get_logs_dir() == Path("cfg_logs")
assert paths.get_scripts_dir() == Path("cfg_scripts")
def test_precedence(tmp_path, monkeypatch): def test_precedence(tmp_path, monkeypatch):
root_dir = Path(paths.__file__).resolve().parent.parent
config_file = tmp_path / "custom_config.toml" config_file = tmp_path / "custom_config.toml"
content = """ content = """
[paths] [paths]
@@ -63,7 +60,5 @@ conductor_dir = "cfg_conductor"
monkeypatch.setenv("SLOP_CONFIG", str(config_file)) monkeypatch.setenv("SLOP_CONFIG", str(config_file))
monkeypatch.setenv("SLOP_CONDUCTOR_DIR", "env_conductor") monkeypatch.setenv("SLOP_CONDUCTOR_DIR", "env_conductor")
# paths._CONFIG_PATH = config_file # No longer needed
# Env var should take precedence over config # Env var should take precedence over config
assert paths.get_conductor_dir() == Path("env_conductor") assert paths.get_conductor_dir() == (root_dir / "env_conductor").resolve()