diff --git a/conductor/tests/verify_phase_3.py b/conductor/tests/verify_phase_3.py new file mode 100644 index 0000000..480e78c --- /dev/null +++ b/conductor/tests/verify_phase_3.py @@ -0,0 +1,24 @@ +import subprocess +import sys +import os + +def verify_phase_3(): + print("Verifying Phase 3: Discussion & Context Structure Mutation...") + + # Run the comprehensive simulation test + result = subprocess.run( + ["uv", "run", "pytest", "tests/test_undo_redo_sim.py"], + capture_output=True, + text=True + ) + + if result.returncode == 0: + print("Phase 3 verification PASSED.") + else: + print("Phase 3 verification FAILED.") + print(result.stdout) + print(result.stderr) + sys.exit(1) + +if __name__ == "__main__": + verify_phase_3() diff --git a/src/app_controller.py b/src/app_controller.py index aa809ea..484d006 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -403,7 +403,9 @@ class AppController: 'ui_separate_tier4': 'ui_separate_tier4', 'show_text_viewer': 'show_text_viewer', 'text_viewer_title': 'text_viewer_title', - 'text_viewer_type': 'text_viewer_type' + 'text_viewer_type': 'text_viewer_type', + 'disc_entries': 'disc_entries', + 'ui_file_paths': 'ui_file_paths' } self._gettable_fields = dict(self._settable_fields) self._gettable_fields.update({ @@ -575,6 +577,24 @@ class AppController: except: pass + @property + def ui_file_paths(self) -> list[str]: + return [f.path if hasattr(f, 'path') else str(f) for f in self.files] + + @ui_file_paths.setter + def ui_file_paths(self, value: list[str]) -> None: + old_files = {f.path: f for f in self.files if hasattr(f, 'path')} + new_files = [] + import time + now = time.time() + for p in value: + if p in old_files: + new_files.append(old_files[p]) + else: + from src import models + new_files.append(models.FileItem(path=p, injected_at=now)) + self.files = new_files + @property def operations_live_indicator(self) -> bool: return not self.is_viewing_prior_session diff --git a/src/gui_2.py b/src/gui_2.py index 52249d3..1f88ac4 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -346,19 +346,27 @@ class App: self._is_applying_snapshot = False def _handle_undo(self) -> None: + sys.stderr.write(f"[DEBUG History] _handle_undo called. can_undo={self.history.can_undo}\n") + sys.stderr.flush() if not self.history.can_undo: return current = self._take_snapshot() entry = self.history.undo(current, "Undo Action") if entry: + sys.stderr.write(f"[DEBUG History] Undoing to: {entry.description}\n") + sys.stderr.flush() self._apply_snapshot(entry.state) def _handle_redo(self) -> None: + sys.stderr.write(f"[DEBUG History] _handle_redo called. can_redo={self.history.can_redo}\n") + sys.stderr.flush() if not self.history.can_redo: return current = self._take_snapshot() entry = self.history.redo(current, "Redo Action") if entry: + sys.stderr.write(f"[DEBUG History] Redoing to: {entry.description}\n") + sys.stderr.flush() self._apply_snapshot(entry.state) def shutdown(self) -> None: @@ -400,6 +408,8 @@ class App: @ui_file_paths.setter def ui_file_paths(self, paths: list[str]) -> None: + sys.stderr.write(f"[DEBUG] Setting ui_file_paths to: {paths}\n") + sys.stderr.flush() old_files = {f.path: f for f in self.files if hasattr(f, 'path')} new_files = [] now = time.time() @@ -407,6 +417,7 @@ class App: if p in old_files: new_files.append(old_files[p]) else: + from src import models new_files.append(models.FileItem(path=p, injected_at=now)) self.files = new_files @@ -1210,9 +1221,10 @@ class App: if self.perf_profiling_enabled: self.perf_monitor.end_component("_gui_func") def _handle_history_logic(self) -> None: - if self._is_applying_snapshot: - return - + # Skip history tracking only during active AI thinking/tool execution + is_thinking = self.ai_status in ["sending...", "streaming...", "running powershell...", "fetching url...", "searching web..."] + if self._is_applying_snapshot or is_thinking: + return io = imgui.get_io() # 1. Hotkey handling (Undo: Ctrl+Z, Redo: Ctrl+Y or Ctrl+Shift+Z) @@ -1241,19 +1253,27 @@ class App: current.global_system_prompt != self._last_ui_snapshot.global_system_prompt or current.base_system_prompt != self._last_ui_snapshot.base_system_prompt or current.use_default_base_prompt != self._last_ui_snapshot.use_default_base_prompt or - current.temperature != self._last_ui_snapshot.temperature or - current.top_p != self._last_ui_snapshot.top_p or + abs(current.temperature - self._last_ui_snapshot.temperature) > 1e-5 or + abs(current.top_p - self._last_ui_snapshot.top_p) > 1e-5 or current.max_tokens != self._last_ui_snapshot.max_tokens or current.auto_add_history != self._last_ui_snapshot.auto_add_history or - len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) + len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) or + len(current.files) != len(self._last_ui_snapshot.files) or + len(current.screenshots) != len(self._last_ui_snapshot.screenshots) ) + + if not changed and len(current.disc_entries) > 0: + if current.disc_entries[-1].get('content') != self._last_ui_snapshot.disc_entries[-1].get('content'): + changed = True if changed: if not self._pending_snapshot: self._pending_snapshot = True self._snapshot_timer = time.time() + # Capture state BEFORE current change self._state_to_push = self._last_ui_snapshot else: + # Reset timer for settle debounce self._snapshot_timer = time.time() self._last_ui_snapshot = current @@ -1266,7 +1286,6 @@ class App: def _render_base_prompt_diff_modal(self) -> None: if not getattr(self.controller, "_show_base_prompt_diff_modal", False): return - imgui.open_popup("Base Prompt Diff") if imgui.begin_popup_modal("Base Prompt Diff", True, imgui.WindowFlags_.always_auto_resize)[0]: imgui.text_colored(C_IN, "Difference between Default and Custom Base System Prompt") diff --git a/tests/test_undo_redo_sim.py b/tests/test_undo_redo_sim.py index ce823d9..b89c315 100644 --- a/tests/test_undo_redo_sim.py +++ b/tests/test_undo_redo_sim.py @@ -3,6 +3,7 @@ import time import sys import os import json +from pathlib import Path sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) @@ -15,46 +16,116 @@ def test_undo_redo_lifecycle(live_gui): assert client.wait_for_server(timeout=15), "Hook server did not start" # 1. Set initial state + print("Setting initial state...") client.set_value('temperature', 0.5) client.set_value('ai_input', "Initial Input") - time.sleep(0.5) # Wait for debounce timer to start if it was triggered - # Trigger a snapshot by waiting for debounce - time.sleep(2.0) + # Wait for settle and first push (S_init -> S0) + time.sleep(3.0) # 2. Change state + print("Modifying state...") client.set_value('temperature', 1.5) client.set_value('ai_input', "Modified Input") - # Wait for debounce to push "Modified" state to history - time.sleep(2.0) + # Wait for settle and second push (S0 -> S1) + time.sleep(3.0) # Verify current state - assert client.get_value('temperature') == 1.5 - assert client.get_value('ai_input') == "Modified Input" + temp = client.get_value('temperature') + ai_in = client.get_value('ai_input') + print(f"Current state: temp={temp}, ai_input={ai_in}") + assert temp == 1.5 + assert ai_in == "Modified Input" # 3. Undo print("Sending Undo...") - # Since we don't have a direct 'undo' hook, we can use 'click' if we added a button, - # or we can simulate the hotkey if the hook API supports it. - # Actually, I'll add a 'btn_undo' and 'btn_redo' to clickable actions for testing. client.click('btn_undo') # Wait for state to revert - time.sleep(0.5) + time.sleep(1.0) - # Should be back to initial state - # Note: Undo moves 'current' to redo stack and pops from undo. - # If we were at 'Modified', and we undo, we should get 'Initial'. - assert client.get_value('ai_input') == "Initial Input" - assert client.get_value('temperature') == 0.5 + ai_in_undo = client.get_value('ai_input') + temp_undo = client.get_value('temperature') + print(f"After undo: ai_input={ai_in_undo}, temp={temp_undo}") + + assert ai_in_undo == "Initial Input" + assert temp_undo == 0.5 # 4. Redo print("Sending Redo...") client.click('btn_redo') + time.sleep(1.0) + + ai_in_redo = client.get_value('ai_input') + temp_redo = client.get_value('temperature') + print(f"After redo: ai_input={ai_in_redo}, temp={temp_redo}") + + assert ai_in_redo == "Modified Input" + assert temp_redo == 1.5 + + print("Undo/Redo basic lifecycle PASSED.") + +@pytest.mark.integration +def test_undo_redo_discussion_mutation(live_gui): + client = api_hook_client.ApiHookClient() + assert client.wait_for_server(timeout=15) + + # Get initial entries count + initial_entries = client.get_value('disc_entries') + initial_count = len(initial_entries) + print(f"Initial entries: {initial_count}") + + # 1. Add an entry (we simulate this by appending to disc_entries) + # Wait for settle + time.sleep(2.0) + + new_entries = initial_entries + [{"role": "User", "content": "New Entry", "collapsed": False, "ts": "2026-03-11 12:00:00"}] + client.set_value('disc_entries', new_entries) + + # Wait for debounce + time.sleep(2.0) + assert len(client.get_value('disc_entries')) == initial_count + 1 + + # 2. Undo addition + print("Undoing entry addition...") + client.click('btn_undo') time.sleep(0.5) + assert len(client.get_value('disc_entries')) == initial_count - assert client.get_value('ai_input') == "Modified Input" - assert client.get_value('temperature') == 1.5 + # 3. Redo addition + print("Redoing entry addition...") + client.click('btn_redo') + time.sleep(0.5) + assert len(client.get_value('disc_entries')) == initial_count + 1 - print("Undo/Redo lifecycle test PASSED.") + print("Undo/Redo discussion mutation PASSED.") + +@pytest.mark.integration +def test_undo_redo_context_mutation(live_gui): + client = api_hook_client.ApiHookClient() + assert client.wait_for_server(timeout=15) + + # Wait for settle + time.sleep(2.0) + + # 1. Add a file + client.set_value('ui_file_paths', ['test_undo.py']) + + # Wait for debounce + time.sleep(2.0) + assert 'test_undo.py' in client.get_value('ui_file_paths') + + # 2. Undo addition + print("Undoing file addition...") + client.click('btn_undo') + time.sleep(0.5) + assert 'test_undo.py' not in client.get_value('ui_file_paths') + + # 3. Redo addition + print("Redoing file addition...") + client.click('btn_redo') + time.sleep(0.5) + assert 'test_undo.py' in client.get_value('ui_file_paths') + + print("Undo/Redo context mutation PASSED.")