Fix Qt worker lifecycle crash

This commit is contained in:
2026-07-30 21:10:56 +02:00
parent cba80dfeb1
commit 7f4fc23e1d
9 changed files with 272 additions and 20 deletions
+13
View File
@@ -0,0 +1,13 @@
Fatal Python error: Aborted
Current thread 0x000066dc (most recent call first):
File "D:\Javis-Projekt\src\javis\ui\main_window.py", line 301 in _generation_complete
File "D:\Javis-Projekt\src\javis\ui\app.py", line 44 in run_gui
File "D:\Javis-Projekt\src\javis\interface\cli.py", line 429 in main
File "D:\Javis-Projekt\.venv\Scripts\javis.exe\__main__.py", line 10 in <module>
File "<frozen runpy>", line 88 in _run_code
File "<frozen runpy>", line 198 in _run_module_as_main
Extension modules: charset_normalizer.md, charset_normalizer.cd, requests.packages.charset_normalizer.md, requests.packages.chardet.md, requests.packages.charset_normalizer.cd, requests.packages.chardet.cd, _cffi_backend, websockets.speedups, shiboken6.Shiboken, PySide6.QtCore, PySide6.QtGui, PySide6.QtWidgets (total: 12)
QThreadStorage: entry 1 destroyed before end of thread 0x28a793bea70
QThreadStorage: entry 0 destroyed before end of thread 0x28a793bea70
+22
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import threading
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
@@ -84,6 +85,8 @@ class HybridProvider:
self._max_cloud_input_chars = max_cloud_input_chars self._max_cloud_input_chars = max_cloud_input_chars
self._max_cloud_context_messages = max_cloud_context_messages self._max_cloud_context_messages = max_cloud_context_messages
self._cloud_session_disabled = False self._cloud_session_disabled = False
self._approval_lock = threading.Lock()
self._next_approval: ApprovalChoice | None = None
self.last_route: RouteStatus | None = None self.last_route: RouteStatus | None = None
def supports_session(self, provider: str, model: str) -> bool: def supports_session(self, provider: str, model: str) -> bool:
@@ -97,7 +100,26 @@ class HybridProvider:
raise ValueError("Provider-Modus muss auto, local oder gemini sein.") raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
self.mode = mode self.mode = mode
def privacy_decision(self, text: str) -> PrivacyDecision:
"""Classify a prospective request without contacting a provider."""
return self._privacy_router.classify(
text,
cloud_requested=self.mode == "gemini",
)
def queue_approval(self, choice: ApprovalChoice) -> None:
"""Provide the GUI decision for exactly the next ASK request."""
if choice not in {ApprovalChoice.ALLOW, ApprovalChoice.LOCAL}:
raise ValueError("Nur Freigabe oder lokale Antwort kann vorgemerkt werden.")
with self._approval_lock:
self._next_approval = choice
def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice: def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice:
with self._approval_lock:
queued = self._next_approval
self._next_approval = None
if queued is not None:
return queued
result = self._approval_callback(decision) result = self._approval_callback(decision)
if isinstance(result, bool): if isinstance(result, bool):
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
+2 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox from PySide6.QtWidgets import QApplication, QMessageBox
from javis.config.settings import ConfigurationError, Settings from javis.config.settings import ConfigurationError, Settings
from javis.core.provider_router import ApprovalChoice
from javis.core.runtime import build_javis_runtime from javis.core.runtime import build_javis_runtime
from javis.memory.sqlite_store import SessionStoreError from javis.memory.sqlite_store import SessionStoreError
from javis.memory.usage_store import UsageStoreError from javis.memory.usage_store import UsageStoreError
@@ -26,7 +27,7 @@ def run_gui(argv: Sequence[str] | None = None) -> int:
settings = Settings.from_env() settings = Settings.from_env()
runtime = build_javis_runtime( runtime = build_javis_runtime(
settings, settings,
approval_callback=approval_bridge.request, approval_callback=lambda _decision: ApprovalChoice.CANCEL,
notice_callback=approval_bridge.notice_received.emit, notice_callback=approval_bridge.notice_received.emit,
) )
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc: except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
+12
View File
@@ -6,9 +6,11 @@ import re
import threading import threading
from collections.abc import Iterator from collections.abc import Iterator
from javis.core.provider_router import ApprovalChoice
from javis.core.runtime import JavisRuntime from javis.core.runtime import JavisRuntime
from javis.memory.sqlite_store import ChatSession from javis.memory.sqlite_store import ChatSession
from javis.providers.base import ProviderError, ResponseAbortedError from javis.providers.base import ProviderError, ResponseAbortedError
from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.models import ( from javis.ui.models import (
DesktopStatus, DesktopStatus,
GenerationState, GenerationState,
@@ -65,6 +67,16 @@ class DesktopController:
def set_mode(self, mode: str) -> None: def set_mode(self, mode: str) -> None:
self.runtime.hybrid_provider.set_mode(mode) self.runtime.hybrid_provider.set_mode(mode)
def approval_decision(self, text: str) -> PrivacyDecision | None:
provider = self.runtime.hybrid_provider
decision = provider.privacy_decision(text)
if provider.mode == "local" or decision.policy is not CloudPolicy.ASK:
return None
return decision
def queue_approval(self, choice: ApprovalChoice) -> None:
self.runtime.hybrid_provider.queue_approval(choice)
def stream_message( def stream_message(
self, self,
text: str, text: str,
+32 -8
View File
@@ -57,6 +57,8 @@ class MainWindow(QMainWindow):
self.approval_bridge = approval_bridge self.approval_bridge = approval_bridge
self._thread: QThread | None = None self._thread: QThread | None = None
self._worker: StreamWorker | None = None self._worker: StreamWorker | None = None
self._generation_finalized = True
self._close_when_finished = False
self._messages: list[ChatMessage] = [] self._messages: list[ChatMessage] = []
self._draft_user = "" self._draft_user = ""
self._draft_assistant = "" self._draft_assistant = ""
@@ -158,7 +160,6 @@ class MainWindow(QMainWindow):
self.stop_button.clicked.connect(self._stop_generation) self.stop_button.clicked.connect(self._stop_generation)
self.message_edit.send_requested.connect(self._send) self.message_edit.send_requested.connect(self._send)
self.mode_combo.currentIndexChanged.connect(self._mode_changed) self.mode_combo.currentIndexChanged.connect(self._mode_changed)
self.approval_bridge.approval_requested.connect(self._show_approval)
self.approval_bridge.notice_received.connect(self._show_notice) self.approval_bridge.notice_received.connect(self._show_notice)
def _apply_style(self) -> None: def _apply_style(self) -> None:
@@ -266,25 +267,40 @@ class MainWindow(QMainWindow):
text = self.message_edit.toPlainText().strip() text = self.message_edit.toPlainText().strip()
if not text or self._worker is not None: if not text or self._worker is not None:
return return
try:
decision = self.controller.approval_decision(text)
if decision is not None:
choice = self._show_approval(decision)
if choice is ApprovalChoice.CANCEL:
self.statusBar().showMessage("Anfrage abgebrochen.", 3000)
return
self.controller.queue_approval(choice)
except Exception as exc:
self._show_error(str(exc))
return
self.message_edit.clear() self.message_edit.clear()
self._draft_user = text self._draft_user = text
self._draft_assistant = "" self._draft_assistant = ""
self._render_chat() self._render_chat()
self._set_generating(True) self._set_generating(True)
self._generation_finalized = False
thread = QThread(self) thread = QThread(self)
worker = StreamWorker(self.controller, text) worker = StreamWorker(self.controller, text)
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.event_received.connect(self._handle_stream_event) worker.event_received.connect(self._handle_stream_event)
worker.completed.connect(thread.quit) worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater)
worker.completed.connect(self._generation_complete) worker.completed.connect(self._generation_complete)
thread.finished.connect(worker.deleteLater) thread.finished.connect(self._thread_finished)
thread.finished.connect(thread.deleteLater) thread.finished.connect(thread.deleteLater)
self._thread = thread self._thread = thread
self._worker = worker self._worker = worker
thread.start() thread.start()
def _handle_stream_event(self, event: StreamEvent) -> None: def _handle_stream_event(self, event: StreamEvent) -> None:
if self._generation_finalized:
return
if event.kind is StreamEventKind.CHUNK: if event.kind is StreamEventKind.CHUNK:
self._draft_assistant += event.text self._draft_assistant += event.text
self._render_chat() self._render_chat()
@@ -294,12 +310,13 @@ class MainWindow(QMainWindow):
self._show_error(event.text) self._show_error(event.text)
def _generation_complete(self) -> None: def _generation_complete(self) -> None:
if self._generation_finalized:
return
self._generation_finalized = True
failed = self.controller.state in { failed = self.controller.state in {
GenerationState.ABORTED, GenerationState.ABORTED,
GenerationState.ERROR, GenerationState.ERROR,
} }
self._worker = None
self._thread = None
self._set_generating(False) self._set_generating(False)
try: try:
loaded = self.controller.load_session(self.controller.active_session_id or "") loaded = self.controller.load_session(self.controller.active_session_id or "")
@@ -315,11 +332,17 @@ class MainWindow(QMainWindow):
else: else:
self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000) self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000)
def _thread_finished(self) -> None:
self._worker = None
self._thread = None
if self._close_when_finished:
self._close_when_finished = False
QTimer.singleShot(0, self.close)
def _stop_generation(self) -> None: def _stop_generation(self) -> None:
if self._worker is None: if self._worker is None:
return return
self._worker.request_cancel() self._worker.request_cancel()
self.approval_bridge.cancel_pending()
self.statusBar().showMessage("Abbruch angefordert …") self.statusBar().showMessage("Abbruch angefordert …")
def _set_generating(self, active: bool) -> None: def _set_generating(self, active: bool) -> None:
@@ -336,7 +359,7 @@ class MainWindow(QMainWindow):
except Exception as exc: except Exception as exc:
self._show_error(str(exc)) self._show_error(str(exc))
def _show_approval(self, decision: PrivacyDecision) -> None: def _show_approval(self, decision: PrivacyDecision) -> ApprovalChoice:
box = QMessageBox(self) box = QMessageBox(self)
box.setWindowTitle("Cloudfreigabe") box.setWindowTitle("Cloudfreigabe")
box.setIcon(QMessageBox.Question) box.setIcon(QMessageBox.Question)
@@ -357,9 +380,9 @@ class MainWindow(QMainWindow):
choice = ApprovalChoice.LOCAL choice = ApprovalChoice.LOCAL
elif clicked is cancel: elif clicked is cancel:
choice = ApprovalChoice.CANCEL choice = ApprovalChoice.CANCEL
self.approval_bridge.resolve(choice)
if box.clickedButton() is local: if box.clickedButton() is local:
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000) self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
return choice
def _show_notice(self, text: str) -> None: def _show_notice(self, text: str) -> None:
self.statusBar().showMessage(text, 5000) self.statusBar().showMessage(text, 5000)
@@ -438,8 +461,9 @@ class MainWindow(QMainWindow):
self.search_edit.selectAll() self.search_edit.selectAll()
def closeEvent(self, event: QCloseEvent) -> None: def closeEvent(self, event: QCloseEvent) -> None:
if self._thread is not None:
self._close_when_finished = True
self._stop_generation() self._stop_generation()
if self._thread is not None and not self._thread.wait(2500):
event.ignore() event.ignore()
self.statusBar().showMessage( self.statusBar().showMessage(
"Javis beendet die laufende Antwort noch kontrolliert.", "Javis beendet die laufende Antwort noch kontrolliert.",
+7
View File
@@ -53,9 +53,14 @@ class StreamWorker(QObject):
self._controller = controller self._controller = controller
self._text = text self._text = text
self._cancel_event = threading.Event() self._cancel_event = threading.Event()
self._started = False
self._completed = False
@Slot() @Slot()
def run(self) -> None: def run(self) -> None:
if self._started:
return
self._started = True
try: try:
for event in self._controller.stream_message( for event in self._controller.stream_message(
self._text, self._text,
@@ -63,6 +68,8 @@ class StreamWorker(QObject):
): ):
self.event_received.emit(event) self.event_received.emit(event)
finally: finally:
if not self._completed:
self._completed = True
self.completed.emit() self.completed.emit()
def request_cancel(self) -> None: def request_cancel(self) -> None:
+20
View File
@@ -7,8 +7,10 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from javis.core.chat_service import ChatService from javis.core.chat_service import ChatService
from javis.core.provider_router import ApprovalChoice
from javis.memory.sqlite_store import SQLiteSessionStore from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage, ResponseAbortedError from javis.providers.base import ChatMessage, ResponseAbortedError
from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.chat_controller import DesktopController, sanitized_error from javis.ui.chat_controller import DesktopController, sanitized_error
from javis.ui.models import GenerationState, StreamEventKind from javis.ui.models import GenerationState, StreamEventKind
@@ -45,10 +47,17 @@ class _Service:
class _Hybrid: class _Hybrid:
mode = "auto" mode = "auto"
last_route = None last_route = None
queued_approval = None
def set_mode(self, mode: str) -> None: def set_mode(self, mode: str) -> None:
self.mode = mode self.mode = mode
def privacy_decision(self, _text: str) -> PrivacyDecision:
return PrivacyDecision(CloudPolicy.ASK, "Test")
def queue_approval(self, choice: ApprovalChoice) -> None:
self.queued_approval = choice
class _StreamingProvider: class _StreamingProvider:
name = "ollama" name = "ollama"
@@ -91,6 +100,17 @@ class DesktopControllerTests(unittest.TestCase):
self.assertEqual(loaded.messages[0].content, "Hallo") self.assertEqual(loaded.messages[0].content, "Hallo")
self.assertEqual(renamed.title, "Neu") self.assertEqual(renamed.title, "Neu")
def test_controller_preflights_and_queues_gui_approval(self) -> None:
decision = self.controller.approval_decision("Private Frage")
self.controller.queue_approval(ApprovalChoice.ALLOW)
self.assertIsNotNone(decision)
self.assertIs(decision.policy, CloudPolicy.ASK)
self.assertIs(
self.runtime.hybrid_provider.queued_approval,
ApprovalChoice.ALLOW,
)
def test_error_sanitizer_masks_key_patterns(self) -> None: def test_error_sanitizer_masks_key_patterns(self) -> None:
secret = "AIza" + "x" * 25 secret = "AIza" + "x" * 25
result = sanitized_error(RuntimeError(f"Fehler {secret}")) result = sanitized_error(RuntimeError(f"Fehler {secret}"))
+150 -9
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import os import os
import threading
import time
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
@@ -13,8 +15,8 @@ from javis.core.provider_router import ApprovalChoice
from javis.providers.base import ChatMessage from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy, PrivacyDecision from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.main_window import MainWindow from javis.ui.main_window import MainWindow
from javis.ui.models import DesktopStatus, GenerationState from javis.ui.models import DesktopStatus, GenerationState, StreamEvent, StreamEventKind
from javis.ui.workers import ApprovalBridge from javis.ui.workers import ApprovalBridge, StreamWorker
class _Controller: class _Controller:
@@ -28,6 +30,15 @@ class _Controller:
message_count=2, message_count=2,
updated_at="2026-07-30T20:00:00+00:00", updated_at="2026-07-30T20:00:00+00:00",
) )
self.messages = [
ChatMessage("user", "Hallo"),
ChatMessage("assistant", "Guten Tag"),
]
self.approval: PrivacyDecision | None = None
self.queued_approval: ApprovalChoice | None = None
self.started = threading.Event()
self.release = threading.Event()
self.release.set()
def list_sessions(self, search: str = ""): def list_sessions(self, search: str = ""):
return [self.session] if not search or search in self.session.title else [] return [self.session] if not search or search in self.session.title else []
@@ -36,10 +47,7 @@ class _Controller:
self.active_session_id = session_id self.active_session_id = session_id
return SimpleNamespace( return SimpleNamespace(
session=self.session, session=self.session,
messages=[ messages=list(self.messages),
ChatMessage("user", "Hallo"),
ChatMessage("assistant", "Guten Tag"),
],
) )
def new_session(self): def new_session(self):
@@ -53,6 +61,38 @@ class _Controller:
def set_mode(self, _mode: str) -> None: def set_mode(self, _mode: str) -> None:
return return
def approval_decision(self, _text: str) -> PrivacyDecision | None:
return self.approval
def queue_approval(self, choice: ApprovalChoice) -> None:
self.queued_approval = choice
def stream_message(self, text: str, cancel_event: threading.Event):
self.started.set()
self.state = GenerationState.THINKING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
while not self.release.wait(0.005):
if cancel_event.is_set():
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
if cancel_event.is_set():
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
self.state = GenerationState.ANSWERING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
yield StreamEvent(StreamEventKind.CHUNK, text="Testantwort")
self.messages.extend(
[
ChatMessage("user", text),
ChatMessage("assistant", "Testantwort"),
]
)
self.session.message_count = len(self.messages)
self.state = GenerationState.READY
yield StreamEvent(StreamEventKind.FINISHED, state=self.state)
def status(self) -> DesktopStatus: def status(self) -> DesktopStatus:
return DesktopStatus( return DesktopStatus(
state=self.state, state=self.state,
@@ -108,14 +148,26 @@ class DesktopGuiTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.bridge = ApprovalBridge() self.bridge = ApprovalBridge()
self.window = MainWindow(_Controller(), self.bridge) self.controller = _Controller()
self.window = MainWindow(self.controller, self.bridge)
self.window.show() self.window.show()
self.application.processEvents() self.application.processEvents()
def tearDown(self) -> None: def tearDown(self) -> None:
self.controller.release.set()
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.window.close() self.window.close()
self.application.processEvents() self.application.processEvents()
def _process_until(self, condition, timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while not condition() and time.monotonic() < deadline:
self.application.processEvents()
time.sleep(0.005)
self.application.processEvents()
self.assertTrue(condition())
def test_offscreen_window_loads_session_and_core_widgets(self) -> None: def test_offscreen_window_loads_session_and_core_widgets(self) -> None:
self.assertEqual(self.window.windowTitle(), "Javis Desktop") self.assertEqual(self.window.windowTitle(), "Javis Desktop")
self.assertEqual(self.window.session_list.count(), 1) self.assertEqual(self.window.session_list.count(), 1)
@@ -137,5 +189,94 @@ class DesktopGuiTests(unittest.TestCase):
for label, choice in expected.items(): for label, choice in expected.items():
with self.subTest(label=label): with self.subTest(label=label):
_MessageBox.selected_label = label _MessageBox.selected_label = label
self.window._show_approval(decision) self.assertIs(self.window._show_approval(decision), choice)
self.bridge.resolve.assert_called_with(choice)
def test_local_completion_and_second_question_keep_gui_reusable(self) -> None:
for text in ("Erste Frage", "Zweite Frage"):
self.window.message_edit.setPlainText(text)
self.window._send()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.session.message_count, 6)
self.assertIn("Zweite Frage", self.window.chat_view.toPlainText())
self.assertTrue(self.window.send_button.isEnabled())
def test_approval_is_decided_before_worker_starts(self) -> None:
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
self.window._show_approval = Mock(return_value=ApprovalChoice.ALLOW)
self.window.message_edit.setPlainText("Private Testfrage")
self.window._send()
self.assertIs(self.controller.queued_approval, ApprovalChoice.ALLOW)
self.window._show_approval.assert_called_once()
self._process_until(lambda: self.window._thread is None)
def test_local_approval_choice_is_queued_before_worker_starts(self) -> None:
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
self.window._show_approval = Mock(return_value=ApprovalChoice.LOCAL)
self.window.message_edit.setPlainText("Lokal beantworten")
self.window._send()
self.assertIs(self.controller.queued_approval, ApprovalChoice.LOCAL)
self._process_until(lambda: self.window._thread is None)
def test_stop_during_streaming_discards_partial_exchange(self) -> None:
self.controller.release.clear()
before = list(self.controller.messages)
self.window.message_edit.setPlainText("Nicht speichern")
self.window._send()
self._process_until(self.controller.started.is_set)
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.messages, before)
self.assertEqual(self.controller.state, GenerationState.ABORTED)
def test_stop_immediately_after_start_is_safe(self) -> None:
self.controller.release.clear()
self.window.message_edit.setPlainText("Sofort stoppen")
self.window._send()
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.session.message_count, 2)
def test_completion_is_idempotent_and_retains_qt_references(self) -> None:
worker = object()
thread = object()
self.window._worker = worker
self.window._thread = thread
self.window._generation_finalized = False
self.window._generation_complete()
self.window._generation_complete()
self.assertIs(self.window._worker, worker)
self.assertIs(self.window._thread, thread)
self.window._thread_finished()
self.assertIsNone(self.window._worker)
self.assertIsNone(self.window._thread)
def test_worker_emits_completion_only_once(self) -> None:
worker = StreamWorker(self.controller, "Nur einmal")
completions: list[bool] = []
worker.completed.connect(lambda: completions.append(True))
worker.run()
worker.run()
self.assertEqual(completions, [True])
def test_close_during_generation_cancels_then_closes(self) -> None:
self.controller.release.clear()
self.window.message_edit.setPlainText("Beim Schließen stoppen")
self.window._send()
self._process_until(self.controller.started.is_set)
self.window.close()
self.assertTrue(self.window._close_when_finished)
self._process_until(lambda: self.window._thread is None)
self._process_until(lambda: not self.window.isVisible())
+12
View File
@@ -182,6 +182,18 @@ class HybridProviderTests(unittest.TestCase):
self.assertEqual(answer, "Cloud") self.assertEqual(answer, "Cloud")
self.assertEqual(len(self.cloud.calls), 1) self.assertEqual(len(self.cloud.calls), 1)
def test_queued_gui_approval_is_one_shot_and_uses_fake_cloud(self) -> None:
router = self._router()
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
router.queue_approval(ApprovalChoice.ALLOW)
chunks = list(router.stream_chat(question))
second_answer = router.chat(question)
self.assertEqual(chunks, ["Cloud"])
self.assertEqual(second_answer, "Lokal")
self.assertEqual(len(self.approvals), 1)
def test_ask_supports_allow_local_and_cancel_choices(self) -> None: def test_ask_supports_allow_local_and_cancel_choices(self) -> None:
question = [ChatMessage("user", "Meine Familie plant Urlaub")] question = [ChatMessage("user", "Meine Familie plant Urlaub")]