Compare commits
4 Commits
0c2df6c188
...
e9d9cdeb28
| Author | SHA1 | Date | |
|---|---|---|---|
| e9d9cdeb28 | |||
| 95f8a6d120 | |||
| 813e58ce30 | |||
| 7ea833e2d3 |
@@ -57,3 +57,4 @@ For deep implementation details when planning or implementing tracks, consult `d
|
|||||||
- **Remote Confirmation Protocol:** A non-blocking, ID-based challenge/response mechanism for approving AI actions via the REST API, enabling remote "Human-in-the-Loop" safety.
|
- **Remote Confirmation Protocol:** A non-blocking, ID-based challenge/response mechanism for approving AI actions via the REST API, enabling remote "Human-in-the-Loop" safety.
|
||||||
- **Gemini CLI Integration:** Allows using the `gemini` CLI as a headless backend provider. This enables leveraging Gemini subscriptions with advanced features like persistent sessions, while maintaining full "Human-in-the-Loop" safety through a dedicated bridge for synchronous tool call approvals within the Manual Slop GUI. Now features full functional parity with the direct API, including accurate token estimation, safety settings, and robust system instruction handling.
|
- **Gemini CLI Integration:** Allows using the `gemini` CLI as a headless backend provider. This enables leveraging Gemini subscriptions with advanced features like persistent sessions, while maintaining full "Human-in-the-Loop" safety through a dedicated bridge for synchronous tool call approvals within the Manual Slop GUI. Now features full functional parity with the direct API, including accurate token estimation, safety settings, and robust system instruction handling.
|
||||||
- **Context & Token Visualization:** Detailed UI panels for monitoring real-time token usage, history depth, and **visual cache awareness** (tracking specific files currently live in the provider's context cache).
|
- **Context & Token Visualization:** Detailed UI panels for monitoring real-time token usage, history depth, and **visual cache awareness** (tracking specific files currently live in the provider's context cache).
|
||||||
|
- **On-Demand Definition Lookup:** Allows developers to request specific class or function definitions during discussions using `@SymbolName` syntax. Injected definitions feature syntax highlighting, intelligent collapsing for long blocks, and a **[Source]** button for instant navigation to the full file.
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ This file tracks all major tracks for the project. Each track has its own detail
|
|||||||
12. [x] **Track: Manual Skeleton Context Injection**
|
12. [x] **Track: Manual Skeleton Context Injection**
|
||||||
*Link: [./tracks/manual_skeleton_injection_20260306/](./tracks/manual_skeleton_injection_20260306/)*
|
*Link: [./tracks/manual_skeleton_injection_20260306/](./tracks/manual_skeleton_injection_20260306/)*
|
||||||
|
|
||||||
13. [~] **Track: On-Demand Definition Lookup**
|
13. [x] **Track: On-Demand Definition Lookup**
|
||||||
*Link: [./tracks/on_demand_def_lookup_20260306/](./tracks/on_demand_def_lookup_20260306/)*
|
*Link: [./tracks/on_demand_def_lookup_20260306/](./tracks/on_demand_def_lookup_20260306/)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -33,35 +33,17 @@ Focus: Use existing MCP tool to get definitions
|
|||||||
return None
|
return None
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 3: Inline Display
|
## Phase 3: Inline Display [checkpoint: 7ea833e]
|
||||||
Focus: Display definition in discussion
|
Focus: Display definition in discussion
|
||||||
|
|
||||||
- [ ] Task 3.1: Inject definition as context
|
- [x] Task 3.1: Inject definition as context (7ea833e)
|
||||||
- WHERE: `src/gui_2.py` `_send_callback()`
|
|
||||||
- WHAT: Append definition to message
|
|
||||||
- HOW:
|
|
||||||
```python
|
|
||||||
symbols = parse_symbols(user_message)
|
|
||||||
for symbol in symbols:
|
|
||||||
result = get_symbol_definition(symbol, self.project_files)
|
|
||||||
if result:
|
|
||||||
file_path, definition = result
|
|
||||||
user_message += f"\n\n[Definition: {symbol} from {file_path}]\n```python\n{definition}\n```"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 4: Click Navigation
|
## Phase 4: Click Navigation [checkpoint: 7ea833e]
|
||||||
Focus: Allow clicking definition to open file
|
Focus: Allow clicking definition to open file
|
||||||
|
|
||||||
- [ ] Task 4.1: Store file/line metadata with definition
|
- [x] Task 4.1: Store file/line metadata with definition (7ea833e)
|
||||||
- WHERE: Discussion entry structure
|
- [x] Task 4.2: Add click handler (7ea833e)
|
||||||
- WHAT: Track source location
|
|
||||||
- HOW: Add to discussion entry dict
|
|
||||||
|
|
||||||
- [ ] Task 4.2: Add click handler
|
## Phase 5: Testing [checkpoint: 7ea833e]
|
||||||
- WHERE: `src/gui_2.py` discussion rendering
|
- [x] Task 5.1: Write unit tests for parsing (7ea833e)
|
||||||
- WHAT: On click, scroll to definition
|
- [x] Task 5.2: Conductor - Phase Verification (7ea833e)
|
||||||
- HOW: Use selectable text with callback
|
|
||||||
|
|
||||||
## Phase 5: Testing
|
|
||||||
- [ ] Task 5.1: Write unit tests for parsing
|
|
||||||
- [ ] Task 5.2: Conductor - Phase Verification
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[ai]
|
[ai]
|
||||||
provider = "deepseek"
|
provider = "gemini"
|
||||||
model = "deepseek-chat"
|
model = "gemini-2.5-flash-lite"
|
||||||
temperature = 0.0
|
temperature = 0.0
|
||||||
max_tokens = 8192
|
max_tokens = 8192
|
||||||
history_trunc_limit = 8000
|
history_trunc_limit = 8000
|
||||||
@@ -34,8 +34,8 @@ separate_tool_calls_panel = false
|
|||||||
"Tier 4: QA" = true
|
"Tier 4: QA" = true
|
||||||
"Discussion Hub" = true
|
"Discussion Hub" = true
|
||||||
"Operations Hub" = true
|
"Operations Hub" = true
|
||||||
Message = true
|
Message = false
|
||||||
Response = true
|
Response = false
|
||||||
"Tool Calls" = true
|
"Tool Calls" = true
|
||||||
Theme = true
|
Theme = true
|
||||||
"Log Management" = true
|
"Log Management" = true
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ def parse_symbols(text: str) -> list[str]:
|
|||||||
"""
|
"""
|
||||||
return re.findall(r"@([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", text)
|
return re.findall(r"@([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", text)
|
||||||
|
|
||||||
def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str] | None:
|
def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str, int] | None:
|
||||||
for file_path in files:
|
for file_path in files:
|
||||||
result = mcp_client.py_get_definition(file_path, symbol)
|
result = mcp_client.py_get_symbol_info(file_path, symbol)
|
||||||
if 'not found' not in result.lower():
|
if isinstance(result, tuple):
|
||||||
return (file_path, result)
|
source, line = result
|
||||||
|
return (file_path, source, line)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class GenerateRequest(BaseModel):
|
class GenerateRequest(BaseModel):
|
||||||
@@ -1827,6 +1828,15 @@ class AppController:
|
|||||||
self.last_file_items = file_items
|
self.last_file_items = file_items
|
||||||
self._set_status("sending...")
|
self._set_status("sending...")
|
||||||
user_msg = self.ui_ai_input
|
user_msg = self.ui_ai_input
|
||||||
|
|
||||||
|
symbols = parse_symbols(user_msg)
|
||||||
|
file_paths = [f['path'] for f in file_items]
|
||||||
|
for symbol in symbols:
|
||||||
|
res = get_symbol_definition(symbol, file_paths)
|
||||||
|
if res:
|
||||||
|
file_path, definition, line = res
|
||||||
|
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.ui_files_base_dir
|
||||||
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()
|
||||||
|
|||||||
30
src/gui_2.py
30
src/gui_2.py
@@ -21,6 +21,8 @@ from src import log_registry
|
|||||||
from src import log_pruner
|
from src import log_pruner
|
||||||
from src import models
|
from src import models
|
||||||
from src import app_controller
|
from src import app_controller
|
||||||
|
from src import mcp_client
|
||||||
|
import re
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed
|
from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed
|
||||||
@@ -1404,7 +1406,33 @@ class App:
|
|||||||
if read_mode:
|
if read_mode:
|
||||||
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
||||||
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||||
imgui.text(entry["content"])
|
content = entry["content"]
|
||||||
|
last_idx = 0
|
||||||
|
pattern = re.compile(r"\[Definition: (.*?) from (.*?) \(line (\d+)\)\](\s+```[\s\S]*?```)?")
|
||||||
|
for match in pattern.finditer(content):
|
||||||
|
before = content[last_idx:match.start()]
|
||||||
|
if before: imgui.text(before)
|
||||||
|
header_text = match.group(0).split("\n")[0].strip()
|
||||||
|
path = match.group(2)
|
||||||
|
code_block = match.group(4)
|
||||||
|
if imgui.collapsing_header(header_text):
|
||||||
|
if imgui.button(f"[Source]##{i}_{match.start()}"):
|
||||||
|
res = mcp_client.read_file(path)
|
||||||
|
if res:
|
||||||
|
self.text_viewer_title = path
|
||||||
|
self.text_viewer_content = res
|
||||||
|
self.show_text_viewer = True
|
||||||
|
if code_block:
|
||||||
|
code_content = code_block.strip()
|
||||||
|
if code_content.count("\n") + 1 > 50:
|
||||||
|
imgui.begin_child(f"code_{i}_{match.start()}", imgui.ImVec2(0, 200), True)
|
||||||
|
imgui.text(code_content)
|
||||||
|
imgui.end_child()
|
||||||
|
else:
|
||||||
|
imgui.text(code_content)
|
||||||
|
last_idx = match.end()
|
||||||
|
after = content[last_idx:]
|
||||||
|
if after: imgui.text(after)
|
||||||
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
||||||
imgui.end_child()
|
imgui.end_child()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -384,6 +384,32 @@ def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]:
|
|||||||
current = found
|
current = found
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
def py_get_symbol_info(path: str, name: str) -> tuple[str, int] | str:
|
||||||
|
"""
|
||||||
|
Returns (source_code, line_number) for a specific class, function, or method definition.
|
||||||
|
If not found, returns an error string.
|
||||||
|
"""
|
||||||
|
p, err = _resolve_and_check(path)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert p is not None
|
||||||
|
if not p.exists():
|
||||||
|
return f"ERROR: file not found: {path}"
|
||||||
|
if not p.is_file():
|
||||||
|
return f"ERROR: not a file: {path}"
|
||||||
|
try:
|
||||||
|
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
|
||||||
|
lines = code.splitlines(keepends=True)
|
||||||
|
tree = ast.parse(code)
|
||||||
|
node = _get_symbol_node(tree, name)
|
||||||
|
if node:
|
||||||
|
start = cast(int, getattr(node, "lineno"))
|
||||||
|
end = cast(int, getattr(node, "end_lineno"))
|
||||||
|
return ("".join(lines[start-1:end]), start)
|
||||||
|
return f"ERROR: definition '{name}' not found in {path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR retrieving definition '{name}' from '{path}': {e}"
|
||||||
|
|
||||||
def py_get_definition(path: str, name: str) -> str:
|
def py_get_definition(path: str, name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Returns the source code for a specific class, function, or method definition.
|
Returns the source code for a specific class, function, or method definition.
|
||||||
|
|||||||
79
tests/test_gui_symbol_navigation.py
Normal file
79
tests/test_gui_symbol_navigation.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from src.gui_2 import App
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["User", "AI"])
|
||||||
|
def test_render_discussion_panel_symbol_lookup(mock_app, role):
|
||||||
|
# Mock imgui, mcp_client, and project_manager as requested
|
||||||
|
with (
|
||||||
|
patch('src.gui_2.imgui') as mock_imgui,
|
||||||
|
patch('src.gui_2.mcp_client') as mock_mcp,
|
||||||
|
patch('src.gui_2.project_manager') as mock_pm
|
||||||
|
):
|
||||||
|
# Set up App instance state
|
||||||
|
mock_app.perf_profiling_enabled = False
|
||||||
|
mock_app.ai_status = "idle"
|
||||||
|
mock_app.is_viewing_prior_session = False
|
||||||
|
mock_app.active_discussion = "Default"
|
||||||
|
mock_app.project = {"discussion": {"discussions": {"Default": {}}}}
|
||||||
|
mock_app.disc_entries = [{"role": role, "content": "[Definition: MyClass from src/models.py (line 10)]", "collapsed": False, "read_mode": True}]
|
||||||
|
mock_app.disc_roles = ["User", "AI"]
|
||||||
|
mock_app.ui_files_base_dir = "."
|
||||||
|
mock_app.active_track = None
|
||||||
|
mock_app.ui_disc_new_name_input = ""
|
||||||
|
mock_app.ui_auto_add_history = False
|
||||||
|
mock_app.ui_disc_truncate_pairs = 10
|
||||||
|
mock_app.ui_disc_new_role_input = ""
|
||||||
|
mock_app._disc_entries_lock = MagicMock()
|
||||||
|
mock_app._scroll_disc_to_bottom = False
|
||||||
|
mock_app.ui_word_wrap = False
|
||||||
|
mock_app.show_text_viewer = False
|
||||||
|
mock_app.text_viewer_title = ""
|
||||||
|
mock_app.text_viewer_content = ""
|
||||||
|
|
||||||
|
# Mock internal methods to avoid side effects
|
||||||
|
mock_app._get_discussion_names = MagicMock(return_value=["Default"])
|
||||||
|
mock_app._render_text_viewer = MagicMock()
|
||||||
|
|
||||||
|
# Mock imgui behavior to reach the entry rendering loop
|
||||||
|
mock_imgui.collapsing_header.return_value = True
|
||||||
|
mock_imgui.begin_combo.return_value = False
|
||||||
|
mock_imgui.begin_child.return_value = True
|
||||||
|
mock_imgui.checkbox.return_value = (False, False)
|
||||||
|
mock_imgui.input_text.side_effect = lambda label, value, *args, **kwargs: (False, value)
|
||||||
|
mock_imgui.input_text_multiline.side_effect = lambda label, value, *args, **kwargs: (False, value)
|
||||||
|
mock_imgui.input_int.side_effect = lambda label, value, *args, **kwargs: (False, value)
|
||||||
|
|
||||||
|
# Mock clipper to process the single entry
|
||||||
|
mock_clipper = MagicMock()
|
||||||
|
mock_imgui.ListClipper.return_value = mock_clipper
|
||||||
|
mock_clipper.step.side_effect = [True, False]
|
||||||
|
mock_clipper.display_start = 0
|
||||||
|
mock_clipper.display_end = 1
|
||||||
|
|
||||||
|
# Mock button click for the [Source] button
|
||||||
|
# The code renders: if imgui.button(f"[Source]##{i}_{match.start()}"):
|
||||||
|
# We want it to return True for our entry at index 0.
|
||||||
|
def button_side_effect(label):
|
||||||
|
if label == "[Source]##0_0":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
mock_imgui.button.side_effect = button_side_effect
|
||||||
|
|
||||||
|
# Mock mcp_client.read_file return value
|
||||||
|
mock_mcp.read_file.return_value = "class MyClass:\n pass"
|
||||||
|
|
||||||
|
# Execute the panel rendering
|
||||||
|
mock_app._render_discussion_panel()
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
# 1. Assert that the regex correctly identifies the pattern and imgui.button('[Source]##0_0') is called
|
||||||
|
mock_imgui.button.assert_any_call("[Source]##0_0")
|
||||||
|
|
||||||
|
# 2. Verify mcp_client.read_file('src/models.py') is called upon button click
|
||||||
|
mock_mcp.read_file.assert_called_with("src/models.py")
|
||||||
|
|
||||||
|
# 3. Verify the text viewer state is updated correctly
|
||||||
|
assert mock_app.text_viewer_title == "src/models.py"
|
||||||
|
assert mock_app.text_viewer_content == "class MyClass:\n pass"
|
||||||
|
assert mock_app.show_text_viewer is True
|
||||||
@@ -32,24 +32,25 @@ class TestSymbolLookup(unittest.TestCase):
|
|||||||
files = ["file1.py", "file2.py"]
|
files = ["file1.py", "file2.py"]
|
||||||
symbol = "my_func"
|
symbol = "my_func"
|
||||||
def_content = "def my_func():\n pass"
|
def_content = "def my_func():\n pass"
|
||||||
|
line_number = 10
|
||||||
|
|
||||||
with patch("src.mcp_client.py_get_definition") as mock_get_def:
|
with patch("src.mcp_client.py_get_symbol_info") as mock_get_info:
|
||||||
# First file not found, second file found
|
# First file not found, second file found
|
||||||
mock_get_def.side_effect = [
|
mock_get_info.side_effect = [
|
||||||
"ERROR: definition 'my_func' not found in file1.py",
|
"ERROR: definition 'my_func' not found in file1.py",
|
||||||
def_content
|
(def_content, line_number)
|
||||||
]
|
]
|
||||||
|
|
||||||
result = get_symbol_definition(symbol, files)
|
result = get_symbol_definition(symbol, files)
|
||||||
self.assertEqual(result, ("file2.py", def_content))
|
self.assertEqual(result, ("file2.py", def_content, line_number))
|
||||||
self.assertEqual(mock_get_def.call_count, 2)
|
self.assertEqual(mock_get_info.call_count, 2)
|
||||||
|
|
||||||
def test_get_symbol_definition_not_found(self):
|
def test_get_symbol_definition_not_found(self):
|
||||||
files = ["file1.py"]
|
files = ["file1.py"]
|
||||||
symbol = "my_func"
|
symbol = "my_func"
|
||||||
|
|
||||||
with patch("src.mcp_client.py_get_definition") as mock_get_def:
|
with patch("src.mcp_client.py_get_symbol_info") as mock_get_info:
|
||||||
mock_get_def.return_value = "ERROR: definition 'my_func' not found in file1.py"
|
mock_get_info.return_value = "ERROR: definition 'my_func' not found in file1.py"
|
||||||
|
|
||||||
result = get_symbol_definition(symbol, files)
|
result = get_symbol_definition(symbol, files)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|||||||
84
tests/test_symbol_parsing.py
Normal file
84
tests/test_symbol_parsing.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from pathlib import Path
|
||||||
|
from src.app_controller import AppController
|
||||||
|
from src.events import UserRequestEvent
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller():
|
||||||
|
with (
|
||||||
|
patch('src.models.load_config', return_value={
|
||||||
|
"ai": {"provider": "gemini", "model": "model-1"},
|
||||||
|
"projects": {"paths": [], "active": ""},
|
||||||
|
"gui": {"show_windows": {}}
|
||||||
|
}),
|
||||||
|
patch('src.project_manager.load_project', return_value={}),
|
||||||
|
patch('src.project_manager.migrate_from_legacy_config', return_value={}),
|
||||||
|
patch('src.project_manager.save_project'),
|
||||||
|
patch('src.session_logger.open_session'),
|
||||||
|
patch('src.app_controller.AppController._init_ai_and_hooks'),
|
||||||
|
patch('src.app_controller.AppController._fetch_models')
|
||||||
|
):
|
||||||
|
c = AppController()
|
||||||
|
# Mock necessary state
|
||||||
|
c.ui_files_base_dir = "."
|
||||||
|
c.event_queue = MagicMock()
|
||||||
|
return c
|
||||||
|
|
||||||
|
def test_handle_generate_send_appends_definitions(controller):
|
||||||
|
# Setup
|
||||||
|
file_items = [{"path": "src/models.py", "entry": "src/models.py"}]
|
||||||
|
controller._do_generate = MagicMock(return_value=(
|
||||||
|
"full_md", Path("output.md"), file_items, "stable_md", "disc_text"
|
||||||
|
))
|
||||||
|
controller.ui_ai_input = "Explain @Track object"
|
||||||
|
|
||||||
|
# Mock symbol helpers
|
||||||
|
with (
|
||||||
|
patch('src.app_controller.parse_symbols', return_value=["Track"]) as mock_parse,
|
||||||
|
patch('src.app_controller.get_symbol_definition', return_value=("src/models.py", "class Track: pass")) as mock_get_def,
|
||||||
|
patch('threading.Thread') as mock_thread
|
||||||
|
):
|
||||||
|
# Execute
|
||||||
|
controller._handle_generate_send()
|
||||||
|
|
||||||
|
# Run worker manually
|
||||||
|
worker = mock_thread.call_args[1]['target']
|
||||||
|
worker()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
mock_parse.assert_called_once_with("Explain @Track object")
|
||||||
|
mock_get_def.assert_called_once()
|
||||||
|
|
||||||
|
controller.event_queue.put.assert_called_once()
|
||||||
|
event_name, event_payload = controller.event_queue.put.call_args[0]
|
||||||
|
assert event_name == "user_request"
|
||||||
|
assert isinstance(event_payload, UserRequestEvent)
|
||||||
|
|
||||||
|
# Check if definition was appended
|
||||||
|
expected_suffix = "\n\n[Definition: Track from src/models.py]\n```python\nclass Track: pass\n```"
|
||||||
|
assert event_payload.prompt == "Explain @Track object" + expected_suffix
|
||||||
|
|
||||||
|
def test_handle_generate_send_no_symbols(controller):
|
||||||
|
# Setup
|
||||||
|
file_items = [{"path": "src/models.py", "entry": "src/models.py"}]
|
||||||
|
controller._do_generate = MagicMock(return_value=(
|
||||||
|
"full_md", Path("output.md"), file_items, "stable_md", "disc_text"
|
||||||
|
))
|
||||||
|
controller.ui_ai_input = "Just a normal prompt"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('src.app_controller.parse_symbols', return_value=[]) as mock_parse,
|
||||||
|
patch('threading.Thread') as mock_thread
|
||||||
|
):
|
||||||
|
# Execute
|
||||||
|
controller._handle_generate_send()
|
||||||
|
|
||||||
|
# Run worker manually
|
||||||
|
worker = mock_thread.call_args[1]['target']
|
||||||
|
worker()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
controller.event_queue.put.assert_called_once()
|
||||||
|
_, event_payload = controller.event_queue.put.call_args[0]
|
||||||
|
assert event_payload.prompt == "Just a normal prompt"
|
||||||
Reference in New Issue
Block a user