Merge remote-tracking branch 'origin/gamma/refactor'

This commit is contained in:
Hiep Ha Van
2026-08-25 23:55:46 +09:00
178 changed files with 35668 additions and 2404 deletions
@@ -0,0 +1,317 @@
"""Characterization test cho khu vực AGENTS trong sidebar của ``Co4ETab``
(``ui/co4e_tab.py``): ``ag_new_btn``/``ag_edit_btn``/``ag_del_btn``/``agent_list``
và phần "populate agent_list" bên trong ``_reload_sidebar`` — đúng các đoạn được
giao: dòng 549-568 (dựng widget + nối signal), 689-713 (``_reload_sidebar``, CHỈ
đoạn agent: item "Parallel" cố định rồi tới danh sách custom agent) và
1339-1367 (``_new_agent``/``_edit_agent``/``_edit_agent_dialog``/``_delete_agent``).
VÌ SAO GHI LẠI CHỨ KHÔNG PHÁN XÉT: đây là lưới an toàn cho đợt tách
``ui/co4e_tab.py`` (2000+ dòng) thành các module con dưới ``presentation/co4e/``
(xem ``docs/architecture/co4e-split-map.md``). Mọi ``assert`` dưới đây được chốt
lại từ giá trị THẬT in ra khi chạy code (quy trình ngược: chạy trước, in ra,
dán vào assert), không phải giá trị tôi nghĩ nó "nên" là gì.
VÌ SAO CHẠY TRONG TIẾN TRÌNH CON CÔ LẬP HOME: y hệt kỹ thuật của
``test_co4e_skills_panel.py`` (đọc file đó để thấy khuôn subprocess gốc) —
dựng ``Co4ETab`` thật kéo theo ``AppConfig``/``CONFIG_DIR`` là hằng số module
tính MỘT LẦN lúc import từ ``Path.home()``, nên phải cô lập ``HOME``/
``USERPROFILE`` TRƯỚC bất kỳ import ``cowork_local.*`` nào, trong một tiến
trình con sạch — patch thuộc tính module sau khi import là không đủ.
AN TOÀN DỮ LIỆU — agent_list NGUY HIỂM HƠN skill_list: ``_reload_sidebar``/
``_new_agent``/``_edit_agent``/``_delete_agent`` gọi thẳng
``core.co4e.list_custom_agents``/``save_custom_agent``/``delete_custom_agent``,
và ``delete_custom_agent`` THỰC SỰ XOÁ file JSON dưới ``CO4E_DIR``. Script con
assert ``str(CONFIG_DIR).startswith(sandbox)`` NGAY sau khi import, trước khi
gọi bất kỳ hàm co4e nào — chạy nhầm trên máy thật sẽ xoá agent thật của người
dùng.
KHÔNG gọi provider AI thật / spawn thread thật: không đụng tới
``Co4ERunManager.start()`` hay ``AgentWorker`` được start thật. ``gen_btn``
("AI-assist" trong ``Co4EAgentDialog``) không được click ở bất kỳ case nào.
``Co4EAgentDialog.exec`` (modal, sẽ treo tiến trình headless) được monkeypatch
thành một hàm giả NGAY TRONG tiến trình con của test — không sửa code sản
phẩm; hàm giả chỉ gõ vào các ô nhập liệu thật (``name_edit``/``role_edit``)
rồi trả về "Accepted" để đường xử lý thật (``dlg.result_agent()`` +
``save_custom_agent``) chạy nguyên vẹn.
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới):
* ``_mint_id`` (``core/co4e.py``) dùng MỘT bộ đếm ``_counter["n"]`` DÙNG
CHUNG cho mọi loại id (workflow/node/agent...), không tách theo tiền tố.
``Co4ETab.__init__`` mint ``wf_000001`` cho luồng rỗng ban đầu TRƯỚC khi
bất kỳ agent nào được tạo, nên agent custom đầu tiên trong cả tiến trình
mang id ``agent_000002`` chứ không phải ``agent_000001`` — id "nhảy số"
không phải bug ghi riêng cho agent, mà là hệ quả của một bộ đếm toàn cục.
* Dòng 706 dùng ``role=ca.role or "AGENT"`` khi dựng payload kéo-thả, nhưng
nhãn hiển thị trên dòng 711 dùng THẲNG ``ca.role`` (không fallback) — một
agent lưu với ``role=""`` hiện dòng tiêu đề rỗng (" · · tùy chỉnh")
nhưng payload kéo lên canvas lại có ``role="AGENT"``. Hai chỗ đọc cùng một
field nhưng ứng xử khác nhau với chuỗi rỗng.
* ``_edit_agent``/``_delete_agent`` đọc ``cid`` từ item đang chọn rồi tra
lại trong ``list_custom_agents()`` MỚI (không dùng payload đã cache) — nếu
file bị xoá ở "phía sau" (script khác, hoặc do một _delete_agent khác) mà
``agent_list`` chưa được ``_reload_sidebar()`` lại, thao tác Sửa trên dòng
đó là NO-OP thầm lặng (không báo lỗi, không mở dialog) vì
``agent is not None`` chặn ở nhánh fallback.
* Chọn dòng "Parallel" (cố định, không phải custom agent) rồi bấm Sửa/Xoá
tạo ra CÙNG MỘT thông báo với việc không chọn gì cả
(``tr("co4e.select_custom_agent")``) — ``cid`` là ``None`` trong cả hai
trường hợp vì item Parallel không set ``Qt.UserRole + 1``.
VÒNG ĐỜI: đây là giàn giáo cho đợt tách khu vực Agents sang
``presentation/co4e/`` (xem cột "Trạng thái" của dòng liên quan trong
``docs/architecture/co4e-split-map.md``). Sau khi tách xong thành một
``AgentsListPanel``/tương đương với hợp đồng rõ ràng (giống
``SkillsListPanel`` đã làm), các case ở đây nên được viết lại thành test đặc
tả cho panel mới (input rõ ràng, không cần dựng cả ``Co4ETab``/``QApplication``
nặng nề qua subprocess). Ba quirk "bộ đếm id dùng chung", "role rỗng hiển thị
khác payload" và "sửa trên cid đã xoá là no-op thầm lặng" đáng mở issue hỏi ý
kiến sản phẩm trước khi ai đó "dọn" chúng trong lúc tách — đặc biệt quirk thứ
hai, vì nó rất dễ bị "sửa cho gọn" thành dùng cùng một biểu thức fallback ở cả
hai chỗ, và như vậy vô tình đổi cả nhãn hiển thị lẫn dữ liệu kéo-thả.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from PySide6.QtWidgets import QAbstractItemView, QApplication
from PySide6.QtCore import Qt
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.core import co4e as co4e_mod
from cowork_local.ui.co4e_tab import Co4ETab
from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog
from cowork_local.i18n import tr
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
tab = Co4ETab(ctx)
# ---- 549-568: widget cua khu vuc AGENTS duoc dung dung nhu quan sat -------
assert tab.ag_new_btn.text() == tr("co4e.new"), tab.ag_new_btn.text()
assert tab.ag_new_btn.toolTip() == tr("co4e.tt_new_agent"), tab.ag_new_btn.toolTip()
assert tab.ag_new_btn.objectName() == "co4eSectionAction"
assert tab.ag_new_btn.isFlat() is True
assert tab.ag_new_btn.cursor().shape() == Qt.PointingHandCursor
assert tab.ag_edit_btn.toolTip() == tr("co4e.tt_edit_agent"), tab.ag_edit_btn.toolTip()
assert tab.ag_edit_btn.width() == 34, tab.ag_edit_btn.width()
assert tab.ag_del_btn.toolTip() == tr("co4e.tt_del_agent"), tab.ag_del_btn.toolTip()
assert tab.ag_del_btn.width() == 34, tab.ag_del_btn.width()
assert tab.agent_list.dragEnabled() is True
assert tab.agent_list.dragDropMode() == QAbstractItemView.DragOnly
print("CASE_WIDGETS_OK")
# ---- 689-713: _reload_sidebar voi thu muc agents RONG ---------------------
# Chi con dong "Parallel" co dinh, khong co custom agent nao.
tab._reload_sidebar()
assert tab.agent_list.count() == 1, tab.agent_list.count()
it0 = tab.agent_list.item(0)
assert it0.text() == tr("co4e.parallel_node"), it0.text()
payload0 = it0.data(Qt.UserRole)
assert payload0 == {
"variant": "parallel", "label": "Parallel", "role": "PARALLEL",
"icon": "server", "sub_agents": [],
}, payload0
assert it0.data(Qt.UserRole + 1) is None
print("CASE_EMPTY_OK")
# ---- quirk: bo dem id dung chung cho moi loai (xem docstring dau file) ----
# Co4ETab.__init__ da mint "wf_000001" cho luong rong ban dau -> agent CUSTOM
# dau tien trong ca tien trinh mang id "agent_000002", khong phai "..._000001".
agent1 = co4e_mod.new_custom_agent("Reviewer Bot")
assert agent1.id == "agent_000002", agent1.id
print("CASE_ID_COUNTER_QUIRK_OK")
agent1.role = "REVIEWER"
agent1.icon = "eye"
agent1.instructions = "Review the diff."
agent1.skills = ["Test Skill"]
co4e_mod.save_custom_agent(agent1)
tab._reload_sidebar()
assert tab.agent_list.count() == 2, tab.agent_list.count()
it1 = tab.agent_list.item(1)
assert it1.text() == "Reviewer Bot · REVIEWER · " + tr("co4e.custom"), it1.text()
payload1 = it1.data(Qt.UserRole)
assert payload1 == {
"variant": "step", "label": "Reviewer Bot", "agent_slug": "reviewer-bot",
"role": "REVIEWER", "icon": "eye", "instructions": "Review the diff.",
"context": "", "model": "", "self_verify": True, "max_verify_rounds": 1,
"permission_preset": "full", "skills": ["Test Skill"], "attachments": [],
"sub_agents": [],
}, payload1
assert it1.data(Qt.UserRole + 1) == agent1.id
assert it1.icon().isNull() is False
print("CASE_ONE_AGENT_OK")
# ---- quirk: agent luu voi name="" va role="" -------------------------------
# Nhan hien thi (dong 711) dung THANG ca.role -> rong; nhung payload keo-tha
# (dong 706) dung "ca.role or 'AGENT'" -> fallback ve "AGENT". Cung mot field,
# hai cach doc khac nhau khi gap chuoi rong.
agent2 = co4e_mod.new_custom_agent("")
agent2.role = ""
co4e_mod.save_custom_agent(agent2)
tab._reload_sidebar()
assert tab.agent_list.count() == 3, tab.agent_list.count()
it2 = tab.agent_list.item(2)
assert it2.text() == " · · " + tr("co4e.custom"), repr(it2.text())
payload2 = it2.data(Qt.UserRole)
assert payload2["role"] == "AGENT", payload2["role"]
print("CASE_EMPTY_NAME_ROLE_QUIRK_OK")
# ---- 1339-1340: _new_agent() mo dialog cho MOT agent moi tinh --------------
# Gia lap Co4EAgentDialog.exec() ngay trong tien trinh con nay (khong sua code
# san pham): go vao chinh cac o nhap that (name_edit/role_edit) roi tra ve
# Accepted, de duong xu ly that (result_agent() + save_custom_agent) chay
# nguyen ven.
calls = {"n": 0}
def _fake_exec(self):
calls["n"] += 1
self.name_edit.setText(f"Fresh-{calls['n']}")
self.role_edit.setText("Scout")
return 1 # QDialog.Accepted
Co4EAgentDialog.exec = _fake_exec
before = tab.agent_list.count()
tab.ag_new_btn.click() # 555: noi that toi _new_agent
assert calls["n"] == 1
assert tab.agent_list.count() == before + 1, tab.agent_list.count()
names = [tab.agent_list.item(i).text() for i in range(tab.agent_list.count())]
assert names[-1] == "Fresh-1 · SCOUT · " + tr("co4e.custom"), names
print("CASE_NEW_AGENT_VIA_CLICK_OK")
# ---- 1342-1350: _edit_agent() khong co dong nao dang chon ------------------
msgs = []
tab.status_message.connect(lambda m: msgs.append(m))
tab.agent_list.setCurrentRow(-1)
assert tab.agent_list.currentItem() is None
tab._edit_agent()
assert msgs == [tr("co4e.select_custom_agent")], msgs
print("CASE_EDIT_NO_SELECTION_OK")
# ---- quirk: chon dong "Parallel" (khong phai custom agent) -> CUNG thong
# bao nhu khong chon gi, vi Qt.UserRole + 1 la None o dong nay -----------
tab.agent_list.setCurrentRow(0)
tab._edit_agent()
assert msgs == [tr("co4e.select_custom_agent")] * 2, msgs
tab._delete_agent()
assert msgs == [tr("co4e.select_custom_agent")] * 3, msgs
assert tab.agent_list.count() == before + 1, "chon Parallel roi Xoa khong lam mat dong nao"
print("CASE_PARALLEL_ROW_NOOP_QUIRK_OK")
# ---- 1342-1358: _edit_agent() tren mot custom agent that -------------------
target_row = next(
i for i in range(tab.agent_list.count())
if tab.agent_list.item(i).data(Qt.UserRole + 1) == agent1.id
)
tab.agent_list.setCurrentRow(target_row)
tab.ag_edit_btn.click() # 562: noi that toi _edit_agent
assert calls["n"] == 2
names = [tab.agent_list.item(i).text() for i in range(tab.agent_list.count())]
assert names[target_row] == "Fresh-2 · SCOUT · " + tr("co4e.custom"), names
# id giu nguyen qua lan sua (chi noi dung doi, khong mint id moi)
assert tab.agent_list.item(target_row).data(Qt.UserRole + 1) == agent1.id
print("CASE_EDIT_EXISTING_OK")
# ---- quirk: sua tren mot cid da bi xoa "phia sau" -> no-op tham lang -------
# _edit_agent tra cuu lai list_custom_agents() MOI, khong dung payload da cache
# trong item; neu file da mat va agent_list CHUA duoc _reload_sidebar(), thao
# tac Sua tren dong do khong lam gi (khong mo dialog, khong loi).
co4e_mod.delete_custom_agent(agent1.id)
stale_item = tab.agent_list.item(target_row)
assert stale_item.data(Qt.UserRole + 1) == agent1.id # item van con cid cu
tab.agent_list.setCurrentRow(target_row)
tab._edit_agent()
assert calls["n"] == 2, "khong duoc mo dialog voi cid da bi xoa"
print("CASE_EDIT_STALE_CID_QUIRK_OK")
# ---- 1360-1367: _delete_agent() tren mot custom agent that -----------------
tab._reload_sidebar()
count_before_delete = tab.agent_list.count()
del_row, del_cid = next(
(i, tab.agent_list.item(i).data(Qt.UserRole + 1))
for i in range(tab.agent_list.count())
if tab.agent_list.item(i).data(Qt.UserRole + 1)
)
tab.agent_list.setCurrentRow(del_row)
tab.ag_del_btn.click() # 563: noi that toi _delete_agent
assert tab.agent_list.count() == count_before_delete - 1
assert not (co4e_mod.agents_dir() / f"{del_cid}.json").exists()
print("CASE_DELETE_REAL_OK")
# ---- fallback: xoa lai mot id da khong con file -> khong nem loi -----------
co4e_mod.delete_custom_agent(del_cid)
print("CASE_DELETE_ALREADY_GONE_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_co4e_agent_panel_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_WIDGETS_OK",
"CASE_EMPTY_OK",
"CASE_ID_COUNTER_QUIRK_OK",
"CASE_ONE_AGENT_OK",
"CASE_EMPTY_NAME_ROLE_QUIRK_OK",
"CASE_NEW_AGENT_VIA_CLICK_OK",
"CASE_EDIT_NO_SELECTION_OK",
"CASE_PARALLEL_ROW_NOOP_QUIRK_OK",
"CASE_EDIT_EXISTING_OK",
"CASE_EDIT_STALE_CID_QUIRK_OK",
"CASE_DELETE_REAL_OK",
"CASE_DELETE_ALREADY_GONE_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi save_custom_agent()/delete_custom_agent() cua
# chinh test (khong co du lieu nguoi dung that nao bi cham vao) -
# CONFIG_DIR nam trong sandbox nhu da assert ngay dau script.
assert (sandbox / ".cowork_local" / "co4e").exists()
@@ -0,0 +1,374 @@
"""Characterization tests cho 8 ham hinh hoc thuan cua Co4E canvas.
Vong doi: day la gian giao (scaffolding), khong phai test dac ta cuoi cung.
Muc dich la ghi lai HANH VI DANG CO cua ``_dist``, ``_towards``,
``_rounded_path``, ``_seg_hits_rect``, ``_hits``, ``_route``, ``_ortho_path``,
``_elide`` — hien dang duoc re-export tu ``ui/co4e_canvas.py`` (thuc chat da
duoc doi sang song o ``presentation/co4e/canvas_geometry.py``, xem docstring
cua file do) — de lam luoi an toan cho dot tach file 2000+ dong. Test nay
KHONG phan xet dung/sai thiet ke, chi dong dinh lai output thuc te da chay va
in ra. Sau khi dot tach hoan tat va on dinh, cac test o day nen duoc viet lai
thanh test dac ta (specification test) that su — luc do co the xoa cac assert
kieu "quirk" ben duoi va thay bang assert dua tren hop dong ro rang, hoac mo
issue rieng de sua cac quirk neu chung thuc su la bug.
Khong can QApplication: cac ham nay chi dung QPointF/QRectF/QPainterPath nhu
kieu gia tri thuan, khong ve, khong doc kich thuoc widget.
Cac quirk dang chu y da duoc dong dinh o day (dung sua o code san pham):
* ``_elide(text, n)`` dung slicing ``text[: n - 1] + "..."``. Voi ``n=0``,
``n - 1 == -1`` nen KHONG cat rong ma cat mat ky tu cuoi cung cua chuoi
con lai roi noi dau "..." vao — vi du ``_elide("abc", 0) == "ab..."`` chu
khong phai chuoi rong. Voi ``n=1``, ket qua la chinh dau "..." (do
``text[:0] == ""``).
* ``_seg_hits_rect`` kiem tra nhanh "horizontal" (``abs(y1-y2) < 0.5``)
TRUOC nhanh "vertical" — mot doan suy bien (diem trung diem, ``p1==p2``)
luon roi vao nhanh horizontal du no cung thoa dieu kien vertical.
* ``_route`` co the "bo cuoc": khi vat can qua lon bao kin moi phuong an
tranh, no tra ve elbow co ban (``base``) DU NO VAN VA CHAM vat can — ham
khong nem loi, khong bao dam duong tra ve khong va cham.
* ``_towards(a, b, d)`` khi ``a == b`` (khoang cach ~0) tra ve ban sao cua
``a`` bat ke ``d`` la bao nhieu, thay vi loi hoac diem khong xac dinh.
Cac assert duoi day duoc chot bang cach CHAY code that qua
``.venv/Scripts/python.exe -c "..."`` roi dan nguyen ket qua in duoc vao
assert, khong suy luan ly thuyet.
"""
from __future__ import annotations
import pytest
from cowork_local.ui.co4e_canvas import (
_dist,
_elide,
_hits,
_ortho_path,
_route,
_rounded_path,
_seg_hits_rect,
_towards,
)
from PySide6.QtCore import QPointF, QRectF
# ---------------------------------------------------------------------------
# _dist
# ---------------------------------------------------------------------------
def test_dist_pythagorean_3_4_5():
assert _dist(QPointF(0, 0), QPointF(3, 4)) == pytest.approx(5.0)
def test_dist_same_point_is_zero():
assert _dist(QPointF(5, 5), QPointF(5, 5)) == pytest.approx(0.0)
def test_dist_negative_coordinates():
assert _dist(QPointF(-1, -1), QPointF(2, 3)) == pytest.approx(5.0)
# ---------------------------------------------------------------------------
# _towards
# ---------------------------------------------------------------------------
def test_towards_moves_along_axis_by_distance():
p = _towards(QPointF(0, 0), QPointF(10, 0), 5)
assert p.x() == pytest.approx(5.0)
assert p.y() == pytest.approx(0.0)
def test_towards_same_point_returns_copy_of_a_regardless_of_d():
# quirk: khi a == b (khoang cach ~0), tra ve ban sao cua a, khong loi.
p = _towards(QPointF(0, 0), QPointF(0, 0), 5)
assert p.x() == pytest.approx(0.0)
assert p.y() == pytest.approx(0.0)
def test_towards_zero_distance_stays_at_a():
p = _towards(QPointF(0, 0), QPointF(10, 0), 0)
assert p.x() == pytest.approx(0.0)
assert p.y() == pytest.approx(0.0)
def test_towards_overshoot_past_b_is_allowed():
# quirk: d lon hon khoang cach a->b van duoc ngoai suy, khong bi kep lai.
p = _towards(QPointF(0, 0), QPointF(10, 0), 20)
assert p.x() == pytest.approx(20.0)
assert p.y() == pytest.approx(0.0)
# ---------------------------------------------------------------------------
# _rounded_path
# ---------------------------------------------------------------------------
def test_rounded_path_empty_points_returns_empty_path():
path = _rounded_path([])
assert path.elementCount() == 0
def test_rounded_path_single_point():
path = _rounded_path([QPointF(1, 2)])
assert path.elementCount() == 1
e = path.elementAt(0)
assert (e.x, e.y) == pytest.approx((1.0, 2.0))
def test_rounded_path_two_points_is_a_straight_line_no_bend():
path = _rounded_path([QPointF(0, 0), QPointF(10, 0)])
assert path.elementCount() == 2
e0, e1 = path.elementAt(0), path.elementAt(1)
assert (e0.x, e0.y) == pytest.approx((0.0, 0.0))
assert (e1.x, e1.y) == pytest.approx((10.0, 0.0))
def test_rounded_path_three_points_default_radius():
# dist(prev,cur)=10, dist(cur,nxt)=10 -> rr = min(12, 5, 5) = 5.
path = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)])
assert path.elementCount() == 6
pts = [(path.elementAt(i).x, path.elementAt(i).y) for i in range(6)]
expected = [
(0.0, 0.0),
(5.0, 0.0),
(8.333333333333334, 0.0),
(10.0, 1.6666666666666667),
(10.0, 5.0),
(10.0, 10.0),
]
for got, exp in zip(pts, expected):
assert got[0] == pytest.approx(exp[0])
assert got[1] == pytest.approx(exp[1])
rect = path.boundingRect()
assert (rect.x(), rect.y(), rect.width(), rect.height()) == pytest.approx(
(0.0, 0.0, 10.0, 10.0)
)
def test_rounded_path_default_radius_matches_explicit_r_12():
a = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)])
b = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)], 12)
assert a.elementCount() == b.elementCount()
for i in range(a.elementCount()):
ea, eb = a.elementAt(i), b.elementAt(i)
assert (ea.x, ea.y) == pytest.approx((eb.x, eb.y))
def test_rounded_path_custom_smaller_radius_changes_bend_points():
path = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)], r=2)
assert path.elementCount() == 6
pts = [(path.elementAt(i).x, path.elementAt(i).y) for i in range(6)]
expected = [
(0.0, 0.0),
(8.0, 0.0),
(9.333333333333334, 0.0),
(10.0, 0.6666666666666666),
(10.0, 2.0),
(10.0, 10.0),
]
for got, exp in zip(pts, expected):
assert got[0] == pytest.approx(exp[0])
assert got[1] == pytest.approx(exp[1])
# ---------------------------------------------------------------------------
# _seg_hits_rect
# ---------------------------------------------------------------------------
RECT = QRectF(10, 10, 20, 20) # x in [10, 30], y in [10, 30]
def test_seg_hits_rect_horizontal_through_rect():
assert _seg_hits_rect(QPointF(0, 20), QPointF(40, 20), RECT) is True
def test_seg_hits_rect_horizontal_outside_y_range():
assert _seg_hits_rect(QPointF(0, 5), QPointF(40, 5), RECT) is False
def test_seg_hits_rect_horizontal_not_reaching_rect_x_range():
assert _seg_hits_rect(QPointF(0, 20), QPointF(5, 20), RECT) is False
def test_seg_hits_rect_vertical_through_rect():
assert _seg_hits_rect(QPointF(20, 0), QPointF(20, 40), RECT) is True
def test_seg_hits_rect_vertical_outside_x_range():
assert _seg_hits_rect(QPointF(5, 0), QPointF(5, 40), RECT) is False
def test_seg_hits_rect_diagonal_intersecting():
assert _seg_hits_rect(QPointF(0, 0), QPointF(40, 40), RECT) is True
def test_seg_hits_rect_diagonal_not_intersecting():
assert _seg_hits_rect(QPointF(0, 0), QPointF(5, 5), RECT) is False
def test_seg_hits_rect_degenerate_point_inside_counts_as_hit():
# quirk: p1 == p2 roi vao nhanh "horizontal" (abs(y1-y2) < 0.5 duoc kiem
# truoc), du no cung thoa nhanh vertical.
assert _seg_hits_rect(QPointF(20, 20), QPointF(20, 20), RECT) is True
def test_seg_hits_rect_degenerate_point_outside():
assert _seg_hits_rect(QPointF(0, 0), QPointF(0, 0), RECT) is False
# ---------------------------------------------------------------------------
# _hits
# ---------------------------------------------------------------------------
def test_hits_empty_points_list_is_false():
assert _hits([], [RECT]) is False
def test_hits_single_point_has_no_segments_so_false():
assert _hits([QPointF(20, 20)], [RECT]) is False
def test_hits_no_obstacles_default_behaviour_false():
assert _hits([QPointF(0, 20), QPointF(40, 20)], []) is False
def test_hits_true_when_segment_crosses_obstacle():
assert _hits([QPointF(0, 20), QPointF(40, 20)], [RECT]) is True
def test_hits_false_when_segment_misses_obstacle():
assert _hits([QPointF(0, 5), QPointF(40, 5)], [RECT]) is False
# ---------------------------------------------------------------------------
# _route
# ---------------------------------------------------------------------------
def test_route_same_y_no_obstacles_is_straight_line():
r = _route(QPointF(0, 0), QPointF(100, 0))
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx([(0.0, 0.0), (100.0, 0.0)])
def test_route_default_obstacles_none_matches_explicit_none():
# tham so mac dinh: goi khong truyen obstacles == truyen None tuong minh.
r_default = _route(QPointF(0, 0), QPointF(100, 0))
r_explicit = _route(QPointF(0, 0), QPointF(100, 0), None)
pts_default = [(p.x(), p.y()) for p in r_default]
pts_explicit = [(p.x(), p.y()) for p in r_explicit]
assert pts_default == pytest.approx(pts_explicit)
def test_route_different_y_no_obstacles_is_mid_x_elbow():
r = _route(QPointF(0, 0), QPointF(100, 50))
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx(
[(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (100.0, 50.0)]
)
def test_route_same_y_with_obstacle_falls_back_to_detour():
# obstacle nam giua duong thang mid_x va cung chan luon dai vertical band
# (obstacle qua rong so voi khoang cach 2 diem) -> _route roi xuong nhanh
# detour tren/duoi (margin 44) thay vi elbow don gian.
obstacle_mid = QRectF(40, -10, 20, 20) # phu y=0 tai x trong [40, 60]
r = _route(QPointF(0, 0), QPointF(100, 0), [obstacle_mid])
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx(
[
(0.0, 0.0),
(34.0, 0.0),
(34.0, -54.0),
(66.0, -54.0),
(66.0, 0.0),
(100.0, 0.0),
]
)
# duong tra ve nay khong con va cham obstacle da cho.
assert _hits(r, [obstacle_mid]) is False
def test_route_gives_up_and_returns_colliding_base_when_fully_boxed_in():
# quirk: neu vat can qua lon, bao kin moi phuong an tranh (vertical band
# va detour tren/duoi deu khong thoat), _route "bo cuoc" va tra ve elbow
# co ban (base) DU NO VAN VA CHAM vat can — khong nem loi, khong dam bao
# duong tra ve la an toan.
huge = QRectF(-1000, -1000, 3000, 3000)
r = _route(QPointF(0, 0), QPointF(100, 50), [huge])
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx(
[(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (100.0, 50.0)]
)
assert _hits(r, [huge]) is True
# ---------------------------------------------------------------------------
# _ortho_path
# ---------------------------------------------------------------------------
def test_ortho_path_straight_case_element_count():
op = _ortho_path(QPointF(0, 0), QPointF(100, 0))
assert op.elementCount() == 2
def test_ortho_path_elbow_case_element_count_and_bounds():
op = _ortho_path(QPointF(0, 0), QPointF(100, 50))
assert op.elementCount() == 10
rect = op.boundingRect()
assert (rect.x(), rect.y(), rect.width(), rect.height()) == pytest.approx(
(0.0, 0.0, 100.0, 50.0)
)
def test_ortho_path_default_radius_is_corner_r_12():
a = _ortho_path(QPointF(0, 0), QPointF(100, 50))
b = _ortho_path(QPointF(0, 0), QPointF(100, 50), 12)
assert a.elementCount() == b.elementCount()
for i in range(a.elementCount()):
ea, eb = a.elementAt(i), b.elementAt(i)
assert (ea.x, ea.y) == pytest.approx((eb.x, eb.y))
# ---------------------------------------------------------------------------
# _elide
# ---------------------------------------------------------------------------
def test_elide_short_text_under_limit_is_unchanged():
assert _elide("hello", 10) == "hello"
def test_elide_text_exactly_at_limit_is_unchanged():
assert _elide("abc", 3) == "abc"
def test_elide_long_text_is_cut_with_ellipsis_and_total_len_equals_n():
result = _elide("hello world this is long", 10)
assert result == "hello wor…"
assert len(result) == 10
def test_elide_newlines_are_replaced_with_spaces():
assert _elide("line1\nline2", 20) == "line1 line2"
def test_elide_empty_string_stays_empty():
assert _elide("", 10) == ""
def test_elide_none_is_treated_as_empty_string():
assert _elide(None, 10) == ""
def test_elide_n_zero_quirk_slices_off_last_char_not_empty():
# quirk: text[: n - 1] voi n=0 la text[:-1], KHONG phai cat rong. Voi
# chuoi "abc" (len 3 > 0) ket qua la "ab" + dau "..." = "ab...".
assert _elide("abc", 0) == "ab…"
def test_elide_n_one_quirk_result_is_just_ellipsis():
# quirk: voi n=1, text[:0] == "" nen ket qua chi con dau "...".
assert _elide("abc", 1) == "…"
def test_elide_n_larger_than_text_length_boundary():
# len("abcd") = 4 > 3 nen van bi cat, dung == thi khong cat.
assert _elide("abcd", 3) == "ab…"
@@ -0,0 +1,842 @@
"""Characterization test cho ``Co4ECanvas`` (``ui/co4e_canvas.py``, dòng
289-701) — KHÔNG bao gồm ``_NodeItem``/``_EdgeItem`` (hai lớp đó chỉ vẽ, đã
được phủ gián tiếp bởi ``tests/characterization/test_co4e_canvas_geometry.py``
qua ``_rounded_path``/``_route``/``_elide`` mà ``_EdgeItem.update_path``/
``_NodeItem.paint`` dùng).
VÒNG ĐỜI: đây là giàn giáo (scaffolding), KHÔNG phải đặc tả cuối cùng. Mục
đích DUY NHẤT là lưới an toàn cho đợt tách ``ui/co4e_canvas.py`` (2000+ dòng
cả file, xem ``docs/architecture/co4e-split-map.md``) — 8 hàm hình học thuần
đã dời sang ``presentation/co4e/canvas_geometry.py`` rồi (xem test cùng tên);
đợt sau nhiều khả năng sẽ động vào chính ``Co4ECanvas`` (tách thành
``_NodeItem``/``_EdgeItem`` module riêng, hoặc tách state machine
connect/port-drag ra khỏi lớp view). Khi việc tách phần này hoàn tất và ổn
định, các test ở đây nên được viết lại thành test đặc tả (specification test)
cho lớp/API mới; quirk nào liệt kê dưới đây nên có issue riêng nếu ai đó muốn
"dọn" chúng — ĐỪNG tự sửa code sản phẩm để "dọn" quirk khi đọc thấy test này.
CÁCH CHỐT ASSERT: mọi giá trị dưới đây lấy bằng cách CHẠY code thật (script
``_PROBE_SCRIPT`` bên dưới, qua ``.venv/Scripts/python.exe``, giống hệt lệnh ở
cuối file) rồi dán NGUYÊN VĂN JSON in được vào assert — không suy luận lý
thuyết.
AN TOÀN DỮ LIỆU: ``Co4ECanvas`` tự nó KHÔNG chạm đĩa/mạng (khác
``Co4ETab``/``Co4ERunManager``) — nhưng import ``cowork_local.config`` (qua
chuỗi import ``cowork_local.ui.co4e_canvas`` -> ``..core.co4e`` ->
``..config``) vẫn tính ``CONFIG_DIR`` từ ``Path.home()`` một lần lúc module
nạp. Để không rủi ro và để chạy trong TIẾN TRÌNH CON riêng (tránh xung đột
``QApplication`` singleton với các test khác đã/sẽ tạo app trong cùng lượt
chạy pytest), toàn bộ probe chạy qua ``subprocess`` với ``HOME``/
``USERPROFILE`` trỏ vào một ``tmp_path`` sandbox ĐẶT TRƯỚC khi script import
bất kỳ thứ gì thuộc ``cowork_local`` (đúng kỹ thuật
``tools/capture_screens.py::_isolate_home()``: đặt ``USERPROFILE``/``HOME``,
xoá ``HOMEDRIVE``/``HOMEPATH``), và ``QT_QPA_PLATFORM=offscreen`` được đặt
TRƯỚC khi import PySide6 (đúng khuôn ``tools/check_co4e.py`` dòng 22+40-42).
Một assert ngay trong tiến trình con chốt ``CONFIG_DIR`` nằm trong sandbox
trước khi làm gì khác (kiểu ``tools/check_co4e.py:47``).
KHÔNG spawn thread/gọi provider thật: ``Co4ECanvas`` không có method nào dựng
``QThread``/gọi AI — mọi method characterize ở đây (``add_node``,
``begin_port_drag``/``finish_port_drag``, ``delete_*``, ``zoom_*``,
``relayout*``, ``add_workflow``, ``dropEvent``...) chỉ thao tác trên
``QGraphicsScene``/dict nội bộ, gọi trực tiếp không cần seed job nào.
CẦN QApplication: các method characterize ở đây dựng ``QGraphicsScene``/
``QGraphicsItem`` thật, đọc ``transform()``, tạo ``QDropEvent`` — không phải
kiểu giá trị thuần như ``_dist``/``_towards`` (khác
``test_co4e_canvas_geometry.py``, không cần app).
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới — ĐỪNG "dọn" các chỗ này khi
tách, chúng trông như bug nhưng là hành vi đang chạy thật hôm nay):
* ``_finish_connect(target_id)`` với ``target_id == connect_from`` hiện tại
(tự nối vào chính nó) bị bỏ qua HOÀN TOÀN im lặng — không tạo cạnh, không
báo lỗi — nhưng ``_connect_from`` VẪN bị reset về ``None`` (chế độ connect
kết thúc dù không nối được gì).
* ``_make_edge`` chống trùng cạnh CÙNG source+target (kể cả khi gọi lại qua
``finish_port_drag`` lần hai với cùng cặp) nhưng KHÔNG chống cạnh ngược
hướng (target->source) — hàm không kiểm tra chiều ngược, chỉ kiểm tra
đúng chiều đã cho.
* ``delete_edge(edge)`` gọi ``graph_changed.emit()`` VÔ ĐIỀU KIỆN, kể cả khi
không có item nào khớp để xoá (ví dụ gọi lại lần hai với cùng đối tượng
``Edge`` đã bị xoá trước đó) — không có "removed count" nào được kiểm tra
trước khi emit.
* ``delete_node`` với id không tồn tại trả về sớm (``item is None: return``)
TRƯỚC dòng emit — nên KHÔNG phát ``graph_changed`` trong trường hợp này,
khác hẳn ``delete_edge`` ở trên.
* ``delete_selected()`` chạy 2 vòng lặp riêng (xoá node trước, xoá cạnh
sau) — nếu một node bị xoá đã kéo theo xoá cả các cạnh nối tới nó (qua
``delete_node``), thì vòng lặp cạnh thứ hai KHÔNG còn thấy các cạnh đó
nữa (chúng đã biến mất khỏi ``self._edges`` trước khi vòng lặp cạnh chạy
tới), nên chỉ những cạnh CÒN SỐNG và đang selected riêng mới bị xoá thêm.
* ``_zoom_by`` khi đã chạm biên (``_ZOOM_MIN``/``_ZOOM_MAX``) và gọi
``zoom_in()``/``zoom_out()`` thêm lần nữa: hiệu ứng là no-op tuyệt đối —
không gọi ``self.scale()``, không đổi ``self._zoom`` — vì
``abs(target - cur) < 1e-6`` chặn sớm. 20 lần ``zoom_in()`` liên tiếp từ
1.0 chạm trần 3.0 sau đúng 7 lần, 13 lần còn lại là no-op.
* ``add_workflow(nodes, edges, at=None)``: offset áp dụng cho batch mới phụ
thuộc vào canvas ĐÃ có node hay chưa TẠI THỜI ĐIỂM GỌI — batch đầu tiên
(canvas rỗng) giữ nguyên toạ độ gốc (offset 0,0); batch thứ hai (canvas đã
có node từ batch trước) bị dịch (60, 60) dù truyền cùng ``nodes``/``edges``
y hệt lần đầu. ``at`` được cho tường minh thì luôn thắng offset ngầm này.
* ``add_workflow`` bỏ qua ÂM THẦM mọi cạnh mà một đầu (source hoặc target)
không nằm trong danh sách ``nodes`` đang được thả — không lỗi, không log,
cạnh đó biến mất khỏi kết quả.
* ``dropEvent`` với payload JSON hỏng (không parse được) hoặc payload
workflow rỗng (``wf.nodes`` rỗng) đều là NO-OP HOÀN TOÀN im lặng — không
thêm node nào, không báo lỗi, không exception nào lộ ra ngoài.
* ``dropEvent`` gọi ``self.mapToScene(...)`` trên một view CHƯA từng
``show()``/resize — toạ độ scene kết quả (đóng đinh trong
``s11_step_drop_pos``) là quirk của việc mapToScene phụ thuộc viewport mặc
định lúc chưa hiển thị, KHÔNG phải toạ độ "50, 60" người ta tưởng sẽ thấy;
nếu đợt tách sau show canvas trước khi test tương tự, số này sẽ đổi và đó
là dấu hiệu ĐÚNG cần cập nhật lại giá trị đóng đinh, không phải lỗi.
Lệnh thủ công đã dùng để chốt các con số trên (quy trình ngược, xem cuối
file để chạy lại nếu cần chốt lại sau khi code đổi có chủ đích).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_PROBE_SCRIPT = r"""
import json
import os
import sys
from pathlib import Path
sandbox = sys.argv[1]
repo_parent = sys.argv[2]
sys.path.insert(0, repo_parent)
os.environ["QT_QPA_PLATFORM"] = "offscreen"
for var in ("USERPROFILE", "HOME"):
os.environ[var] = sandbox
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
from PySide6.QtCore import QByteArray, QMimeData, QPointF, Qt
from PySide6.QtGui import QDropEvent
from PySide6.QtWidgets import QApplication
app = QApplication([])
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(Path(sandbox).resolve()) in str(CONFIG_DIR.resolve()), (
"khong co lap: CONFIG_DIR=" + str(CONFIG_DIR))
from cowork_local.core.co4e import Edge, Node, Step, Workflow, workflow_to_dict
from cowork_local.ui.co4e_canvas import CO4E_MIME, Co4ECanvas, _NODE_H, _NODE_W
result = {}
# --- Section 1: add_node ----------------------------------------------------
c1 = Co4ECanvas()
sel_calls = []
c1.node_selected.connect(lambda nid: sel_calls.append(nid))
changed = {"n": 0}
c1.graph_changed.connect(lambda: changed.__setitem__("n", changed["n"] + 1))
nid1 = c1.add_node(Step(label="A"))
result["s1_nid1"] = nid1
result["s1_nid1_pos"] = [c1._nodes[nid1].node.x, c1._nodes[nid1].node.y]
result["s1_after_nid1_edges"] = len(c1._edges)
result["s1_sel_calls_after_1"] = list(sel_calls)
result["s1_changed_after_1"] = changed["n"]
nid2 = c1.add_node(Step(label="B"), x=10, y=20, connect_from=nid1)
result["s1_nid2"] = nid2
result["s1_nid2_pos"] = [c1._nodes[nid2].node.x, c1._nodes[nid2].node.y]
result["s1_after_nid2_edges"] = [(e.edge.source, e.edge.target, e.edge.id) for e in c1._edges]
result["s1_sel_calls_after_2"] = list(sel_calls)
result["s1_changed_after_2"] = changed["n"]
nid3 = c1.add_node(Step(label="C"), connect_from="does-not-exist")
result["s1_nid3"] = nid3
result["s1_after_nid3_edges_count"] = len(c1._edges)
# --- Section 2: add_step_below -----------------------------------------------
before_count = len(c1._nodes)
c1.add_step_below("nope-does-not-exist")
result["s2_missing_parent_nodes_unchanged"] = len(c1._nodes) == before_count
c1.add_step_below(nid2)
new_ids = [nid for nid in c1._nodes if nid not in (nid1, nid2, nid3)]
result["s2_new_node_ids"] = new_ids
new_id = new_ids[0]
result["s2_new_node_pos"] = [c1._nodes[new_id].node.x, c1._nodes[new_id].node.y]
result["s2_edge_nid2_to_new"] = any(
e.edge.source == nid2 and e.edge.target == new_id for e in c1._edges
)
# --- Section 3: _chain_tail ---------------------------------------------------
result["s3_tail_current_state"] = c1._chain_tail()
c_empty = Co4ECanvas()
result["s3_tail_empty_canvas"] = c_empty._chain_tail()
c_single = Co4ECanvas()
single_id = c_single.add_node(Step(label="Solo"))
result["s3_tail_single_node_no_edges"] = c_single._chain_tail()
# --- Section 4: add_palette_step ----------------------------------------------
tail_before = c1._chain_tail()
new_pal_id = c1.add_palette_step(Step(label="D"), QPointF(500, 500))
result["s4_add_palette_step_return"] = new_pal_id
newest_ids = [nid for nid in c1._nodes if nid not in (nid1, nid2, nid3, new_id)]
result["s4_new_ids"] = newest_ids
pal_id = newest_ids[0]
result["s4_pos"] = [c1._nodes[pal_id].node.x, c1._nodes[pal_id].node.y]
result["s4_tail_before"] = tail_before
result["s4_edge_from_tail_to_new"] = any(
e.edge.source == tail_before and e.edge.target == pal_id for e in c1._edges
)
# --- Section 5: begin_connect / _finish_connect --------------------------------
c2 = Co4ECanvas()
a2 = c2.add_node(Step(label="A"))
b2 = c2.add_node(Step(label="B"))
result["s5_initial_edges"] = len(c2._edges)
c2.begin_connect(a2)
result["s5_connect_from_after_begin"] = c2._connect_from
c2._finish_connect(a2) # self-connect
result["s5_connect_from_after_self_finish"] = c2._connect_from
result["s5_edges_after_self_finish"] = len(c2._edges)
c2.begin_connect(a2)
c2._finish_connect(b2)
result["s5_connect_from_after_finish_ab"] = c2._connect_from
result["s5_edges_after_finish_ab"] = [(e.edge.source, e.edge.target) for e in c2._edges]
c2._finish_connect(a2) # no active connect (src None)
result["s5_edges_after_finish_with_no_active_connect"] = len(c2._edges)
# --- Section 6: begin_port_drag / update / finish / _node_at / _make_edge dedup -
c3 = Co4ECanvas()
p3 = c3.add_node(Step(label="P"), x=0, y=0)
q3 = c3.add_node(Step(label="Q"), x=400, y=0)
result["s6_scene_items_before_drag"] = len(c3._scene.items())
p_item = c3._nodes[p3]
src_pt = p_item.pos() + QPointF(_NODE_W, _NODE_H / 2)
c3.begin_port_drag(p3, src_pt)
result["s6_port_src_after_begin"] = c3._port_src
result["s6_temp_edge_in_scene_after_begin"] = c3._temp_edge in c3._scene.items()
mid_pt = QPointF(200, 100)
c3.update_port_drag(mid_pt)
result["s6_temp_edge_path_elements_after_update"] = c3._temp_edge.path().elementCount()
q_hit_pt = c3._nodes[q3].pos() + QPointF(50, 50) # inside Q's card
node_at_q = c3._node_at(q_hit_pt)
result["s6_node_at_hit_point"] = node_at_q
empty_pt = QPointF(-500, -500)
result["s6_node_at_empty_point"] = c3._node_at(empty_pt)
c3.finish_port_drag(q_hit_pt)
result["s6_port_src_after_finish"] = c3._port_src
result["s6_temp_edge_after_finish"] = c3._temp_edge
result["s6_edges_after_finish"] = [(e.edge.source, e.edge.target) for e in c3._edges]
c3.begin_port_drag(p3, src_pt)
c3.finish_port_drag(q_hit_pt)
result["s6_edges_after_duplicate_drag"] = len(c3._edges)
c3.begin_port_drag(p3, src_pt)
c3.finish_port_drag(empty_pt)
result["s6_edges_after_finish_over_empty_space"] = len(c3._edges)
result["s6_port_src_after_empty_finish"] = c3._port_src
# --- Section 7: delete_edge / delete_node / delete_selected --------------------
c4 = Co4ECanvas()
a4 = c4.add_node(Step(label="A"), x=0, y=0)
b4 = c4.add_node(Step(label="B"), x=300, y=0, connect_from=a4)
c4_ = c4.add_node(Step(label="C"), x=600, y=0, connect_from=b4)
d4 = c4.add_node(Step(label="D"), x=900, y=0, connect_from=c4_)
result["s7_initial_edges"] = [(e.edge.source, e.edge.target) for e in c4._edges]
edge_ab = next(e.edge for e in c4._edges if e.edge.source == a4 and e.edge.target == b4)
changed4 = {"n": 0}
c4.graph_changed.connect(lambda: changed4.__setitem__("n", changed4["n"] + 1))
c4.delete_edge(edge_ab)
result["s7_edges_after_delete_ab"] = [(e.edge.source, e.edge.target) for e in c4._edges]
result["s7_changed_after_delete_ab"] = changed4["n"]
c4.delete_edge(edge_ab)
result["s7_changed_after_delete_ab_again"] = changed4["n"]
result["s7_edges_after_delete_ab_again"] = len(c4._edges)
result["s7_delete_node_missing_returns_early"] = c4.delete_node("no-such-node") is None
result["s7_changed_after_delete_missing_node"] = changed4["n"]
c4._nodes[c4_].setSelected(True)
c4.delete_selected()
result["s7_nodes_after_delete_selected"] = sorted(c4._nodes.keys())
result["s7_edges_after_delete_selected"] = [(e.edge.source, e.edge.target) for e in c4._edges]
# --- Section 8: zoom -----------------------------------------------------------
c5 = Co4ECanvas()
result["s8_initial_m11"] = c5.transform().m11()
result["s8_initial_zoom_attr"] = c5._zoom
c5.zoom_in()
result["s8_m11_after_1_zoom_in"] = c5.transform().m11()
result["s8_zoom_attr_after_1_zoom_in"] = c5._zoom
m11_series = []
for _ in range(20):
c5.zoom_in()
m11_series.append(round(c5.transform().m11(), 6))
result["s8_m11_series_zoom_in_x20_more"] = m11_series
result["s8_zoom_attr_after_many_zoom_in"] = c5._zoom
before_m11 = c5.transform().m11()
c5.zoom_in()
result["s8_m11_unchanged_when_already_at_cap"] = c5.transform().m11() == before_m11
c5.reset_zoom()
result["s8_m11_after_reset"] = c5.transform().m11()
result["s8_zoom_attr_after_reset"] = c5._zoom
m11_series_out = []
for _ in range(30):
c5.zoom_out()
m11_series_out.append(round(c5.transform().m11(), 6))
result["s8_m11_series_zoom_out_x30"] = m11_series_out
result["s8_zoom_attr_after_many_zoom_out"] = c5._zoom
# --- Section 9: relayout / relayout_if_vertical --------------------------------
c6 = Co4ECanvas()
c6.relayout()
result["s9_relayout_empty_ok"] = True
a6 = c6.add_node(Step(label="A"), x=0, y=0)
b6 = c6.add_node(Step(label="B"), x=0, y=150, connect_from=a6)
c6_ = c6.add_node(Step(label="C"), x=0, y=300, connect_from=b6)
result["s9_vertical_before"] = {
a6: [c6._nodes[a6].pos().x(), c6._nodes[a6].pos().y()],
b6: [c6._nodes[b6].pos().x(), c6._nodes[b6].pos().y()],
c6_: [c6._nodes[c6_].pos().x(), c6._nodes[c6_].pos().y()],
}
c6.relayout_if_vertical()
result["s9_vertical_after_relayout_if_vertical"] = {
a6: [c6._nodes[a6].pos().x(), c6._nodes[a6].pos().y()],
b6: [c6._nodes[b6].pos().x(), c6._nodes[b6].pos().y()],
c6_: [c6._nodes[c6_].pos().x(), c6._nodes[c6_].pos().y()],
}
c7 = Co4ECanvas()
a7 = c7.add_node(Step(label="A"), x=0, y=0)
b7 = c7.add_node(Step(label="B"), x=500, y=50, connect_from=a7)
result["s9_horizontal_before"] = {
a7: [c7._nodes[a7].pos().x(), c7._nodes[a7].pos().y()],
b7: [c7._nodes[b7].pos().x(), c7._nodes[b7].pos().y()],
}
c7.relayout_if_vertical()
result["s9_horizontal_after_relayout_if_vertical"] = {
a7: [c7._nodes[a7].pos().x(), c7._nodes[a7].pos().y()],
b7: [c7._nodes[b7].pos().x(), c7._nodes[b7].pos().y()],
}
c8 = Co4ECanvas()
a8 = c8.add_node(Step(label="Solo"), x=0, y=0)
c8.relayout_if_vertical()
result["s9_single_node_after_relayout_if_vertical"] = [
c8._nodes[a8].pos().x(), c8._nodes[a8].pos().y()
]
# --- Section 10: add_workflow ---------------------------------------------------
c9 = Co4ECanvas()
src_nodes = [
Node(id="src1", x=0.0, y=0.0, data=Step(label="X")),
Node(id="src2", x=200.0, y=0.0, data=Step(label="Y")),
]
src_edges = [Edge(id="e1", source="src1", target="src2")]
c9.add_workflow(src_nodes, src_edges, at=None)
first_batch_ids = sorted(c9._nodes.keys())
result["s10_first_batch_ids"] = first_batch_ids
result["s10_first_batch_positions"] = {
nid: [c9._nodes[nid].pos().x(), c9._nodes[nid].pos().y()] for nid in first_batch_ids
}
result["s10_first_batch_edges"] = [(e.edge.source, e.edge.target) for e in c9._edges]
c9.add_workflow(src_nodes, src_edges, at=None)
second_batch_ids = sorted(set(c9._nodes.keys()) - set(first_batch_ids))
result["s10_second_batch_ids"] = second_batch_ids
result["s10_second_batch_positions"] = {
nid: [c9._nodes[nid].pos().x(), c9._nodes[nid].pos().y()] for nid in second_batch_ids
}
result["s10_total_edges_after_second_call"] = len(c9._edges)
c9.add_workflow(src_nodes, src_edges, at=QPointF(1000, 1000))
third_batch_ids = sorted(
set(c9._nodes.keys()) - set(first_batch_ids) - set(second_batch_ids)
)
result["s10_third_batch_ids"] = third_batch_ids
result["s10_third_batch_positions"] = {
nid: [c9._nodes[nid].pos().x(), c9._nodes[nid].pos().y()] for nid in third_batch_ids
}
c10 = Co4ECanvas()
edges_unknown_target = [Edge(id="ex", source="src1", target="unknown")]
c10.add_workflow(src_nodes, edges_unknown_target, at=None)
result["s10_edges_with_unknown_target_dropped"] = len(c10._edges)
result["s10_nodes_still_added_despite_bad_edge"] = len(c10._nodes)
# --- Section 11: dropEvent ------------------------------------------------------
_mime_keepalive = [] # QDropEvent only stores a pointer to the QMimeData; the
# Python wrapper must be kept alive for the event's lifetime or mimeData()
# comes back as a dangling/base QObject (hit exactly this while writing the probe).
def make_drop_event(payload_bytes, fmt, pos):
mime = QMimeData()
if fmt is not None:
mime.setData(fmt, QByteArray(payload_bytes))
_mime_keepalive.append(mime)
return QDropEvent(pos, Qt.CopyAction, mime, Qt.NoButton, Qt.NoModifier)
c11 = Co4ECanvas()
step_payload = json.dumps({"label": "Dropped Step", "role": "AGENT"}).encode("utf-8")
ev1 = make_drop_event(step_payload, CO4E_MIME, QPointF(50, 60))
c11.dropEvent(ev1)
result["s11_step_drop_nodes_count"] = len(c11._nodes)
only_id = next(iter(c11._nodes))
result["s11_step_drop_label"] = c11._nodes[only_id].node.data.label
result["s11_step_drop_pos"] = [c11._nodes[only_id].pos().x(), c11._nodes[only_id].pos().y()]
wf_payload_dict = {
"kind": "workflow",
"workflow": workflow_to_dict(
Workflow(id="wfX", name="WF", nodes=[Node(id="wn1", x=0.0, y=0.0, data=Step(label="WFNode"))])
),
}
ev2 = make_drop_event(json.dumps(wf_payload_dict).encode("utf-8"), CO4E_MIME, QPointF(10, 10))
before_wf_drop = len(c11._nodes)
c11.dropEvent(ev2)
result["s11_workflow_drop_added_nodes"] = len(c11._nodes) - before_wf_drop
empty_wf_payload = {
"kind": "workflow",
"workflow": workflow_to_dict(Workflow(id="wfEmpty", name="Empty", nodes=[], edges=[])),
}
ev3 = make_drop_event(json.dumps(empty_wf_payload).encode("utf-8"), CO4E_MIME, QPointF(10, 10))
before_empty_drop = len(c11._nodes)
c11.dropEvent(ev3)
result["s11_empty_workflow_drop_is_noop"] = len(c11._nodes) == before_empty_drop
ev4 = make_drop_event(b"{not valid json", CO4E_MIME, QPointF(10, 10))
before_bad_json = len(c11._nodes)
c11.dropEvent(ev4)
result["s11_bad_json_drop_is_noop_no_crash"] = len(c11._nodes) == before_bad_json
ev5 = make_drop_event(b"whatever", "text/plain", QPointF(10, 10))
before_wrong_fmt = len(c11._nodes)
c11.dropEvent(ev5)
result["s11_wrong_mime_format_is_noop_no_crash"] = len(c11._nodes) == before_wrong_fmt
print(json.dumps(result, sort_keys=True))
print("PROBE_OK")
"""
@pytest.fixture(scope="module")
def probe_result(tmp_path_factory):
"""Chạy ``_PROBE_SCRIPT`` một lần cho cả module trong TIẾN TRÌNH CON, trả
về dict JSON đã in được. Test riêng lẻ chỉ đọc lại dict này — không dựng
lại canvas cho mỗi assert."""
sandbox = tmp_path_factory.mktemp("co4e-canvas-widget-home")
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
result = subprocess.run(
[sys.executable, "-c", _PROBE_SCRIPT, str(sandbox), str(REPO_PARENT)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"probe co4e canvas widget that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
assert "PROBE_OK" in result.stdout, result.stdout
json_line = result.stdout.strip().splitlines()[-2]
return json.loads(json_line)
# ---------------------------------------------------------------------------
# Section 1: add_node
# ---------------------------------------------------------------------------
def test_add_node_default_pos_and_no_edge_without_connect_from(probe_result):
assert probe_result["s1_nid1"] == "node_000001"
assert probe_result["s1_nid1_pos"] == pytest.approx([60.0, 60.0])
assert probe_result["s1_after_nid1_edges"] == 0
def test_add_node_emits_node_selected_and_graph_changed(probe_result):
# add_node emits graph_changed rồi node_selected — 1 lần add_node "trơn"
# (không connect_from hợp lệ) vẫn tính là 2 lần graph_changed: một từ
# add_node, một từ _reposition_edges gọi ngầm bên trong add_node? Số thực
# tế chốt được là 2 sau lần add_node đầu tiên.
assert probe_result["s1_changed_after_1"] == 2
assert probe_result["s1_sel_calls_after_1"] == ["node_000001"]
def test_add_node_with_valid_connect_from_creates_edge_with_deterministic_id(probe_result):
assert probe_result["s1_nid2"] == "node_000002"
assert probe_result["s1_nid2_pos"] == pytest.approx([10.0, 20.0])
assert probe_result["s1_after_nid2_edges"] == [
["node_000001", "node_000002", "e_node_000001__node_000002"],
]
assert probe_result["s1_changed_after_2"] == 5
assert probe_result["s1_sel_calls_after_2"] == ["node_000001", "node_000002"]
def test_add_node_with_unknown_connect_from_is_silently_skipped(probe_result):
# quirk: connect_from khong ton tai trong _nodes -> khong tao canh, khong
# loi, node van duoc them binh thuong.
assert probe_result["s1_nid3"] == "node_000003"
assert probe_result["s1_after_nid3_edges_count"] == 1
# ---------------------------------------------------------------------------
# Section 2: add_step_below
# ---------------------------------------------------------------------------
def test_add_step_below_missing_parent_is_noop(probe_result):
assert probe_result["s2_missing_parent_nodes_unchanged"] is True
def test_add_step_below_places_new_node_to_the_right_and_connects(probe_result):
# parent (nid2) o (10, 20); node moi o (10 + _NODE_W(210) + 150, 20) = (370, 20).
assert probe_result["s2_new_node_ids"] == ["node_000004"]
assert probe_result["s2_new_node_pos"] == pytest.approx([370.0, 20.0])
assert probe_result["s2_edge_nid2_to_new"] is True
# ---------------------------------------------------------------------------
# Section 3: _chain_tail
# ---------------------------------------------------------------------------
def test_chain_tail_picks_last_inserted_node_without_outgoing_edge(probe_result):
# trang thai luc nay: nid1->nid2->new(node_000004); nid3 dung mot minh.
# tails (theo thu tu chen) = [nid3, new] -> tails[-1] = new.
assert probe_result["s3_tail_current_state"] == "node_000004"
def test_chain_tail_empty_canvas_returns_empty_string(probe_result):
assert probe_result["s3_tail_empty_canvas"] == ""
def test_chain_tail_single_node_with_no_edges_is_itself(probe_result):
assert probe_result["s3_tail_single_node_no_edges"] == "node_000005"
# ---------------------------------------------------------------------------
# Section 4: add_palette_step
# ---------------------------------------------------------------------------
def test_add_palette_step_returns_none_unlike_add_node(probe_result):
# quirk: add_palette_step KHONG tra ve id node moi (khac add_node) - no
# goi self.add_node(...) nhung khong return ket qua cua no.
assert probe_result["s4_add_palette_step_return"] is None
def test_add_palette_step_uses_pos_directly_and_chains_from_tail(probe_result):
assert probe_result["s4_tail_before"] == "node_000004"
assert probe_result["s4_new_ids"] == ["node_000006"]
assert probe_result["s4_pos"] == pytest.approx([500.0, 500.0])
assert probe_result["s4_edge_from_tail_to_new"] is True
# ---------------------------------------------------------------------------
# Section 5: begin_connect / _finish_connect
# ---------------------------------------------------------------------------
def test_begin_connect_sets_pending_source(probe_result):
assert probe_result["s5_connect_from_after_begin"] == "node_000007"
def test_finish_connect_self_target_quirk_resets_state_but_makes_no_edge(probe_result):
# quirk: target_id == connect_from (tu noi minh vao minh) bi bo qua im
# lang, KHONG tao canh, nhung _connect_from van duoc reset ve None.
assert probe_result["s5_connect_from_after_self_finish"] is None
assert probe_result["s5_edges_after_self_finish"] == 0
def test_finish_connect_valid_pair_creates_edge_and_resets_state(probe_result):
assert probe_result["s5_connect_from_after_finish_ab"] is None
assert probe_result["s5_edges_after_finish_ab"] == [["node_000007", "node_000008"]]
def test_finish_connect_with_no_active_connect_is_noop(probe_result):
assert probe_result["s5_edges_after_finish_with_no_active_connect"] == 1
# ---------------------------------------------------------------------------
# Section 6: begin_port_drag / update_port_drag / finish_port_drag / _node_at
# ---------------------------------------------------------------------------
def test_begin_port_drag_adds_temp_edge_item_to_scene(probe_result):
assert probe_result["s6_scene_items_before_drag"] == 2
assert probe_result["s6_port_src_after_begin"] == "node_000009"
assert probe_result["s6_temp_edge_in_scene_after_begin"] is True
def test_update_port_drag_sets_elbow_path_on_temp_edge(probe_result):
# P o (0,0)->cong o (210,48); dich toi (200,100) khac y -> nhanh elbow
# cua _ortho_path (10 element, khop voi test_co4e_canvas_geometry.py).
assert probe_result["s6_temp_edge_path_elements_after_update"] == 10
def test_node_at_hits_node_under_point_and_none_when_empty(probe_result):
assert probe_result["s6_node_at_hit_point"] == "node_000010"
assert probe_result["s6_node_at_empty_point"] is None
def test_finish_port_drag_creates_edge_and_clears_temp_state(probe_result):
assert probe_result["s6_port_src_after_finish"] is None
assert probe_result["s6_temp_edge_after_finish"] is None
assert probe_result["s6_edges_after_finish"] == [["node_000009", "node_000010"]]
def test_make_edge_dedups_same_source_target_pair(probe_result):
# quirk: keo lai dung cap p->q lan hai khong tao canh trung.
assert probe_result["s6_edges_after_duplicate_drag"] == 1
def test_finish_port_drag_over_empty_space_adds_no_edge_and_clears_state(probe_result):
assert probe_result["s6_edges_after_finish_over_empty_space"] == 1
assert probe_result["s6_port_src_after_empty_finish"] is None
# ---------------------------------------------------------------------------
# Section 7: delete_edge / delete_node / delete_selected
# ---------------------------------------------------------------------------
def test_delete_edge_removes_matching_item_and_emits_graph_changed(probe_result):
assert probe_result["s7_initial_edges"] == [
["node_000011", "node_000012"],
["node_000012", "node_000013"],
["node_000013", "node_000014"],
]
assert probe_result["s7_edges_after_delete_ab"] == [
["node_000012", "node_000013"],
["node_000013", "node_000014"],
]
assert probe_result["s7_changed_after_delete_ab"] == 1
def test_delete_edge_called_again_on_already_removed_edge_still_emits(probe_result):
# quirk: khong co "removed count" guard - goi lai voi Edge da bi xoa van
# phat graph_changed, du khong con item nao khop de xoa.
assert probe_result["s7_changed_after_delete_ab_again"] == 2
assert probe_result["s7_edges_after_delete_ab_again"] == 2
def test_delete_node_missing_id_returns_early_without_emitting(probe_result):
# quirk: khac delete_edge o tren - delete_node voi id khong ton tai return
# SOM (truoc dong emit), nen KHONG phat graph_changed trong truong hop nay.
assert probe_result["s7_delete_node_missing_returns_early"] is True
assert probe_result["s7_changed_after_delete_missing_node"] == 2
def test_delete_selected_node_cascades_its_edges_before_edge_loop_runs(probe_result):
# quirk: xoa node c4_ (id thu 3, "node_000013") keo theo xoa ca 2 canh noi
# toi no (b->c va c->d) NGAY trong vong lap xoa node; vong lap xoa canh
# (rieng, cho canh dang selected) sau do khong con thay 2 canh nay nua.
assert probe_result["s7_nodes_after_delete_selected"] == [
"node_000011", "node_000012", "node_000014",
]
assert probe_result["s7_edges_after_delete_selected"] == []
# ---------------------------------------------------------------------------
# Section 8: _zoom_by / zoom_in / zoom_out / reset_zoom
# ---------------------------------------------------------------------------
def test_zoom_in_once_scales_by_1_15(probe_result):
assert probe_result["s8_initial_m11"] == pytest.approx(1.0)
assert probe_result["s8_initial_zoom_attr"] == pytest.approx(1.0)
assert probe_result["s8_m11_after_1_zoom_in"] == pytest.approx(1.15)
assert probe_result["s8_zoom_attr_after_1_zoom_in"] == pytest.approx(1.15)
def test_zoom_in_clamps_at_max_after_7_steps_from_1_0(probe_result):
# quirk: tran _ZOOM_MAX=3.0 dat duoc sau dung 7 lan zoom_in() lien tiep tu
# 1.0 (1 lan da tinh o test truoc + 6 lan trong series nay); 13 lan con
# lai trong series 20 lan la no-op tuyet doi (gia tri dung yen o 3.0).
expected = [
1.3225, 1.520875, 1.749006, 2.011357, 2.313061, 2.66002, 3.0,
3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0,
]
assert probe_result["s8_m11_series_zoom_in_x20_more"] == pytest.approx(expected)
assert probe_result["s8_zoom_attr_after_many_zoom_in"] == pytest.approx(3.0)
def test_zoom_in_at_cap_is_exact_noop(probe_result):
assert probe_result["s8_m11_unchanged_when_already_at_cap"] is True
def test_reset_zoom_returns_to_1_0_regardless_of_prior_zoom(probe_result):
assert probe_result["s8_m11_after_reset"] == pytest.approx(1.0)
assert probe_result["s8_zoom_attr_after_reset"] == pytest.approx(1.0)
def test_zoom_out_clamps_at_min_after_8_steps_from_1_0(probe_result):
# quirk: san _ZOOM_MIN=0.3 dat duoc sau dung 8 lan zoom_out() lien tiep tu
# 1.0; 22 lan con lai trong series 30 lan la no-op (dung yen o 0.3).
expected = [
0.869565, 0.756144, 0.657516, 0.571753, 0.497177, 0.432328, 0.375937,
0.326902, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3,
0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3,
]
assert probe_result["s8_m11_series_zoom_out_x30"] == pytest.approx(expected, abs=1e-5)
assert probe_result["s8_zoom_attr_after_many_zoom_out"] == pytest.approx(0.3)
# ---------------------------------------------------------------------------
# Section 9: relayout / relayout_if_vertical
# ---------------------------------------------------------------------------
def test_relayout_on_empty_canvas_is_a_safe_noop(probe_result):
assert probe_result["s9_relayout_empty_ok"] is True
def test_relayout_if_vertical_reflows_a_stacked_chain_left_to_right(probe_result):
# 3 node xep doc cung x=0 (chenh lech < _NODE_W=210) -> duoc coi la
# "vertical" -> relayout() sap lai theo wave: cot 0/1/2 x = w*(210+110).
assert probe_result["s9_vertical_before"] == {
"node_000015": pytest.approx([0.0, 0.0]),
"node_000016": pytest.approx([0.0, 150.0]),
"node_000017": pytest.approx([0.0, 300.0]),
}
assert probe_result["s9_vertical_after_relayout_if_vertical"] == {
"node_000015": pytest.approx([0.0, 0.0]),
"node_000016": pytest.approx([320.0, 0.0]),
"node_000017": pytest.approx([640.0, 0.0]),
}
def test_relayout_if_vertical_leaves_already_horizontal_graph_untouched(probe_result):
# chenh lech x (500) >= _NODE_W(210) -> khong duoc coi la "vertical" ->
# relayout_if_vertical() khong dong gi toi vi tri da xep, du no khong
# thang hang theo luoi wave.
before = probe_result["s9_horizontal_before"]
after = probe_result["s9_horizontal_after_relayout_if_vertical"]
assert after == before
assert after == {
"node_000018": pytest.approx([0.0, 0.0]),
"node_000019": pytest.approx([500.0, 50.0]),
}
def test_relayout_if_vertical_with_fewer_than_2_nodes_is_noop(probe_result):
assert probe_result["s9_single_node_after_relayout_if_vertical"] == pytest.approx(
[0.0, 0.0]
)
# ---------------------------------------------------------------------------
# Section 10: add_workflow
# ---------------------------------------------------------------------------
def test_add_workflow_first_batch_on_empty_canvas_keeps_original_positions(probe_result):
# quirk: offset ngam ("60 neu self._nodes khac rong") kiem tra TRANG THAI
# canvas LUC GOI, khong phai lien quan gi den tham so `at`. Canvas rong
# luc goi -> offset (0,0) -> toa do y het nodes goc.
ids = probe_result["s10_first_batch_ids"]
assert len(ids) == 2
positions = probe_result["s10_first_batch_positions"]
assert positions[ids[0]] == pytest.approx([0.0, 0.0])
assert positions[ids[1]] == pytest.approx([200.0, 0.0])
assert probe_result["s10_first_batch_edges"] == [[ids[0], ids[1]]]
def test_add_workflow_second_batch_same_args_gets_implicit_60_60_offset(probe_result):
# quirk: goi lai add_workflow VOI CUNG nodes/edges (khong at) nhung canvas
# gio da co node tu lan truoc -> offset ngam (60, 60) duoc ap dung, id moi
# hoan toan khac (fresh new_node_id moi lan).
ids = probe_result["s10_second_batch_ids"]
positions = probe_result["s10_second_batch_positions"]
assert positions[ids[0]] == pytest.approx([60.0, 60.0])
assert positions[ids[1]] == pytest.approx([260.0, 60.0])
assert probe_result["s10_total_edges_after_second_call"] == 2
def test_add_workflow_with_explicit_at_overrides_implicit_offset(probe_result):
ids = probe_result["s10_third_batch_ids"]
positions = probe_result["s10_third_batch_positions"]
assert positions[ids[0]] == pytest.approx([1000.0, 1000.0])
assert positions[ids[1]] == pytest.approx([1200.0, 1000.0])
def test_add_workflow_silently_drops_edges_with_unknown_endpoint(probe_result):
assert probe_result["s10_edges_with_unknown_target_dropped"] == 0
assert probe_result["s10_nodes_still_added_despite_bad_edge"] == 2
# ---------------------------------------------------------------------------
# Section 11: dropEvent
# ---------------------------------------------------------------------------
def test_drop_event_step_payload_adds_node_with_dropped_label(probe_result):
assert probe_result["s11_step_drop_nodes_count"] == 1
assert probe_result["s11_step_drop_label"] == "Dropped Step"
# quirk: view chua tung show()/resize khi dropEvent chay -> mapToScene tra
# ve toa do phu thuoc kich thuoc viewport MAC DINH cua QGraphicsView chua
# hien, KHONG phai (50, 60) nhu vi tri tha ban dau - dong dinh dung so da
# chay ra duoc, khong suy doan.
assert probe_result["s11_step_drop_pos"] == pytest.approx([-269.0, -179.0])
def test_drop_event_workflow_payload_merges_its_nodes(probe_result):
assert probe_result["s11_workflow_drop_added_nodes"] == 1
def test_drop_event_empty_workflow_payload_is_a_silent_noop(probe_result):
# quirk: workflow rong (wf.nodes == []) khong lam gi ca - `if wf.nodes:`
# false nen add_workflow khong duoc goi, khong loi, khong node moi.
assert probe_result["s11_empty_workflow_drop_is_noop"] is True
def test_drop_event_invalid_json_payload_is_a_silent_noop(probe_result):
# quirk: except (ValueError, UnicodeDecodeError): return - khong nem loi
# ra ngoai, khong e.acceptProposedAction() nao duoc goi trong nhanh nay.
assert probe_result["s11_bad_json_drop_is_noop_no_crash"] is True
def test_drop_event_wrong_mime_format_falls_back_to_base_class_noop(probe_result):
assert probe_result["s11_wrong_mime_format_is_noop_no_crash"] is True
# ---------------------------------------------------------------------------
# Lenh thu cong da dung de chot cac gia tri JSON o tren (quy trinh nguoc):
#
# .venv/Scripts/python.exe -c "<noi dung _PROBE_SCRIPT>" <sandbox> <repo_parent>
#
# voi <sandbox> la mot thu muc rong duoc gan vao HOME/USERPROFILE TRUOC khi
# script import bat ky thu gi thuoc cowork_local, va <repo_parent> la thu muc
# cha cua repo (de "import cowork_local" hoat dong dung nhu conftest.py lam).
# ---------------------------------------------------------------------------
@@ -0,0 +1,474 @@
"""Characterization test cho khu vực CHAT của ``Co4ETab`` (``ui/co4e_tab.py``).
BỌC HAI PHẦN ĐỘC LẬP (đúng phạm vi được giao cho lượt này — KHÔNG động tới gì
khác):
(A) ``class _ChatInput(QLineEdit)`` (dòng 139-228) + 3 hàm module-level nó
dùng: ``_skill_names`` (63-67), ``_agent_names`` (70-73),
``_directive_token`` (124-136). Đây là ô chat có autocomplete
``/skill:``/``/agent:`` (popup gợi ý, phím mũi tên/Tab/Enter/Escape).
(B) phần DỰNG WIDGET của ``Co4ETab._build_chat`` (dòng 1030-1093) — header
"Messages" + ``chat_stack`` + composer (ô chat + routing toggle + nút
gửi). KHÔNG bao gồm ``_toggle_messages`` (1095-1127) — hàm đó vẫn ở lại
``Co4ETab`` và không được test ở đây.
VÌ SAO GHI LẠI CHỨ KHÔNG PHÁN XÉT: đây là lưới an toàn cho đợt tách
``ui/co4e_tab.py`` (2000+ dòng) sang ``presentation/co4e/co4e_chat_view.py``.
Đợt này CHƯA tạo file production đó — chỉ chụp ảnh hành vi hiện tại của khu
vực chat để đợt tách sau có bằng chứng "trước/sau giống nhau". Mọi ``assert``
dưới đây được chốt lại từ giá trị THẬT in ra khi chạy code (quy trình ngược:
chạy trước, in kết quả, dán vào assert) — không phải giá trị suy luận trước.
VÌ SAO CHẠY TRONG TIẾN TRÌNH CON CÔ LẬP HOME: giống hệt kỹ thuật của
``tests/characterization/test_co4e_skills_panel.py``/``test_co4e_agent_panel.py``.
``_ChatInput._maybe_popup`` gọi ``_skill_names()``/``_agent_names()``, và
``_skill_names()`` đọc thật từ ``core/skills.py::SKILLS_DIR`` (hằng số module
tính MỘT LẦN lúc import, từ ``CONFIG_DIR = Path.home() / ".cowork_local"``).
Muốn đổi ``Path.home()`` phải đặt ``USERPROFILE``/``HOME`` TRƯỚC bất kỳ
import ``cowork_local.*`` nào, nên toàn bộ phần dựng ``QApplication`` +
``Co4ETab``/``_ChatInput`` chạy trong MỘT tiến trình con sạch (giống
``tools/capture_screens.py::_isolate_home()``), không phải trong tiến trình
pytest chính (nơi ``cowork_local`` rất có thể đã bị import từ trước bởi một
test khác, khiến việc patch ``os.environ`` sau đó vô nghĩa).
KHÔNG gọi AI/QThread thật: cả (A) và (B) không đụng ``Co4ERunManager``/
``AgentWorker``/``ChatView`` thật đi gửi tin — (B) chỉ dựng widget rồi thay
``_chat_send``/``_toggle_messages`` bằng stub đếm lệnh gọi (Co4ETab thật cần
``self.ctx``, ``self._chat_send``, ``self._toggle_messages`` đã tồn tại vì
``_build_chat`` nối ``.clicked``/``.submit`` tới chúng ngay trong hàm — gọi
``Co4ETab._build_chat(fake_self)`` như một hàm KHÔNG bị ràng buộc (unbound),
với ``fake_self`` là một object tối giản chỉ có ``ctx`` + 2 stub đó, để không
phải dựng toàn bộ ``Co4ETab``/canvas/sidebar nặng nề).
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới):
* ``/agent:<name>`` chèn NGUYÊN TÊN agent (có thể chứa dấu cách, ví dụ
"Business Analyst") vào ô chat, KHÔNG slugify — trong khi ``/skill:<name>``
chèn ``co4e.slugify(name)`` (không dấu cách). Hai directive cùng cú pháp
nhưng xử lý tên khác nhau; một khi đã chèn, chuỗi "/agent:Business Analyst "
có dấu cách nên ``_directive_token`` không còn coi phần sau dấu cách là
thuộc token đó nữa (regex partial dùng ``[\\w\\-.]*``, không nhận dấu cách).
* Autocomplete kích hoạt từ khi gõ mới 2 ký tự ("/a" hoặc "/sk"), TRƯỚC khi
có dấu ``:`` — nhánh fallback trong ``_directive_token`` khớp theo tiền tố
của ``"/skill"``/``"/agent"`` với ``partial=""``, nên popup hiện TOÀN BỘ
danh sách skill/agent ngay từ 2 ký tự, không phải danh sách rỗng.
* Khi popup ĐANG hiện có ít nhất 1 dòng, phím Enter/Return CHỌN dòng đó
(``_accept``) và KHÔNG emit ``submit`` — ngược với khi popup ẩn, Enter emit
``submit`` để gửi tin. Cùng một phím, hai hành vi khác nhau tuỳ trạng thái
popup.
* Phím Down/Up trên popup dùng modulo (``(row + step) % n``) nên vòng lặp:
từ dòng cuối bấm Down quay về dòng đầu, từ dòng đầu bấm Up quay về dòng
cuối — không dừng ở biên như nhiều danh sách khác.
* ``_accept()`` khi popup không có dòng nào được chọn (ẩn, rỗng) là no-op
tuyệt đối — không đổi text, không đổi con trỏ, không ném lỗi.
* ``_agent_names()`` không loại trùng theo slug mà theo TÊN HIỂN THỊ: một
custom agent trùng tên với một agent built-in (ví dụ "Business Analyst")
"thắng" — built-in cùng tên bị lọc khỏi danh sách (custom agents được đưa
vào trước, built-in chỉ thêm nếu tên chưa có).
* ``_skill_names()`` bọc try/except quanh toàn bộ ``list_skills() +
builtin_skills()`` và trả về ``[]`` một cách im lặng khi lỗi — autocomplete
``/skill:`` khi đó chỉ đơn giản không hiện popup, không có thông báo lỗi.
VÒNG ĐỜI: đây là giàn giáo cho đợt tách khu vực CHAT (``ui/co4e_tab.py`` dòng
139-228 + phần dựng widget 1030-1093) sang
``presentation/co4e/co4e_chat_view.py``. Sau khi tách xong, các case ở phần
(A) nên viết lại thành test đặc tả cho ``_ChatInput``/hàm module độc lập
(không cần subprocess/QApplication nặng nếu module mới không còn đọc đĩa lúc
import), còn phần (B) nên viết lại thành test đặc tả cho widget composer mới
(input rõ ràng: ctx giả + 2 callback, không cần dựng qua ``Co4ETab`` gốc).
Quirk "agent không slugify nhưng skill có" và quirk "dedup theo tên hiển thị"
đáng mở issue hỏi ý kiến sản phẩm trước khi ai đó "dọn" chúng trong lúc tách —
rất dễ bị coi là bug và "sửa" nhầm trong khi đây là hành vi đang chạy thật.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import json
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from PySide6.QtWidgets import QApplication, QListWidget
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QKeyEvent
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.core import co4e, skills as skills_mod
from cowork_local.core.co4e_builtins import BUILTIN_AGENTS
from cowork_local.ui.co4e_tab import (
Co4ETab, _ChatInput, _directive_token, _skill_names, _agent_names,
)
from cowork_local.i18n import tr
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
# =====================================================================
# PHAN A1: _directive_token (124-136) - ham thuan, khong dung Qt/dia
# =====================================================================
cases = [
("/skill:", 7, (0, "skill", "")),
("/skill:abc", 11, (0, "skill", "abc")),
("/agent", 6, (0, "agent", "")),
("/agent:", 7, (0, "agent", "")),
("/sk", 3, (0, "skill", "")),
("/a", 2, (0, "agent", "")),
("/", 1, None),
("", 0, None),
("hello", 5, None),
("hello /agent:bob", 17, (6, "agent", "bob")),
("hello /agent:bob", 10, (6, "agent", "")),
("/skill:ab cd", 9, (0, "skill", "ab")),
("/skill:ab cd", 12, None),
("//skill", 7, None),
]
for text, pos, expected in cases:
got = _directive_token(text, pos)
assert got == expected, (text, pos, got, expected)
print("CASE_DIRECTIVE_TOKEN_OK")
# =====================================================================
# PHAN A2: _skill_names / _agent_names - doc dia thong qua sandbox
# =====================================================================
assert not skills_mod.SKILLS_DIR.exists(), skills_mod.SKILLS_DIR
assert _skill_names() == []
builtin_only = _agent_names()
assert builtin_only == [a.name for a in BUILTIN_AGENTS], builtin_only
assert len(builtin_only) == 19, len(builtin_only)
print("CASE_EMPTY_DISK_NAMES_OK")
# quirk: custom agent TRUNG TEN voi built-in -> built-in bi loc, khong con 2 ban
co4e.AGENTS_DIR.mkdir(parents=True, exist_ok=True)
(co4e.AGENTS_DIR / "a1.json").write_text(
json.dumps({"id": "a1", "name": "Business Analyst", "role": "AGENT"}),
encoding="utf-8")
(co4e.AGENTS_DIR / "a2.json").write_text(
json.dumps({"id": "a2", "name": "My Custom Agent", "role": "AGENT"}),
encoding="utf-8")
mixed = _agent_names()
assert mixed[:2] == ["Business Analyst", "My Custom Agent"], mixed[:2]
assert mixed.count("Business Analyst") == 1, mixed
assert len(mixed) == 20, len(mixed) # 19 built-in - 1 trung ten + 2 custom
print("CASE_AGENT_NAME_DEDUP_QUIRK_OK")
skills_mod.SKILLS_DIR.mkdir(parents=True, exist_ok=True)
(skills_mod.SKILLS_DIR / "s1.json").write_text(json.dumps({
"name": "Viet Test", "description": "d", "instructions": "content", "enabled": True,
}), encoding="utf-8")
assert _skill_names() == ["Viet Test"], _skill_names()
print("CASE_SKILL_NAMES_AFTER_CREATE_OK")
# =====================================================================
# PHAN A3: _ChatInput - popup autocomplete + phim tat
# =====================================================================
ci = _ChatInput()
assert hasattr(ci, "submit")
assert ci._popup.focusPolicy() == Qt.NoFocus
assert ci._popup.windowFlags() == (
Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
| Qt.NoDropShadowWindowHint)
print("CASE_CHATINPUT_CTOR_OK")
# go "/skill:vi" -> 1 dong goi y, replacement da slugify + khoang trang cuoi
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
assert ci._popup.count() == 1, ci._popup.count()
row0 = ci._popup.item(0)
assert row0.text() == "Viet Test", row0.text()
assert row0.data(Qt.UserRole) == "/skill:viet-test ", row0.data(Qt.UserRole)
assert row0.toolTip() == "Viet Test", row0.toolTip()
assert ci._popup.isVisible() is True
# quirk: chieu rong popup = max(280, chieu rong o chat) - khong co gia tri co dinh
assert ci._popup.width() == max(280, ci.width()), (ci._popup.width(), ci.width())
assert ci._popup.height() == 8 + 1 * 22, ci._popup.height() # 1 dong -> 30px
print("CASE_SKILL_POPUP_SLUGIFIED_OK")
# quirk: /agent:<ten> CHEN NGUYEN TEN (co dau cach), KHONG slugify
ci.setText("/agent:business")
ci.setCursorPosition(len("/agent:business"))
ci._maybe_popup()
assert ci._popup.count() == 1, ci._popup.count()
row_a = ci._popup.item(0)
assert row_a.text() == "Business Analyst", row_a.text()
assert row_a.data(Qt.UserRole) == "/agent:Business Analyst ", row_a.data(Qt.UserRole)
print("CASE_AGENT_POPUP_NOT_SLUGIFIED_QUIRK_OK")
# quirk: goi "/a" (2 ky tu, chua co dau :) da kich hoat popup agent VOI partial rong
# -> hien TOAN BO danh sach agent, khong phai danh sach rong
ci.setText("/a")
ci.setCursorPosition(2)
ci._maybe_popup()
assert ci._popup.count() == len(_agent_names()), (ci._popup.count(), len(_agent_names()))
print("CASE_TWO_CHAR_PREFIX_TRIGGERS_FULL_LIST_QUIRK_OK")
# khong khop skill nao -> popup an, count 0
ci.setText("/skill:khongtontai")
ci.setCursorPosition(len("/skill:khongtontai"))
ci._maybe_popup()
assert ci._popup.isVisible() is False, ci._popup.isVisible()
assert ci._popup.count() == 0, ci._popup.count()
print("CASE_NO_MATCH_HIDES_POPUP_OK")
# khong phai directive -> an popup (du popup dang duoc show truoc do)
ci._popup.show()
ci.setText("hello")
ci.setCursorPosition(5)
ci._maybe_popup()
assert ci._popup.isVisible() is False
print("CASE_NON_DIRECTIVE_HIDES_POPUP_OK")
# _accept() thay the token bang replacement + dat lai con tro
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
ci._accept()
assert ci.text() == "/skill:viet-test ", ci.text()
assert ci.cursorPosition() == len("/skill:viet-test "), ci.cursorPosition()
assert ci._popup.isVisible() is False
print("CASE_ACCEPT_REPLACES_TOKEN_OK")
# quirk: _accept() khi khong co dong nao duoc chon -> no-op tuyet doi
ci._popup.clear()
ci._popup.hide()
ci.setText("hello world")
ci.setCursorPosition(5)
ci._accept()
assert ci.text() == "hello world", ci.text()
assert ci.cursorPosition() == 5, ci.cursorPosition()
print("CASE_ACCEPT_NOOP_WHEN_NO_ITEM_QUIRK_OK")
# Enter voi popup AN -> emit submit (gui tin)
submitted = []
ci.submit.connect(lambda: submitted.append(1))
ci._popup.hide()
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Return, Qt.NoModifier))
assert submitted == [1], submitted
print("CASE_ENTER_SUBMITS_WHEN_POPUP_HIDDEN_OK")
# quirk: Enter voi popup DANG HIEN (co dong) -> accept, KHONG submit
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
assert ci._popup.isVisible() is True and ci._popup.count() == 1
submitted.clear()
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Return, Qt.NoModifier))
assert ci.text() == "/skill:viet-test ", ci.text()
assert submitted == [], submitted
print("CASE_ENTER_ACCEPTS_INSTEAD_OF_SUBMIT_WHEN_POPUP_VISIBLE_QUIRK_OK")
# quirk: Down/Up dung modulo -> vong lap qua bien
(skills_mod.SKILLS_DIR / "s2.json").write_text(json.dumps({
"name": "Second Skill", "description": "", "instructions": "x", "enabled": True,
}), encoding="utf-8")
ci.setText("/skill:")
ci.setCursorPosition(len("/skill:"))
ci._maybe_popup()
assert ci._popup.count() == 2, ci._popup.count()
assert ci._popup.currentRow() == 0
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Down, Qt.NoModifier))
assert ci._popup.currentRow() == 1
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Down, Qt.NoModifier))
assert ci._popup.currentRow() == 0, ci._popup.currentRow() # vong lai dau
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Up, Qt.NoModifier))
assert ci._popup.currentRow() == 1, ci._popup.currentRow() # vong ve cuoi
print("CASE_UP_DOWN_WRAP_AROUND_QUIRK_OK")
# Escape an popup
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Escape, Qt.NoModifier))
assert ci._popup.isVisible() is False
print("CASE_ESCAPE_HIDES_POPUP_OK")
# Tab cung accept (giong Enter khi popup hien)
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier))
assert ci.text() == "/skill:viet-test ", ci.text()
print("CASE_TAB_ACCEPTS_OK")
# =====================================================================
# PHAN B: Co4ETab._build_chat (1030-1093) - CHI phan dung widget,
# KHONG bao gom _toggle_messages (o lai Co4ETab, khong test o day)
# =====================================================================
calls = []
class _FakeCo4ETab:
ctx = ctx
def _toggle_messages(self):
calls.append("toggle")
def _chat_send(self):
calls.append("send")
fake = _FakeCo4ETab()
w = Co4ETab._build_chat(fake) # goi nhu ham khong rang buoc, khong dung Co4ETab thuc
# DA CAP NHAT sau khi tach "Chat View" (xem
# presentation/co4e/co4e_chat_view.py): _build_chat gio tra ve mot ChatPanel
# (subclass QWidget dung trong presentation/co4e/co4e_chat_view.py) thay vi
# mot QWidget tran - van la mot QWidget that su (layout/cac widget con van
# nguyen), chi ten class cu the doi (cung khuon mau da dung cho
# RunsPagePanel, xem test_co4e_runs_page.py::test_runs_table_is_parented_into_the_returned_widget).
assert type(w).__name__ == "ChatPanel"
assert w is fake._chat_widget
assert w.layout().contentsMargins().left() == 0
assert w.layout().spacing() == 0
assert w.layout().count() == 3, w.layout().count()
child_types = [w.layout().itemAt(i).widget().objectName() or type(w.layout().itemAt(i).widget()).__name__
for i in range(w.layout().count())]
assert child_types == ["msgHeader", "QStackedWidget", "QWidget"], child_types
print("CASE_BUILD_CHAT_TOP_LAYOUT_OK")
# ---- header "Messages" ----
assert fake._mhdr.objectName() == "msgHeader"
mh = fake._mhdr.layout()
assert mh.count() == 4, mh.count()
assert mh.itemAt(0).widget() is fake.msgs_icon
assert mh.itemAt(1).widget() is fake.msgs_title
assert mh.itemAt(2).widget() is None # addStretch(1) - khong phai widget
assert mh.itemAt(3).widget() is fake.chat_toggle_btn
assert mh.contentsMargins().left() == 6 and mh.contentsMargins().top() == 3
assert mh.spacing() == 6
assert fake.msgs_title.text() == tr("co4e.messages"), fake.msgs_title.text()
assert fake.msgs_title.objectName() == "hint"
assert fake.msgs_icon.pixmap().width() == 14 and fake.msgs_icon.pixmap().height() == 14
assert fake.chat_toggle_btn.objectName() == "msgToggle"
assert fake.chat_toggle_btn.isFlat() is True
assert fake.chat_toggle_btn.width() == 22 and fake.chat_toggle_btn.height() == 22
assert fake.chat_toggle_btn.toolTip() == tr("co4e.tt_expand_msgs"), fake.chat_toggle_btn.toolTip()
print("CASE_BUILD_CHAT_HEADER_OK")
# ---- chat_stack + flow_logs ----
assert fake.chat_stack.count() == 0
assert fake._flow_logs == {}
assert fake.chat_stack.isHidden() is True # mac dinh COLLAPSED
print("CASE_BUILD_CHAT_STACK_OK")
# ---- composer (chat_input_row) ----
crow = fake.chat_input_row.layout()
assert crow.count() == 2, crow.count()
assert crow.itemAt(0).widget() is fake._usage_total_lbl
assert crow.contentsMargins().top() == 4 and crow.spacing() == 3
assert fake._usage_total_lbl.text() == ""
assert fake._usage_total_lbl.objectName() == "hint"
inp_widget = crow.itemAt(1).widget()
row = inp_widget.layout()
assert row.contentsMargins().left() == 0
assert row.count() == 3, row.count()
assert row.itemAt(0).widget() is fake.chat_input
assert row.stretch(0) == 1
assert row.itemAt(1).widget() is fake.co4e_routing_toggle
assert row.stretch(1) == 0
assert row.itemAt(2).widget() is fake.chat_send_btn
assert type(fake.chat_input).__name__ == "_ChatInput"
assert fake.chat_input.placeholderText() == tr("co4e.chat_placeholder"), fake.chat_input.placeholderText()
assert fake.chat_send_btn.text() == tr("co4e.send"), fake.chat_send_btn.text()
assert type(fake.co4e_routing_toggle).__name__ == "RoutingToggle"
assert fake.co4e_routing_toggle.surface == "co4e"
assert fake._co4e_routed_provider is None
assert fake.chat_input_row.isHidden() is True # mac dinh COLLAPSED
print("CASE_BUILD_CHAT_COMPOSER_OK")
# ---- 1073/1075: submit/click cua composer noi thang toi _chat_send ----
fake.chat_send_btn.click()
assert calls == ["send"], calls
calls.clear()
fake.chat_input.submit.emit()
assert calls == ["send"], calls
calls.clear()
print("CASE_COMPOSER_WIRES_TO_CHAT_SEND_OK")
# ---- 1047: chat_toggle_btn.clicked noi thang toi _toggle_messages ----
fake.chat_toggle_btn.click()
assert calls == ["toggle"], calls
calls.clear()
print("CASE_TOGGLE_BTN_WIRES_TO_TOGGLE_MESSAGES_OK")
# ---- trang thai mac dinh COLLAPSED sau khi _build_chat tra ve ----
assert fake._vsplit_sizes == [540, 220], fake._vsplit_sizes
assert fake._msgs_collapsed is True
assert w.maximumHeight() == fake._mhdr.sizeHint().height() + 6, (
w.maximumHeight(), fake._mhdr.sizeHint().height())
print("CASE_BUILD_CHAT_DEFAULT_COLLAPSED_STATE_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_co4e_chat_view_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_DIRECTIVE_TOKEN_OK",
"CASE_EMPTY_DISK_NAMES_OK",
"CASE_AGENT_NAME_DEDUP_QUIRK_OK",
"CASE_SKILL_NAMES_AFTER_CREATE_OK",
"CASE_CHATINPUT_CTOR_OK",
"CASE_SKILL_POPUP_SLUGIFIED_OK",
"CASE_AGENT_POPUP_NOT_SLUGIFIED_QUIRK_OK",
"CASE_TWO_CHAR_PREFIX_TRIGGERS_FULL_LIST_QUIRK_OK",
"CASE_NO_MATCH_HIDES_POPUP_OK",
"CASE_NON_DIRECTIVE_HIDES_POPUP_OK",
"CASE_ACCEPT_REPLACES_TOKEN_OK",
"CASE_ACCEPT_NOOP_WHEN_NO_ITEM_QUIRK_OK",
"CASE_ENTER_SUBMITS_WHEN_POPUP_HIDDEN_OK",
"CASE_ENTER_ACCEPTS_INSTEAD_OF_SUBMIT_WHEN_POPUP_VISIBLE_QUIRK_OK",
"CASE_UP_DOWN_WRAP_AROUND_QUIRK_OK",
"CASE_ESCAPE_HIDES_POPUP_OK",
"CASE_TAB_ACCEPTS_OK",
"CASE_BUILD_CHAT_TOP_LAYOUT_OK",
"CASE_BUILD_CHAT_HEADER_OK",
"CASE_BUILD_CHAT_STACK_OK",
"CASE_BUILD_CHAT_COMPOSER_OK",
"CASE_COMPOSER_WIRES_TO_CHAT_SEND_OK",
"CASE_TOGGLE_BTN_WIRES_TO_TOGGLE_MESSAGES_OK",
"CASE_BUILD_CHAT_DEFAULT_COLLAPSED_STATE_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi cac file skill/agent test tu tao xuong
# SKILLS_DIR/AGENTS_DIR (khong co du lieu nguoi dung that nao bi cham vao) -
# CONFIG_DIR nam trong sandbox nhu da assert ngay dau script.
assert (sandbox / ".cowork_local").exists()
@@ -0,0 +1,464 @@
"""Characterization test cho lớp CŨ ``core/co4e_run_manager.py``
(``Co4ERunManager`` + ``RunHandle``) — hook ``_on_event``/``_on_finished``/
``_on_failed`` và round-trip ``RunHandle.to_record``/``RunHandle.from_record``.
VÒNG ĐỜI: đây là giàn giáo (scaffolding), không phải công trình cuối cùng.
Mục đích DUY NHẤT là làm lưới an toàn cho đợt tách ``Co4ERunManager`` thành
``domain/workflows/run_record.py::RunRecord`` (DTO thuần) +
``application/workflows/co4e_workflow_service.py::Co4EWorkflowService``
(hành vi + lifecycle + lưu đĩa) — xem ``tests/test_co4e_workflow_service.py``,
nơi lớp MỚI được bọc lại bằng test đặc tả tử tế, và nơi có
``test_new_service_produces_same_json_record_as_old_manager`` chạy CÙNG một
chuỗi thao tác trên cả hai lớp rồi so JSON ghi ra đĩa — bằng chứng "hành vi
không lệch" chạy được. File NÀY chỉ ghi lại hành vi của lớp CŨ, KHÔNG được sửa
lớp cũ để "cho khớp" test — nếu một assert dưới đây đỏ mà code cũ trông "sai",
sửa assert, không sửa ``core/co4e_run_manager.py``. Sau khi đợt tách hoàn tất
và ``core/co4e_run_manager.py`` bị xoá/deprecate hẳn, file này hết nhiệm vụ và
nên được xoá theo (không viết lại thành spec — spec test đã có sẵn ở
``tests/test_co4e_workflow_service.py``).
CÁCH CHỐT ASSERT: mọi giá trị dưới đây được lấy bằng cách CHẠY code thật qua
``.venv/Scripts/python.exe -c "..."`` rồi dán nguyên kết quả in được vào
assert (đúng quy trình ngược yêu cầu), không suy luận lý thuyết.
AN TOÀN DỮ LIỆU (BẮT BUỘC — lý do có 2 lớp phòng thủ dưới đây):
1. ``Co4ERunManager.__init__`` gọi ``_load_history()`` đọc
``~/.cowork_local/co4e/run_history.json`` THẬT (``CONFIG_DIR`` là
``Path.home() / ".cowork_local"``, hằng số module tính MỘT LẦN lúc
``cowork_local.config`` được import), rồi mọi ``changed.emit()`` (từ mọi
hook mà test này gọi) kéo theo ``_save_history()`` ghi ĐÈ file đó. Vì
vậy, TRƯỚC khi import bất kỳ thứ gì thuộc ``cowork_local``, module này tự
dựng một HOME giả (``_isolate_home()``, cùng kỹ thuật
``tools/capture_screens.py::_isolate_home()``: đặt ``USERPROFILE``/
``HOME`` trỏ vào một thư mục tạm, xoá ``HOMEDRIVE``/``HOMEPATH``) rồi mới
import ``cowork_local.config``/``cowork_local.core.co4e_run_manager`` —
một assert ngay sau import chốt rằng ``CONFIG_DIR`` thật sự nằm trong
sandbox đó (kiểu ``tools/check_co4e.py:47``).
2. Phòng thủ thứ hai, độc lập với (1): mỗi test còn monkeypatch
``Co4ERunManager._history_path`` trỏ về một file trong ``tmp_path`` CỦA
RIÊNG NÓ. Lý do cần thêm lớp này dù đã có (1): nếu file test này được
chạy CÙNG bộ với các file khác đã import ``cowork_local.config`` với HOME
thật trước đó (thứ tự collect của pytest), hằng số module ``CONFIG_DIR``/
``CO4E_DIR`` đã bị đóng băng theo HOME thật mất rồi — xem đúng cái bẫy
này được ghi lại trong docstring đầu
``tests/characterization/test_co4e_skills_panel.py``. Vá thẳng
``_history_path`` (đọc lại lúc GỌI, không đọc lúc import) không phụ
thuộc thời điểm import nên luôn đúng bất kể thứ tự collect.
KHÔNG gọi ``Co4ERunManager.start()`` (spawn ``AgentWorker``/``QThread`` thật,
gọi provider AI thật, tốn tiền, ghi file thật). Mọi test dưới đây seed thẳng
vào ``manager._runs[...]`` bằng ``RunHandle`` rồi gọi ``_on_event``/
``_on_finished``/``_on_failed`` trực tiếp — đúng route hook thật mà
``start()`` nối qua ``worker.event.connect(...)`` v.v., chỉ bỏ qua phần
spawn/chạy job.
KHÔNG CẦN QApplication: ``Co4ERunManager``/``RunHandle`` chỉ dùng
``QObject``+``Signal`` đồng luồng (kết nối rồi ``emit()`` ngay trong test,
không dựng widget, không đọc kích thước, không cần app instance).
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới — ĐỪNG "dọn" các chỗ này khi
tách, chúng trông như bug nhưng là hành vi đang chạy thật hôm nay):
* ``event = Signal(str, dict)`` khai báo kiểu cứng cho tham số thứ hai. Khi
``_on_event(run_id, ev)`` được gọi với ``ev=None`` và ``run_id`` KHÔNG có
trong ``_runs`` (nên nhánh xử lý dict bị bỏ qua, đi thẳng xuống
``self.event.emit(run_id, ev)``), Qt/Shiboken ép kiểu ``None`` thành
``dict`` RỖNG (``{}``) ngay tại điểm ``emit`` — listener nhận được
``{}`` chứ KHÔNG PHẢI ``None``. (Shiboken có in một dòng cảnh báo
"Cannot copy-convert ... (NoneType) to C++" ra stderr, nhưng KHÔNG ném
lỗi.) Đây là khác biệt cố ý với lớp MỚI
(``Co4EWorkflowService``/``domain/workflows/run_record.py``), nơi không
còn ``Signal`` nữa nên callback nhận đúng ``None`` gốc — xem
``tests/test_co4e_workflow_service.py::test_on_event_none_payload_does_not_raise_and_reemits_none``.
* Khi ``ev["type"] == "run_done"`` mà ``handle.status`` KHÔNG phải
``"running"`` (ví dụ đã ``"stopped"``), nhánh ``if handle.status ==
"running":`` không đổi ``status``, nhưng ``self.changed.emit()`` vẫn được
gọi VÔ ĐIỀU KIỆN ngay sau đó (nằm ngoài ``if``) — run "đã xong" vẫn kích
một lần refresh + một lần ghi lịch sử xuống đĩa, dù không có gì thay đổi
trên ``handle`` đó.
* ``RunHandle.to_record()``/``from_record()`` đổi trường ``wf`` qua lại
thành ĐỐI TƯỢNG ``Workflow`` thật (``workflow_to_dict``/
``workflow_from_dict`` từ ``core/co4e.py``) — khác hẳn ``RunRecord`` mới
(``domain/workflows/run_record.py``), nơi ``wf`` CỐ Ý được giữ nguyên là
dict thô vì domain không được phép import ``core.co4e.Workflow``. Đây là
một khác biệt thiết kế có chủ đích giữa bản cũ và bản mới, không phải lỗi
port thiếu.
* Round-trip KHÔNG đối xứng: ``status == "running"`` đọc lại từ
``from_record()`` bị chốt thành ``"stopped"`` (worker của nó đã mất theo
khi app tắt giữa run), nhưng ``to_record()`` vẫn ghi đúng "running" xuống
đĩa tại thời điểm lưu. ``from_record({})``/``from_record(None)`` mặc định
``status="done"`` (không phải "running") nên KHÔNG rơi vào nhánh đổi
thành "stopped".
* ``total`` âm bị ``max(0, total)`` kẹp về 0 ngay lúc khởi tạo
``RunHandle``, không giữ nguyên giá trị âm.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
def _isolate_home() -> Path:
"""Trỏ USERPROFILE/HOME sang một thư mục tạm TRƯỚC khi import
``cowork_local`` — cùng kỹ thuật ``tools/capture_screens.py::_isolate_home()``.
Không có dữ liệu thật nào được sao chép vào đây (khác capture_screens):
test này không cần đọc lịch sử run thật, chỉ cần KHÔNG BAO GIỜ chạm vào nó.
"""
sandbox = Path(tempfile.mkdtemp(prefix="co4e-run-manager-test-home-"))
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
for var in ("USERPROFILE", "HOME"):
os.environ[var] = str(sandbox)
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
return sandbox
_SANDBOX_HOME = _isolate_home()
import pytest # noqa: E402
from cowork_local.config import CONFIG_DIR # noqa: E402
from cowork_local.core.co4e import Node, Step, Workflow # noqa: E402
from cowork_local.core.co4e_run_manager import Co4ERunManager, RunHandle # noqa: E402
# GIỚI HẠN ĐÃ BIẾT (không phải lỗ hổng an toàn dữ liệu — xem lớp phòng thủ #2
# ở docstring đầu file, mọi test dưới đây đều tự vá ``_history_path`` bất kể
# kết quả kiểm tra này): ``CONFIG_DIR`` là hằng số module tính MỘT LẦN lúc
# ``cowork_local.config`` được import. Khi chạy CHỈ file này (đúng lệnh VERIFY
# ở đầu task), ``_isolate_home()`` ở trên chạy trước import đầu tiên nên chốt
# đúng. Khi chạy CẢ BỘ, một file khác được pytest collect trước có thể đã
# import ``cowork_local.config`` với HOME thật rồi — ``CONFIG_DIR`` khi đó đã
# đóng băng theo giá trị thật, không cách nào isolate lại được nữa từ file này
# (cùng giới hạn được ghi trong docstring đầu
# ``tests/characterization/test_co4e_skills_panel.py``). Vì vậy test dưới đây
# CHỦ ĐỘNG bỏ qua (không fail cả file, không làm mất 30 test còn lại) khi phát
# hiện giới hạn này, thay vì assert cứng ở cấp module (từng thử — gây lỗi
# collection cho TOÀN BỘ file khi chạy chung với các file khác đã import
# ``cowork_local.config`` trước).
def test_home_isolation_pins_config_dir_into_sandbox_when_first_to_import():
if str(_SANDBOX_HOME) not in str(CONFIG_DIR):
pytest.skip(
"cowork_local.config da bi mot file test khac import voi HOME "
"that TRUOC file nay trong cung phien pytest (thu tu collect) -- "
f"CONFIG_DIR={CONFIG_DIR!r} khong con nam trong sandbox cua file "
"nay. Day la gioi han da biet (xem docstring dau file), KHONG "
"phai mat an toan du lieu: moi test hook trong file nay tu va "
"thang Co4ERunManager._history_path (doc lap voi CONFIG_DIR) nen "
"khong test nao trong file thuc su cham vao lich su run that."
)
assert str(_SANDBOX_HOME) in str(CONFIG_DIR)
class _Ctx:
"""Stub ``ctx``: không hook/round-trip nào trong file này đụng tới
``ctx.config`` (chỉ ``start()``/``_out_dir()`` — không thuộc phạm vi bọc
của file này — mới cần nó thật)."""
@pytest.fixture
def manager(tmp_path, monkeypatch):
# Lớp phòng thủ thứ hai (xem docstring đầu file): mỗi test có lịch sử
# riêng trong tmp_path của chính nó, độc lập với CONFIG_DIR.
history_path = tmp_path / "run_history.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: history_path)
return Co4ERunManager(_Ctx())
def _seed(manager: Co4ERunManager, run_id: str, **kw) -> RunHandle:
defaults = dict(wf_id="wf1", name="Flow", total=3, plan_mode=False, manual=False)
defaults.update(kw)
h = RunHandle(run_id, **defaults)
manager._runs[run_id] = h
return h
def _make_workflow(node_count: int = 1, wf_id: str = "wf-x", name: str = "Flow X") -> Workflow:
nodes = [Node(id=f"n{i}", x=0.0, y=0.0, data=Step(label=f"Step{i}")) for i in range(1, node_count + 1)]
return Workflow(id=wf_id, name=name, nodes=nodes, edges=[])
# ---------------------------------------------------------------------------
# RunHandle: gia tri mac dinh / kep bien
# ---------------------------------------------------------------------------
def test_run_handle_defaults_on_construction():
h = RunHandle("run1", "wf1", "My Flow", 3, False, False)
assert h.status == "running"
assert h.done == 0
assert h.progress_text() == "0/3"
assert h.running is True
def test_run_handle_negative_total_clamped_to_zero():
h = RunHandle("run2", "wf2", "Flow2", -5, False, False)
assert h.total == 0
def test_run_handle_zero_total_progress_text_falls_back_to_status():
h = RunHandle("run3", "wf3", "Flow3", 0, False, False)
assert h.progress_text() == "running"
# ---------------------------------------------------------------------------
# to_record / from_record
# ---------------------------------------------------------------------------
def test_to_record_contains_expected_keys_and_values():
h = RunHandle("run1", "wf1", "My Flow", 3, False, False,
created_by="alice", created_at="2026-08-23 10:00", project_id="p1")
rec = h.to_record()
assert sorted(rec.keys()) == [
"created_at", "created_by", "done", "error", "id", "manual", "name",
"node_status", "out_dir", "plan_mode", "project_id", "status", "total",
"wf", "wf_id",
]
assert rec["id"] == "run1"
assert rec["status"] == "running"
assert rec["wf"] is None
def test_round_trip_status_running_becomes_stopped():
h = RunHandle("run1", "wf1", "My Flow", 3, False, False)
rec = h.to_record()
assert rec["status"] == "running" # to_record ghi dung "running"
back = RunHandle.from_record(rec)
assert back.status == "stopped" # nhung from_record chot lai thanh "stopped"
@pytest.mark.parametrize("status", ["done", "error", "stopped"])
def test_round_trip_non_running_statuses_are_preserved(status):
h = RunHandle("run1", "wf1", "My Flow", 3, False, False)
h.status = status
back = RunHandle.from_record(h.to_record())
assert back.status == status
def test_from_record_empty_dict_uses_documented_defaults():
h = RunHandle.from_record({})
assert h.id == ""
assert h.status == "done" # quirk: khong roi vao nhanh doi thanh "stopped"
assert h.wf is None
assert h.node_status == {}
def test_from_record_none_treated_same_as_empty_dict():
assert RunHandle.from_record(None).id == RunHandle.from_record({}).id
assert RunHandle.from_record(None).status == RunHandle.from_record({}).status
def test_round_trip_workflow_snapshot_becomes_real_workflow_object():
# quirk: khac RunRecord moi (giu wf la dict tho), RunHandle CU doi wf qua
# lai thanh doi tuong Workflow that qua workflow_to_dict/workflow_from_dict.
h = RunHandle("run4", "wf-x", "Flow X run", 1, False, False)
h.wf = _make_workflow()
rec = h.to_record()
assert isinstance(rec["wf"], dict) # tren dia luon la dict (JSON-able)
assert rec["wf"]["id"] == "wf-x"
assert rec["wf"]["name"] == "Flow X"
back = RunHandle.from_record(rec)
assert isinstance(back.wf, Workflow) # nhung doc lai thanh doi tuong that
assert back.wf.id == "wf-x"
assert back.wf.name == "Flow X"
assert len(back.wf.nodes) == 1
assert back.wf.nodes[0].id == "n1"
# ---------------------------------------------------------------------------
# _on_event
# ---------------------------------------------------------------------------
def test_on_event_node_status_done_increments_progress_and_emits_changed(manager):
h = _seed(manager, "run1")
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
assert h.node_status == {"n1": "done"}
assert h.done == 1
assert len(changed) == 1
def test_on_event_node_status_planned_counts_as_terminal_too(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "planned"})
assert h.done == 1
def test_on_event_node_status_running_is_not_terminal(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "running"})
assert h.done == 0
def test_on_event_node_status_missing_keys_stores_none_key(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "node_status"})
assert h.node_status == {None: None}
def test_on_event_run_done_default_ok_marks_done(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "run_done"})
assert h.status == "done"
def test_on_event_run_done_ok_false_marks_error(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "run_done", "ok": False})
assert h.status == "error"
def test_on_event_run_done_when_not_running_leaves_status_but_still_emits_changed(manager):
# quirk (xem docstring dau file): "if status == running" khong doi status,
# nhung changed.emit() nam NGOAI if nen van chay du khong co gi doi tren
# handle nay.
h = _seed(manager, "run1")
h.status = "stopped"
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_event("run1", {"type": "run_done", "ok": False})
assert h.status == "stopped"
assert len(changed) == 1
def test_on_event_unknown_run_id_does_not_raise_and_still_reemits_event(manager):
received = []
manager.event.connect(lambda rid, ev: received.append((rid, ev)))
manager._on_event("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})
assert received == [("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})]
def test_on_event_none_payload_on_unknown_run_id_is_coerced_to_empty_dict_by_qt(manager):
# quirk (xem docstring dau file): event = Signal(str, dict) ep None thanh
# {} ngay tai diem emit -- khac han lop MOI (khong con Signal) nhan dung
# None goc. Day la khac biet CO CHU Y giua ban cu va ban moi, khong phai
# bug can sua.
received = []
manager.event.connect(lambda rid, ev: received.append((rid, ev)))
manager._on_event("no-such-run", None)
assert received == [("no-such-run", {})]
def test_on_event_none_payload_on_known_run_id_does_not_mutate_handle(manager):
h = _seed(manager, "run1")
received = []
manager.event.connect(lambda rid, ev: received.append((rid, ev)))
manager._on_event("run1", None)
assert h.status == "running"
assert h.node_status == {}
assert received == [("run1", {})] # cung bi Qt ep thanh {} nhu tren
# ---------------------------------------------------------------------------
# _on_finished
# ---------------------------------------------------------------------------
def test_on_finished_while_running_settles_to_done(manager):
h = _seed(manager, "run1")
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_finished("run1")
assert h.status == "done"
assert len(changed) == 1
def test_on_finished_when_already_settled_is_a_noop(manager):
h = _seed(manager, "run1")
h.status = "error"
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_finished("run1")
assert h.status == "error"
assert len(changed) == 0
def test_on_finished_unknown_run_id_is_a_total_noop(manager):
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_finished("no-such-run")
assert manager._runs == {}
assert changed == []
# ---------------------------------------------------------------------------
# _on_failed
# ---------------------------------------------------------------------------
def test_on_failed_marks_error_with_message_and_emits_run_error_event(manager):
h = _seed(manager, "run1")
events = []
changed = []
manager.event.connect(lambda rid, ev: events.append((rid, ev)))
manager.changed.connect(lambda: changed.append(1))
manager._on_failed("run1", "boom")
assert h.status == "error"
assert h.error == "boom"
assert events == [("run1", {"type": "run_error", "error": "boom"})]
assert len(changed) == 1
def test_on_failed_overrides_status_even_when_already_settled(manager):
h = _seed(manager, "run1")
h.status = "done"
manager._on_failed("run1", "late failure")
assert h.status == "error"
def test_on_failed_unknown_run_id_is_a_total_noop(manager):
events = []
changed = []
manager.event.connect(lambda rid, ev: events.append((rid, ev)))
manager.changed.connect(lambda: changed.append(1))
manager._on_failed("no-such-run", "err")
assert events == []
assert changed == []
# ---------------------------------------------------------------------------
# persistence: hook -> dia THAT (trong sandbox cua rieng tung test) -> reload
# ---------------------------------------------------------------------------
def test_changed_hook_persists_to_history_file(manager, tmp_path):
_seed(manager, "run1")
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
path = tmp_path / "run_history.json"
assert path.exists()
import json
data = json.loads(path.read_text(encoding="utf-8"))
assert len(data["runs"]) == 1
assert data["runs"][0]["id"] == "run1"
assert data["runs"][0]["status"] == "running"
def test_reloading_manager_after_hook_settles_running_to_stopped(tmp_path, monkeypatch):
history_path = tmp_path / "run_history.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: history_path)
mgr1 = Co4ERunManager(_Ctx())
_seed(mgr1, "run1")
mgr1._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
mgr2 = Co4ERunManager(_Ctx())
assert "run1" in mgr2._runs
assert mgr2._runs["run1"].status == "stopped"
assert mgr2._seq == 1
def test_reloaded_seq_avoids_colliding_with_history_ids(tmp_path, monkeypatch):
history_path = tmp_path / "run_history.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: history_path)
mgr1 = Co4ERunManager(_Ctx())
_seed(mgr1, "run7")
mgr1._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
mgr2 = Co4ERunManager(_Ctx())
assert mgr2._seq == 7
assert mgr2._next_id() == "run8"
def test_load_history_missing_file_is_silent_noop(tmp_path, monkeypatch):
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: tmp_path / "does-not-exist.json")
mgr = Co4ERunManager(_Ctx())
assert mgr._runs == {}
assert mgr._seq == 0
@@ -0,0 +1,524 @@
"""Characterization test cho phần DỰNG WIDGET của trang "Runs" trong Co4E
(``ui/co4e_tab.py``, method ``_build_runs_page``, dòng 869-928).
VÒNG ĐỜI: đây là giàn giáo (scaffolding), KHÔNG phải đặc tả cuối cùng. Mục
đích DUY NHẤT là lưới an toàn cho đợt tách "Run Control" sắp tới (xem
``docs/architecture/co4e-split-map-run-control.md`` — dự kiến ``_build_runs_page``
sẽ dời sang ``presentation/co4e/co4e_run_control_widget.py``). Sau khi việc
tách hoàn tất và ổn định, các test ở đây nên được viết lại thành test đặc tả
(specification test) cho widget/API mới; quirk nào liệt kê dưới đây nên có
issue riêng nếu ai đó muốn "dọn" chúng — ĐỪNG tự sửa code sản phẩm để "dọn"
quirk khi đọc thấy test này.
PHẠM VI: CHỈ ``_build_runs_page`` — phần dựng ``QWidget``/``runs_table`` (5
cột) + 7 widget con (``runs_back_btn``, ``runs_title``, ``ws_folder_btn``,
``run_stop_btn``, ``run_rename_btn``, ``run_del_btn``, ``run_clear_btn``) và
việc NỐI (connect) các signal của chúng tới các method xử lý. CÁC METHOD XỬ LÝ
(``_refresh_runs``, ``_stop_selected_run``, ``_delete_selected_run``,
``_runs_context_menu``, ``_rename_selected_run``, ``_open_run_from_table``,
``_open_workspace_folder``, ``_refresh_ws_folder_btn``) VẪN Ở NGUYÊN trên
``Co4ETab`` và KHÔNG được characterize ở đây (đề bài giao đúng phạm vi
constructor cho lượt này) — test này chỉ xác nhận rằng bấm nút/emit signal
tương ứng CÓ gọi tới đúng method trên ``self`` (qua fake/stub), không xác
nhận method đó làm gì bên trong.
CÁCH DỰNG: ``_build_runs_page`` là instance method cần rất nhiều state của
``Co4ETab`` thật (``self.ctx``, ``self.manager``, hàng chục method khác) để
dựng trọn vẹn — dựng cả ``Co4ETab`` chỉ để test 60 dòng constructor này là
tốn kém và kéo theo rủi ro chạm những phần KHÔNG thuộc phạm vi. Nên ở đây
gọi thẳng ``Co4ETab._build_runs_page(fake_self)`` (unbound, theo đúng gợi ý
của đề bài) trên một ``_FakeTab`` tối giản: chỉ có ``ctx``/``_project_dir``/
``manager`` (để ``_flow_output_root``/``_refresh_ws_folder_btn`` — hai
method DÙNG THẬT của ``Co4ETab``, được gọi ngay TRONG lúc dựng ở dòng 893 —
chạy được) và các method xử lý còn lại được thay bằng stub ghi lại số lần gọi
(KHÔNG gọi ``Co4ERunManager.start()``/dựng ``QThread``/``AgentWorker`` thật —
đúng ràng buộc "không spawn thread/gọi provider thật").
AN TOÀN DỮ LIỆU: dù bước dựng widget này tự nó KHÔNG chạm đĩa, việc gọi
``_flow_output_root()``/``_refresh_ws_folder_btn()`` NGAY trong lúc dựng lại
tính ``CONFIG_DIR`` (qua ``cowork_local.config.CONFIG_DIR = Path.home() /
".cowork_local"``, xuyên qua ``core.co4e.CO4E_DIR``) — nên toàn bộ probe chạy
trong TIẾN TRÌNH CON riêng (tránh xung đột ``QApplication`` singleton với các
test khác trong cùng lượt chạy pytest) với ``HOME``/``USERPROFILE`` trỏ vào
một ``tmp_path`` sandbox ĐẶT TRƯỚC khi script import bất kỳ thứ gì thuộc
``cowork_local`` (đúng kỹ thuật ``tools/capture_screens.py::_isolate_home()``:
đặt ``USERPROFILE``/``HOME``, xoá ``HOMEDRIVE``/``HOMEPATH``), và
``QT_QPA_PLATFORM=offscreen`` được đặt TRƯỚC khi import PySide6 (đúng khuôn
``tools/check_co4e.py`` dòng 22+40-42). Một assert ngay trong tiến trình con
chốt ``CONFIG_DIR`` nằm trong sandbox trước khi làm gì khác (kiểu
``tools/check_co4e.py:47``).
CẦN QApplication: ``_build_runs_page`` dựng ``QWidget``/``QTableWidget`` thật,
đọc ``.text()``/``.toolTip()``/``.icon()``/``.cursor()`` và bấm nút thật qua
``.click()`` — không phải kiểu giá trị thuần, nên bắt buộc ``QApplication``
(``offscreen``), khác các test hình học thuần (``test_co4e_canvas_geometry.py``).
CÁCH CHỐT ASSERT: mọi giá trị dưới đây lấy bằng cách CHẠY code thật (script
``_PROBE_SCRIPT`` bên dưới, qua ``.venv/Scripts/python.exe``, giống hệt lệnh ở
cuối file) rồi dán NGUYÊN VĂN JSON in được vào assert — không suy luận lý
thuyết. Các chuỗi hiển thị phụ thuộc ``tr()`` (tiếng Việt mặc định) được so
sánh bằng cách gọi LẠI ``tr()`` thật ngay trong tiến trình test (không cần
QApplication, ``i18n.py`` là tra bảng thuần) thay vì chép tay chuỗi có dấu —
tránh gõ nhầm ký tự Unicode khi transcribe.
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới — ĐỪNG "dọn" các chỗ này khi
tách, chúng trông như bất đối xứng khó hiểu nhưng là hành vi đang chạy thật
hôm nay):
* ``run_clear_btn`` là nút DUY NHẤT trong 5 nút hàng thao tác KHÔNG có
``setIcon(...)`` — icon của nó luôn rỗng (``.icon().isNull() is True``),
khác hẳn ``run_stop_btn``/``run_rename_btn``/``run_del_btn``/
``ws_folder_btn`` đều có icon SVG built-in.
* ``run_clear_btn.clicked`` nối THẲNG tới
``lambda: self.manager.clear_finished()`` — gọi trực tiếp method trên
``manager``, KHÔNG đi qua một method riêng trên ``self`` như 3 nút hàng
xóm (``_stop_selected_run``/``_rename_selected_run``/
``_delete_selected_run``) — không có lớp bọc nào để thêm xác nhận
(confirm dialog) sau này mà không sửa trực tiếp dòng connect này.
* ``ws_folder_btn`` text được dựng bằng ``"…/" + "/".join(parts[-2:])``
(đúng 3 ký tự: dấu chấm lửng Unicode U+2026 rồi dấu gạch chéo xuôi) khi
đường dẫn có hơn 2 phần — dùng ``"/"`` LUÔN LUÔN, kể cả trên Windows
(khác separator ``\\`` của phần còn lại của path), và không có fallback
hiển thị full path trừ khi ``len(parts) <= 2``.
* ``_flow_output_root()`` (được ``_refresh_ws_folder_btn()`` gọi NGAY
trong lúc dựng, dòng 893) bọc ``ctx.config.cowork_output_dir()`` trong
``except Exception`` RỘNG — bất kỳ lỗi nào từ ``ctx.config`` (kể cả
``AttributeError`` vì ``ctx`` không có ``.config`` như trong probe này)
đều rơi vào nhánh fallback ``co4e.CO4E_DIR / "runs"`` một cách im lặng,
không log, không báo cho người gọi biết đã fallback.
* ``runs_table`` sau khi dựng xong LUÔN có 0 hàng bất kể ``manager`` đang
có run nào hay không — ``_build_runs_page`` không tự seed dữ liệu, việc
đó thuộc về ``_refresh_runs`` (method riêng, KHÔNG thuộc phạm vi test
này) — ai tách file mà tưởng constructor này "phải" gọi ``_refresh_runs``
luôn thì sẽ đổi hành vi so với hôm nay.
Lệnh thủ công đã dùng để chốt các con số trên (quy trình ngược, xem cuối file
để chạy lại nếu cần chốt lại sau khi code đổi có chủ đích).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_PROBE_SCRIPT = r"""
import json
import os
import sys
from pathlib import Path
sandbox = sys.argv[1]
repo_parent = sys.argv[2]
sys.path.insert(0, repo_parent)
os.environ["QT_QPA_PLATFORM"] = "offscreen"
for var in ("USERPROFILE", "HOME"):
os.environ[var] = sandbox
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
from PySide6.QtCore import QPoint, Qt
from PySide6.QtWidgets import (
QApplication, QHeaderView, QTableWidget, QTableWidgetItem,
)
app = QApplication([])
from cowork_local.config import CONFIG_DIR
assert str(Path(sandbox).resolve()) in str(CONFIG_DIR.resolve()), (
"khong co lap: CONFIG_DIR=" + str(CONFIG_DIR))
from cowork_local.ui.co4e_tab import Co4ETab
result = {}
calls = {"show_runs": [], "stop": 0, "rename": 0, "delete": 0, "open_run": 0,
"ws_open": 0, "ctx_menu": 0}
class _Manager:
# Stand-in cho Co4ERunManager - KHONG dung QThread/AgentWorker that.
def __init__(self):
self.clear_finished_calls = 0
def clear_finished(self):
self.clear_finished_calls += 1
class _Config:
def cowork_output_dir(self):
# co tinh nem loi de _flow_output_root roi vao nhanh fallback
# (quirk duoc dong dinh trong docstring cua file test).
raise RuntimeError("no config in probe")
class _Ctx:
def __init__(self):
self.config = _Config()
class _FakeTab:
# Chi mang du state de goi duoc Co4ETab._build_runs_page(self) unbound -
# KHONG dung ca Co4ETab that (tranh keo theo sidebar/canvas/config panel
# ngoai pham vi).
# Hai method NAY la method THAT cua Co4ETab, duoc _build_runs_page goi
# NGAY trong luc dung (dong 893) - phai la ban that, khong stub.
_flow_output_root = Co4ETab._flow_output_root
_refresh_ws_folder_btn = Co4ETab._refresh_ws_folder_btn
_build_runs_page = Co4ETab._build_runs_page
def __init__(self):
self.ctx = _Ctx()
self._project_dir = None
self.manager = _Manager()
def _show_runs(self, on):
calls["show_runs"].append(on)
def _stop_selected_run(self):
calls["stop"] += 1
def _rename_selected_run(self):
calls["rename"] += 1
def _delete_selected_run(self):
calls["delete"] += 1
def _open_run_from_table(self, item):
calls["open_run"] += 1
def _open_workspace_folder(self):
calls["ws_open"] += 1
def _runs_context_menu(self, pos):
calls["ctx_menu"] += 1
fake = _FakeTab()
w = fake._build_runs_page()
result["w_type"] = type(w).__name__
result["layout_type"] = type(w.layout()).__name__
result["table_is_child_of_w"] = w.findChild(type(fake.runs_table)) is fake.runs_table
result["col_count"] = fake.runs_table.columnCount()
result["row_count"] = fake.runs_table.rowCount()
result["header_resize_mode_is_stretch"] = (
fake.runs_table.horizontalHeader().sectionResizeMode(0) == QHeaderView.Stretch
)
result["vheader_visible"] = fake.runs_table.verticalHeader().isVisible()
# isVisible() luon False vi 'w' khong bao gio duoc .show() trong probe nay
# (widget khong nam trong mot top-level dang hien thi that) - dung
# isVisibleTo(ancestor) de bat dung co hidden explicit da duoc setVisible(...)
# dat tren verticalHeader, khong phu thuoc chuoi ancestor co duoc show hay
# khong. Xem PySide6 doc QWidget.isVisibleTo(): tra ve True/False dua tren co
# WA_WState_Hidden explicit cua chinh widget do (va cac ancestor tinh den
# truoc 'ancestor'), bat ke ancestor da .show() hay chua.
result["vheader_visible_to_table"] = (
fake.runs_table.verticalHeader().isVisibleTo(fake.runs_table)
)
result["edit_triggers_is_no_edit"] = (
fake.runs_table.editTriggers() == QTableWidget.NoEditTriggers
)
result["selection_behavior_is_select_rows"] = (
fake.runs_table.selectionBehavior() == QTableWidget.SelectRows
)
result["context_menu_policy_is_custom"] = (
fake.runs_table.contextMenuPolicy() == Qt.CustomContextMenu
)
result["table_tooltip"] = fake.runs_table.toolTip()
result["back_btn_text"] = fake.runs_back_btn.text()
result["back_btn_tooltip"] = fake.runs_back_btn.toolTip()
result["back_icon_isnull"] = fake.runs_back_btn.icon().isNull()
result["title_text"] = fake.runs_title.text()
result["title_object_name"] = fake.runs_title.objectName()
result["ws_folder_flat"] = fake.ws_folder_btn.isFlat()
result["ws_folder_cursor_is_pointing_hand"] = (
fake.ws_folder_btn.cursor().shape() == Qt.PointingHandCursor
)
result["ws_folder_text"] = fake.ws_folder_btn.text()
result["ws_folder_tooltip"] = fake.ws_folder_btn.toolTip()
result["ws_folder_icon_isnull"] = fake.ws_folder_btn.icon().isNull()
result["stop_btn_text"] = fake.run_stop_btn.text()
result["stop_btn_object_name"] = fake.run_stop_btn.objectName()
result["stop_btn_tooltip"] = fake.run_stop_btn.toolTip()
result["stop_btn_icon_isnull"] = fake.run_stop_btn.icon().isNull()
result["rename_btn_text"] = fake.run_rename_btn.text()
result["rename_btn_tooltip"] = fake.run_rename_btn.toolTip()
result["rename_btn_icon_isnull"] = fake.run_rename_btn.icon().isNull()
result["del_btn_text"] = fake.run_del_btn.text()
result["del_btn_tooltip"] = fake.run_del_btn.toolTip()
result["del_btn_icon_isnull"] = fake.run_del_btn.icon().isNull()
result["clear_btn_text"] = fake.run_clear_btn.text()
result["clear_btn_tooltip"] = fake.run_clear_btn.toolTip()
result["clear_btn_object_name"] = fake.run_clear_btn.objectName()
result["clear_btn_icon_isnull"] = fake.run_clear_btn.icon().isNull()
# --- bam nut / emit signal that -> xac nhan CO goi dung method tren self ----
fake.runs_back_btn.click()
result["calls_after_back_click"] = list(calls["show_runs"])
fake.ws_folder_btn.click()
result["ws_open_calls_after_click"] = calls["ws_open"]
fake.run_stop_btn.click()
result["stop_calls_after_click"] = calls["stop"]
fake.run_rename_btn.click()
result["rename_calls_after_click"] = calls["rename"]
fake.run_del_btn.click()
result["delete_calls_after_click"] = calls["delete"]
fake.run_clear_btn.click()
result["manager_clear_finished_calls_after_click"] = fake.manager.clear_finished_calls
item = QTableWidgetItem("row0")
fake.runs_table.setRowCount(1)
fake.runs_table.setItem(0, 0, item)
fake.runs_table.itemDoubleClicked.emit(item)
result["open_run_calls_after_dbl_click"] = calls["open_run"]
fake.runs_table.customContextMenuRequested.emit(QPoint(5, 5))
result["ctx_menu_calls_after_signal"] = calls["ctx_menu"]
result["flow_output_root_fallback"] = str(fake._flow_output_root())
result["config_dir"] = str(CONFIG_DIR)
print(json.dumps(result, sort_keys=True, ensure_ascii=True))
print("PROBE_OK")
"""
@pytest.fixture(scope="module")
def probe_result(tmp_path_factory):
"""Chạy ``_PROBE_SCRIPT`` một lần cho cả module trong TIẾN TRÌNH CON, trả
về dict JSON đã in được cùng ``sandbox`` đã dùng (để test tính lại các giá
trị phụ thuộc đường dẫn, thay vì chép tay chuỗi tuyệt đối)."""
sandbox = tmp_path_factory.mktemp("co4e-runs-page-home")
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
result = subprocess.run(
[sys.executable, "-c", _PROBE_SCRIPT, str(sandbox), str(REPO_PARENT)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"probe co4e runs page that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
assert "PROBE_OK" in result.stdout, result.stdout
json_line = result.stdout.strip().splitlines()[-2]
data = json.loads(json_line)
data["_sandbox"] = str(sandbox)
return data
# ---------------------------------------------------------------------------
# runs_table: cau truc/co so mac dinh
# ---------------------------------------------------------------------------
def test_runs_table_has_5_columns_and_starts_empty(probe_result):
assert probe_result["col_count"] == 5
# quirk: _build_runs_page KHONG tu seed hang nao, du manager co run hay
# khong - seed la viec cua _refresh_runs (ngoai pham vi test nay).
assert probe_result["row_count"] == 0
def test_runs_table_is_parented_into_the_returned_widget(probe_result):
# DA CAP NHAT sau khi tach "Run Control" (xem
# docs/architecture/co4e-split-map-run-control.md): _build_runs_page gio
# tra ve mot RunsPagePanel (subclass QWidget dung trong presentation/co4e/
# co4e_run_control_widget.py) thay vi mot QWidget tran - van la mot QWidget
# that su (layout/table van nguyen), chi ten class cu the doi.
assert probe_result["w_type"] == "RunsPagePanel"
assert probe_result["layout_type"] == "QVBoxLayout"
assert probe_result["table_is_child_of_w"] is True
def test_runs_table_display_settings(probe_result):
assert probe_result["header_resize_mode_is_stretch"] is True
# KHONG dung "vheader_visible" (QWidget.isVisible()) o day: gia tri do
# luon False bat ke setVisible(True/False) trong code san pham, vi
# runs_table/verticalHeader khong bao gio duoc .show() thuc su trong luc
# probe (isVisible() phu thuoc CA chuoi ancestor co dang hien tren man
# hinh hay khong). Dung "vheader_visible_to_table" (isVisibleTo(ancestor))
# de bat dung co explicit hidden ma setVisible(False) dat len
# verticalHeader, khong phu thuoc runs_table co duoc show hay khong -
# mutation setVisible(False) -> True lam gia tri nay lat tu False len True.
assert probe_result["vheader_visible_to_table"] is False
assert probe_result["edit_triggers_is_no_edit"] is True
assert probe_result["selection_behavior_is_select_rows"] is True
assert probe_result["context_menu_policy_is_custom"] is True
def test_runs_table_tooltip_matches_tr_key(probe_result):
from cowork_local.i18n import tr
assert probe_result["table_tooltip"] == tr("co4e.tt_runs_list")
# ---------------------------------------------------------------------------
# runs_back_btn
# ---------------------------------------------------------------------------
def test_back_button_text_tooltip_and_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["back_btn_text"] == tr("co4e.back_to_flow")
assert probe_result["back_btn_tooltip"] == tr("co4e.tt_back_to_flow")
assert probe_result["back_icon_isnull"] is False
def test_back_button_click_calls_show_runs_with_false(probe_result):
# _build_runs_page noi runs_back_btn.clicked -> lambda: self._show_runs(False)
assert probe_result["calls_after_back_click"] == [False]
# ---------------------------------------------------------------------------
# runs_title
# ---------------------------------------------------------------------------
def test_runs_title_text_and_object_name(probe_result):
from cowork_local.i18n import tr
assert probe_result["title_text"] == tr("co4e.running_flows")
assert probe_result["title_object_name"] == "hint"
# ---------------------------------------------------------------------------
# ws_folder_btn
# ---------------------------------------------------------------------------
def test_ws_folder_button_is_flat_with_pointing_hand_cursor(probe_result):
assert probe_result["ws_folder_flat"] is True
assert probe_result["ws_folder_cursor_is_pointing_hand"] is True
assert probe_result["ws_folder_icon_isnull"] is False
def test_ws_folder_button_text_uses_ellipsis_and_forward_slash_regardless_of_os(
probe_result,
):
# quirk: "…/" (dau cham lung That, KHONG phai 3 dau cham thuong) roi
# noi 2 phan cuoi cua path bang "/" luon luon - kha nang fallback rong day
# du duoc kich hoat boi ctx.config gia lap khong co .config that.
assert probe_result["ws_folder_text"] == "…/runs/co4e"
def test_ws_folder_button_tooltip_embeds_the_fallback_root_path(probe_result):
from cowork_local.i18n import tr
root = str(Path(probe_result["config_dir"]) / "co4e" / "runs" / "co4e")
assert probe_result["ws_folder_tooltip"] == tr(
"co4e.tt_open_workspace", path=root
)
assert probe_result["flow_output_root_fallback"] == root
def test_ws_folder_button_click_calls_open_workspace_folder(probe_result):
assert probe_result["ws_open_calls_after_click"] == 1
def test_config_dir_is_isolated_inside_the_sandbox(probe_result):
# chot lai rang qua trinh dung widget khong lam chuyen sang HOME that.
assert probe_result["_sandbox"] in probe_result["config_dir"]
# ---------------------------------------------------------------------------
# run_stop_btn / run_rename_btn / run_del_btn / run_clear_btn
# ---------------------------------------------------------------------------
def test_stop_button_text_object_name_tooltip_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["stop_btn_text"] == tr("co4e.stop")
assert probe_result["stop_btn_object_name"] == "danger"
assert probe_result["stop_btn_tooltip"] == tr("co4e.tt_stop_run")
assert probe_result["stop_btn_icon_isnull"] is False
def test_stop_button_click_calls_stop_selected_run(probe_result):
assert probe_result["stop_calls_after_click"] == 1
def test_rename_button_text_tooltip_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["rename_btn_text"] == tr("co4e.rename_run")
assert probe_result["rename_btn_tooltip"] == tr("co4e.tt_rename_run")
assert probe_result["rename_btn_icon_isnull"] is False
def test_rename_button_click_calls_rename_selected_run(probe_result):
assert probe_result["rename_calls_after_click"] == 1
def test_delete_button_text_tooltip_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["del_btn_text"] == tr("co4e.delete_run")
assert probe_result["del_btn_tooltip"] == tr("co4e.tt_delete_run")
assert probe_result["del_btn_icon_isnull"] is False
def test_delete_button_click_calls_delete_selected_run(probe_result):
assert probe_result["delete_calls_after_click"] == 1
def test_clear_button_text_tooltip_and_no_object_name(probe_result):
from cowork_local.i18n import tr
assert probe_result["clear_btn_text"] == tr("co4e.clear_done")
assert probe_result["clear_btn_tooltip"] == tr("co4e.tt_clear_runs")
# quirk: khac 4 nut hang xom, run_clear_btn khong setObjectName.
assert probe_result["clear_btn_object_name"] == ""
def test_clear_button_has_no_icon_unlike_its_siblings(probe_result):
# quirk: run_clear_btn la nut DUY NHAT trong hang khong co setIcon(...).
assert probe_result["clear_btn_icon_isnull"] is True
def test_clear_button_click_calls_manager_clear_finished_directly(probe_result):
# quirk: noi THANG toi lambda: self.manager.clear_finished() - khong di
# qua mot method rieng tren self nhu 3 nut hang xom (stop/rename/delete).
assert probe_result["manager_clear_finished_calls_after_click"] == 1
# ---------------------------------------------------------------------------
# runs_table: double-click / context-menu signals
# ---------------------------------------------------------------------------
def test_double_click_on_a_row_calls_open_run_from_table(probe_result):
assert probe_result["open_run_calls_after_dbl_click"] == 1
def test_context_menu_request_calls_runs_context_menu(probe_result):
assert probe_result["ctx_menu_calls_after_signal"] == 1
# ---------------------------------------------------------------------------
# Lenh thu cong da dung de chot cac gia tri JSON o tren (quy trinh nguoc):
#
# .venv/Scripts/python.exe -c "<noi dung _PROBE_SCRIPT>" <sandbox> <repo_parent>
#
# voi <sandbox> la mot thu muc rong duoc gan vao HOME/USERPROFILE TRUOC khi
# script import bat ky thu gi thuoc cowork_local, va <repo_parent> la thu muc
# cha cua repo (de "import cowork_local" hoat dong dung nhu conftest.py lam).
# ---------------------------------------------------------------------------
@@ -0,0 +1,309 @@
"""Characterization test cho khu vực SKILLS trong sidebar của ``Co4ETab``
(``ui/co4e_tab.py``): ``sk_manage_btn``/``skill_list``/``_manage_skills`` (nối
click) và phần "populate skill_list" bên trong ``_reload_sidebar`` — đúng các
đoạn được giao: dòng 569-580 (dựng widget qua ``SkillsListPanel`` + nối
signal), 602 (vòng lặp ``setMinimumHeight(56)`` dùng CHUNG cho
``wf_list``/``agent_list``/``skill_list``/``runs_side_list``), 690-723
(``_reload_sidebar``, CHỈ đoạn skill) và 1370-1374 (``_manage_skills``).
VÌ SAO GHI LẠI CHỨ KHÔNG PHÁN XÉT: đây là lưới an toàn cho đợt tách
``ui/co4e_tab.py`` (2000+ dòng) thành các module con dưới ``presentation/co4e/``
(xem ``docs/architecture/co4e-split-map.md`` — khu vực SKILLS đã dời phần
DỰNG WIDGET sang ``presentation/co4e/skills_list_panel.py::SkillsListPanel``,
còn phần NỐI SIGNAL + populate vẫn ở ``Co4ETab``). Mọi ``assert`` dưới đây
được chốt lại từ giá trị THẬT in ra khi chạy code (quy trình ngược: chạy
trước, in ra, dán vào assert) — không phải giá trị tôi nghĩ nó "nên" là gì.
VÌ SAO CHẠY TRONG TIẾN TRÌNH CON CÔ LẬP HOME: giống hệt kỹ thuật của
``tests/test_build_co4e_tab.py`` (đọc docstring đầu file đó để thấy khuôn gốc)
và ``tests/characterization/test_co4e_agent_panel.py`` — dựng ``Co4ETab`` thật
kéo theo ``AppConfig``/``CONFIG_DIR`` (config.py) và ``SKILLS_DIR``
(core/skills.py) đều là hằng số module tính MỘT LẦN lúc import từ
``Path.home()``. Monkeypatch thuộc tính module SAU khi import không đủ (còn
``AppConfig.load()`` có tham số mặc định đóng băng lúc định nghĩa hàm — xem
``test_build_co4e_tab.py``), nên phải cô lập ``HOME``/``USERPROFILE`` TRƯỚC
bất kỳ import ``cowork_local.*`` nào, trong một tiến trình con sạch hoàn toàn.
AN TOÀN DỮ LIỆU: script con assert ``str(CONFIG_DIR).startswith(sandbox)``
NGAY sau khi import, trước khi ghi bất kỳ file skill nào xuống
``skills_mod.SKILLS_DIR`` (``CONFIG_DIR / "skills"``) — chạy nhầm trên máy
thật sẽ ghi/xoá skill thật của người dùng.
KHÔNG gọi provider AI thật / spawn thread thật: test này không đụng tới
``Co4ERunManager``/``AgentWorker``. ``SkillsDialog.exec()`` (modal, sẽ treo
tiến trình headless) được monkeypatch thành một lớp giả NGAY TRONG tiến trình
con của test — không sửa code sản phẩm; lớp giả chỉ đếm số lần được gọi và trả
về một giá trị falsy (giống bấm Cancel) để lộ ra quirk "luôn reload" bên dưới.
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới):
* ``skill_list`` không lọc theo ``Skill.enabled`` — ``_skill_names()`` gọi
``list_skills() + builtin_skills()`` không quan tâm cờ ``enabled``, nên
một skill được TẠO nhưng chưa được người dùng tick bật (``enabled=False``)
vẫn xuất hiện trong danh sách kéo-thả của Co4E y hệt một skill đã bật.
Cờ ``enabled`` chỉ ảnh hưởng nơi khác (``active_skills_text`` cho chat),
không ảnh hưởng palette này.
* Payload kéo-thả của một skill KHÔNG chứa ``instructions`` thô của skill,
mà chứa nguyên khối trả về bởi ``skills_mod.skill_prefix_for(name)`` —
tức đã có tiền tố ``"## Skill: <tên>\\n"`` dán trước nội dung. Ai "dọn"
chỗ này để dùng thẳng ``skill.instructions`` sẽ làm mọi flow kéo-thả sẵn
mất dòng tiêu đề đó.
* Khi skill có ``instructions`` rỗng, ``skill_prefix_for`` trả về chuỗi rỗng
(không phải ``None``, không ném lỗi) — payload kéo-thả của skill đó có
``instructions == ""`` dù skill vẫn hiện trong danh sách với đúng tên.
* ``_manage_skills`` LUÔN gọi ``self._reload_sidebar()`` sau
``SkillsDialog(...).exec()``, bất kể dialog trả về gì (khác với
``_new_agent``/``_edit_agent`` ở khu vực AGENTS, nơi chỉ reload khi
``dlg.exec()`` truthy) — đóng Skills manager bằng Cancel/Esc vẫn khiến
``skill_list`` bị dựng lại từ đĩa.
* Thư mục skills không tồn tại (chưa từng tạo skill nào) không ném lỗi:
``_skill_names()`` có ``try/except`` bọc quanh, trả về ``[]`` một cách im
lặng — ``skill_list`` rỗng, không có thông báo lỗi nào cho người dùng.
VÒNG ĐỜI: đây là giàn giáo cho đợt tách phần NỐI SIGNAL + populate của khu vực
Skills sang ``presentation/co4e/`` (dựng widget đã tách xong thành
``SkillsListPanel`` — xem cột "Trạng thái" dòng liên quan trong
``docs/architecture/co4e-split-map.md``). Sau khi phần còn lại được tách (ví
dụ một ``SkillsListController`` biết ``_manage_skills``/populate mà không cần
biết toàn bộ ``Co4ETab``), các case ở đây nên viết lại thành test đặc tả cho
controller mới (input rõ ràng, không cần dựng cả ``Co4ETab``/``QApplication``
nặng nề qua subprocess). Quirk "payload dùng khối có tiền tố thay vì
instructions thô" và quirk "luôn reload dù Cancel" đáng mở issue hỏi ý kiến
sản phẩm trước khi ai đó "dọn" chúng trong lúc tách.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import json
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from PySide6.QtWidgets import QAbstractItemView, QApplication
from PySide6.QtCore import Qt
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.core import skills as skills_mod
from cowork_local.ui.co4e_tab import Co4ETab, _PaletteList, _skill_names
from cowork_local.ui.icons import icon as _icon
from cowork_local.i18n import tr
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
tab = Co4ETab(ctx)
# ---- 569-580: widget cua khu vuc SKILLS duoc dung qua SkillsListPanel ------
assert tab.sk_manage_btn.text() == tr("co4e.manage_skills"), tab.sk_manage_btn.text()
assert tab.sk_manage_btn.toolTip() == tr("co4e.tt_manage_skills"), tab.sk_manage_btn.toolTip()
assert tab.sk_manage_btn.objectName() == "co4eSectionAction"
assert tab.sk_manage_btn.isFlat() is True
assert tab.sk_manage_btn.cursor().shape() == Qt.PointingHandCursor
assert isinstance(tab.skill_list, _PaletteList), type(tab.skill_list)
assert tab.skill_list.dragEnabled() is True
assert tab.skill_list.dragDropMode() == QAbstractItemView.DragOnly
print("CASE_WIDGETS_OK")
# ---- 602: bon danh sach dung CHUNG mot vong lap setMinimumHeight(56) -------
heights = {
"wf_list": tab.wf_list.minimumHeight(),
"agent_list": tab.agent_list.minimumHeight(),
"skill_list": tab.skill_list.minimumHeight(),
"runs_side_list": tab.runs_side_list.minimumHeight(),
}
assert heights == {
"wf_list": 56, "agent_list": 56, "skill_list": 56, "runs_side_list": 56,
}, heights
print("CASE_MIN_HEIGHT_SHARED_OK")
# ---- quirk: chua tao skill nao -> thu muc SKILLS_DIR khong ton tai ---------
# _skill_names() boc try/except quanh list_skills()/builtin_skills(); thu muc
# chua ton tai (Co4ETab.__init__ khong tu tao no) khong nem loi, tra ve rong.
assert not skills_mod.SKILLS_DIR.exists(), skills_mod.SKILLS_DIR
assert _skill_names() == []
tab._reload_sidebar()
assert tab.skill_list.count() == 0, tab.skill_list.count()
print("CASE_EMPTY_DIR_OK")
# ---- 690-723 (doan skill): mot skill co instructions, da bat enabled -------
skills_mod.SKILLS_DIR.mkdir(parents=True, exist_ok=True)
(skills_mod.SKILLS_DIR / "s1.json").write_text(json.dumps({
"name": "Viet test",
"description": "desc 1",
"instructions": "Luon viet test",
"enabled": True,
}), encoding="utf-8")
tab._reload_sidebar()
assert tab.skill_list.count() == 1, tab.skill_list.count()
it0 = tab.skill_list.item(0)
assert it0.text() == "Viet test", it0.text()
assert it0.icon().isNull() is False
# Dong lo hong da bi mutation test bat duoc (xem docs/architecture/
# co4e-refactor-run-report.md muc 4, luot 3): doi icon_name truyen vao
# _palette_item(name, "sparkle", payload) tu "sparkle" sang "robot" o
# ui/co4e_tab.py (dong _reload_sidebar, khu vuc SKILLS) truoc day KHONG bi bat,
# vi assert cu chi kiem "co icon" (isNull() is False) ma khong kiem la icon NAO.
# So sanh pixmap that (icon() la ham thuan, cung name/size/color -> cung anh)
# thay vi so QIcon truc tiep (QIcon khong dinh nghia __eq__ theo noi dung).
assert it0.icon().pixmap(16, 16).toImage() == _icon("sparkle").pixmap(16, 16).toImage(), (
"icon cua skill trong palette phai dung 'sparkle' (_palette_item(name, 'sparkle', payload))"
)
payload0 = it0.data(Qt.UserRole)
assert payload0 == {
"variant": "step", "label": "Viet test", "agent_slug": "viet-test",
"role": "SKILL", "icon": "sparkle",
"instructions": "## Skill: Viet test\nLuon viet test",
"context": "", "model": "", "self_verify": True, "max_verify_rounds": 1,
"permission_preset": "full", "skills": ["Viet test"], "attachments": [],
"sub_agents": [],
}, payload0
assert it0.data(Qt.UserRole + 1) is None
print("CASE_ONE_SKILL_OK")
# ---- quirk: skill CHUA duoc bat (enabled=False) van hien trong palette -----
# list_skills()/builtin_skills() khong loc theo enabled - _skill_names() lay
# ca hai, nen mot skill "tat" van keo-tha duoc tu Co4E y het skill "bat".
(skills_mod.SKILLS_DIR / "s2-disabled.json").write_text(json.dumps({
"name": "Chua bat",
"description": "",
"instructions": "Noi dung chua bat",
"enabled": False,
}), encoding="utf-8")
tab._reload_sidebar()
assert tab.skill_list.count() == 2, tab.skill_list.count()
names = [tab.skill_list.item(i).text() for i in range(tab.skill_list.count())]
assert names == ["Viet test", "Chua bat"], names
it1 = tab.skill_list.item(1)
payload1 = it1.data(Qt.UserRole)
assert payload1["instructions"] == "## Skill: Chua bat\nNoi dung chua bat", payload1
print("CASE_DISABLED_SKILL_STILL_SHOWN_QUIRK_OK")
# ---- quirk: skill co instructions RONG -> payload instructions == "" ------
# skill_prefix_for() tra ve "" khi instructions rong (khong None, khong loi);
# skill van hien dung ten trong danh sach.
(skills_mod.SKILLS_DIR / "s3-empty-instr.json").write_text(json.dumps({
"name": "Rong noi dung",
"description": "",
"instructions": "",
"enabled": True,
}), encoding="utf-8")
tab._reload_sidebar()
assert tab.skill_list.count() == 3, tab.skill_list.count()
it2 = tab.skill_list.item(2)
assert it2.text() == "Rong noi dung", it2.text()
payload2 = it2.data(Qt.UserRole)
assert payload2["instructions"] == "", payload2
assert payload2["skills"] == ["Rong noi dung"], payload2
print("CASE_EMPTY_INSTRUCTIONS_QUIRK_OK")
# ---- xoa het skill roi reload lai -> khong con dong nao (khong con sot) ----
for p in skills_mod.SKILLS_DIR.glob("*.json"):
p.unlink()
tab._reload_sidebar()
assert tab.skill_list.count() == 0, tab.skill_list.count()
print("CASE_CLEAR_ON_RELOAD_OK")
# ---- 1370-1374: _manage_skills() mo SkillsDialog(self, self.ctx) -----------
import cowork_local.ui.skills_dialog as skills_dialog_mod
dialog_calls = []
class _FakeSkillsDialog:
def __init__(self, parent, ctx_arg):
dialog_calls.append((parent is tab, ctx_arg is ctx))
def exec(self):
return 0 # falsy, gia lap bam Cancel/Esc
orig_dialog_cls = skills_dialog_mod.SkillsDialog
skills_dialog_mod.SkillsDialog = _FakeSkillsDialog
reload_calls = {"n": 0}
orig_reload = tab._reload_sidebar
def _counting_reload():
reload_calls["n"] += 1
return orig_reload()
tab._reload_sidebar = _counting_reload
tab._manage_skills()
skills_dialog_mod.SkillsDialog = orig_dialog_cls
tab._reload_sidebar = orig_reload
assert dialog_calls == [(True, True)], dialog_calls
# quirk: dialog tra ve gia tri falsy (Cancel) nhung sidebar VAN duoc reload.
assert reload_calls["n"] == 1, reload_calls
print("CASE_MANAGE_SKILLS_ALWAYS_RELOADS_QUIRK_OK")
# ---- 573: sk_manage_btn.clicked noi thang toi _manage_skills ---------------
recorded = []
tab._manage_skills = lambda: recorded.append(1)
tab.sk_manage_btn.click()
assert recorded == [1], recorded
print("CASE_CLICK_WIRES_TO_MANAGE_SKILLS_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_co4e_skills_panel_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_WIDGETS_OK",
"CASE_MIN_HEIGHT_SHARED_OK",
"CASE_EMPTY_DIR_OK",
"CASE_ONE_SKILL_OK",
"CASE_DISABLED_SKILL_STILL_SHOWN_QUIRK_OK",
"CASE_EMPTY_INSTRUCTIONS_QUIRK_OK",
"CASE_CLEAR_ON_RELOAD_OK",
"CASE_MANAGE_SKILLS_ALWAYS_RELOADS_QUIRK_OK",
"CASE_CLICK_WIRES_TO_MANAGE_SKILLS_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi cac file skill test tu tao xuong SKILLS_DIR
# (khong co du lieu nguoi dung that nao bi cham vao) - CONFIG_DIR nam
# trong sandbox nhu da assert ngay dau script.
assert (sandbox / ".cowork_local").exists()
@@ -0,0 +1,463 @@
"""Characterization test cho ``StepConfigPanel`` (``ui/co4e_config_panel.py``,
dong 133-528) — KHONG bao gom ``_SectionHeader``/``_add_section`` (khung UI
chung, khong co hanh vi nghiep vu rieng dang characterize).
VI SAO GHI LAI CHU KHONG PHAN XET: day la luoi an toan cho dot tach
``StepConfigPanel`` ra khoi ``ui/co4e_config_panel.py`` sang cac module con
duoi ``presentation/co4e/`` (vi du ``node_property_panel.py``,
``node_property_actions_mixin.py``, ``step_config_section.py`` — xem
``docs/architecture/co4e-split-map-node-property.md``). Moi ``assert`` duoi
day duoc chot lai tu gia tri THAT in ra khi chay code that (quy trinh nguoc:
chay truoc, in ra, dan vao assert) — khong phai gia tri "nen" la gi theo suy
doan.
VI SAO CAN TIEN TRINH CON + CO LAP HOME: ban than ``StepConfigPanel.__init__``/
``load_step`` KHONG cham dia/mang, nhung ``_available_agent_names()`` (goi tu
``_add_subagent``/``_edit_subagent``) doc THAT tu
``core.co4e.list_custom_agents()`` duoi ``CONFIG_DIR/co4e/agents`` — mot
hang so module tinh MOT LAN tu ``Path.home()`` luc import. Vi vay HOME/
USERPROFILE phai duoc tro sang thu muc tam TRUOC BAT KY import
``cowork_local.*`` nao, trong mot tien trinh con sach (patch thuoc tinh sau
khi import la khong du) — dung khuon ``tools/capture_screens.py::_isolate_home``
va ``tests/characterization/test_co4e_agent_panel.py``. Tien trinh con rieng
cung tranh xung dot QApplication singleton neu mot tien trinh pytest khac da
tao QApplication trong cung luot chay. ``QT_QPA_PLATFORM=offscreen`` duoc dat
TRUOC khi import PySide6 (dung khuon ``tools/check_co4e.py`` dong 22+40-42);
script con assert ngay ``str(CONFIG_DIR).startswith(sandbox)`` truoc khi goi
bat ky ham co4e nao (giong ``tools/check_co4e.py:47``) — khong co lap la doc
(va co the ghi) du lieu that cua nguoi dung qua ``list_custom_agents()``/
``save_custom_agent()``.
KHONG goi provider AI that / spawn thread that: ``_ai_draft()`` va
``_load_models()`` (dung ``AgentWorker``/``QThread`` that) KHONG duoc goi o
bat ky case nao trong file nay — hai ham do nam NGOAI pham vi duoc giao
(139-462) va can duoc characterize rieng, tach biet, voi worker/thread duoc
gia lap chu khong start that.
QUIRK DA DONG DINH (xem case tuong ung ben duoi):
* ``gen_btn``/``load_models_btn`` duoc set ``setEnabled(ctx is not None)``
ngay trong ``__init__`` (dong 181, 208), NHUNG dong 313 goi
``self.setEnabled(False)`` cho CA PANEL o cuoi ``__init__`` — vi Qt tinh
``isEnabled()`` hieu qua tu ca chuoi ancestor, ca hai nut deu tra ve
``False`` NGAY SAU KHI DUNG XONG bat ke ``ctx`` la gi, cho toi khi
``load_step()`` (hoac ``setEnabled(True)`` truc tiep) bat lai ca panel.
Doc rieng dong 181/208 se de tuong "ctx=None thi nut luon tat, ctx khac
None thi nut luon bat" — sai, ca hai deu tat cho den khi co step duoc nap.
* ``rounds_spin`` = ``QSpinBox(range=1..5)`` nhung code goi
``setValue(max(1, step.max_verify_rounds))`` (dong 330) — voi
``max_verify_rounds=10``, ``max(1, 10) == 10`` nhung ``QSpinBox`` tu kep
ve tran cua no nen gia tri hien thi la ``5``, khong phai ``10``. Doc code
ma khong chay se tuong gia tri duoc giu nguyen.
* ``perm_combo`` voi ``step.permission_preset`` khong nam trong
``PERMISSION_PRESETS`` (nhanh fallback cua ``findData`` tra ve -1) roi ve
index 0 == ``"inherit"`` — im lang, khong bao loi.
* ``load_step`` chi dung skill_names de dung skills_list; mot skill trong
``step.skills`` khong con trong ``skill_names`` (skill da bi xoa khoi
registry) don gian BIEN MAT khoi checklist — va khi ``_on_edit`` chay lan
ke tiep (do BAT KY thay doi field nao khac, khong can dung vao
skills_list), ``s.skills`` duoc GHI DE lai chi bang cac item dang hien
trong checklist, nen ten skill "mo coi" do bi RUNG VINH VIEN khoi
``step.skills`` — mot edit khong lien quan (vi du sua role) am tham xoa
du lieu skill cu.
* ``variant != "parallel"``: ``_parallel_card`` bi an VA ``sub_list.clear()``
duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False
— du liệu van con trong step, chi khong hien tren UI.
* ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG,
code chuyen sang ``QInputDialog.getText`` thay vi ``getItem`` (dong
401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin
+ custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot
nhanh code song.
* ``_edit_subagent``: neu agent hien tai cua sub-agent khong con trong danh
sach ten kha dung (``cur not in names``), dialog mo tai index 0 thay vi
bao loi hay giu nguyen lua chon cu.
VONG DOI: day la gian giao cho dot tach ``StepConfigPanel`` sang
``presentation/co4e/`` (xem ``docs/architecture/co4e-split-map-node-property.md``
cho ke hoach tach cu the). Sau khi tach xong thanh cac lop/mixin voi hop dong
ro rang, cac case o day nen duoc viet lai thanh test dac ta cho tung phan
(khong can dung ca ``QApplication``/tien trinh con neu phan tach ra la logic
thuan). Ba quirk "panel disabled de len enable cua nut", "orphan skill bi xoa
qua mot edit khong lien quan" va "sub_agents khong dong bo voi sub_list khi
doi variant" dang mo issue hoi y kien san pham truoc khi ai do "don" chung
trong luc tach — dac biet quirk orphan-skill, vi day la mot dang mat du lieu
tham lang de bi coi la bug can sua ngay khi gap lai, nhung sua no thay doi
hanh vi luu tru hien co ma khong ai ro co ai dang phu thuoc vao khong.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from pathlib import Path as _PP
from PySide6.QtWidgets import QApplication, QInputDialog, QFileDialog, QListWidgetItem
from PySide6.QtCore import Qt
from cowork_local.config import CONFIG_DIR
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(str(_PP(sandbox))), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
from cowork_local.ui.co4e_config_panel import StepConfigPanel
from cowork_local.core.co4e import Step, SubAgent, PERMISSION_PRESETS
from cowork_local.core import co4e as co4e_mod
from cowork_local.core.co4e_builtins import BUILTIN_AGENTS
# ---- __init__ (139-313) ----------------------------------------------------
p = StepConfigPanel()
assert p.gen_btn.isEnabled() is False
assert p.load_models_btn.isEnabled() is False
assert p.isEnabled() is False, "panel bat dau bi setEnabled(False) o dong 313"
assert p.perm_combo.count() == len(PERMISSION_PRESETS) == 4
assert [p.perm_combo.itemData(i) for i in range(4)] == list(PERMISSION_PRESETS)
assert (p.rounds_spin.minimum(), p.rounds_spin.maximum()) == (1, 5)
print("CASE_INIT_DEFAULT_CTX_NONE_OK")
# quirk: gen_btn/load_models_btn duoc set theo ctx, nhung setEnabled(False)
# cho CA PANEL o cuoi __init__ de len tren ca hai -> ca hai deu False cho den
# khi mot cai gi do bat lai ca panel (vd load_step() goi setEnabled(True)).
p_ctx = StepConfigPanel(ctx=object())
assert p_ctx.gen_btn.isEnabled() is False, "quirk: panel disabled de len len ctx!=None"
assert p_ctx.load_models_btn.isEnabled() is False
p_ctx.setEnabled(True) # mo phong dieu load_step() lam
assert p_ctx.gen_btn.isEnabled() is True
assert p_ctx.load_models_btn.isEnabled() is True
p_none = StepConfigPanel(ctx=None)
p_none.setEnabled(True)
assert p_none.gen_btn.isEnabled() is False, "ctx=None -> nut van tat sau khi panel duoc bat"
assert p_none.load_models_btn.isEnabled() is False
print("CASE_INIT_CTX_ENABLE_QUIRK_OK")
p.show()
app.processEvents()
assert p._parallel_card.isVisible() is True, "form4/_parallel_card khong tu an luc dung"
print("CASE_INIT_PARALLEL_CARD_DEFAULT_VISIBLE_OK")
# ---- load_step (316-354): step "step" thuong -------------------------------
step = Step(
variant="step", label="My Step", role="worker", icon="file",
instructions="Do X", context="bg info", model="gpt-4",
permission_preset="standard", self_verify=False, max_verify_rounds=3,
skills=["Test Skill", "OrphanSkill"], attachments=["C:/foo/bar/baz.txt", "note.md"],
sub_agents=[SubAgent(agent="Ghost")],
)
p.load_step("n1", step, ["Test Skill", "Other Skill"])
assert p.label_edit.text() == "My Step"
assert p.role_edit.text() == "worker"
assert p.icon_edit.currentText() == "file"
assert p.instructions_edit.toPlainText() == "Do X"
assert p.context_edit.toPlainText() == "bg info"
assert p.model_combo.currentText() == "gpt-4"
assert (p.perm_combo.currentIndex(), p.perm_combo.currentData()) == (2, "standard")
assert p.verify_chk.isChecked() is False
assert p.rounds_spin.value() == 3
assert p.skills_list.count() == 2, "chi 2 muc trong skill_names duoc ve, OrphanSkill khong co hang"
assert [p.skills_list.item(i).text() for i in range(2)] == ["Test Skill", "Other Skill"]
assert p.skills_list.item(0).checkState() == Qt.Checked
assert p.skills_list.item(1).checkState() == Qt.Unchecked
assert p.attach_list.count() == 2
assert p.attach_list.item(0).text() == "baz.txt"
assert p.attach_list.item(0).toolTip() == "C:/foo/bar/baz.txt"
assert p.attach_list.item(1).text() == "note.md"
app.processEvents()
assert p._parallel_card.isVisible() is False, "variant='step' -> card an"
assert p.sub_list.count() == 0, "quirk: sub_agents khong rong nhung khong dong bo vao UI khi khong phai parallel"
assert step.sub_agents == [SubAgent(agent="Ghost")], "du lieu step khong bi dong cham, chi UI khong ve"
print("CASE_LOAD_STEP_BASIC_OK")
# quirk: rounds_spin.setValue(max(1, n)) nhung QSpinBox tu kep tran o 5.
p.load_step("n0", Step(max_verify_rounds=0), [])
assert p.rounds_spin.value() == 1
p.load_step("nneg", Step(max_verify_rounds=-5), [])
assert p.rounds_spin.value() == 1
p.load_step("n10", Step(max_verify_rounds=10), [])
assert p.rounds_spin.value() == 5, "quirk: max(1,10)=10 nhung QSpinBox kep ve tran 5"
print("CASE_LOAD_STEP_ROUNDS_CLAMP_QUIRK_OK")
# quirk: permission_preset la khoa khong ton tai -> fallback ve index 0 (inherit)
p.load_step("nbad", Step(permission_preset="does-not-exist"), [])
assert (p.perm_combo.currentIndex(), p.perm_combo.currentData()) == (0, "inherit")
print("CASE_LOAD_STEP_UNKNOWN_PRESET_FALLBACK_OK")
# variant="parallel" -> card hien, sub_list duoc ve tu sub_agents
steppar = Step(variant="parallel", sub_agents=[SubAgent(agent="A1"), SubAgent(agent="A2")])
p.load_step("npar", steppar, [])
app.processEvents()
assert p._parallel_card.isVisible() is True
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["A1", "A2"]
print("CASE_LOAD_STEP_PARALLEL_OK")
# rong: skills/attachments rong -> list rong, khong loi
p.load_step("nempty", Step(), [])
assert p.skills_list.count() == 0
assert p.attach_list.count() == 0
print("CASE_LOAD_STEP_EMPTY_OK")
# ---- clear_step (356-359) ---------------------------------------------------
p.clear_step()
assert p._step is None
assert p._node_id == ""
assert p.isEnabled() is False
print("CASE_CLEAR_STEP_OK")
# _on_edit voi _step None -> khong loi, khong lam gi
p._on_edit()
print("CASE_ON_EDIT_NO_STEP_NOOP_OK")
# ---- _on_edit (362-378): guard _loading, fallback role, quirk orphan-skill --
p.load_step("npar2", step, ["Test Skill", "Other Skill"])
assert step.skills == ["Test Skill", "OrphanSkill"], "chua edit gi thi step chua bi dong den"
p.role_edit.setText("") # -> _on_edit tu dong chay qua signal that, khong mock
assert step.role == "AGENT", "role rong -> fallback 'AGENT' (dong 367)"
assert step.skills == ["Test Skill"], (
"quirk: mot edit KHONG LIEN QUAN (sua role) cung ghi de s.skills bang "
"danh sach dang checked trong UI -> OrphanSkill bi rung vinh vien"
)
print("CASE_ON_EDIT_ROLE_FALLBACK_AND_ORPHAN_SKILL_QUIRK_OK")
p._loading = True
step.role = "UNTOUCHED"
p.role_edit.setText("Something Else")
assert step.role == "UNTOUCHED", "guard _loading chan _on_edit khong ghi lai step"
p._loading = False
print("CASE_ON_EDIT_LOADING_GUARD_OK")
p.role_edit.setText("scout")
assert step.role == "SCOUT", "role duoc upper() hoa (dong 367)"
print("CASE_ON_EDIT_ROLE_UPPERCASE_OK")
# ---- _available_agent_names (380-390, static) ------------------------------
names0 = StepConfigPanel._available_agent_names()
assert names0 == [a.name for a in BUILTIN_AGENTS], "khong co custom agent -> chi builtin, dung thu tu"
assert names0[0] == "Business Analyst"
assert len(names0) == len(BUILTIN_AGENTS) == 19
print("CASE_AVAILABLE_NAMES_NO_CUSTOM_OK")
custom = co4e_mod.new_custom_agent("Zed Custom")
co4e_mod.save_custom_agent(custom)
names1 = StepConfigPanel._available_agent_names()
assert names1[0] == "Zed Custom", "custom agent dung TRUOC builtin"
assert len(names1) == len(BUILTIN_AGENTS) + 1
print("CASE_AVAILABLE_NAMES_ONE_CUSTOM_OK")
# quirk: custom trung ten voi mot builtin -> chi giu 1 lan (dong 389: "not in names")
dup = co4e_mod.new_custom_agent(BUILTIN_AGENTS[0].name)
co4e_mod.save_custom_agent(dup)
names2 = StepConfigPanel._available_agent_names()
assert names2.count(BUILTIN_AGENTS[0].name) == 1, "trung ten voi builtin bi loai bo, khong nhan doi"
assert names2[0] == "Zed Custom"
print("CASE_AVAILABLE_NAMES_DEDUPE_WITH_BUILTIN_OK")
# ---- _add_subagent (392-408) ------------------------------------------------
pstep = Step(variant="parallel")
p.load_step("nsub", pstep, [])
p._step = None
before_n = p.sub_list.count()
p._add_subagent()
assert p.sub_list.count() == before_n, "_step None -> _add_subagent la no-op"
print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK")
p._step = pstep
orig_getItem = QInputDialog.getItem
orig_getText = QInputDialog.getText
changed_n = {"n": 0}
p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1))
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Picked Agent", True))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"]
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"]
assert changed_n["n"] == 1
print("CASE_ADD_SUBAGENT_PICKED_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("", True))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them"
assert changed_n["n"] == 1, "khong them thi khong emit changed"
print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Should Not Add", False))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them"
print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
# quirk: khi _available_agent_names() rong -> dung getText thay vi getItem (dong 401-402)
# GHI CHU DOT TACH mixin (khong doi hanh vi, chi doi CACH patch/restore trong
# test): sau khi StepConfigPanel._available_agent_names duoc dua vao mixin
# rieng (_StepConfigActionsMixin), no khong con nam trong
# StepConfigPanel.__dict__ nua (ma nam trong __dict__ cua mixin, StepConfigPanel
# chi ke thua qua MRO) nen "StepConfigPanel.__dict__['_available_agent_names']"
# nem KeyError. Gan de ghi de truc tiep len StepConfigPanel (nhu dong duoi) van
# shadow dung nhu truoc; khi xong chi can `del` thuoc tinh do khoi
# StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong
# hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao.
StepConfigPanel._available_agent_names = staticmethod(lambda: [])
QInputDialog.getText = staticmethod(lambda *a, **k: ("Typed Agent", True))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"]
del StepConfigPanel._available_agent_names
QInputDialog.getItem = orig_getItem
QInputDialog.getText = orig_getText
print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK")
# ---- _edit_subagent (410-428) -----------------------------------------------
pstep2 = Step(variant="parallel", sub_agents=[SubAgent(agent="Unknown Agent XYZ")])
p.load_step("nedit", pstep2, [])
captured = {}
def _fake_get_item(*a, **k):
captured["items"] = a[3]
captured["current_index"] = a[4]
return ("Renamed", True)
QInputDialog.getItem = staticmethod(_fake_get_item)
item0 = p.sub_list.item(0)
p._edit_subagent(item0)
assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0"
assert pstep2.sub_agents[0].agent == "Renamed"
assert item0.text() == "Renamed"
print("CASE_EDIT_SUBAGENT_UNKNOWN_CUR_STARTS_AT_0_OK")
foreign_item = QListWidgetItem("not in list")
snapshot = dict(captured)
p._edit_subagent(foreign_item)
assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op"
QInputDialog.getItem = orig_getItem
print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK")
# ---- _del_subagent (430-437) -------------------------------------------------
pstep3 = Step(variant="parallel", sub_agents=[SubAgent(agent="A"), SubAgent(agent="B")])
p.load_step("ndel", pstep3, [])
p.sub_list.setCurrentRow(-1)
p._del_subagent()
assert [s.agent for s in pstep3.sub_agents] == ["A", "B"], "khong chon dong nao -> no-op"
p.sub_list.setCurrentRow(0)
p._del_subagent()
assert [s.agent for s in pstep3.sub_agents] == ["B"]
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["B"]
print("CASE_DEL_SUBAGENT_OK")
# ---- _add_attachment (439-453) -----------------------------------------------
astep = Step(attachments=["already/here.txt"])
p.load_step("natt", astep, [])
orig_getOpenFileNames = QFileDialog.getOpenFileNames
QFileDialog.getOpenFileNames = staticmethod(
lambda *a, **k: (["already/here.txt", "new/one.txt", ""], "")
)
att_changed = {"n": 0}
p.changed.connect(lambda: att_changed.__setitem__("n", att_changed["n"] + 1))
p._add_attachment()
assert astep.attachments == ["already/here.txt", "new/one.txt"], (
"duplicate bi loai (dong 447), chuoi rong bi loai boi 'if f and ...'"
)
assert p.attach_list.count() == 2
print("CASE_ADD_ATTACHMENT_DEDUPE_AND_SKIP_EMPTY_OK")
before_att_changed = att_changed["n"]
QFileDialog.getOpenFileNames = staticmethod(lambda *a, **k: ([], ""))
p._add_attachment()
assert att_changed["n"] == before_att_changed, "files rong -> khong emit changed (dong 452-453)"
QFileDialog.getOpenFileNames = orig_getOpenFileNames
print("CASE_ADD_ATTACHMENT_EMPTY_FILES_NO_CHANGED_OK")
p._step = None
before_att_count = p.attach_list.count()
p._add_attachment()
assert p.attach_list.count() == before_att_count, "_step None -> no-op"
p._step = astep
print("CASE_ADD_ATTACHMENT_NO_STEP_NOOP_OK")
# ---- _del_attachment (455-462) -----------------------------------------------
p.attach_list.setCurrentRow(-1)
p._del_attachment()
assert astep.attachments == ["already/here.txt", "new/one.txt"], "khong chon dong -> no-op"
p.attach_list.setCurrentRow(0)
p._del_attachment()
assert astep.attachments == ["new/one.txt"]
assert p.attach_list.count() == 1
print("CASE_DEL_ATTACHMENT_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_step_config_panel_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_INIT_DEFAULT_CTX_NONE_OK",
"CASE_INIT_CTX_ENABLE_QUIRK_OK",
"CASE_INIT_PARALLEL_CARD_DEFAULT_VISIBLE_OK",
"CASE_LOAD_STEP_BASIC_OK",
"CASE_LOAD_STEP_ROUNDS_CLAMP_QUIRK_OK",
"CASE_LOAD_STEP_UNKNOWN_PRESET_FALLBACK_OK",
"CASE_LOAD_STEP_PARALLEL_OK",
"CASE_LOAD_STEP_EMPTY_OK",
"CASE_CLEAR_STEP_OK",
"CASE_ON_EDIT_NO_STEP_NOOP_OK",
"CASE_ON_EDIT_ROLE_FALLBACK_AND_ORPHAN_SKILL_QUIRK_OK",
"CASE_ON_EDIT_LOADING_GUARD_OK",
"CASE_ON_EDIT_ROLE_UPPERCASE_OK",
"CASE_AVAILABLE_NAMES_NO_CUSTOM_OK",
"CASE_AVAILABLE_NAMES_ONE_CUSTOM_OK",
"CASE_AVAILABLE_NAMES_DEDUPE_WITH_BUILTIN_OK",
"CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK",
"CASE_ADD_SUBAGENT_PICKED_OK",
"CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK",
"CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK",
"CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK",
"CASE_EDIT_SUBAGENT_UNKNOWN_CUR_STARTS_AT_0_OK",
"CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK",
"CASE_DEL_SUBAGENT_OK",
"CASE_ADD_ATTACHMENT_DEDUPE_AND_SKIP_EMPTY_OK",
"CASE_ADD_ATTACHMENT_EMPTY_FILES_NO_CHANGED_OK",
"CASE_ADD_ATTACHMENT_NO_STEP_NOOP_OK",
"CASE_DEL_ATTACHMENT_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi save_custom_agent() cua chinh test (khong co
# du lieu nguoi dung that nao bi cham vao) - CONFIG_DIR nam trong sandbox
# nhu da assert ngay dau script.
assert (sandbox / ".cowork_local" / "co4e").exists()
+157
View File
@@ -0,0 +1,157 @@
"""Characterization tests for core/chat_agent.py (run_chat and run_cowork runtime seams).
These tests capture existing behavior as an executable baseline specification,
ensuring that future refactoring to ConversationApplicationService does not alter
core turn semantics, event emissions, or file handling.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
from cowork_local.core import chat_agent
from cowork_local.tests.fakes.fake_provider import FakeProvider
def test_run_chat_characterization() -> None:
"""Capture baseline behavior of run_chat: system prompt insertion, streaming, and message persistence."""
provider = FakeProvider()
provider.queue_response(content="Hello there!", chunks=["Hello ", "there!"])
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Hi assistant"}]
emitted_events: List[Dict[str, Any]] = []
def emit(event: Dict[str, Any]) -> None:
emitted_events.append(event)
result = chat_agent.run_chat(
provider=provider,
messages=messages,
emit=emit,
)
# 1. Verify system prompt was injected at position 0
assert messages[0]["role"] == "system"
assert "Cowork Local" in messages[0]["content"]
# 2. Verify returned assistant message
assert result["role"] == "assistant"
assert result["content"] == "Hello there!"
# 3. Verify assistant message was appended to messages list
assert messages[-1] == result
# 4. Verify emitted events sequence
text_deltas = [e["delta"] for e in emitted_events if e["type"] == "text"]
assert "".join(text_deltas) == "Hello there!"
assert any(e["type"] == "assistant_done" for e in emitted_events)
def test_run_cowork_save_file_characterization(tmp_path: Path) -> None:
"""Capture baseline behavior of run_cowork: tool execution loop and file production."""
output_dir = tmp_path / "output"
output_dir.mkdir(parents=True, exist_ok=True)
provider = FakeProvider()
# Step 1: Model requests save_file tool
provider.queue_response(
content="Saving your requested report.",
tool_calls=[{
"id": "call_save_1",
"name": "save_file",
"arguments": {
"filename": "report.md",
"content": "# Executive Summary\nAll systems nominal.",
},
}],
)
# Step 2: Model finishes after tool result
provider.queue_response(
content="I have created report.md in your output directory.",
chunks=["I have created report.md in your output directory."],
)
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Export report to markdown file"}]
emitted_events: List[Dict[str, Any]] = []
def emit(event: Dict[str, Any]) -> None:
emitted_events.append(event)
final_messages = chat_agent.run_cowork(
provider=provider,
messages=messages,
output_dir=output_dir,
emit=emit,
enforce_rules=False,
)
# 1. Verify file was created in output directory with expected content
created_file = output_dir / "report.md"
assert created_file.exists()
assert created_file.read_text(encoding="utf-8") == "# Executive Summary\nAll systems nominal."
# 2. Verify message history contains user -> assistant (tool_calls) -> tool -> assistant
roles = [m["role"] for m in final_messages]
assert "system" in roles
assert "user" in roles
assert "tool" in roles
# 3. Verify tool result message content
tool_msg = next(m for m in final_messages if m["role"] == "tool")
assert tool_msg["name"] == "save_file"
assert "Saved report.md" in tool_msg["content"]
def test_run_cowork_cancellation_characterization(tmp_path: Path) -> None:
"""Capture cancellation behavior in run_cowork."""
output_dir = tmp_path / "output_cancel"
output_dir.mkdir(parents=True, exist_ok=True)
provider = FakeProvider()
provider.queue_response(content="Working...")
is_cancelled = True
def check_cancel() -> bool:
return is_cancelled
emitted_events: List[Dict[str, Any]] = []
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Please start"}]
chat_agent.run_cowork(
provider=provider,
messages=messages,
output_dir=output_dir,
emit=lambda e: emitted_events.append(e),
cancel=check_cancel,
enforce_rules=False,
)
# Provider should not have executed turns if cancelled right away
assert provider.call_count == 0
def test_cleanup_turn_output_characterization(tmp_path: Path) -> None:
"""Capture behavior of temporary .scratch folder cleanup and artifact preservation."""
output_dir = tmp_path / "output_cleanup"
output_dir.mkdir(parents=True, exist_ok=True)
scratch_dir = output_dir / ".scratch"
scratch_dir.mkdir(parents=True, exist_ok=True)
# Create a generator script and a deliverable inside scratch
generator_script = scratch_dir / "gen.py"
generator_script.write_text("print('generating')", encoding="utf-8")
deliverable = scratch_dir / "data.csv"
deliverable.write_text("a,b,c\n1,2,3", encoding="utf-8")
before_snapshot = chat_agent._snapshot(output_dir)
removed, moved = chat_agent._cleanup_cowork_intermediates(output_dir, before_snapshot, cancelled=False)
# .scratch directory should be removed
assert not scratch_dir.exists()
# deliverable should be moved to output root
root_csv = output_dir / "data.csv"
assert root_csv.exists()
# script should not be in output root
assert not (output_dir / "gen.py").exists()
+69 -4
View File
@@ -1,10 +1,75 @@
"""Make the repository package importable when pytest runs from the repo root."""
"""Make THIS checkout importable as the ``cowork_local`` package during tests.
Why this is not just a ``sys.path`` insert
------------------------------------------
Test modules import the app in two different styles:
* top-level (``from providers.base import ...``) — resolved by the repository
root already sitting on ``sys.path`` when pytest is launched from it;
* fully qualified (``from cowork_local.core.routing.service import ...``) —
which only resolves when a directory literally named ``cowork_local`` is
importable.
Simply appending the repository's PARENT directory to ``sys.path`` (the previous
behaviour) makes the second style resolve against *whatever* sibling folder
happens to be called ``cowork_local`` — on a developer machine that is often an
unrelated older checkout, so the whole suite silently exercises the wrong code
while still reporting green. Instead we bind the name ``cowork_local`` in
``sys.modules`` to the package rooted at THIS repository, so both import styles
always reach the working copy under test regardless of the checkout's directory
name.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
REPOSITORY_PARENT = Path(__file__).resolve().parents[2]
if str(REPOSITORY_PARENT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_PARENT))
# .../<checkout>/tests/conftest.py -> .../<checkout>
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_NAME = "cowork_local"
# The repository root must stay importable so the top-level import style
# (``providers``/``domain``/``application``/``tests``) keeps working.
if str(PACKAGE_ROOT) not in sys.path:
sys.path.insert(0, str(PACKAGE_ROOT))
def _bind_checkout_as_package() -> None:
"""Register this checkout in ``sys.modules`` under the canonical package name.
Executed at import time of the conftest (i.e. before any test module is
imported) so that a stale same-named directory elsewhere on ``sys.path`` can
never win the lookup. A no-op when the package is already bound to this very
directory, which keeps repeated conftest loads (pytest-xdist, sub-sessions)
idempotent.
"""
existing = sys.modules.get(PACKAGE_NAME)
if existing is not None:
# Already bound. Only rebind when it points at a DIFFERENT checkout,
# otherwise re-executing the package __init__ would duplicate module
# state that tests may already hold references to.
origin = getattr(existing, "__file__", "") or ""
if Path(origin).resolve().parent == PACKAGE_ROOT:
return
spec = importlib.util.spec_from_file_location(
PACKAGE_NAME,
PACKAGE_ROOT / "__init__.py",
# Declaring the search locations is what turns the module into a real
# package, so ``cowork_local.core.routing`` and friends resolve as
# sub-modules of this directory.
submodule_search_locations=[str(PACKAGE_ROOT)],
)
if spec is None or spec.loader is None: # pragma: no cover — defensive
return
module = importlib.util.module_from_spec(spec)
# Insert BEFORE executing so that a circular ``import cowork_local`` from
# inside the package body resolves to the partially-initialised module
# instead of restarting the import (standard CPython import semantics).
sys.modules[PACKAGE_NAME] = module
spec.loader.exec_module(module)
_bind_checkout_as_package()
+7
View File
@@ -0,0 +1,7 @@
"""Contract tests: one shared specification every interchangeable adapter must satisfy.
Unlike unit tests (which pin ONE implementation's behaviour), a contract test is
parametrised over every implementation of an interface, so adding a new provider
means adding a row — not writing a new test file — and a provider that quietly
breaks the canonical shape fails here rather than in production.
"""
+178
View File
@@ -0,0 +1,178 @@
"""Offline transport doubles + per-protocol stream scripts for the provider contract tests.
Kept in its own module so ``test_providers.py`` stays a readable list of
assertions instead of a wall of SSE fixtures, and so the LOC ceiling (400 lines
per production file, applied here too) is comfortably met by both halves.
Nothing in here touches the network: :class:`FakeStreamResponse` mimics just
enough of ``requests.Response`` for the streaming loops in
``providers/openai_compat.py`` and ``providers/anthropic.py`` — status code,
mutable ``encoding``, ``iter_lines`` and ``close``.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
# Canonical turn every protocol script below must produce, so the contract test
# can assert one expected result no matter which provider produced it.
EXPECTED_TEXT = "Hello world"
EXPECTED_TOOL_CALL = {"id": "call-1", "name": "read_file", "arguments": {"path": "a.txt"}}
EXPECTED_INPUT_TOKENS = 11
EXPECTED_OUTPUT_TOKENS = 7
EXPECTED_CACHED_TOKENS = 3
class FakeStreamResponse:
"""A minimal stand-in for a streaming ``requests.Response``.
``iter_lines`` replays pre-baked SSE lines; ``closed`` records that the
provider released the connection, which the contract asserts because a
provider that leaks the response leaks a socket per turn.
"""
def __init__(
self,
lines: Optional[List[str]] = None,
status_code: int = 200,
body: str = "",
headers: Optional[Dict[str, str]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> None:
self.status_code = status_code
self._lines = list(lines or ())
self.text = body
self.headers = dict(headers or {})
self._payload = payload
self.closed = False
# Providers force UTF-8 on the response before reading it; the attribute
# simply has to exist and be writable.
self.encoding = None
def iter_lines(self, decode_unicode: bool = False):
for line in self._lines:
yield line
def json(self) -> Any:
if self._payload is None:
raise ValueError("no JSON payload configured on this fake response")
return self._payload
def close(self) -> None:
self.closed = True
def _sse(payload: Dict[str, Any]) -> str:
"""One SSE ``data:`` line carrying a JSON event."""
return "data: " + json.dumps(payload, ensure_ascii=False)
def openai_stream_lines() -> List[str]:
"""A complete OpenAI Chat Completions stream: text, one tool call, usage.
Split across several deltas on purpose — chunk boundaries are where naive
stream parsers break, so the contract exercises them.
"""
return [
_sse({"choices": [{"delta": {"content": "Hello "}}]}),
_sse({"choices": [{"delta": {"content": "world"}}]}),
_sse({"choices": [{"delta": {"tool_calls": [{
"index": 0, "id": "call-1",
"function": {"name": "read_file", "arguments": '{"path":'},
}]}}]}),
# Arguments arrive fragmented; the provider must concatenate before parsing.
_sse({"choices": [{"delta": {"tool_calls": [{
"index": 0, "function": {"arguments": '"a.txt"}'},
}]}}]}),
_sse({
"choices": [{"delta": {}}],
"usage": {
"prompt_tokens": EXPECTED_INPUT_TOKENS,
"completion_tokens": EXPECTED_OUTPUT_TOKENS,
"prompt_tokens_details": {"cached_tokens": EXPECTED_CACHED_TOKENS},
},
}),
"data: [DONE]",
]
def anthropic_stream_lines() -> List[str]:
"""The same canonical turn expressed as an Anthropic Messages stream."""
return [
_sse({"type": "message_start", "message": {"usage": {
"input_tokens": EXPECTED_INPUT_TOKENS,
"cache_read_input_tokens": EXPECTED_CACHED_TOKENS,
}}}),
_sse({"type": "content_block_start", "index": 0,
"content_block": {"type": "text"}}),
_sse({"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "Hello "}}),
_sse({"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "world"}}),
_sse({"type": "content_block_start", "index": 1, "content_block": {
"type": "tool_use", "id": "call-1", "name": "read_file"}}),
_sse({"type": "content_block_delta", "index": 1,
"delta": {"type": "input_json_delta", "partial_json": '{"path":'}}),
_sse({"type": "content_block_delta", "index": 1,
"delta": {"type": "input_json_delta", "partial_json": '"a.txt"}'}}),
_sse({"type": "message_delta",
"usage": {"output_tokens": EXPECTED_OUTPUT_TOKENS}}),
_sse({"type": "message_stop"}),
]
# Per wire protocol: how to script a successful turn, and the model-list payload
# ``list_models()`` expects. Keyed by the descriptor's wire protocol value so a
# new provider that reuses an existing protocol needs no new entry here.
PROTOCOL_FIXTURES = {
"openai_compat": {
"stream_lines": openai_stream_lines,
"models_payload": {"data": [{"id": "gpt-4o-mini"}, {"id": "gpt-4o"}]},
"expected_models": ["gpt-4o-mini", "gpt-4o"],
},
"anthropic": {
"stream_lines": anthropic_stream_lines,
"models_payload": {"data": [{"id": "claude-sonnet-4-6"}]},
"expected_models": ["claude-sonnet-4-6"],
},
}
class ScriptedTransport:
"""Replaces ``Provider._request`` and hands back scripted responses.
Records every call so a test can assert *how* the provider talked to the
endpoint (method, url, JSON payload) without a socket ever being opened.
"""
def __init__(self, responses: List[FakeStreamResponse]) -> None:
self._responses = list(responses)
self.calls: List[Dict[str, Any]] = []
def __call__(self, method: str, url: str, **kwargs) -> FakeStreamResponse:
self.calls.append({"method": method, "url": url, **kwargs})
if not self._responses:
raise AssertionError(f"unexpected extra request: {method} {url}")
# Pop in order: a provider that retries gets the NEXT scripted response,
# which is how the retry/error paths are driven.
return self._responses.pop(0)
@property
def last_payload(self) -> Dict[str, Any]:
"""The JSON body of the most recent request."""
return self.calls[-1].get("json") or {}
__all__ = [
"EXPECTED_CACHED_TOKENS",
"EXPECTED_INPUT_TOKENS",
"EXPECTED_OUTPUT_TOKENS",
"EXPECTED_TEXT",
"EXPECTED_TOOL_CALL",
"FakeStreamResponse",
"PROTOCOL_FIXTURES",
"ScriptedTransport",
"anthropic_stream_lines",
"openai_stream_lines",
]
+279
View File
@@ -0,0 +1,279 @@
"""R03-T01 — the contract every LLM provider adapter must satisfy.
Parametrised over EVERY provider in the central registry
(``infrastructure/providers/provider_registry.py``), so registering a new
provider automatically subjects it to the same specification and a provider that
drifts from the canonical shapes fails here.
The contract, in one list:
* construction — the registry builds a real ``Provider`` for every id;
* ``chat()`` — canonical signature, canonical assistant message, streamed text
delivered through ``on_text``, tool calls normalised to
``{"id", "name", "arguments": dict}``, response always closed;
* tool schema translation matches the adapter's wire protocol;
* failures raise ``ProviderError`` — never a bare transport exception;
* ``list_models()`` / ``test_connection()`` report a reason instead of a silent
empty list;
* telemetry — exactly one ``UsageEvent`` per turn (R03-T06), with the real
counts when the stream reports them.
Everything runs offline: ``Provider._request`` is replaced by a scripted
transport, so the suite needs no network, no API key and no Qt event loop.
"""
from __future__ import annotations
import pytest
import requests
from cowork_local.infrastructure.providers.provider_registry import (
BUILTIN_DESCRIPTORS,
ProviderRegistry,
)
from cowork_local.infrastructure.telemetry import usage_sink
from cowork_local.providers.base import Provider, ProviderError, ToolSpec
from cowork_local.tests.contracts.provider_stubs import (
EXPECTED_CACHED_TOKENS,
EXPECTED_INPUT_TOKENS,
EXPECTED_OUTPUT_TOKENS,
EXPECTED_TEXT,
EXPECTED_TOOL_CALL,
PROTOCOL_FIXTURES,
FakeStreamResponse,
ScriptedTransport,
)
# Every provider id in the catalogue — the parametrisation that makes this a
# contract suite rather than a per-adapter unit test.
PROVIDER_IDS = [d.provider_id for d in BUILTIN_DESCRIPTORS]
# Minimal config: enough for any adapter to build a URL and headers offline.
BASE_CONF = {"base_url": "https://gateway.test/v1", "api_key": "test-key"}
SAMPLE_MESSAGES = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Say hello"},
]
SAMPLE_TOOL = ToolSpec(
name="read_file",
description="Read a file from disk",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
)
@pytest.fixture()
def registry() -> ProviderRegistry:
"""A private registry per test so registrations never leak between tests."""
return ProviderRegistry(BUILTIN_DESCRIPTORS)
@pytest.fixture()
def collected_usage(monkeypatch) -> usage_sink.InMemoryUsageSink:
"""Swap the process-wide telemetry sink for an in-memory one.
Restored by monkeypatch after each test, so a contract run never appends to
the developer's real ``~/.cowork_local/usage/`` files.
"""
sink = usage_sink.InMemoryUsageSink()
monkeypatch.setattr(usage_sink, "_sink", usage_sink.CompositeUsageSink([sink]))
return sink
def _fixtures_for(registry: ProviderRegistry, provider_id: str) -> dict:
"""The stream/model-list script matching this provider's wire protocol."""
protocol = registry.get(provider_id).wire_protocol.value
return PROTOCOL_FIXTURES[protocol]
def _build(registry: ProviderRegistry, provider_id: str, transport=None) -> Provider:
"""Build a provider and (optionally) replace its transport with a script."""
provider = registry.build(provider_id, dict(BASE_CONF))
if transport is not None:
# Patch the INSTANCE, not the class: parallel parametrised cases must
# not see each other's scripted transport.
provider._request = transport
return provider
# --------------------------------------------------------------------------- #
# Construction & interface shape
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_registry_builds_a_provider_for_every_registered_id(registry, provider_id) -> None:
"""Every catalogued provider must be constructible — a descriptor with no
working adapter is a broken entry, not a feature flag."""
provider = _build(registry, provider_id)
assert isinstance(provider, Provider)
# The registry fills in the descriptor's default model when config omits it,
# so a half-configured provider still names a concrete model.
assert provider.model, f"{provider_id} built without a model id"
assert provider.describe() == f"{provider.name}:{provider.model}"
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_chat_signature_is_uniform(registry, provider_id) -> None:
"""All adapters accept the same call, so the agent runtime can swap
providers without knowing which one it holds."""
import inspect
provider = _build(registry, provider_id)
params = list(inspect.signature(provider.chat).parameters)
assert params == ["messages", "tools", "on_text", "cancel", "on_reasoning"]
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_tool_schema_matches_the_wire_protocol(registry, provider_id) -> None:
"""A ToolSpec must translate into the exact shape the endpoint expects."""
descriptor = registry.get(provider_id)
if descriptor.wire_protocol.value == "anthropic":
translated = SAMPLE_TOOL.to_anthropic()
assert translated["input_schema"] == SAMPLE_TOOL.parameters
assert translated["name"] == "read_file"
else:
translated = SAMPLE_TOOL.to_openai()
assert translated["type"] == "function"
assert translated["function"]["parameters"] == SAMPLE_TOOL.parameters
# --------------------------------------------------------------------------- #
# The turn itself
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_chat_returns_the_canonical_assistant_message(registry, provider_id, collected_usage) -> None:
"""Whatever the wire format, one turn yields the same canonical result."""
fixtures = _fixtures_for(registry, provider_id)
response = FakeStreamResponse(lines=fixtures["stream_lines"]())
transport = ScriptedTransport([response])
provider = _build(registry, provider_id, transport)
streamed: list = []
result = provider.chat(
SAMPLE_MESSAGES, tools=[SAMPLE_TOOL], on_text=streamed.append,
)
assert result["role"] == "assistant"
assert result["content"] == EXPECTED_TEXT
# Text must arrive incrementally, not only in the final message — the chat
# UI streams from these callbacks.
assert "".join(streamed) == EXPECTED_TEXT
assert len(streamed) >= 2
# Tool calls are normalised: parsed arguments, never the raw JSON fragments.
assert result["tool_calls"] == [EXPECTED_TOOL_CALL]
assert response.closed, "provider left the streaming response open"
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_chat_publishes_exactly_one_usage_event(registry, provider_id, collected_usage) -> None:
"""R03-T06: a turn reports its token usage through the telemetry sink, with
the server's real counts when the stream carried them."""
fixtures = _fixtures_for(registry, provider_id)
transport = ScriptedTransport([FakeStreamResponse(lines=fixtures["stream_lines"]())])
provider = _build(registry, provider_id, transport)
provider.chat(SAMPLE_MESSAGES, tools=[SAMPLE_TOOL])
events = collected_usage.snapshot()
assert len(events) == 1, "a turn must publish exactly one usage event"
event = events[0]
assert event.provider == provider.name
assert event.model == provider.model
assert event.input_tokens == EXPECTED_INPUT_TOKENS
assert event.output_tokens == EXPECTED_OUTPUT_TOKENS
assert event.cached_tokens == EXPECTED_CACHED_TOKENS
# Real counts were available, so the event must NOT be flagged as a guess.
assert event.estimated is False
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_usage_is_estimated_when_the_stream_reports_none(registry, provider_id, collected_usage) -> None:
"""Gateways that never send usage still produce a dashboard row — clearly
flagged as an estimate rather than silently recorded as zero."""
# Only text; no usage block anywhere in the stream.
silent_stream = ['data: ' + '{"choices": [{"delta": {"content": "hi"}}]}', "data: [DONE]"]
if registry.get(provider_id).wire_protocol.value == "anthropic":
silent_stream = [
'data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}',
'data: {"type": "content_block_delta", "index": 0,'
' "delta": {"type": "text_delta", "text": "hi"}}',
]
transport = ScriptedTransport([FakeStreamResponse(lines=silent_stream)])
provider = _build(registry, provider_id, transport)
provider.chat(SAMPLE_MESSAGES)
events = collected_usage.snapshot()
assert len(events) == 1
assert events[0].estimated is True
# An estimate still has to be a positive number to be worth showing.
assert events[0].total_tokens > 0
# --------------------------------------------------------------------------- #
# Failure behaviour
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_http_error_becomes_provider_error(registry, provider_id, collected_usage) -> None:
"""Callers handle exactly one exception type; adapters must not leak
transport- or JSON-level errors past their boundary."""
failing = FakeStreamResponse(status_code=401, body='{"error": {"message": "bad key"}}')
transport = ScriptedTransport([failing])
provider = _build(registry, provider_id, transport)
with pytest.raises(ProviderError):
provider.chat(SAMPLE_MESSAGES)
assert failing.closed, "provider left a failed response open"
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_list_models_and_test_connection_report_a_reason(registry, provider_id) -> None:
"""A failed model load must explain itself: ``last_error`` is what Settings
shows instead of an unexplained empty dropdown."""
def _boom(*_args, **_kwargs):
# A transport failure, i.e. what actually happens when the gateway is
# unreachable — adapters translate this class of error, not arbitrary
# programming errors, which must still surface as bugs.
raise requests.ConnectionError("network down")
provider = _build(registry, provider_id, _boom)
models = provider.list_models()
assert provider.last_error, f"{provider_id} swallowed a model-load failure"
ok, message = provider.test_connection()
assert ok is False
assert message
# Anthropic answers with a built-in fallback catalogue; a gateway answers
# with nothing. Both are acceptable — the contract is only that a failure is
# never reported as success.
assert isinstance(models, list)
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_list_models_returns_ids_on_success(registry, provider_id) -> None:
"""The happy path returns plain model-id strings, not raw API objects."""
fixtures = _fixtures_for(registry, provider_id)
transport = ScriptedTransport([
FakeStreamResponse(status_code=200, payload=fixtures["models_payload"]),
])
provider = _build(registry, provider_id, transport)
models = provider.list_models()
assert models == fixtures["expected_models"]
assert provider.last_error == ""
assert all(isinstance(m, str) for m in models)
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_strip_think_removes_inline_reasoning(registry, provider_id) -> None:
"""Reasoning must never leak into a final answer, whichever adapter ran."""
provider = _build(registry, provider_id)
cleaned = provider.strip_think("<think>secret plan</think>Visible answer")
assert cleaned == "Visible answer"
+14
View File
@@ -0,0 +1,14 @@
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.
Gói này cố ý **không** import sẵn fake nào. Import ở đây là import háo hức:
chạm vào bất kỳ fake nào là kéo theo mọi phụ thuộc của nó, nên chỉ cần một
fake lỡ import module cần sys.path đặc biệt là cả gói hỏng trong môi trường
cô lập. Đã xảy ra thật khi merge Delta: `fake_provider` dùng
`from providers.base import ...` (import tuyệt đối) làm đứt bài kiểm
"dùng fake mà không nạp config thật".
Import thẳng module cần dùng:
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
from cowork_local.tests.fakes.fake_provider import FakeProvider
"""
+174
View File
@@ -0,0 +1,174 @@
"""``Co4EWorkflowService`` giả — cho widget Co4E Studio (presentation/) và cho
test khác dùng khi service thật
(``application/workflows/co4e_workflow_service.py``) chưa được ``bootstrap.py``
lắp vào, hoặc khi test không muốn chạm đĩa/AI thật.
Chạy hoàn toàn trong bộ nhớ, đồng bộ, không cần ``runner`` thật (không
``AgentWorker``/``QThread`` nào được tạo): ``start()`` ghi nhận run ở trạng
thái "running" rồi đứng yên — muốn mô phỏng tiến trình thì test tự gọi
``deliver_event``/``mark_finished``/``mark_failed``, giống hệt cách
``tests/characterization/test_co4e_run_manager_behavior.py`` seed tay vào
``Co4ERunManager`` thật rồi gọi ``_on_event``/``_on_finished``/``_on_failed``.
Ví dụ dùng::
>>> from tests.fakes.fake_co4e_workflow_service import FakeCo4EWorkflowService
>>> class _Wf:
... id = "wf1"; name = "Flow"; nodes = []; edges = []
>>> svc = FakeCo4EWorkflowService()
>>> run_id = svc.start(_Wf())
>>> svc.started_workflows[0].id
'wf1'
>>> svc.runs()[0].status
'running'
>>> svc.mark_finished(run_id)
>>> svc.runs()[0].status
'done'
"""
from __future__ import annotations
from pathlib import Path
from typing import Callable, Dict, List, Optional
from cowork_local.domain.workflows.run_record import RunRecord
# Mirror dung gia tri cua STEP_DONE/STEP_ERROR/STEP_PLANNED (core/co4e.py) ma
# khong import core/ o day -- fake nay chi phu thuoc domain/, giu no nhe va
# nhanh de import trong test cua team khac.
_TERMINAL_NODE = {"done", "error", "planned"}
class FakeCo4EWorkflowService:
"""Bản giả của ``Co4EWorkflowService`` — cùng API công khai, ghi lại mọi
lời gọi để test khẳng định được "có gọi service không" và "gọi với gì"."""
def __init__(self):
self._runs: Dict[str, RunRecord] = {}
self._seq = 0
self._project_id: str = ""
self._output_root: Optional[Path] = None
self._changed_callbacks: List[Callable[[], None]] = []
self._event_callbacks: List[Callable[[str, dict], None]] = []
#: moi workflow da duoc start(), dung thu tu goi -- test khang dinh
#: "co goi service.start() khong" ma khong can thuc thi that.
self.started_workflows: list = []
self.stopped_run_ids: List[str] = []
self.removed_run_ids: List[str] = []
self.renamed: List[tuple] = []
# ---- callback thay Signal (giong Co4EWorkflowService that) -------------
def on_changed(self, cb: Callable[[], None]) -> None:
self._changed_callbacks.append(cb)
def on_event(self, cb: Callable[[str, dict], None]) -> None:
self._event_callbacks.append(cb)
def _emit_changed(self) -> None:
for cb in self._changed_callbacks:
cb()
def _emit_event(self, run_id: str, ev: dict) -> None:
for cb in self._event_callbacks:
cb(run_id, ev)
# ---- lifecycle ----------------------------------------------------
def start(self, wf, *, skill_map=None, plan_mode: bool = False, only_nodes=None,
seed_outputs=None, manual: bool = False, label: Optional[str] = None) -> str:
self._seq += 1
run_id = f"run{self._seq}"
nodes = getattr(wf, "nodes", None) or []
total = len(only_nodes) if only_nodes else len(nodes)
record = RunRecord(run_id, getattr(wf, "id", ""), label or getattr(wf, "name", ""),
total, plan_mode, manual, project_id=self._project_id)
self._runs[run_id] = record
self.started_workflows.append(wf)
self._emit_changed()
return run_id
# ---- hook gia lap tien trinh (goi TU TEST, khong phai tu runner that) --
def deliver_event(self, run_id: str, ev: dict) -> None:
"""Mo phong dung ``Co4EWorkflowService._on_event`` that."""
record = self._runs.get(run_id)
if record is not None and isinstance(ev, dict):
t = ev.get("type")
if t == "node_status":
record.node_status[ev.get("node_id")] = ev.get("status")
record.done = sum(1 for s in record.node_status.values() if s in _TERMINAL_NODE)
self._emit_changed()
elif t == "run_done":
if record.status == "running":
record.status = "done" if ev.get("ok", True) else "error"
self._emit_changed()
self._emit_event(run_id, ev)
def mark_finished(self, run_id: str) -> None:
record = self._runs.get(run_id)
if record is not None and record.status == "running":
record.status = "done"
self._emit_changed()
def mark_failed(self, run_id: str, err: str) -> None:
record = self._runs.get(run_id)
if record is not None:
record.status = "error"
record.error = str(err)
self._emit_event(run_id, {"type": "run_error", "error": str(err)})
self._emit_changed()
# ---- control --------------------------------------------------------
def stop(self, run_id: str) -> None:
record = self._runs.get(run_id)
if record is not None and record.running:
record.status = "stopped"
self.stopped_run_ids.append(run_id)
self._emit_changed()
def stop_all(self) -> None:
for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]:
self.stop(run_id)
def rename(self, run_id: str, new_name: str) -> None:
record = self._runs.get(run_id)
new_name = (new_name or "").strip()
if record is None or not new_name or new_name == record.name:
return
record.name = new_name
if record.wf is not None:
record.wf["name"] = new_name
self.renamed.append((run_id, new_name))
self._emit_changed()
def remove(self, run_id: str) -> None:
self._runs.pop(run_id, None)
self.removed_run_ids.append(run_id)
self._emit_changed()
def clear_finished(self) -> None:
for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]:
self._runs.pop(run_id, None)
self._emit_changed()
# ---- queries ----------------------------------------------------------
def _belongs(self, r: RunRecord) -> bool:
return getattr(r, "project_id", "") == self._project_id
def runs(self) -> List[RunRecord]:
return [r for r in self._runs.values() if self._belongs(r)]
def all_runs(self) -> List[RunRecord]:
return list(self._runs.values())
def get(self, run_id: str) -> Optional[RunRecord]:
return self._runs.get(run_id)
def active_count(self) -> int:
return sum(1 for r in self._runs.values() if r.running and self._belongs(r))
def set_current_project(self, project_id: str) -> None:
pid = project_id or ""
if pid != self._project_id:
self._project_id = pid
self._emit_changed()
def set_output_root(self, root) -> None:
self._output_root = Path(root) if root else None
+138
View File
@@ -0,0 +1,138 @@
"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ.
Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi
bản thật xong ngày 23/08 và 26/08.
Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài
mili giây.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
class FakeSecretStore:
"""SecretStore trong bộ nhớ.
>>> s = FakeSecretStore({"provider:openai": "sk-test"})
>>> s.get("provider:openai")
'sk-test'
>>> s.get("provider:chua-co") is None
True
"""
def __init__(self, seed: Dict[str, str] | None = None):
self._items: Dict[str, str] = dict(seed or {})
def get(self, key: str) -> str | None:
return self._items.get(key)
def set(self, key: str, value: str) -> None:
self._items[key] = value
def delete(self, key: str) -> None:
self._items.pop(key, None)
def has(self, key: str) -> bool:
return key in self._items
class FakeConfigRepository:
"""ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý.
Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống
mình cần::
cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung")
"""
def __init__(self, *, active_provider: str = "ollama",
providers: Dict[str, Dict[str, Any]] | None = None,
shared_dir: str = "", theme: str = "dark", language: str = "vi",
routing: Dict[str, Any] | None = None,
auth: Dict[str, Any] | None = None,
agent_security: Dict[str, Any] | None = None,
tools_disabled: list[str] | None = None,
history_dir: Path | None = None,
output_dir: Path | None = None):
self._active_provider = active_provider
self._providers = providers or {
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"},
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"},
}
self._shared_dir = shared_dir
self._theme = theme
self._language = language
self._routing = routing or {"mode": "off"}
self._auth = auth or {}
self._agent_security = agent_security or {"cowork_confirm_commands": True}
self._tools_disabled = list(tools_disabled or [])
self._history_dir = history_dir or Path("/fake/history")
self._output_dir = output_dir or Path("/fake/workspace")
#: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa
self.saves = 0
# ---- provider ------------------------------------------------------
@property
def active_provider(self) -> str:
return self._active_provider
def set_active_provider(self, name: str) -> None:
self._active_provider = name
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
return dict(self._providers.get(name or self._active_provider, {}))
# ---- đường dẫn -----------------------------------------------------
@property
def shared_dir(self) -> str:
return self._shared_dir
def history_dir(self) -> Path:
return self._history_dir
def cowork_output_dir(self) -> Path:
return self._output_dir
# ---- giao diện -----------------------------------------------------
@property
def theme(self) -> str:
return self._theme
def set_theme(self, value: str) -> None:
self._theme = value
@property
def language(self) -> str:
return self._language
def set_language(self, value: str) -> None:
self._language = value
# ---- nhóm cấu hình --------------------------------------------------
@property
def routing(self) -> Dict[str, Any]:
return self._routing
@property
def auth(self) -> Dict[str, Any]:
return self._auth
@property
def agent_security(self) -> Dict[str, Any]:
return self._agent_security
@property
def tools_disabled(self) -> list[str]:
return list(self._tools_disabled)
def set_tool_enabled(self, name: str, enabled: bool) -> None:
if enabled:
self._tools_disabled = [t for t in self._tools_disabled if t != name]
elif name not in self._tools_disabled:
self._tools_disabled.append(name)
# ---- ghi ------------------------------------------------------------
def save(self) -> None:
self.saves += 1
+113
View File
@@ -0,0 +1,113 @@
"""Fake LLM Provider for offline unit, contract, and characterization testing.
Provides deterministic responses, stream simulation, tool-call dispatching,
and fault injection without requiring any external network access or API keys.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from providers.base import CancelFn, Provider, ProviderError, TextCallback, ToolSpec
class FakeProvider(Provider):
"""Deterministic test double mimicking real LLM Providers (OpenAI, Anthropic, Ollama)."""
name = "fake"
supports_vision = True
def __init__(self, conf: Optional[Dict[str, Any]] = None) -> None:
# Initialize base provider with default configuration if none provided
super().__init__(conf or {"model": "fake-model-v1"})
# History of all message batches sent across all chat calls
self.call_history: List[List[Dict[str, Any]]] = []
# Queue of programmed assistant responses to return sequentially
self.response_queue: List[Dict[str, Any]] = []
# Queue of exceptions to raise on corresponding calls
self.error_queue: List[Exception] = []
# Default text returned when response queue is empty
self.default_text: str = "Fake model response."
# Total number of chat invocations
self.call_count: int = 0
# Recorded tool specs passed into each turn
self.last_tools: Optional[List[ToolSpec]] = None
def queue_response(
self,
content: str = "",
tool_calls: Optional[List[Dict[str, Any]]] = None,
reasoning: Optional[str] = None,
chunks: Optional[List[str]] = None,
) -> FakeProvider:
"""Enqueue a pre-configured response structure for upcoming chat turns."""
self.response_queue.append({
"content": content,
"tool_calls": tool_calls or [],
"reasoning": reasoning,
"chunks": chunks or ([content] if content else []),
})
return self
def queue_error(self, exc: Exception) -> FakeProvider:
"""Enqueue an exception to simulate network/API errors on the next turn."""
self.error_queue.append(exc)
return self
def chat(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[ToolSpec]] = None,
on_text: Optional[TextCallback] = None,
cancel: Optional[CancelFn] = None,
on_reasoning: Optional[TextCallback] = None,
) -> Dict[str, Any]:
"""Simulate single LLM turn with full streaming and tool-call support."""
self.call_count += 1
self.call_history.append([dict(m) for m in messages])
self.last_tools = tools
# 1. Check for injected errors
if self.error_queue:
raise self.error_queue.pop(0)
# 2. Check early cancellation before processing
if cancel and cancel():
raise ProviderError("Execution aborted by user cancel signal before response generation.")
# 3. Retrieve queued response or construct default response
if self.response_queue:
resp_spec = self.response_queue.pop(0)
content = resp_spec.get("content", "")
tool_calls = resp_spec.get("tool_calls", [])
reasoning = resp_spec.get("reasoning")
chunks = resp_spec.get("chunks", [content] if content else [])
else:
content = self.default_text
tool_calls = []
reasoning = None
chunks = [content]
# 4. Stream reasoning chunks if provided
if reasoning and on_reasoning:
on_reasoning(reasoning)
# 5. Stream text chunks, checking cancellation between fragments
for chunk in chunks:
if cancel and cancel():
raise ProviderError("Execution cancelled during text chunk streaming.")
if on_text and chunk:
on_text(chunk)
# 6. Return canonical assistant message payload
assistant_msg: Dict[str, Any] = {
"role": "assistant",
"content": content,
}
if tool_calls:
assistant_msg["tool_calls"] = tool_calls
return assistant_msg
def list_models(self) -> List[str]:
"""Return available mock models for settings and validation tests."""
return ["fake-model-v1", "fake-reasoner-pro", "fake-vision-plus"]
+71
View File
@@ -0,0 +1,71 @@
"""Fake Tool Executor for isolated, offline agent tool-call verification.
Allows tests to verify tool invocation arguments, mock tool return values,
and simulate failures/delays without performing unsafe host disk or OS operations.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
class FakeToolExecutor:
"""Mock execution engine for agent tool-call dispatching."""
def __init__(self) -> None:
# History of all executed tool invocations: List of {"name": str, "args": dict, "result": dict}
self.call_log: List[Dict[str, Any]] = []
# Custom handlers registered per tool name
self.handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
# Pre-programmed fixed responses keyed by tool name
self.mock_responses: Dict[str, Dict[str, Any]] = {}
# Default response when no specific handler or response is found
self.default_result: Dict[str, Any] = {"ok": True, "output": "Fake tool executed successfully."}
def register_handler(
self,
tool_name: str,
handler: Callable[[Dict[str, Any]], Dict[str, Any]],
) -> FakeToolExecutor:
"""Register a dynamic handler function for a specific tool name."""
self.handlers[tool_name] = handler
return self
def set_mock_response(
self,
tool_name: str,
result: Dict[str, Any],
) -> FakeToolExecutor:
"""Set a static return payload for a specific tool name."""
self.mock_responses[tool_name] = result
return self
def execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a tool call using registered mocks and record invocation details."""
# 1. Resolve result from handler, preset response, or default fallback
if tool_name in self.handlers:
result = self.handlers[tool_name](arguments)
elif tool_name in self.mock_responses:
result = self.mock_responses[tool_name]
else:
result = dict(self.default_result)
result["tool"] = tool_name
result["received_args"] = arguments
# 2. Record execution trace for post-test assertions
self.call_log.append({
"name": tool_name,
"args": dict(arguments),
"result": dict(result),
})
return result
def get_calls_for(self, tool_name: str) -> List[Dict[str, Any]]:
"""Retrieve all recorded calls for a given tool name."""
return [call for call in self.call_log if call["name"] == tool_name]
def reset(self) -> None:
"""Clear recorded logs and registered mock responses."""
self.call_log.clear()
self.handlers.clear()
self.mock_responses.clear()
+44
View File
@@ -0,0 +1,44 @@
"""ToolPolicyGateway giả — để N3 (Co4E) chạy được khi Team Hoa chưa cài đặt.
Mặc định cho qua hết, vì phần lớn test Co4E quan tâm tới luồng workflow chứ
không phải chính sách. Test nào cần kiểm nhánh bị chặn thì lập trình câu trả
lời::
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
"""
from __future__ import annotations
from typing import Callable, Dict
from cowork_local.domain.security.tool_policy import (
PolicyDecision, ToolCallRequest, allow,
)
class FakeToolPolicyGateway:
"""Cổng chính sách trong bộ nhớ, có ghi lại đã hỏi những gì."""
def __init__(self, rules: Dict[str, PolicyDecision] | None = None,
default: PolicyDecision | None = None,
decide: Callable[[ToolCallRequest], PolicyDecision] | None = None):
#: {tên tool: quyết định} — tra trước default
self.rules = dict(rules or {})
self.default = default or allow()
#: hàm tự quyết, dùng khi cần logic phức tạp hơn tra bảng
self._decide = decide
#: mọi lời gọi đã đi qua — để test khẳng định "có hỏi cổng không"
self.seen: list[ToolCallRequest] = []
def check(self, request: ToolCallRequest) -> PolicyDecision:
self.seen.append(request)
if self._decide is not None:
return self._decide(request)
return self.rules.get(request.name, self.default)
# ---- tiện cho test --------------------------------------------------
def asked_for(self, name: str) -> bool:
return any(r.name == name for r in self.seen)
@property
def call_count(self) -> int:
return len(self.seen)
+141
View File
@@ -0,0 +1,141 @@
"""Offline test doubles for the R04 turn runtime seams.
Sits beside ``fake_provider.py``/``fake_tool_executor.py`` (R01-T02) and plays
the same role one level up: those fake a *provider*, these fake the ports
``ConversationApplicationService`` is driven through
(``application/conversations/turn_runtime.py``).
Deliberately dumb — they record what they were asked and return canned answers.
A failing test then points at the service under test rather than at a mock
framework's configuration.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from cowork_local.domain.agents.agent_event import ToolPreview
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
class FakeSpec:
"""An advertised tool. The service only ever reads ``.name`` off a spec."""
def __init__(self, name: str) -> None:
self.name = name
class FakeReply:
"""One programmed provider answer."""
def __init__(self, content: str = "", tool_calls=None, chunks=None, reasoning: str = ""):
self.content = content
self.tool_calls = tool_calls or []
# Default to streaming the whole content as a single chunk, which is what
# a non-streaming gateway effectively does.
self.chunks = chunks if chunks is not None else ([content] if content else [])
self.reasoning = reasoning
class FakeModelCall:
""":class:`ModelCallPort` returning programmed replies in order.
A programmed entry may be an exception instead of a reply, which is how a
test simulates the gateway dying mid-turn.
"""
def __init__(self, replies: List[Any]) -> None:
self.replies = list(replies)
self.calls: List[Dict[str, Any]] = []
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
# Snapshot the messages: the service keeps mutating its own list, so
# storing it by reference would make every recorded call look identical.
self.calls.append({"messages": [dict(m) for m in messages],
"tool_names": [getattr(t, "name", "") for t in tools]})
reply = self.replies.pop(0) if self.replies else FakeReply(content="(default)")
if isinstance(reply, BaseException):
raise reply
if reply.reasoning and on_reasoning:
on_reasoning(reply.reasoning)
for chunk in reply.chunks:
if on_text and chunk:
on_text(chunk)
assistant: Dict[str, Any] = {"role": "assistant", "content": reply.content}
if reply.tool_calls:
assistant["tool_calls"] = reply.tool_calls
return assistant
class FakeToolRuntime:
""":class:`ToolRuntimePort` over an imaginary output folder."""
def __init__(self, specs=("save_file", "run_command", "update_plan"),
results: Optional[Dict[str, Dict[str, Any]]] = None,
removed: Tuple[str, ...] = (), added: Tuple[str, ...] = ()) -> None:
self._specs = [FakeSpec(n) for n in specs]
self._results = results or {}
self._removed, self._added = removed, added
self.executed: List[Tuple[str, Dict[str, Any]]] = []
self.finalize_calls: List[Dict[str, Any]] = []
# When set, every executed tool streams this string through ``on_output``.
self.emit_output: Optional[str] = None
def specs(self, allowed_tools=None):
if allowed_tools is None:
return list(self._specs)
return [s for s in self._specs if s.name in allowed_tools]
def preview(self, name, args):
return ToolPreview(kind="info", title=name, text=str(args))
def execute(self, name, args, on_output=None, cancel=None):
self.executed.append((name, dict(args)))
if self.emit_output and on_output:
on_output(self.emit_output)
return dict(self._results.get(name, {"ok": True, "output": f"{name} ok"}))
def snapshot(self):
return "before"
def finalize(self, before, cancelled=False):
self.finalize_calls.append({"before": before, "cancelled": cancelled})
return list(self._removed), list(self._added)
# --------------------------------------------------------------------------- #
# Small helpers shared by the turn tests.
# --------------------------------------------------------------------------- #
def make_request(**overrides) -> ConversationExecutionRequest:
"""A minimal valid request; each test overrides only what it exercises."""
base: Dict[str, Any] = {"turn_id": "t1", "session_id": "s1", "prompt": "do it"}
base.update(overrides)
return ConversationExecutionRequest(**base)
def run_turn(service, request=None, cancel=None):
"""Execute a turn and return ``(result, events)``."""
events: List[Any] = []
result = service.execute(request or make_request(), events.append, cancel=cancel)
return result, events
def events_of_type(events, cls):
"""Every emitted event of one type, in order."""
return [e for e in events if isinstance(e, cls)]
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
"""A turn that calls one tool and then answers — ``(model, tools)``."""
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
FakeReply(content="done")])
return model, FakeToolRuntime(**tool_kwargs)
__all__ = [
"FakeSpec", "FakeReply", "FakeModelCall", "FakeToolRuntime",
"make_request", "run_turn", "events_of_type", "tool_turn",
]
+7
View File
@@ -0,0 +1,7 @@
"""Integration tests: several real layers wired together, still fully offline.
Where unit tests pin one class against fakes and contract tests pin an interface
across implementations, these exercise a real path end to end — e.g. the
application routing service on top of the real ``core/routing`` engine — so a
seam that only works against a mock is caught here.
"""
@@ -0,0 +1,113 @@
"""R04-T02 — the typed event vocabulary vs. what the real runtime emits.
The unit tests pin each event against the shape I *read* out of
``core/chat_agent.py``. This one removes the reading: it runs the actual
``run_cowork`` loop offline (FakeProvider, real tool execution, real cleanup)
and asserts every dict it emits is recognised by :func:`from_legacy_dict` and
survives a round trip byte-for-byte.
That makes it a guard against the two failure modes a hand-written vocabulary
has: an event type nobody modelled, and a key that silently changes meaning.
Either one would surface here as a failure instead of as a blank chat bubble
after R04-T03 starts routing events through the typed layer.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import pytest
from cowork_local.core import chat_agent
from cowork_local.domain.agents.agent_event_codec import from_legacy_dict
from cowork_local.tests.fakes.fake_provider import FakeProvider
def _run_turn_and_collect(tmp_path: Path, provider: FakeProvider) -> List[Dict[str, Any]]:
"""Run one real ``run_cowork`` turn offline and return every emitted dict."""
output_dir = tmp_path / "output"
output_dir.mkdir(parents=True, exist_ok=True)
emitted: List[Dict[str, Any]] = []
chat_agent.run_cowork(
provider=provider,
messages=[{"role": "user", "content": "make me a report"}],
output_dir=output_dir,
emit=emitted.append,
# security_config=None disables the AI guardrail layers, which is the
# documented behaviour for headless callers and keeps this test offline.
security_config=None,
title="Report",
)
return emitted
def _reporting_turn(tmp_path: Path) -> List[Dict[str, Any]]:
"""A turn that streams text, calls save_file, then answers — the common path."""
provider = FakeProvider()
provider.queue_response(
content="Writing it now.",
chunks=["Writing ", "it now."],
tool_calls=[{"id": "call_1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved to report.md.", chunks=["Saved to report.md."])
return _run_turn_and_collect(tmp_path, provider)
def test_the_runtime_emits_only_event_types_the_domain_layer_models(tmp_path: Path) -> None:
emitted = _reporting_turn(tmp_path)
unmodelled = sorted({e["type"] for e in emitted if from_legacy_dict(e) is None})
assert unmodelled == [], f"run_cowork emits event types R04-T02 does not model: {unmodelled}"
def test_every_emitted_event_round_trips_without_losing_a_key(tmp_path: Path) -> None:
emitted = _reporting_turn(tmp_path)
assert emitted, "the turn produced no events at all — the fixture is wrong"
for raw in emitted:
event = from_legacy_dict(raw)
assert event is not None, raw
assert event.to_legacy_dict() == raw, f"round trip changed the {raw['type']} event"
def test_a_tool_using_turn_really_exercises_the_tool_events(tmp_path: Path) -> None:
# Guards the test above from passing trivially: if the fixture ever stopped
# calling a tool, the round-trip check would only cover text events.
types = {e["type"] for e in _reporting_turn(tmp_path)}
assert {"text", "assistant_done", "tool_proposed", "tool_result"} <= types
def test_reasoning_events_from_a_thinking_model_round_trip(tmp_path: Path) -> None:
# A separate fixture because only reasoning models emit these, and the
# common-path turn above would otherwise never cover the event.
provider = FakeProvider()
provider.queue_response(content="42", chunks=["42"], reasoning="Let me think...")
emitted = _run_turn_and_collect(tmp_path, provider)
reasoning_events = [e for e in emitted if e["type"] == "reasoning"]
assert reasoning_events, "a reasoning model produced no reasoning event"
for raw in reasoning_events:
assert from_legacy_dict(raw).to_legacy_dict() == raw
def test_plan_events_from_the_real_update_plan_tool_round_trip(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Planning.",
tool_calls=[{"id": "call_1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"},
{"title": "Review", "status": "pending"}]}}],
)
provider.queue_response(content="Done.")
emitted = _run_turn_and_collect(tmp_path, provider)
plan_events = [e for e in emitted if e["type"] == "plan_set"]
assert plan_events, "update_plan did not produce a plan_set event"
for raw in plan_events:
assert from_legacy_dict(raw).to_legacy_dict() == raw
@@ -0,0 +1,207 @@
"""R04-T03 (c) — the service must behave exactly like ``run_cowork``.
The unit tests prove the loop follows the rules I wrote down. They cannot prove
those rules are the ones the shipped runtime actually follows. This file does:
each test scripts one provider, runs the SAME turn twice — once through
``core/chat_agent.py::run_cowork``, once through
``ConversationApplicationService`` wired by ``core_runtime_adapter`` — and
compares the emitted event stream, the resulting conversation and the tool list
the model was shown.
Anything the port got wrong (a missing event, a reordered guard, a different
tool set, a changed message) fails here rather than in front of a user. The only
allowed difference is the extra ``turn_completed`` event R04 introduces, which
has no legacy consumer.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from cowork_local.application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from cowork_local.core import chat_agent
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
from cowork_local.tests.fakes.fake_provider import FakeProvider
_USER_TURN = [{"role": "user", "content": "make me a report"}]
class _FakeGate:
"""Stands in for ``core/permissions.py::PermissionGate``."""
def __init__(self, approve: bool) -> None:
self.approve = approve
self.requests: List[Dict[str, Any]] = []
def request(self, action: Dict[str, Any]) -> bool:
self.requests.append(action)
return self.approve
def _normalise(events: List[Dict[str, Any]], out_dir: Path) -> List[Dict[str, Any]]:
"""Replace the run's own output path with a placeholder.
The two runs write into different temp folders, so absolute paths in
``tool_result``/``outputs_*`` events differ by construction. Everything else
must match verbatim.
"""
marker, raw = "<OUT>", str(out_dir)
def scrub(value: Any) -> Any:
if isinstance(value, str):
return value.replace(raw, marker).replace(raw.replace("\\", "/"), marker)
if isinstance(value, list):
return [scrub(v) for v in value]
if isinstance(value, dict):
return {k: scrub(v) for k, v in value.items()}
return value
return [scrub(e) for e in events]
def _run_legacy(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
gate: Optional[_FakeGate] = None, max_steps: int = 30
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
"""Run the turn through the existing ``run_cowork``."""
out_dir = tmp_path / "legacy"
out_dir.mkdir(parents=True, exist_ok=True)
events: List[Dict[str, Any]] = []
messages = [dict(m) for m in _USER_TURN]
chat_agent.run_cowork(
provider, messages, out_dir, events.append, title="Report",
security_config=None, allowed_tools=allowed_tools, gate=gate, max_steps=max_steps,
)
tool_names = [t.name for t in (provider.last_tools or [])]
return _normalise(events, out_dir), messages, tool_names
def _run_service(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
gate: Optional[_FakeGate] = None, max_steps: int = 30
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
"""Run the same turn through the application service."""
out_dir = tmp_path / "service"
out_dir.mkdir(parents=True, exist_ok=True)
events: List[Dict[str, Any]] = []
service = build_cowork_conversation_service(
provider, out_dir, events.append, title="Report", security_config=None, gate=gate)
request = ConversationExecutionRequest(
turn_id="t1", session_id="s1",
# run_cowork receives the user message already appended; the request
# carries the history and this turn's prompt separately.
messages=_USER_TURN[:-1], prompt=_USER_TURN[-1]["content"],
output_dir=out_dir, allowed_tools=allowed_tools, max_steps=max_steps,
gate_mode="confirm" if gate is not None else "auto",
)
result = service.execute(request, legacy_event_sink(events.append))
# The end-of-turn event is new in R04 and has no legacy counterpart.
kept = [e for e in events if e.get("type") != "turn_completed"]
tool_names = [t.name for t in (provider.last_tools or [])]
return _normalise(kept, out_dir), list(result.messages), tool_names
def _assert_parity(tmp_path: Path, script, *, approve: Optional[bool] = None, **kwargs) -> None:
"""Script two identical providers, run both paths, compare everything."""
legacy_provider, service_provider = FakeProvider(), FakeProvider()
script(legacy_provider)
script(service_provider)
legacy_gate = _FakeGate(approve) if approve is not None else None
service_gate = _FakeGate(approve) if approve is not None else None
legacy_events, legacy_messages, legacy_tools = _run_legacy(
tmp_path, legacy_provider, gate=legacy_gate, **kwargs)
service_events, service_messages, service_tools = _run_service(
tmp_path, service_provider, gate=service_gate, **kwargs)
assert service_events == legacy_events
assert service_messages == legacy_messages
assert service_tools == legacy_tools
if legacy_gate is not None and service_gate is not None:
assert [r["name"] for r in service_gate.requests] == \
[r["name"] for r in legacy_gate.requests]
# --------------------------------------------------------------------------- #
# Scenarios.
# --------------------------------------------------------------------------- #
def test_a_plain_answer_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
_assert_parity(tmp_path, script)
def test_a_save_file_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Writing it.",
tool_calls=[{"id": "c1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved.")
_assert_parity(tmp_path, script)
def test_an_update_plan_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Planning.",
tool_calls=[{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"},
{"title": "Ship", "status": "pending"}]}}],
)
provider.queue_response(content="Done.")
_assert_parity(tmp_path, script)
def test_a_reasoning_only_reply_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="", reasoning="thinking hard")
_assert_parity(tmp_path, script)
def test_restricting_the_tool_scope_advertises_the_same_tools(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="ok")
_assert_parity(tmp_path, script, allowed_tools=["save_file"])
def test_a_rejected_command_behaves_identically(tmp_path: Path) -> None:
# The security-critical path: the gate says no, so the command must never
# run and the model must read back the same refusal in both designs.
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Running it.",
tool_calls=[{"id": "c1", "name": "run_command",
"arguments": {"command": "echo hi"}}],
)
provider.queue_response(content="Understood.")
_assert_parity(tmp_path, script, approve=False)
def test_hitting_the_step_ceiling_behaves_identically(tmp_path: Path) -> None:
# The model never stops calling tools, so both paths must stop at the same
# place and say so the same way.
def script(provider: FakeProvider) -> None:
for i in range(4):
provider.queue_response(
content=f"step {i}",
tool_calls=[{"id": f"c{i}", "name": "save_file",
"arguments": {"filename": f"f{i}.md", "content": "x"}}],
)
_assert_parity(tmp_path, script, max_steps=2)
+220
View File
@@ -0,0 +1,220 @@
"""R04-T04 — the migrated Cowork call site, exercised end to end without Qt.
``CoworkTab.build_job`` only ever *reads attributes* off its widget, so the real
production method can be invoked against a stand-in that supplies those
attributes. That is what happens here: the actual ``build_job`` body runs, builds
a request, wires the service through ``core_runtime_adapter``, and drives a real
turn (real tool execution, real output-folder cleanup) against ``FakeProvider``.
Why it matters: this is the only automated check that the widget's contract with
the service still holds — that the worker's list is appended to in place (the
transcript re-render and history merge both read it), that events still arrive as
legacy dicts, and that a produced file really lands in the turn's folder. None of
it needs a display server, so it runs in CI like every other test.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.tests.fakes.fake_provider import FakeProvider
class _FakeWorker:
"""The parts of ``core/worker.py::AgentWorker`` a job actually touches."""
def __init__(self, approve_commands: bool = True) -> None:
self.events: List[Dict[str, Any]] = []
self.gate: Optional[Any] = None
self._approve = approve_commands
self.cancelled = False
def emit_event(self, event: Dict[str, Any]) -> None:
self.events.append(event)
def is_cancelled(self) -> bool:
return self.cancelled
def new_gate(self, mode: str, agent_role: str = "") -> Any:
# Mirrors AgentWorker.new_gate: the gate is stored on the worker so the
# UI thread can resolve it, and answers request() from the worker thread.
worker = self
class _Gate:
requests: List[Dict[str, Any]] = []
def request(self, action: Dict[str, Any]) -> bool:
self.requests.append(action)
return worker._approve
self.gate = _Gate()
return self.gate
class _FakeCtx:
"""The ``AppContext`` surface ``build_job`` uses."""
def __init__(self, config: AppConfig, confirm_commands: bool = False) -> None:
self.config = config
self._confirm = confirm_commands
def project_confirm_commands(self) -> bool:
return self._confirm
def build_mcp_tools(self):
return [], None
class _WidgetStub:
"""Stands in for the CoworkTab instance ``build_job`` reads its state from."""
kind = "cowork"
def __init__(self, out_root: Path, ctx: _FakeCtx, provider: FakeProvider) -> None:
self._out_root = out_root
self.ctx = ctx
self._provider = provider
self.title = "Report"
self.session_id = "s1"
self.project_id = "" # the auto-seeded default workspace
self._model = ""
self._routed_provider = None
self._routed_model = None
def _session_output_dir(self) -> Path:
return self._out_root
def workspace_dir(self) -> Path:
return self._out_root
def admin_agent_prompt(self) -> str:
return ""
def build_provider(self) -> FakeProvider:
return self._provider
def _config() -> AppConfig:
"""A real AppConfig that never touches ``~/.cowork_local``.
The AI security guardrails are switched off: they would call the model to
review the prompt, which is a separate feature with its own tests and would
make this one depend on what the fake answers.
"""
data = copy.deepcopy(DEFAULT_CONFIG)
data["agent_security"]["enabled"] = False
return AppConfig(data)
def _run_turn(tmp_path: Path, provider: FakeProvider, messages: List[Dict[str, Any]],
*, confirm_commands: bool = False, approve: bool = True):
"""Invoke the real ``CoworkTab.build_job`` against the stub and run its job."""
from cowork_local.ui.cowork_tab import CoworkTab
out_dir = tmp_path / ".turns" / "t1"
out_dir.mkdir(parents=True, exist_ok=True)
widget = _WidgetStub(tmp_path, _FakeCtx(_config(), confirm_commands), provider)
worker = _FakeWorker(approve_commands=approve)
job = CoworkTab.build_job(widget, "make me a report", messages, out_dir)
result = job(worker)
return result, worker
def test_the_turn_runs_and_reports_its_folder(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
messages = [{"role": "user", "content": "make me a report"}]
result, worker = _run_turn(tmp_path, provider, messages)
assert result["turn_dir"] == str(tmp_path / ".turns" / "t1")
assert [e["type"] for e in worker.events] == [
"text", "text", "assistant_done", "turn_completed"]
def test_the_worker_list_is_appended_to_in_place(tmp_path: Path) -> None:
# _reattach_running_turn replays from this very list while the turn runs, and
# _finalize_turn slices it by the pre-turn length afterwards.
provider = FakeProvider()
provider.queue_response(content="Done.")
user = {"role": "user", "content": "make me a report"}
messages = [user]
result, _ = _run_turn(tmp_path, provider, messages)
assert result["messages"] is messages
# Identity, not just equality: _reattach_running_turn locates the turn's user
# message with ``m is ctx["user_msg"]`` to replay the steps after it.
assert any(m is user for m in messages)
# Several system blocks are expected — the tool prompt plus the tagged
# skills/security-rules blocks the runtime refreshes on every turn.
assert [m["role"] for m in messages if m["role"] != "system"] == ["user", "assistant"]
assert messages[-1]["content"] == "Done."
def test_a_saved_file_lands_in_the_turn_folder(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Writing it.",
tool_calls=[{"id": "c1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved.")
messages = [{"role": "user", "content": "make me a report"}]
_, worker = _run_turn(tmp_path, provider, messages)
produced = list((tmp_path / ".turns" / "t1").glob("*.md"))
assert len(produced) == 1
assert produced[0].read_text(encoding="utf-8") == "# Report\n"
results = [e for e in worker.events if e["type"] == "tool_result"]
assert results and results[0]["ok"] is True
def test_auto_run_mode_never_creates_a_permission_gate(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="ok")
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "hi"}],
confirm_commands=False)
assert worker.gate is None
def test_confirm_mode_creates_the_gate_and_a_refusal_stops_the_command(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Running it.",
tool_calls=[{"id": "c1", "name": "run_command",
"arguments": {"command": "echo hi"}}],
)
provider.queue_response(content="Understood.")
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "run it"}],
confirm_commands=True, approve=False)
assert worker.gate is not None
refusals = [e for e in worker.events
if e["type"] == "tool_result" and e["output"] == "Rejected by user."]
assert len(refusals) == 1
def test_cancelling_before_the_turn_starts_calls_no_model(tmp_path: Path) -> None:
from cowork_local.ui.cowork_tab import CoworkTab
provider = FakeProvider()
provider.queue_response(content="never")
out_dir = tmp_path / ".turns" / "t1"
out_dir.mkdir(parents=True)
widget = _WidgetStub(tmp_path, _FakeCtx(_config()), provider)
worker = _FakeWorker()
worker.cancelled = True
CoworkTab.build_job(widget, "x", [{"role": "user", "content": "x"}], out_dir)(worker)
assert provider.call_count == 0
@@ -0,0 +1,249 @@
"""R03-T03/T04/T05 — the unified routing path over the REAL routing engine.
The unit tests drive ``RoutingApplicationService`` against fakes; this suite
proves the same service produces correct outcomes on top of the actual
``core/routing`` stack (classifier → assessment store → scorer → selector →
switch controller), which is what the three chat surfaces now call.
Offline by construction: a fake probe client answers benchmarks and judging, and
the assessment store is a temp file — no network, no Qt, no ``$HOME`` writes.
"""
from __future__ import annotations
import copy
import pytest
from cowork_local.application.model_routing import (
AppContextModeResolver,
CoreRoutingEngine,
RoutingApplicationService,
RoutingMode,
RoutingRequest,
)
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.core import projects as projects_mod
from cowork_local.core.routing.clients import CompletionResult
from cowork_local.core.routing.service import RoutingService
from cowork_local.core.routing.store import AssessmentStore
from cowork_local.state import AppContext
STRONG_ANSWER = "STRONG-DETAILED-CORRECT-ANSWER"
WEAK_ANSWER = "weak"
class FakeProbeClient:
"""Deterministic stand-in for the provider layer used during assessment.
Mirrors ``tests/routing/test_service.py``'s client: benchmark prompts get a
per-model canned answer, and judge prompts are graded by looking up that
answer, so scores are stable and no model is ever really called.
"""
def __init__(self, answers, quality) -> None:
self.answers = answers
self.quality = quality
def complete(self, provider, model_id, messages) -> CompletionResult:
text = messages[0]["content"]
if "grading an AI assistant" in text: # the judge rubric prompt
score = 0.0
for answer, value in self.quality.items():
if answer and answer in text:
score = value
break
return CompletionResult(text='{"score": %s}' % score)
answer = self.answers.get((provider, model_id))
if answer is None:
return CompletionResult(error="unavailable")
return CompletionResult(text=answer, tokens_out=len(answer) // 4)
@pytest.fixture()
def ctx(tmp_path, monkeypatch):
"""An AppContext with two assessable models and temp-only persistence."""
# Keep workspace load/save off the developer's real ~/.cowork_local.
monkeypatch.setattr(projects_mod, "PROJECTS_DIR", tmp_path / "projects")
data = copy.deepcopy(DEFAULT_CONFIG)
data["providers"] = {
"anthropic": {"base_url": "x", "api_key": "x", "model": "strong-model"},
}
data["routing"]["candidates"] = [
{"provider": "anthropic", "model_id": "strong-model", "tier": "powerful"},
{"provider": "anthropic", "model_id": "weak-model", "tier": "fast"},
]
data["routing"]["judge_provider"] = "anthropic"
data["routing"]["judge_model"] = "judge-model"
data["routing"]["policy"] = "quality"
data["routing"]["min_score_gain"] = 0.05
return AppContext(AppConfig(data=data, path=tmp_path / "config.json"))
@pytest.fixture()
def routing_service(ctx, tmp_path) -> RoutingService:
"""A real RoutingService with a populated assessment store."""
client = FakeProbeClient(
answers={
("anthropic", "strong-model"): STRONG_ANSWER,
("anthropic", "weak-model"): WEAK_ANSWER,
},
quality={STRONG_ANSWER: 0.95, WEAK_ANSWER: 0.35},
)
store = AssessmentStore(store_path=tmp_path / "assess.json",
history_dir=tmp_path / "history")
service = RoutingService(ctx, store=store, client=client)
service.reassess() # populate real probe results + fit scores
return service
@pytest.fixture()
def app_service(ctx, routing_service) -> RoutingApplicationService:
"""The application service wired exactly the way the UI wires it."""
return RoutingApplicationService(
CoreRoutingEngine(routing_service),
AppContextModeResolver(ctx),
confirm_timeout_sec=lambda: float(ctx.config.routing["confirm_timeout_sec"]),
)
def coding_request(**overrides) -> RoutingRequest:
"""A coding turn currently pinned to the weaker model."""
fields = dict(
surface="cowork",
prompt="Write a Python function to reverse a linked list",
current_provider="anthropic",
current_model="weak-model",
)
fields.update(overrides)
return RoutingRequest(**fields)
# --------------------------------------------------------------------------- #
# Auto / Off / Manual over the real engine
# --------------------------------------------------------------------------- #
def test_auto_switches_to_the_better_assessed_model(app_service) -> None:
"""The real scorer must rank the strong model first and the service must
hand that model back as this turn's override."""
outcome = app_service.resolve(coding_request(mode=RoutingMode.AUTO))
assert outcome.switched is True
assert outcome.provider == "anthropic"
assert outcome.model == "strong-model"
assert outcome.task_type == "coding" # classified from the prompt
assert outcome.score_gain > 0
def test_off_keeps_the_pinned_model(app_service) -> None:
"""Off must not switch even when a clearly better model is assessed."""
outcome = app_service.resolve(coding_request(mode=RoutingMode.OFF))
assert outcome.switched is False
assert outcome.provider is None
def test_manual_asks_before_switching(app_service) -> None:
"""The confirm callback receives the engine's own decision object, which is
what ``ui/routing_toggle.py::confirm_switch`` renders."""
seen: list = []
outcome = app_service.resolve(
coding_request(mode=RoutingMode.MANUAL),
confirm=lambda decision, timeout: seen.append((decision, timeout)) or True,
)
assert outcome.switched is True
decision, timeout = seen[0]
assert decision.to_model == "anthropic/strong-model"
assert decision.reason # human-readable explanation
assert timeout == pytest.approx(60.0) # from DEFAULT_CONFIG
def test_manual_decline_keeps_the_pinned_model(app_service) -> None:
outcome = app_service.resolve(
coding_request(mode=RoutingMode.MANUAL),
confirm=lambda decision, timeout: False,
)
assert outcome.switched is False
assert outcome.declined is True
def test_already_best_model_is_left_alone(app_service) -> None:
"""No pointless churn: being on the best model is not a switch."""
outcome = app_service.resolve(
coding_request(mode=RoutingMode.AUTO, current_model="strong-model"))
assert outcome.switched is False
# --------------------------------------------------------------------------- #
# Fallback over the real engine
# --------------------------------------------------------------------------- #
def test_fallback_keeps_an_assessed_model_even_though_a_better_one_exists(app_service) -> None:
"""weak-model IS usable (it has a real probe score), so Fallback stays put
where Auto would switch — the behavioural difference between the modes."""
outcome = app_service.resolve(coding_request(mode=RoutingMode.FALLBACK))
assert outcome.switched is False
def test_fallback_rescues_a_model_the_engine_cannot_serve(app_service) -> None:
"""A model absent from the ranking (never assessed / unavailable) is exactly
the situation Fallback exists for."""
outcome = app_service.resolve(
coding_request(mode=RoutingMode.FALLBACK, current_model="ghost-model"))
assert outcome.switched is True
assert outcome.model == "strong-model"
# --------------------------------------------------------------------------- #
# Surface parity — the point of R03-T04/T05
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("surface", ["cowork", "co4e", "ai_edit"])
def test_every_surface_gets_the_same_decision(app_service, surface) -> None:
"""Chat, Co4E and AI-Edit used to hold three copies of this logic. Given the
same inputs they must now be indistinguishable."""
outcome = app_service.resolve(coding_request(surface=surface, mode=RoutingMode.AUTO))
assert outcome.switched is True
assert outcome.model == "strong-model"
def test_ai_edit_pinned_task_type_reaches_the_engine(app_service) -> None:
"""AI-Edit pins "coding" instead of classifying; the engine must honour it
even when the instruction text reads like something else entirely."""
outcome = app_service.resolve(coding_request(
surface="ai_edit",
prompt="Write a poem about the ocean", # classifier would say "creative"
task_type="coding",
mode=RoutingMode.AUTO,
))
assert outcome.task_type == "coding"
def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None:
"""With no explicit mode, the service reads the per-workspace setting — the
lookup the widgets used to do themselves."""
ctx.config.data["routing"]["switch_mode"] = "auto"
outcome = app_service.resolve(coding_request())
assert outcome.mode is RoutingMode.AUTO
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_unknown_persisted_mode_degrades_to_off(ctx) -> None:
"""A hand-edited config must not enable routing by accident."""
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
assert ctx.config.routing_mode_for("cowork") == "off"
@@ -0,0 +1,191 @@
"""R04-T05 — the Schedule Task runner's cowork branch, pinned before and after.
Written against the CURRENT ``_run_agent`` first, as the safety net for moving it
onto ``ConversationApplicationService``: an unattended run has five behaviours the
interactive path does not have (the plan reminder prefixed to the prompt, the
session registered in History before the model starts, a re-save after every
assistant message, the timeout notice, and the "did the agent's own checklist
finish?" report), and none of them was covered by a test.
Everything is isolated from the user's real config: history goes to ``tmp_path``
via ``history.custom_dir`` and the AI guardrails are off, so no run touches
``~/.cowork_local`` or calls a model to review a prompt.
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.core import task_executors
from cowork_local.tests.fakes.fake_provider import FakeProvider
class _FakeCtx:
"""The ``AppContext`` surface ``_run_agent`` touches."""
def __init__(self, config: AppConfig, provider: FakeProvider) -> None:
self.config = config
self._provider = provider
def build_active_provider(self) -> FakeProvider:
return self._provider
def build_provider_for(self, name=None, model=None) -> FakeProvider:
return self._provider
def _config(tmp_path: Path) -> AppConfig:
data = copy.deepcopy(DEFAULT_CONFIG)
# Keep the run entirely offline and off the real config dir.
data["agent_security"]["enabled"] = False
data["history"]["custom_dir"] = str(tmp_path / "history")
return AppConfig(data)
def _run(tmp_path: Path, provider: FakeProvider, *, prompt: str = "write the report",
timeout_sec: Optional[int] = None, admin_agent: Any = None):
"""Run one cowork task and return ``(result_tuple, events, config)``."""
out_dir = tmp_path / "run"
out_dir.mkdir(parents=True, exist_ok=True)
config = _config(tmp_path)
events: List[Dict[str, Any]] = []
result = task_executors._run_agent(
_FakeCtx(config, provider), "cowork", prompt, out_dir,
events.append, lambda: False, title="Weekly report",
timeout_sec=timeout_sec, admin_agent=admin_agent,
)
return result, events, config
def _saved_conversation(config: AppConfig) -> Dict[str, Any]:
"""The single conversation the run wrote into the isolated history folder."""
files = list(Path(config.history_dir()).rglob("*.json"))
assert len(files) == 1, f"expected one saved conversation, found {files}"
return json.loads(files[0].read_text(encoding="utf-8"))
# --------------------------------------------------------------------------- #
def test_a_cowork_task_returns_the_final_answer(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="Report is ready.")
(answer, timed_out, incomplete), _events, _config = _run(tmp_path, provider)
assert answer == "Report is ready."
assert timed_out is False
assert incomplete == ""
def test_the_plan_reminder_is_prefixed_to_the_prompt(tmp_path: Path) -> None:
# An unattended run has nobody watching, so the agent is pushed to keep its
# own checklist honest. The reminder must lead the message.
provider = FakeProvider()
provider.queue_response(content="ok")
_run(tmp_path, provider, prompt="write the report")
sent = provider.call_history[0][-1]["content"]
assert sent.startswith("This runs unattended (Schedule Task)")
assert sent.endswith("write the report")
def test_an_admin_agent_persona_sits_between_the_reminder_and_the_prompt(
tmp_path: Path) -> None:
class _Agent:
# An admin agent may pin its own provider/model; blank means "use the
# machine's Settings default", which is what build_agent_provider reads.
provider = ""
model = ""
def effective_prompt(self) -> str:
return "You are the reporting agent."
provider = FakeProvider()
provider.queue_response(content="ok")
_run(tmp_path, provider, prompt="write the report", admin_agent=_Agent())
sent = provider.call_history[0][-1]["content"]
assert sent.index("This runs unattended") < sent.index("You are the reporting agent.")
assert sent.index("You are the reporting agent.") < sent.index("write the report")
def test_the_session_is_announced_once_it_exists_on_disk(tmp_path: Path) -> None:
# The scheduler refreshes History on this event, so it must not fire before
# the conversation is really there.
provider = FakeProvider()
provider.queue_response(content="ok")
_result, events, config = _run(tmp_path, provider)
ready = [e for e in events if e["type"] == "history_ready"]
assert len(ready) == 1
assert ready[0]["session_id"]
assert _saved_conversation(config)["session_id"] == ready[0]["session_id"]
def test_the_saved_conversation_carries_the_answer_and_the_task_title(
tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="Report is ready.")
_result, _events, config = _run(tmp_path, provider)
saved = _saved_conversation(config)
assert saved["title"] == "[Task] Weekly report"
assert saved["messages"][-1] == {"role": "assistant", "content": "Report is ready."}
def test_an_unfinished_checklist_is_reported_back_to_the_scheduler(
tmp_path: Path) -> None:
# The agent ticked no step to done, so the task must not be called finished
# just because no exception was raised.
provider = FakeProvider()
provider.queue_response(
content="Working on it.",
tool_calls=[{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"}]}}],
)
provider.queue_response(content="Stopping here.")
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
assert incomplete
assert "Draft" in incomplete
def test_a_finished_checklist_reports_nothing_outstanding(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Done.",
tool_calls=[{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "done"}]}}],
)
provider.queue_response(content="All done.")
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
assert incomplete == ""
def test_running_out_of_time_appends_the_timeout_notice_to_the_conversation(
tmp_path: Path) -> None:
# A negative timeout puts the deadline in the past, which is the only
# deterministic way to exercise a wall-clock branch in a unit test.
provider = FakeProvider()
provider.queue_response(content="never gets there")
(answer, timed_out, incomplete), events, config = _run(
tmp_path, provider, timeout_sec=-1)
assert timed_out is True
assert incomplete == "" # a timeout is not an unfinished checklist
assert "quá thời gian chờ" in answer
assert any(e["type"] == "assistant_done" and "quá thời gian chờ" in e["content"]
for e in events)
assert "quá thời gian chờ" in _saved_conversation(config)["messages"][-1]["content"]
+5 -13
View File
@@ -1,17 +1,9 @@
"""Pytest fixtures/shared helpers for the routing test suite.
Ensures the ``cowork_local`` package is importable when pytest is invoked from
the package directory itself (so ``import cowork_local.core.routing...`` works
regardless of the working directory the suite is launched from).
Package importability is handled once and for all by ``tests/conftest.py``,
which binds THIS checkout to the ``cowork_local`` name in ``sys.modules``.
This file used to push the checkout's PARENT directory onto ``sys.path``, which
let an unrelated sibling folder named ``cowork_local`` shadow the working copy —
so that logic is intentionally gone; keep it that way.
"""
from __future__ import annotations
import sys
from pathlib import Path
# .../cowork_local/tests/routing/conftest.py → parent of the package dir
_PKG_DIR = Path(__file__).resolve().parents[2] # .../cowork_local
_REPO_ROOT = _PKG_DIR.parent # .../cowork_local_20260722
for p in (str(_REPO_ROOT), str(_PKG_DIR)):
if p not in sys.path:
sys.path.insert(0, p)
+142
View File
@@ -0,0 +1,142 @@
"""AtomicJsonFile — R02-T01. Test tiêm lỗi, đúng như cột nghiệm thu của plan.md.
Cách kiểm: cắt ngang giữa lúc ghi rồi khẳng định file cũ **còn nguyên**. Nếu
chỉ test "ghi rồi đọc lại thấy đúng" thì `path.write_text()` cũ cũng qua — mà
đó chính là thứ ta đang thay.
"""
from __future__ import annotations
import json
import os
import pytest
from cowork_local.infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
def test_ghi_roi_doc_lai(tmp_path):
f = AtomicJsonFile(tmp_path / "cau_hinh.json")
f.write({"theme": "dark", "ngôn ngữ": "vi"})
assert f.read() == {"theme": "dark", "ngôn ngữ": "vi"}
def test_chua_co_file_thi_tra_mac_dinh(tmp_path):
f = AtomicJsonFile(tmp_path / "chua-ton-tai.json")
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
assert f.exists() is False
def test_chet_giua_luc_ghi_thi_file_cu_con_nguyen(tmp_path, monkeypatch):
"""Lõi của R02-T01.
Giả lập mất điện đúng lúc: cho ``os.replace`` ném lỗi. Đây là bước cuối
cùng, tức là dữ liệu mới đã nằm trong file tạm rồi — nếu cài đặt sai theo
kiểu ghi đè thẳng, file đích lúc này đã hỏng.
"""
path = tmp_path / "cau_hinh.json"
f = AtomicJsonFile(path)
f.write({"phiên bản": 1, "quan trọng": "đừng mất"})
def no_dien(*args, **kwargs):
raise OSError("mô phỏng mất điện")
monkeypatch.setattr(os, "replace", no_dien)
with pytest.raises(OSError):
f.write({"phiên bản": 2})
# bản cũ phải còn y nguyên
assert f.read() == {"phiên bản": 1, "quan trọng": "đừng mất"}
def test_khong_de_lai_rac_tmp_khi_ghi_hong(tmp_path, monkeypatch):
path = tmp_path / "cau_hinh.json"
f = AtomicJsonFile(path)
f.write({"a": 1})
monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("x")))
with pytest.raises(OSError):
f.write({"a": 2})
con_lai = [p.name for p in tmp_path.iterdir()]
assert con_lai == ["cau_hinh.json"], f"còn rác: {con_lai}"
def test_file_hong_thi_cach_ly_va_tra_mac_dinh(tmp_path):
"""Hỏng cấu hình không được chặn khởi động — giữ đúng hành vi config.py
hiện tại, nhưng thêm phần giữ lại bản hỏng để còn cứu."""
path = tmp_path / "cau_hinh.json"
path.write_text("{ đây không phải json", encoding="utf-8")
f = AtomicJsonFile(path)
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
assert not path.exists(), "file hỏng phải được dời đi"
bad = list(tmp_path.glob("*.bad-*"))
assert len(bad) == 1, "phải giữ lại bản hỏng để cứu tay"
assert "đây không phải json" in bad[0].read_text(encoding="utf-8")
def test_ghi_de_nhieu_lan_van_dung(tmp_path):
f = AtomicJsonFile(tmp_path / "dem.json")
for i in range(20):
f.write({"lần": i})
assert f.read() == {"lần": 19}
assert list(tmp_path.glob("*.tmp")) == []
def test_giu_nguyen_tieng_viet_khong_escape(tmp_path):
"""config.py hiện dùng ensure_ascii=False — giữ nguyên để file đọc được
bằng mắt và git diff không thành một đống \\uXXXX."""
path = tmp_path / "vi.json"
AtomicJsonFile(path).write({"tên": "Nguyễn Văn Đức"})
raw = path.read_text(encoding="utf-8")
assert "Nguyễn Văn Đức" in raw
assert "\\u" not in raw
def test_tao_thu_muc_cha_neu_chua_co(tmp_path):
f = AtomicJsonFile(tmp_path / "sâu" / "hơn" / "nữa" / "c.json")
f.write({"ok": True})
assert f.read() == {"ok": True}
def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path):
path = tmp_path / "c.json"
AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]})
assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]}
# ---- Windows: os.replace bị Defender/Indexer chặn tạm thời -----------------
def test_thu_lai_khi_windows_chan_tam_thoi(tmp_path, monkeypatch):
"""Hỏng 2 lần đầu rồi thành công — phải ghi được, không ném lỗi.
Đây là lỗi thật bắt được ngày 25/08: chạy vòng 20 lần ghi thì cứ 7 lượt
lại có 1 lượt văng ``PermissionError: [WinError 5]`` ở ``os.replace``.
"""
that = os.replace
con_hong = [2]
def replace_do_dong(src, dst):
if con_hong[0]:
con_hong[0] -= 1
raise PermissionError(5, "Access is denied")
return that(src, dst)
monkeypatch.setattr(os, "replace", replace_do_dong)
AtomicJsonFile(tmp_path / "a.json").write({"x": 1})
assert con_hong[0] == 0, "phải thật sự có thử lại, không phải may mà qua"
assert json.loads((tmp_path / "a.json").read_text(encoding="utf-8")) == {"x": 1}
assert list(tmp_path.glob("*.tmp")) == []
def test_hong_that_thi_van_nem_loi_va_khong_de_lai_rac(tmp_path, monkeypatch):
"""Thử lại không được phép nuốt lỗi quyền thật — hết lượt là ném."""
def luon_hong(src, dst):
raise PermissionError(5, "Access is denied")
monkeypatch.setattr(os, "replace", luon_hong)
with pytest.raises(PermissionError):
AtomicJsonFile(tmp_path / "b.json").write({"x": 1})
assert list(tmp_path.glob("*.tmp")) == [], "phải dọn file tạm"
+102
View File
@@ -0,0 +1,102 @@
"""Smoke test cho ``presentation.co4e.co4e_tab.build_co4e_tab`` — gọi thật
factory, dựng thật ``Co4ETab``, xác nhận nó không vỡ.
Vì sao chạy trong tiến trình con thay vì import thẳng trong tiến trình pytest
chính: ``CONFIG_DIR`` (config.py) và ``CO4E_DIR`` (core/co4e.py) đều là hằng số
module tính MỘT LẦN lúc import từ ``Path.home()``. Nhiều file test khác trong
bộ này (chạy trước theo thứ tự collect) đã import ``cowork_local.config``/
``cowork_local.core.co4e`` với HOME thật rồi — monkeypatch thuộc tính module
(cách ``tests/characterization/test_co4e_run_manager_behavior.py`` dùng cho
``CO4E_DIR``) chỉ vá được đúng chỗ đó, còn ``AppConfig.load()`` có thêm một bẫy
riêng: tham số mặc định ``path: Path = CONFIG_PATH`` được gán MỘT LẦN lúc định
nghĩa hàm, nên monkeypatch ``CONFIG_PATH`` sau đó không đổi được giá trị mặc
định đã đóng băng — gọi ``AppConfig.load()`` không tham số vẫn đọc file thật
dù đã vá module. Dựng ``Co4ETab`` thật kéo theo cả hai đường trên (và có thể
còn đường khác chưa biết, vì lớp này 2000+ dòng). Cô lập bằng biến môi trường
``HOME``/``USERPROFILE`` TRƯỚC bất kỳ import nào, trong một tiến trình con
sạch hoàn toàn, né được toàn bộ lớp bẫy này một lần — không cần biết hết mọi
hằng số tính lúc import ở đâu trong file 2000+ dòng đó.
Bắt được gì: đổi sai độ sâu dấu chấm ở import tương đối trong
``presentation/co4e/co4e_tab.py`` (``from ...ui.co4e_tab import Co4ETab``),
đổi chữ ký ``Co4ETab.__init__`` mà quên sửa lệnh gọi trong factory, hoặc
factory trả sai kiểu/sai ``ctx`` — không có test nào khác trong bộ này gọi
``build_co4e_tab()``, nên đây là lưới an toàn DUY NHẤT cho hàm này.
KHÔNG chạm dữ liệu thật: sandbox trống hoàn toàn, không copy
``~/.cowork_local`` thật (khác ``tools/capture_screens.py::_isolate_home()`` —
ở đó cố tình copy để chụp ảnh có dữ liệu mẫu; ở đây không cần, càng sạch càng
tốt cho một smoke test).
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
REPO_PARENT = REPO_ROOT.parent
_SMOKE_SCRIPT = """
import sys
sys.path.insert(0, {repo_parent!r})
from PySide6.QtWidgets import QApplication, QWidget
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.presentation.co4e.co4e_tab import build_co4e_tab
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
class _FakeWorkflowService:
\"\"\"Chua dung toi trong than ham build_co4e_tab hien tai (xem docstring
cua factory) - chi can mot doi tuong bat ky de kiem factory nhan dung
tham so bat buoc thu hai.\"\"\"
widget = build_co4e_tab(ctx, _FakeWorkflowService())
assert isinstance(widget, QWidget), "khong phai QWidget: " + repr(type(widget))
assert widget.ctx is ctx, "factory khong gan dung ctx cho widget tra ve"
print("SMOKE_OK")
"""
def test_build_co4e_tab_dung_that_va_gan_dung_ctx(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SMOKE_SCRIPT.format(repo_parent=str(REPO_PARENT))
result = subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"smoke build_co4e_tab that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
assert "SMOKE_OK" in result.stdout, result.stdout
# Sandbox khong duoc dung: chua co gi ghi vao no truoc khi tien trinh con
# chay (con AppConfig.load() khong ghi gi ca - chi save() moi ghi).
assert not (sandbox / ".cowork_local").exists(), (
"AppConfig.load() khong duoc tu tao thu muc config that trong sandbox"
)
+138
View File
@@ -0,0 +1,138 @@
"""Integration test đầu-cuối cho Co4E Studio — dựng THẬT ``Co4ETab`` qua
``build_co4e_tab()`` và lái một luồng người dùng thật xuyên qua NHIỀU panel đã
tách (canvas, chat, agent/skills list, run control) trong CÙNG MỘT instance,
để bắt lỗi mà các characterization test riêng từng panel (test_co4e_canvas_widget,
test_co4e_chat_view, test_co4e_runs_page, test_co4e_agent_panel,
test_co4e_skills_panel) không thể bắt: các panel đó mỗi cái dựng ĐỘC LẬP, không
đi qua ``Co4ETab`` thật nên không lộ lỗi wiring xuyên-panel (ví dụ: alias thiếu,
gọi nhầm panel khác, state canvas mất khi chuyển qua trang Runs rồi quay lại).
PHẠM VI CHỦ ĐỘNG LOẠI TRỪ — KHÔNG bấm nút Run/Stop và KHÔNG gọi bất kỳ
method nào dẫn tới ``Co4ERunManager.start()`` (dòng dẫn tới ``AgentWorker``/
``QThread``/gọi AI thật) — đúng nguyên tắc đã áp dụng xuyên suốt mọi
characterization test của lane N3 (xem ``test_co4e_run_manager_behavior.py``).
Trang "Flow Status"/Runs được kiểm ở trạng thái RỖNG (không có run nào), đủ để
xác nhận panel + wiring không vỡ khi chuyển trang, không cần một run thật.
Vì sao chạy trong tiến trình con cô lập HOME/USERPROFILE (giống
``test_build_co4e_tab.py``, xem docstring đầu file đó để biết đủ cả 2 cái bẫy
CONFIG_DIR/CONFIG_PATH tính lúc import): dựng ``Co4ETab`` thật kéo theo
``Co4ERunManager`` (đọc lịch sử run từ ``CO4E_DIR``/``CONFIG_DIR`` lúc
``__init__``) — không cô lập sẽ đọc/ghi vào ``~/.cowork_local`` thật của người
dùng chạy test.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = """
import sys
sys.path.insert(0, {repo_parent!r})
from PySide6.QtWidgets import QApplication
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.core.co4e import Step
from cowork_local.presentation.co4e.co4e_tab import build_co4e_tab
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
class _FakeWorkflowService:
\"\"\"build_co4e_tab() hien tai chua dung toi (xem presentation/co4e/co4e_tab.py) -
chi can mot doi tuong bat ky de kiem factory nhan dung tham so bat buoc thu hai.\"\"\"
tab = build_co4e_tab(ctx, _FakeWorkflowService())
# ---- 1) sidebar: 4 panel da tach deu co mat, danh sach rong luc moi dung ---
assert tab.wf_list.count() == 0, "wf_list phai rong luc moi dung"
assert tab.agent_list.count() >= 0 # AgentListPanel.list_widget qua alias
assert tab.skill_list.count() >= 0 # SkillsListPanel.list_widget qua alias
assert tab.runs_table.rowCount() == 0, "Flow Status phai rong khi chua co run nao"
# ---- 2) "New" workflow (wf_new_btn -> _new_workflow) -----------------------
tab.wf_new_btn.click()
assert tab.canvas.nodes() == [], "flow moi phai la canvas rong"
assert tab._wf.name, "flow moi phai co ten (untitled)"
# ---- 3) them 2 node ket noi tren canvas THAT (khong drag-drop, goi truc tiep
# dung method public da duoc characterization test_co4e_canvas_widget.py khoa
# hanh vi - integration test nay chi kiem NO CHAY DUOC xuyen qua Co4ETab thuc,
# khong lap lai chi tiet hanh vi canvas) --------------------------------------
n1 = tab.canvas.add_node(Step(label="Buoc 1"), x=60, y=60)
n2_id = None
tab.canvas.add_step_below(n1)
assert len(tab.canvas.nodes()) == 2, "canvas phai co 2 node sau add_node + add_step_below"
assert len(tab.canvas.edges()) == 1, "add_step_below phai tu noi edge tu node truoc"
# ---- 4) mo/thu gon khung chat (ChatPanel + _toggle_messages xuyen panel) ---
assert tab.chat_stack.isHidden(), "chat phai COLLAPSED mac dinh (dung dac ta ChatPanel)"
tab.chat_toggle_btn.click()
assert not tab.chat_stack.isHidden(), "bam nut thu/mo phai HIEN khung chat"
tab.chat_toggle_btn.click()
assert tab.chat_stack.isHidden(), "bam lan 2 phai AN lai (toggle dung 2 chieu)"
# ---- 5) chuyen qua trang Flow Status (RunsPagePanel) roi quay lai flow editor,
# xac nhan canvas KHONG mat 2 node da them o buoc 3 (rui ro thuc su cua viec
# tach RunsPagePanel: state flow co song sot qua center_stack.setCurrentIndex?)
tab.runs_btn.setChecked(True)
assert tab.center_stack.currentIndex() == 0, "bam Flow Status phai chuyen sang trang Runs"
assert tab.runs_table.rowCount() == 0, "van chua co run nao, bang phai rong"
tab.runs_btn.setChecked(False)
assert tab.center_stack.currentIndex() == 1, "bo chon Flow Status phai tro lai flow editor"
assert len(tab.canvas.nodes()) == 2, "quay lai flow editor KHONG duoc mat node da them truoc do"
# ---- CHU DINH KHONG lam: khong bam run_stop_btn/bat ky nut Run nao, khong goi
# tab.manager.start(...) - do se tao AgentWorker/QThread thuc va co the goi AI
# thuc (ngoai pham vi integration test nay, xem docstring dau file).
print("INTEGRATION_OK")
"""
def test_co4e_end_to_end_qua_nhieu_panel_da_tach(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.format(repo_parent=str(REPO_PARENT))
result = subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"integration test that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
assert "INTEGRATION_OK" in result.stdout, result.stdout
# Sandbox khong duoc dung: khong co run nao duoc kich hoat trong luot nay,
# nen Co4ERunManager khong co gi de ghi xuong dia (_save_history chi ghi khi
# changed.emit() that su co run/thay doi - _new_workflow khong dung toi
# manager, add_node/canvas khong dung toi manager).
assert not (sandbox / ".cowork_local" / "co4e" / "run_history.json").exists(), (
"chua co run nao thi khong duoc tu ghi lich su run xuong dia"
)
+577
View File
@@ -0,0 +1,577 @@
"""Test đặc tả cho phần vừa tách khỏi ``core/co4e_run_manager.py``:
* ``domain/workflows/run_record.py::RunRecord`` — DTO thuần domain.
* ``application/workflows/co4e_workflow_service.py::Co4EWorkflowService`` —
phần hành vi (hook + lifecycle + lưu lịch sử), thuần Python.
Khác với ``tests/characterization/test_co4e_run_manager_behavior.py`` (bọc lớp
CŨ, không được sửa), file này bọc lớp MỚI, và có thêm một test bắt buộc theo
yêu cầu tách: ``test_new_service_produces_same_json_record_as_old_manager`` —
chạy CÙNG một chuỗi thao tác trên CẢ HAI lớp (cũ và mới) với cùng input, rồi so
JSON ghi ra đĩa của chúng bằng nhau. Đây là bằng chứng "hành vi không lệch"
chạy được, không phải suy luận bằng mắt.
Không gọi ``Co4EWorkflowService.start()`` với ``runner=None`` bỏ qua — luôn
truyền ``runner`` fake không thực thi job thật (không gọi AI thật), giống lý do
``test_co4e_run_manager_behavior.py`` không bao giờ gọi ``Co4ERunManager.start()``
thật.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from cowork_local.application.workflows.co4e_workflow_service import Co4EWorkflowService
from cowork_local.core.co4e import Node, Step, Workflow
from cowork_local.domain.workflows.run_record import RunRecord
class _FakeConfig:
def __init__(self, output_dir: Path):
self._output_dir = output_dir
def cowork_output_dir(self) -> Path:
return self._output_dir
class _Ctx:
"""Stub ctx: chỉ ``start()``/``_out_dir()`` mới đụng ``ctx.config``."""
def __init__(self, output_dir: Path):
self.config = _FakeConfig(output_dir)
class _RecordingRunner:
"""Fake ``WorkflowRunner`` — ghi lại lời gọi ``start()``, KHÔNG thực thi
``job`` (job thật gọi ``core.co4e_runner.run_workflow`` -> AI thật, tốn
tiền/ghi file thật, đúng lý do old characterization test tránh gọi
``Co4ERunManager.start()``). Trả một handle giả để test ``stop()``."""
def __init__(self):
self.calls = []
def start(self, run_id, job, on_event, on_finished, on_failed):
handle = _FakeWorkerHandle()
self.calls.append((run_id, job, on_event, on_finished, on_failed, handle))
return handle
class _FakeWorkerHandle:
def __init__(self):
self.stop_requested = False
def request_stop(self):
self.stop_requested = True
def _make_workflow(node_count: int = 3, wf_id: str = "wf1", name: str = "Flow") -> Workflow:
# Dung dung dataclass that (core/co4e.py) thay vi stub -- workflow_to_dict()
# trong Co4EWorkflowService.start() doc n.id/n.x/n.y/n.data tren tung node
# va wf.is_template tren workflow, khong the gia lap bang string/duck-type
# thieu thuoc tinh.
nodes = [Node(id=f"n{i}", x=0.0, y=0.0, data=Step(label=f"Step{i}")) for i in range(1, node_count + 1)]
return Workflow(id=wf_id, name=name, nodes=nodes, edges=[])
@pytest.fixture
def service(tmp_path):
return Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "run_history.json")
def _seed(service: Co4EWorkflowService, run_id: str, **kw) -> RunRecord:
defaults = dict(wf_id="wf1", name="Flow", total=3, plan_mode=False, manual=False)
defaults.update(kw)
r = RunRecord(run_id, **defaults)
service._runs[run_id] = r
return r
# ---------------------------------------------------------------------------
# RunRecord: gia tri mac dinh / kep bien / round trip (khop ban cu)
# ---------------------------------------------------------------------------
def test_run_record_defaults_on_construction():
r = RunRecord("run1", "wf1", "My Flow", 3, False, False)
assert r.status == "running"
assert r.done == 0
assert r.progress_text() == "0/3"
assert r.running is True
def test_run_record_negative_total_clamped_to_zero():
r = RunRecord("run2", "wf2", "Flow2", -5, False, False)
assert r.total == 0
def test_run_record_zero_total_progress_text_falls_back_to_status():
r = RunRecord("run3", "wf3", "Flow3", 0, False, False)
assert r.progress_text() == "running"
def test_to_dict_contains_expected_keys_and_values():
r = RunRecord("run1", "wf1", "My Flow", 3, False, False,
created_by="alice", created_at="2026-08-23 10:00", project_id="p1")
rec = r.to_dict()
assert sorted(rec.keys()) == [
"created_at", "created_by", "done", "error", "id", "manual", "name",
"node_status", "out_dir", "plan_mode", "project_id", "status", "total",
"wf", "wf_id",
]
assert rec["id"] == "run1"
assert rec["status"] == "running"
assert rec["wf"] is None
def test_round_trip_status_running_becomes_stopped():
r = RunRecord("run1", "wf1", "My Flow", 3, False, False)
rec = r.to_dict()
assert rec["status"] == "running"
back = RunRecord.from_dict(rec)
assert back.status == "stopped"
@pytest.mark.parametrize("status", ["done", "error", "stopped"])
def test_round_trip_non_running_statuses_are_preserved(status):
r = RunRecord("run1", "wf1", "My Flow", 3, False, False)
r.status = status
back = RunRecord.from_dict(r.to_dict())
assert back.status == status
def test_from_dict_empty_dict_uses_documented_defaults():
r = RunRecord.from_dict({})
assert r.id == ""
assert r.status == "done"
assert r.wf is None
assert r.node_status == {}
def test_from_dict_none_treated_same_as_empty_dict():
assert RunRecord.from_dict(None).id == RunRecord.from_dict({}).id
assert RunRecord.from_dict(None).status == RunRecord.from_dict({}).status
def test_round_trip_preserves_raw_workflow_snapshot_dict():
# domain khong parse "wf" thanh doi tuong -- giu nguyen dict tho (khac
# RunHandle cu, xem docstring domain/workflows/run_record.py).
r = RunRecord("run4", "wf-x", "Flow X run", 1, False, False)
r.wf = {"id": "wf-x", "name": "Flow X", "nodes": [{"id": "n1"}], "edges": []}
back = RunRecord.from_dict(r.to_dict())
assert back.wf == r.wf
assert isinstance(back.wf, dict)
# ---------------------------------------------------------------------------
# _on_event / _on_finished / _on_failed (hanh vi khop ban cu, callback thay Signal)
# ---------------------------------------------------------------------------
def test_on_event_node_status_done_increments_progress_and_emits_changed(service):
r = _seed(service, "run1")
changed = []
service.on_changed(lambda: changed.append(1))
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
assert r.node_status == {"n1": "done"}
assert r.done == 1
assert len(changed) == 1
def test_on_event_node_status_planned_counts_as_terminal_too(service):
r = _seed(service, "run1")
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "planned"})
assert r.done == 1
def test_on_event_node_status_running_is_not_terminal(service):
r = _seed(service, "run1")
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "running"})
assert r.done == 0
def test_on_event_node_status_missing_keys_stores_none_key(service):
r = _seed(service, "run1")
service._on_event("run1", {"type": "node_status"})
assert r.node_status == {None: None}
def test_on_event_run_done_default_ok_marks_done(service):
r = _seed(service, "run1")
service._on_event("run1", {"type": "run_done"})
assert r.status == "done"
def test_on_event_run_done_ok_false_marks_error(service):
r = _seed(service, "run1")
service._on_event("run1", {"type": "run_done", "ok": False})
assert r.status == "error"
def test_on_event_run_done_ignored_when_not_running(service):
r = _seed(service, "run1")
r.status = "stopped"
service._on_event("run1", {"type": "run_done", "ok": False})
assert r.status == "stopped"
def test_on_event_unknown_run_id_does_not_raise_and_still_reemits_event(service):
received = []
service.on_event(lambda rid, ev: received.append((rid, ev)))
service._on_event("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})
assert received == [("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})]
def test_on_event_none_payload_does_not_raise_and_reemits_none(service):
# Khac ban cu (Qt ep None -> {} do Signal(str, dict)): o day khong con
# Signal nen callback nhan DUNG gia tri goc None. Xem comment trong
# co4e_workflow_service.py::_on_event ve ly do khong gia lap lai viec ep
# kieu do.
_seed(service, "run1")
received = []
service.on_event(lambda rid, ev: received.append((rid, ev)))
service._on_event("run1", None)
assert received == [("run1", None)]
def test_on_finished_while_running_settles_to_done(service):
r = _seed(service, "run1")
changed = []
service.on_changed(lambda: changed.append(1))
service._on_finished("run1")
assert r.status == "done"
assert len(changed) == 1
def test_on_finished_when_already_settled_is_a_noop(service):
r = _seed(service, "run1")
r.status = "error"
changed = []
service.on_changed(lambda: changed.append(1))
service._on_finished("run1")
assert r.status == "error"
assert len(changed) == 0
def test_on_finished_unknown_run_id_is_a_total_noop(service):
changed = []
service.on_changed(lambda: changed.append(1))
service._on_finished("no-such-run")
assert service._runs == {}
assert changed == []
def test_on_failed_marks_error_with_message_and_emits_run_error_event(service):
r = _seed(service, "run1")
events = []
changed = []
service.on_event(lambda rid, ev: events.append((rid, ev)))
service.on_changed(lambda: changed.append(1))
service._on_failed("run1", "boom")
assert r.status == "error"
assert r.error == "boom"
assert events == [("run1", {"type": "run_error", "error": "boom"})]
assert len(changed) == 1
def test_on_failed_overrides_status_even_when_already_settled(service):
r = _seed(service, "run1")
r.status = "done"
service._on_failed("run1", "late failure")
assert r.status == "error"
def test_on_failed_unknown_run_id_is_a_total_noop(service):
events = []
changed = []
service.on_event(lambda rid, ev: events.append((rid, ev)))
service.on_changed(lambda: changed.append(1))
service._on_failed("no-such-run", "err")
assert events == []
assert changed == []
# ---------------------------------------------------------------------------
# persistence: hook -> dia -> from_dict round trip
# ---------------------------------------------------------------------------
def test_changed_hook_persists_to_history_file(service, tmp_path):
_seed(service, "run1")
service._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
path = tmp_path / "run_history.json"
assert path.exists()
data = json.loads(path.read_text(encoding="utf-8"))
assert len(data["runs"]) == 1
assert data["runs"][0]["id"] == "run1"
assert data["runs"][0]["status"] == "running"
def test_reloading_service_after_hook_settles_running_to_stopped(tmp_path):
history_path = tmp_path / "run_history.json"
s1 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
_seed(s1, "run1")
s1._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
s2 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
assert "run1" in s2._runs
assert s2._runs["run1"].status == "stopped"
assert s2._seq == 1
def test_reloaded_seq_avoids_colliding_with_history_ids(tmp_path):
history_path = tmp_path / "run_history.json"
s1 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
_seed(s1, "run7")
s1._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
s2 = Co4EWorkflowService(_Ctx(tmp_path), history_path=history_path)
assert s2._seq == 7
assert s2._next_id() == "run8"
def test_load_history_missing_file_is_silent_noop(tmp_path):
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "does-not-exist.json")
assert s._runs == {}
assert s._seq == 0
# ---------------------------------------------------------------------------
# start() qua WorkflowRunner Protocol (khong QThread, khong AI that)
# ---------------------------------------------------------------------------
def test_start_registers_run_and_delegates_to_injected_runner(tmp_path):
runner = _RecordingRunner()
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
changed = []
s.on_changed(lambda: changed.append(1))
run_id = s.start(_make_workflow(node_count=2))
assert run_id == "run1"
record = s.get(run_id)
assert record is not None
assert record.status == "running"
assert record.total == 2
assert record.wf["id"] == "wf1"
assert record.wf["name"] == "Flow"
assert len(record.wf["nodes"]) == 2
assert len(runner.calls) == 1
assert runner.calls[0][0] == run_id
assert len(changed) == 1
def test_start_with_only_nodes_uses_its_length_as_total(tmp_path):
runner = _RecordingRunner()
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
run_id = s.start(_make_workflow(node_count=3), only_nodes={"n1", "n2"})
assert s.get(run_id).total == 2
def test_start_without_runner_still_registers_run_but_no_job_delegated(tmp_path):
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json") # runner=None mac dinh
run_id = s.start(_make_workflow())
assert s.get(run_id) is not None
assert s.get(run_id).status == "running"
def test_stop_calls_runner_handle_request_stop(tmp_path):
runner = _RecordingRunner()
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
run_id = s.start(_make_workflow())
handle = runner.calls[0][5]
s.stop(run_id)
assert handle.stop_requested is True
assert s.get(run_id).status == "stopped"
def test_stop_running_run_emits_changed(tmp_path):
# Bite-test: neu ai xoa self._emit_changed() ben trong stop(), test nay
# phai do (khac assertion ve status/stop_requested o test ben tren, von
# khong dung toi len goi on_changed()).
runner = _RecordingRunner()
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
run_id = s.start(_make_workflow())
changed = []
s.on_changed(lambda: changed.append(1))
s.stop(run_id)
assert len(changed) == 1
def test_stop_non_running_run_is_noop_and_does_not_emit_changed(tmp_path):
runner = _RecordingRunner()
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
run_id = s.start(_make_workflow())
s.get(run_id).status = "done"
changed = []
s.on_changed(lambda: changed.append(1))
s.stop(run_id)
assert changed == []
def test_rename_updates_name_and_wf_dict_and_emits_changed(service):
r = _seed(service, "run1")
r.wf = {"id": "wf1", "name": "Old", "nodes": [], "edges": []}
changed = []
service.on_changed(lambda: changed.append(1))
service.rename("run1", "New Name")
assert r.name == "New Name"
assert r.wf["name"] == "New Name"
assert len(changed) == 1
def test_rename_blank_name_is_noop_and_does_not_emit_changed(service):
r = _seed(service, "run1", name="Flow")
changed = []
service.on_changed(lambda: changed.append(1))
service.rename("run1", " ")
assert r.name == "Flow"
assert changed == []
def test_rename_same_name_is_noop_and_does_not_emit_changed(service):
_seed(service, "run1", name="Flow")
changed = []
service.on_changed(lambda: changed.append(1))
service.rename("run1", "Flow")
assert changed == []
def test_rename_unknown_run_id_is_noop_and_does_not_emit_changed(service):
changed = []
service.on_changed(lambda: changed.append(1))
service.rename("no-such-run", "New Name")
assert changed == []
def test_remove_running_run_stops_it_then_removes_and_emits_changed_twice(tmp_path):
# remove() goi stop() (rieng no da emit mot lan) roi tu emit them mot lan
# sau khi pop -- 2 la con so dung khop ban cu (core/co4e_run_manager.py::
# remove), khong phai 1.
runner = _RecordingRunner()
s = Co4EWorkflowService(_Ctx(tmp_path), history_path=tmp_path / "h.json", runner=runner)
run_id = s.start(_make_workflow())
handle = runner.calls[0][5]
changed = []
s.on_changed(lambda: changed.append(1))
s.remove(run_id)
assert handle.stop_requested is True
assert s.get(run_id) is None
assert len(changed) == 2
def test_remove_non_running_run_emits_changed_once(service):
r = _seed(service, "run1")
r.status = "done"
changed = []
service.on_changed(lambda: changed.append(1))
service.remove("run1")
assert service.get("run1") is None
assert len(changed) == 1
def test_clear_finished_emits_changed_even_with_no_matching_runs(service):
# Ban cu luon emit sau vong lap, ke ca khi khong xoa gi -- giu quirk nay.
changed = []
service.on_changed(lambda: changed.append(1))
service.clear_finished()
assert len(changed) == 1
def test_clear_finished_removes_only_finished_runs_of_current_project(service):
r1 = _seed(service, "run1")
r1.status = "done"
r2 = _seed(service, "run2")
r2.status = "running"
changed = []
service.on_changed(lambda: changed.append(1))
service.clear_finished()
assert "run1" not in service._runs
assert "run2" in service._runs
assert len(changed) == 1
def test_set_current_project_changes_pid_and_emits_changed(service):
changed = []
service.on_changed(lambda: changed.append(1))
service.set_current_project("proj1")
assert service._project_id == "proj1"
assert len(changed) == 1
def test_set_current_project_same_pid_is_noop_and_does_not_emit_changed(service):
service.set_current_project("proj1")
changed = []
service.on_changed(lambda: changed.append(1))
service.set_current_project("proj1")
assert changed == []
# ---------------------------------------------------------------------------
# Bang chung "hanh vi khong lech": cung input -> cung JSON tren dia, ca lop
# cu (core/co4e_run_manager.py) lan lop moi (application/workflows/...).
# ---------------------------------------------------------------------------
def test_new_service_produces_same_json_record_as_old_manager(tmp_path, monkeypatch):
from cowork_local.core import co4e as _co4e_module
from cowork_local.core.co4e_run_manager import Co4ERunManager, RunHandle as OldRunHandle
# Co lap CO4E_DIR cho manager cu bang cach patch THUOC TINH MODULE (dung ky
# thuat cua tests/characterization/test_co4e_run_manager_behavior.py, xem
# docstring dau file do ve ly do KHONG dung bien moi truong truoc luc
# import: _history_path() doc lai CO4E_DIR tuoi ngay luc goi ham).
monkeypatch.setattr(_co4e_module, "CO4E_DIR", tmp_path / "old_home" / ".cowork_local" / "co4e")
old_history = tmp_path / "old_history.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: old_history)
class _OldCtx:
pass
old_mgr = Co4ERunManager(_OldCtx())
old_mgr._runs["run1"] = OldRunHandle(
"run1", "wf1", "Flow", 3, False, False,
created_by="alice", created_at="2026-08-23 10:00", project_id="p1",
)
old_mgr._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
old_mgr._on_event("run1", {"type": "node_status", "node_id": "n2", "status": "running"})
old_mgr._on_event("run1", {"type": "run_done", "ok": True})
old_record = json.loads(old_history.read_text(encoding="utf-8"))["runs"][0]
new_history = tmp_path / "new_history.json"
new_svc = Co4EWorkflowService(_Ctx(tmp_path), history_path=new_history)
new_svc._runs["run1"] = RunRecord(
"run1", "wf1", "Flow", 3, False, False,
created_by="alice", created_at="2026-08-23 10:00", project_id="p1",
)
new_svc._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
new_svc._on_event("run1", {"type": "node_status", "node_id": "n2", "status": "running"})
new_svc._on_event("run1", {"type": "run_done", "ok": True})
new_record = json.loads(new_history.read_text(encoding="utf-8"))["runs"][0]
assert new_record == old_record
def test_new_service_reload_quirk_matches_old_manager_reload_quirk(tmp_path, monkeypatch):
"""Cung quirk round-trip khong doi xung ('running' -> 'stopped' sau khi
doc lai tu dia) phai xay ra giong het nhau tren ca hai lop."""
from cowork_local.core import co4e as _co4e_module
from cowork_local.core.co4e_run_manager import Co4ERunManager, RunHandle as OldRunHandle
monkeypatch.setattr(_co4e_module, "CO4E_DIR", tmp_path / "old_home2" / ".cowork_local" / "co4e")
old_history = tmp_path / "old_history2.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: old_history)
class _OldCtx:
pass
old_mgr = Co4ERunManager(_OldCtx())
old_mgr._runs["run7"] = OldRunHandle("run7", "wf1", "Flow", 2, False, False)
old_mgr._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
old_mgr2 = Co4ERunManager(_OldCtx())
new_history = tmp_path / "new_history2.json"
new_svc = Co4EWorkflowService(_Ctx(tmp_path), history_path=new_history)
new_svc._runs["run7"] = RunRecord("run7", "wf1", "Flow", 2, False, False)
new_svc._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
new_svc2 = Co4EWorkflowService(_Ctx(tmp_path), history_path=new_history)
assert old_mgr2._runs["run7"].status == new_svc2._runs["run7"].status == "stopped"
assert old_mgr2._seq == new_svc2._seq == 7
+155
View File
@@ -0,0 +1,155 @@
"""JsonConfigRepository — R02-T02.
Hai nhóm bài:
* **round-trip** — ghi rồi nạp lại phải ra đúng thứ đã ghi (cột nghiệm thu
của plan.md cho ngày 22-23/08)
* **đường A** — ``provider_conf()`` vẫn trả ``api_key``, nhưng file JSON
trên đĩa thì không có, để qua CASAN Check 1
"""
from __future__ import annotations
import json
import pytest
from cowork_local.infrastructure.config.config_repository import ConfigRepository
from cowork_local.infrastructure.config.json_config_repository import (
JsonConfigRepository,
)
from cowork_local.tests.fakes.fake_config import FakeSecretStore
DEFAULTS = {
"active_provider": "ollama",
"providers": {
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3",
"api_key": "ollama"},
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini",
"api_key": ""},
},
"theme": "dark", "language": "vi", "shared_dir": "",
"routing": {"mode": "off"}, "auth": {}, "agent_security": {},
"tools_disabled": [], "history": {}, "cowork": {}, "ms365": {},
}
def _repo(tmp_path, secrets=None):
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
defaults=DEFAULTS, env_overrides=lambda d: d)
def test_khop_hop_dong(tmp_path):
assert isinstance(_repo(tmp_path), ConfigRepository)
def test_chua_co_file_thi_dung_mac_dinh(tmp_path):
cfg = _repo(tmp_path)
assert cfg.active_provider == "ollama"
assert cfg.theme == "dark"
def test_round_trip(tmp_path):
cfg = _repo(tmp_path)
cfg.set_theme("light")
cfg.set_language("en")
cfg.set_active_provider("openai")
cfg.set_tool_enabled("run_command", False)
cfg.save()
lai = _repo(tmp_path)
assert lai.theme == "light"
assert lai.language == "en"
assert lai.active_provider == "openai"
assert lai.tools_disabled == ["run_command"]
def test_gia_tri_luu_trong_file_trum_len_mac_dinh_nhung_giu_phan_con_thieu(tmp_path):
"""Trộn sâu: file cũ thiếu khoá mới thì lấy mặc định, không mất phần cũ."""
(tmp_path / "config.json").write_text(
json.dumps({"theme": "light", "providers": {"openai": {"model": "gpt-5"}}}),
encoding="utf-8")
cfg = _repo(tmp_path)
assert cfg.theme == "light" # từ file
assert cfg.language == "vi" # từ mặc định
assert cfg.provider_conf("openai")["model"] == "gpt-5" # từ file
assert "api.openai.com" in cfg.provider_conf("openai")["base_url"] # mặc định
# ---- đường A: khoá vào kho bí mật, nhưng dict vẫn có ------------------------
def test_provider_conf_van_tra_api_key_sau_khi_chuyen_vao_kho(tmp_path):
"""Điểm mấu chốt của quyết định A: 5 nơi đọc conf['api_key'] không đổi."""
secrets = FakeSecretStore()
cfg = _repo(tmp_path, secrets)
cfg.set_api_key("openai", "sk-that-bi-mat")
assert cfg.provider_conf("openai")["api_key"] == "sk-that-bi-mat"
def test_khoa_khong_bao_gio_nam_tren_dia(tmp_path):
"""Điều kiện qua CASAN Check 1."""
secrets = FakeSecretStore()
cfg = _repo(tmp_path, secrets)
cfg.set_api_key("openai", "sk-that-bi-mat")
cfg.save()
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "sk-that-bi-mat" not in raw
assert secrets.get("provider:openai") == "sk-that-bi-mat"
def test_sua_dict_tra_ve_khong_lam_ban_cau_hinh(tmp_path):
"""provider_conf trả bản sao — nếu trả tham chiếu thì khoá vừa ghép vào sẽ
lẫn ngược vào self.data rồi theo save() xuống đĩa."""
secrets = FakeSecretStore()
cfg = _repo(tmp_path, secrets)
cfg.set_api_key("openai", "sk-bi-mat")
conf = cfg.provider_conf("openai")
conf["model"] = "bị sửa bậy"
cfg.save()
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "bị sửa bậy" not in raw
assert "sk-bi-mat" not in raw
def test_khong_co_kho_bi_mat_thi_van_chay_nhu_cu(tmp_path):
"""Máy không có keyring: hành vi lùi về đúng như config.py hôm nay."""
cfg = _repo(tmp_path, secrets=None)
cfg.set_api_key("openai", "sk-nam-trong-file")
cfg.save()
assert cfg.provider_conf("openai")["api_key"] == "sk-nam-trong-file"
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "sk-nam-trong-file" in raw # đúng như cũ, có đánh đổi rõ ràng
# ---- giữ nguyên hành vi cũ --------------------------------------------------
def test_ms365_unlocked_khong_bao_gio_xuong_dia(tmp_path):
cfg = _repo(tmp_path)
cfg.data["ms365"]["unlocked"] = True
cfg.save()
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
assert raw["ms365"]["unlocked"] is False
assert cfg.data["ms365"]["unlocked"] is True # trong bộ nhớ vẫn giữ
assert _repo(tmp_path).data["ms365"]["unlocked"] is False
def test_ghi_hong_giua_chung_khong_lam_mat_cau_hinh(tmp_path, monkeypatch):
"""Thừa hưởng từ AtomicJsonFile — kiểm lại ở tầng này cho chắc."""
import os
cfg = _repo(tmp_path)
cfg.set_theme("light")
cfg.save()
monkeypatch.setattr(os, "replace",
lambda *a, **k: (_ for _ in ()).throw(OSError("mất điện")))
cfg.set_theme("hỏng")
with pytest.raises(OSError):
cfg.save()
assert _repo(tmp_path).theme == "light"
+143
View File
@@ -0,0 +1,143 @@
"""Hợp đồng của mục chung có thật sự gỡ chốt cho N2 và N3 không.
Đây là bài nghiệm thu, không phải test cho vui: nếu ba bài dưới đây xanh thì
hai nhánh kia code được ngay hôm nay mà không cần chờ ``ConfigRepository`` hay
``KeyringAdapter`` bản thật.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from cowork_local.infrastructure.config.config_repository import ConfigRepository
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
from cowork_local.tests.fakes.fake_config import FakeConfigRepository, FakeSecretStore
REPO_PARENT = Path(__file__).resolve().parents[2]
def test_fake_config_khop_hop_dong():
"""Fake phải cài đủ interface — thiếu một hàm là hai nhánh kia gọi vào sẽ vỡ."""
assert isinstance(FakeConfigRepository(), ConfigRepository)
def test_fake_secret_store_khop_hop_dong():
assert isinstance(FakeSecretStore(), SecretStore)
def test_secret_store_thieu_key_thi_tra_none_chu_khong_nem_loi():
"""Thiếu API key là chuyện thường (người dùng chưa nhập), không phải sự cố."""
store = FakeSecretStore()
assert store.get(provider_key("openai")) is None
assert store.has(provider_key("openai")) is False
store.delete(provider_key("openai")) # xoá cái không có: im lặng
store.set(provider_key("openai"), "sk-test")
assert store.get(provider_key("openai")) == "sk-test"
assert store.has(provider_key("openai")) is True
def test_config_gia_ghi_nhan_save_ma_khong_cham_dia():
cfg = FakeConfigRepository(theme="light")
assert cfg.theme == "light"
cfg.set_theme("dark")
cfg.save()
assert cfg.theme == "dark"
assert cfg.saves == 1
def test_bat_duoc_tool_bi_tat():
cfg = FakeConfigRepository(tools_disabled=["run_command"])
assert cfg.tools_disabled == ["run_command"]
cfg.set_tool_enabled("run_command", True)
assert cfg.tools_disabled == []
cfg.set_tool_enabled("write_file", False)
assert cfg.tools_disabled == ["write_file"]
def test_dung_duoc_fake_ma_khong_hề_nap_config_that():
"""Bài nghiệm thu chính của mục chung.
N2 và N3 phải dựng được màn hình và chạy test của mình mà KHÔNG kéo theo
``cowork_local.config`` — module nặng, đọc đĩa, và đang bị N1 viết lại.
Kiểm bằng tiến trình riêng để không dính module đã nạp sẵn ở test khác.
"""
snippet = (
"import sys\n"
"from cowork_local.tests.fakes.fake_config import "
"FakeConfigRepository, FakeSecretStore\n"
"cfg = FakeConfigRepository(active_provider='openai')\n"
"assert cfg.provider_conf()['model'] == 'gpt-4o-mini'\n"
"assert FakeSecretStore().get('x') is None\n"
"assert 'cowork_local.config' not in sys.modules, "
"'fake keo theo config that -> van con phu thuoc'\n"
"assert 'PySide6' not in sys.modules, 'fake keo theo Qt -> test se cham'\n"
"print('OK')\n"
)
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO_PARENT,
capture_output=True, text=True, timeout=60)
assert out.returncode == 0, out.stderr
assert "OK" in out.stdout
# ---------------------------------------------------------------------------
# ToolPolicyGateway — bản đề xuất Gamma viết hộ, chờ Team Hoa xác nhận.
# N3 (Co4E) code dựa vào đây từ hôm nay thay vì tự phỏng đoán.
# ---------------------------------------------------------------------------
from cowork_local.domain.security.tool_policy import ( # noqa: E402
PolicyOutcome, ToolCallRequest, ToolPolicyGateway, allow, ask, deny,
)
from cowork_local.tests.fakes.fake_tool_policy import ( # noqa: E402
FakeToolPolicyGateway,
)
def test_fake_gateway_khop_hop_dong():
assert isinstance(FakeToolPolicyGateway(), ToolPolicyGateway)
def test_mac_dinh_cho_qua_va_co_ghi_lai_da_hoi():
gate = FakeToolPolicyGateway()
d = gate.check(ToolCallRequest(name="read_file", surface="co4e"))
assert d.outcome is PolicyOutcome.ALLOW
assert d.allowed is True
assert gate.asked_for("read_file")
assert gate.call_count == 1
def test_chan_theo_ten_tool():
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
assert gate.check(ToolCallRequest(name="run_command")).outcome is PolicyOutcome.DENY
assert gate.check(ToolCallRequest(name="read_file")).allowed is True
def test_ask_khong_phai_la_duoc_phep():
"""Bẫy dễ mắc nhất: coi ASK như ALLOW thì tool chạy mà chưa ai đồng ý."""
d = ask("cần người dùng xác nhận")
assert d.outcome is PolicyOutcome.ASK
assert d.allowed is False
def test_deny_va_ask_bat_buoc_co_ly_do():
"""Người dùng phải biết vì sao bị chặn, và audit log cần ghi lại."""
import pytest
with pytest.raises(ValueError):
deny("")
with pytest.raises(ValueError):
ask("")
allow() # ALLOW thì không cần lý do
def test_chinh_sach_khac_nhau_theo_man():
"""Co4E chạy nền nên không bật được hộp thoại — chặn thẳng thay vì hỏi."""
def by_surface(req: ToolCallRequest):
if req.surface == "co4e" and req.name == "run_command":
return deny("Co4E chạy nền, không hỏi được người dùng")
return ask("cần xác nhận") if req.name == "run_command" else allow()
gate = FakeToolPolicyGateway(decide=by_surface)
assert gate.check(ToolCallRequest("run_command", surface="co4e")).outcome is PolicyOutcome.DENY
assert gate.check(ToolCallRequest("run_command", surface="cowork")).outcome is PolicyOutcome.ASK
+93
View File
@@ -0,0 +1,93 @@
"""KeyringAdapter — R02-T04.
Không đụng vào keyring thật của máy chạy test: tiêm một backend giả. Test mà
ghi vào Credential Manager thật thì để lại rác trên máy người khác, và trên CI
thì không có kho nào để ghi.
"""
from __future__ import annotations
import pytest
from cowork_local.infrastructure.secrets.keyring_adapter import KeyringAdapter
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
class _KeyringGia:
"""Đủ giống thư viện keyring để adapter dùng được."""
def __init__(self, hong: bool = False):
self.kho: dict[tuple[str, str], str] = {}
self.hong = hong
def get_password(self, service, key):
if self.hong:
raise RuntimeError("kho bí mật không phản hồi")
return self.kho.get((service, key))
def set_password(self, service, key, value):
if self.hong:
raise RuntimeError("kho bí mật không phản hồi")
self.kho[(service, key)] = value
def delete_password(self, service, key):
if self.hong:
raise RuntimeError("kho bí mật không phản hồi")
del self.kho[(service, key)]
@pytest.fixture
def store():
a = KeyringAdapter(service="test-cowork")
a._backend = _KeyringGia()
a._available = True
return a
def test_khop_hop_dong_secret_store(store):
assert isinstance(store, SecretStore)
def test_luu_doc_xoa(store):
k = provider_key("openai")
assert store.get(k) is None
assert store.has(k) is False
store.set(k, "sk-that-la-bi-mat")
assert store.get(k) == "sk-that-la-bi-mat"
assert store.has(k) is True
store.delete(k)
assert store.get(k) is None
def test_moi_provider_mot_khoa_rieng(store):
store.set(provider_key("openai"), "khoa-openai")
store.set(provider_key("anthropic"), "khoa-anthropic")
assert store.get(provider_key("openai")) == "khoa-openai"
assert store.get(provider_key("anthropic")) == "khoa-anthropic"
def test_may_khong_co_kho_thi_im_lang_chu_khong_sap():
"""Linux headless và CI không có Secret Service. App vẫn phải chạy."""
a = KeyringAdapter(service="test-cowork")
a._backend = None
a._available = False
assert a.available is False
assert a.get("bat-ky") is None
a.set("bat-ky", "gia-tri") # không ném lỗi
a.delete("bat-ky") # không ném lỗi
assert a.has("bat-ky") is False
def test_kho_loi_giua_chung_thi_khong_lam_sap_app(store):
"""Keyring có thể hỏng lúc đang chạy — mất DBus, người dùng khoá máy."""
store._backend.hong = True
assert store.get("x") is None # nuốt lỗi, trả None
store.set("x", "y") # nuốt lỗi
store.delete("x") # nuốt lỗi
def test_xoa_khoa_khong_ton_tai_thi_bo_qua(store):
store.delete(provider_key("chua-bao-gio-luu")) # không ném lỗi
+91
View File
@@ -0,0 +1,91 @@
"""Không file mã nguồn nào được nằm ngoài repo vì `.gitignore`.
Bài này sinh ra từ một lỗi thật, mất hai ngày mới lộ:
``.gitignore`` dòng 31 ghi ``secrets/`` — mẫu **không neo**, nên git bỏ qua
mọi thư mục tên ``secrets`` ở mọi độ sâu, kể cả ``infrastructure/secrets/``
vốn là **mã nguồn**. Ba file trong đó chưa bao giờ lên repo. Máy người viết
vẫn chạy 150 test xanh, nhưng ai clone sạch về thì 4 file test đỏ ngay lúc
thu thập.
Trên máy đã có file thì không cách nào nhận ra: ``pytest`` đọc đĩa, không đọc
git. Nên phải hỏi thẳng git.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
#: Thư mục chứa mã nguồn của ứng dụng — file .py ở đây bắt buộc phải vào repo.
SOURCE_DIRS = ["domain", "application", "infrastructure", "presentation",
"adapters", "core", "ui", "providers", "scripts", "tools", "tests"]
def _git(*args: str) -> str:
out = subprocess.run(["git", *args], cwd=REPO, capture_output=True,
text=True, encoding="utf-8", errors="replace")
return out.stdout
def test_khong_file_py_nao_bi_gitignore_nuot():
"""File .py có trên đĩa nhưng git không thấy — vừa chưa theo dõi, vừa bị
bỏ qua. Đó chính là hình dạng của lỗi ``secrets/``."""
existing = []
for d in SOURCE_DIRS:
root = REPO / d
if root.is_dir():
existing.append(d)
assert existing, "không thấy thư mục mã nguồn nào — kiểm lại SOURCE_DIRS"
ignored = _git("ls-files", "--others", "--ignored", "--exclude-standard",
"--", *existing).splitlines()
ignored_py = [p for p in ignored
if p.endswith(".py") and "__pycache__" not in p]
assert not ignored_py, (
"File mã nguồn bị .gitignore nuốt — clone sạch sẽ thiếu:\n "
+ "\n ".join(ignored_py)
+ "\nChạy `git check-ignore -v <file>` để biết dòng nào gây ra."
)
def test_khong_file_py_nao_bi_bo_quen_chua_theo_doi():
"""Chưa bị ignore nhưng cũng chưa `git add` — quên, không phải cố ý."""
untracked = _git("ls-files", "--others", "--exclude-standard").splitlines()
forgotten = [p for p in untracked
if p.endswith(".py")
and p.split("/")[0] in SOURCE_DIRS
and "__pycache__" not in p]
assert not forgotten, (
"File mã nguồn chưa được git add — clone sạch sẽ thiếu:\n "
+ "\n ".join(forgotten)
)
def test_moi_module_duoc_import_deu_co_trong_repo():
"""Bắt theo hướng ngược: đi từ những gì code THỰC SỰ import.
Hai bài trên quét theo thư mục; bài này bắt cả trường hợp file nằm ngoài
danh sách đó mà vẫn được import.
"""
tracked = set(_git("ls-files").splitlines())
missing = []
for d in ("domain", "application", "infrastructure", "adapters"):
root = REPO / d
if not root.is_dir():
continue
for f in root.rglob("*.py"):
rel = f.relative_to(REPO).as_posix()
if "__pycache__" in rel:
continue
if rel not in tracked:
missing.append(rel)
assert not missing, (
"Module thuộc kiến trúc mới nhưng không có trong repo:\n "
+ "\n ".join(missing)
)
+59
View File
@@ -0,0 +1,59 @@
"""Không thư mục nào ở gốc repo được trùng tên module thư viện chuẩn.
Bài này sinh ra từ một lỗi thật: kế hoạch refactor đặt tên một tầng là
``platform/``, và ngay khi tạo thư mục đó thì mọi script chạy từ gốc repo —
``python tools/check_*.py``, ``python scripts/audit_security.py``, 26 file tất
cả — đều nạp nhầm ``platform/`` thay cho ``platform`` của Python. ``keyring``
chết ngay với ``AttributeError: module 'platform' has no attribute 'system'``.
Kiểm bằng tên chứ không phải bằng cách thử import: import chỉ hỏng khi có ai
đó thật sự dùng module bị che, nên nó im lặng cho tới lúc muộn.
"""
from __future__ import annotations
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
#: Không tính: đây là thư mục dữ liệu/tài liệu, không phải package Python.
NOT_PACKAGES = {".git", ".gitea", ".vibeflow-preview", "docs", "assets",
"__pycache__", ".pytest_cache", "cowork-local-gitea",
".cowork_history", ".cowork_local"}
def _top_level_packages() -> list[str]:
return [d.name for d in REPO.iterdir()
if d.is_dir() and d.name not in NOT_PACKAGES
and (d / "__init__.py").exists()]
def test_khong_package_nao_che_khuat_thu_vien_chuan():
stdlib = set(sys.stdlib_module_names)
clashes = [name for name in _top_level_packages() if name in stdlib]
assert not clashes, (
"Thư mục ở gốc repo trùng tên module thư viện chuẩn: "
+ ", ".join(sorted(clashes))
+ ". Chạy script từ gốc repo sẽ nạp nhầm thư mục này. Đổi tên thư mục."
)
def test_import_duoc_stdlib_khi_chay_tu_goc_repo():
"""Bài trên bắt bằng tên; bài này bắt bằng hành vi thật.
Chạy tiến trình con với thư mục làm việc là gốc repo — đúng cách 26 script
trong ``tools/`` và ``scripts/`` được gọi.
"""
import subprocess
snippet = (
"import platform, json, types, io\n"
"assert 'site-packages' not in platform.__file__\n"
"assert platform.system(), 'platform.system() phải trả về tên hệ điều hành'\n"
"import keyring\n"
"print('OK')\n"
)
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO,
capture_output=True, text=True, timeout=60)
assert out.returncode == 0, out.stderr
assert "OK" in out.stdout
+8 -1
View File
@@ -14,7 +14,6 @@ from cowork_local.mcp_servers.project_context.registry import (
)
from cowork_local.mcp_servers.project_context.runtime import require_supported_python
from cowork_local.mcp_servers.project_context.server import dispatch
from mcp import types
EXPECTED_TOOLS = {
"get_project_issue_context",
@@ -88,6 +87,14 @@ def source() -> dict[str, str]:
def test_template_exposes_exactly_three_provider_neutral_tools() -> None:
# The MCP SDK is a RUNTIME dependency (requirements.txt) and is deliberately
# absent from requirements-test.txt, which is all CI installs. Importing it at
# module scope aborted collection for the ENTIRE suite, so the guard lives here,
# inside the only test that touches the SDK. Guarding per-test rather than
# per-module keeps the other cases -- pure-Python contract checks that need no
# SDK -- running on CI instead of silently skipping with it.
types = pytest.importorskip("mcp.types")
assert set(TOOL_NAMES) == EXPECTED_TOOLS
declarations = tool_declarations()
assert {item["name"] for item in declarations} == EXPECTED_TOOLS
+126
View File
@@ -0,0 +1,126 @@
"""Đánh số phiên bản + chuyển API key — R02-T06 và R02-T05."""
from __future__ import annotations
import json
from cowork_local.infrastructure.config.json_config_repository import (
JsonConfigRepository,
)
from cowork_local.infrastructure.config.schema_migration import (
CURRENT_VERSION, migrate, read_version,
)
from cowork_local.tests.fakes.fake_config import FakeSecretStore
DEFAULTS = {
"active_provider": "openai",
"providers": {"openai": {"base_url": "u", "model": "m", "api_key": ""},
"ollama": {"base_url": "u", "model": "m", "api_key": "ollama"}},
"theme": "dark", "language": "vi", "ms365": {},
}
def _repo(tmp_path, secrets=None):
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
defaults=DEFAULTS, env_overrides=lambda d: d)
def test_thieu_so_phien_ban_thi_coi_la_v1():
assert read_version({}) == 1
assert read_version({"schema_version": 2}) == 2
assert read_version({"schema_version": "hỏng"}) == 1
def test_v1_sang_v2_chuyen_khoa_vao_kho_bi_mat():
secrets = FakeSecretStore()
data = {"providers": {"openai": {"api_key": "sk-cu-nam-trong-file"}}} # casan: allow - du lieu test
out, changed = migrate(data, secrets=secrets)
assert changed is True
assert out["schema_version"] == 2
assert out["providers"]["openai"]["api_key"] == ""
assert secrets.get("provider:openai") == "sk-cu-nam-trong-file"
def test_khong_day_gia_tri_bu_nhin_cua_ollama_vao_kho():
"""Ollama đòi có api_key nhưng bỏ qua nội dung — đẩy vào keyring chỉ tổ rác."""
secrets = FakeSecretStore()
out, _ = migrate({"providers": {"ollama": {"api_key": "ollama"}}}, secrets=secrets)
assert secrets.get("provider:ollama") is None
assert out["providers"]["ollama"]["api_key"] == "ollama"
def test_khong_co_kho_bi_mat_thi_KHONG_chuyen():
"""Thà để khoá nằm nguyên trong file còn hơn xoá đi rồi người dùng mất
khoá mà không hiểu vì sao."""
data = {"providers": {"openai": {"api_key": "sk-quy-gia"}}}
out, changed = migrate(data, secrets=None)
assert changed is False
assert out["providers"]["openai"]["api_key"] == "sk-quy-gia"
assert read_version(out) == 1 # giữ v1, lần sau có keyring sẽ chuyển
def test_da_v2_thi_khong_lam_gi_them():
out, changed = migrate({"schema_version": 2}, secrets=FakeSecretStore())
assert changed is False
def test_file_moi_hon_app_thi_dung_nguyen_trang():
"""App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu."""
data = {"schema_version": 99, "thu_gi_do_tuong_lai": True}
out, changed = migrate(data, secrets=FakeSecretStore())
assert changed is False
assert out == data
def test_sao_luu_truoc_khi_nang_cap(tmp_path):
path = tmp_path / "config.json"
path.write_text(json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}),
encoding="utf-8")
migrate(json.loads(path.read_text(encoding="utf-8")),
secrets=FakeSecretStore(), path=path)
backups = list(tmp_path.glob("*.bak"))
assert len(backups) == 1, "phải có bản sao lưu để còn đường lùi"
assert "sk-x" in backups[0].read_text(encoding="utf-8")
# ---- nối vào repository ----------------------------------------------------
def test_repository_tu_chuyen_khoa_khi_mo_file_cu(tmp_path):
"""Cảnh thật: người dùng cập nhật app, mở lên, khoá cũ tự vào keyring."""
(tmp_path / "config.json").write_text(
json.dumps({"providers": {"openai": {"api_key": "sk-tu-ban-cu"}}}), # casan: allow - du lieu test
encoding="utf-8")
secrets = FakeSecretStore()
cfg = _repo(tmp_path, secrets)
# đọc ra vẫn thấy khoá...
assert cfg.provider_conf("openai")["api_key"] == "sk-tu-ban-cu"
# ...nhưng trên đĩa thì hết
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "sk-tu-ban-cu" not in raw
assert json.loads(raw)["schema_version"] == CURRENT_VERSION
# và có bản sao lưu
assert len(list(tmp_path.glob("*.bak"))) == 1
def test_mo_lai_lan_hai_khong_chuyen_lai(tmp_path):
(tmp_path / "config.json").write_text(
json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}), encoding="utf-8")
secrets = FakeSecretStore()
_repo(tmp_path, secrets)
so_ban_sao = len(list(tmp_path.glob("*.bak")))
_repo(tmp_path, secrets)
assert len(list(tmp_path.glob("*.bak"))) == so_ban_sao, "không nâng cấp lại"
def test_save_luon_ghi_so_phien_ban(tmp_path):
cfg = _repo(tmp_path)
cfg.save()
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
assert raw["schema_version"] == CURRENT_VERSION
+92
View File
@@ -0,0 +1,92 @@
"""Typed Settings Facade — R02-T03."""
from __future__ import annotations
from cowork_local.infrastructure.config.settings_facade import (
ProviderSettings, RoutingSettings, SecuritySettings, Settings,
)
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
def test_provider_doc_duoc_ba_truong():
p = ProviderSettings({"base_url": "http://x/v1", "model": "llama3",
"api_key": "sk-abc"})
assert p.base_url == "http://x/v1"
assert p.model == "llama3"
assert p.api_key == "sk-abc"
assert p.configured is True
def test_ollama_khong_can_khoa_van_tinh_la_da_cau_hinh():
"""Điều kiện là có base_url và model, không phải có api_key — Ollama chạy
cục bộ nên không cần khoá."""
p = ProviderSettings({"base_url": "http://localhost:11434/v1", "model": "llama3"})
assert p.api_key == ""
assert p.configured is True
def test_thieu_model_thi_chua_cau_hinh():
assert ProviderSettings({"base_url": "http://x/v1"}).configured is False
assert ProviderSettings({}).configured is False
def test_gia_tri_None_tra_ve_mac_dinh_chu_khong_None():
"""File cấu hình cũ hay có khoá để null. Đọc ra None rồi đem so sánh số là
vỡ — nên khung nhìn phải nuốt luôn trường hợp này."""
r = RoutingSettings({"switch_mode": None, "min_score_gain": None,
"confirm_timeout_sec": None})
assert r.switch_mode == "off"
assert r.min_score_gain == 0.05
assert r.confirm_timeout_sec == 60
def test_routing_kieu_du_lieu_dung():
r = RoutingSettings({"switch_mode": "auto", "min_score_gain": "0.2",
"confirm_timeout_sec": "90"})
assert r.enabled is True
assert isinstance(r.min_score_gain, float) and r.min_score_gain == 0.2
assert isinstance(r.confirm_timeout_sec, int) and r.confirm_timeout_sec == 90
def test_tat_dinh_tuyen():
assert RoutingSettings({"switch_mode": "off"}).enabled is False
assert RoutingSettings({}).enabled is False
def test_sua_qua_khung_nhin_la_sua_vao_dict_that():
"""Khung nhìn, không phải bản sao — sửa xong gọi save() là xuống đĩa."""
d = {"switch_mode": "off"}
RoutingSettings(d).switch_mode = "auto"
assert d["switch_mode"] == "auto"
def test_raw_de_khong_ai_bi_ket():
d = {"switch_mode": "auto", "khoa_chua_dua_vao_khung_nhin": 1}
assert RoutingSettings(d).raw()["khoa_chua_dua_vao_khung_nhin"] == 1
def test_security_mac_dinh_la_bat():
"""Mặc định an toàn: thiếu cấu hình thì bật kiểm tra, không phải tắt."""
s = SecuritySettings({})
assert s.enabled is True
assert s.validate_prompt is True
assert s.validate_commands is True
assert s.cowork_confirm_commands is True
assert s.command_ai_check is False # trừ cái này: gọi AI, tốn tiền
def test_settings_noi_vao_repo():
repo = FakeConfigRepository(active_provider="openai",
routing={"switch_mode": "auto"},
agent_security={"cowork_confirm_commands": False})
s = Settings(repo)
assert s.provider().model == "gpt-4o-mini"
assert s.routing.enabled is True
assert s.security.cowork_confirm_commands is False
def test_doi_provider_thi_khung_nhin_theo_ngay():
repo = FakeConfigRepository(active_provider="ollama")
s = Settings(repo)
assert s.provider().model == "llama3"
repo.set_active_provider("openai")
assert s.provider().model == "gpt-4o-mini"
View File
+19
View File
@@ -0,0 +1,19 @@
"""Dựng Qt ở chế độ offscreen cho test giao diện.
Offscreen là bắt buộc, không phải cho nhanh: máy dev là máy làm việc thật của
người dùng. Test bật cửa sổ lên là nó nhảy ra trước mặt, che thứ đang mở.
"""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@pytest.fixture(scope="session")
def qapp():
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
+149
View File
@@ -0,0 +1,149 @@
"""Đặc tả hành vi SettingsDialog TRƯỚC khi tách — R08-T07.
Không phải test tính năng mới. Đây là lưới an toàn: chốt lại dialog hiện
đang làm gì, để khi bóc 727 dòng thành các widget con còn biết mình có làm
lệch đi chỗ nào không. Bài nào ở đây đỏ sau khi tách nghĩa là tách sai.
"""
from __future__ import annotations
import pytest
class _Config:
"""Đủ dùng cho SettingsDialog, không hơn — xem danh sách ctx.* nó chạm."""
def __init__(self):
self.data = {
"active_provider": "openai_compat",
"language": "vi",
"theme": "dark",
"providers": {
"openai_compat": {"base_url": "https://api.openai.com/v1",
"api_key": "khoa-cu", "model": "gpt-4o"},
"ollama": {"base_url": "http://localhost:11434",
"api_key": "ollama", "model": "qwen2.5-coder"},
},
"tray": {"minimize_on_close": True, "notify_on_done": False},
"agent_security": {
"enabled": True, "cowork_confirm_commands": False,
"block_network": True, "command_ai_check": False,
"resource_limit_cpu_percent": 55,
"resource_limit_memory_mb": 1024,
"resource_limit_disk_mb": 2048,
},
"attachments": {"max_tokens": 32000, "max_files": 7},
"structure": {"max_nodes": 300, "max_edges": 600},
"routing": {"switch_mode": "auto", "policy": "cost",
"min_score_gain": 0.05, "confirm_timeout_sec": 90,
"reassess_interval_hours": 12,
"per_provider_concurrency": 3,
"judge_model": "gpt-4o-mini"},
}
self._data = self.data
self._agent_security = self.data["agent_security"]
language = property(lambda self: self.data["language"])
theme = property(lambda self: self.data["theme"])
active_provider = property(lambda self: self.data["active_provider"])
agent_security = property(lambda self: self.data["agent_security"])
routing = property(lambda self: self.data["routing"])
class _Ctx:
def __init__(self):
self.config = _Config()
self.routing = None
self.saves = 0
def save(self):
self.saves += 1
@pytest.fixture
def dialog(qapp):
from cowork_local.ui.settings_dialog import SettingsDialog
ctx = _Ctx()
dlg = SettingsDialog(ctx)
yield dlg, ctx
dlg.deleteLater()
# ---- dialog gồm những mục nào -------------------------------------------
def test_co_dung_nam_muc(dialog):
"""Năm mục thật trên màn hình. Plan R08-T07 ghi bốn widget và có một cái
tên `connector`, nhưng UI connector đã dời khỏi Settings từ trước (xem
ghi chú ở settings_dialog.py:180) — nên con số thật là năm, không bốn."""
dlg, _ = dialog
labels = [dlg.section_list.item(i).text()
for i in range(dlg.section_list.count())]
assert len(labels) == 5, labels
assert dlg.section_stack.count() == 5
def test_moi_muc_deu_bam_duoc(dialog):
dlg, _ = dialog
for i in range(dlg.section_list.count()):
dlg.section_list.setCurrentRow(i)
assert dlg.section_stack.currentIndex() == i
# ---- nạp giá trị từ config ----------------------------------------------
def test_nap_dung_gia_tri_dang_co(dialog):
dlg, ctx = dialog
assert dlg.language_combo.currentData() == "vi"
assert dlg.theme_combo.currentData() == "dark"
assert dlg.provider_combo.currentData() == "openai_compat"
assert dlg.prov_base.text() == "https://api.openai.com/v1"
assert dlg.tray_chk.isChecked() is True
assert dlg.notify_chk.isChecked() is False
assert dlg.routing_mode.currentData() == "auto"
assert dlg.routing_policy.currentData() == "cost"
assert dlg.routing_timeout.value() == 90
assert dlg.attach_files.value() == 7
assert dlg.struct_nodes.value() == 300
assert dlg.sandbox_cpu.value() == 55
def test_khoa_api_khong_hien_ro(dialog):
"""QLineEdit.Password — khoá không được đọc được bằng mắt qua vai."""
from PySide6.QtWidgets import QLineEdit
dlg, _ = dialog
assert dlg.prov_key.echoMode() == QLineEdit.Password
# ---- lưu ghi ra đúng chỗ -------------------------------------------------
def test_luu_ghi_dung_moi_o(dialog):
dlg, ctx = dialog
dlg.language_combo.setCurrentIndex(
dlg.language_combo.findData("en") if dlg.language_combo.findData("en") >= 0 else 0)
dlg.tray_chk.setChecked(False)
dlg.routing_timeout.setValue(120)
dlg.attach_files.setValue(3)
dlg.sandbox_cpu.setValue(80)
dlg._save()
d = ctx.config.data
assert d["tray"]["minimize_on_close"] is False
assert d["routing"]["confirm_timeout_sec"] == 120
assert d["attachments"]["max_files"] == 3
assert d["agent_security"]["resource_limit_cpu_percent"] == 80
assert ctx.saves == 1
def test_luu_doi_min_gain_tu_phan_tram_sang_phan_le(dialog):
"""Ô nhập là %, config lưu số thập phân. Đây là chỗ dễ tách sai nhất."""
dlg, ctx = dialog
dlg.routing_min_gain.setValue(25)
dlg._save()
assert ctx.config.data["routing"]["min_score_gain"] == 0.25
def test_luu_xoa_cache_de_app_doc_lai_ngay(dialog):
dlg, ctx = dialog
dlg._save()
assert ctx.config._data is None
assert ctx.config._agent_security is None
+209
View File
@@ -0,0 +1,209 @@
"""R04-T02 — unit tests for the typed agent event stream.
The events replace the untyped ``{"type": ...}`` dicts the runtime emits today,
but ``ui/chat_panel.py::_on_event`` still dispatches on those dicts until R08.
So the contract under test is two-sided: each event must be a real typed value
AND must serialise back to the exact legacy shape the widget already reads —
same wire name, same keys, same optional-key behaviour.
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from cowork_local.domain.agents.agent_event import (
AssistantMessageCompletedEvent,
ErrorEvent,
HistoryReadyEvent,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
ToolPreview,
TurnCompletedEvent,
)
from cowork_local.domain.agents.agent_event_codec import from_legacy_dict
# -- base contract --------------------------------------------------------- #
def test_events_reject_mutation() -> None:
event = TextChunkEvent(delta="hello")
with pytest.raises(FrozenInstanceError):
event.delta = "goodbye"
# -- legacy wire compatibility --------------------------------------------- #
def test_text_chunk_serialises_as_the_legacy_text_event() -> None:
assert TextChunkEvent(delta="hi").to_legacy_dict() == {"type": "text", "delta": "hi"}
def test_reasoning_chunk_serialises_as_the_legacy_reasoning_event() -> None:
assert ReasoningChunkEvent(delta="hmm").to_legacy_dict() == {
"type": "reasoning", "delta": "hmm"}
def test_assistant_message_completed_serialises_as_assistant_done() -> None:
# Fires once per provider call, so several times in a tool-using turn — it
# is NOT the end of the turn (that is TurnCompletedEvent).
assert AssistantMessageCompletedEvent(content="done").to_legacy_dict() == {
"type": "assistant_done", "content": "done"}
def test_tool_call_started_serialises_with_the_legacy_id_and_args_keys() -> None:
event = ToolCallStartedEvent(
call_id="call_1", name="write_file", arguments={"path": "a.md"},
preview=ToolPreview(kind="diff", title="Create file: a.md", text="+ hi"),
)
assert event.to_legacy_dict() == {
"type": "tool_proposed",
"id": "call_1",
"name": "write_file",
"args": {"path": "a.md"},
"preview": {"kind": "diff", "title": "Create file: a.md", "text": "+ hi"},
}
def test_tool_call_started_omits_the_preview_when_there_is_none() -> None:
event = ToolCallStartedEvent(call_id="call_1", name="read_file")
assert "preview" not in event.to_legacy_dict()
def test_tool_output_chunk_serialises_as_the_legacy_tool_output_event() -> None:
event = ToolOutputChunkEvent(call_id="call_1", name="run_command", delta="line\n")
assert event.to_legacy_dict() == {
"type": "tool_output", "id": "call_1", "name": "run_command", "delta": "line\n"}
def test_tool_call_finished_serialises_as_the_legacy_tool_result_event() -> None:
event = ToolCallFinishedEvent(
call_id="call_1", name="save_file", ok=True, output="saved",
path="C:/out/a.md", produced=["C:/out/b.pptx"],
)
assert event.to_legacy_dict() == {
"type": "tool_result",
"id": "call_1",
"name": "save_file",
"ok": True,
"output": "saved",
"path": "C:/out/a.md",
"produced": ["C:/out/b.pptx"],
}
def test_tool_call_finished_omits_path_and_produced_when_empty() -> None:
# chat_agent only sets these keys when they exist; emitting them as None
# would make ``ev.get("path")`` truthy checks read differently downstream.
legacy = ToolCallFinishedEvent(call_id="c", name="read_file", ok=True).to_legacy_dict()
assert "path" not in legacy
assert "produced" not in legacy
def test_plan_updated_serialises_steps_back_to_title_status_dicts() -> None:
event = PlanUpdatedEvent(steps=(PlanStep(title="Read config", status="done"),
PlanStep(title="Patch it", status="running")))
assert event.to_legacy_dict() == {
"type": "plan_set",
"steps": [{"title": "Read config", "status": "done"},
{"title": "Patch it", "status": "running"}],
}
def test_notice_serialises_with_its_level() -> None:
assert NoticeEvent(text="reading page 2/9", level="progress").to_legacy_dict() == {
"type": "notice", "level": "progress", "text": "reading page 2/9"}
def test_notice_defaults_to_the_info_level() -> None:
assert NoticeEvent(text="compacted").to_legacy_dict()["level"] == "info"
def test_outputs_added_and_removed_serialise_their_path_lists() -> None:
assert OutputsAddedEvent(paths=("a.md",)).to_legacy_dict() == {
"type": "outputs_added", "paths": ["a.md"]}
assert OutputsRemovedEvent(paths=("tmp.py",)).to_legacy_dict() == {
"type": "outputs_removed", "paths": ["tmp.py"]}
def test_history_ready_serialises_its_session_id() -> None:
assert HistoryReadyEvent(session_id="s7").to_legacy_dict() == {
"type": "history_ready", "session_id": "s7"}
# -- events introduced by R04 (no legacy consumer) ------------------------- #
def test_turn_completed_carries_the_final_answer_and_step_count() -> None:
event = TurnCompletedEvent(final_text="all done", steps_used=3)
assert event.to_legacy_dict() == {
"type": "turn_completed", "final_text": "all done", "steps_used": 3,
"cancelled": False, "budget_exhausted": False}
def test_error_event_is_fatal_unless_marked_recoverable() -> None:
assert ErrorEvent(message="boom").recoverable is False
assert ErrorEvent(message="rate limited", recoverable=True).recoverable is True
# -- parsing legacy dicts back into events --------------------------------- #
_ROUND_TRIP_CASES = [
TextChunkEvent(delta="hi"),
ReasoningChunkEvent(delta="hmm"),
AssistantMessageCompletedEvent(content="done"),
ToolCallStartedEvent(call_id="c", name="run_command", arguments={"command": "ls"},
preview=ToolPreview(kind="command", title="Run", text="ls")),
ToolCallStartedEvent(call_id="c", name="read_file"),
ToolOutputChunkEvent(call_id="c", name="run_command", delta="out"),
ToolCallFinishedEvent(call_id="c", name="save_file", ok=True, output="ok",
path="a.md", produced=["b.md"]),
ToolCallFinishedEvent(call_id="c", name="read_file", ok=False, output="missing"),
PlanUpdatedEvent(steps=(PlanStep(title="Step", status="pending"),)),
NoticeEvent(text="warned", level="warning"),
OutputsAddedEvent(paths=("a.md",)),
OutputsRemovedEvent(paths=("tmp.py",)),
HistoryReadyEvent(session_id="s7"),
TurnCompletedEvent(final_text="done", steps_used=2, cancelled=True),
ErrorEvent(message="boom", recoverable=True),
]
@pytest.mark.parametrize("event", _ROUND_TRIP_CASES, ids=lambda e: type(e).__name__)
def test_every_event_survives_a_round_trip_through_the_legacy_dict(event) -> None:
assert from_legacy_dict(event.to_legacy_dict()) == event
def test_unknown_event_types_parse_to_none_instead_of_raising() -> None:
# Co4E emits its own vocabulary (node_status, stage_text, run_done) which R04
# deliberately leaves alone; a bridge must be able to pass those through
# untouched rather than crash on them.
assert from_legacy_dict({"type": "node_status", "node_id": "n1"}) is None
assert from_legacy_dict({"type": ""}) is None
assert from_legacy_dict("not a dict") is None
def test_missing_payload_keys_parse_to_empty_values() -> None:
# Defensive: a truncated event from an older emitter must not kill the turn.
assert from_legacy_dict({"type": "text"}) == TextChunkEvent(delta="")
assert from_legacy_dict({"type": "tool_result", "id": "c", "name": "x"}) == (
ToolCallFinishedEvent(call_id="c", name="x", ok=False, output=""))
def test_plan_steps_from_legacy_drop_entries_without_a_title() -> None:
# normalize_plan_steps already clamps upstream; this only guards the parse
# path so a hand-written dict cannot produce a titleless step.
event = from_legacy_dict({"type": "plan_set",
"steps": [{"title": "Real", "status": "done"}, {"status": "done"}]})
assert event == PlanUpdatedEvent(steps=(PlanStep(title="Real", status="done"),))
+101
View File
@@ -0,0 +1,101 @@
"""R04-T03 (a) — unit tests for the value a finished turn returns.
Two callers need different things out of one turn today:
``ui/chat_panel.py::_finalize_turn`` wants the message list, while
``core/task_executors.py::_run_agent`` returns a
``(answer_text, timed_out, incomplete_reason)`` tuple assembled by hand. This
type is what both read instead, so "what happened in that turn?" has one answer
with names on it.
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from cowork_local.domain.agents.agent_event import PlanStep, TurnCompletedEvent
from cowork_local.domain.agents.agent_result import AgentResult
def test_result_rejects_mutation() -> None:
result = AgentResult(steps_used=1)
with pytest.raises(FrozenInstanceError):
result.steps_used = 2
def test_messages_are_frozen_into_a_tuple() -> None:
live = [{"role": "user", "content": "hi"}]
result = AgentResult(messages=live)
live.append({"role": "assistant", "content": "later"})
assert result.messages == ({"role": "user", "content": "hi"},)
def test_final_text_is_the_last_non_empty_assistant_message() -> None:
# A turn ends on a tool message often enough (cancelled mid-loop) that the
# answer cannot simply be messages[-1].
result = AgentResult(messages=[
{"role": "assistant", "content": "first pass"},
{"role": "assistant", "content": "the answer"},
{"role": "tool", "tool_call_id": "c", "name": "read_file", "content": "..."},
])
assert result.final_text == "the answer"
def test_final_text_skips_a_blank_assistant_message() -> None:
result = AgentResult(messages=[
{"role": "assistant", "content": "the answer"},
{"role": "assistant", "content": " "},
])
assert result.final_text == "the answer"
def test_final_text_is_empty_when_the_model_never_answered() -> None:
assert AgentResult(messages=[{"role": "user", "content": "hi"}]).final_text == ""
def test_a_plain_finished_turn_is_ok() -> None:
assert AgentResult(messages=[{"role": "assistant", "content": "done"}]).ok is True
def test_a_cancelled_turn_is_not_ok() -> None:
assert AgentResult(cancelled=True).ok is False
def test_a_failed_turn_is_not_ok_and_keeps_its_message() -> None:
result = AgentResult(error="SecurityBlocked: nope")
assert result.ok is False
assert result.error == "SecurityBlocked: nope"
def test_hitting_the_step_ceiling_is_reported_separately_from_cancelling() -> None:
# "Stopped because the safety limit was reached" and "the user pressed Stop"
# need different wording in the transcript, so they stay separate flags.
result = AgentResult(budget_exhausted=True, steps_used=30)
assert result.budget_exhausted is True
assert result.cancelled is False
def test_result_converts_to_the_turn_completed_event() -> None:
result = AgentResult(
messages=[{"role": "assistant", "content": "done"}],
steps_used=3, cancelled=False, budget_exhausted=True,
)
assert result.to_turn_completed_event() == TurnCompletedEvent(
final_text="done", steps_used=3, cancelled=False, budget_exhausted=True)
def test_plan_steps_are_frozen_into_a_tuple() -> None:
steps = [PlanStep(title="Draft", status="done")]
result = AgentResult(plan_steps=steps)
steps.append(PlanStep(title="Review"))
assert result.plan_steps == (PlanStep(title="Draft", status="done"),)
+59
View File
@@ -0,0 +1,59 @@
"""Unit tests for the Clean Architecture AST Import Guard (check_imports.py)."""
from __future__ import annotations
from pathlib import Path
from scripts.check_imports import FORBIDDEN_MODULE_PREFIXES, scan_file
def test_clean_python_file_passes(tmp_path: Path) -> None:
"""Verify that pure Python code without GUI imports produces 0 violations."""
clean_code = """
import os
import json
from dataclasses import dataclass
from typing import List
@dataclass
class UserRequest:
id: str
prompt: str
"""
clean_file = tmp_path / "clean_service.py"
clean_file.write_text(clean_code, encoding="utf-8")
violations = scan_file(clean_file, FORBIDDEN_MODULE_PREFIXES)
assert len(violations) == 0
def test_forbidden_pyside_import_detected(tmp_path: Path) -> None:
"""Verify that PySide6 import is caught with correct line number."""
dirty_code = """
from dataclasses import dataclass
from PySide6.QtWidgets import QWidget
class BadService:
pass
"""
dirty_file = tmp_path / "bad_service.py"
dirty_file.write_text(dirty_code, encoding="utf-8")
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
assert len(violations) == 1
assert violations[0].line_number == 3
assert "PySide6" in violations[0].imported_module
def test_forbidden_ui_and_app_import_detected(tmp_path: Path) -> None:
"""Verify that importing concrete UI or app modules from domain is caught."""
dirty_code = """
import ui.chat_panel
from app import MainWindow
"""
dirty_file = tmp_path / "cross_layer_leak.py"
dirty_file.write_text(dirty_code, encoding="utf-8")
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
assert len(violations) == 2
modules = [v.imported_module for v in violations]
assert "ui.chat_panel" in modules
assert "app" in modules
@@ -0,0 +1,293 @@
"""R04-T03 (b) — the turn loop: composition, tool dispatch, budget, cancel.
Behaviour that used to be reachable only by running the real widget. Every
dependency is a fake from ``tests/fakes/turn_runtime_fakes.py``, so the file
runs in milliseconds and each test states one rule of the loop.
"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from cowork_local.application.conversations.conversation_application_service import (
ConversationApplicationService,
)
from cowork_local.domain.agents.agent_event import (
AssistantMessageCompletedEvent,
PlanStep,
PlanUpdatedEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
ToolPreview,
TurnCompletedEvent,
)
from cowork_local.tests.fakes.turn_runtime_fakes import (
FakeModelCall,
FakeReply,
FakeToolRuntime,
events_of_type,
make_request,
run_turn,
tool_turn,
)
def _service(model, tools, **overrides) -> ConversationApplicationService:
return ConversationApplicationService(model, tools, **overrides)
# --------------------------------------------------------------------------- #
# The happy path.
# --------------------------------------------------------------------------- #
def test_a_plain_answer_streams_text_then_reports_the_message_and_the_turn() -> None:
model = FakeModelCall([FakeReply(content="Hello there", chunks=["Hello ", "there"])])
result, events = run_turn(_service(model, FakeToolRuntime()))
assert [e.delta for e in events_of_type(events, TextChunkEvent)] == ["Hello ", "there"]
assert events_of_type(events, AssistantMessageCompletedEvent) == [
AssistantMessageCompletedEvent(content="Hello there")]
assert events_of_type(events, TurnCompletedEvent) == [
TurnCompletedEvent(final_text="Hello there", steps_used=1)]
assert result.final_text == "Hello there"
assert result.ok is True
def test_the_composed_user_message_is_appended_before_the_first_call() -> None:
model = FakeModelCall([FakeReply(content="ok")])
request = make_request(prompt="ship it", instruction_prefix="RULES",
session_notes="earlier: a.md",
messages=[{"role": "user", "content": "previous"}])
run_turn(_service(model, FakeToolRuntime()), request)
sent = model.calls[0]["messages"]
assert sent[-1] == {"role": "user",
"content": "RULES\n\n---\n\nship it\n\nearlier: a.md"}
assert sent[-2] == {"role": "user", "content": "previous"}
def test_attachments_are_read_when_the_turn_runs_not_when_it_was_built() -> None:
# Extraction can pip-install a parser or shell out to LibreOffice, so it must
# happen here (worker thread), not while the UI was assembling the request.
seen: List[Tuple[str, Tuple[str, ...]]] = []
def reader(prompt: str, attachments: Tuple[str, ...]) -> str:
seen.append((prompt, attachments))
return f"{prompt}\n\n<contents of {len(attachments)} file(s)>"
model = FakeModelCall([FakeReply(content="ok")])
request = make_request(prompt="summarise", attachments=["a.docx", "b.pdf"])
run_turn(_service(model, FakeToolRuntime(), attachment_reader=reader), request)
assert seen == [("summarise", ("a.docx", "b.pdf"))]
assert "contents of 2 file(s)" in model.calls[0]["messages"][-1]["content"]
def test_the_prompt_preparer_is_told_which_tools_the_turn_advertises() -> None:
# The system prompt gains an MS365 paragraph only when ms365__* tools are
# present, so the preparer has to see the real list.
seen: List[Tuple[str, ...]] = []
model = FakeModelCall([FakeReply(content="ok")])
tools = FakeToolRuntime(specs=("save_file", "ms365__send_mail"))
run_turn(_service(model, tools,
prepare_prompt=lambda messages, names: seen.append(names)))
assert seen == [("save_file", "ms365__send_mail")]
def test_only_the_allowed_tools_are_advertised() -> None:
model = FakeModelCall([FakeReply(content="ok")])
tools = FakeToolRuntime(specs=("save_file", "run_command", "update_plan"))
run_turn(_service(model, tools), make_request(allowed_tools=("save_file", "update_plan")))
assert model.calls[0]["tool_names"] == ["save_file", "update_plan"]
# --------------------------------------------------------------------------- #
# Tool dispatch.
# --------------------------------------------------------------------------- #
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
"""A turn that calls one tool, then answers."""
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
FakeReply(content="done")])
return model, FakeToolRuntime(**tool_kwargs)
def test_a_tool_call_is_announced_executed_and_answered_in_the_message_list() -> None:
model, tools = tool_turn(results={"save_file": {"ok": True, "output": "saved",
"path": "out/a.md"}})
result, events = run_turn(_service(model, tools))
assert events_of_type(events, ToolCallStartedEvent) == [ToolCallStartedEvent(
call_id="c1", name="save_file", arguments={"filename": "a.md"},
preview=ToolPreview(kind="info", title="save_file", text="{'filename': 'a.md'}"))]
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
call_id="c1", name="save_file", ok=True, output="saved", path="out/a.md")]
assert tools.executed == [("save_file", {"filename": "a.md"})]
assert result.messages[-2] == {"role": "tool", "tool_call_id": "c1",
"name": "save_file", "content": "saved"}
def test_live_tool_output_is_streamed_while_the_tool_runs() -> None:
model, tools = tool_turn("run_command", {"command": "ls"})
tools.emit_output = "file-a\n"
_, events = run_turn(_service(model, tools))
assert events_of_type(events, ToolOutputChunkEvent) == [ToolOutputChunkEvent(
call_id="c1", name="run_command", delta="file-a\n")]
def test_the_loop_ends_as_soon_as_the_model_stops_calling_tools() -> None:
model, tools = tool_turn()
result, _ = run_turn(_service(model, tools))
assert result.steps_used == 2
assert result.budget_exhausted is False
def test_the_plan_tool_reports_a_plan_update_and_no_tool_bubble() -> None:
calls = [{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"}]}}]
model = FakeModelCall([FakeReply(content="planning", tool_calls=calls), FakeReply(content="done")])
tools = FakeToolRuntime(results={"update_plan": {
"ok": True, "output": "Plan updated.",
"plan_steps": [PlanStep(title="Draft", status="running")]}})
result, events = run_turn(_service(model, tools))
assert events_of_type(events, PlanUpdatedEvent) == [
PlanUpdatedEvent(steps=(PlanStep(title="Draft", status="running"),))]
assert events_of_type(events, ToolCallStartedEvent) == []
assert events_of_type(events, ToolCallFinishedEvent) == []
assert result.plan_steps == (PlanStep(title="Draft", status="running"),)
# --------------------------------------------------------------------------- #
# Budget, cancellation.
# --------------------------------------------------------------------------- #
def test_running_out_of_steps_is_flagged_and_announced() -> None:
# The model keeps calling tools forever; the ceiling must stop it visibly.
forever = [FakeReply(content=f"step {i}",
tool_calls=[{"id": f"c{i}", "name": "save_file", "arguments": {}}])
for i in range(5)]
model = FakeModelCall(forever)
result, events = run_turn(_service(model, FakeToolRuntime()), make_request(max_steps=2))
assert result.steps_used == 2
assert result.budget_exhausted is True
assert "2-step safety limit" in events_of_type(events, TextChunkEvent)[-1].delta
# The note reaches the transcript but NOT the stored answer: a turn that hits
# the ceiling always ends on a tool message, and the existing runtime only
# merges the note when the last message is the assistant's. Pinned here so a
# future change to that rule is a deliberate decision, not a silent drift.
assert result.final_text == "step 1"
def test_run_to_completion_uses_the_higher_ceiling() -> None:
forever = [FakeReply(content="x", tool_calls=[{"id": "c", "name": "save_file", "arguments": {}}])
for _ in range(6)]
model = FakeModelCall(forever)
result, _ = run_turn(_service(model, FakeToolRuntime()),
make_request(max_steps=2, completion_max_steps=5, run_to_completion=True))
assert result.steps_used == 5
def test_a_turn_cancelled_before_it_starts_never_calls_the_model() -> None:
model = FakeModelCall([FakeReply(content="never")])
result, events = run_turn(_service(model, FakeToolRuntime()), cancel=lambda: True)
assert model.calls == []
assert result.cancelled is True
assert result.budget_exhausted is False
assert events_of_type(events, TurnCompletedEvent) == [TurnCompletedEvent(cancelled=True)]
def test_cancelling_during_a_turn_stops_dispatching_the_remaining_tool_calls() -> None:
calls = [{"id": "c1", "name": "save_file", "arguments": {}},
{"id": "c2", "name": "save_file", "arguments": {}}]
model = FakeModelCall([FakeReply(content="two tools", tool_calls=calls)])
tools = FakeToolRuntime()
stop = {"now": False}
def cancel() -> bool:
return stop["now"]
original_execute = tools.execute
def execute(name, args, on_output=None, cancel=None):
stop["now"] = True # cancel raised while the first tool runs
return original_execute(name, args, on_output=on_output, cancel=cancel)
tools.execute = execute
result, _ = run_turn(_service(model, tools), cancel=cancel)
assert len(tools.executed) == 1
assert result.cancelled is True
# --------------------------------------------------------------------------- #
# Bring-your-own working list.
#
# ``ui/chat_panel.py`` holds the turn's message list in its own turn context and
# reads it WHILE the worker appends (``_reattach_running_turn`` replays the steps
# done so far when the user reopens a running conversation; ``_finalize_turn``
# slices it by ``snapshot_len``). A service that built its own private list would
# silently break both, so a caller can hand its list over instead.
# --------------------------------------------------------------------------- #
def test_a_caller_supplied_list_is_appended_to_in_place() -> None:
model, tools = tool_turn()
live: List[Dict[str, Any]] = [{"role": "user", "content": "already composed"}]
result = ConversationApplicationService(model, tools).execute(
make_request(), lambda event: None, messages=live)
roles = [m["role"] for m in live]
assert roles == ["user", "assistant", "tool", "assistant"]
assert result.messages == tuple(live)
def test_a_caller_supplied_list_is_used_as_is_without_recomposing_the_prompt() -> None:
# The widget already applied the skill prefix and the session notes when it
# built its message; composing again would duplicate them.
model = FakeModelCall([FakeReply(content="ok")])
user = {"role": "user", "content": "already composed"}
live = [user]
ConversationApplicationService(model, FakeToolRuntime()).execute(
make_request(prompt="typed text", instruction_prefix="RULES",
session_notes="notes"),
lambda event: None, messages=live)
assert live[0] is user
assert live[0]["content"] == "already composed"
assert [m["role"] for m in live].count("user") == 1
def test_a_caller_supplied_list_skips_the_attachment_reader() -> None:
# Reading the attachments is what produced the caller's message in the first
# place; doing it again would re-parse every file.
model = FakeModelCall([FakeReply(content="ok")])
calls: List[Any] = []
ConversationApplicationService(
model, FakeToolRuntime(),
attachment_reader=lambda prompt, attachments: calls.append(prompt) or prompt,
).execute(make_request(attachments=["a.docx"]), lambda event: None,
messages=[{"role": "user", "content": "composed"}])
assert calls == []
@@ -0,0 +1,132 @@
"""R04-T01 — unit tests for the immutable turn snapshot.
The snapshot exists so a turn already running cannot be altered by the UI the
user keeps clicking on. These tests pin exactly that: the object refuses
mutation, it copies the mutable collections handed to it at submit time, and it
owns the prompt-composition rules that were inline in
``ui/chat_panel.py::_start_turn``'s worker closure (prefix separator, session
notes, model-switch review note).
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
from pathlib import Path
import pytest
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
def _request(**overrides) -> ConversationExecutionRequest:
"""A minimal valid request; each test overrides only what it exercises."""
base = {"turn_id": "t1", "session_id": "s1"}
base.update(overrides)
return ConversationExecutionRequest(**base)
# -- immutability ---------------------------------------------------------- #
def test_request_rejects_mutation_after_construction() -> None:
request = _request(model="gpt-4o-mini")
with pytest.raises(FrozenInstanceError):
request.model = "claude-sonnet-4-6"
def test_turn_id_is_required() -> None:
with pytest.raises(ValueError):
ConversationExecutionRequest(turn_id="", session_id="s1")
def test_session_id_is_required() -> None:
with pytest.raises(ValueError):
ConversationExecutionRequest(turn_id="t1", session_id="")
# -- snapshotting mutable UI state ---------------------------------------- #
def test_attachments_are_snapshotted_away_from_the_caller_list() -> None:
picked = ["a.docx"]
request = _request(attachments=picked)
picked.append("b.pdf") # the composer clears/refills its own list next turn
assert request.attachments == ("a.docx",)
def test_messages_are_snapshotted_away_from_the_live_history_list() -> None:
history = [{"role": "user", "content": "earlier"}]
request = _request(messages=history)
history.append({"role": "assistant", "content": "later"})
assert len(request.messages) == 1
assert isinstance(request.messages, tuple)
def test_allowed_tools_none_means_every_tool_stays_available() -> None:
# None and () must stay distinguishable: None = no restriction, () = deny
# every built-in tool. Coercing None to () would silently disarm the agent.
assert _request().allowed_tools is None
assert _request(allowed_tools=[]).allowed_tools == ()
def test_output_paths_accept_strings_and_normalise_to_path() -> None:
request = _request(output_dir="out/t1", home_output_root="out")
assert request.output_dir == Path("out/t1")
assert request.home_output_root == Path("out")
# -- derived turn policy --------------------------------------------------- #
def test_effective_max_steps_uses_the_interactive_cap_by_default() -> None:
assert _request(max_steps=30, completion_max_steps=200).effective_max_steps == 30
def test_effective_max_steps_lifts_the_cap_when_running_to_completion() -> None:
request = _request(max_steps=30, completion_max_steps=200, run_to_completion=True)
assert request.effective_max_steps == 200
def test_permission_gate_is_required_only_in_confirm_mode() -> None:
assert _request(gate_mode="confirm").requires_permission_gate is True
assert _request(gate_mode="auto").requires_permission_gate is False
def test_has_prompt_ignores_whitespace_only_input() -> None:
assert _request(prompt=" \n ").has_prompt is False
assert _request(prompt="do it").has_prompt is True
# -- prompt composition (moved out of the widget's worker closure) --------- #
def test_user_content_returns_the_body_unchanged_without_prefix_or_notes() -> None:
assert _request().user_content("the body") == "the body"
def test_user_content_separates_the_instruction_prefix_from_the_body() -> None:
request = _request(instruction_prefix="SKILL RULES")
assert request.user_content("the body") == "SKILL RULES\n\n---\n\nthe body"
def test_user_content_appends_session_notes_after_the_body() -> None:
request = _request(session_notes="Files produced earlier: a.md")
assert request.user_content("the body") == "the body\n\nFiles produced earlier: a.md"
def test_user_content_falls_back_to_session_notes_when_the_body_is_empty() -> None:
# An attachment-only turn has no typed text, so the notes must not be
# prefixed with a stray blank line.
request = _request(session_notes="Files produced earlier: a.md")
assert request.user_content("") == "Files produced earlier: a.md"
def test_user_content_puts_the_review_note_ahead_of_everything_else() -> None:
request = _request(instruction_prefix="SKILL RULES", review_note="[Note: switched]")
content = request.user_content("the body")
assert content == "[Note: switched]\n\nSKILL RULES\n\n---\n\nthe body"
+210
View File
@@ -0,0 +1,210 @@
"""R04-T03 (b) — the turn loop: guards, permission gate, compaction, cleanup.
Split out of ``test_conversation_application_service.py`` to keep each file
inside the 400-LOC limit. Same fakes, same service; this half pins the ORDER of
the safety steps (guard before model, guard before execute, gate before execute)
and the promise that the output sandbox is tidied on the way out.
"""
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from cowork_local.application.conversations.conversation_application_service import (
ConversationApplicationService,
)
from cowork_local.domain.agents.agent_event import (
ErrorEvent,
OutputsAddedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
)
from cowork_local.tests.fakes.turn_runtime_fakes import (
FakeModelCall,
FakeReply,
FakeToolRuntime,
events_of_type,
make_request,
run_turn,
tool_turn,
)
def _service(model, tools, **overrides) -> ConversationApplicationService:
return ConversationApplicationService(model, tools, **overrides)
# --------------------------------------------------------------------------- #
# Guards and the permission gate.
# --------------------------------------------------------------------------- #
def test_the_prompt_guard_runs_before_the_model_is_ever_called() -> None:
order: List[str] = []
model = FakeModelCall([FakeReply(content="ok")])
model_call = model.call
def call(*a, **kw):
order.append("model")
return model_call(*a, **kw)
model.call = call
run_turn(_service(model, FakeToolRuntime(), prompt_guard=lambda messages: order.append("guard")))
assert order == ["guard", "model"]
def test_a_blocked_prompt_propagates_before_the_output_folder_is_touched() -> None:
model = FakeModelCall([FakeReply(content="never")])
tools = FakeToolRuntime()
events: List[Any] = []
def guard(messages) -> None:
raise RuntimeError("SecurityBlocked: nope")
service = _service(model, tools, prompt_guard=guard)
with pytest.raises(RuntimeError, match="SecurityBlocked"):
service.execute(make_request(), events.append)
assert model.calls == []
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="SecurityBlocked: nope")]
# Cleanup is NOT a read-only operation (it deletes a stale .scratch and every
# empty sub-folder), so a turn rejected before it started must not run it.
assert tools.finalize_calls == []
def test_output_cleanup_still_runs_when_the_turn_fails_mid_loop() -> None:
# Once the turn has started producing files, the sandbox must be tidied on
# the way out no matter how the turn ends.
model = FakeModelCall([RuntimeError("gateway exploded")])
tools = FakeToolRuntime()
events: List[Any] = []
with pytest.raises(RuntimeError, match="gateway exploded"):
_service(model, tools).execute(make_request(), events.append)
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="gateway exploded")]
def test_the_command_guard_runs_before_the_tool_executes() -> None:
order: List[str] = []
model, tools = tool_turn("run_command", {"command": "ls"})
original = tools.execute
def execute(name, args, on_output=None, cancel=None):
order.append("execute")
return original(name, args, on_output=on_output, cancel=cancel)
tools.execute = execute
run_turn(_service(model, tools,
command_guard=lambda name, args: order.append(f"guard:{name}")))
assert order == ["guard:run_command", "execute"]
def test_disabling_rule_enforcement_skips_both_guards() -> None:
# Co4E flow steps run inside the workspace sandbox and opt out on purpose.
calls: List[str] = []
model, tools = tool_turn("run_command", {"command": "ls"})
run_turn(_service(model, tools,
prompt_guard=lambda messages: calls.append("prompt"),
command_guard=lambda name, args: calls.append("command")),
make_request(enforce_rules=False))
assert calls == []
def test_the_permission_gate_is_asked_only_for_command_tools() -> None:
asked: List[str] = []
model, tools = tool_turn("save_file", {"filename": "a.md"})
run_turn(_service(model, tools,
permission_request=lambda action: asked.append(action["name"]) or True),
make_request(gate_mode="confirm"))
assert asked == [] # save_file writes into the sandbox: never gated
def test_a_command_tool_in_confirm_mode_asks_before_running() -> None:
asked: List[Dict[str, Any]] = []
model, tools = tool_turn("run_command", {"command": "ls"})
def approve(action: Dict[str, Any]) -> bool:
asked.append(action)
return True
run_turn(_service(model, tools, permission_request=approve), make_request(gate_mode="confirm"))
assert [a["name"] for a in asked] == ["run_command"]
assert tools.executed == [("run_command", {"command": "ls"})]
def test_a_rejected_command_is_reported_as_a_failed_tool_and_never_runs() -> None:
model, tools = tool_turn("run_command", {"command": "rm -rf /"})
result, events = run_turn(_service(model, tools, permission_request=lambda action: False),
make_request(gate_mode="confirm"))
assert tools.executed == []
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
call_id="c1", name="run_command", ok=False, output="Rejected by user.")]
assert result.messages[-2]["content"] == "Rejected by user."
def test_auto_mode_never_asks_even_for_a_command() -> None:
model, tools = tool_turn("run_command", {"command": "ls"})
def refuse(action): # would block the turn if it were consulted
raise AssertionError("the gate must not be consulted in auto mode")
run_turn(_service(model, tools, permission_request=refuse), make_request(gate_mode="auto"))
assert tools.executed == [("run_command", {"command": "ls"})]
# --------------------------------------------------------------------------- #
# Context compaction, reasoning, output cleanup.
# --------------------------------------------------------------------------- #
def test_the_conversation_is_offered_for_compaction_before_every_call() -> None:
compactions: List[int] = []
model, tools = tool_turn()
run_turn(_service(model, tools,
compact=lambda messages, cancel: compactions.append(len(messages))))
assert len(compactions) == 2 # once per provider call
def test_reasoning_is_streamed_as_its_own_event() -> None:
model = FakeModelCall([FakeReply(content="42", reasoning="thinking...")])
_, events = run_turn(_service(model, FakeToolRuntime()))
assert events_of_type(events, ReasoningChunkEvent) == [ReasoningChunkEvent(delta="thinking...")]
def test_a_reasoning_only_reply_gets_a_visible_note_in_the_transcript() -> None:
# Otherwise a Schedule Task run reads back an empty answer and writes
# "(no output)" into its report.
model = FakeModelCall([FakeReply(content="", reasoning="thought hard")])
result, events = run_turn(_service(model, FakeToolRuntime()))
assert "only its reasoning" in events_of_type(events, TextChunkEvent)[-1].delta
assert "only its reasoning" in result.final_text
def test_promoted_and_discarded_output_files_are_reported_at_the_end() -> None:
model = FakeModelCall([FakeReply(content="ok")])
tools = FakeToolRuntime(added=("out/report.pptx",))
_, events = run_turn(_service(model, tools))
assert events_of_type(events, OutputsAddedEvent) == [
OutputsAddedEvent(paths=("out/report.pptx",))]
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
+220
View File
@@ -0,0 +1,220 @@
"""Unit tests for the adapters that bridge the routing engine to the app service.
The integration suite covers the happy path over the real engine; this file pins
the translation edge cases that are hard to provoke there — malformed task
types, a missing ranking, and the service-caching contract.
"""
from __future__ import annotations
import pytest
from cowork_local.application.model_routing import (
AppContextModeResolver,
CoreRoutingEngine,
RoutingApplicationService,
RoutingMode,
RoutingRequest,
)
from cowork_local.application.model_routing.core_routing_adapter import (
build_routing_application_service,
)
from cowork_local.core.routing.models import SwitchDecision, SwitchMode, TaskType
class FakeRanking:
"""Just enough of ``selector.Ranking`` for the adapter's usability check."""
def __init__(self, scores) -> None:
self._scores = dict(scores)
def score_of(self, key: str) -> float:
return self._scores.get(key, 0.0)
class FakeRouteResult:
"""Stands in for ``core.routing.service.RouteResult``."""
def __init__(self, decision, task_type=TaskType.CODING, ranking=None, target=None) -> None:
self.decision = decision
self.task_type = task_type
self.ranking = ranking
self._target = target
@property
def should_switch(self) -> bool:
return self.decision.should_switch
def target(self):
return self._target
class FakeRoutingService:
"""Records the arguments the adapter forwards to the engine."""
def __init__(self, result: FakeRouteResult) -> None:
self.result = result
self.calls: list = []
def route(self, surface, prompt, current_provider, current_model, **kwargs):
self.calls.append({"surface": surface, "prompt": prompt,
"current_provider": current_provider,
"current_model": current_model, **kwargs})
return self.result
def make_decision(**overrides) -> SwitchDecision:
fields = dict(
should_switch=True,
from_model="anthropic/weak-model",
to_model="anthropic/strong-model",
score_gain=0.3,
reason="coding fit 0.9 > current 0.6",
mode=SwitchMode.AUTO,
task_type="coding",
)
fields.update(overrides)
return SwitchDecision(**fields)
def make_request(**overrides) -> RoutingRequest:
fields = dict(surface="cowork", prompt="Fix this bug",
current_provider="anthropic", current_model="weak-model")
fields.update(overrides)
return RoutingRequest(**fields)
# --------------------------------------------------------------------------- #
# CoreRoutingEngine translation
# --------------------------------------------------------------------------- #
def test_engine_flattens_the_route_result() -> None:
"""No ``core.routing`` type may leak past the adapter — the application
service and the widgets only ever see plain fields."""
service = FakeRoutingService(FakeRouteResult(
make_decision(),
ranking=FakeRanking({"anthropic/weak-model": 0.6}),
target=("anthropic", "strong-model"),
))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.task_type == "coding" # str, not TaskType
assert evaluation.should_switch is True
assert evaluation.target_provider == "anthropic"
assert evaluation.target_model == "strong-model"
assert evaluation.score_gain == pytest.approx(0.3)
assert evaluation.current_is_usable is True
def test_engine_forwards_the_mode_as_a_plain_string() -> None:
"""``RoutingService.route`` takes the mode as a string; handing it an enum
would silently fall through to its "unknown mode -> off" branch."""
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert service.calls[0]["mode_override"] == "auto"
def test_engine_reports_an_unranked_model_as_unusable() -> None:
"""This is the signal Fallback acts on: absent from the ranking means the
selector already rejected it (unavailable / no probe / failed probe)."""
service = FakeRoutingService(FakeRouteResult(
make_decision(),
ranking=FakeRanking({"anthropic/strong-model": 0.9}), # current is absent
target=("anthropic", "strong-model"),
))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.current_is_usable is False
def test_engine_assumes_usable_without_a_ranking() -> None:
"""No ranking (routing off, or the engine's own error path) is absence of
evidence — it must not trigger a surprise Fallback switch."""
service = FakeRoutingService(FakeRouteResult(make_decision(), ranking=None))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.current_is_usable is True
def test_engine_assumes_usable_when_the_ranking_misbehaves() -> None:
"""A broken ranking object must not fail the turn."""
class BrokenRanking:
def score_of(self, key):
raise RuntimeError("corrupt ranking")
service = FakeRoutingService(FakeRouteResult(make_decision(), ranking=BrokenRanking()))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.current_is_usable is True
@pytest.mark.parametrize(
"raw, expected",
[("coding", TaskType.CODING), ("QA", TaskType.QA), (None, None), ("nonsense", None)],
)
def test_task_type_strings_are_coerced_or_dropped(raw, expected) -> None:
"""A pinned task type is honoured; an unknown one falls back to letting the
engine classify the prompt rather than raising mid-turn."""
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
CoreRoutingEngine(service).evaluate(make_request(task_type=raw), RoutingMode.AUTO)
assert service.calls[0]["task_type"] == expected
def test_required_capabilities_are_passed_as_a_list_or_none() -> None:
"""``rank_models`` filters on a list; an empty tuple must become None so it
is treated as "no filter" rather than "require nothing, but filter"."""
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
engine = CoreRoutingEngine(service)
engine.evaluate(make_request(required_capabilities=("vision",)), RoutingMode.AUTO)
engine.evaluate(make_request(), RoutingMode.AUTO)
assert service.calls[0]["required_capabilities"] == ["vision"]
assert service.calls[1]["required_capabilities"] is None
# --------------------------------------------------------------------------- #
# Mode resolver + wiring
# --------------------------------------------------------------------------- #
def test_mode_resolver_reads_the_per_workspace_mode() -> None:
"""Per-workspace routing keeps working now that the lookup left the widgets."""
class StubCtx:
def project_routing_mode(self, surface):
return "fallback" if surface == "co4e" else "off"
resolver = AppContextModeResolver(StubCtx())
assert resolver.mode_for("co4e") is RoutingMode.FALLBACK
assert resolver.mode_for("cowork") is RoutingMode.OFF
def test_service_is_built_once_and_cached_on_the_context() -> None:
"""Every surface must share one instance, so future per-surface state (a
cool-down, a switch history) is shared rather than duplicated per widget."""
class StubCtx:
def __init__(self):
self.routing_calls = 0
self.config = type("Cfg", (), {"routing": {"confirm_timeout_sec": 45}})()
def routing(self):
self.routing_calls += 1
return FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
def project_routing_mode(self, surface):
return "off"
ctx = StubCtx()
first = build_routing_application_service(ctx)
second = build_routing_application_service(ctx)
assert first is second
assert ctx.routing_calls == 1
assert isinstance(first, RoutingApplicationService)
# The confirm timeout is read from config at call time, not frozen at build.
assert first.confirm_timeout() == pytest.approx(45.0)
+76
View File
@@ -0,0 +1,76 @@
"""R04-T04 — unit tests for the UI-state -> request mapping.
Three small rules used to sit inline in ``ui/cowork_tab.py::build_job``, where no
test could reach them: the turn's prompt is the last message in the working list,
the history is everything before it, and the confirm-commands flag becomes a gate
mode. Getting any of them wrong is silent (a duplicated user message, a command
that stops asking for approval), so they are pinned here.
"""
from __future__ import annotations
from pathlib import Path
from cowork_local.application.conversations.cowork_turn_request import (
build_cowork_turn_request,
)
def _build(**overrides):
base = {
"turn_id": "t3",
"session_id": "s1",
"messages": [{"role": "user", "content": "make me a report"}],
}
base.update(overrides)
return build_cowork_turn_request(**base)
def test_the_last_message_becomes_the_prompt_and_the_rest_the_history() -> None:
request = _build(messages=[
{"role": "user", "content": "earlier"},
{"role": "assistant", "content": "sure"},
{"role": "user", "content": "now this"},
])
assert request.prompt == "now this"
assert request.messages == ({"role": "user", "content": "earlier"},
{"role": "assistant", "content": "sure"})
def test_an_empty_working_list_yields_an_empty_prompt() -> None:
# Defensive: a turn with no message at all must not raise on messages[-1].
request = _build(messages=[])
assert request.prompt == ""
assert request.messages == ()
def test_confirming_commands_puts_the_turn_in_confirm_gate_mode() -> None:
assert _build(confirm_commands=True).gate_mode == "confirm"
assert _build(confirm_commands=False).gate_mode == "auto"
assert _build().gate_mode == "auto" # auto-run is the default
def test_the_captured_widget_state_is_carried_into_the_request() -> None:
request = _build(
surface="cowork", project_id="p7", title="Weekly report",
provider_id="anthropic", model="claude-sonnet-4-6",
instructions="PROJECT RULES", output_dir="out/.turns/t3",
home_output_root="out", agent_role="cowork",
)
assert (request.turn_id, request.session_id) == ("t3", "s1")
assert (request.surface, request.project_id, request.title) == \
("cowork", "p7", "Weekly report")
assert (request.provider_id, request.model) == ("anthropic", "claude-sonnet-4-6")
assert request.project_context == "PROJECT RULES"
assert request.output_dir == Path("out/.turns/t3")
assert request.home_output_root == Path("out")
assert request.agent_role == "cowork"
def test_the_prompt_survives_a_message_whose_content_is_missing() -> None:
request = _build(messages=[{"role": "user"}])
assert request.prompt == ""
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests for FakeProvider and FakeToolExecutor test doubles."""
from __future__ import annotations
import pytest
from providers.base import ProviderError
from tests.fakes.fake_provider import FakeProvider
from tests.fakes.fake_tool_executor import FakeToolExecutor
def test_fake_provider_text_streaming() -> None:
"""Verify that FakeProvider streams text chunks to on_text callback."""
provider = FakeProvider()
provider.queue_response(content="Hello world", chunks=["Hello ", "world"])
streamed: list[str] = []
response = provider.chat(
messages=[{"role": "user", "content": "Hi"}],
on_text=lambda piece: streamed.append(piece),
)
assert response["role"] == "assistant"
assert response["content"] == "Hello world"
assert "".join(streamed) == "Hello world"
assert provider.call_count == 1
def test_fake_provider_tool_calls_and_reasoning() -> None:
"""Verify reasoning streaming and tool_calls payload emission."""
provider = FakeProvider()
tool_call = {
"id": "call_123",
"name": "save_file",
"arguments": {"filename": "out.txt", "content": "data"},
}
provider.queue_response(
content="Creating file",
tool_calls=[tool_call],
reasoning="User wants output in a file",
)
reasoning_chunks: list[str] = []
response = provider.chat(
messages=[{"role": "user", "content": "Save to out.txt"}],
on_reasoning=lambda piece: reasoning_chunks.append(piece),
)
assert response["content"] == "Creating file"
assert response["tool_calls"] == [tool_call]
assert reasoning_chunks == ["User wants output in a file"]
def test_fake_provider_error_injection() -> None:
"""Verify that queued exceptions are raised on demand."""
provider = FakeProvider()
provider.queue_error(ProviderError("Rate limit exceeded (429)"))
with pytest.raises(ProviderError, match="Rate limit exceeded"):
provider.chat(messages=[{"role": "user", "content": "Hi"}])
def test_fake_provider_cancellation() -> None:
"""Verify that cancellation stops execution immediately."""
provider = FakeProvider()
provider.queue_response(content="Long reply", chunks=["Part 1", "Part 2"])
is_cancelled = False
def cancel_fn() -> bool:
return is_cancelled
is_cancelled = True
with pytest.raises(ProviderError, match="aborted by user cancel"):
provider.chat(
messages=[{"role": "user", "content": "Hi"}],
cancel=cancel_fn,
)
def test_fake_tool_executor() -> None:
"""Verify that FakeToolExecutor records calls and returns expected mock outputs."""
executor = FakeToolExecutor()
executor.set_mock_response("read_file", {"ok": True, "content": "file contents"})
executor.register_handler("calc", lambda args: {"ok": True, "result": args.get("a", 0) + args.get("b", 0)})
res1 = executor.execute("read_file", {"path": "test.txt"})
assert res1["ok"] is True
assert res1["content"] == "file contents"
res2 = executor.execute("calc", {"a": 5, "b": 10})
assert res2["result"] == 15
assert len(executor.call_log) == 2
assert executor.get_calls_for("calc")[0]["args"] == {"a": 5, "b": 10}
+204
View File
@@ -0,0 +1,204 @@
"""R03-T02 — unit tests for ProviderDescriptor and the central ProviderRegistry.
Covers what the rest of the app now relies on the catalogue for: resolving ids
and aliases, resolving a bare model id back to its provider, filling in default
models, and refusing to let a duplicate registration silently hijack a built-in.
"""
from __future__ import annotations
import pytest
from cowork_local.domain.models.provider_descriptor import (
AuthKind,
ProviderDescriptor,
WireProtocol,
)
from cowork_local.infrastructure.providers.provider_registry import (
BUILTIN_DESCRIPTORS,
ProviderNotFoundError,
ProviderRegistry,
)
def make_descriptor(**overrides) -> ProviderDescriptor:
"""A minimal valid descriptor; tests override just the field under test."""
fields = dict(
provider_id="demo",
display_name="Demo provider",
wire_protocol=WireProtocol.OPENAI_COMPAT,
default_model="demo-small",
models=("demo-small", "demo-large"),
)
fields.update(overrides)
return ProviderDescriptor(**fields)
# --------------------------------------------------------------------------- #
# ProviderDescriptor
# --------------------------------------------------------------------------- #
def test_descriptor_rejects_an_empty_id() -> None:
"""An id-less descriptor could never be looked up, so it must not exist."""
with pytest.raises(ValueError):
make_descriptor(provider_id="")
def test_descriptor_rejects_a_non_enum_protocol() -> None:
"""The protocol drives adapter selection; a stray string would silently
fall through to "no adapter" at build time instead of failing here."""
with pytest.raises(TypeError):
make_descriptor(wire_protocol="openai_compat")
def test_descriptor_is_immutable() -> None:
"""Descriptors are shared process-wide; a mutation would be visible to every
other reader mid-iteration."""
descriptor = make_descriptor()
with pytest.raises(Exception):
descriptor.default_model = "hacked" # type: ignore[misc]
def test_id_matching_ignores_case_and_honours_aliases() -> None:
"""Provider ids come from hand-edited config files and old app versions."""
descriptor = make_descriptor(aliases=("legacy-demo",))
assert descriptor.matches("DEMO")
assert descriptor.matches(" legacy-demo ")
assert not descriptor.matches("other")
def test_capabilities_use_the_routing_vocabulary() -> None:
"""The set must be feedable straight into the routing selector's filter."""
descriptor = make_descriptor(supports_vision=True, supports_tools=True,
supports_streaming=False)
assert descriptor.capabilities == frozenset({"vision", "tools"})
assert descriptor.has_capability("vision")
assert not descriptor.has_capability("streaming")
def test_average_cost_is_none_when_a_price_is_unknown() -> None:
"""Unknown prices stay unknown — a guessed number would silently skew the
routing scorer's cost term."""
assert make_descriptor(cost_per_1k_input=0.5).avg_cost_per_1k is None
priced = make_descriptor(cost_per_1k_input=1.0, cost_per_1k_output=3.0)
# Same 1:3 input:output weighting as ModelMetadata.avg_cost_per_1k.
assert priced.avg_cost_per_1k == pytest.approx((1.0 + 9.0) / 4.0)
def test_resolve_model_prefers_the_caller_then_the_default() -> None:
"""One place implements the "picked model or provider default" fallback that
every chat surface used to re-implement inline."""
descriptor = make_descriptor()
assert descriptor.resolve_model("demo-large") == "demo-large"
assert descriptor.resolve_model("") == "demo-small"
assert descriptor.resolve_model(" ") == "demo-small"
def test_with_models_repoints_a_default_that_vanished() -> None:
"""After discovery, the default must still name a model that exists."""
descriptor = make_descriptor()
updated = descriptor.with_models(["demo-v2", "demo-v2", "demo-v3"])
assert updated.models == ("demo-v2", "demo-v3") # de-duplicated, order kept
assert updated.default_model == "demo-v2"
assert descriptor.models == ("demo-small", "demo-large"), "original was mutated"
def test_with_models_keeps_a_default_that_survived() -> None:
"""Discovery must not reshuffle a user's working selection."""
updated = make_descriptor().with_models(["demo-large", "demo-small"])
assert updated.default_model == "demo-small"
# --------------------------------------------------------------------------- #
# ProviderRegistry
# --------------------------------------------------------------------------- #
def test_registry_resolves_ids_aliases_and_reports_unknowns() -> None:
"""Lookup must be forgiving about form, but loud about genuinely unknown
providers — a typo should fail at the call site, not as a None later."""
registry = ProviderRegistry([make_descriptor(aliases=("legacy-demo",))])
assert registry.get("demo").provider_id == "demo"
assert registry.get("legacy-demo").provider_id == "demo"
assert registry.find("missing") is None
assert "demo" in registry
with pytest.raises(ProviderNotFoundError):
registry.get("missing")
def test_registry_refuses_to_overwrite_silently_but_replace_works() -> None:
"""A second registration of the same id is almost always a bug; updating a
descriptor is a deliberate act with its own method."""
registry = ProviderRegistry([make_descriptor()])
with pytest.raises(ValueError):
registry.register(make_descriptor(display_name="Impostor"))
registry.replace(make_descriptor(display_name="Renamed"))
assert registry.get("demo").display_name == "Renamed"
assert len(registry) == 1
def test_registry_re_registering_an_identical_descriptor_is_a_no_op() -> None:
"""Idempotent registration keeps repeated bootstrap calls harmless."""
registry = ProviderRegistry([make_descriptor()])
registry.register(make_descriptor())
assert len(registry) == 1
def test_find_by_model_resolves_a_bare_model_id() -> None:
"""Routing decisions and saved conversations sometimes carry only a model
name; the registry is what turns that back into a provider."""
registry = ProviderRegistry([make_descriptor()])
assert registry.find_by_model("demo-large").provider_id == "demo"
# A gateway model we cannot enumerate offline is a miss, not an error — the
# caller falls back to the configured active provider.
assert registry.find_by_model("unknown-model") is None
assert registry.find_by_model("") is None
def test_builtin_catalogue_covers_every_configured_provider() -> None:
"""The catalogue and DEFAULT_CONFIG must not drift: a provider users can
configure but the registry cannot build is a dead Settings entry."""
from cowork_local.config import DEFAULT_CONFIG
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
for provider_id in DEFAULT_CONFIG["providers"]:
assert registry.find(provider_id) is not None, f"{provider_id} missing from registry"
def test_build_fills_in_the_default_model() -> None:
"""A half-written config must still produce a usable provider rather than an
empty model id that only fails once the request reaches the gateway."""
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
provider = registry.build("anthropic", {"api_key": "k"})
assert provider.model == registry.get("anthropic").default_model
def test_build_respects_an_explicit_model() -> None:
"""Per-tab model selection must win over the catalogue default."""
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
provider = registry.build("anthropic", {"api_key": "k", "model": "claude-opus-4-8"})
assert provider.model == "claude-opus-4-8"
def test_factory_still_raises_provider_error_for_unknown_ids() -> None:
"""Existing call sites catch ProviderError; routing lookups through the
registry must not change the exception type they see."""
from cowork_local.providers import build_provider
from cowork_local.providers.base import ProviderError
with pytest.raises(ProviderError):
build_provider("definitely-not-a-provider", {})
@@ -0,0 +1,384 @@
"""R03-T03 — unit tests for the unified routing decision rules.
The point of moving these rules out of the three chat widgets is that they can
now be exercised without Qt, without the assessment store and without a network:
the service talks to two narrow ports, so every mode is driven here by ~10-line
fakes. Each test names the behaviour a chat surface depends on.
"""
from __future__ import annotations
import pytest
from cowork_local.application.model_routing import (
RouteEvaluation,
RoutingApplicationService,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
class FakeDecisionPort:
"""A routing engine that returns a canned verdict and records its input."""
def __init__(self, evaluation: RouteEvaluation) -> None:
self.evaluation = evaluation
self.calls: list = []
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
self.calls.append((request, mode))
return self.evaluation
class ExplodingDecisionPort:
"""An engine that fails — proves routing degrades instead of breaking a turn."""
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
raise RuntimeError("assessment store is corrupt")
class FakeModeResolver:
"""Per-surface mode lookup, standing in for the workspace settings."""
def __init__(self, mode) -> None:
self.mode = mode
self.surfaces: list = []
def mode_for(self, surface: str):
self.surfaces.append(surface)
return self.mode
def make_request(**overrides) -> RoutingRequest:
"""A representative turn: Cowork chat, currently on a cheap OpenAI model."""
fields = dict(
surface="cowork",
prompt="Refactor this function",
current_provider="codex",
current_model="gpt-4o-mini",
)
fields.update(overrides)
return RoutingRequest(**fields)
def switch_evaluation(**overrides) -> RouteEvaluation:
"""An engine verdict that proposes a switch to a better coding model."""
fields = dict(
task_type="coding",
should_switch=True,
target_provider="anthropic",
target_model="claude-sonnet-4-6",
score_gain=0.21,
reason="coding fit 0.88 > current 0.67",
decision=object(),
)
fields.update(overrides)
return RouteEvaluation(**fields)
# --------------------------------------------------------------------------- #
# Off
# --------------------------------------------------------------------------- #
def test_off_mode_never_consults_the_engine() -> None:
"""Off must be free: no ranking, no store read, no decision at all."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.OFF))
outcome = service.resolve(make_request())
assert outcome.switched is False
assert outcome.provider is None and outcome.model is None
assert port.calls == [], "Off mode must not call the routing engine"
def test_missing_mode_resolver_defaults_to_off() -> None:
"""Routing stays opt-in: with no way to read the mode, never switch."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port)
outcome = service.resolve(make_request())
assert outcome.mode is RoutingMode.OFF
assert outcome.switched is False
def test_empty_prompt_is_not_routed() -> None:
"""An empty message carries no signal to classify, so the engine is skipped."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request(prompt=" "))
assert outcome.switched is False
assert port.calls == []
# --------------------------------------------------------------------------- #
# Auto
# --------------------------------------------------------------------------- #
def test_auto_mode_switches_silently() -> None:
"""Auto applies the engine's verdict without asking the user."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is True
assert outcome.provider == "anthropic"
assert outcome.model == "claude-sonnet-4-6"
assert outcome.task_type == "coding"
assert outcome.score_gain == pytest.approx(0.21)
assert outcome.should_notify is True
def test_auto_mode_keeps_current_when_nothing_is_better() -> None:
"""No proposed switch means the surface's own selection is untouched."""
port = FakeDecisionPort(switch_evaluation(
should_switch=False, reason="current model is already best-fit"))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is False
assert outcome.provider is None
assert "already best-fit" in outcome.reason
def test_switch_without_a_target_is_ignored() -> None:
"""A verdict that says "switch" but names nothing is not actionable — a
surface must never be handed an empty model id."""
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model=None))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is False
def test_same_provider_switch_keeps_the_current_provider() -> None:
"""A model-only switch must not blank out the provider the surface uses."""
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model="o3"))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is True
assert outcome.provider == "codex" # unchanged, from the request
assert outcome.model == "o3"
# --------------------------------------------------------------------------- #
# Manual
# --------------------------------------------------------------------------- #
def test_manual_mode_switches_only_after_approval() -> None:
"""Manual's contract: ask first, then apply exactly what was approved."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(
port, FakeModeResolver(RoutingMode.MANUAL),
confirm_timeout_sec=lambda: 30.0,
)
asked: list = []
def confirm(decision, timeout):
asked.append((decision, timeout))
return True
outcome = service.resolve(make_request(), confirm=confirm)
assert outcome.switched is True
assert len(asked) == 1
# The configured timeout must reach the dialog, not a hard-coded default.
assert asked[0][1] == pytest.approx(30.0)
def test_manual_mode_decline_is_reported_distinctly() -> None:
""""The user said no" must be distinguishable from "nothing better found",
so a surface can stay quiet in one case and explain itself in the other."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
outcome = service.resolve(make_request(), confirm=lambda decision, timeout: False)
assert outcome.switched is False
assert outcome.declined is True
def test_manual_mode_without_a_callback_never_switches() -> None:
"""Silently switching in Manual mode would violate the mode's promise."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
outcome = service.resolve(make_request(), confirm=None)
assert outcome.switched is False
def test_manual_mode_treats_a_broken_dialog_as_a_decline() -> None:
"""A crashing confirm dialog must not auto-approve a model change."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
def confirm(decision, timeout):
raise RuntimeError("dialog blew up")
outcome = service.resolve(make_request(), confirm=confirm)
assert outcome.switched is False
assert outcome.declined is True
# --------------------------------------------------------------------------- #
# Fallback
# --------------------------------------------------------------------------- #
def test_fallback_keeps_a_healthy_model_even_when_a_better_one_exists() -> None:
"""Fallback is a resilience mode, not an optimiser: a usable pinned model
wins over a higher-scoring candidate."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=True))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
outcome = service.resolve(make_request())
assert outcome.switched is False
assert "healthy" in outcome.reason
def test_fallback_switches_when_the_current_model_cannot_serve_the_turn() -> None:
"""The one case Fallback exists for: rescue an unusable selection."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
outcome = service.resolve(make_request())
assert outcome.switched is True
assert outcome.model == "claude-sonnet-4-6"
def test_fallback_asks_the_engine_with_auto_semantics() -> None:
"""The engine only understands off/auto/manual, so Fallback must reach it as
Auto — otherwise the engine would reject the unknown mode and rank nothing."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
service.resolve(make_request())
assert port.calls[0][1] is RoutingMode.AUTO
def test_fallback_never_confirms_with_the_user() -> None:
"""Rescuing an unusable model is not a proposal — it happens silently."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
asked: list = []
outcome = service.resolve(
make_request(), confirm=lambda decision, timeout: asked.append(1) or True)
assert outcome.switched is True
assert asked == []
def test_fallback_with_no_replacement_keeps_current() -> None:
"""Nothing to fall back to means keep going with what we have and let the
provider surface the real error, rather than blanking the model."""
port = FakeDecisionPort(switch_evaluation(
current_is_usable=False, target_provider=None, target_model=None))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
outcome = service.resolve(make_request())
assert outcome.switched is False
# --------------------------------------------------------------------------- #
# Robustness & plumbing
# --------------------------------------------------------------------------- #
def test_engine_failure_degrades_to_keep_current() -> None:
"""A broken assessment store must never stop a user sending a message."""
service = RoutingApplicationService(
ExplodingDecisionPort(), FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert isinstance(outcome, RoutingOutcome)
assert outcome.switched is False
assert "error" in outcome.reason
def test_mode_resolver_failure_degrades_to_off() -> None:
"""An unreadable workspace config must not enable routing by accident."""
class BrokenResolver:
def mode_for(self, surface):
raise OSError("workspace file unreadable")
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, BrokenResolver())
outcome = service.resolve(make_request())
assert outcome.mode is RoutingMode.OFF
assert port.calls == []
def test_explicit_request_mode_overrides_the_resolver() -> None:
"""A surface may pin the mode for one turn (tests, replay, admin actions)."""
resolver = FakeModeResolver(RoutingMode.OFF)
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, resolver)
outcome = service.resolve(make_request(mode=RoutingMode.AUTO))
assert outcome.switched is True
assert resolver.surfaces == [], "an explicit mode must skip the resolver"
def test_request_is_forwarded_to_the_engine_unchanged() -> None:
"""Surface, prompt and pinned task type must survive the hand-off — AI-Edit
relies on its "coding" pin reaching the engine."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
request = make_request(surface="ai_edit", task_type="coding",
required_capabilities=("vision",))
service.resolve(request)
forwarded = port.calls[0][0]
assert forwarded is request
assert forwarded.surface == "ai_edit"
assert forwarded.task_type == "coding"
assert forwarded.required_capabilities == ("vision",)
@pytest.mark.parametrize(
"raw, expected",
[
("auto", RoutingMode.AUTO),
("MANUAL", RoutingMode.MANUAL),
(" fallback ", RoutingMode.FALLBACK),
("nonsense", RoutingMode.OFF),
("", RoutingMode.OFF),
(None, RoutingMode.OFF),
],
)
def test_mode_parsing_is_forgiving(raw, expected) -> None:
"""Config values are hand-edited; an unknown one must degrade, not raise."""
assert RoutingMode.parse(raw) is expected
def test_confirm_timeout_falls_back_to_the_default_when_unusable() -> None:
"""A corrupted timeout must not produce a zero-second dialog that declines
every switch before the user can read it."""
service = RoutingApplicationService(
FakeDecisionPort(switch_evaluation()),
FakeModeResolver(RoutingMode.MANUAL),
confirm_timeout_sec=lambda: 0.0,
)
assert service.confirm_timeout() == RoutingApplicationService.DEFAULT_CONFIRM_TIMEOUT_SEC
def test_routing_request_is_immutable() -> None:
"""The snapshot must not change under a turn that is already in flight."""
request = make_request()
with pytest.raises(Exception):
request.prompt = "something else" # type: ignore[misc]
+34
View File
@@ -0,0 +1,34 @@
"""R04-T05 — unit tests for the unattended-run prompt assembly.
``_run_agent`` used to build this by rebinding ``prompt`` three times, each with
its own ``f"{block}\n\n{prompt}"``. The ORDER that produced is load-bearing (the
plan reminder has to lead, the task's own words have to trail) and it was
readable only by replaying the rebindings in your head.
"""
from __future__ import annotations
from cowork_local.core.task_executors import _unattended_prompt
def test_the_plan_reminder_leads_and_the_task_prompt_trails() -> None:
built = _unattended_prompt("write the report")
assert built.startswith("This runs unattended (Schedule Task)")
assert built.endswith("write the report")
def test_a_skill_block_sits_between_the_reminder_and_the_agent_persona() -> None:
built = _unattended_prompt("write the report", skill_text="SKILL",
agent_instructions="PERSONA")
assert built.index("This runs unattended") < built.index("SKILL")
assert built.index("SKILL") < built.index("PERSONA")
assert built.index("PERSONA") < built.index("write the report")
def test_absent_blocks_leave_no_extra_blank_lines() -> None:
built = _unattended_prompt("do it", skill_text="", agent_instructions=None)
assert "\n\n\n" not in built
assert built.count("do it") == 1
+36
View File
@@ -0,0 +1,36 @@
"""R04-T04 — unit tests for the shared turn-runtime helpers.
``combine_instructions`` is the small rule the UI applied inline: a turn's
standing instructions are several independent blocks (project context, an Admin
agent's persona, a skill's rules, an unattended-run reminder) that must be joined
with one blank line, skipping whatever is absent. Two call sites need it (T04's
widget and T05's task runner), which is exactly when a rule stops being an inline
expression.
"""
from __future__ import annotations
from cowork_local.application.conversations.turn_runtime import combine_instructions
def test_two_blocks_are_joined_by_a_blank_line() -> None:
assert combine_instructions("PROJECT", "AGENT") == "PROJECT\n\nAGENT"
def test_an_absent_block_leaves_no_blank_line_behind() -> None:
assert combine_instructions("", "AGENT") == "AGENT"
assert combine_instructions("PROJECT", "") == "PROJECT"
assert combine_instructions("PROJECT", None) == "PROJECT"
def test_whitespace_only_blocks_do_not_count_as_instructions() -> None:
assert combine_instructions(" \n ", "AGENT") == "AGENT"
def test_nothing_to_say_produces_an_empty_string() -> None:
assert combine_instructions() == ""
assert combine_instructions("", None, " ") == ""
def test_more_than_two_blocks_keep_their_order() -> None:
assert combine_instructions("A", "B", "C") == "A\n\nB\n\nC"
+184
View File
@@ -0,0 +1,184 @@
"""R03-T06 — unit tests for the token-usage telemetry seam.
The seam exists so provider adapters stop owning telemetry policy. These tests
pin the two properties that makes that safe: events reach every subscriber, and
no telemetry failure can ever propagate back into the turn that produced it.
"""
from __future__ import annotations
import pytest
from cowork_local.infrastructure.telemetry import usage_sink
from cowork_local.infrastructure.telemetry.usage_sink import (
CompositeUsageSink,
InMemoryUsageSink,
UsageEvent,
UsageTrackerSink,
)
@pytest.fixture(autouse=True)
def isolated_sink(monkeypatch):
"""Give every test its own process-wide sink.
Autouse because a leaked sink would let one test's subscriber observe the
next test's events — and, worse, let a test write to the developer's real
usage files through the default tracker sink.
"""
monkeypatch.setattr(usage_sink, "_sink", None)
yield
monkeypatch.setattr(usage_sink, "_sink", None)
def make_event(**overrides) -> UsageEvent:
fields = dict(provider="anthropic", model="claude-sonnet-4-6",
input_tokens=100, output_tokens=40, cached_tokens=10)
fields.update(overrides)
return UsageEvent(**fields)
# --------------------------------------------------------------------------- #
# UsageEvent
# --------------------------------------------------------------------------- #
def test_event_is_immutable() -> None:
"""A subscriber must not be able to edit the event the next one receives."""
event = make_event()
with pytest.raises(Exception):
event.input_tokens = 0 # type: ignore[misc]
def test_total_tokens_does_not_double_count_cache_reads() -> None:
"""Every gateway we support already reports cached tokens inside the input
count, so adding them again would inflate the dashboard."""
assert make_event().total_tokens == 140
def test_to_dict_uses_the_stored_row_keys() -> None:
"""Matching the tracker's short keys lets a caller diff an event against a
persisted row without a translation table."""
row = make_event(source="cowork", label="Refactor chat").to_dict()
assert row["in"] == 100 and row["out"] == 40 and row["cache"] == 10
assert row["source"] == "cowork" and row["label"] == "Refactor chat"
assert row["estimated"] is False
# --------------------------------------------------------------------------- #
# Fan-out
# --------------------------------------------------------------------------- #
def test_publish_reaches_every_subscriber() -> None:
"""The whole point of the seam: extra consumers attach without patching
provider code."""
first, second = InMemoryUsageSink(), InMemoryUsageSink()
usage_sink.set_usage_sink(CompositeUsageSink([first, second]))
usage_sink.publish(make_event())
assert len(first.snapshot()) == 1
assert len(second.snapshot()) == 1
def test_one_failing_subscriber_does_not_starve_the_others() -> None:
"""A buggy consumer must not silently disable the Dashboard."""
class Exploding:
def emit(self, event):
raise RuntimeError("subscriber is broken")
healthy = InMemoryUsageSink()
usage_sink.set_usage_sink(CompositeUsageSink([Exploding(), healthy]))
usage_sink.publish(make_event())
assert len(healthy.snapshot()) == 1
def test_subscribe_and_unsubscribe_round_trip() -> None:
"""Teardown code calls unsubscribe unconditionally, so removing a sink that
was never added must be harmless."""
extra = InMemoryUsageSink()
usage_sink.subscribe(extra)
usage_sink.publish(make_event())
usage_sink.unsubscribe(extra)
usage_sink.unsubscribe(extra) # second removal is a no-op
usage_sink.publish(make_event(model="claude-opus-4-8"))
assert [e.model for e in extra.snapshot()] == ["claude-sonnet-4-6"]
def test_default_sink_is_the_usage_tracker() -> None:
"""Out of the box the seam must preserve the existing Dashboard pipeline."""
sinks = usage_sink.get_usage_sink().sinks()
assert any(isinstance(s, UsageTrackerSink) for s in sinks)
def test_in_memory_sink_totals_and_clears() -> None:
"""Test-double conveniences the contract suite relies on."""
sink = InMemoryUsageSink()
sink.emit(make_event())
sink.emit(make_event(input_tokens=1, output_tokens=1, cached_tokens=0))
assert sink.total_tokens == 142
sink.clear()
assert sink.snapshot() == []
# --------------------------------------------------------------------------- #
# UsageTrackerSink forwarding
# --------------------------------------------------------------------------- #
def test_tracker_sink_forwards_the_counts() -> None:
"""The adapter must hand the tracker exactly what the provider measured."""
recorded: list = []
def fake_record(provider, model, tokens_in, tokens_out, cached, estimated=False):
recorded.append((provider, model, tokens_in, tokens_out, cached, estimated))
UsageTrackerSink(recorder=fake_record).emit(make_event(estimated=True))
assert recorded == [("anthropic", "claude-sonnet-4-6", 100, 40, 10, True)]
def test_tracker_sink_restores_the_thread_context_it_borrowed() -> None:
"""An event carrying its own attribution must relabel ONE row, not every
later turn that happens to run on the same worker thread."""
from cowork_local.core import usage_tracker as tracker
tracker.set_context("cowork", "original chat")
seen: list = []
UsageTrackerSink(recorder=lambda *a, **k: seen.append(tracker.current_context())).emit(
make_event(source="co4e", label="flow run"))
assert seen == [("co4e", "flow run")], "event attribution was not applied"
assert tracker.current_context() == ("cowork", "original chat")
def test_tracker_sink_swallows_recorder_failures() -> None:
"""Telemetry is never allowed to abort an otherwise successful turn."""
def boom(*_args, **_kwargs):
raise OSError("usage directory is read-only")
UsageTrackerSink(recorder=boom).emit(make_event()) # must not raise
def test_publish_never_raises_even_with_a_broken_sink() -> None:
"""Last line of defence: providers call publish() inside their stream loop."""
class Hostile:
def emit(self, event):
raise RuntimeError("nope")
def sinks(self):
raise RuntimeError("nope")
usage_sink.set_usage_sink(Hostile())
usage_sink.publish(make_event()) # must not raise
def test_estimate_tokens_matches_the_tracker_heuristic() -> None:
"""Re-exported so adapters need one telemetry import; it must not drift."""
from cowork_local.core import usage_tracker as tracker
for text in ("", "a", "hello world", "x" * 4001):
assert usage_sink.estimate_tokens(text) == tracker.estimate_tokens(text)