From b699e161854967bc87eaf3345ab6802a213e442d Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Mon, 7 Sep 2026 19:23:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(history):=20"T=E1=BA=A5t=20c=E1=BA=A3=20pro?= =?UTF-8?q?ject"=20v=C3=A0=20s=E1=BB=91=20li=E1=BB=87u=20m=E1=BB=97i=20pro?= =?UTF-8?q?ject=20=C4=91=E1=BB=8Dc=20=C4=91=E1=BB=A7=20m=E1=BB=8Di=20th?= =?UTF-8?q?=C6=B0=20m=E1=BB=A5c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lịch sử hội thoại KHÔNG nằm chung một chỗ: _bind_project đặt config._project_history_dir thành /.cowork_history mỗi lần chọn project khác — cố ý, để chia sẻ thư mục project là chia sẻ cả lịch sử. Nhưng hai chỗ đọc lại chỉ đọc MỘT thư mục, gây hai triệu chứng cùng gốc: - ui/sidebar.py đọc history_dir() (thư mục của project ĐANG mở), nên khung "Tất cả project…" dựng đủ tiêu đề nhóm mà mọi nhóm trừ một đều rỗng; - _project_counts gọi list_conversations() KHÔNG tham số, tức đọc HISTORY_DIR toàn cục nơi không có hội thoại nào của project, nên mọi dòng project đều đếm "0 đoạn chat". Thêm history_dirs() + list_conversations_by_project(), giữ đúng thứ tự cũ (ghim trước, mới nhất trước) và chống trùng. Thư mục là chủ sở hữu có thẩm quyền: hội thoại nằm trong workspace của project nào thì thuộc project đó, kể cả khi trường project_id trong file đã cũ vì project bị đổi thư mục. HISTORY_SUBDIR + project_history_dir() gom đường dẫn về một định nghĩa duy nhất — chuỗi ".cowork_history" từng nằm rải trong ui/workspace_tab.py. Co-Authored-By: Claude Opus 5 (1M context) --- core/history.py | 52 +++++++++ core/projects.py | 10 ++ tests/test_history_across_projects.py | 158 ++++++++++++++++++++++++++ ui/sidebar.py | 11 +- 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 tests/test_history_across_projects.py diff --git a/core/history.py b/core/history.py index 7575baa..5d14fc6 100644 --- a/core/history.py +++ b/core/history.py @@ -132,6 +132,58 @@ def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bo return False +def history_dirs() -> list: + """Các cặp ``(project_id, thư mục lịch sử)`` của MỌI project, cộng thư mục + mặc định cho hội thoại chưa thuộc project nào. + + Có hàm này vì lịch sử KHÔNG nằm chung một chỗ, mà nằm trong thư mục làm việc + của từng project. Ai chỉ gọi ``list_conversations()`` một lần sẽ chỉ thấy + hội thoại của project đang mở — hoặc, nếu gọi không tham số, không thấy cái + nào cả. Đó chính là hai lỗi đã xảy ra: khung "Tất cả project…" hiện nhóm + rỗng cho mọi project trừ một, và mọi dòng project đều đếm "0 đoạn chat". + """ + from ..config import HISTORY_DIR + from .projects import list_projects, project_history_dir + + pairs = [("default", HISTORY_DIR)] + for project in list_projects(): + pairs.append((project.project_id, project_history_dir(project))) + return pairs + + +def list_conversations_by_project(pairs, query: str = "") -> List[Dict[str, Any]]: + """Gộp lịch sử hội thoại của NHIỀU project. ``pairs`` là các cặp + ``(project_id, directory)``. + + Lịch sử KHÔNG nằm chung một chỗ: ``WorkspaceTab`` đặt + ``config._project_history_dir`` thành ``/.cowork_history`` + mỗi lần người dùng chọn project khác, nên ``config.history_dir()`` chỉ trả về + thư mục của project ĐANG mở. Một lần gọi :func:`list_conversations` vì thế + chỉ thấy được hội thoại của project đó — khung "Tất cả project…" dựng đủ + tiêu đề nhóm cho mọi project nhưng mọi nhóm trừ một đều rỗng. + + Thư mục là chủ sở hữu có thẩm quyền: hội thoại nằm trong thư mục làm việc của + project nào thì thuộc project đó, kể cả khi trường ``project_id`` ghi trong + file đã cũ (project bị đổi thư mục chẳng hạn). + """ + seen: set = set() + items: List[Dict[str, Any]] = [] + for project_id, directory in pairs: + if directory is None: + continue + for meta in list_conversations(directory, query=query): + key = str(meta["path"]) + if key in seen: + continue + seen.add(key) + if project_id: + meta["project_id"] = project_id + items.append(meta) + # Cùng thứ tự mà list_conversations dùng: ghim lên đầu, rồi mới nhất trước. + items.sort(key=lambda d: (not d["pinned"], -d["mtime"])) + return items + + def list_conversations(directory: Optional[Path] = None, query: str = "") -> List[Dict[str, Any]]: """List saved conversations, most recent first (pinned always on top). diff --git a/core/projects.py b/core/projects.py index f0f2ed4..f6bef6b 100644 --- a/core/projects.py +++ b/core/projects.py @@ -110,6 +110,16 @@ def _slugify(name: str) -> str: return s or "project" +#: Lich su hoi thoai cua mot project nam TRONG thu muc lam viec cua no, de chia +#: se thu muc do la chia se ca lich su (may khac xem va tiep tuc duoc). +HISTORY_SUBDIR = ".cowork_history" + + +def project_history_dir(project) -> Path: + """Thư mục lịch sử hội thoại của một project.""" + return project.workspace_dir() / HISTORY_SUBDIR + + def new_project(name: str, description: str = "", instructions: str = "", output_dir: str = "", directory: Path = None) -> Project: """Create + persist a new project with a unique id derived from the name.""" diff --git a/tests/test_history_across_projects.py b/tests/test_history_across_projects.py new file mode 100644 index 0000000..6e13dcd --- /dev/null +++ b/tests/test_history_across_projects.py @@ -0,0 +1,158 @@ +"""Lịch sử hội thoại nằm trong thư mục của TỪNG project, không nằm chung. + +``WorkspaceTab._bind_project`` đặt ``config._project_history_dir`` thành +``/.cowork_history`` mỗi lần người dùng chọn project khác. +Hệ quả: một lần gọi ``list_conversations()`` chỉ thấy hội thoại của project đang +mở — và gọi KHÔNG tham số thì không thấy cái nào cả, vì nó đọc ``HISTORY_DIR`` +toàn cục. + +Hai lỗi đã xảy ra vì đúng chuyện này: + +* Khung "Tất cả project…" dựng đủ tiêu đề nhóm cho mọi project nhưng mọi nhóm + trừ một đều rỗng. +* Mọi dòng project đều đếm "0 đoạn chat · 0 task" dù người dùng đã chat. + +Test không chạm ``~/.cowork_local``: ``core/projects.py`` gắn ``PROJECTS_DIR`` +vào thư mục dữ liệu THẬT, nên mọi thứ ở đây dùng ``tmp_path`` và fake. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from cowork_local.core.history import ( + list_conversations, list_conversations_by_project, +) + + +def _viet_hoi_thoai(directory: Path, session_id: str, title: str, + project_id: str = "", mtime: float | None = None) -> Path: + """Ghi một file hội thoại tối thiểu mà ``list_conversations`` đọc được.""" + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{session_id}.json" + path.write_text(json.dumps({ + "kind": "cowork", + "title": title, + "created": "2026-09-07T10:00:00", + "session_id": session_id, + "project_id": project_id, + "messages": [{"role": "user", "content": title}], + }), encoding="utf-8") + if mtime is not None: + import os + os.utime(path, (mtime, mtime)) + return path + + +@pytest.fixture +def hai_project(tmp_path): + """Hai project, mỗi cái một đoạn chat — đúng tình huống người dùng báo.""" + a = tmp_path / "test" / ".cowork_history" + b = tmp_path / "test1" / ".cowork_history" + _viet_hoi_thoai(a, "s-a", "chat cua test", mtime=1000) + _viet_hoi_thoai(b, "s-b", "chat cua test1", mtime=2000) + return [("p-test", a), ("p-test1", b)] + + +# ---- triệu chứng gốc ----------------------------------------------------- + +def test_mot_lan_goi_chi_thay_mot_project(hai_project): + """Chốt lại chính nguyên nhân, để nó không bị coi là chuyện đương nhiên.""" + _pid_a, dir_a = hai_project[0] + + chi_mot = list_conversations(dir_a) + + assert len(chi_mot) == 1 + assert chi_mot[0]["title"] == "chat cua test" + + +def test_gop_nhieu_thu_muc_thi_thay_du(hai_project): + tat_ca = list_conversations_by_project(hai_project) + + assert {c["title"] for c in tat_ca} == {"chat cua test", "chat cua test1"} + + +def test_moi_hoi_thoai_thuoc_dung_project(hai_project): + """Thư mục là chủ sở hữu có thẩm quyền, không phải trường project_id trong file.""" + theo_pid = {c["project_id"]: c["title"] for c in list_conversations_by_project(hai_project)} + + assert theo_pid == {"p-test": "chat cua test", "p-test1": "chat cua test1"} + + +def test_project_id_cu_trong_file_bi_ghi_de(tmp_path): + """Project bị đổi thư mục thì trường trong file đã cũ — thư mục vẫn đúng.""" + d = tmp_path / "moi" / ".cowork_history" + _viet_hoi_thoai(d, "s1", "x", project_id="pid-cu-roi") + + ket_qua = list_conversations_by_project([("pid-that", d)]) + + assert ket_qua[0]["project_id"] == "pid-that" + + +# ---- thứ tự và trùng lặp ------------------------------------------------- + +def test_giu_dung_thu_tu_moi_nhat_truoc(hai_project): + tat_ca = list_conversations_by_project(hai_project) + + assert [c["title"] for c in tat_ca] == ["chat cua test1", "chat cua test"] + + +def test_ghim_len_dau_bat_ke_thoi_gian(tmp_path): + d1 = tmp_path / "a" / ".cowork_history" + d2 = tmp_path / "b" / ".cowork_history" + _viet_hoi_thoai(d1, "cu", "cu ma ghim", mtime=1000) + _viet_hoi_thoai(d2, "moi", "moi ma khong ghim", mtime=9000) + data = json.loads((d1 / "cu.json").read_text(encoding="utf-8")) + data["pinned"] = True + (d1 / "cu.json").write_text(json.dumps(data), encoding="utf-8") + + tat_ca = list_conversations_by_project([("a", d1), ("b", d2)]) + + assert tat_ca[0]["title"] == "cu ma ghim" + + +def test_cung_mot_thu_muc_hai_lan_khong_dem_doi(hai_project): + """``history_dirs()`` có thể trả về trùng thư mục khi cấu hình chồng nhau.""" + _pid, d = hai_project[0] + + assert len(list_conversations_by_project([("x", d), ("y", d)])) == 1 + + +# ---- đầu vào xấu --------------------------------------------------------- + +def test_thu_muc_chua_ton_tai_thi_bo_qua(tmp_path, hai_project): + tat_ca = list_conversations_by_project( + hai_project + [("rong", tmp_path / "chua-he-co")]) + + assert len(tat_ca) == 2 + + +def test_thu_muc_None_thi_bo_qua(hai_project): + assert len(list_conversations_by_project(hai_project + [("x", None)])) == 2 + + +def test_khong_co_project_nao_thi_tra_rong(): + assert list_conversations_by_project([]) == [] + + +# ---- tìm kiếm vẫn hoạt động khi đã gộp ----------------------------------- + +def test_tim_kiem_ap_cho_moi_thu_muc(hai_project): + assert len(list_conversations_by_project(hai_project, query="test1")) == 1 + assert len(list_conversations_by_project(hai_project, query="chat cua")) == 2 + assert list_conversations_by_project(hai_project, query="khong-he-co") == [] + + +# ---- một định nghĩa duy nhất cho đường dẫn ------------------------------- + +def test_duong_dan_lich_su_chi_dinh_nghia_mot_cho(): + """Chuỗi ".cowork_history" từng nằm rải ở ``ui/workspace_tab.py``.""" + from cowork_local.core.projects import HISTORY_SUBDIR + + repo = Path(__file__).resolve().parents[1] + src = (repo / "ui" / "workspace_tab.py").read_text(encoding="utf-8") + + assert HISTORY_SUBDIR == ".cowork_history" + assert ".cowork_history" not in src, "phải dùng project_history_dir()" diff --git a/ui/sidebar.py b/ui/sidebar.py index 4675903..8497fe3 100644 --- a/ui/sidebar.py +++ b/ui/sidebar.py @@ -241,8 +241,17 @@ class HistorySidebar(QWidget): for p in projects: groups[p.project_id] = _make_group(p.name) + # Loc theo mot project -> doc dung thu muc cua no. Khong loc ("Tat ca + # project…") -> phai doc thu muc lich su cua TUNG project roi gop lai: + # lich su nam trong thu muc lam viec cua project, nen mot lan goi + # list_conversations chi thay duoc project dang mo, va moi nhom con lai + # hien ra rong tuy nguoi dung da chat trong do. try: - convos = list_conversations(self.ctx.config.history_dir(), query=query) + if self._project_filter: + convos = list_conversations(self.ctx.config.history_dir(), query=query) + else: + from ..core.history import history_dirs, list_conversations_by_project + convos = list_conversations_by_project(history_dirs(), query=query) except Exception: convos = []