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:
2026-09-22 08:39:43 +09:00
co-authored by Claude Opus 5
parent b500b3e57d
commit ddb31f9aff
7 changed files with 335 additions and 7 deletions
+2
View File
@@ -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.",
+5
View File
@@ -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"},
+7 -1
View File
@@ -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
+32 -3
View File
@@ -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()
+8 -1
View File
@@ -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")
+8 -2
View File
@@ -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:
+273
View File
@@ -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}"