fix(chat): bấm Dừng thì báo rõ là đã dừng, không dội lỗi Python
Một lượt chạy đã 159 giây, bấm Dừng, rồi hoặc không thấy gì đổi, hoặc nhận một dòng đỏ "'NoneType' object has no attribute 'read'". Cả hai đều không trả lời được câu người dùng đang hỏi: nó dừng chưa? Ba chỗ nói sai: - stop() chỉ đặt cờ huỷ rồi thôi. Huỷ thật mất vài giây (đang chờ gateway, đang chạy dở một tool), mà trong khoảng đó chỉ báo vẫn đếm "Đang chạy · 160s". Nay đổi nhãn sang "Đang dừng" ngay lúc bấm, và _sync_indicators giữ nhãn đó khi chuyển tab. - Huỷ giữa stream đóng socket, urllib3 ném AttributeError chứ không phải requests.RequestException, nên hai vòng đọc stream của provider bắt hụt và lỗi chui lên tận giao diện. Nay bắt rộng rồi lọc lại: chỉ nuốt khi thật sự đang huỷ, lỗi khác vẫn ném tiếp nguyên vẹn. - Lượt chạy kết thúc sau khi huỷ vẫn đặt dấu XANH "Đã hoàn thành" (hoặc bong bóng lỗi đỏ nếu kết thúc bằng ngoại lệ). Nay đặt "⏹ Đã dừng theo yêu cầu", và thanh trạng thái nói cùng một chuyện. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"},
|
||||
"chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
|
||||
"chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."},
|
||||
"chatpanel.stopped": {"en": "{name}: stopped.", "ja": "{name}: 停止しました。",
|
||||
"vi": "{name}: đã dừng."},
|
||||
"chatpanel.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"},
|
||||
"chatpanel.attach_limit": {
|
||||
"en": "Max {n} attachments — extra files were skipped.",
|
||||
|
||||
@@ -229,6 +229,11 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
||||
"chat.open_output_folder": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
|
||||
"chat.done_marker": {"en": "Done", "ja": "完了しました", "vi": "Đã hoàn thành"},
|
||||
"chat.stopping": {"en": "Stopping", "ja": "停止中", "vi": "Đang dừng"},
|
||||
"chat.stopped_marker": {
|
||||
"en": "⏹ Stopped at your request",
|
||||
"ja": "⏹ リクエストにより停止しました",
|
||||
"vi": "⏹ Đã dừng theo yêu cầu"},
|
||||
"chat.session_folder_marker": {
|
||||
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
|
||||
"vi": "Thư mục output của hội thoại này"},
|
||||
|
||||
@@ -99,6 +99,9 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
||||
self.worker: AgentWorker | None = None
|
||||
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
|
||||
# Người dùng đã bấm Dừng cho lượt đang chạy chưa. Cờ này chỉ đổi thứ
|
||||
# MÀN HÌNH nói, không đổi việc huỷ: huỷ vẫn là cờ trên worker.
|
||||
self._stop_requested = False
|
||||
self._turn_seq: int = 0
|
||||
# session_id -> its live messages list, for every conversation that still has
|
||||
# a turn running. Lets you start a new chat / reopen an old one WHILE work
|
||||
@@ -337,7 +340,10 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
Switching chats, or hitting History → Refresh, shows whether THIS chat is
|
||||
still processing (a background turn) or idle."""
|
||||
if self._view_busy():
|
||||
self.thinking.start("chat.running") # this conversation is still working
|
||||
# Đã bấm Dừng mà lượt chưa kết thúc: giữ nhãn "đang dừng", đừng kéo
|
||||
# ngược về "đang chạy" — người dùng vừa bấm xong mà thấy chữ cũ thì
|
||||
# đọc ra là nút không ăn.
|
||||
self.thinking.start("chat.stopping" if self._stop_requested else "chat.running")
|
||||
else:
|
||||
self.thinking.stop()
|
||||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||||
|
||||
@@ -217,6 +217,7 @@ class ChatTurnRunnerMixin:
|
||||
if self._view_busy() or len(self._active) >= self._max_parallel():
|
||||
self.composer.set_busy(True)
|
||||
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
|
||||
self._stop_requested = False # lượt mới: xoá dấu vết lần Dừng trước
|
||||
self.thinking.start("chat.running")
|
||||
worker.start()
|
||||
|
||||
@@ -259,7 +260,7 @@ class ChatTurnRunnerMixin:
|
||||
self._show_usage(ctx) # per-turn + conversation token/cost
|
||||
except Exception: # noqa: BLE001 — usage display must never break a turn
|
||||
pass
|
||||
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
|
||||
done = self._dau_ket_thuc()
|
||||
folder = self.workspace_dir()
|
||||
if folder:
|
||||
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
|
||||
@@ -273,12 +274,32 @@ class ChatTurnRunnerMixin:
|
||||
self._maybe_notify_teams(result)
|
||||
self._drain_queue()
|
||||
|
||||
def _dau_ket_thuc(self):
|
||||
"""Dấu kết thúc đặt vào khung chat khi một lượt vừa xong.
|
||||
|
||||
Dừng theo yêu cầu KHÔNG phải là hoàn thành: dấu xanh "Đã hoàn thành" ở
|
||||
đó nói ngược hẳn với thứ người dùng vừa làm, và là lý do người dùng báo
|
||||
"bấm Dừng mà không biết nó đã dừng hay chưa".
|
||||
|
||||
Tách khỏi ``_on_finished`` để nhánh này kiểm được bằng test mà không
|
||||
phải dựng cả một ``ChatPanel``.
|
||||
"""
|
||||
if self._stop_requested:
|
||||
return self.chat_view.add_status(tr("chat.stopped_marker"))
|
||||
return self.chat_view.add_success(tr("chat.done_marker"))
|
||||
|
||||
def _on_failed(self, ctx: Dict[str, Any], err: str) -> None:
|
||||
"""Lượt chạy lỗi: huỷ thư mục kết quả tạm và hiện lỗi (nếu hội thoại còn đang mở)."""
|
||||
live = self._turn_is_live(ctx)
|
||||
self._end_turn(ctx)
|
||||
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
|
||||
if live:
|
||||
if live and self._stop_requested:
|
||||
# Người dùng vừa bấm Dừng: mọi lỗi phát sinh trong lúc huỷ là hệ quả
|
||||
# của chính việc huỷ (đóng socket giữa stream, tool bị cắt ngang).
|
||||
# Dội một traceback đỏ vào mặt họ là trả lời sai câu hỏi "nó dừng
|
||||
# chưa?" — thứ họ cần là một dòng nói rõ là đã dừng.
|
||||
self._dau_ket_thuc()
|
||||
elif live:
|
||||
self.chat_view.add_error(err)
|
||||
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
|
||||
from ...providers.base import is_model_not_found_error
|
||||
@@ -291,7 +312,10 @@ class ChatTurnRunnerMixin:
|
||||
self.composer.set_text(ctx["display_text"])
|
||||
else:
|
||||
self._persist_session(ctx)
|
||||
self.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
|
||||
# Thanh trạng thái phải nói cùng một chuyện với khung chat: dừng theo
|
||||
# yêu cầu thì không phải "lỗi".
|
||||
key = "chatpanel.stopped" if self._stop_requested else "chatpanel.failed"
|
||||
self.status_message.emit(tr(key, name=tr(f"app.tab.{self.kind}")))
|
||||
self.turn_finished.emit({"error": err})
|
||||
self._drain_queue()
|
||||
|
||||
@@ -316,6 +340,11 @@ class ChatTurnRunnerMixin:
|
||||
"""Dừng mọi lượt đang chạy của hội thoại này và xoá sạch hàng đợi."""
|
||||
if not self._active:
|
||||
return
|
||||
# Báo NGAY trên màn: huỷ thật có thể mất vài giây (đang chờ gateway trả
|
||||
# lời, đang chạy dở một tool), mà trong lúc đó chỉ báo vẫn đếm "Đang
|
||||
# chạy · 160s" — người dùng đọc ra là nút Dừng không ăn.
|
||||
self._stop_requested = True
|
||||
self.thinking.set_label("chat.stopping")
|
||||
for w in list(self._active):
|
||||
if w.isRunning():
|
||||
w.request_stop()
|
||||
|
||||
@@ -278,10 +278,17 @@ class AnthropicProvider(Provider):
|
||||
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or blocks:
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
|
||||
@@ -254,13 +254,19 @@ class OpenAICompatProvider(Provider):
|
||||
slot["args"] += fn["arguments"]
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# If cancel was requested, close cleanly without retry
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or tool_acc:
|
||||
# Partial answer already on screen — keep it, note the cut.
|
||||
if on_text:
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Bấm "Dừng" phải thấy được là nó đã ăn.
|
||||
|
||||
Triệu chứng người dùng báo: một lượt chạy đã 159 giây, bấm Dừng, rồi không có
|
||||
gì đổi trên màn hình — chỉ báo vẫn đếm "Đang chạy · 160s", nên không biết nút
|
||||
có tác dụng hay không.
|
||||
|
||||
Hai chỗ nói sai, cả hai đều kiểm được:
|
||||
|
||||
* Lúc bấm — ``stop()`` chỉ đặt cờ huỷ trên worker rồi thôi. Huỷ thật có thể mất
|
||||
vài giây (đang chờ gateway trả lời, đang chạy dở một tool), mà trong khoảng đó
|
||||
màn hình vẫn nói "Đang chạy".
|
||||
* Lúc kết thúc — ``_on_finished`` luôn đặt dấu XANH "Đã hoàn thành", kể cả khi
|
||||
lượt chạy vừa bị người dùng dừng. Dấu đó nói ngược hẳn với thứ vừa xảy ra.
|
||||
|
||||
Gọi thẳng phương thức với một ``self`` giả, không dựng ``ChatPanel`` thật: những
|
||||
hàm này là Python thuần trên vài thuộc tính của chính nó, và dựng cả màn chat
|
||||
trong ``tests/ui`` kéo theo QtWebEngine (xem
|
||||
``test_graphrag_follows_folder_change.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module")
|
||||
|
||||
from cowork_local.presentation.chat.chat_panel import ChatPanel
|
||||
from cowork_local.presentation.chat.chat_turn_runner import ChatTurnRunnerMixin
|
||||
|
||||
|
||||
class _ChiBaoGia:
|
||||
"""Thay ``ThinkingIndicator`` — ghi lại nhãn nó được yêu cầu hiện."""
|
||||
|
||||
def __init__(self):
|
||||
self.nhan = []
|
||||
|
||||
def set_label(self, key):
|
||||
self.nhan.append(key)
|
||||
|
||||
def start(self, key="chat.running"):
|
||||
self.nhan.append(key)
|
||||
|
||||
def stop(self):
|
||||
self.nhan.append(None)
|
||||
|
||||
|
||||
class _KhungChatGia:
|
||||
"""Thay ``chat_view`` — ghi lại loại dấu kết thúc được đặt vào."""
|
||||
|
||||
def __init__(self):
|
||||
self.dau = []
|
||||
|
||||
def add_error(self, text):
|
||||
self.dau.append(("error", text))
|
||||
return object()
|
||||
|
||||
def add_status(self, text):
|
||||
self.dau.append(("status", text))
|
||||
return object()
|
||||
|
||||
def add_success(self, text):
|
||||
self.dau.append(("success", text))
|
||||
return object()
|
||||
|
||||
|
||||
class _WorkerGia:
|
||||
def __init__(self, dang_chay=True):
|
||||
self._dang_chay = dang_chay
|
||||
self.da_yeu_cau_dung = False
|
||||
|
||||
def isRunning(self):
|
||||
return self._dang_chay
|
||||
|
||||
def request_stop(self):
|
||||
self.da_yeu_cau_dung = True
|
||||
|
||||
|
||||
class _ComposerGia:
|
||||
def __init__(self):
|
||||
self.da_xoa_hang_doi = False
|
||||
|
||||
def clear_queue(self):
|
||||
self.da_xoa_hang_doi = True
|
||||
|
||||
def set_running(self, _v):
|
||||
pass
|
||||
|
||||
def set_busy(self, _v):
|
||||
pass
|
||||
|
||||
def set_text(self, _v):
|
||||
pass
|
||||
|
||||
|
||||
class _TinNhanGia:
|
||||
def __init__(self):
|
||||
self.da_phat = []
|
||||
|
||||
def emit(self, *args):
|
||||
self.da_phat.append(args[0] if len(args) == 1 else args)
|
||||
|
||||
|
||||
class _Panel:
|
||||
"""``self`` giả cho các phương thức đang kiểm."""
|
||||
|
||||
kind = "cowork"
|
||||
|
||||
def __init__(self, dang_chay=True):
|
||||
self.worker = _WorkerGia(dang_chay)
|
||||
self._active = {self.worker: {}} if dang_chay else {}
|
||||
self.composer = _ComposerGia()
|
||||
self.status_message = _TinNhanGia()
|
||||
self.turn_finished = _TinNhanGia()
|
||||
self.graph_event = _TinNhanGia()
|
||||
self.session_name = "test"
|
||||
self.thinking = _ChiBaoGia()
|
||||
self.chat_view = _KhungChatGia()
|
||||
self._stop_requested = False
|
||||
self._ban_ron = dang_chay
|
||||
|
||||
def _view_busy(self):
|
||||
return self._ban_ron
|
||||
|
||||
def _max_parallel(self):
|
||||
return 2
|
||||
|
||||
# -- những thứ _on_failed cần, đều là no-op ------------------------- #
|
||||
def _turn_is_live(self, _ctx):
|
||||
return True
|
||||
|
||||
def _end_turn(self, _ctx):
|
||||
pass
|
||||
|
||||
def _cleanup_turn(self, _ctx, _ok):
|
||||
pass
|
||||
|
||||
def _drain_queue(self):
|
||||
pass
|
||||
|
||||
def _persist_session(self, _ctx):
|
||||
pass
|
||||
|
||||
def _dau_ket_thuc(self):
|
||||
"""Gọi phương thức THẬT — đây là thứ đang được kiểm, không giả lập.
|
||||
|
||||
Tra tên lúc GỌI chứ không lúc dựng lớp: tra lúc dựng thì trên bản
|
||||
chưa sửa cả file test đổ ngay ở khâu thu thập, và một lỗi thu thập
|
||||
không nói được gì về hành vi.
|
||||
"""
|
||||
return ChatTurnRunnerMixin._dau_ket_thuc(self)
|
||||
|
||||
|
||||
|
||||
# ---- lúc bấm Dừng -------------------------------------------------------
|
||||
|
||||
def test_bam_dung_thi_chi_bao_doi_sang_dang_dung():
|
||||
"""Đây là chỗ hỏng người dùng thấy: bấm xong màn hình không đổi gì."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin.stop(p)
|
||||
|
||||
assert p._stop_requested is True
|
||||
assert "chat.stopping" in p.thinking.nhan, (
|
||||
"chỉ báo vẫn nói 'Đang chạy' — người dùng đọc ra là nút Dừng không ăn")
|
||||
|
||||
|
||||
def test_bam_dung_van_yeu_cau_worker_dung_va_xoa_hang_doi():
|
||||
"""Hành vi vốn có, không được mất khi thêm phần hiển thị."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin.stop(p)
|
||||
|
||||
assert p.worker.da_yeu_cau_dung is True
|
||||
assert p.composer.da_xoa_hang_doi is True
|
||||
|
||||
|
||||
def test_khong_co_luot_nao_chay_thi_bam_dung_khong_lam_gi():
|
||||
"""Không có gì để dừng thì đừng nói dối là đang dừng."""
|
||||
p = _Panel(dang_chay=False)
|
||||
|
||||
ChatTurnRunnerMixin.stop(p)
|
||||
|
||||
assert p._stop_requested is False
|
||||
assert p.thinking.nhan == []
|
||||
|
||||
|
||||
# ---- lúc lượt chạy kết thúc --------------------------------------------
|
||||
|
||||
def test_da_dung_thi_dat_dau_da_dung_chu_khong_phai_hoan_thanh():
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatTurnRunnerMixin._dau_ket_thuc(p)
|
||||
|
||||
loai, text = p.chat_view.dau[0]
|
||||
assert loai == "status", "dừng theo yêu cầu mà vẫn đặt dấu xanh 'Đã hoàn thành'"
|
||||
assert "dừng" in text.lower() or "stop" in text.lower(), text
|
||||
|
||||
|
||||
def test_ket_thuc_binh_thuong_van_dat_dau_hoan_thanh():
|
||||
"""Chặn một chiều là hỏng tính năng — lượt chạy xong xuôi vẫn phải xanh."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin._dau_ket_thuc(p)
|
||||
|
||||
assert p.chat_view.dau[0][0] == "success"
|
||||
|
||||
|
||||
# ---- đồng bộ lại chỉ báo (đổi tab, Refresh History) --------------------
|
||||
|
||||
def test_dong_bo_lai_khong_keo_nhan_ve_dang_chay():
|
||||
"""``_sync_indicators`` chạy lại khi chuyển tab; nó mà đặt lại
|
||||
"chat.running" là nhãn "đang dừng" bị xoá ngay sau khi bấm."""
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatPanel._sync_indicators(p)
|
||||
|
||||
assert p.thinking.nhan[-1] == "chat.stopping"
|
||||
|
||||
|
||||
def test_dong_bo_lai_khi_chua_bam_dung_thi_van_la_dang_chay():
|
||||
p = _Panel()
|
||||
|
||||
ChatPanel._sync_indicators(p)
|
||||
|
||||
assert p.thinking.nhan[-1] == "chat.running"
|
||||
|
||||
|
||||
|
||||
# ---- lượt chạy kết thúc bằng NGOẠI LỆ vì vừa bị huỷ --------------------
|
||||
|
||||
def test_da_bam_dung_thi_loi_luc_huy_hien_ra_la_da_dung():
|
||||
"""Đúng thứ người dùng chụp lại: bấm Dừng xong nhận một dòng đỏ
|
||||
"'NoneType' object has no attribute 'read'" — đó là hệ quả của chính việc
|
||||
huỷ (đóng socket giữa stream), không phải một lỗi cần báo."""
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatTurnRunnerMixin._on_failed(p, {}, "'NoneType' object has no attribute 'read'")
|
||||
|
||||
loai = [l for l, _ in p.chat_view.dau]
|
||||
assert "error" not in loai, "vẫn dội lỗi Python ra màn hình sau khi người dùng bấm Dừng"
|
||||
assert loai == ["status"]
|
||||
assert "dừng" in p.chat_view.dau[0][1].lower()
|
||||
|
||||
|
||||
def test_loi_that_khi_chua_bam_dung_van_bao_loi_nhu_cu():
|
||||
"""Chặn một chiều là nuốt mất lỗi thật."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin._on_failed(p, {}, "gateway 500")
|
||||
|
||||
assert p.chat_view.dau == [("error", "gateway 500")]
|
||||
|
||||
|
||||
def test_thanh_trang_thai_noi_cung_mot_chuyen_voi_khung_chat():
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatTurnRunnerMixin._on_failed(p, {}, "bat ky loi gi")
|
||||
|
||||
assert any("dừng" in t.lower() for t in p.status_message.da_phat), p.status_message.da_phat
|
||||
|
||||
# ---- i18n ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("key", ["chat.stopping", "chat.stopped_marker",
|
||||
"chatpanel.stopped"])
|
||||
def test_key_moi_co_du_ba_ngon_ngu(key):
|
||||
from cowork_local import i18n
|
||||
|
||||
entry = i18n.STRINGS[key]
|
||||
for lang in ("en", "ja", "vi"):
|
||||
assert entry.get(lang), f"{key} thiếu {lang}"
|
||||
Reference in New Issue
Block a user