Compare commits

..
Author SHA1 Message Date
minhanhpkproandClaude Opus 5.5 a6b68a0bf8 feat(app): ghi log khi app văng/treo và báo "Đang xử lý…" khi giao diện đứng
App văng ra với mã 0xC0000409 mà không để lại dấu vết: lỗi ở tầng native không in
traceback Python và console đóng theo app. crash_guard ghi vào
~/.cowork_local/logs/:

- crash.log: stack mọi luồng lúc tiến trình chết (faulthandler), cùng exception
  Python không ai bắt ở luồng chính lẫn luồng nền (chỉ ghi, app chạy tiếp).
- qt.log: cảnh báo/lỗi của Qt.
- hang.log: stack mọi luồng mỗi khi luồng giao diện đứng quá 5 giây.

Khi giao diện đứng, một luồng canh mở hộp thoại Windows "Đang xử lý…" (theo ngôn
ngữ đang chọn trong app) và tự đóng khi app phản hồi lại. Cài đặt bọc trong try,
không bao giờ chặn app khởi động.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:40:06 +09:00
minhanhpkproandClaude Opus 5.5 0b6b220bd9 feat(routing): ô định tuyến chỉ còn Auto và Manual
Bỏ hai chế độ Off và Fallback ở ô định tuyến cạnh khung chat và ở mục Định
tuyến trong Cài đặt. Mặc định chuyển sang Auto.

- USER_ROUTING_MODES = ("auto", "manual"); giá trị off/fallback/lạ còn lưu trong
  config hay project đều được hiểu là Auto (routing_mode_for,
  project_routing_mode, set_*). Engine vẫn hiểu "off" khi truyền mode_override
  tường minh.
- RoutingScheduler: định tuyến giờ luôn bật nên việc chấm điểm model định kỳ
  luôn chạy; muốn tắt thì đặt reassess_interval_hours = 0.
- Cập nhật các test đang ghim hành vi "mặc định off".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:40:05 +09:00
minhanhpkproandClaude Opus 5.5 fefc9f94db fix(chat): đổi tên hội thoại thì tiêu đề khung chat đổi theo ngay
Đổi tên một hội thoại ở cột lịch sử (chuột phải → Đổi tên) thì tiêu đề khung
chat bên phải vẫn giữ tên cũ, phải click ra ngoài rồi click lại mới đổi. Tệ hơn,
self.title cũ vẫn nằm trong bộ nhớ nên lượt chat kế tiếp lưu đè tên cũ lên tên
người dùng vừa đặt.

- HistorySidebar phát conversation_renamed(session_id, title); WorkspaceTab nối
  vào apply_renamed_title của Cowork. Hội thoại đang mở thì đổi luôn self.title
  và làm mới thanh tiêu đề.
- Tiêu đề hội thoại chỉ hiện tối đa 10 ký tự, dư thì "…", tên đầy đủ ở tooltip.
  Áp cho cả thanh tiêu đề khung chat lẫn danh sách hội thoại trong project
  (dùng chung clip_chars). Hộp thoại Đổi tên vẫn điền tên đầy đủ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:40:05 +09:00
20 changed files with 540 additions and 69 deletions
+12
View File
@@ -72,6 +72,12 @@ def run(argv: List[str] | None = None) -> int:
app = QApplication.instance() or QApplication(argv)
app.setApplicationName(APP_NAME)
app.setWindowIcon(app_icon())
try:
from .config import CONFIG_DIR
from .presentation.shell import crash_guard
crash_guard.install(Path(CONFIG_DIR) / "logs")
except Exception: # noqa: BLE001 - crash logging must never block startup
crash_guard = None
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
# điều hành, không còn config.py::AppConfig.
@@ -132,4 +138,10 @@ def run(argv: List[str] | None = None) -> int:
pass
win.show()
if crash_guard is not None:
try:
watchdog = crash_guard.HangWatchdog(DISPLAY_NAME, win)
app.aboutToQuit.connect(watchdog.stop)
except Exception: # noqa: BLE001
pass
return app.exec()
+2 -2
View File
@@ -236,7 +236,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
# this section is only the behaviour config the user edits.
"routing": {
"switch_mode": "off", # global default: "off" | "auto" | "manual"
"switch_mode": "auto", # global default: "auto" | "manual"
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
@@ -246,7 +246,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An
# Per-surface Auto/Manual toggle state (the chat-screen toggle). An
# empty string means "follow the global switch_mode above".
"surface_modes": {
"cowork": "",
+5 -15
View File
@@ -75,26 +75,16 @@ class RoutingScheduler(QObject):
return None
def _routing_enabled_anywhere(self) -> bool:
"""Is routing actually in use? True if the global mode is auto/manual OR
any chat surface overrides to auto/manual. When everything is Off, the
assessment scores would never be consulted — so we don't spend tokens
probing for them (no surprise cost on a fresh install)."""
try:
routing = self.ctx.config.routing
if (routing.get("switch_mode") or "off") in ("auto", "manual"):
"""Is routing actually in use? Always, now: the Off mode was removed and
every stored value resolves to Auto or Manual (see
``config.user_routing_mode``). Paid probing is switched off through
``reassess_interval_hours = 0`` instead."""
return True
for m in (routing.get("surface_modes") or {}).values():
if m in ("auto", "manual"):
return True
except Exception: # noqa: BLE001
pass
return False
def is_due(self) -> bool:
"""Đã đến lúc chấm điểm lại chưa.
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
không được tiêu tiền cho một tính năng người dùng đã tắt.
Dò model là lượt gọi có tính phí: đặt chu kỳ chấm lại = 0 thì không dò.
"""
if not self._routing_enabled_anywhere():
return False # routing off everywhere → don't probe (would be wasted cost)
+6
View File
@@ -155,6 +155,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.lang.switching": {
"en": "Switching language…", "ja": "言語を切り替えています…",
"vi": "Đang đổi ngôn ngữ…"},
# Popup do luồng canh treo mở khi giao diện đứng quá vài giây
# (presentation/shell/crash_guard.py).
"app.hang.message": {
"en": "Processing, please wait…\n\nThis window closes by itself once the app responds again.",
"ja": "処理中です。しばらくお待ちください…\n\nアプリが応答を再開すると、このウィンドウは自動的に閉じます。",
"vi": "Đang xử lý, vui lòng đợi…\n\nCửa sổ này tự đóng khi app phản hồi lại."},
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
@@ -258,6 +258,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
#: Chế độ người dùng còn chọn được. "off"/"fallback" đã bỏ khỏi giao diện;
#: giá trị cũ còn lưu trong config/project (hoặc giá trị lạ) đều hiểu là "auto".
USER_ROUTING_MODES = ("auto", "manual")
@classmethod
def user_routing_mode(cls, mode: str) -> str:
"""Quy một giá trị đã lưu về chế độ người dùng chọn được (mặc định "auto")."""
return mode if mode in cls.USER_ROUTING_MODES else "auto"
@classmethod
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
@@ -313,16 +321,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
"""Chế độ có hiệu lực cho một bề mặt chat.
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
có người sửa tay file cấu hình."""
Chỉ còn Auto/Manual: "off", "fallback" cũ hay giá trị lạ đều hiểu là "auto"."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in self.ROUTING_MODES else "off"
return self.user_routing_mode(override or routing.get("switch_mode", ""))
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
mode = mode if mode in self.ROUTING_MODES else "off"
mode = self.user_routing_mode(mode)
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
+7
View File
@@ -9,6 +9,13 @@ from typing import Any, Dict, List, Optional
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
HEADER_TITLE_MAX_CHARS = 10 # thanh tiêu đề khung chat chỉ hiện tối đa ngần này ký tự
def clip_chars(text: str, limit: int = HEADER_TITLE_MAX_CHARS) -> str:
"""Giữ tối đa ``limit`` ký tự, dư thì cắt và thêm "…"."""
return text if len(text) <= limit else text[:limit].rstrip() + "…"
def _format_plan_steps(steps) -> str:
+17
View File
@@ -224,6 +224,23 @@ class ChatSessionMixin:
if getattr(self, "_usage_total_lbl", None) is not None:
self.refresh_usage()
def set_title_label(self, lbl, fallback: str) -> None:
"""Đặt tiêu đề hội thoại lên nhãn: tối đa 10 ký tự, bản đầy đủ ở tooltip."""
from .chat_helpers import clip_chars
full = self.title or fallback
lbl.setText(clip_chars(full))
lbl.setToolTip(full)
def apply_renamed_title(self, session_id: str, title: str) -> None:
"""Hội thoại đang mở vừa được đổi tên ở cột lịch sử: đổi luôn ``self.title``.
Không chỉ để thanh tiêu đề cập nhật ngay — lượt chat kế tiếp lưu bằng
``self.title``, giữ tên cũ sẽ ghi đè mất tên người dùng vừa đặt."""
if session_id and session_id == self.session_id:
self.title = title
self._notify_title()
def load_conversation(self, conv: Dict[str, Any]) -> None:
"""Switch the view to a stored conversation. Allowed while work is running —
the current turns keep going in the background."""
@@ -12,11 +12,9 @@ from PySide6.QtWidgets import (
from ...i18n import tr
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo.
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"))
#: Các chế độ người dùng chọn được — khớp ``AppConfig.USER_ROUTING_MODES``.
#: Off/Fallback đã bỏ; giá trị cũ còn lưu được hiểu là Auto.
MODE_KEYS = (("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"))
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
("latency", "routing.policy_latency"),
@@ -37,7 +35,7 @@ class RoutingSettingsWidget(QGroupBox):
self.mode = QComboBox()
for value, key in MODE_KEYS:
self.mode.addItem(tr(key), value)
_select(self.mode, routing.get("switch_mode", "off"))
_select(self.mode, routing.get("switch_mode", "auto")) # off/fallback cũ → mục đầu (Auto)
form.addRow(tr("routing.settings_mode"), self.mode)
self.policy = QComboBox()
+171
View File
@@ -0,0 +1,171 @@
"""Ghi lại mọi lần app chết hoặc treo, và báo "Đang xử lý…" khi giao diện đứng.
Trước đây app văng ra (mã 0xC0000409) mà không để lại dấu vết gì: lỗi ở tầng
native không in traceback Python, còn cửa sổ console thì đóng ngay theo app. Mô-đun
này ghi mọi thứ vào ``~/.cowork_local/logs/``:
* ``crash.log``: stack của mọi luồng lúc tiến trình chết (``faulthandler``), kèm
exception Python không ai bắt, ở luồng chính lẫn luồng nền. Exception Python
chỉ được ghi lại, app vẫn chạy tiếp.
* ``qt.log``: cảnh báo, lỗi và lỗi nghiêm trọng của Qt (cũng vẫn in ra console).
* ``hang.log``: stack của mọi luồng mỗi khi luồng giao diện đứng quá
``HANG_SECONDS`` giây, để biết app đang kẹt ở dòng nào.
Luồng giao diện đang đứng thì không thể tự vẽ popup, nên popup "Đang xử lý…" do một
luồng canh riêng mở bằng hộp thoại gốc của Windows. Giao diện chạy lại thì hộp
thoại tự đóng.
"""
from __future__ import annotations
import datetime
import faulthandler
import sys
import threading
import time
import traceback
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QTimer, QtMsgType, qInstallMessageHandler
HANG_SECONDS = 5.0 # luồng giao diện đứng quá ngần này giây thì coi là treo
_BEAT_MS = 500 # nhịp luồng giao diện báo "vẫn sống"
_crash_file = None # giữ file mở suốt đời app: faulthandler ghi vào lúc chết
_log_dir: Optional[Path] = None
def _stamp() -> str:
"""Mốc giờ hiện tại, dùng làm tiêu đề mỗi mục trong log."""
return datetime.datetime.now().isoformat(timespec="seconds")
def _append(name: str, text: str) -> None:
"""Ghi thêm vào một file log. Ghi log hỏng thì bỏ qua, không kéo app theo."""
if _log_dir is None:
return
try:
with open(_log_dir / name, "a", encoding="utf-8") as f:
f.write(text)
except OSError:
pass
def _log_exception(where: str, exc_type, exc, tb) -> None:
"""Ghi một exception không ai bắt vào crash.log và in ra console."""
body = "".join(traceback.format_exception(exc_type, exc, tb))
_append("crash.log", f"\n=== {_stamp()} exception chưa bắt ({where}) ===\n{body}")
if sys.__stderr__:
sys.__stderr__.write(body)
def _qt_message(mode, context, message) -> None:
"""Chuyển thông báo của Qt vào qt.log, vẫn in ra console như trước."""
level = {QtMsgType.QtWarningMsg: "WARNING", QtMsgType.QtCriticalMsg: "CRITICAL",
QtMsgType.QtFatalMsg: "FATAL"}.get(mode)
if sys.__stderr__:
sys.__stderr__.write(message + "\n")
if level is None:
return # debug/info: chỉ in, không ghi file
_append("qt.log", f"{_stamp()} {level}: {message}\n")
if mode == QtMsgType.QtFatalMsg and _crash_file is not None:
faulthandler.dump_traceback(_crash_file, all_threads=True)
def install(log_dir: Path) -> None:
"""Bật ghi log crash. Gọi một lần, sớm nhất có thể sau khi có QApplication."""
global _crash_file, _log_dir
log_dir.mkdir(parents=True, exist_ok=True)
_log_dir = log_dir
_crash_file = open(log_dir / "crash.log", "a", encoding="utf-8") # noqa: SIM115
_crash_file.write(f"\n=== {_stamp()} app khởi động ===\n")
_crash_file.flush()
faulthandler.enable(file=_crash_file, all_threads=True)
sys.excepthook = lambda t, e, tb: _log_exception("luồng chính", t, e, tb)
threading.excepthook = lambda a: _log_exception(
f"luồng {a.thread.name if a.thread else '?'}", a.exc_type, a.exc_value, a.exc_traceback)
qInstallMessageHandler(_qt_message)
class HangWatchdog:
"""Canh luồng giao diện: đứng quá ``HANG_SECONDS`` giây thì ghi stack vào
hang.log và mở popup "Đang xử lý…", chạy lại thì đóng popup.
Luồng giao diện đều đặn cập nhật nhịp qua một ``QTimer``; một luồng nền kiểm tra
nhịp đó. Luồng nền không đụng vào widget Qt nào.
"""
def __init__(self, title: str, parent=None) -> None:
self._title = title
self._last_beat = time.monotonic()
self._timer = QTimer(parent)
self._timer.timeout.connect(self._beat)
self._timer.start(_BEAT_MS)
self._stop = threading.Event()
self._popup: Optional[threading.Thread] = None
self._popup_title = title
threading.Thread(target=self._watch, name="hang-watchdog", daemon=True).start()
def stop(self) -> None:
"""Dừng canh (khi app thoát)."""
self._stop.set()
self._timer.stop()
def _beat(self) -> None:
self._last_beat = time.monotonic()
def _watch(self) -> None:
stalled = False
while not self._stop.wait(0.5):
lag = time.monotonic() - self._last_beat
if lag >= HANG_SECONDS and not stalled:
stalled = True
self._dump_stacks(lag)
self._show_popup()
elif lag < HANG_SECONDS and stalled:
stalled = False
self._close_popup()
def _dump_stacks(self, lag: float) -> None:
if _log_dir is None:
return
try:
with open(_log_dir / "hang.log", "a", encoding="utf-8") as f:
f.write(f"\n=== {_stamp()} giao diện đứng {lag:.1f}s ===\n")
f.flush()
faulthandler.dump_traceback(f, all_threads=True)
except OSError:
pass
def _show_popup(self) -> None:
if sys.platform != "win32":
return
import ctypes
from ...i18n import tr
# Đọc chữ NGAY LÚC hiện popup: luôn khớp ngôn ngữ đang chọn trong app,
# kể cả khi người dùng vừa đổi ngôn ngữ.
title, message = self._title, tr("app.hang.message")
# MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND. MessageBoxW có vòng
# thông điệp riêng trên luồng này, nên hiện được dù luồng giao diện đang đứng.
flags = 0x40 | 0x40000 | 0x10000
self._popup_title = title
self._popup = threading.Thread(
target=lambda: ctypes.windll.user32.MessageBoxW(None, message, title, flags),
name="hang-popup", daemon=True)
self._popup.start()
def _close_popup(self) -> None:
if self._popup is None or sys.platform != "win32":
return
import ctypes
user32 = ctypes.windll.user32
for _ in range(10): # hộp thoại có thể chưa kịp hiện ra
hwnd = user32.FindWindowW(None, self._popup_title)
if hwnd:
user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
break
time.sleep(0.05)
self._popup = None
+4 -4
View File
@@ -90,7 +90,7 @@ class AppContext:
return load_project(pid)
def project_routing_mode(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
"""Effective Auto/Manual routing mode for a chat ``surface``
in the ACTIVE workspace: the workspace's own override wins; otherwise the
global default (``config.routing_mode_for``). This is what makes each
workspace keep its own routing mode.
@@ -101,15 +101,15 @@ class AppContext:
project = self._current_project()
if project is not None:
mode = (project.routing_modes or {}).get(surface, "")
if mode in self.config.ROUTING_MODES:
return mode
if mode in self.config.ROUTING_MODES: # old off/fallback → auto
return self.config.user_routing_mode(mode)
return self.config.routing_mode_for(surface)
def set_project_routing_mode(self, surface: str, mode: str) -> None:
"""Persist a surface's routing mode for the ACTIVE workspace. With no
workspace selected, falls back to the global setting so behaviour
outside a project stays global."""
mode = mode if mode in self.config.ROUTING_MODES else "off"
mode = self.config.user_routing_mode(mode)
project = self._current_project()
if project is None:
self.config.set_routing_mode_for(surface, mode)
@@ -0,0 +1,95 @@
"""Đổi tên hội thoại ở cột lịch sử thì thanh tiêu đề Cowork đổi theo ngay.
Trước đây nhãn tiêu đề giữ tên cũ cho tới khi người dùng mở lại hội thoại, và
``self.title`` cũ còn ghi đè tên mới ở lượt chat kế tiếp.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from cowork_local.config import AppConfig # noqa: E402
from cowork_local.presentation.chat.chat_helpers import clip_chars # noqa: E402
from cowork_local.state import AppContext # noqa: E402
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
@pytest.fixture(scope="module")
def qt_app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
@pytest.fixture
def cowork(qt_app, tmp_path: Path):
from cowork_local.ui.cowork_tab import CoworkTab
tab = CoworkTab(AppContext(AppConfig.load(tmp_path / "config.json")))
yield tab
tab.deleteLater()
def test_clip_chars_keeps_ten_characters():
assert clip_chars("Tên ngắn") == "Tên ngắn"
assert clip_chars("0123456789") == "0123456789"
assert clip_chars("0123456789AB") == "0123456789…"
assert clip_chars("Dự án mới toanh") == "Dự án mới…" # không để khoảng trắng trước "…"
def test_rename_of_open_conversation_updates_header(cowork):
cowork.load_conversation({"session_id": "s1", "title": "Tên cũ", "messages": []})
assert cowork._title_lbl.text() == "Tên cũ"
cowork.apply_renamed_title("s1", "Tên mới")
assert cowork.title == "Tên mới" # lượt chat sau lưu bằng tên mới
assert cowork._title_lbl.text() == "Tên mới"
def test_rename_of_other_conversation_is_ignored(cowork):
cowork.load_conversation({"session_id": "s1", "title": "Đang mở", "messages": []})
cowork.apply_renamed_title("s2", "Khác")
assert cowork._title_lbl.text() == "Đang mở"
def test_long_title_is_clipped_with_full_tooltip(cowork):
long_title = "Báo cáo doanh thu quý 3"
cowork.load_conversation({"session_id": "s1", "title": "x", "messages": []})
cowork.apply_renamed_title("s1", long_title)
assert cowork._title_lbl.text() == "Báo cáo do…"
assert cowork._title_lbl.toolTip() == long_title
def test_history_list_clips_title_to_ten_chars(qt_app, tmp_path):
from PySide6.QtCore import Qt
from cowork_local.core.history import save_conversation
from cowork_local.ui.sidebar import HistorySidebar
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
ctx.config.data.setdefault("history", {})["custom_dir"] = str(tmp_path / "history")
assert ctx.config.history_dir() == tmp_path / "history" # không đọc lịch sử thật
title = "Báo cáo doanh thu quý 3"
save_conversation(tmp_path / "history", "cowork", "s1",
[{"role": "user", "content": "hi"}], title, project_id="p-test")
sb = HistorySidebar(ctx)
sb._project_filter = "p-test" # chỉ đọc history_dir() tạm, không gộp các project thật
sb.refresh()
items = []
stack = [sb.tree.topLevelItem(i) for i in range(sb.tree.topLevelItemCount())]
while stack:
it = stack.pop()
if it.data(0, Qt.UserRole):
items.append(it)
stack.extend(it.child(i) for i in range(it.childCount()))
assert len(items) == 1
assert items[0].text(0).splitlines()[0] == "Báo cáo do…"
assert items[0].toolTip(0) == title
assert items[0].data(0, Qt.UserRole + 3) == title # đổi tên vẫn điền tên đầy đủ
sb.deleteLater()
+96
View File
@@ -0,0 +1,96 @@
"""crash_guard ghi lại exception chưa bắt và phát hiện giao diện bị treo."""
from __future__ import annotations
import os
import threading
import time
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
from cowork_local.presentation.shell import crash_guard # noqa: E402
@pytest.fixture
def qt_app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
@pytest.fixture
def guard(tmp_path, monkeypatch):
import faulthandler
import sys
from PySide6.QtCore import qInstallMessageHandler
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
monkeypatch.setattr(threading, "excepthook", threading.excepthook)
crash_guard.install(tmp_path)
yield tmp_path
qInstallMessageHandler(None)
faulthandler.disable()
crash_guard._crash_file.close()
crash_guard._crash_file = None
crash_guard._log_dir = None
def test_uncaught_thread_exception_is_logged_not_fatal(guard):
def boom():
raise ValueError("lỗi luồng nền")
t = threading.Thread(target=boom, name="worker-x")
t.start()
t.join()
log = (guard / "crash.log").read_text(encoding="utf-8")
assert "ValueError: lỗi luồng nền" in log
assert "worker-x" in log
def test_watchdog_dumps_stacks_and_pops_up_while_gui_is_stalled(guard, qt_app, monkeypatch):
monkeypatch.setattr(crash_guard, "HANG_SECONDS", 0.6)
events = []
monkeypatch.setattr(crash_guard.HangWatchdog, "_show_popup", lambda self: events.append("show"))
monkeypatch.setattr(crash_guard.HangWatchdog, "_close_popup", lambda self: events.append("close"))
dog = crash_guard.HangWatchdog("t")
try:
qt_app.processEvents()
time.sleep(1.5) # luồng giao diện "đứng"
assert events == ["show"]
end = time.time() + 2
while "close" not in events and time.time() < end:
qt_app.processEvents() # giao diện chạy lại
time.sleep(0.05)
assert events == ["show", "close"]
finally:
dog.stop()
assert "giao diện đứng" in (guard / "hang.log").read_text(encoding="utf-8")
@pytest.mark.parametrize("lang, expected", [
("vi", "Đang xử lý"), ("en", "Processing"), ("ja", "処理中"),
])
def test_popup_text_follows_the_current_app_language(qt_app, monkeypatch, lang, expected):
import sys
import types
from cowork_local.i18n import get_language, set_language
shown = []
fake_user32 = types.SimpleNamespace(MessageBoxW=lambda h, msg, title, f: shown.append(msg))
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setitem(sys.modules, "ctypes", types.SimpleNamespace(
windll=types.SimpleNamespace(user32=fake_user32)))
before = get_language()
dog = crash_guard.HangWatchdog("t")
try:
set_language(lang)
dog._show_popup()
dog._popup.join(1)
finally:
dog.stop()
set_language(before)
assert shown and expected in shown[0]
@@ -234,16 +234,16 @@ def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None
assert outcome.switched is True
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
"""The new mode must be persistable, or the toggle could never select it."""
ctx.config.set_routing_mode_for("cowork", "fallback")
assert ctx.config.routing_mode_for("cowork") == "fallback"
assert ctx.project_routing_mode("cowork") == "fallback"
def test_removed_modes_resolve_to_auto(ctx) -> None:
"""Off/Fallback were removed from the UI: a stored value resolves to Auto."""
for legacy in ("off", "fallback"):
ctx.config.set_routing_mode_for("cowork", legacy)
assert ctx.config.routing_mode_for("cowork") == "auto"
assert ctx.project_routing_mode("cowork") == "auto"
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
"""A hand-edited config must not enable routing by accident."""
def test_unknown_persisted_mode_degrades_to_auto(ctx) -> None:
"""A hand-edited config resolves to the default mode (Auto)."""
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
assert ctx.config.routing_mode_for("cowork") == "off"
assert ctx.config.routing_mode_for("cowork") == "auto"
+26 -9
View File
@@ -32,16 +32,29 @@ def _mk(ctx, name):
def test_defaults_follow_global_when_no_override(ctx):
a = _mk(ctx, "Alpha")
ctx.active_project_id = a.project_id
# Global default switch_mode is "off".
assert ctx.project_routing_mode("cowork") == "off"
# Global default switch_mode is "auto" (Off was removed).
assert ctx.project_routing_mode("cowork") == "auto"
# Change the GLOBAL default → project with no override follows it.
ctx.config.data["routing"]["switch_mode"] = "auto"
ctx.config.data["routing"]["switch_mode"] = "manual"
assert ctx.project_routing_mode("cowork") == "manual"
def test_legacy_off_and_fallback_resolve_to_auto(ctx):
a = _mk(ctx, "Alpha")
ctx.active_project_id = a.project_id
ctx.config.data["routing"]["switch_mode"] = "manual"
for legacy in ("off", "fallback"):
a.routing_modes = {"cowork": legacy}
save_project(a)
assert ctx.project_routing_mode("cowork") == "auto"
ctx.set_project_routing_mode("cowork", "off")
assert ctx.project_routing_mode("cowork") == "auto"
def test_per_workspace_routing_is_isolated(ctx):
a = _mk(ctx, "Alpha")
b = _mk(ctx, "Beta")
ctx.config.data["routing"]["switch_mode"] = "manual"
ctx.active_project_id = a.project_id
ctx.set_project_routing_mode("cowork", "auto")
@@ -49,16 +62,19 @@ def test_per_workspace_routing_is_isolated(ctx):
# Switching to workspace B must NOT see A's override (falls back to global).
ctx.active_project_id = b.project_id
assert ctx.project_routing_mode("cowork") == "off"
# B sets its own, independently.
ctx.set_project_routing_mode("cowork", "manual")
assert ctx.project_routing_mode("cowork") == "manual"
# A is unchanged.
# B sets its own, independently.
ctx.set_project_routing_mode("cowork", "auto")
ctx.active_project_id = a.project_id
ctx.set_project_routing_mode("cowork", "manual")
ctx.active_project_id = b.project_id
assert ctx.project_routing_mode("cowork") == "auto"
# A keeps its own.
ctx.active_project_id = a.project_id
assert ctx.project_routing_mode("cowork") == "manual"
def test_per_surface_isolated_within_a_workspace(ctx):
a = _mk(ctx, "Alpha")
@@ -68,7 +84,8 @@ def test_per_surface_isolated_within_a_workspace(ctx):
# co4e untouched → global default.
assert ctx.project_routing_mode("cowork") == "auto"
assert ctx.project_routing_mode("ai_edit") == "manual"
assert ctx.project_routing_mode("co4e") == "off"
ctx.config.data["routing"]["switch_mode"] = "manual"
assert ctx.project_routing_mode("co4e") == "manual"
def test_routing_mode_persists_to_disk(ctx):
+15 -6
View File
@@ -88,12 +88,21 @@ def test_best_for_returns_strong(service):
assert ranking.best.assessment.metadata.model_id == "strong-model"
def test_route_off_never_switches(service):
def test_route_off_explicit_override_never_switches(service):
# "off" is no longer user-selectable, but the engine still honours it
# when passed explicitly.
service.reassess()
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model",
mode_override="off")
assert r.mode == SwitchMode.OFF
assert r.should_switch is False
def test_legacy_off_in_config_routes_as_auto(service):
service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "off"
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
assert r.mode == SwitchMode.OFF
assert r.should_switch is False
assert r.mode == SwitchMode.AUTO
def test_route_auto_switches_to_strong(service):
@@ -147,12 +156,12 @@ def test_route_never_raises_on_broken_store(ctx, tmp_path):
def test_per_surface_mode_override(service):
service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "off"
service.ctx.config.data["routing"]["switch_mode"] = "manual"
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
# cowork follows global (off); co4e overridden to auto
# cowork follows global (manual); co4e overridden to auto
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
assert r_cowork.mode == SwitchMode.OFF
assert r_cowork.mode == SwitchMode.MANUAL
assert r_co4e.mode == SwitchMode.AUTO
assert r_co4e.should_switch is True
+42
View File
@@ -0,0 +1,42 @@
"""Ô định tuyến chỉ còn Auto và Manual; giá trị off/fallback cũ hiện thành Auto."""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
@pytest.fixture(scope="module")
def qt_app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
def _toggle(stored: str):
from cowork_local.ui.routing_toggle import RoutingToggle
saved = []
t = RoutingToggle(None, "cowork", get_mode=lambda: stored, set_mode=saved.append)
return t, saved
def test_only_auto_and_manual_are_offered(qt_app):
t, _ = _toggle("auto")
items = [t._combo.itemData(i) for i in range(t._combo.count())]
assert items == ["auto", "manual"]
@pytest.mark.parametrize("stored", ["off", "fallback", "", "turbo"])
def test_legacy_values_show_as_auto(qt_app, stored):
t, _ = _toggle(stored)
assert t.current_mode() == "auto"
def test_choosing_manual_persists_it(qt_app):
t, saved = _toggle("auto")
t._combo.setCurrentIndex(t._combo.findData("manual"))
assert saved == ["manual"]
+1 -1
View File
@@ -95,7 +95,7 @@ class CoworkTab(ChatPanel):
lbl = getattr(self, "_title_lbl", None)
if lbl is None:
return # ChatPanel.__init__ sets self.title before we exist
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
self.set_title_label(lbl, tr("cowork.title"))
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
+7 -10
View File
@@ -40,7 +40,7 @@ class RoutingToggle(QWidget):
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
"""
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
mode_changed = Signal(str) # "auto" | "manual"
def __init__(
self,
@@ -76,14 +76,11 @@ class RoutingToggle(QWidget):
# it the width stays frozen at the language the widget was built in and
# the longer translation is cut off.
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
# (data value, i18n key) — data is the persisted mode string. Order is
# least-to-most autonomous, with Fallback (R03-T03) last because it is
# the "only when something breaks" mode rather than a stronger Auto.
# (data value, i18n key) — data is the persisted mode string. Off and
# Fallback were dropped: only Auto and Manual remain selectable.
self._modes = [
("off", "routing.mode_off"),
("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"),
("fallback", "routing.mode_fallback"),
]
for value, key in self._modes:
self._combo.addItem(tr(key), value)
@@ -100,16 +97,16 @@ class RoutingToggle(QWidget):
on_language_changed(self.retranslate)
def current_mode(self) -> str:
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
return self._combo.currentData() or "off"
"""Chế độ định tuyến đang chọn; 'auto' nếu chưa đặt."""
return self._combo.currentData() or "auto"
def refresh(self) -> None:
"""Re-read the backing mode (e.g. after switching workspace) and show it
without emitting a spurious change."""
try:
mode = self._get_mode() or "off"
mode = self._get_mode() or "auto"
except Exception: # noqa: BLE001
mode = "off"
mode = "auto"
idx = self._combo.findData(mode)
if idx < 0:
idx = 0
+7 -1
View File
@@ -14,6 +14,7 @@ from ..core.history import (
rename_conversation, set_pinned,
)
from ..i18n import on_language_changed, tr
from ..presentation.chat.chat_helpers import clip_chars
from .dialog_buttons import ask_text, confirm
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
from .widgets import CollapseStrip
@@ -81,6 +82,7 @@ class HistorySidebar(QWidget):
expand_requested = Signal() # strip clicked: re-expand
refresh_requested = Signal() # Refresh button: re-list + re-sync agent status
history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync
conversation_renamed = Signal(str, str) # session_id, new title — the open chat retitles itself
def __init__(self, ctx: AppContext):
"""Cột lịch sử hội thoại.
@@ -273,7 +275,9 @@ class HistorySidebar(QWidget):
is_current = bool(sid) and sid == self.current_session_id
is_running = sid in self.running_ids
suffix = tr("sidebar.running_suffix") if is_running else ""
item = QTreeWidgetItem([f"{title}{suffix}\n{created}"])
# Tối đa 10 ký tự như tiêu đề khung chat; tên đầy đủ ở tooltip.
item = QTreeWidgetItem([f"{clip_chars(title)}{suffix}\n{created}"])
item.setToolTip(0, title)
# A running turn (blue LED) takes visual priority over the pin icon.
if is_running:
item.setIcon(0, dot_icon(DOT_BLUE))
@@ -352,6 +356,8 @@ class HistorySidebar(QWidget):
if ok and new.strip():
rename_conversation(path, new.strip())
self.refresh()
sid = load_conversation(path).get("session_id", "")
self.conversation_renamed.emit(sid, new.strip())
elif chosen == del_act:
if confirm(self, tr("sidebar.delete.title"),
tr("sidebar.delete.confirm", title=title)):
+2
View File
@@ -339,6 +339,8 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
sb.expand_requested.connect(lambda: self._on_history_fold(False))
sb.refresh_requested.connect(self._on_sidebar_refresh)
sb.history_changed.connect(self._reload_threads)
if self._cowork is not None:
sb.conversation_renamed.connect(self._cowork.apply_renamed_title)
def _on_history_fold(self, collapsed: bool) -> None:
"""Its chevron closes the panel away, back to the drawn layout."""