Fix Qt worker lifecycle crash
This commit is contained in:
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
@@ -84,6 +85,8 @@ class HybridProvider:
|
||||
self._max_cloud_input_chars = max_cloud_input_chars
|
||||
self._max_cloud_context_messages = max_cloud_context_messages
|
||||
self._cloud_session_disabled = False
|
||||
self._approval_lock = threading.Lock()
|
||||
self._next_approval: ApprovalChoice | None = None
|
||||
self.last_route: RouteStatus | None = None
|
||||
|
||||
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.")
|
||||
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:
|
||||
with self._approval_lock:
|
||||
queued = self._next_approval
|
||||
self._next_approval = None
|
||||
if queued is not None:
|
||||
return queued
|
||||
result = self._approval_callback(decision)
|
||||
if isinstance(result, bool):
|
||||
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from javis.config.settings import ConfigurationError, Settings
|
||||
from javis.core.provider_router import ApprovalChoice
|
||||
from javis.core.runtime import build_javis_runtime
|
||||
from javis.memory.sqlite_store import SessionStoreError
|
||||
from javis.memory.usage_store import UsageStoreError
|
||||
@@ -26,7 +27,7 @@ def run_gui(argv: Sequence[str] | None = None) -> int:
|
||||
settings = Settings.from_env()
|
||||
runtime = build_javis_runtime(
|
||||
settings,
|
||||
approval_callback=approval_bridge.request,
|
||||
approval_callback=lambda _decision: ApprovalChoice.CANCEL,
|
||||
notice_callback=approval_bridge.notice_received.emit,
|
||||
)
|
||||
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
|
||||
|
||||
@@ -6,9 +6,11 @@ import re
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
|
||||
from javis.core.provider_router import ApprovalChoice
|
||||
from javis.core.runtime import JavisRuntime
|
||||
from javis.memory.sqlite_store import ChatSession
|
||||
from javis.providers.base import ProviderError, ResponseAbortedError
|
||||
from javis.security.privacy import CloudPolicy, PrivacyDecision
|
||||
from javis.ui.models import (
|
||||
DesktopStatus,
|
||||
GenerationState,
|
||||
@@ -65,6 +67,16 @@ class DesktopController:
|
||||
def set_mode(self, mode: str) -> None:
|
||||
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(
|
||||
self,
|
||||
text: str,
|
||||
|
||||
@@ -57,6 +57,8 @@ class MainWindow(QMainWindow):
|
||||
self.approval_bridge = approval_bridge
|
||||
self._thread: QThread | None = None
|
||||
self._worker: StreamWorker | None = None
|
||||
self._generation_finalized = True
|
||||
self._close_when_finished = False
|
||||
self._messages: list[ChatMessage] = []
|
||||
self._draft_user = ""
|
||||
self._draft_assistant = ""
|
||||
@@ -158,7 +160,6 @@ class MainWindow(QMainWindow):
|
||||
self.stop_button.clicked.connect(self._stop_generation)
|
||||
self.message_edit.send_requested.connect(self._send)
|
||||
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)
|
||||
|
||||
def _apply_style(self) -> None:
|
||||
@@ -266,25 +267,40 @@ class MainWindow(QMainWindow):
|
||||
text = self.message_edit.toPlainText().strip()
|
||||
if not text or self._worker is not None:
|
||||
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._draft_user = text
|
||||
self._draft_assistant = ""
|
||||
self._render_chat()
|
||||
self._set_generating(True)
|
||||
self._generation_finalized = False
|
||||
thread = QThread(self)
|
||||
worker = StreamWorker(self.controller, text)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.event_received.connect(self._handle_stream_event)
|
||||
worker.completed.connect(thread.quit)
|
||||
worker.completed.connect(worker.deleteLater)
|
||||
worker.completed.connect(self._generation_complete)
|
||||
thread.finished.connect(worker.deleteLater)
|
||||
thread.finished.connect(self._thread_finished)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
self._thread = thread
|
||||
self._worker = worker
|
||||
thread.start()
|
||||
|
||||
def _handle_stream_event(self, event: StreamEvent) -> None:
|
||||
if self._generation_finalized:
|
||||
return
|
||||
if event.kind is StreamEventKind.CHUNK:
|
||||
self._draft_assistant += event.text
|
||||
self._render_chat()
|
||||
@@ -294,12 +310,13 @@ class MainWindow(QMainWindow):
|
||||
self._show_error(event.text)
|
||||
|
||||
def _generation_complete(self) -> None:
|
||||
if self._generation_finalized:
|
||||
return
|
||||
self._generation_finalized = True
|
||||
failed = self.controller.state in {
|
||||
GenerationState.ABORTED,
|
||||
GenerationState.ERROR,
|
||||
}
|
||||
self._worker = None
|
||||
self._thread = None
|
||||
self._set_generating(False)
|
||||
try:
|
||||
loaded = self.controller.load_session(self.controller.active_session_id or "")
|
||||
@@ -315,11 +332,17 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
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:
|
||||
if self._worker is None:
|
||||
return
|
||||
self._worker.request_cancel()
|
||||
self.approval_bridge.cancel_pending()
|
||||
self.statusBar().showMessage("Abbruch angefordert …")
|
||||
|
||||
def _set_generating(self, active: bool) -> None:
|
||||
@@ -336,7 +359,7 @@ class MainWindow(QMainWindow):
|
||||
except Exception as exc:
|
||||
self._show_error(str(exc))
|
||||
|
||||
def _show_approval(self, decision: PrivacyDecision) -> None:
|
||||
def _show_approval(self, decision: PrivacyDecision) -> ApprovalChoice:
|
||||
box = QMessageBox(self)
|
||||
box.setWindowTitle("Cloudfreigabe")
|
||||
box.setIcon(QMessageBox.Question)
|
||||
@@ -357,9 +380,9 @@ class MainWindow(QMainWindow):
|
||||
choice = ApprovalChoice.LOCAL
|
||||
elif clicked is cancel:
|
||||
choice = ApprovalChoice.CANCEL
|
||||
self.approval_bridge.resolve(choice)
|
||||
if box.clickedButton() is local:
|
||||
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
|
||||
return choice
|
||||
|
||||
def _show_notice(self, text: str) -> None:
|
||||
self.statusBar().showMessage(text, 5000)
|
||||
@@ -438,8 +461,9 @@ class MainWindow(QMainWindow):
|
||||
self.search_edit.selectAll()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
if self._thread is not None:
|
||||
self._close_when_finished = True
|
||||
self._stop_generation()
|
||||
if self._thread is not None and not self._thread.wait(2500):
|
||||
event.ignore()
|
||||
self.statusBar().showMessage(
|
||||
"Javis beendet die laufende Antwort noch kontrolliert.",
|
||||
|
||||
@@ -53,9 +53,14 @@ class StreamWorker(QObject):
|
||||
self._controller = controller
|
||||
self._text = text
|
||||
self._cancel_event = threading.Event()
|
||||
self._started = False
|
||||
self._completed = False
|
||||
|
||||
@Slot()
|
||||
def run(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
try:
|
||||
for event in self._controller.stream_message(
|
||||
self._text,
|
||||
@@ -63,6 +68,8 @@ class StreamWorker(QObject):
|
||||
):
|
||||
self.event_received.emit(event)
|
||||
finally:
|
||||
if not self._completed:
|
||||
self._completed = True
|
||||
self.completed.emit()
|
||||
|
||||
def request_cancel(self) -> None:
|
||||
|
||||
@@ -7,8 +7,10 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from javis.core.chat_service import ChatService
|
||||
from javis.core.provider_router import ApprovalChoice
|
||||
from javis.memory.sqlite_store import SQLiteSessionStore
|
||||
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.models import GenerationState, StreamEventKind
|
||||
|
||||
@@ -45,10 +47,17 @@ class _Service:
|
||||
class _Hybrid:
|
||||
mode = "auto"
|
||||
last_route = None
|
||||
queued_approval = None
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
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:
|
||||
name = "ollama"
|
||||
@@ -91,6 +100,17 @@ class DesktopControllerTests(unittest.TestCase):
|
||||
self.assertEqual(loaded.messages[0].content, "Hallo")
|
||||
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:
|
||||
secret = "AIza" + "x" * 25
|
||||
result = sanitized_error(RuntimeError(f"Fehler {secret}"))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
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.security.privacy import CloudPolicy, PrivacyDecision
|
||||
from javis.ui.main_window import MainWindow
|
||||
from javis.ui.models import DesktopStatus, GenerationState
|
||||
from javis.ui.workers import ApprovalBridge
|
||||
from javis.ui.models import DesktopStatus, GenerationState, StreamEvent, StreamEventKind
|
||||
from javis.ui.workers import ApprovalBridge, StreamWorker
|
||||
|
||||
|
||||
class _Controller:
|
||||
@@ -28,6 +30,15 @@ class _Controller:
|
||||
message_count=2,
|
||||
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 = ""):
|
||||
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
|
||||
return SimpleNamespace(
|
||||
session=self.session,
|
||||
messages=[
|
||||
ChatMessage("user", "Hallo"),
|
||||
ChatMessage("assistant", "Guten Tag"),
|
||||
],
|
||||
messages=list(self.messages),
|
||||
)
|
||||
|
||||
def new_session(self):
|
||||
@@ -53,6 +61,38 @@ class _Controller:
|
||||
def set_mode(self, _mode: str) -> None:
|
||||
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:
|
||||
return DesktopStatus(
|
||||
state=self.state,
|
||||
@@ -108,14 +148,26 @@ class DesktopGuiTests(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.bridge = ApprovalBridge()
|
||||
self.window = MainWindow(_Controller(), self.bridge)
|
||||
self.controller = _Controller()
|
||||
self.window = MainWindow(self.controller, self.bridge)
|
||||
self.window.show()
|
||||
self.application.processEvents()
|
||||
|
||||
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.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:
|
||||
self.assertEqual(self.window.windowTitle(), "Javis Desktop")
|
||||
self.assertEqual(self.window.session_list.count(), 1)
|
||||
@@ -137,5 +189,94 @@ class DesktopGuiTests(unittest.TestCase):
|
||||
for label, choice in expected.items():
|
||||
with self.subTest(label=label):
|
||||
_MessageBox.selected_label = label
|
||||
self.window._show_approval(decision)
|
||||
self.bridge.resolve.assert_called_with(choice)
|
||||
self.assertIs(self.window._show_approval(decision), 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())
|
||||
|
||||
@@ -182,6 +182,18 @@ class HybridProviderTests(unittest.TestCase):
|
||||
self.assertEqual(answer, "Cloud")
|
||||
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:
|
||||
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user