From bacb353df3dbaeddff2bba9d6f89729137e07e74 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:14:31 -0500 Subject: [PATCH 01/13] fix: recover failed chat creation and guard overlapping AI tasks --- graphite_app/graphite_app/graphite_app.py | 54 +++++++++++++++++------ 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/graphite_app/graphite_app/graphite_app.py b/graphite_app/graphite_app/graphite_app.py index 14d1873..4db539a 100644 --- a/graphite_app/graphite_app/graphite_app.py +++ b/graphite_app/graphite_app/graphite_app.py @@ -226,16 +226,21 @@ def save_chat(self): else: QMessageBox.information(self, "Nothing to Save", "The canvas is empty.") + def is_generation_running(self): + """True if any active AI generation task is still running.""" + return any( + (worker := getattr(self, name, None)) is not None and worker.isRunning() + for name in ('chat_thread', 'takeaway_thread', 'explainer_thread', 'chart_thread') + ) + def confirm_session_transition(self): """Protect unsaved work before loading, clearing, or closing a session.""" - for worker_name in ('chat_thread', 'takeaway_thread', 'explainer_thread', 'chart_thread'): - worker = getattr(self, worker_name, None) - if worker is not None and worker.isRunning(): - QMessageBox.warning( - self, "Generation in Progress", - "Finish the current AI task before switching or closing chats." - ) - return False + if self.is_generation_running(): + QMessageBox.warning( + self, "Generation in Progress", + "Finish the current AI task before switching or closing chats." + ) + return False if not self.session_manager.has_unsaved_changes(): return True choice = QMessageBox.question( @@ -446,7 +451,10 @@ def send_message(self): message = self.message_input.text().strip() if not message: return - + if self.is_generation_running(): + QMessageBox.information(self, "Generation in Progress", "An AI task is already running.") + return + # Disable input during processing self.message_input.setEnabled(False) self.send_button.setEnabled(False) @@ -459,12 +467,18 @@ def send_message(self): # Add user message node user_node = self.chat_view.scene().add_chat_node( - message, - is_user=True, + message, + is_user=True, parent_node=self.current_node, conversation_history=history ) - + if user_node is None: + self.message_input.setEnabled(True) + self.send_button.setEnabled(True) + self.loading_overlay.hide() + QMessageBox.critical(self, "Message Failed", "Could not create the message node.") + return + # Update conversation history user_node.conversation_history = history + [ {'role': 'user', 'content': message} @@ -502,8 +516,11 @@ def handle_response(self, response, user_node): self.send_button.setEnabled(True) self.loading_overlay.hide() - # Auto-save after response - self.session_manager.save_current_chat() + # A failed save should not leave the UI broken; the canvas remains available. + try: + self.session_manager.save_current_chat() + except Exception as exc: + QMessageBox.warning(self, "Auto-save Failed", f"Your canvas is unsaved: {exc}") def handle_error(self, error_message): QMessageBox.critical(self, "Error", f"An error occurred: {error_message}") @@ -524,6 +541,9 @@ def cleanup_takeaway_thread(self): def generate_takeaway(self, node): """Generate takeaway for the given node""" + if self.is_generation_running(): + QMessageBox.information(self, "Generation in Progress", "Finish the current AI task first.") + return try: # Cleanup any existing thread self.cleanup_takeaway_thread() @@ -585,6 +605,9 @@ def cleanup_explainer_thread(self): def generate_explainer(self, node): """Generate simple explanation for the given node""" + if self.is_generation_running(): + QMessageBox.information(self, "Generation in Progress", "Finish the current AI task first.") + return try: # Cleanup any existing thread self.cleanup_explainer_thread() @@ -638,6 +661,9 @@ def handle_explainer_error(self, error_message): def generate_chart(self, node, chart_type): """Generate chart for the given node""" + if self.is_generation_running(): + QMessageBox.information(self, "Generation in Progress", "Finish the current AI task first.") + return try: # Show loading overlay self.loading_overlay.show() From 51becb7a37b6a89cba1560d2672b1e196da3fe6c Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:14:47 -0500 Subject: [PATCH 02/13] fix: persist in-progress note edits and recover deleted active chats --- graphite_app/graphite_app/graphite_core.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/graphite_app/graphite_app/graphite_core.py b/graphite_app/graphite_app/graphite_core.py index 5025eda..2bb6166 100644 --- a/graphite_app/graphite_app/graphite_core.py +++ b/graphite_app/graphite_app/graphite_core.py @@ -337,7 +337,7 @@ def serialize_frame(self, frame): return { 'nodes': [self.window.chat_view.scene().nodes.index(node) for node in frame.nodes], 'position': {'x': frame.pos().x(), 'y': frame.pos().y()}, - 'note': frame.note, + 'note': frame.edit_text if frame.editing else frame.note, 'size': { 'width': frame.rect.width(), 'height': frame.rect.height() @@ -349,7 +349,7 @@ def serialize_frame(self, frame): def serialize_note(self, note): """Convert a Note to a serializable dictionary""" return { - 'content': note.content, + 'content': note.edit_text if note.editing else note.content, 'position': {'x': note.pos().x(), 'y': note.pos().y()}, 'size': {'width': note.width, 'height': note.height}, 'color': note.color, @@ -402,6 +402,13 @@ def has_unsaved_changes(self): return False return current != self._saved_snapshot + def detach_deleted_chat(self, chat_id): + """Keep an active deleted chat on the canvas as an unsaved new session.""" + if self.current_chat_id == chat_id: + self.current_chat_id = None + # None cannot equal a snapshot, so closing or switching prompts to save. + self._saved_snapshot = None + def mark_current_state_saved(self): """Record a clean baseline after a new session or successful load.""" self._saved_snapshot = self.capture_current_snapshot() From 257e4c2b9356f79147cbdafe8adcc0cc7377c07a Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:15:54 -0500 Subject: [PATCH 03/13] fix: parse literal header text safely and retain chats during deletion/regeneration --- graphite_app/graphite_app/graphite_ui.py | 92 ++++++++++++++---------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/graphite_app/graphite_app/graphite_ui.py b/graphite_app/graphite_app/graphite_ui.py index 225d3a5..26c4816 100644 --- a/graphite_app/graphite_app/graphite_ui.py +++ b/graphite_app/graphite_app/graphite_ui.py @@ -529,9 +529,18 @@ def delete_selected(self): QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: + is_active = chat_id == self.session_manager.current_chat_id self.session_manager.db.delete_chat(chat_id) + if is_active: + self.session_manager.detach_deleted_chat(chat_id) + QMessageBox.information( + self, "Canvas Retained", + "The active chat was deleted from the library. " + "Your current canvas remains open as an unsaved copy. " + "Save it to create a new chat." + ) self.refresh_chat_list() - + def rename_selected(self): current_item = self.chat_list.currentItem() if current_item: @@ -1361,14 +1370,18 @@ def process_text(self, text): current_text = "" continue - if line.startswith('[h'): + # Only treat correctly formatted [hN] tokens as headers. Literal + # messages such as "[hello]" must never crash node creation. + header_end = line.find(']') if line.startswith('[h') else -1 + header_level = line[2:header_end] if header_end > 2 else "" + if header_level.isdecimal() and int(header_level) > 0: if current_text: self.blocks.append(TextBlock(current_text.strip())) current_text = "" - - level = int(line[2]) - content = line[4:].strip() - + + level = int(header_level) + content = line[header_end + 1:].strip() + font_size = max(10, 14 - level) font = QFont('Segoe UI', font_size, QFont.Weight.Bold) self.blocks.append(TextBlock(content, 'header', font)) @@ -2000,9 +2013,15 @@ def regenerate_response(self): if not self.node.parent_node: return - main_window = self.node.scene().window + scene = self.node.scene() + main_window = scene.window if scene else None if not main_window: return + if main_window.is_generation_running(): + QMessageBox.information( + main_window, "Generation in Progress", "Finish the current AI task first." + ) + return user_message = self.node.parent_node.text history = main_window.build_conversation_history(self.node.parent_node) @@ -2019,41 +2038,36 @@ def regenerate_response(self): def handle_regenerated_response(self, new_response): + """Keep the original response and its descendants; add an alternate sibling.""" + scene = self.node.scene() + main_window = scene.window if scene else None try: - self.node.text = new_response - self.node.raw_text = new_response - self.node.process_text(new_response) - self.node._create_layouts() - - if self.node.parent_node: - parent_history = self.node.parent_node.conversation_history[:] if self.node.parent_node.conversation_history else [] - - self.node.conversation_history = parent_history + [ + if not main_window: + return + parent = self.node.parent_node + if parent is None or parent not in scene.nodes: + raise ValueError("The original question is no longer available.") + + history = main_window.build_conversation_history(parent) + alternate = scene.add_chat_node( + new_response, + is_user=False, + parent_node=parent, + conversation_history=history + [ {'role': 'assistant', 'content': new_response} ] - - for child in self.node.children: - if child.conversation_history: - divergence_point = len(parent_history) - child.conversation_history = ( - self.node.conversation_history + - child.conversation_history[divergence_point:] - ) - - main_window = self.node.scene().window - if main_window: - main_window.message_input.setEnabled(True) - main_window.send_button.setEnabled(True) - main_window.loading_overlay.hide() - - main_window.session_manager.save_current_chat() - - self.node.update() - - except Exception as e: - QMessageBox.critical(None, "Error", f"An error occurred while regenerating: {str(e)}") - - main_window = self.node.scene().window + ) + if alternate is None: + raise RuntimeError("Could not create the alternate response.") + main_window.setCurrentNode(alternate) + main_window.chat_view.centerOn(alternate) + main_window.session_manager.save_current_chat() + except Exception as exc: + QMessageBox.critical( + main_window, "Regeneration Failed", + f"Could not finish regenerating the response: {exc}" + ) + finally: if main_window: main_window.message_input.setEnabled(True) main_window.send_button.setEnabled(True) From b8d9912c0293562d0e786f0d72bf84ee80c8f442 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:16:48 -0500 Subject: [PATCH 04/13] fix: reject oversized and non-finite generated chart data --- graphite_app/graphite_app/graphite_agents.py | 41 ++++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/graphite_app/graphite_app/graphite_agents.py b/graphite_app/graphite_app/graphite_agents.py index f94481b..bd81e37 100644 --- a/graphite_app/graphite_app/graphite_agents.py +++ b/graphite_app/graphite_app/graphite_agents.py @@ -1,9 +1,17 @@ import ollama import json +import math from PySide6.QtCore import QThread, Signal, QPointF import graphite_config as config import api_provider +MAX_CHART_VALUES = 2000 +MAX_CHART_BINS = 100 +MAX_SANKEY_NODES = 75 +MAX_SANKEY_LINKS = 300 +MAX_CHART_RESPONSE_CHARS = 256_000 + + class ChatWorkerThread(QThread): """Run chat generation off the UI thread and emit success/error signals.""" finished = Signal(str) @@ -347,12 +355,17 @@ def _normalize_numeric_list(self, values): """Convert model-produced values into a clean float list.""" if not isinstance(values, list): raise ValueError("Values must be a list") + if len(values) > MAX_CHART_VALUES: + raise ValueError(f"Chart exceeds the limit of {MAX_CHART_VALUES} values") normalized = [] for index, value in enumerate(values): try: - normalized.append(float(value)) - except (TypeError, ValueError): - raise ValueError(f"Value at index {index} is not numeric") + number = float(value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"Value at index {index} is not numeric") from exc + if not math.isfinite(number): + raise ValueError(f"Value at index {index} must be finite") + normalized.append(number) return normalized def normalize_chart_payload(self, data, chart_type): @@ -385,7 +398,9 @@ def normalize_chart_payload(self, data, chart_type): bins = int(float(bins)) except (TypeError, ValueError): bins = 10 - normalized['bins'] = max(1, bins) + if not 1 <= bins <= MAX_CHART_BINS: + raise ValueError(f"Histogram bins must be between 1 and {MAX_CHART_BINS}") + normalized['bins'] = bins if chart_type == 'sankey': sankey_data = normalized.get('data', {}) @@ -398,6 +413,11 @@ def normalize_chart_payload(self, data, chart_type): nodes = [] if not isinstance(links, list): links = [] + if len(nodes) > MAX_SANKEY_NODES or len(links) > MAX_SANKEY_LINKS: + raise ValueError( + f"Sankey charts support at most {MAX_SANKEY_NODES} nodes " + f"and {MAX_SANKEY_LINKS} links" + ) clean_nodes = [] for i, node in enumerate(nodes): @@ -412,7 +432,9 @@ def normalize_chart_payload(self, data, chart_type): source = int(link.get('source')) target = int(link.get('target')) value = float(link.get('value')) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): + continue + if not math.isfinite(value): continue clean_links.append({'source': source, 'target': target, 'value': value}) @@ -494,7 +516,9 @@ def validate_chart_data(self, data, chart_type): return False, "Labels must be a list" if len(data['labels']) != len(data['values']): return False, "Labels and values must have the same length" - + if any(v < 0 for v in data['values']) or sum(data['values']) <= 0: + return False, "Pie charts need non-negative values with a positive total" + # Validate numeric values for non-Sankey charts if chart_type != 'sankey': try: @@ -548,7 +572,10 @@ def get_response(self, text, chart_type): # Using a more specialized model for code/JSON generation response = api_provider.chat(task=config.TASK_CHART, messages=messages) - cleaned_response = self.clean_response(response['message']['content']) + raw_text = response['message']['content'] + if len(raw_text) > MAX_CHART_RESPONSE_CHARS: + raise ValueError("Chart response is too large to render safely") + cleaned_response = self.clean_response(raw_text) # Parse JSON try: From 2bf8928b6fc2fd0154a1bafd1b1cb52f74956936 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:17:11 -0500 Subject: [PATCH 05/13] fix: configure finite provider network timeouts --- graphite_app/graphite_app/api_provider.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/graphite_app/graphite_app/api_provider.py b/graphite_app/graphite_app/api_provider.py index cc3b6de..0701638 100644 --- a/graphite_app/graphite_app/api_provider.py +++ b/graphite_app/graphite_app/api_provider.py @@ -3,6 +3,9 @@ import graphite_config as config USE_API_MODE = False +# Bound network waits so user-initiated jobs cannot hang indefinitely. +OLLAMA_REQUEST_TIMEOUT_SECONDS = 180.0 +API_REQUEST_TIMEOUT_SECONDS = 60.0 API_PROVIDER_TYPE = None API_CLIENT = None API_MODELS = { @@ -76,7 +79,9 @@ def chat(task: str, messages: list, **kwargs) -> dict: model = config.OLLAMA_MODELS.get(task) if not model: raise ValueError(f"No Ollama model configured for task: {task}") - return ollama.chat(model=model, messages=messages, **kwargs) + return ollama.Client(timeout=OLLAMA_REQUEST_TIMEOUT_SECONDS).chat( + model=model, messages=messages, **kwargs + ) else: if not API_CLIENT: raise RuntimeError("API client not initialized. Configure API settings first.") @@ -116,7 +121,8 @@ def chat(task: str, messages: list, **kwargs) -> dict: response = gemini_model.generate_content( contents=gemini_history, - generation_config=kwargs + generation_config=kwargs, + request_options={'timeout': API_REQUEST_TIMEOUT_SECONDS} ) return { @@ -142,7 +148,9 @@ def initialize_api(provider: str, api_key: str, base_url: str = None): if not base_url: base_url = 'https://api.openai.com/v1' - API_CLIENT = OpenAI(api_key=api_key, base_url=base_url) + API_CLIENT = OpenAI( + api_key=api_key, base_url=base_url, timeout=API_REQUEST_TIMEOUT_SECONDS + ) elif provider == config.API_PROVIDER_GEMINI: try: From 8f38ac8aae0211bc923a0e1088d1b6a4112edc0f Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:18:06 -0500 Subject: [PATCH 06/13] test: cover crash recovery, unsaved work, regeneration and chart limits --- tests/test_high_priority_fixes.py | 206 ++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/test_high_priority_fixes.py diff --git a/tests/test_high_priority_fixes.py b/tests/test_high_priority_fixes.py new file mode 100644 index 0000000..ac89438 --- /dev/null +++ b/tests/test_high_priority_fixes.py @@ -0,0 +1,206 @@ +"""Regression coverage for Graphite's highest-priority reliability fixes.""" +import copy +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "graphite_app" / "graphite_app")) + +import api_provider +from graphite_agents import ( + ChartDataAgent, MAX_CHART_VALUES, MAX_CHART_RESPONSE_CHARS, MAX_SANKEY_NODES +) +from graphite_app import ChatWindow +from graphite_core import ChatDatabase, ChatSessionManager +from graphite_ui import ChatNode, ChatNodeContextMenu + + +def point(x=0, y=0): + return SimpleNamespace(x=lambda: x, y=lambda: y) + + +def snapshot(): + return { + "chat_data": { + "nodes": [], "connections": [], "frames": [], "charts": [], + "view_state": {"zoom_factor": 1.0, "scroll_position": {"x": 0, "y": 0}}, + }, + "notes_data": [{ + "content": "Important", "position": {"x": 1, "y": 2}, + "size": {"width": 100, "height": 75}, + "color": "#444444", "header_color": None, + }], + "pins_data": [], + } + + +class ParserTests(unittest.TestCase): + def test_literal_header_prefix_is_safe(self): + for literal in ("[hello]", "[h]", "[header]", "[huh]"): + receiver = SimpleNamespace(blocks=[], clean_text=lambda text: text) + with self.subTest(literal=literal), patch("graphite_ui.TextBlock") as block: + ChatNode.process_text(receiver, literal) + block.assert_called_once_with(literal) + + def test_failed_node_creation_restores_controls(self): + message_input = SimpleNamespace(text=lambda: "[hello]", setEnabled=MagicMock()) + send_button = SimpleNamespace(setEnabled=MagicMock()) + loading_overlay = SimpleNamespace(show=MagicMock(), hide=MagicMock()) + scene = SimpleNamespace(add_chat_node=MagicMock(return_value=None)) + window = SimpleNamespace( + is_generation_running=lambda: False, + message_input=message_input, + send_button=send_button, + loading_overlay=loading_overlay, + chat_view=SimpleNamespace(scene=lambda: scene), + current_node=None, + build_conversation_history=lambda node: [], + ) + with patch("graphite_app.QMessageBox.critical") as error_dialog: + ChatWindow.send_message(window) + message_input.setEnabled.assert_any_call(True) + send_button.setEnabled.assert_any_call(True) + loading_overlay.hide.assert_called_once() + error_dialog.assert_called_once() + + def test_running_worker_prevents_another_task(self): + window = SimpleNamespace(chat_thread=SimpleNamespace(isRunning=lambda: True)) + self.assertTrue(ChatWindow.is_generation_running(window)) + + +class SessionSafetyTests(unittest.TestCase): + def test_live_note_and_frame_buffers_are_serialized(self): + manager = ChatSessionManager.__new__(ChatSessionManager) + note = SimpleNamespace( + editing=True, edit_text="Current edit", content="Old value", + pos=lambda: point(), width=100, height=80, + color="#444444", header_color=None + ) + self.assertEqual(manager.serialize_note(note)["content"], "Current edit") + + frame = SimpleNamespace( + editing=True, edit_text="New heading", note="Old heading", + nodes=[], pos=lambda: point(), + rect=SimpleNamespace(width=lambda: 200, height=lambda: 120), + color="#555555", header_color=None + ) + self.assertEqual(manager.serialize_frame(frame)["note"], "New heading") + + def test_deleting_active_chat_keeps_unsaved_copy(self): + with tempfile.TemporaryDirectory() as folder: + db = ChatDatabase.__new__(ChatDatabase) + db.db_path = Path(folder) / "graphite.db" + db.init_database() + + current = snapshot() + old_id = db.save_snapshot("Old", current["chat_data"], current["notes_data"], []) + manager = ChatSessionManager.__new__(ChatSessionManager) + manager.db = db + manager.current_chat_id = old_id + manager._saved_snapshot = copy.deepcopy(current) + manager.capture_current_snapshot = lambda: copy.deepcopy(current) + manager.title_generator = SimpleNamespace(generate_title=lambda message: "Recovered") + + db.delete_chat(old_id) + manager.detach_deleted_chat(old_id) + self.assertIsNone(manager.current_chat_id) + self.assertTrue(manager.has_unsaved_changes()) + self.assertTrue(manager.save_current_chat()) + self.assertNotEqual(manager.current_chat_id, old_id) + self.assertEqual(db.load_notes(manager.current_chat_id)[0]["content"], "Important") + + +class RegenerationTests(unittest.TestCase): + def test_regeneration_creates_sibling_without_overwriting_old_branch(self): + question = SimpleNamespace(text="Question", is_user=True, parent_node=None) + original = SimpleNamespace(text="Original response", parent_node=question) + alternate = SimpleNamespace(text="Alternative response", parent_node=question) + + scene = SimpleNamespace(nodes=[question, original]) + scene.add_chat_node = MagicMock(return_value=alternate) + original.scene = lambda: scene + window = SimpleNamespace( + build_conversation_history=MagicMock(return_value=[ + {"role": "user", "content": "Question"} + ]), + setCurrentNode=MagicMock(), + chat_view=SimpleNamespace(centerOn=MagicMock()), + session_manager=SimpleNamespace(save_current_chat=MagicMock()), + message_input=SimpleNamespace(setEnabled=MagicMock()), + send_button=SimpleNamespace(setEnabled=MagicMock()), + loading_overlay=SimpleNamespace(hide=MagicMock()), + ) + scene.window = window + menu = SimpleNamespace(node=original) + + with patch("graphite_ui.QMessageBox.critical") as error_dialog: + ChatNodeContextMenu.handle_regenerated_response(menu, "Alternative response") + + self.assertEqual(original.text, "Original response") + self.assertIs(original.parent_node, question) + self.assertIs(scene.add_chat_node.call_args.kwargs["parent_node"], question) + self.assertEqual(scene.add_chat_node.call_args.kwargs["conversation_history"][-1], + {"role": "assistant", "content": "Alternative response"}) + window.setCurrentNode.assert_called_once_with(alternate) + window.session_manager.save_current_chat.assert_called_once() + error_dialog.assert_not_called() + + +class ChartSafetyTests(unittest.TestCase): + def setUp(self): + self.agent = ChartDataAgent() + + def test_rejects_excessive_chart_values(self): + with self.assertRaisesRegex(ValueError, "limit"): + self.agent.normalize_chart_payload( + {"values": [1] * (MAX_CHART_VALUES + 1)}, "histogram" + ) + + def test_rejects_nonfinite_chart_values(self): + for bad in ("nan", "inf", "-inf", "1e309"): + with self.subTest(value=bad), self.assertRaisesRegex(ValueError, "finite"): + self.agent.normalize_chart_payload({"values": [bad]}, "histogram") + + def test_rejects_unreasonable_bins(self): + with self.assertRaisesRegex(ValueError, "bins"): + self.agent.normalize_chart_payload( + {"values": [1, 2], "bins": 100_000_000}, "histogram" + ) + + def test_rejects_oversized_sankey(self): + with self.assertRaisesRegex(ValueError, "Sankey"): + self.agent.normalize_chart_payload( + {"data": {"nodes": [{"name": str(i)} for i in range(MAX_SANKEY_NODES + 1)], + "links": []}}, "sankey" + ) + + def test_rejects_negative_pie_values(self): + normalized = self.agent.normalize_chart_payload( + {"labels": ["A", "B"], "values": [-1, 2]}, "pie" + ) + valid, message = self.agent.validate_chart_data(normalized, "pie") + self.assertFalse(valid) + self.assertIn("non-negative", message) + + def test_rejects_oversized_llm_response_before_json_parse(self): + with patch("graphite_agents.api_provider.chat", + return_value={"message": {"content": "x" * (MAX_CHART_RESPONSE_CHARS + 1)}}): + response = json.loads(self.agent.get_response("Data", "bar")) + self.assertIn("too large", response["error"]) + + +class ProviderTests(unittest.TestCase): + def test_ollama_chat_has_finite_timeout(self): + with patch("api_provider.ollama.Client") as client: + with patch.object(api_provider, "USE_API_MODE", False): + api_provider.chat("chat", [{"role": "user", "content": "Hello"}]) + self.assertGreater(client.call_args.kwargs["timeout"], 0) + client.return_value.chat.assert_called_once() + + +if __name__ == "__main__": + unittest.main() From 509c330df084b4c15712679f10c14646353ebbc6 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:18:28 -0500 Subject: [PATCH 07/13] docs: request private disclosure rather than public vulnerability issues --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 5eaf36a..407b240 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ We encourage users to update to the latest version to receive security updates. The security of Graphite is a top priority. We appreciate the community's efforts in responsible disclosure and will make every effort to address vulnerabilities in a timely manner. -If you discover a security vulnerability, please report it by opening an issue on the [GitHub repository](https://github.com/dovvnloading/Graphite/issues). +If you discover a potential security vulnerability, **do not open a public GitHub issue with exploit details**. Instead, use the repository's **Security → Report a vulnerability** option if private vulnerability reporting is enabled. If that option is unavailable, contact the maintainers privately to arrange a secure reporting channel before sharing sensitive technical details. When reporting a vulnerability, please include the following details: From ee99fad2902739d0c749de4f2f393d84dadb6a07 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:19:20 -0500 Subject: [PATCH 08/13] test: use configured chat task identifier in timeout regression --- tests/test_high_priority_fixes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_high_priority_fixes.py b/tests/test_high_priority_fixes.py index ac89438..0a57fe4 100644 --- a/tests/test_high_priority_fixes.py +++ b/tests/test_high_priority_fixes.py @@ -11,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "graphite_app" / "graphite_app")) import api_provider +import graphite_config as config from graphite_agents import ( ChartDataAgent, MAX_CHART_VALUES, MAX_CHART_RESPONSE_CHARS, MAX_SANKEY_NODES ) @@ -197,7 +198,7 @@ class ProviderTests(unittest.TestCase): def test_ollama_chat_has_finite_timeout(self): with patch("api_provider.ollama.Client") as client: with patch.object(api_provider, "USE_API_MODE", False): - api_provider.chat("chat", [{"role": "user", "content": "Hello"}]) + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "Hello"}]) self.assertGreater(client.call_args.kwargs["timeout"], 0) client.return_value.chat.assert_called_once() From 889ba903fa2371f0d93bb443c2bb5361fef0a1fd Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:19:45 -0500 Subject: [PATCH 09/13] fix: keep model-pull Qt threads alive until they finish --- graphite_app/graphite_app/graphite_ui.py | 39 +++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/graphite_app/graphite_app/graphite_ui.py b/graphite_app/graphite_app/graphite_ui.py index 26c4816..34df6b4 100644 --- a/graphite_app/graphite_app/graphite_ui.py +++ b/graphite_app/graphite_app/graphite_ui.py @@ -1955,7 +1955,13 @@ def delete_node(self): scene = self.node.scene() if not scene: return - + if scene.window and scene.window.is_generation_running(): + QMessageBox.information( + scene.window, "Generation in Progress", + "Finish the current AI task before deleting nodes." + ) + return + children = self.node.children[:] parent_node = self.node.parent_node @@ -5048,14 +5054,27 @@ def __init__(self, parent=None): layout.addLayout(button_layout) + def _download_is_running(self): + return self.worker_thread is not None and self.worker_thread.isRunning() + + def reject(self): + # QDialog.reject() can close the dialog without invoking closeEvent. + if self._download_is_running(): + QMessageBox.information( + self, "Download in Progress", + "Finish downloading the Ollama model before closing this dialog." + ) + return + super().reject() + def closeEvent(self, event): - if self.worker_thread and self.worker_thread.isRunning(): - try: - self.worker_thread.status_update.disconnect(self.handle_status_update) - self.worker_thread.finished.disconnect(self.handle_worker_finished) - self.worker_thread.error.disconnect(self.handle_worker_error) - except RuntimeError: - pass + if self._download_is_running(): + QMessageBox.information( + self, "Download in Progress", + "Finish downloading the Ollama model before closing this dialog." + ) + event.ignore() + return super().closeEvent(event) def on_combo_change(self, text): @@ -5093,12 +5112,16 @@ def handle_status_update(self, message): self.status_label.setStyleSheet("color: #3498db;") def handle_worker_finished(self, message, model_name): + # The custom finished signal is emitted just before run() returns. + # Join it before closing, so Qt never destroys an active QThread. + self.worker_thread.wait() config.set_current_model(model_name) self.reset_button() QMessageBox.information(self, "Success", message) self.accept() def handle_worker_error(self, error_message): + self.worker_thread.wait() self.status_label.setText(f"Error: {error_message}") self.status_label.setStyleSheet("color: #e74c3c;") self.reset_button() From b44a9220e38fb038c0696711056c4be75c0bc95d Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:34:31 -0500 Subject: [PATCH 10/13] fix: retain response-regeneration callbacks for background thread lifetime --- graphite_app/graphite_app/graphite_ui.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/graphite_app/graphite_app/graphite_ui.py b/graphite_app/graphite_app/graphite_ui.py index 34df6b4..a91c856 100644 --- a/graphite_app/graphite_app/graphite_ui.py +++ b/graphite_app/graphite_app/graphite_ui.py @@ -2038,11 +2038,24 @@ def regenerate_response(self): main_window.chat_thread = ChatWorkerThread( main_window.agent, user_message, history ) + # Keep this temporary context-menu receiver alive until the worker + # finishes: menu.exec() can return long before the AI response arrives. + main_window._pending_regeneration_menu = self main_window.chat_thread.finished.connect(self.handle_regenerated_response) - main_window.chat_thread.error.connect(main_window.handle_error) + main_window.chat_thread.error.connect(self.handle_regeneration_error) main_window.chat_thread.start() + def handle_regeneration_error(self, error_message): + scene = self.node.scene() + main_window = scene.window if scene else None + if main_window: + try: + main_window.handle_error(error_message) + finally: + if getattr(main_window, '_pending_regeneration_menu', None) is self: + main_window._pending_regeneration_menu = None + def handle_regenerated_response(self, new_response): """Keep the original response and its descendants; add an alternate sibling.""" scene = self.node.scene() @@ -2078,7 +2091,9 @@ def handle_regenerated_response(self, new_response): main_window.message_input.setEnabled(True) main_window.send_button.setEnabled(True) main_window.loading_overlay.hide() - + if getattr(main_window, '_pending_regeneration_menu', None) is self: + main_window._pending_regeneration_menu = None + def cleanup_thread(self): if self.takeaway_thread is not None: self.takeaway_thread.finished.disconnect() From 7c25b6e3a1c4e899a651fef59ee09d3f34929a88 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:34:44 -0500 Subject: [PATCH 11/13] fix: restore chat controls when generated response node fails --- graphite_app/graphite_app/graphite_app.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/graphite_app/graphite_app/graphite_app.py b/graphite_app/graphite_app/graphite_app.py index 4db539a..a313b80 100644 --- a/graphite_app/graphite_app/graphite_app.py +++ b/graphite_app/graphite_app/graphite_app.py @@ -505,7 +505,23 @@ def handle_response(self, response, user_node): {'role': 'assistant', 'content': response} ] ) - + if ai_node is None: + # The user's question is still on the canvas. Keep it recoverable + # instead of leaving the input disabled after a rendering failure. + self.message_input.setEnabled(True) + self.send_button.setEnabled(True) + self.loading_overlay.hide() + try: + self.session_manager.save_current_chat() + except Exception as exc: + QMessageBox.warning(self, "Auto-save Failed", f"Your canvas is unsaved: {exc}") + QMessageBox.critical( + self, "Response Failed", + "The generated response could not be displayed. " + "Your question remains on the canvas." + ) + return + # Update current node and view self.current_node = ai_node self.message_input.clear() From d7d7a7b634901c647afa83e2f9774d3fef5698e0 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:35:19 -0500 Subject: [PATCH 12/13] test: cover failed AI response recovery and regeneration worker ownership --- tests/test_high_priority_fixes.py | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_high_priority_fixes.py b/tests/test_high_priority_fixes.py index 0a57fe4..903faf4 100644 --- a/tests/test_high_priority_fixes.py +++ b/tests/test_high_priority_fixes.py @@ -72,6 +72,26 @@ def test_running_worker_prevents_another_task(self): window = SimpleNamespace(chat_thread=SimpleNamespace(isRunning=lambda: True)) self.assertTrue(ChatWindow.is_generation_running(window)) + def test_failed_ai_response_node_restores_controls_and_saves_question(self): + user_node = SimpleNamespace( + conversation_history=[{"role": "user", "content": "Question"}] + ) + scene = SimpleNamespace(add_chat_node=MagicMock(return_value=None)) + window = SimpleNamespace( + chat_view=SimpleNamespace(scene=lambda: scene), + message_input=SimpleNamespace(setEnabled=MagicMock()), + send_button=SimpleNamespace(setEnabled=MagicMock()), + loading_overlay=SimpleNamespace(hide=MagicMock()), + session_manager=SimpleNamespace(save_current_chat=MagicMock()), + ) + with patch("graphite_app.QMessageBox.critical") as error_dialog: + ChatWindow.handle_response(window, "Answer", user_node) + window.message_input.setEnabled.assert_called_with(True) + window.send_button.setEnabled.assert_called_with(True) + window.loading_overlay.hide.assert_called_once() + window.session_manager.save_current_chat.assert_called_once() + error_dialog.assert_called_once() + class SessionSafetyTests(unittest.TestCase): def test_live_note_and_frame_buffers_are_serialized(self): @@ -151,6 +171,37 @@ def test_regeneration_creates_sibling_without_overwriting_old_branch(self): error_dialog.assert_not_called() +class WorkerLifetimeTests(unittest.TestCase): + def test_regeneration_keeps_context_menu_alive_until_completion(self): + question = SimpleNamespace(text="Prompt", parent_node=None) + node = SimpleNamespace(parent_node=question) + scene = SimpleNamespace(window=None) + node.scene = lambda: scene + window = SimpleNamespace( + agent=object(), + is_generation_running=lambda: False, + build_conversation_history=lambda parent: [ + {"role": "user", "content": "Prompt"} + ], + message_input=SimpleNamespace(setEnabled=MagicMock()), + send_button=SimpleNamespace(setEnabled=MagicMock()), + loading_overlay=SimpleNamespace(show=MagicMock()), + ) + scene.window = window + menu = SimpleNamespace(node=node) + # Use real unbound method so no temporary QMenu needs a GUI to exist. + with patch("graphite_agents.ChatWorkerThread") as thread_class: + ChatNodeContextMenu.regenerate_response(menu) + self.assertIs(window._pending_regeneration_menu, menu) + thread_class.return_value.finished.connect.assert_called_once() + thread_class.return_value.error.connect.assert_called_once() + + window.handle_error = MagicMock() + ChatNodeContextMenu.handle_regeneration_error(menu, "Network error") + self.assertIsNone(window._pending_regeneration_menu) + window.handle_error.assert_called_once_with("Network error") + + class ChartSafetyTests(unittest.TestCase): def setUp(self): self.agent = ChartDataAgent() From 45babfa5381546d82c04d49194c18e4197666c35 Mon Sep 17 00:00:00 2001 From: Matthew Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:35:32 -0500 Subject: [PATCH 13/13] test: provide mock regeneration callback slots --- tests/test_high_priority_fixes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_high_priority_fixes.py b/tests/test_high_priority_fixes.py index 903faf4..4e5530d 100644 --- a/tests/test_high_priority_fixes.py +++ b/tests/test_high_priority_fixes.py @@ -188,7 +188,11 @@ def test_regeneration_keeps_context_menu_alive_until_completion(self): loading_overlay=SimpleNamespace(show=MagicMock()), ) scene.window = window - menu = SimpleNamespace(node=node) + menu = SimpleNamespace( + node=node, + handle_regenerated_response=MagicMock(), + handle_regeneration_error=MagicMock(), + ) # Use real unbound method so no temporary QMenu needs a GUI to exist. with patch("graphite_agents.ChatWorkerThread") as thread_class: ChatNodeContextMenu.regenerate_response(menu)