Compare commits

...
Author SHA1 Message Date
Nam Pham Dinh ThanhandClaude Opus 5 9d6a7be31b fix(infra): AtomicJsonFile — os.replace trên Windows thỉnh thoảng bị từ chối
Bắt được nhờ merge Delta: bộ test của họ chạy lâu hơn nên lộ ra một bài
của tôi chập chờn. Truy ra không phải lỗi test mà là lỗi thật trong code
chạy máy người dùng:

    PermissionError: [WinError 5] Access is denied
      .dem.json.l7x2a8pd.tmp -> dem.json

MoveFileEx trả ERROR_ACCESS_DENIED khi tiến trình khác đang giữ handle
lên nguồn hoặc đích — trên Windows gần như luôn là Defender hoặc Search
Indexer quét file vừa tạo, giữ vài chục mili-giây rồi nhả.

Đo được: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức khoảng 1 trên 140 lần
lưu. Nghĩa là người dùng thỉnh thoảng bấm Lưu là văng lỗi, và không tài
nào tái hiện được để báo.

Thêm vòng thử lại 6 lượt, nghỉ tăng dần 20ms → 640ms. Hết lượt vẫn ném
lỗi, không nuốt lỗi quyền thật, và luôn dọn file tạm.

Hai bài test mới, đã kiểm ngược: bỏ vòng thử lại thì bài thứ nhất đỏ.
Chạy lại 30 lượt sau khi vá: 0 hỏng (trước khi vá: 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:21:01 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 dcf2e8f995 merge: kéo Delta epic-R04 (gồm cả R01 và R03) vào gamma/refactor
Nam chốt: không chờ Delta merge vào main, lấy sớm để va chạm nhỏ và sửa
ngay, thay vì dồn một cục lúc cả hai cùng lên main.

R04 chứa trọn R01 và R03 nên một lần merge là đủ cả ba: 96 file, +8260
dòng. Xung đột chỉ 5 file, đều là __init__.py add/add — hai team cùng
dựng khung thư mục nên đụng docstring. Giữ docstring của Gamma (nói rõ
ràng buộc "không import PySide6"), giữ mọi phần code của Delta.

Riêng tests/fakes/__init__.py: bỏ hai dòng import háo hức của Delta
(fake_provider, fake_tool_executor). fake_provider dùng
`from providers.base import ...` — import tuyệt đối, chỉ chạy được khi
cwd là gốc repo — nên nó làm đứt bài test "dùng fake mà không nạp config
thật". Không ai import ở cấp package; test của Delta gọi thẳng module
nên bỏ đi không ảnh hưởng họ. Đã ghi lý do vào docstring của gói.

Delta cũng xoá preview-desktop và "requirements (cloud copy).txt".

430 test xanh sau merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:20:36 +09:00
duylh19andClaude Opus 5 3665135c38 feat(R04): run every Cowork turn through ConversationApplicationService
R04-T03 — the turn lifecycle, extracted from `core/chat_agent.py::run_cowork`
into `application/conversations/`. The 260-line body mixed the lifecycle (step
budget, cancel checks, guard -> preview -> gate -> execute ordering, sandbox
tidy-up) with the machinery doing each step, and reaching any of it meant
standing up a Qt widget and a worker thread. It is now a plain object driven
through two Protocols and six callables (`turn_runtime.py`), with the concrete
`core/*` wiring confined to `core_runtime_adapter.py` — the same shape R03 used
for routing. Faithful port, not an improvement pass: where the original had a
quirk (the step-ceiling note only merges into the answer when the last message
is the assistant's) the quirk is preserved and commented.

R04-T04 — `ui/cowork_tab.py::build_job` no longer calls run_cowork. It captures
the widget's state at submit time, builds the request via the new
`cowork_turn_request.py` and executes it. `execute(..., messages=...)` hands the
widget's own list over because `_reattach_running_turn` replays from it WHILE
the worker appends and `_finalize_turn` slices it afterwards — a private list
would break both silently.

R04-T05 — `core/task_executors.py`'s cowork branch shares the same engine. All
five unattended-run behaviours stay put (plan reminder, history_ready, History
autosave per assistant message, timeout notice, plan_incomplete_reason), and
`_unattended_prompt` now expresses the load-bearing prefix order in one
readable call instead of three successive rebindings.

Verification: 74 new tests (364 passed, 1 skipped overall; check_imports PASS).
The two that matter most:
- `test_conversation_service_parity.py` runs the same scripted turn through
  run_cowork AND the service and compares the event stream, the resulting
  conversation and the advertised tool list across 7 scenarios;
- `test_task_executor_turn.py` was written BEFORE the migration and passed 8/8
  against the old code, then unchanged against the new.

Known: `ui/cowork_tab.py` (416 -> 455) and `core/task_executors.py` (476 -> 524)
stay above the 400-LOC limit. Both were already over it before this change;
bringing them under needs the R08 / R07 decompositions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:14:15 +09:00
duylh19andClaude Opus 5 19e6b4deb2 feat(R04): add the immutable turn snapshot and typed agent event stream
R04-T01 — `domain/agents/conversation_execution_request.py`: a frozen
snapshot of everything one chat turn needs. Turn inputs previously lived in a
closure plus a 15-key ctx dict inside `ui/chat_panel.py::_start_turn`, and the
worker thread kept reading the widget back while it ran, so every later click
was visible to work already in flight. The request also owns the prompt
composition rules (instruction prefix separator, session notes, model-switch
review note) that were inline in that closure.

R04-T02 — `domain/agents/agent_event.py`: 13 frozen event types replacing the
untyped `{"type": ...}` dicts, whose only specification was the 130-line
if/elif chain in `_on_event`. Each event serialises back to the exact legacy
dict, so the presentation layer is untouched; `agent_event_codec.py` parses the
other way and is a temporary shim, isolated so R08 can delete it in one move.
`assistant_done` is deliberately NOT the end of a turn (it fires once per
provider call), so it maps to AssistantMessageCompletedEvent while the new
TurnCompletedEvent reports the turn itself.

R04-T03 (part) — `domain/agents/agent_result.py`: one named outcome for a
finished turn, replacing the message list / 3-tuple / reconstructed-from-side-
effects trio the three callers each read differently.

Verification: 66 tests. Beyond the unit tests,
`tests/integration/test_agent_event_bridge.py` runs the REAL `run_cowork` loop
offline and asserts every dict it emits is recognised and round-trips
byte-for-byte — a guard against an event type nobody modelled or a key whose
meaning silently drifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:13:56 +09:00
duylh19andClaude Opus 5 176e6aef79 fix(ci): guard the MCP SDK import so pytest can collect the suite
`tests/test_project_context_mcp_template.py` imported `mcp` at module scope,
but the SDK is a runtime dependency (requirements.txt) and is deliberately
absent from requirements-test.txt — the only thing CI installs. Collection
therefore aborted for the ENTIRE suite before a single test ran.

The guard now sits inside the one test that touches the SDK, so the other
cases in the file (pure-Python contract checks) keep running on CI instead
of being skipped along with it.

Unrelated to the R04 refactor; kept as its own commit so it can be cherry-
picked to main on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:13:42 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 ca7ea1479d fix(infra): neo nốt logs/ build/ dist/ out/ — cùng hình dạng lỗi secrets/
Sau khi vá secrets/ thì rà cả file xem còn mẫu không neo nào sắp cắn hai
người kia. Còn hai quả đang sống:

  logs/   -> nuốt infrastructure/logs/   (Hiệp làm CanonicalAuditLogger,
                                          đây là tên rất dễ đặt)
  build/  -> nuốt application/*/build/
  dist/, out/ cùng kiểu

Chưa ai vấp, vá trước. Trong repo không có build//dist//out//logs/ lồng
nhau nào nên neo về gốc không mất gì — đã kiểm hai chiều: đường dẫn mã
nguồn qua được, còn build/x.o, dist/app.exe, logs/run.log ở gốc vẫn bị
chặn như cũ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:15:39 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 d74c052af3 fix(infra): .gitignore nuốt infrastructure/secrets/ — nhánh đỏ với mọi máy trừ máy tôi
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 ở đó chưa bao giờ lên repo. Máy tôi vẫn 150 test xanh vì pytest
đọc đĩa chứ không đọc git; ai clone sạch thì đỏ 4 file ngay lúc thu thập:

    ModuleNotFoundError: No module named
    'cowork_local.infrastructure.secrets'

Hiệp phát hiện, không phải tôi. Đã dựng lại bằng clone sạch vào thư mục
đặt đúng tên cowork_local để tái hiện.

Neo mẫu thành /secrets/ và thêm tests/test_no_ignored_source.py — hỏi
thẳng git chứ không hỏi đĩa, nên lần sau lỗi cùng hình dạng sẽ đỏ ngay
trên máy người viết. Đã kiểm ngược: trả lại `secrets/` thì cả ba bài đỏ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:11:42 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 8be5ce1bab docs(arch): mô hình chính sách an toàn — R09-T01
Mô tả hệ thống ĐANG CHẠY, không phải hệ thống mong muốn. Mọi khẳng định chỉ
tới file:dòng cụ thể, và mỗi tham chiếu đã được kiểm bằng script: mở đúng file,
đọc đúng dòng, đối chiếu nội dung có khớp điều đang nói không. Lần kiểm đầu bắt
được 3 tham chiếu thiếu tiền tố core/ và 2 số dòng lệch — dòng 249 là "No-op
for any other tool", câu về bộ phân loại luôn bật nằm ở 250.

Bốn điểm đáng chú ý trong tài liệu:

  - Đây KHÔNG phải rào chắn an ninh. Chính agent_security.py nói vậy ở đầu
    file, và hệ quả là mọi tầng AI đều mở khi hỏng. Ai đọc để đánh giá rủi ro
    phải hiểu đúng chỗ này.

  - Phân biệt quy tắc xác định và quy tắc do AI phán. Tắt hết công tắc trong
    màn Cài đặt thì VẪN còn bộ phân loại mẫu và sandbox — đây là điểm dễ hiểu
    nhầm nhất, vì mấy công tắc đó chỉ tắt phần AI.

  - Trạng thái thứ ba: hỏi người dùng. Hệ thống đã có (chat_panel.py:1312) mà
    chưa gọi tên; tool_policy.py gộp thành ALLOW/DENY/ASK.

  - Mục 8 liệt kê 4 chỗ đã biết là yếu, để người sau khỏi tưởng đã kín: mở khi
    hỏng, bí mật vẫn đi trong bộ nhớ (hệ quả của đường A), bộ luật OneDrive
    không ký số, và ASK chưa nối được vào Co4E.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:11:01 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 ab0d26761f feat(infra): xong R02 — Settings Facade, versioning, chuyển khoá sang keyring
R02-T03 Typed Settings Facade
  Khắp nơi đang viết ctx.config.routing.get("switch_mode", "off"). Gõ sai một
  chữ thì lặng lẽ nhận mặc định, không ai biết cho tới lúc tính năng "không
  hiểu sao không chạy". ProviderSettings / RoutingSettings / SecuritySettings
  làm sai tên là lỗi ngay, và kiểu ghi rõ nên đọc là biết confirm_timeout_sec
  tính bằng giây.

  Là KHUNG NHÌN lên dict sống, không phải dataclass sao chép — sửa qua đây là
  sửa vào cấu hình, save() là xuống đĩa, khỏi sinh chuyện đồng bộ hai chiều.
  Có raw() để ai thiếu thuộc tính thì dùng tạm, đừng vòng lại config.data.

  Bắt cả trường hợp giá trị là null: file cũ hay để null, đọc ra None rồi đem
  so sánh số là vỡ.

R02-T06 Schema versioning + phục hồi
  config.json hôm nay không có số phiên bản, nên mọi thay đổi hình dạng phải
  đoán — _migrate_connectors() đoán "có khoá office nghĩa là file cũ". Giờ:
  thiếu schema_version thì coi là v1, mỗi bước là một hàm chạy tuần tự, sao
  lưu trước khi nâng, và file mới hơn app thì dùng nguyên trạng chứ không đoán
  ngược.

R02-T05 Chuyển API key sang kho bí mật
  Là bước v1→v2. Người dùng cập nhật app, mở lên, khoá cũ tự vào keyring và
  biến khỏi đĩa — có test cho đúng cảnh đó.

  Hai chỗ cố tình không làm:
    - Máy chưa có keyring: KHÔNG chuyển, giữ nguyên v1. Thà để khoá trong file
      còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao.
    - Giá trị "ollama" là bù nhìn (Ollama đòi có api_key nhưng bỏ qua nội
      dung), đẩy vào keyring chỉ tổ rác.

Hai chuỗi test trông giống khoá thật bị CASAN Check 1 bắt — đánh dấu
"# casan: allow" kèm lý do, đúng lối thoát đã thiết kế cho cả đội.

150 test xanh (129 + 21 mới). CASAN Check 1 sạch. File mới đều dưới 200 dòng.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:50:09 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 a7e369e46c feat(infra): JsonConfigRepository — R02-T02, hiện thực đường A đã chốt
Thay cho config.py::AppConfig. Hai khác biệt về hành vi, cả hai đều là thứ
muốn có; mọi thứ còn lại giữ y nguyên vì đây là refactor.

1. Ghi qua AtomicJsonFile — mất điện giữa lúc lưu không còn làm hỏng cấu hình.
   Có test riêng ở tầng này chứ không chỉ dựa vào test của AtomicJsonFile.

2. Đường A (chốt 21/08): provider_conf() đọc khoá từ SecretStore rồi ghép vào
   dict trả về, còn set_api_key() ghi khoá vào kho và để chuỗi rỗng trên đĩa.
   Kết quả: 5 nơi đang đọc conf["api_key"] không sửa dòng nào — 3 trong đó
   thuộc providers/ của Team Duy — mà file JSON vẫn sạch để qua CASAN Check 1.
   Hai test riêng cho đúng hai vế đó.

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 — đúng thứ đường A phải tránh.
Có test cho chuyện này.

secrets=None thì lùi về hành vi cũ (khoá nằm trong file). Cần vậy để chuyển
dần ở R02-T05 chứ không phải đổi một phát cả app, và để máy không có keyring
vẫn chạy.

Giữ nguyên có chủ đích: trộn sâu với mặc định, biến môi trường, và
ms365.unlocked không bao giờ chạm đĩa — mỗi thứ một test.

_deep_merge chép lại 6 dòng thay vì import từ config.py: file này phải sống
được sau khi config.py biến mất.

129 test xanh (119 + 10 mới). CASAN Check 1 sạch. File mới: 188/102/86 dòng,
đều dưới ngưỡng 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:37:43 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 d6dd6a030e feat(infra): AtomicJsonFile + KeyringAdapter, và đổi tên platform/ vì nó che stdlib
Ngày 21/08 của làn N1 (Nam): R02-T01 và R02-T04.

--- Lỗi phải sửa trước khi làm được gì ---

Kế hoạch đặt tên một tầng là platform/. Tôi dựng đúng theo đó sáng nay, có
kiểm "platform stdlib không bị che" và báo là an toàn. Kiểm đó SAI: tôi chỉ
thử từ thư mục cha. Chạy từ gốc repo — đúng cách 26 script trong tools/ và
scripts/ được gọi — thì platform/ che khuất platform của thư viện chuẩn, và
import keyring chết ngay:

    AttributeError: module 'platform' has no attribute 'system'

Nghĩa là R02-T04 không thể làm được chừng nào thư mục đó còn tên cũ. Đổi
platform/ -> adapters/. Đây là lệch khỏi plan.md và ảnh hưởng Team Hoa (họ sở
hữu platform/qt/qt_scheduler_clock.py) — đã ghi vào GammaTeam_decisions.md.

tests/test_no_stdlib_shadow.py chặn lỗi tái diễn, hai lớp: một bài so tên thư
mục gốc repo với sys.stdlib_module_names, một bài chạy tiến trình con với cwd
là gốc repo rồi import keyring thật. Dựng lại platform/ là cả hai đỏ.

--- R02-T01: AtomicJsonFile ---

config.py::save() đang gọi path.write_text(), tức là cắt file về 0 byte rồi
mới ghi. Chết giữa chừng là mất sạch cấu hình. Thay bằng: ghi file tạm cùng
thư mục -> flush + fsync -> os.replace (nguyên tử trên cả Windows và POSIX).

Test tiêm lỗi đúng như cột nghiệm thu của plan.md: cho os.replace ném lỗi
ngay bước cuối rồi khẳng định file cũ còn nguyên. Chỉ test "ghi rồi đọc lại"
thì write_text() cũ cũng qua — mà đó chính là thứ đang thay.

Phần đọc: file hỏng được dời thành .bad-<thời điểm> rồi trả mặc định. Giữ
đúng hành vi "hỏng cấu hình không chặn khởi động" của config.py, thêm phần
cứu được bản hỏng.

--- R02-T04: KeyringAdapter ---

Windows Credential Manager / macOS Keychain / Linux Secret Service. Không bao
giờ ném lỗi: máy không có kho (Linux headless, CI) thì available=False và trả
None, để tầng UI nói "chưa lưu được khoá" thay vì sập app. Test tiêm backend
giả, không đụng keyring thật của máy chạy test.

119 test xanh (102 + 17 mới). CASAN Check 1 sạch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:44:34 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 2627e691ce docs(refactor): cả ba đẩy chung gamma/refactor; đặt tên Nam, Hiệp, Lâm
Đổi mô hình: không còn nhánh riêng mỗi người, cả ba cùng đẩy vào
gamma/refactor. Ba "nhánh" thành ba "làn" — vẫn chia việc như cũ, nhưng ranh
giới file bây giờ là thứ DUY NHẤT giữ ba người không giẫm chân, vì không còn
nhánh riêng làm vùng đệm.

Thêm quy ước số 4 cho nhánh chung, xếp vào nhóm bắt buộc: pull --rebase trước
mỗi lần đẩy; commit nhỏ, đẩy trong ngày; không bao giờ đẩy thứ làm
pytest tests -q đỏ, vì nhánh hỏng là hai người kia đứng hình.

Phần nghiệm thu đổi theo: trước đây so file giữa ba nhánh, giờ không còn ba
nhánh để so. Thay bằng git log --name-only --pretty=%an trên gamma/refactor —
không file nào được xuất hiện dưới hai tên khác nhau.

Hai quyết định đã chốt, ghi vào GammaTeam_decisions.md:
  1. api_key: đường A — ConfigRepository ghép key từ SecretStore vào dict, 5
     nơi đọc không đổi dòng nào, không cần báo Duy và Hoa.
  2. 24 checker UI: đường A — ai dời file thì sửa checker ngay trong commit
     đó, kèm ràng buộc phải nói rõ sửa gì và chạy check_probes_bite.py sau.
     Không đưa vào CI sprint này vì chúng dựng MainWindow thật.

Baseline trong tài liệu cập nhật 90 -> 102 test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:24:03 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 3138856741 feat(domain): DTO ToolPolicyGateway — bản đề xuất, gỡ chốt cho N3
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường: N3 ngồi đợi
(trái nguyên tắc không team nào chặn team nào), N3 tự phỏng đoán (không ai
soi, chắc chắn phải sửa), hoặc viết một bản đề xuất để Hoa duyệt. Chọn cái
thứ ba.

Ranh giới giữ đúng sơ đồ phân hệ trong plan.md: domain/security/ là của
Gamma, application/conversations/tool_policy_gateway.py là của Hoa. Nên Gamma
định nghĩa hình dạng, Hoa cài đặt. Không đụng file nào của họ.

Hình dạng bám vào code đang chạy: SecurityVerdict (allowed/reason/layer) và
hộp thoại xin phép ở chat_panel.py:1312. Khác biệt duy nhất là gộp thành một
câu trả lời ba trạng thái ALLOW/DENY/ASK, thay vì bắt chỗ gọi tự nhớ hỏi hai
nơi.

Hai ràng buộc đưa vào có chủ đích, mỗi cái một test:
  - DENY và ASK bắt buộc có reason, ném lỗi ngay lúc dựng. Người dùng cần
    biết vì sao bị chặn và audit_log cần ghi lại.
  - ASK không phải allowed. Đây là bẫy dễ mắc nhất: coi ASK như ALLOW thì
    tool chạy trước khi có ai đồng ý.

Kèm FakeToolPolicyGateway lập trình được theo tên tool hoặc theo hàm, có ghi
lại đã hỏi những gì — test khẳng định được "có hỏi cổng không", không chỉ
"kết quả đúng không".

docs/refactor/GammaTeam_decisions.md thêm quyết định 3, kèm nguyên văn tin
nhắn cần gửi Hoa và ô đánh dấu đã gửi / đã xác nhận.

102 test xanh (96 + 6 mới). CASAN Check 1 sạch. domain/ và application/ có 0
import PySide6 — kiểm bằng AST, vì grep đếm ra 4 mà cả 4 là chữ "PySide6"
nằm trong chính docstring cảnh báo. Check 3 của Team Duy nên phân tích cú
pháp chứ đừng grep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:07:00 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 a164f32bfb docs(refactor): thêm mục input/output cho từng người
Ba khối, mỗi người một khối: cột trái là thứ phải có trong tay mới làm được kèm
nguồn, cột phải là thứ bắt buộc giao ra kèm người nhận. Nhãn 'có rồi' đánh dấu
những gì mục chung đã giao xong hôm nay (SecretStore, ConfigRepository, fake,
script CASAN).

Kèm bảng output bắt buộc với cả ba mỗi PR, mỗi dòng có lệnh tự kiểm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:25:20 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 8a9ee5f875 chore(refactor): mục chung của Team Gamma — khung, hợp đồng, cổng CASAN
Sáu việc trong "mục chung" của bản phân công, làm trước khi ba nhánh tính năng
tách ra.

1. Khung 5 tầng theo đúng đường dẫn plan.md: domain/ application/
   infrastructure/ presentation/ platform/ + tests/fakes/ — 38 __init__.py.
   Trước đó là 0 file, mà mọi task của cả ba người đều ghi vào đây.
   Đã kiểm platform/ không che khuất module platform của stdlib.

2. Hợp đồng SecretStore và ConfigRepository (Protocol, chưa cài đặt) + fake
   chạy trong bộ nhớ. Danh sách thuộc tính không bịa: đếm 156 lời gọi
   ctx.config.* trong 29 file rồi lấy những cái dùng thật, xếp theo số lần.
   Cố ý bỏ config.data (36 lời gọi, nhiều nhất) — bê dict thô sang kiến trúc
   mới là bê nguyên vấn đề cũ.

3. tests/test_contracts.py — bài nghiệm thu, không phải test cho vui. Bài
   chính chạy tiến trình riêng và khẳng định dùng fake KHÔNG kéo theo
   cowork_local.config lẫn PySide6; đó là điều kiện để N2 và N3 code ngay hôm
   nay thay vì đợi bản thật ngày 23 và 26/08.

4. scripts/audit_security.py — CASAN Check 1, Gamma chủ trì (hạn 30/08). Viết
   sớm để kiểm liên tục trong lúc chuyển API key, không đợi tới ngày cổng.
   Lần chạy đầu ra 3 báo động giả (secret_in_output là tên quy tắc, api_key="x"
   là dữ liệu test) nên đã siết: ngưỡng độ dài, hằng liệt kê, hình dạng khoá
   i18n, và dấu "# casan: allow" làm lối thoát chuẩn.
   --self-test cắm 4 credential thật + 5 mẫu vô hại để chứng minh nó còn cắn
   được — một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
   biết tìm.

5. Ba check CASAN vào CI, chạy mọi PR thay vì dồn tới 30/08. Check 2 và 3
   thuộc Team Hoa và Team Duy, chưa có script — bước CI bỏ qua nếu file chưa
   tồn tại, để thêm cổng không làm đỏ CI của hai team kia.

6. docs/refactor/GammaTeam_decisions.md — hai quyết định chờ nhóm trưởng chốt:
   provider_conf() còn trả api_key hay không (ảnh hưởng 5 nơi, 3 nằm ngoài
   team), và số phận 24 checker UI sẽ vỡ khi file bị dời.

96 test xanh (90 cũ + 6 mới). CASAN Check 1: 0 credential lộ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:58:35 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 09b1c93624 docs(refactor): phân việc Team Gamma thành 1 mục chung + 3 nhánh song song
Trang HTML tự đứng một mình, mở bằng trình duyệt là xem được, không cần mạng.
Chia toàn bộ phần việc của team trong plan.md (R02, R07-T06, R08-T07…T10, R09,
CASAN Check 1) cho 3 người:

  - Một mục chung nhóm trưởng làm trước, xong mới chia nhánh: dựng khung 5 thư
    mục đích (hiện là 0 file), interface + fake cho Config/Secrets, chốt số phận
    api_key, script CASAN Check 1, đưa 3 check vào CI, quyết số phận 24 checker
    UI sẽ vỡ khi file bị dời.
  - Ba nhánh tính năng ngang nhau, mỗi nhánh ~2.700 dòng: N1 cấu hình và vỏ ứng
    dụng (nhóm trưởng giữ, vì chạm app.py / config.py / theme.py / i18n.py),
    N2 giám sát, N3 Co4E.
  - Bảy quy ước cho N2 và N3, ba trong đó là bắt buộc.

Số dòng code, 156 lời gọi ctx.config, 24 lời gọi audit_log.record và baseline
90 test đều đo trực tiếp trên main ngày 21/08, không lấy từ tài liệu.

Footer ghi rõ phần nào là đề xuất, phần nào lấy từ ba tài liệu gốc — mục chung,
cách chia nhánh, quy ước và nghiệm thu là đề xuất.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 18:53:05 +09:00
58 changed files with 7262 additions and 62 deletions
+29
View File
@@ -39,3 +39,32 @@ jobs:
- name: Run tests
run: python -m pytest tests -q
# --- CASAN Verification Gate -------------------------------------
# Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để
# biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng.
#
# Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3
# (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để
# thêm cổng không làm đỏ CI của hai team kia.
- name: "CASAN Check 1 — không có credential lộ (Team Gamma)"
run: |
python scripts/audit_security.py --self-test
python scripts/audit_security.py
- name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)"
run: |
if [ -f scripts/check_loc.py ]; then
python scripts/check_loc.py
else
echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua."
fi
- name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)"
run: |
if [ -f scripts/check_imports.py ]; then
python scripts/check_imports.py
else
echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua."
fi
+11 -5
View File
@@ -28,7 +28,10 @@ bower_components/
.env.preview
*.pem
*.key
secrets/
# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ
# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải
# bí mật) khỏi repo suốt 21-22/08.
/secrets/
credentials.json
.npmrc
.yarnrc
@@ -36,9 +39,11 @@ credentials.json
# =============================================================================
# Build & Distribution
# =============================================================================
dist/
build/
out/
# Neo vao goc — mau khong neo se nuot moi thu muc trung ten o moi do sau,
# ke ca ma nguon. Da mac dung loi do voi secrets/ (xem khoi Credentials).
/dist/
/build/
/out/
.next/
.nuxt/
.output/
@@ -73,7 +78,8 @@ desktop.ini
# Logs & Debug
# =============================================================================
*.log
logs/
# Neo vao goc: infrastructure/logs/ la ma nguon, khong phai log chay may.
/logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+12
View File
@@ -0,0 +1,12 @@
"""adapters/ — Adapter riêng cho Qt (clock, thread, timer).
Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy
bất kỳ script nào từ thư mục gốc repo (``python tools/...``,
``python scripts/...``) thì ``platform/`` **che khuất module ``platform``
của thư viện chuẩn**, và ``import keyring`` chết ngay với
``AttributeError: module 'platform' has no attribute 'system'``.
Repo có 26 script chạy đúng kiểu đó.
Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao
giờ chạy python từ thư mục gốc".
"""
View File
+1 -1
View File
@@ -1 +1 @@
"""Application Layer: Pure Python use cases and application services."""
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
@@ -0,0 +1,322 @@
"""The turn lifecycle, once, in pure Python (R04-T03).
Extracted from ``core/chat_agent.py::run_cowork``, whose 260-line body mixed the
lifecycle (compose the prompt, call the model, dispatch tools, respect the step
ceiling, tidy the sandbox) with the concrete machinery that does each of those
things. The lifecycle is the part with rules worth testing — and the part that
was untestable, because reaching it meant standing up a Qt widget and a worker
thread.
Here it is a plain object driven through the seams in :mod:`turn_runtime`, so a
test states a rule ("the guard runs before the model", "a rejected command never
executes") in three lines. ``core/chat_agent.py`` keeps its signature and
delegates, and the presentation layer keeps receiving the same events via the
legacy codec, so nothing downstream had to change with it.
Behavioural contract: this is a faithful port, not an improvement pass. Where
the original had a quirk (the step-ceiling note only merges into the answer when
the last message is the assistant's), the quirk is preserved and commented —
changing what a user sees belongs in its own change, not smuggled into a move.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
from ...domain.agents.agent_event import (
AssistantMessageCompletedEvent,
ErrorEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
)
from ...domain.agents.agent_result import AgentResult
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
from .turn_runtime import (
BUDGET_NOTE_TEMPLATE,
GATED_TOOLS,
PLAN_TOOL,
REASONING_ONLY_NOTE,
REJECTED_OUTPUT,
AttachmentReader,
CancelFn,
CommandGuard,
ContextCompactor,
EventSink,
ModelCallPort,
PermissionRequest,
PromptGuard,
PromptPreparer,
ToolRuntimePort,
)
logger = logging.getLogger("cowork_local.application.conversations")
class ConversationApplicationService:
"""Runs one :class:`ConversationExecutionRequest` to completion."""
def __init__(
self,
model: ModelCallPort,
tools: ToolRuntimePort,
*,
prepare_prompt: Optional[PromptPreparer] = None,
prompt_guard: Optional[PromptGuard] = None,
command_guard: Optional[CommandGuard] = None,
compact: Optional[ContextCompactor] = None,
permission_request: Optional[PermissionRequest] = None,
attachment_reader: Optional[AttachmentReader] = None,
) -> None:
self._model = model
self._tools = tools
# Every hook is optional so the service degrades to a plain chat turn.
# That is not only a test convenience: a headless caller legitimately has
# no guards (``security_config=None`` today) and no permission dialog.
self._prepare_prompt = prepare_prompt
self._prompt_guard = prompt_guard
self._command_guard = command_guard
self._compact = compact
self._permission_request = permission_request
self._attachment_reader = attachment_reader
# -- public API ------------------------------------------------------ #
def execute(self, request: ConversationExecutionRequest, sink: EventSink,
cancel: Optional[CancelFn] = None,
messages: Optional[List[Dict[str, Any]]] = None) -> AgentResult:
"""Run the turn, streaming events to ``sink``, and report the outcome.
``messages``, when given, is a working list the caller already built —
it MUST already end with this turn's user message, and the service
appends into that very object instead of composing its own. The Cowork
widget needs this: it hands out the same list to
``_reattach_running_turn``, which replays the steps done so far while the
worker is still appending, and to ``_finalize_turn``, which slices it by
the pre-turn snapshot length. A private list would break both silently.
Passing ``None`` (every headless caller) lets the service compose the
list from the request, which is the mode the rest of this class assumes.
Raises whatever the runtime raises (a blocked prompt, a dead gateway):
the caller already has a failure path for that — ``AgentWorker.failed``
in the UI, the artifact writer in Schedule Task — and swallowing the
exception here would silently turn a failed turn into an empty answer.
An :class:`ErrorEvent` is emitted first so subscribers see the failure
on the same stream as everything else.
"""
cancel = cancel or (lambda: False)
# -- pre-flight. Runs BEFORE the output snapshot, so a turn refused here
# leaves the output folder completely untouched (tidying is not a
# read-only operation — see ToolRuntimePort.finalize).
try:
# The caller's list is used by reference on purpose (see above); only
# the self-composed path may build a fresh one.
working = messages if messages is not None else self._compose_messages(request)
tools = list(self._tools.specs(request.allowed_tools))
if self._prepare_prompt is not None:
self._prepare_prompt(working, tuple(getattr(t, "name", "") for t in tools))
if request.enforce_rules and self._prompt_guard is not None:
self._prompt_guard(working)
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
sink(ErrorEvent(message=str(exc)))
raise
before = self._tools.snapshot()
steps_used = 0
plan_steps: Tuple[PlanStep, ...] = ()
completed_naturally = False
try:
for _ in range(request.effective_max_steps):
if cancel():
break
# Auto-compress when nearing the model's context budget; a no-op
# when off or when the conversation is still short.
if self._compact is not None:
self._compact(working, cancel)
assistant = self._model.call(
working, tools,
on_text=lambda piece: sink(TextChunkEvent(delta=piece)),
on_reasoning=lambda piece: sink(ReasoningChunkEvent(delta=piece)),
cancel=cancel,
)
working.append(assistant)
steps_used += 1
tool_calls = assistant.get("tool_calls") or []
if not tool_calls and not (assistant.get("content") or "").strip():
# Written into the message, not just emitted, so the stored
# conversation never ends on a blank assistant turn.
assistant["content"] = REASONING_ONLY_NOTE
sink(TextChunkEvent(delta=REASONING_ONLY_NOTE))
sink(AssistantMessageCompletedEvent(content=assistant.get("content", "")))
if not tool_calls:
completed_naturally = True
break
for call in tool_calls:
if cancel():
break
tool_message, steps = self._dispatch(request, call, sink, cancel)
working.append(tool_message)
if steps is not None:
plan_steps = steps
if not completed_naturally and not cancel():
self._announce_budget_exhausted(request, working, sink)
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
sink(ErrorEvent(message=str(exc)))
raise
finally:
# Always tidy: the sandbox and generator scripts must not survive a
# turn that stopped abruptly. Runs on success, cancel and failure.
self._finalize_outputs(before, sink, cancelled=cancel())
result = AgentResult(
messages=working, steps_used=steps_used, cancelled=cancel(),
budget_exhausted=not completed_naturally and not cancel(),
plan_steps=plan_steps,
)
sink(result.to_turn_completed_event())
return result
# -- internals ------------------------------------------------------- #
def _compose_messages(self, request: ConversationExecutionRequest) -> List[Dict[str, Any]]:
"""History snapshot plus this turn's user message.
The attachment text is read HERE rather than when the request was built,
because extraction is slow enough to freeze the UI thread; the request
deliberately carries paths only.
"""
body = request.prompt
if self._attachment_reader is not None:
body = self._attachment_reader(request.prompt, request.attachments)
messages = [dict(m) for m in request.messages]
messages.append({"role": "user", "content": request.user_content(body)})
return messages
def _dispatch(self, request: ConversationExecutionRequest, call: Dict[str, Any],
sink: EventSink, cancel: CancelFn
) -> Tuple[Dict[str, Any], Optional[Tuple[PlanStep, ...]]]:
"""Run one tool call.
Returns ``(tool_message, plan_steps)`` — the message to append to the
conversation, and the new checklist when this call was the plan tool
(``None`` otherwise, so the caller can tell "no change" from "empty
plan").
"""
call_id = str(call.get("id", ""))
name = str(call.get("name", ""))
args = call.get("arguments") or {}
# The plan tool is invisible in the transcript: it updates the Plan panel
# and nothing else, so it skips preview, guard and gate entirely.
if name == PLAN_TOOL:
outcome = self._tools.execute(name, args, on_output=None, cancel=cancel)
steps = tuple(outcome.get("plan_steps") or ())
sink(PlanUpdatedEvent(steps=steps))
return self._tool_message(call_id, name, outcome.get("output", "")), steps
# Announce first: the user sees the code/command about to run before the
# guard or the approval dialog interrupts them, which is the whole point
# of showing the step CLI-style.
preview = self._tools.preview(name, args)
sink(ToolCallStartedEvent(call_id=call_id, name=name, arguments=dict(args),
preview=preview))
if request.enforce_rules and self._command_guard is not None:
self._command_guard(name, args)
if not self._approved(request, name, args, preview, sink, call_id):
return self._tool_message(call_id, name, REJECTED_OUTPUT), None
outcome = self._tools.execute(
name, args,
on_output=lambda piece: sink(ToolOutputChunkEvent(
call_id=call_id, name=name, delta=piece)),
cancel=cancel,
)
sink(ToolCallFinishedEvent(
call_id=call_id, name=name, ok=bool(outcome.get("ok", False)),
output=str(outcome.get("output", "")), path=str(outcome.get("path", "") or ""),
produced=outcome.get("produced") or (),
))
return self._tool_message(call_id, name, outcome.get("output", "")), None
def _approved(self, request: ConversationExecutionRequest, name: str,
args: Dict[str, Any], preview: Any, sink: EventSink,
call_id: str) -> bool:
"""Whether this call may run.
Only command-shaped tools are gated, and only when the workspace asked
to confirm them: file writes stay inside the turn's own sandbox, so
prompting for those would be noise. A rejection is reported as a failed
tool result — the model needs to read back that it was refused, or it
will simply try the same call again.
"""
if not request.requires_permission_gate or name not in GATED_TOOLS:
return True
if self._permission_request is None:
# Confirm mode with nobody to ask: refusing is the safe direction,
# since auto-running is exactly what confirm mode exists to prevent.
logger.warning("turn: confirm mode without a permission callback — refusing %r", name)
approved = False
else:
approved = bool(self._permission_request({
"name": name, "args": args,
"preview": preview.to_dict() if preview is not None else {},
}))
if not approved:
sink(ToolCallFinishedEvent(call_id=call_id, name=name, ok=False,
output=REJECTED_OUTPUT))
return approved
@staticmethod
def _tool_message(call_id: str, name: str, output: Any) -> Dict[str, Any]:
"""The canonical ``role: tool`` message the model reads back."""
return {"role": "tool", "tool_call_id": call_id, "name": name,
"content": str(output or "")}
@staticmethod
def _announce_budget_exhausted(request: ConversationExecutionRequest,
messages: List[Dict[str, Any]], sink: EventSink) -> None:
"""Report being cut off by the step ceiling.
The note always reaches the transcript. It is merged into the stored
answer only when the last message is the assistant's — which, when the
ceiling is hit, it never is (the turn ends on a tool result). The branch
is kept because it is what the current runtime does, and because it is
the correct behaviour the day a caller ends the loop differently.
"""
note = BUDGET_NOTE_TEMPLATE.format(steps=request.effective_max_steps)
sink(TextChunkEvent(delta=note))
if messages and messages[-1].get("role") == "assistant":
messages[-1]["content"] = (messages[-1].get("content") or "") + note
def _finalize_outputs(self, before: Any, sink: EventSink, cancelled: bool) -> None:
"""Tidy the output folder and report what moved.
Failures are logged, never raised: this runs in a ``finally``, so an
exception here would replace the turn's real error (or its success) with
a housekeeping one.
"""
try:
removed, added = self._tools.finalize(before, cancelled=cancelled)
except Exception: # noqa: BLE001
logger.exception("turn: tidying the output folder failed")
return
if removed:
sink(OutputsRemovedEvent(paths=tuple(removed)))
if added:
sink(OutputsAddedEvent(paths=tuple(added)))
__all__ = ["ConversationApplicationService"]
@@ -0,0 +1,325 @@
"""Wires :class:`ConversationApplicationService` to the existing runtime (R04-T03).
The service is written against the narrow seams in :mod:`turn_runtime` so it can
be tested with plain fakes. This module supplies the real implementations — the
provider call with its recovery pass, the tool/sandbox runtime, the security
guards, context compaction — and is therefore the ONLY file in
``application/conversations/`` that knows ``core/*`` exists. Same shape (and
same reason) as ``application/model_routing/core_routing_adapter.py`` in R03.
Every ``core`` import is deferred into a method body: importing the tool runtime
pulls in ``requests``, ``psutil`` and the sandbox stack, and code that merely
*builds* a service must not pay for that.
Faithfulness notes — two places where this reproduces a quirk of the current
runtime rather than the behaviour one would design fresh. Both are marked
inline: the MS365 system-prompt paragraph keys off the CONFIGURED extra tools
(not the advertised subset), and the ``tool_result`` path falls back to the
call's own ``path`` argument resolved against the workdir.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from ...domain.agents.agent_event import PlanStep, ToolPreview
from .conversation_application_service import ConversationApplicationService
from .turn_runtime import PLAN_TOOL, EventSink
# Legacy emit: the dict-based callback every current caller already owns.
LegacyEmit = Callable[[Dict[str, Any]], None]
def legacy_event_sink(emit: LegacyEmit) -> EventSink:
"""Adapt a typed :class:`EventSink` onto the legacy dict ``emit``.
This is what lets R04 land without touching the presentation layer: the
service thinks in typed events, ``ui/chat_panel.py::_on_event`` keeps
receiving exactly the dicts it already dispatches on. Deleted in R08 once
the widget consumes events directly.
"""
return lambda event: emit(event.to_legacy_dict())
class CoreModelCall:
""":class:`ModelCallPort` over ``code_agent._call_provider_with_recovery``.
Not ``provider.chat`` directly: the recovery wrapper adds the one bounded
retry that hides a dropped connection or a momentarily unreachable gateway,
and losing it would be a visible regression on flaky corporate networks.
"""
def __init__(self, provider: Any) -> None:
self._provider = provider
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
from ...core.code_agent import _call_provider_with_recovery
return _call_provider_with_recovery(self._provider, messages, tools, on_text,
cancel, on_reasoning)
class CoreToolRuntime:
""":class:`ToolRuntimePort` over ``core/tools.py`` + Cowork's file tools."""
def __init__(self, output_dir: Path, *, title: str = "",
extra_tools: Optional[Sequence[Any]] = None, extra_executor=None,
security_config: Any = None, agent_role: str = "") -> None:
self._output_dir = Path(output_dir)
self._title = title
self._extra_tools = list(extra_tools or ())
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
# The connector executor MCP/REST tools are routed to; None when the
# turn has no connectors enabled.
self._extra_executor = extra_executor
self._security_config = security_config
self._agent_role = agent_role
self._ctx: Any = None # built on first use (see _tool_context)
# -- the configured extra tools, for the system-prompt hints ---------- #
@property
def extra_names(self) -> frozenset:
return frozenset(self._extra_names)
def _tool_context(self):
"""The sandboxed ``ToolContext`` every built-in tool call runs inside.
Built once per turn and cached: it carries the resource limits and the
network policy, so re-deriving it mid-turn could let a Settings change
take effect halfway through work already in flight.
"""
if self._ctx is None:
from ...core import agent_security
from ...core.tools import ToolContext
limits, block_network = agent_security.sandbox_settings(self._security_config)
self._ctx = ToolContext(
self._output_dir, flatten_writes=True, # keep every file in the Output root
resource_limits=limits, block_network=block_network,
allow_url_fetch=agent_security.url_fetch_allowed(self._security_config),
jira=(self._security_config.data.get("jira") if self._security_config else None),
)
return self._ctx
# -- ToolRuntimePort -------------------------------------------------- #
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> List[Any]:
"""Advertised tools: Cowork's own two, the enabled built-ins, then MCP.
``allowed_tools`` restricts the list so a read-only step literally cannot
write. ``update_plan`` and the connector tools always survive the filter:
the plan tool has no side effects, and connectors are opted into
explicitly rather than governed by the built-in capability scope.
"""
from ...core.chat_agent import SAVE_FILE_SPEC
from ...core.plan import UPDATE_PLAN_SPEC
from ...core.tools import enabled_tool_specs
specs = ([SAVE_FILE_SPEC, UPDATE_PLAN_SPEC]
+ list(enabled_tool_specs(self._security_config))
+ self._extra_tools)
if allowed_tools is None:
return specs
allow = set(allowed_tools) | {PLAN_TOOL} | self._extra_names
return [t for t in specs if getattr(t, "name", "") in allow]
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
"""What the user sees before the call runs."""
# A connector call has no local diff to show, so it renders as the plain
# argument dump the runtime already used.
if name in self._extra_names:
return ToolPreview(kind="info", title=name, text=str(args))
if name == "save_file":
return self._save_file_preview(args)
from ...core.tools import describe_action
raw = describe_action(self._tool_context(), name, args)
return ToolPreview.from_dict(raw)
def _save_file_preview(self, args: Dict[str, Any]) -> ToolPreview:
"""A before/after diff for the file the agent is about to write.
A brand-new file renders all-green (before is empty); an overwrite shows
the real change, so saving a file reads like editing one.
"""
import difflib
from ...core.chat_agent import _structure_summary, _titled_filename
fname = _titled_filename(self._title, args.get("filename", "output.txt"))
content = str(args.get("content", ""))
summary = _structure_summary(fname, content)
old = ""
existing = self._output_dir / fname
if existing.exists():
try:
old = existing.read_text(encoding="utf-8", errors="replace")
except OSError:
pass # unreadable existing file: show it as a fresh write
diff = "".join(difflib.unified_diff(
old.splitlines(keepends=True), content.splitlines(keepends=True),
fromfile=f"a/{fname}", tofile=f"b/{fname}",
)) or content[:4000]
return ToolPreview(kind="diff", title=f"Save {fname}",
text=f"{summary}\n\n{diff[:4000]}")
def execute(self, name: str, args: Dict[str, Any], on_output=None,
cancel=None) -> Dict[str, Any]:
"""Run one tool call and return the runtime's result mapping."""
if name == PLAN_TOOL:
return self._execute_plan(args)
if name in self._extra_names and self._extra_executor is not None:
# Connector results carry no local file, so no path/produced keys —
# matching what the runtime reports for an MCP call today.
result = self._extra_executor(name, args) or {}
return {"ok": bool(result.get("ok", False)), "output": result.get("output", "")}
if name == "save_file":
from ...core.chat_agent import _do_save_file
return dict(_do_save_file(self._output_dir, self._title, args))
from ...core.tools import execute_tool
ctx = self._tool_context()
result = dict(execute_tool(ctx, name, args, cancel=cancel, on_output=on_output,
agent_role=self._agent_role))
# Quirk preserved: a tool that wrote the file named in its OWN arguments
# (write_file/edit_file) does not report a path, so the runtime derives
# one from the argument. Dropping this would empty the Output list.
if not result.get("path") and isinstance(args, dict) and args.get("path"):
result["path"] = str(ctx.workdir / str(args["path"]))
return result
def _execute_plan(self, args: Dict[str, Any]) -> Dict[str, Any]:
"""Apply an ``update_plan`` call: validate the steps and audit them.
Produces no file and no chat bubble; the service turns the returned
steps into a single plan event.
"""
from ...core import agent_roles, audit_log
from ...core.plan import normalize_plan_steps
steps = normalize_plan_steps(args.get("steps"))
audit_log.record("tool_call", PLAN_TOOL, True, f"{len(steps)} step(s)",
agent_role=agent_roles.PLANNER)
return {"ok": True, "output": "Plan updated.",
"plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]}
def snapshot(self) -> Any:
from ...core.tools import _snapshot
return _snapshot(self._output_dir)
def finalize(self, before: Any, cancelled: bool = False
) -> Tuple[List[str], List[str]]:
"""Drop the scratch sandbox and flatten deliverables into the root.
Returns ``(gone, arrived)``: a file that MOVED counts as both, because
the Output list keys entries by path and must drop the old one.
"""
from ...core.chat_agent import _cleanup_cowork_intermediates
removed, moved = _cleanup_cowork_intermediates(self._output_dir, before,
cancelled=cancelled)
gone = list(removed) + [old for old, _new in moved]
arrived = [new for _old, new in moved]
return gone, arrived
def build_cowork_conversation_service(
provider: Any,
output_dir: Path,
emit: LegacyEmit,
*,
title: str = "",
project_context: str = "",
extra_tools: Optional[Sequence[Any]] = None,
extra_executor=None,
security_config: Any = None,
gate: Any = None,
agent_role: str = "",
) -> ConversationApplicationService:
"""A service wired to the real runtime, ready to execute a Cowork turn.
``emit`` is the legacy dict callback: the guards and the compactor publish
their own notices through it directly (exactly as they do now), while the
service's typed events reach it via :func:`legacy_event_sink`.
``gate`` present means the workspace asked to confirm commands; pass the
request with ``gate_mode="confirm"`` so the two agree. A gate of ``None``
keeps the pre-existing auto-run behaviour.
"""
from ...core import agent_roles
tools = CoreToolRuntime(
output_dir, title=title, extra_tools=extra_tools, extra_executor=extra_executor,
security_config=security_config, agent_role=agent_role or agent_roles.COWORK,
)
def prepare_prompt(messages: List[Dict[str, Any]], advertised: Tuple[str, ...]) -> None:
"""Insert the system prompt, then fold in skills, rules and project text.
``advertised`` is unused on purpose: the runtime decides the MS365
paragraph from the CONFIGURED connector tools, not from the subset a
capability scope left advertised. Changing that changes the prompt the
model sees, so it stays as-is here and belongs to R05's tool-policy work.
"""
from ...core.chat_agent import (
COWORK_TOOL_PROMPT,
OPENDATALOADER_PDF_PROMPT,
_apply_project_context,
_apply_security_rules,
_apply_skills,
)
from ...core.deps import _can_pip
from ...core.java_runtime import find_java
from ...core.security_rules import load_rules
from ...core.skills import active_skills_text
if not messages or messages[0].get("role") != "system":
system = COWORK_TOOL_PROMPT
if any(n.startswith("ms365_") for n in tools.extra_names):
system += ("\nThe user has signed in to Microsoft 365 and enabled some ms365__* "
"tools (Outlook / Teams / OneDrive / SharePoint / meeting transcripts, "
"via the built-in MS365 MCP server). Use them whenever the request "
"involves that data — don't say you can't access it.")
if find_java() is not None and _can_pip():
# Only advertise the Java-backed PDF extractor when BOTH the JVM
# and pip are available, so the agent is never steered into a
# command that cannot work on this machine.
system += "\n\n" + OPENDATALOADER_PDF_PROMPT
messages.insert(0, {"role": "system", "content": system})
_apply_skills(messages, active_skills_text())
_apply_security_rules(messages, load_rules())
_apply_project_context(messages, project_context)
def prompt_guard(messages: List[Dict[str, Any]]) -> None:
from ...core import agent_security
agent_security.enforce_prompt(provider, messages, security_config, emit)
def command_guard(name: str, args: Dict[str, Any]) -> None:
from ...core import agent_security
agent_security.enforce_command(provider, name, args, security_config, emit)
def compact(messages: List[Dict[str, Any]], cancel) -> None:
from ...core import context_budget
context_budget.maybe_compact(provider, messages, security_config,
emit=emit, cancel=cancel)
return ConversationApplicationService(
CoreModelCall(provider), tools,
prepare_prompt=prepare_prompt,
prompt_guard=prompt_guard,
command_guard=command_guard,
compact=compact,
permission_request=(gate.request if gate is not None else None),
)
__all__ = [
"LegacyEmit", "legacy_event_sink", "CoreModelCall", "CoreToolRuntime",
"build_cowork_conversation_service",
]
@@ -0,0 +1,77 @@
"""Turn the Cowork widget's captured state into a request (R04-T04).
``ui/cowork_tab.py::build_job`` reads a dozen values off the widget on the UI
thread and has to translate three of them before a turn can run: which message
is this turn's prompt, which messages are its history, and whether the workspace
wants commands confirmed. Those rules lived inline in the widget, where no test
could reach them — and each fails silently when wrong (a duplicated user message,
or a command that quietly stops asking for approval).
They live here instead, as the mapping step the migration map assigns to the
application layer. The widget keeps only what is genuinely widget-specific:
reading its own state and building the provider.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): pure Python.
Everything arrives as a plain value, so this module never sees a widget.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Sequence
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
def build_cowork_turn_request(
*,
turn_id: str,
session_id: str,
messages: Sequence[Dict[str, Any]],
surface: str = "cowork",
project_id: str = "",
title: str = "",
provider_id: str = "",
model: str = "",
instructions: str = "",
output_dir: Optional[Any] = None,
home_output_root: Optional[Any] = None,
confirm_commands: bool = False,
agent_role: str = "cowork",
) -> ConversationExecutionRequest:
"""Build one Cowork turn's immutable request.
``messages`` is the widget's working list, which ALREADY ends with this
turn's user message (the chat panel composes it — prefix, attachments,
session notes — before the job starts). So the prompt is that last message
and the history is everything before it. The request records both; the
service is handed the same working list and appends into it.
Keyword-only on purpose: a dozen positional strings in a call site is exactly
how a title ends up in the project-id slot.
"""
history = list(messages or ())
# ``pop`` rather than ``[-1]``/``[:-1]`` so the empty-list case needs no
# special branch: a turn with nothing in it yields an empty prompt instead of
# raising IndexError deep inside a worker thread.
last = history.pop() if history else {}
return ConversationExecutionRequest(
turn_id=turn_id,
session_id=session_id,
surface=surface,
project_id=project_id,
title=title,
prompt=str(last.get("content") or ""),
messages=history,
provider_id=provider_id,
model=model,
project_context=instructions,
output_dir=output_dir,
home_output_root=home_output_root,
# The workspace's Auto-run override (or the global setting) decides
# whether run_command/install_package must be approved first.
gate_mode="confirm" if confirm_commands else "auto",
agent_role=agent_role,
)
__all__ = ["build_cowork_turn_request"]
+177
View File
@@ -0,0 +1,177 @@
"""The seams :mod:`conversation_application_service` runs a turn through (R04-T03).
Two Protocols and six callables — chosen deliberately, not by reflex. The
refactor plan forbids giving every class an interface, so a contract exists here
only where there is both a real ``core/*`` implementation AND a test double:
* :class:`ModelCallPort` — one provider round-trip *including* the app's
existing context-overflow recovery, which is why the raw ``Provider.chat``
signature is not enough.
* :class:`ToolRuntimePort` — the tool + output-folder runtime, kept as one
cohesive object because every method operates on the same sandbox.
Everything else is a single function, so it is expressed as a callable type
rather than a class with one method (the same choice R03 made for
``ConfirmationCallback``). All of them are optional: a service built with none
of them still runs a plain chat turn, which is what keeps the unit tests short.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
layer — pure Python. Nothing here imports PySide6, ``core.*``, ``providers.*``
or ``ui.*``; the concrete wiring lives in :mod:`core_runtime_adapter`.
"""
from __future__ import annotations
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Protocol,
Sequence,
Tuple,
runtime_checkable,
)
from ...domain.agents.agent_event import AgentEvent, ToolPreview
# The plan tool is special-cased by the loop: it drives the Plan panel and
# produces no chat bubble and no file. Named here so the check is not a bare
# string literal in the middle of the dispatch.
PLAN_TOOL = "update_plan"
# Tools that need approval before they run when the workspace is in confirm
# mode. R05 replaces this tuple with a real ``ToolPolicyGateway`` keyed on
# ToolCapability; until then it mirrors exactly what the runtime gates today.
GATED_TOOLS = ("run_command", "install_package")
# Shown when the user (or the workspace policy) rejects a proposed command. The
# exact string also becomes the tool message the model reads back, so it must
# stay stable.
REJECTED_OUTPUT = "Rejected by user."
# A reasoning model can answer with thinking only. The note is written into the
# assistant message itself, not merely emitted, so an unattended run does not
# read back an empty answer and report "(no output)".
REASONING_ONLY_NOTE = "*(model returned only its reasoning — try rephrasing)*"
# Emitted when the turn is stopped by its own safety ceiling rather than by the
# model finishing. Never silent: being cut off looks exactly like being done.
BUDGET_NOTE_TEMPLATE = (
"\n\n⚠️ Reached the {steps}-step safety limit before the task signalled "
"completion — stopping here. Re-run to continue if more work remains."
)
def combine_instructions(*blocks: Optional[str]) -> str:
"""Join the standing-instruction blocks of a turn, skipping the absent ones.
A turn's instructions arrive as several independent blocks — the project's
shared context, an Admin agent's persona, a skill's rules, the
"this runs unattended" reminder — and each caller was joining them inline
with its own ``f"{a}\\n\\n{b}" if a else b`` expression. Two call sites now
need the same rule (the Cowork widget in R04-T04 and the task runner in
R04-T05), which is the point at which it stops being an expression.
Whitespace-only blocks count as absent: they would otherwise open the system
prompt with a stray blank line.
"""
return "\n\n".join(b.strip() for b in blocks if b and b.strip())
# --------------------------------------------------------------------------- #
# Callables.
# --------------------------------------------------------------------------- #
# Receives every typed event the turn produces. The caller decides what that
# means — render it, forward it as a legacy dict, autosave on it.
EventSink = Callable[[AgentEvent], None]
# True once the user has asked to stop. Polled between steps and between tool
# calls, the same cadence the current runtime uses.
CancelFn = Callable[[], bool]
# ``(prompt, attachment_paths) -> body``. Runs on the worker thread because
# extracting a .docx may pip-install a parser or call LibreOffice.
AttachmentReader = Callable[[str, Tuple[str, ...]], str]
# ``(messages, advertised_tool_names) -> None`` — inserts the system prompt and
# folds in skills, security rules and project instructions, in place. It needs
# the tool names because the system prompt gains an MS365 paragraph only when
# ms365 tools are actually present.
PromptPreparer = Callable[[List[Dict[str, Any]], Tuple[str, ...]], None]
# Reviews the assembled request; raises to refuse the turn outright.
PromptGuard = Callable[[List[Dict[str, Any]]], None]
# Reviews one proposed tool call; raises to refuse it.
CommandGuard = Callable[[str, Dict[str, Any]], None]
# ``(messages, cancel) -> None``. Summarises old turns in place when the
# conversation nears the model's context budget; a no-op when compaction is off
# or the conversation is short. It takes the cancel signal because compacting
# calls the model itself, so Stop has to reach it too.
ContextCompactor = Callable[[List[Dict[str, Any]], "CancelFn"], None]
# ``(action) -> approved``. Blocks the worker thread while a human decides.
PermissionRequest = Callable[[Dict[str, Any]], bool]
# --------------------------------------------------------------------------- #
# Ports.
# --------------------------------------------------------------------------- #
@runtime_checkable
class ModelCallPort(Protocol):
"""One call to the model, with the app's retry/recovery behaviour applied."""
def call(self, messages: List[Dict[str, Any]], tools: Sequence[Any],
on_text: Optional[Callable[[str], None]] = None,
on_reasoning: Optional[Callable[[str], None]] = None,
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
"""Return the canonical assistant message (content plus tool calls)."""
@runtime_checkable
class ToolRuntimePort(Protocol):
"""The tools a turn may call, and the folder its files land in."""
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> Sequence[Any]:
"""Tool specs to advertise to the model, already filtered.
Returns opaque objects (the provider layer's ``ToolSpec``); the service
only ever reads ``.name`` off them, which is what keeps this layer free
of a provider import.
"""
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
"""Human-readable description of a call that is about to run."""
def execute(self, name: str, args: Dict[str, Any],
on_output: Optional[Callable[[str], None]] = None,
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
"""Run one tool call.
Returns the runtime's own result mapping: ``ok``, ``output``, optionally
``path``/``produced`` for files it created, and ``plan_steps`` for the
plan tool.
"""
def snapshot(self) -> Any:
"""Opaque record of the output folder before the turn started."""
def finalize(self, before: Any, cancelled: bool = False
) -> Tuple[Sequence[str], Sequence[str]]:
"""Tidy the output folder; return ``(removed_paths, added_paths)``.
Not read-only — it deletes the scratch sandbox and flattens sub-folders —
so the service only calls it for a turn that actually started.
"""
__all__ = [
"PLAN_TOOL", "GATED_TOOLS", "REJECTED_OUTPUT", "REASONING_ONLY_NOTE",
"BUDGET_NOTE_TEMPLATE", "combine_instructions",
"EventSink", "CancelFn", "AttachmentReader", "PromptPreparer", "PromptGuard",
"CommandGuard", "ContextCompactor", "PermissionRequest",
"ModelCallPort", "ToolRuntimePort",
]
+69 -19
View File
@@ -2,7 +2,9 @@
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
- ``cowork`` → ``chat_agent.run_cowork`` (documents/answers, real files)
- ``cowork`` → ``ConversationApplicationService`` (documents/answers, real
files) — the same turn engine the interactive Cowork chat
runs on since R04-T05
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
- ``script`` → local subprocess with a timeout
- ``flow`` → the task's own simple step list, run sequentially, each
@@ -162,6 +164,30 @@ _TIMEOUT_NOTICE_TMPL = (
)
_UNATTENDED_PREFIX = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed."
)
def _unattended_prompt(prompt: str, *, skill_text: str = "",
agent_instructions: str = "") -> str:
"""Assemble the user message an unattended run sends.
The order is load-bearing and used to be encoded as three successive
rebindings of ``prompt``, each prepending its own block: the plan reminder
must lead (it is the instruction that keeps a run without a human watching
honest), then the chosen skill's rules, then the Admin agent's persona, and
the task's own words last. Routing it through ``combine_instructions`` keeps
that order in one readable expression and drops the absent blocks instead of
leaving blank lines behind.
"""
from ..application.conversations.turn_runtime import combine_instructions
return combine_instructions(_UNATTENDED_PREFIX, skill_text, agent_instructions, prompt)
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
@@ -218,36 +244,29 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
# default, see state.build_provider_for). A legacy Admin-agent preset
# (task.admin_agent_id), if still set on an older task, keeps working and
# takes precedence — it pins the provider/model AND prepends instructions.
agent_instructions = ""
if admin_agent is not None:
from .admin_agents import build_agent_provider
provider = build_agent_provider(ctx, admin_agent)
agent_instructions = admin_agent.effective_prompt()
if agent_instructions:
prompt = f"{agent_instructions}\n\n{prompt}"
elif provider_name or model:
# An explicit per-task provider/model override.
provider = ctx.build_provider_for(provider_name or None, model or None)
else:
# Neither overridden → the machine's own Settings default, exactly as before.
provider = ctx.build_active_provider()
# A chosen skill's instructions are prepended so this unattended run follows
# A chosen skill's instructions are applied so this unattended run follows
# them, mirroring how the interactive chat applies /skill.
skill_text = ""
if skill_slug:
from .skills import skill_prefix_for
skill_text = skill_prefix_for(skill_slug)
if skill_text:
prompt = f"{skill_text}\n\n{prompt}"
# This is an UNATTENDED run (no human watching to catch a half-finished
# job) — push the agent to actually use the Plan checklist so completion
# can be verified afterward, instead of just trusting "no exception".
prompt = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
# Assemble reminder + skill + persona + the task's own words in one place
# (see _unattended_prompt for why that order matters).
prompt = _unattended_prompt(prompt, skill_text=skill_text,
agent_instructions=agent_instructions)
messages = [{"role": "user", "content": prompt}]
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
@@ -273,10 +292,41 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
from .chat_agent import run_cowork
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
security_config=ctx.config, agent_role=agent_roles.TASK,
project_context=project_context)
# R04-T05: the unattended run shares the interactive turn engine
# instead of calling run_cowork itself, so there is exactly one place
# where a turn's lifecycle is defined. Everything unattended-specific
# stays here (the plan reminder above, the History autosave in
# emit_and_autosave, the timeout notice below).
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from ..domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
# No extra_tools/extra_executor and no permission gate: a scheduled
# run gets no MCP connectors and nobody is there to approve a
# command, which is exactly what run_cowork was called with.
service = build_cowork_conversation_service(
provider, out_dir, emit_and_autosave, title=title,
project_context=project_context, security_config=ctx.config,
agent_role=agent_roles.TASK,
)
request = ConversationExecutionRequest(
# The artifact folder is named by the run id, which identifies
# this attempt in the audit log.
turn_id=out_dir.name or session_id, session_id=session_id,
surface="task", title=title, project_id=project_id,
prompt=prompt, output_dir=out_dir,
agent_role=agent_roles.TASK, unattended=True,
timeout_sec=timeout_sec,
)
# ``messages`` is handed over so the History autosave in
# emit_and_autosave (and the final save in the finally block below)
# keep reading the live conversation as it grows.
service.execute(request, legacy_event_sink(emit_and_autosave),
cancel=watched_cancel, messages=messages)
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)
+159
View File
@@ -0,0 +1,159 @@
# Mô hình chính sách an toàn — CoworkLocal
R09-T01 · Team Gamma · viết 22/08/2026
Tài liệu này mô tả **hệ thống đang chạy**, không phải hệ thống mong muốn. Mọi
khẳng định đều chỉ tới file và dòng cụ thể để đối chiếu được.
---
## 1. Câu hỏi quan trọng nhất: đây có phải rào chắn an ninh không
**Không.** `core/agent_security.py` nói thẳng ngay ở đầu file:
> *"this is a business productivity tool, not a hard security boundary"*
Điều đó quyết định mọi thứ còn lại. Cụ thể: **mọi tầng dùng AI đều mở khi
hỏng** (`allowed=True` khi không gọi được validator, `core/agent_security.py:150`).
Mạng chập chờn hay gateway trục trặc thì agent vẫn chạy, không bị khoá cứng.
Đánh đổi có chủ đích: chọn *dùng được* thay vì *chặn tuyệt đối*. Ai đọc tài
liệu này để đánh giá rủi ro cần hiểu đúng điều đó — đây là lớp giảm tai nạn,
không phải lớp chống kẻ tấn công có chủ đích.
---
## 2. Hai loại quy tắc, đừng lẫn
| | Quy tắc xác định | Quy tắc do AI phán |
|---|---|---|
| Cách hoạt động | So khớp mẫu cố định | Hỏi một model |
| Kết quả | Luôn giống nhau | Có thể khác nhau giữa hai lần |
| Khi hỏng | Vẫn chạy | **Mở** (cho qua) |
| Tắt được không | Không — luôn bật | Có, từng tầng một |
| Ở đâu | Bộ phân loại mẫu chặn + sandbox | 3 tầng validate |
Câu ở `core/agent_security.py:250` nói rõ ranh giới:
> *"always-on block-pattern classifier + sandbox still apply regardless"*
Nghĩa là **tắt hết ba tầng AI thì vẫn còn hai lớp xác định**. Đây là điểm dễ
hiểu nhầm nhất khi đọc màn Cài đặt: mấy công tắc ở đó **chỉ tắt phần AI**.
---
## 3. Ba tầng AI
Bật/tắt độc lập trong `agent_security` của `config.json`.
| Tầng | Kiểm cái gì | Khoá cấu hình | Khi nào chạy |
|---|---|---|---|
| Prompt | Yêu cầu của chính người dùng | `validate_prompt` | Trước khi agent làm gì |
| Attachment | Văn bản trích ra từ tệp đính kèm | `validate_attachments` | Trước khi vào ngữ cảnh model |
| Command | `run_command` / `install_package` | `validate_commands` | Trước khi thực thi |
Cả ba đọc chung một bộ luật: file cục bộ `core/security_rules.py` cộng thêm
tài liệu quản trị viên đặt trên OneDrive (nếu có cấu hình). Riêng agent Code
dùng bộ luật khác — `RULEforCode.md` thay vì `RULEBASE.md`.
Công tắc tổng `agent_security.enabled` tắt cả ba.
---
## 4. Chuyện gì xảy ra khi bị chặn
Theo đúng thứ tự trong `core/agent_security.py:266-273`:
1. Hiện thông báo trong khung chat — người dùng thấy ngay, kèm lý do
2. Ghi `audit_log.record("security_block", …)` — vào nhật ký kiểm toán
3. `notify_admin(...)` — gửi email quản trị viên
4. Ném `SecurityBlocked` — dừng lượt chạy
Ba bước đầu **không được phép ném lỗi**. `audit_log.record()` có ghi rõ trong
docstring: *"never raises — audit logging must never break a chat turn"*. Ghi
nhật ký hỏng không được kéo theo cả phiên làm việc.
---
## 5. Hỏi người dùng: trạng thái thứ ba
Ngoài cho/chặn còn một trạng thái nữa mà hệ thống hiện tại **có nhưng chưa gọi
tên**: hỏi người dùng.
`ui/chat_panel.py:1312` kiểm `ctx.project_confirm_commands()` rồi bật
`PermissionDialog`. Đó là một quyết định chính sách thật, nhưng nằm rải ở tầng
giao diện chứ không phải một kết quả chính thức.
`domain/security/tool_policy.py` (đề xuất, chờ Team Hoa xác nhận) gộp lại
thành ba trạng thái:
| | Nghĩa |
|---|---|
| `ALLOW` | Chạy |
| `DENY` | Không chạy, có lý do |
| `ASK` | Hỏi người dùng đã |
**`ASK` không phải là `allowed`.** Coi ASK như ALLOW nghĩa là tool chạy trước
khi có ai đồng ý — bẫy dễ mắc nhất, đã có test riêng chặn.
Cổng chính sách **không tự bật hộp thoại**. Nó chỉ trả lời; hỏi ai và hỏi thế
nào là việc của tầng giao diện. Nhờ vậy Co4E chạy nền mới dùng chung cổng được
với Cowork chạy tương tác — Co4E không hỏi được thì đổi `ASK` thành `DENY`.
---
## 6. Bí mật
Từ 21/08 (R02-T05), API key **không còn nằm trong `config.json`**:
* Lưu trong kho của hệ điều hành qua `KeyringAdapter` — Windows Credential
Manager, macOS Keychain, Linux Secret Service
* `provider_conf()` đọc từ kho rồi ghép vào dict trả về, nên chỗ gọi không
đổi (đường A, `GammaTeam_decisions.md`)
* File cũ tự chuyển ở lần mở đầu tiên, có sao lưu trước khi chuyển
Máy không có kho bí mật (Linux headless, CI) thì **không chuyển** — thà để
khoá trong file còn hơn xoá đi rồi người dùng mất khoá.
Kiểm bằng `python scripts/audit_security.py`, chạy tự động trong CI.
---
## 7. Sandbox
`core/sandbox_manager.py` chạy lệnh trong môi trường hạn chế. Luôn bật, không
tắt được, không phụ thuộc công tắc AI nào.
Năng lực khác nhau theo hệ điều hành — ma trận đầy đủ sẽ nằm ở
`infrastructure/sandbox/sandbox_capabilities.py` (R09-T06, Hiệp phụ trách).
Chỗ này cập nhật khi task đó xong.
---
## 8. Những chỗ đã biết là yếu
Ghi ra để người sau khỏi tưởng đã kín:
1. **Mở khi hỏng.** Gateway chết là ba tầng AI cho qua hết. Có chủ đích, nhưng
nghĩa là không chống được kẻ tấn công biết cách làm validator ngừng trả lời.
2. **Bí mật vẫn đi trong bộ nhớ.** Đường A ghép khoá vào dict `provider_conf()`
trả về, nên khoá vẫn có thể lọt vào log gỡ lỗi hay ảnh chụp màn hình. Đường
B (bỏ hẳn khỏi dict) đã ghi vào nợ kỹ thuật.
3. **Bộ luật lấy từ OneDrive không ký số.** Ai sửa được tài liệu đó là sửa được
luật.
4. **`ASK` chưa được nối vào Co4E.** Co4E chạy nền, chưa có đường hỏi người
dùng — hiện phải chọn giữa cho qua hết hoặc chặn hết.
---
## Đối chiếu nhanh
| Nội dung | Nguồn |
|---|---|
| Ba tầng AI, mở khi hỏng | `core/agent_security.py:1-25` |
| Phân loại mẫu + sandbox luôn bật | `core/agent_security.py:250` |
| Thứ tự khi bị chặn | `core/agent_security.py:266-273` |
| Nhật ký không được ném lỗi | `core/audit_log.py:46` |
| Hỏi người dùng | `ui/chat_panel.py:1312` |
| Ba trạng thái chính sách | `domain/security/tool_policy.py` |
| Bí mật | `infrastructure/secrets/keyring_adapter.py` |
+768
View File
@@ -0,0 +1,768 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Phân Việc Refactor Team Gamma</title>
</head>
<body>
<style>
:root {
--ground: #FAF9FC; --surface: #FFFFFF; --surface-2: #F3F0F8;
--ink: #191325; --muted: #665E7C; --line: #E4DEEE;
--accent: #6D3A9E;
--lead: #6D3A9E; --m1: #14707F; --m2: #A65418;
--warn: #A81F1A; --ok: #1B6B40;
--lead-wash: #F1E9F9; --m1-wash: #E2F1F3; --m2-wash: #F8EDE2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
--accent: #C08CF0;
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
--warn: #F08A84; --ok: #6FD69C;
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
}
}
:root[data-theme="dark"] {
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
--accent: #C08CF0;
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
--warn: #F08A84; --ok: #6FD69C;
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--ground); color: var(--ink);
font-family: "Segoe UI", -apple-system, system-ui, "Helvetica Neue", sans-serif;
font-size: 15px; line-height: 1.62; -webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 28px 96px; }
code, .mono, td.day, th.day, .num {
font-family: Consolas, "Cascadia Mono", "SF Mono", ui-monospace, monospace;
font-variant-numeric: tabular-nums;
}
header.top { border-bottom: 2px solid var(--ink); padding: 56px 0 22px; margin-bottom: 34px; }
.eyebrow { font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
color: var(--accent); font-weight: 700; margin: 0 0 14px; }
h1 { font-size: clamp(30px, 4.4vw, 44px); line-height: 1.1; margin: 0 0 16px;
font-weight: 700; letter-spacing: -.02em; text-wrap: balance; }
.lede { font-size: 17px; color: var(--muted); margin: 0; max-width: 64ch; }
.alias { margin: 18px 0 0; padding: 12px 16px; max-width: 74ch;
background: var(--surface-2); border-left: 3px solid var(--accent);
border-radius: 3px; font-size: 14px; color: var(--muted); }
.alias b { color: var(--ink); }
.facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 26px;
padding-top: 20px; border-top: 1px solid var(--line); }
.fact .k { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
color: var(--muted); display: block; margin-bottom: 3px; }
.fact .v { font-size: 15px; font-weight: 600; }
h2 { font-size: 23px; margin: 52px 0 6px; letter-spacing: -.01em; font-weight: 700; text-wrap: balance; }
h2 + .sub { color: var(--muted); margin: 0 0 22px; max-width: 70ch; }
h3 { font-size: 17px; margin: 30px 0 10px; font-weight: 700; }
/* gate = việc phải xong trước khi chia nhánh */
.gatebox { background: var(--surface); border: 1px solid var(--line);
border-left: 4px solid var(--warn); border-radius: 3px; padding: 4px 26px 22px; }
.gatebox h2 { margin-top: 22px; }
.steps { list-style: none; counter-reset: s; padding: 0; margin: 0; }
.steps > li { counter-increment: s; position: relative; padding: 14px 0 14px 46px;
border-bottom: 1px solid var(--line); }
.steps > li:last-child { border-bottom: none; }
.steps > li::before {
content: counter(s); position: absolute; left: 0; top: 14px;
width: 26px; height: 26px; border-radius: 50%; background: var(--accent);
color: #fff; font-size: 13px; font-weight: 700; display: flex;
align-items: center; justify-content: center;
font-family: Consolas, ui-monospace, monospace;
}
.steps b { display: block; margin-bottom: 2px; }
.steps small { color: var(--muted); font-size: 13.5px; display: block; }
.est { float: right; font-size: 12px; color: var(--muted); font-weight: 600;
font-family: Consolas, ui-monospace, monospace; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
@media (max-width: 940px) { .cards { grid-template-columns: 1fr; } }
.card { background: var(--surface); border: 1px solid var(--line);
border-top: 3px solid var(--c); border-radius: 3px; padding: 20px;
display: flex; flex-direction: column; }
.card.lead { --c: var(--lead); --w: var(--lead-wash); }
.card.one { --c: var(--m1); --w: var(--m1-wash); }
.card.two { --c: var(--m2); --w: var(--m2-wash); }
.card .tag { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
font-weight: 700; color: var(--c); margin-bottom: 6px; }
.card h3 { margin: 0 0 4px; font-size: 18px; }
.card .who { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
.card .branch { font-size: 12.5px; background: var(--w); color: var(--c);
padding: 5px 9px; border-radius: 3px; display: inline-block;
margin-bottom: 16px; word-break: break-all; font-weight: 600; }
.card h4 { font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
color: var(--muted); margin: 16px 0 7px; font-weight: 700; }
.card ul { margin: 0; padding-left: 17px; font-size: 14px; }
.card li { margin-bottom: 6px; }
.tid { font-size: 12px; font-weight: 700; color: var(--c);
font-family: Consolas, ui-monospace, monospace; }
.paths { list-style: none; padding: 0; margin: 0; font-size: 12.5px; }
.paths li { padding: 3px 0; border-bottom: 1px dotted var(--line);
font-family: Consolas, ui-monospace, monospace; color: var(--muted); word-break: break-all; }
.paths li:last-child { border-bottom: none; }
.weight { margin-top: auto; padding-top: 16px; font-size: 12.5px; color: var(--muted); }
.weight b { color: var(--ink); font-size: 15px; }
.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 3px; }
table { border-collapse: collapse; width: 100%; font-size: 13.5px; background: var(--surface); }
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
thead th { background: var(--surface-2); font-size: 11px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); font-weight: 700; white-space: nowrap; }
tbody tr:last-child td { border-bottom: none; }
td.day, th.day { white-space: nowrap; font-weight: 700; font-size: 13px; }
td.cl { border-left: 3px solid var(--lead); }
td.c1 { border-left: 3px solid var(--m1); }
td.c2 { border-left: 3px solid var(--m2); }
tr.mark td { background: var(--surface-2); font-weight: 600; }
td small { color: var(--muted); display: block; font-size: 12.5px; }
.pill { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 7px;
border-radius: 2px; letter-spacing: .04em; white-space: nowrap; }
.pill.cp { background: var(--m1-wash); color: var(--m1); }
.pill.gate { background: var(--m2-wash); color: var(--m2); }
.pill.ship { background: var(--lead-wash); color: var(--lead); }
.rules { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
@media (max-width: 760px) { .rules { grid-template-columns: 1fr; } }
.rule { background: var(--surface); border: 1px solid var(--line);
border-radius: 3px; padding: 18px 20px; border-left: 3px solid var(--c, var(--line)); }
.rule.hard { --c: var(--warn); }
.rule.soft { --c: var(--ok); }
.rule h3 { margin: 0 0 8px; font-size: 15px; }
.rule p { margin: 0; font-size: 14px; color: var(--muted); }
.rule code { color: var(--ink); }
/* tóm tắt: đọc 30 giây là nắm được, trước khi vào chi tiết */
.tldr {
display: grid; grid-template-columns: 1.35fr 1fr; gap: 0;
border: 1px solid var(--line); border-radius: 3px; overflow: hidden;
margin-bottom: 8px; background: var(--surface);
}
@media (max-width: 820px) { .tldr { grid-template-columns: 1fr; } }
.tldr > div { padding: 20px 24px; }
.tldr .right { background: var(--surface-2); border-left: 1px solid var(--line); }
@media (max-width: 820px) { .tldr .right { border-left: none; border-top: 1px solid var(--line); } }
.tldr .cap {
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
color: var(--muted); font-weight: 700; margin: 0 0 12px;
}
.flow { list-style: none; padding: 0; margin: 0; font-size: 14px; }
.flow li { padding: 7px 0; border-bottom: 1px dotted var(--line); display: flex; gap: 10px; }
.flow li:last-child { border-bottom: none; }
.flow .b {
flex: 0 0 auto; font-size: 11.5px; font-weight: 700; padding: 1px 7px; border-radius: 2px;
background: var(--w2); color: var(--c2); height: fit-content; margin-top: 2px;
font-family: Consolas, ui-monospace, monospace;
}
.flow li.f0 { --c2: var(--warn); --w2: var(--surface-2); }
.flow li.f1 { --c2: var(--lead); --w2: var(--lead-wash); }
.flow li.f2 { --c2: var(--m1); --w2: var(--m1-wash); }
.flow li.f3 { --c2: var(--m2); --w2: var(--m2-wash); }
.flow .t { flex: 1; }
.flow .t b { display: block; }
.flow .t small { color: var(--muted); font-size: 12.5px; }
.must { margin: 0; padding-left: 18px; font-size: 14px; }
.must li { margin-bottom: 8px; }
.must li:last-child { margin-bottom: 0; }
.must b { color: var(--ink); }
/* input / output từng người */
.io { display: grid; gap: 18px; }
.iorow { background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--c); border-radius: 3px; overflow: hidden; }
.iorow.n1 { --c: var(--lead); --w: var(--lead-wash); }
.iorow.n2 { --c: var(--m1); --w: var(--m1-wash); }
.iorow.n3 { --c: var(--m2); --w: var(--m2-wash); }
.iohead { padding: 14px 20px; background: var(--w); display: flex;
align-items: baseline; gap: 12px; flex-wrap: wrap; }
.iohead b { color: var(--c); font-size: 15px; }
.iohead span { color: var(--muted); font-size: 13px; }
.iogrid { display: grid; grid-template-columns: 1fr 1fr; }
@media (max-width: 860px) { .iogrid { grid-template-columns: 1fr; } }
.iogrid > div { padding: 16px 20px; }
.iogrid > div + div { border-left: 1px solid var(--line); }
@media (max-width: 860px) {
.iogrid > div + div { border-left: none; border-top: 1px solid var(--line); }
}
.iocap { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
color: var(--muted); font-weight: 700; margin: 0 0 10px; }
.iolist { list-style: none; margin: 0; padding: 0; font-size: 13.5px; }
.iolist li { padding: 5px 0; border-bottom: 1px dotted var(--line); }
.iolist li:last-child { border-bottom: none; }
.iolist code { font-size: 12.5px; }
.frm { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 6px;
border-radius: 2px; background: var(--surface-2); color: var(--muted);
margin-right: 6px; font-family: Consolas, ui-monospace, monospace; }
.frm.done { background: var(--m1-wash); color: var(--m1); }
.frm.risk { background: var(--m2-wash); color: var(--m2); }
footer { margin-top: 64px; padding-top: 20px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--muted); }
</style>
<div class="wrap">
<header class="top">
<p class="eyebrow">Team Gamma · Automation, Workflows &amp; Governance</p>
<h1>Một nhánh chung, ba làn không đụng nhau</h1>
<p class="lede">
Toàn bộ phần việc refactor 10 ngày của Team Gamma — Nam, Hiệp, Lâm. Cả ba đẩy chung vào <code>gamma/refactor</code>. Nam làm thêm một mục chung —
khung kiến trúc, hợp đồng dữ liệu, cổng kiểm duyệt — nằm ngoài ba nhánh; xong mục đó thì
ba người vào ba nhánh tính năng ngang nhau, không ai phải sửa chung file với ai.
</p>
<p class="alias">
Ba tài liệu refactor gọi team này là <b>“Team Nam”</b> (theo tên lead). Cùng một team, cùng
phạm vi R02 · R08 · R09 · R07-T06. Nhánh của team dùng tiền tố <code>gamma/</code>; ba tài liệu refactor viết
<code>nam/workflow-governance-*</code> theo tên lead — cùng một thứ.
</p>
<div class="facts">
<div class="fact"><span class="k">Thời hạn</span><span class="v mono">21/08 → 31/08</span></div>
<div class="fact"><span class="k">Người</span><span class="v mono">Nam · Hiệp · Lâm</span></div>
<div class="fact"><span class="k">Nhánh</span><span class="v mono">gamma/refactor</span></div>
<div class="fact"><span class="k">Code phải bóc</span><span class="v mono">~6.500 dòng</span></div>
<div class="fact"><span class="k">Cổng phải qua</span><span class="v mono">CASAN Check 1</span></div>
</div>
</header>
<section class="tldr">
<div>
<p class="cap">Tóm tắt · thứ tự làm</p>
<ul class="flow">
<li class="f0">
<span class="b">CHUNG</span>
<span class="t"><b>Nam làm trước, nửa ngày</b>
<small>Dựng khung 5 thư mục (đang là 0 file) · interface + fake cho Config/Secrets ·
chốt <code>api_key</code> và báo Team Duy · script CASAN Check 1 · đưa 3 check vào CI ·
quyết số phận 24 checker UI. Merge xong mới chia nhánh.</small></span>
</li>
<li class="f1">
<span class="b">N1</span>
<span class="t"><b>N1 — Nam · Cấu hình, Bí mật, Vỏ ứng dụng</b>
<small>R02 (6 task) · settings 4 widget · bootstrap + MainWindow · policy doc.
Giữ luôn <code>app.py</code>, <code>config.py</code>, <code>theme.py</code>,
<code>i18n.py</code>. ~2.700 dòng.</small></span>
</li>
<li class="f2">
<span class="b">N2</span>
<span class="t"><b>N2 — Hiệp · Giám sát</b>
<small>7 tab Monitoring · CanonicalAuditLogger · MonitoringQueryService ·
2 vòng lặp import · ma trận Sandbox. ~2.650 dòng.</small></span>
</li>
<li class="f3">
<span class="b">N3</span>
<span class="t"><b>N3 — Lâm · Co4E Studio</b>
<small>Co4EWorkflowService · tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code>
thành 5 phần. ~2.880 dòng, file to nhất team.</small></span>
</li>
</ul>
</div>
<div class="right">
<p class="cap">Ba điều bắt buộc</p>
<ol class="must">
<li><b>Không chạm file dùng chung.</b> Cần thêm chuỗi hay màu thì nhắn nhóm trưởng, đừng tự sửa.</li>
<li><b>Nộp factory, không tự lắp vào <code>app.py</code>.</b> N1 lắp trong <code>bootstrap.py</code> ngày 28/08.</li>
<li><b>Bị chặn thì dùng fake, báo ngay trong ngày.</b> Không ngồi đợi ai.</li>
</ol>
<p class="cap" style="margin-top:20px">Nghiệm thu</p>
<p style="margin:0;font-size:14px;color:var(--muted)">
Trên <code>gamma/refactor</code>: không file nào được sửa bởi hai người khác nhau.
Có là quy ước <b style="color:var(--ink)">số 1</b> đang bị vi phạm.
</p>
</div>
</section>
<section class="gatebox">
<h2>Mục chung — Nam làm, xong hai người kia mới bắt đầu</h2>
<p class="sub">
Sáu việc dưới đây không thuộc làn nào — chúng là thứ cả ba người cùng đụng
vào. Nam làm một lần và đẩy lên <code>gamma/refactor</code>, rồi hai người kia
mới bắt đầu. Ước tính nửa ngày.
</p>
<ol class="steps">
<li>
<span class="est">~30 phút</span>
<b>Dựng khung thư mục</b>
<small>
<code>domain/</code> <code>application/</code> <code>infrastructure/</code>
<code>presentation/</code> <code>platform/</code> <code>tests/fakes/</code> —
hiện tại <b>chưa tồn tại, 0 file</b>. Mọi task của cả ba người đều ghi vào đây; để ba
người tự tạo là đụng nhau ở <code>__init__.py</code> ngay ngày đầu.
</small>
</li>
<li>
<span class="est">~45 phút</span>
<b>Viết interface + fake cho Config và Secrets</b>
<small>
<code>SecretStore</code>, <code>ConfigRepository</code>, kèm
<code>FakeSecretStore</code> và <code>FakeConfigRepository</code>. Chỉ chữ ký, chưa cần
thân hàm. Đây là thứ gỡ chốt cho cả hai người kia — <b>156 lời gọi
<code>ctx.config.*</code> trong 29 file</b> đang chờ nó.
</small>
</li>
<li>
<span class="est">~20 phút</span>
<b>Chốt số phận <code>api_key</code> và báo Team Duy</b>
<small>
<code>provider_conf()</code> còn trả <code>api_key</code> bên trong, hay tách hẳn sang
<code>SecretStore</code>? Có 5 nơi đọc trực tiếp, <b>3 trong số đó nằm trong
<code>providers/</code> của Team Duy</b>. Quyết một mình rồi im lặng là làm vỡ code
team bạn.
</small>
</li>
<li>
<span class="est">~30 phút</span>
<b>Viết <code>scripts/audit_security.py</code> (CASAN Check 1)</b>
<small>
Gamma chủ trì check này ngày 30/08. Viết ngay hôm nay thì lead tự kiểm được trong suốt
quá trình chuyển API key, thay vì tới ngày cổng mới chạy lần đầu và phát hiện vấn đề.
</small>
</li>
<li>
<span class="est">~20 phút</span>
<b>Thêm 3 check CASAN vào CI</b>
<small>
CI hiện chỉ chạy <code>pytest tests -q</code>. Ba check (secret · ≤400 dòng · import
guard) không nằm trong CI, nên tới 30/08 mới biết ai vi phạm. Đưa vào CI thì mỗi PR tự
báo.
</small>
</li>
<li>
<span class="est">~30 phút</span>
<b>Quyết số phận 24 checker UI, rồi thông báo</b>
<small>
Chúng bám vào <code>cowork_local.config</code> (34 chỗ) và <code>cowork_local.app</code>
(16 chỗ) — <b>sẽ chết ngay khi lead đụng <code>config.py</code></b>. Đây là lưới an toàn
duy nhất cho phần UI vừa làm xong. Xem mục quy ước bên dưới.
</small>
</li>
</ol>
</section>
<h2>Ba làn</h2>
<p class="sub">
Ba làn ngang nhau, mỗi làn khoảng 2.700 dòng phải bóc tách, <b>cùng đẩy vào một
nhánh</b> <code>gamma/refactor</code>. Nam nhận làn N1 vì đó là làn chạm tới file
dùng chung nhiều nhất. Cột “sở hữu” là danh sách file <em>chỉ</em> người đó được
sửa — trên nhánh chung, đây là thứ duy nhất giữ cho ba người không giẫm chân.
</p>
<div class="cards">
<div class="card lead">
<div class="tag">Làn N1 · Nam</div>
<h3>Cấu hình, Bí mật &amp; Vỏ ứng dụng</h3>
<p class="who">Nam giữ — làn chạm nhiều file dùng chung nhất</p>
<div class="branch">gamma/refactor</div>
<h4>Việc</h4>
<ul>
<li><span class="tid">R02-T01…T06</span> AtomicJsonFile · ConfigRepository · Typed Settings Facade · SecretStore + Keyring · chuyển API key · schema versioning</li>
<li><span class="tid">R08-T07</span> tách <code>settings_dialog.py</code> → 4 section widget</li>
<li><span class="tid">R08-T10</span> <code>bootstrap.py</code> + tách <code>MainWindow</code> → shell · tray · lifecycle <em>(cuối sprint, lắp factory của hai người kia)</em></li>
<li><span class="tid">R09-T01</span> tài liệu Security Policy Model</li>
<li>Chủ trì <b>CASAN Check 1</b> · giữ CI · duyệt PR của hai người</li>
</ul>
<h4>Sở hữu độc quyền</h4>
<ul class="paths">
<li>config.py</li>
<li>app.py → presentation/shell/</li>
<li>bootstrap.py</li>
<li>theme.py · i18n.py</li>
<li>infrastructure/config/ · secrets/ · persistence/</li>
<li>ui/settings_dialog.py → presentation/settings/</li>
<li>scripts/ · .gitea/workflows/</li>
</ul>
<p class="weight"><b>~2.700 dòng</b> · 727 settings + 1.352 app + 616 config<br>+ mục chung ở trên</p>
</div>
<div class="card one">
<div class="tag">Làn N2 · Hiệp</div>
<h3>Giám sát &amp; Quan trắc</h3>
<p class="who">Hiệp — 7 tab, việc lặp cần kỷ luật</p>
<div class="branch">gamma/refactor</div>
<h4>Việc</h4>
<ul>
<li><span class="tid">R08-T08</span> tách <code>monitoring_tab.py</code> → 7 tab độc lập</li>
<li><span class="tid">R09-T04</span> <code>CanonicalAuditLogger</code></li>
<li><span class="tid">R09-T05</span> <code>MonitoringQueryService</code> read-only, phân trang</li>
<li><span class="tid">R09-T02</span> gỡ vòng lặp <code>model_pricing</code> ↔ <code>usage_tracker</code></li>
<li><span class="tid">R09-T03</span> gỡ vòng lặp <code>agent_security</code> ↔ <code>alert</code></li>
<li><span class="tid">R09-T06</span> ma trận Sandbox theo hệ điều hành</li>
</ul>
<h4>Sở hữu độc quyền</h4>
<ul class="paths">
<li>ui/monitoring_tab.py → presentation/monitoring/</li>
<li>application/monitoring/</li>
<li>infrastructure/telemetry/ · sandbox/</li>
<li>core/audit_log.py</li>
<li>core/model_pricing.py · usage_tracker.py</li>
<li>core/agent_security*.py</li>
</ul>
<p class="weight"><b>~2.650 dòng</b> · 1.545 monitoring + ~1.100 core</p>
</div>
<div class="card two">
<div class="tag">Làn N3 · Lâm</div>
<h3>Co4E Studio</h3>
<p class="who">Lâm — canvas và luồng chạy workflow</p>
<div class="branch">gamma/refactor</div>
<h4>Việc</h4>
<ul>
<li><span class="tid">R07-T06</span> <code>Co4EWorkflowService</code> thuần Python</li>
<li><span class="tid">R08-T09</span> tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code> → canvas · node property · run control · chat view · agent list</li>
<li>Gọi tool qua <code>ToolPolicyGateway</code> của Team Hoa — dùng fake, không chờ</li>
</ul>
<h4>Sở hữu độc quyền</h4>
<ul class="paths">
<li>ui/co4e_tab.py → presentation/co4e/</li>
<li>ui/co4e_canvas.py</li>
<li>ui/co4e_config_panel.py</li>
<li>application/workflows/</li>
<li>domain/workflows/</li>
<li>core/co4e_run_manager.py</li>
</ul>
<p class="weight"><b>~2.880 dòng</b> · file to nhất của cả team</p>
</div>
</div>
<h2>Tám quy ước</h2>
<p class="sub">
Tám điều dưới đây là luật của team, Nam chốt. Bốn điều đầu là bắt buộc — trên một
nhánh chung, vi phạm không chỉ hại mình mà chặn cả hai người kia.
</p>
<div class="rules">
<div class="rule hard">
<h3>1 · Không chạm file dùng chung</h3>
<p>
<code>app.py</code>, <code>theme.py</code>, <code>i18n.py</code>, <code>config.py</code>,
<code>bootstrap.py</code> thuộc nhánh N1 của Nam. Cần thêm chuỗi hay token màu thì
<b>nhắn, đừng sửa</b> — Nam thêm trong ngày. Đây là ba file duy nhất có thể gây conflict
thật, và luật này xoá hẳn khả năng đó.
</p>
</div>
<div class="rule hard">
<h3>2 · Nộp factory, không tự lắp vào app</h3>
<p>
Mỗi nhánh expose một hàm dựng widget với chữ ký chốt từ ngày đầu, ví dụ
<code>build_monitoring_tab(ctx, query_service) -&gt; QWidget</code>. Nam gọi nó trong <code>bootstrap.py</code> ngày 28/08. Không ai tự sửa chỗ khởi tạo trong
<code>app.py</code>.
</p>
</div>
<div class="rule hard">
<h3>3 · Bị chặn thì dùng fake, không ngồi đợi</h3>
<p>
Chưa có <code>ConfigRepository</code> bản thật thì dùng <code>FakeConfigRepository</code>.
Chưa có <code>ToolPolicyGateway</code> của Team Hoa thì đã có fake sẵn. <b>Báo ngay trong ngày</b>
nếu thiếu fake nào — đó là việc của nhóm trưởng, không phải lý do dừng tay.
</p>
</div>
<div class="rule hard">
<h3>4 · Nhánh chung: kéo trước khi đẩy, đừng để nhánh đỏ</h3>
<p>
Cả ba đẩy vào <code>gamma/refactor</code>, nên không còn nhánh riêng làm vùng
đệm. Ba việc bắt buộc: <code>git pull --rebase</code> trước mỗi lần đẩy;
commit nhỏ và đẩy trong ngày, đừng ôm 500 dòng ba hôm; và
<b>không bao giờ đẩy thứ làm <code>pytest tests -q</code> đỏ</b> — nhánh hỏng
là hai người kia đứng hình. Lỡ đẩy nhầm thì sửa ngay hoặc
<code>git revert</code>, đừng để qua đêm.
</p>
</div>
<div class="rule soft">
<h3>5 · Commit nhỏ, mỗi ngày một lần</h3>
<p>
Một PR cho một sub-widget hoặc một service, không dồn 7 tab vào một PR cuối tuần. Nhóm
trưởng duyệt trong ngày. PR càng to thì rủi ro càng dồn về ngày 28/08.
</p>
</div>
<div class="rule soft">
<h3>6 · Mỗi commit kèm test, và không làm đỏ 90 test cũ</h3>
<p>
Baseline hiện tại: <b>102 test xanh trong 3,4 giây</b>. Chạy <code>pytest tests -q</code>
trước mỗi lần đẩy. Đây là lưới an toàn cho phần logic — giữ nó xanh suốt 10 ngày.
</p>
</div>
<div class="rule soft">
<h3>7 · File mới ≤ 400 dòng, không import PySide6 vào lõi</h3>
<p>
Hai điều kiện của CASAN Check 2 và 3. Tự kiểm trước khi đẩy — CI sẽ báo, nhưng biết
sớm thì đỡ phải tách lại lần hai.
</p>
</div>
<div class="rule soft">
<h3>8 · Checker UI thuộc phạm vi ai, người đó cập nhật</h3>
<p>
24 checker sẽ vỡ khi file bị dời. Ai dời file thì sửa checker tương ứng ngay trong
commit đó — tốn thêm khoảng 15% thời gian, đổi lại giữ được lưới an toàn cho phần
UI vừa làm xong.
<b>Đã chốt 21/08: đường A. Nam chịu trách nhiệm nếu đổi ý.</b>
</p>
</div>
</div>
<h2>Mỗi người nhận gì, giao gì</h2>
<p class="sub">
Cột trái là thứ phải có trong tay mới làm được, kèm nguồn. Cột phải là thứ bắt
buộc giao ra, kèm người nhận. Nhãn <span class="frm done">có rồi</span> nghĩa là
mục chung đã làm xong.
</p>
<div class="io">
<div class="iorow n1">
<div class="iohead"><b>N1 · Cấu hình, Bí mật &amp; Vỏ</b><span>Nam · nhóm trưởng</span></div>
<div class="iogrid">
<div>
<p class="iocap">Input — cần có</p>
<ul class="iolist">
<li><span class="frm">mã cũ</span><code>config.py</code> 616 dòng</li>
<li><span class="frm">mã cũ</span><code>ui/settings_dialog.py</code> 727 dòng</li>
<li><span class="frm">mã cũ</span><code>app.py</code> 1.352 dòng</li>
<li><span class="frm risk">tự chốt</span>Quyết định <code>api_key</code> — trước 26/08</li>
<li><span class="frm">từ Hiệp</span>Chữ ký <code>build_monitoring_tab()</code> — trước 28/08</li>
<li><span class="frm">từ Lâm</span>Chữ ký <code>build_co4e_tab()</code> — trước 28/08</li>
</ul>
</div>
<div>
<p class="iocap">Output — phải giao</p>
<ul class="iolist">
<li><span class="frm done">có rồi</span><code>SecretStore</code> · <code>ConfigRepository</code> + fake → <b>cho Hiệp và Lâm</b></li>
<li><span class="frm done">có rồi</span><code>scripts/audit_security.py</code> → cho CI</li>
<li><code>infrastructure/persistence/json/atomic_json_file.py</code></li>
<li><code>infrastructure/config/</code> — cài đặt thật + settings facade</li>
<li><code>infrastructure/secrets/keyring_adapter.py</code></li>
<li><code>presentation/settings/</code> — 4 widget</li>
<li><code>bootstrap.py</code> + <code>presentation/shell/</code> — 3 file</li>
<li><code>docs/architecture/security-policy.md</code></li>
</ul>
</div>
</div>
</div>
<div class="iorow n2">
<div class="iohead"><b>N2 · Giám sát</b><span>Hiệp</span></div>
<div class="iogrid">
<div>
<p class="iocap">Input — cần có</p>
<ul class="iolist">
<li><span class="frm">mã cũ</span><code>ui/monitoring_tab.py</code> 1.545 dòng</li>
<li><span class="frm">mã cũ</span><code>core/usage_tracker.py</code> 524 · <code>sandbox_manager.py</code> 335</li>
<li><span class="frm">mã cũ</span><code>core/model_pricing.py</code> 284 · <code>agent_security.py</code> 272 · <code>audit_log.py</code> 115</li>
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code> — dùng được ngay</li>
<li><span class="frm risk">tự chốt</span>Giữ nguyên 9 trường log, báo Duy và Hoa</li>
</ul>
</div>
<div>
<p class="iocap">Output — phải giao</p>
<ul class="iolist">
<li><code>build_monitoring_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
<li><code>FakeAuditLogger</code> · <code>FakeMonitoringQueryService</code> → <b>cho cả team</b></li>
<li><code>presentation/monitoring/</code> — 7 tab + shell</li>
<li><code>application/monitoring/monitoring_query_service.py</code></li>
<li><code>infrastructure/telemetry/audit_logger.py</code></li>
<li><code>infrastructure/sandbox/sandbox_capabilities.py</code></li>
<li><b>0 circular import</b> ở pricing ↔ usage và security ↔ alert</li>
</ul>
</div>
</div>
</div>
<div class="iorow n3">
<div class="iohead"><b>N3 · Co4E Studio</b><span>Lâm</span></div>
<div class="iogrid">
<div>
<p class="iocap">Input — cần có</p>
<ul class="iolist">
<li><span class="frm">mã cũ</span><code>ui/co4e_tab.py</code> 2.089 dòng</li>
<li><span class="frm">mã cũ</span><code>ui/co4e_canvas.py</code> 791 · <code>co4e_config_panel.py</code></li>
<li><span class="frm">mã cũ</span><code>core/co4e_run_manager.py</code> 331</li>
<li><span class="frm">có sẵn</span><code>core/co4e.py</code> — dataclass Workflow/Node/Edge đã có</li>
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code></li>
<li><span class="frm risk">từ Team Hoa</span>DTO <code>ToolPolicyGateway</code> — <b>rủi ro liên team cao nhất</b>, lấy trong hôm nay</li>
</ul>
</div>
<div>
<p class="iocap">Output — phải giao</p>
<ul class="iolist">
<li><code>build_co4e_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
<li><code>FakeCo4EWorkflowService</code> → <b>cho cả team</b></li>
<li><code>domain/workflows/</code> — DTO chốt ngày đầu</li>
<li><code>application/workflows/co4e_workflow_service.py</code></li>
<li><code>presentation/co4e/</code> — 5 phần</li>
</ul>
</div>
</div>
</div>
</div>
<h3>Output bắt buộc với cả ba, mỗi lần đẩy</h3>
<div class="scroll">
<table>
<thead><tr><th>Điều kiện</th><th>Ngưỡng</th><th>Tự kiểm bằng</th></tr></thead>
<tbody>
<tr><td>File mới sau khi tách</td><td class="mono">≤ 400 dòng</td><td class="mono">wc -l</td></tr>
<tr><td><code>domain/</code> và <code>application/</code> import PySide6</td><td class="mono">0</td><td class="mono">grep -r PySide6</td></tr>
<tr><td>Test hiện có</td><td class="mono">102 xanh</td><td class="mono">pytest tests -q</td></tr>
<tr><td>Credential lộ</td><td class="mono">0</td><td class="mono">python scripts/audit_security.py</td></tr>
<tr><td>Checker UI trong phạm vi mình dời</td><td>đã cập nhật</td><td class="mono">python tools/check_&lt;tên&gt;.py</td></tr>
</tbody>
</table>
</div>
<h2>Lịch từng ngày</h2>
<p class="sub">Ba hàng chạy độc lập. Hàng tô nền là lúc cả ba phải gặp nhau.</p>
<div class="scroll">
<table>
<thead>
<tr>
<th class="day">Ngày</th>
<th>N1 · Nam</th>
<th>N2 · Hiệp</th>
<th>N3 · Lâm</th>
</tr>
</thead>
<tbody>
<tr>
<td class="day">21/08<br><small>T6</small></td>
<td class="cl"><b>Mục chung</b> · dựng khung · interface + fake · chốt api_key · CASAN script<small>Merge trước khi hai người kia bắt đầu</small></td>
<td class="c1">Chốt schema log 9 trường<small>Giữ nguyên định dạng cũ để 24 chỗ gọi không phải sửa</small></td>
<td class="c2">Chốt chữ ký <code>Co4EWorkflowService</code><small>Nộp cho lead để lắp bootstrap sau</small></td>
</tr>
<tr>
<td class="day">22–23/08<br><small>T7–CN</small></td>
<td class="cl">AtomicJsonFile · ConfigRepository · Typed Settings Facade</td>
<td class="c1">CanonicalAuditLogger · gỡ vòng lặp pricing ↔ usage</td>
<td class="c2">Co4EWorkflowService — CRUD &amp; validate, test không cần Qt</td>
</tr>
<tr class="mark">
<td class="day">23/08<br><small>17:00</small></td>
<td colspan="3"><span class="pill cp">Checkpoint 1</span> &nbsp; 100% DTO và fake xong · <code>pytest</code> xanh · không ai bị chặn</td>
</tr>
<tr>
<td class="day">24/08<br><small>T2</small></td>
<td class="cl">Tách settings: provider + connector widget</td>
<td class="c1">3 tab đầu: overview · sandbox · security events</td>
<td class="c2">node_property_panel · agent_list_panel</td>
</tr>
<tr>
<td class="day">25/08<br><small>T3</small></td>
<td class="cl">Tách settings: routing + general widget</td>
<td class="c1">4 tab còn lại: MCP · action logs · agent status · security settings</td>
<td class="c2">co4e_canvas_widget — thao tác node</td>
</tr>
<tr>
<td class="day">26/08<br><small>T4</small></td>
<td class="cl">Chuyển API key sang SecretStore<small>Báo Team Duy trước khi đụng providers/</small></td>
<td class="c1">Lắp shell MonitoringTab · query service bản thật</td>
<td class="c2">Run control · chat view</td>
</tr>
<tr>
<td class="day">27/08<br><small>T5</small></td>
<td class="cl">Schema versioning · recovery policy</td>
<td class="c1">Ma trận Sandbox · gỡ vòng lặp agent_security</td>
<td class="c2">Lắp container Co4ETab · thay fake bằng service thật</td>
</tr>
<tr>
<td class="day">28/08<br><small>T6</small></td>
<td class="cl"><b>bootstrap.py + tách MainWindow</b><small>Nhận factory của Hiệp và Lâm để lắp</small></td>
<td class="c1">Nộp factory · dọn file &gt;400 dòng · cập nhật checker</td>
<td class="c2">Nộp factory · dọn file &gt;400 dòng · cập nhật checker</td>
</tr>
<tr class="mark">
<td class="day">28/08<br><small>17:00</small></td>
<td colspan="3"><span class="pill cp">Checkpoint 2</span> &nbsp; Tách xong 100% god file · 0 circular import</td>
</tr>
<tr>
<td class="day">29/08<br><small>T7</small></td>
<td class="cl">Tài liệu Security Policy · integration test Settings</td>
<td class="c1">Integration test Monitoring</td>
<td class="c2">Integration test luồng Co4E đầu-cuối</td>
</tr>
<tr class="mark">
<td class="day">30/08<br><small>CN 17:00</small></td>
<td colspan="3"><span class="pill gate">CASAN Gate</span> &nbsp; <b>Nam chủ trì Check 1</b> — quét toàn bộ config/JSON, phải ra 0 secret plaintext. Hiệp và Lâm sửa ngay phần của mình nếu script bắt được.</td>
</tr>
<tr class="mark">
<td class="day">31/08<br><small>T2 15:00</small></td>
<td colspan="3"><span class="pill ship">Bàn giao</span> &nbsp; Fix tồn đọng · cập nhật tài liệu kiến trúc · merge PR cuối · smoke test 5 luồng chính</td>
</tr>
</tbody>
</table>
</div>
<h2>Nghiệm thu: làm sao biết đã thật sự song song</h2>
<p class="sub">Không phải “đã họp xong” mà là chạy được. Ba câu hỏi, trả lời bằng lệnh.</p>
<div class="scroll">
<table>
<thead>
<tr><th>Câu hỏi</th><th>Cách trả lời</th><th>Khi nào</th></tr>
</thead>
<tbody>
<tr>
<td>Hiệp có chạy được khi chưa có config bản thật?</td>
<td>Dựng một tab Monitoring, chạy test của nó, <b>không import <code>cowork_local.config</code></b> dòng nào — chỉ dùng <code>FakeConfigRepository</code></td>
<td class="mono">21/08</td>
</tr>
<tr>
<td>Lâm có chạy được khi Team Hoa chưa xong gateway?</td>
<td>Test <code>Co4EWorkflowService</code> xanh với <code>FakeToolPolicyGateway</code></td>
<td class="mono">23/08</td>
</tr>
<tr>
<td>Ba người có đụng file nhau không?</td>
<td><code>git log --name-only --pretty=%an</code> trên <code>gamma/refactor</code> —
không file nào được xuất hiện dưới hai tên khác nhau</td>
<td class="mono">mỗi ngày</td>
</tr>
</tbody>
</table>
</div>
<footer>
Nguồn: <code>docs/refactor/plan.md</code>, <code>Refactoring_Checklist.md</code>,
<code>Feature_Architecture_Proposal.md</code>. Số dòng code, số lời gọi và baseline test đo
trực tiếp trên nhánh <code>main</code> ngày 21/08.
Mục chung, cách chia ba nhánh, bảy quy ước và mục nghiệm thu là đề xuất — không có trong
tài liệu gốc.
</footer>
</div>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
# Quyết định của Team Gamma
Team: **Nam** (nhóm trưởng, nhánh N1) · **Hiệp** (N2) · **Lâm** (N3).
Ghi ở đây thay vì chôn trong comment, vì cả ba đều ảnh hưởng ra ngoài phạm vi
một người.
| # | Việc | Trạng thái |
|---|---|---|
| 1 | `provider_conf()` còn trả `api_key` | **Chốt 21/08 — đường A** |
| 2 | Số phận 24 checker UI | **Chốt 21/08 — đường A** |
| 3 | DTO `ToolPolicyGateway` viết hộ Team Hoa | Đã làm, chờ Hoa xác nhận |
---
## Quyết định 1 — `provider_conf()` còn trả `api_key` hay không
### Vì sao phải quyết trước khi code
R02-T05 chuyển API key sang Keyring. Câu hỏi là sau khi chuyển, dict do
`provider_conf()` trả về **còn chứa `api_key` không**.
Có 5 nơi đang đọc trực tiếp — đo trên `main` ngày 21/08:
| Nơi đọc | Thuộc |
|---|---|
| `providers/anthropic.py:26` | **Team Duy** |
| `providers/openai_compat.py:36` | **Team Duy** |
| `core/image_gen.py:50` | Team Duy (routing/model) |
| `core/ext_connectors.py:98` | Team Hoa |
| `ui/ext_connector_dialog.py:87` | Team Gamma |
Ba trong năm nằm ngoài team. Quyết một mình rồi im lặng là làm vỡ code người khác.
### Hai đường
**A. Giữ `api_key` trong dict, `ConfigRepository` tự lấy từ `SecretStore` rồi ghép vào**
- 5 nơi đọc **không phải sửa dòng nào**
- Không cần báo team khác, không cần đồng bộ lịch
- Đổi lại: bí mật vẫn đi lang thang trong dict, dễ lọt vào log hoặc màn hình debug
- CASAN Check 1 vẫn PASS vì nó quét **file trên đĩa**, không quét bộ nhớ
**B. Bỏ `api_key` khỏi dict, ai cần thì gọi `secrets.get(provider_key(name))`**
- Sạch về nguyên tắc: bí mật chỉ xuất hiện đúng chỗ cần
- Đổi lại: **5 nơi phải sửa**, 3 trong đó phải chờ team khác xếp lịch
- Rủi ro: quên một chỗ thì mất API key lúc chạy thật, mà test có fake nên không bắt được
### Đề xuất
**Đường A cho sprint này, đường B ghi vào nợ kỹ thuật.**
Lý do: mục tiêu của cổng CASAN là *không còn secret nằm trên đĩa*, và đường A
đạt được điều đó. Đường B giải quyết thêm chuyện secret trong bộ nhớ — đúng
nhưng không phải việc của 10 ngày này, và nó kéo hai team khác vào một thay đổi
họ không lên kế hoạch.
Nếu chọn B thì **phải báo Team Duy và Team Hoa trong hôm nay**, không phải lúc
đã sửa xong.
> **Nam chốt 21/08: đường A.**
>
> Việc kèm theo: `ConfigRepository` bản thật phải đọc key từ `SecretStore` rồi
> ghép vào dict do `provider_conf()` trả về. Năm nơi đọc không đổi một dòng,
> nên **không cần báo Duy và Hoa**.
>
> Nợ kỹ thuật đã ghi: đường B (bỏ `api_key` khỏi dict) để sau sprint này.
---
## Quyết định 2 — số phận 24 checker UI
### Vấn đề
`tools/check_*.py` là bộ kiểm tra giao diện viết trong 2 tuần vừa rồi, hiện
**24 file**. Chúng bám vào đường dẫn cũ:
| Import | Số chỗ |
|---|---|
| `cowork_local.config` | 34 |
| `cowork_local.app` | 16 |
| `cowork_local.state` | 22 |
| `cowork_local.ui.*` | ~12 |
R08 dời hết những module đó sang `presentation/`. Nghĩa là **cả 24 checker chết
ngay ngày N1 đụng `config.py`** — và đó là lưới an toàn duy nhất cho phần giao
diện, vì `pytest` không kiểm giao diện (90 test hiện tại là logic).
### Ba đường
**A. Ai dời file thì cập nhật checker tương ứng, ngay trong PR đó**
- Giữ được lưới suốt 10 ngày
- Tốn thêm ~15% thời gian mỗi PR
- Rủi ro: người sửa vội có thể nới lỏng phép kiểm cho nó xanh — đã xảy ra một
lần trong quá trình làm UI, khi một checker được sửa thành *không thể đỏ*
**B. Đóng băng: bỏ khỏi CI, sửa một lượt ngày 31/08**
- Nhanh nhất trong 10 ngày
- Đổi lại: **không có gì canh hồi quy giao diện** suốt cả sprint. Refactor là lúc
dễ vỡ giao diện nhất
- Rủi ro cuối sprint: sửa 24 file cùng lúc, không ai nhớ cái nào đo gì
**C. Bỏ hẳn**
Không khuyến nghị. Vứt đi hai tuần công sức kiểm chứng, và ba tài liệu refactor
không có gì thay thế cho phần giao diện.
### Đề xuất
**Đường A**, kèm một ràng buộc: PR nào *sửa* checker phải nói rõ trong mô tả
**sửa gì và vì sao** — để việc nới lỏng phép kiểm không lọt qua review.
`tools/check_probes_bite.py` đã có sẵn cơ chế chứng minh checker còn cắn được;
chạy nó sau mỗi đợt sửa là bắt được ngay chuyện đó.
> **Chốt 21/08: đường A** — ai dời file thì cập nhật checker tương ứng ngay
> trong PR đó.
>
> Kèm hai ràng buộc, vì rủi ro của đường A là người sửa vội nới lỏng phép kiểm:
>
> 1. PR nào *sửa* checker phải nói rõ trong mô tả **sửa gì và vì sao**.
> 2. Sửa xong chạy `python tools/check_probes_bite.py` — nó cắm lỗi cố ý vào
> code rồi kiểm checker có bắt được không. Chính công cụ này đã từng bắt
> được một checker bị sửa thành *không thể đỏ*.
>
> Không đưa 24 checker vào CI trong sprint này: chúng dựng `MainWindow` thật,
> mỗi lần chạy tốn hàng chục giây và thỉnh thoảng sập lúc Qt dọn dẹp. Chạy tay
> theo phạm vi mình đụng là đủ.
---
## Quyết định 3 — Gamma viết hộ DTO `ToolPolicyGateway` cho Team Hoa
**Đã làm, chờ Hoa xác nhận.** Ngày: 21/08.
### Vì sao làm thay
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường:
| | Hệ quả |
|---|---|
| N3 ngồi đợi Hoa | Mất mấy ngày, trái nguyên tắc "không team nào chặn team nào" |
| N3 tự phỏng đoán | Phỏng đoán của một người, không ai soi, sửa lại chắc chắn |
| **Gamma viết bản đề xuất** | N3 chạy ngay, Hoa có cái cụ thể để duyệt hoặc sửa |
### Ranh giới không lấn
Sơ đồ phân hệ trong `plan.md` giao `domain/security/` cho **Team Gamma**, còn
`application/conversations/tool_policy_gateway.py` cho **Team Hoa**.
Nên chia đúng như vậy:
- **Gamma định nghĩa hình dạng** → `domain/security/tool_policy.py`
- **Hoa cài đặt gateway** → `application/conversations/tool_policy_gateway.py`,
nối vào `core/mcp_client.py` và tool dựng sẵn
Không đụng file nào của Hoa.
### Đã bám vào code đang chạy, không bịa
| Nguồn | Lấy gì |
|---|---|
| `core/agent_security.py::SecurityVerdict` | `allowed` · `reason` · `layer` |
| `ui/permission_dialog.py` + `chat_panel.py:1312` | trạng thái "hỏi người dùng" |
Khác biệt duy nhất: gộp thành **một câu trả lời ba trạng thái**
(`ALLOW` / `DENY` / `ASK`) thay vì bắt chỗ gọi tự nhớ hỏi hai nơi.
Hai ràng buộc đưa vào có chủ đích:
1. `DENY` và `ASK` **bắt buộc có `reason`** — người dùng cần biết vì sao, và
`audit_log` cần ghi lại. Thiếu là ném lỗi ngay lúc dựng, không phải lúc chạy.
2. `ASK` **không phải** `allowed` — bẫy dễ mắc nhất là coi ASK như ALLOW rồi tool
chạy mà chưa ai đồng ý. Có test riêng cho chuyện này.
### Gửi Hoa cái gì
> Bên mình viết trước bản đề xuất `ToolPolicyGateway` ở
> `domain/security/tool_policy.py` vì N3 cần gọi tool mà bên Hoa chưa bắt đầu —
> để N3 khỏi phải tự đoán. Ba kiểu: `ToolCallRequest`, `PolicyDecision`,
> `ToolPolicyGateway`. Phần cài đặt vẫn để bên Hoa ở
> `application/conversations/tool_policy_gateway.py`, bọn mình không đụng.
> Thấy chỗ nào không hợp thì sửa thẳng file đó, đừng tạo kiểu thứ hai. Đổi bây
> giờ còn rẻ vì mới mình N3 dùng.
> Đã gửi Hoa: ☐ — ngày ____ Hoa xác nhận: ☐ đồng ý ☐ có sửa
+16 -10
View File
@@ -135,16 +135,22 @@
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
- [ ] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
*Start: `2026-08-23 00:56` | End: `2026-08-23 01:00`*
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py` (+ `domain/agents/agent_event_codec.py` — shim dịch legacy dict, tách riêng để giữ LOC < 400 và để xoá gọn sau R08)
*Start: `2026-08-23 01:00` | End: `2026-08-23 01:08`*
- [x] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py` (+ `turn_runtime.py` định nghĩa 2 port/6 callable, `core_runtime_adapter.py` cầu nối sang `core/*`, `domain/agents/agent_result.py`)
*Start: `2026-08-23 01:08` | End: `2026-08-23 07:10`*
Chưa đổi call site nào — `run_cowork` giữ nguyên (Co4E vẫn dùng); việc chuyển call site là T04/T05. Bằng chứng tương đương: `tests/integration/test_conversation_service_parity.py` chạy cùng 1 script provider qua 2 đường và so khớp từng event/message/tool list trên 7 kịch bản.
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `2026-08-23 07:10` | End: `2026-08-23 07:23`*
`build_job` không còn gọi `run_cowork`: nó chụp state widget tại submit time ➔ `build_cowork_turn_request()` (mới, `application/conversations/cowork_turn_request.py`) ➔ `ConversationApplicationService`. Thêm `combine_instructions()` vào `turn_runtime.py` (project context + admin agent, T05 dùng lại) và tham số `messages=` cho `execute()` để service append vào **đúng list của widget** — `_reattach_running_turn` đọc list đó trong lúc turn đang chạy và `_finalize_turn` slice nó sau đó. Kiểm chứng: `tests/integration/test_cowork_tab_turn.py` gọi thẳng `CoworkTab.build_job` (widget stub, không cần Qt) và chạy turn thật với `FakeProvider`.
⚠️ `ui/cowork_tab.py` 416 ➔ 455 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R08.
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `2026-08-23 07:23` | End: `2026-08-23 07:31`*
Nhánh `task_type == "cowork"` của `_run_agent` gọi service thay vì `run_cowork`; 5 hành vi riêng của unattended run giữ nguyên (plan reminder, `history_ready`, autosave History mỗi `assistant_done`, timeout notice, `plan_incomplete_reason`). Tách `_unattended_prompt()` dùng `combine_instructions` để thứ tự reminder → skill → persona → prompt nằm ở 1 chỗ đọc được. Lưới an toàn: `tests/integration/test_task_executor_turn.py` viết **trước** khi migrate và pass 8/8 trên code cũ, vẫn pass sau khi migrate.
⚠️ `core/task_executors.py` 476 ➔ 524 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R07 (`application/scheduling/`).
Còn lại gọi `run_cowork`: `core/co4e_runner.py` (×2) và `ui/co4e_tab.py` — phân hệ Co4E của 🟣 Team Nam, R04 không chạm theo luật 1 file 1 team.
---
+1 -1
View File
@@ -1 +1 @@
"""Domain Layer: Pure Python domain entities, value objects, and events."""
"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng."""
+358
View File
@@ -0,0 +1,358 @@
"""Typed events a turn emits while it runs (R04-T02).
The runtime currently speaks in bare dicts: ``emit({"type": "tool_result", "id":
..., "ok": ...})``. Nothing declares which keys a given type carries, so the
only specification is the 130-line ``if/elif`` chain in
``ui/chat_panel.py::_on_event`` — and a typo in an emitter surfaces as a widget
that silently renders nothing.
This module makes the vocabulary explicit. Each event is a frozen dataclass with
real fields, and each one knows how to serialise itself back to the exact legacy
dict the widget already reads (:meth:`AgentEvent.to_legacy_dict`), with
:func:`from_legacy_dict` parsing the other way. That two-way bridge is what lets
R04 introduce typed events WITHOUT touching the presentation layer — decomposing
``_on_event`` into a renderer is R08-T01's job, and forcing both changes into one
PR is exactly the "rewrite everything at once" the refactor plan forbids.
Scope note: this covers the interactive/scheduled **Cowork turn** vocabulary
(the ``run_cowork`` path R04 unifies). Co4E's own node events (``node_status``,
``stage_text``, ``run_done``) belong to ``Co4EWorkflowService`` in R07-T06 and
are deliberately left as dicts here — :func:`from_legacy_dict` returns ``None``
for them so a bridge can pass them straight through.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
layer, standard library only. No PySide6, no ``core/*`` imports.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple
# Notice levels. "progress" is special-cased by the UI (it retargets the live
# thinking indicator instead of adding a bubble), so the vocabulary is pinned
# here rather than left to each emitter's string literal.
NOTICE_INFO = "info"
NOTICE_WARNING = "warning"
NOTICE_PROGRESS = "progress"
# --------------------------------------------------------------------------- #
# Value objects shared by several events.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolPreview:
"""The human-readable preview of a proposed tool call.
Mirrors ``core/tools.py::describe_action``'s return shape exactly (three
string keys, nothing else), so wrapping it in a type is lossless. ``kind``
drives which bubble the UI renders: "diff" -> coloured before/after,
"command" -> terminal block, "info" -> plain text.
"""
kind: str = "info"
title: str = ""
text: str = ""
def to_dict(self) -> Dict[str, str]:
return {"kind": self.kind, "title": self.title, "text": self.text}
@classmethod
def from_dict(cls, raw: Any) -> Optional["ToolPreview"]:
"""Parse a legacy preview dict; ``None`` when there was none.
A non-dict value degrades to ``None`` rather than raising: a malformed
preview must cost the user a nicer bubble, never the whole turn.
"""
if not isinstance(raw, dict) or not raw:
return None
return cls(kind=str(raw.get("kind", "info")), title=str(raw.get("title", "")),
text=str(raw.get("text", "")))
@dataclass(frozen=True)
class PlanStep:
"""One entry of the agent's ``update_plan`` checklist.
``status`` is kept a plain string on purpose: ``core/plan.py`` already owns
validation (clamping anything unknown to "pending" against
pending/running/done/error), and duplicating that vocabulary here would give
the app two sources of truth to drift apart.
"""
title: str
status: str = "pending"
def to_dict(self) -> Dict[str, str]:
return {"title": self.title, "status": self.status}
def _as_str_tuple(values: Iterable[Any]) -> Tuple[str, ...]:
"""Freeze an iterable of paths into a tuple of strings.
Emitters hand us live lists (``record["outputs"]``, ``_cleanup``'s result);
copying decouples the event from later mutation of that list.
"""
return tuple(str(v) for v in (values or ()))
# --------------------------------------------------------------------------- #
# Base class.
# --------------------------------------------------------------------------- #
class AgentEvent:
"""Base for every turn event.
Not a dataclass itself (it holds no data) — subclasses are the frozen
dataclasses. ``EVENT_TYPE`` is the legacy wire name, which stays the single
identifier shared between the typed world and the dict world.
"""
EVENT_TYPE: ClassVar[str] = ""
def _payload(self) -> Dict[str, Any]:
"""Type-specific keys of the legacy dict (without ``type``)."""
return {}
def to_legacy_dict(self) -> Dict[str, Any]:
"""The exact dict shape ``ui/chat_panel.py::_on_event`` dispatches on."""
return {"type": self.EVENT_TYPE, **self._payload()}
# --------------------------------------------------------------------------- #
# Streaming events.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TextChunkEvent(AgentEvent):
"""A fragment of the assistant's visible answer."""
EVENT_TYPE: ClassVar[str] = "text"
delta: str = ""
def _payload(self) -> Dict[str, Any]:
return {"delta": self.delta}
@dataclass(frozen=True)
class ReasoningChunkEvent(AgentEvent):
"""A fragment of a reasoning model's thinking, shown in a collapsed box."""
EVENT_TYPE: ClassVar[str] = "reasoning"
delta: str = ""
def _payload(self) -> Dict[str, Any]:
return {"delta": self.delta}
@dataclass(frozen=True)
class AssistantMessageCompletedEvent(AgentEvent):
"""One assistant message finished streaming.
Emitted once per provider call, so a tool-using turn produces SEVERAL of
these — it marks an autosave point, not the end of the turn. The end of the
turn is :class:`TurnCompletedEvent`.
"""
EVENT_TYPE: ClassVar[str] = "assistant_done"
content: str = ""
def _payload(self) -> Dict[str, Any]:
return {"content": self.content}
# --------------------------------------------------------------------------- #
# Tool-call lifecycle.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolCallStartedEvent(AgentEvent):
"""A tool call is about to run (after any security/permission gate).
Field names are the typed ones (``call_id``, ``arguments``); the legacy keys
``id``/``args`` are produced only at the serialisation boundary, so new code
never has to shadow the ``id`` builtin.
"""
EVENT_TYPE: ClassVar[str] = "tool_proposed"
call_id: str = ""
name: str = ""
arguments: Dict[str, Any] = field(default_factory=dict)
preview: Optional[ToolPreview] = None
def _payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"args": dict(self.arguments)}
# Omitted rather than sent as None: the widget does
# ``preview = ev.get("preview") or {}`` and an absent key is the shape it
# already handles for tools without a preview.
if self.preview is not None:
payload["preview"] = self.preview.to_dict()
return payload
@dataclass(frozen=True)
class ToolOutputChunkEvent(AgentEvent):
"""Live stdout/stderr from a running command, appended to its step bubble."""
EVENT_TYPE: ClassVar[str] = "tool_output"
call_id: str = ""
name: str = ""
delta: str = ""
def _payload(self) -> Dict[str, Any]:
return {"id": self.call_id, "name": self.name, "delta": self.delta}
@dataclass(frozen=True)
class ToolCallFinishedEvent(AgentEvent):
"""A tool call returned. ``path``/``produced`` name files it created."""
EVENT_TYPE: ClassVar[str] = "tool_result"
call_id: str = ""
name: str = ""
ok: bool = False
output: str = ""
path: str = "" # the single file this call wrote, if any
produced: Tuple[str, ...] = () # extra deliverables a command produced
def __post_init__(self) -> None:
# Callers pass a live list; freeze it so the event cannot change later.
object.__setattr__(self, "produced", _as_str_tuple(self.produced))
def _payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
# Both keys stay ABSENT when empty, matching what chat_agent emits today:
# downstream code tests them with ``ev.get(...)`` truthiness and iterates
# ``ev.get("produced", [])``, so adding empty values would be a change.
if self.path:
payload["path"] = self.path
if self.produced:
payload["produced"] = list(self.produced)
return payload
# --------------------------------------------------------------------------- #
# Side-channel events (plan, notices, output folder).
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PlanUpdatedEvent(AgentEvent):
"""The agent published a new version of its step checklist (full list)."""
EVENT_TYPE: ClassVar[str] = "plan_set"
steps: Tuple[PlanStep, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "steps", tuple(self.steps or ()))
def _payload(self) -> Dict[str, Any]:
return {"steps": [s.to_dict() for s in self.steps]}
@dataclass(frozen=True)
class NoticeEvent(AgentEvent):
"""An aside outside the model's own answer.
Three sources today: context auto-compaction (info), a blocked
security check (warning), and attachment reading progress (progress).
"""
EVENT_TYPE: ClassVar[str] = "notice"
text: str = ""
level: str = NOTICE_INFO
def _payload(self) -> Dict[str, Any]:
return {"level": self.level, "text": self.text}
@dataclass(frozen=True)
class OutputsAddedEvent(AgentEvent):
"""Deliverables appeared in the turn's output folder."""
EVENT_TYPE: ClassVar[str] = "outputs_added"
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
return {"paths": list(self.paths)}
@dataclass(frozen=True)
class OutputsRemovedEvent(AgentEvent):
"""Intermediate/generator files were cleaned up — drop them from Output."""
EVENT_TYPE: ClassVar[str] = "outputs_removed"
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
return {"paths": list(self.paths)}
@dataclass(frozen=True)
class HistoryReadyEvent(AgentEvent):
"""The turn's conversation now exists on disk and can be opened.
Emitted by the unattended (Schedule Task) path so the scheduler refreshes
History only once the session is really there.
"""
EVENT_TYPE: ClassVar[str] = "history_ready"
session_id: str = ""
def _payload(self) -> Dict[str, Any]:
return {"session_id": self.session_id}
# --------------------------------------------------------------------------- #
# Turn-level events introduced by R04 (no legacy consumer yet).
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TurnCompletedEvent(AgentEvent):
"""The whole turn ended — exactly once per turn.
Nothing consumes ``"turn_completed"`` yet: the widget's ``if/elif`` chain
simply has no branch for it, so emitting it is inert until R08 wires a
renderer. It exists now because the state it carries (was the turn
cancelled? did it hit the step ceiling?) is currently reconstructed by the
UI from side effects rather than being told to it.
"""
EVENT_TYPE: ClassVar[str] = "turn_completed"
final_text: str = ""
steps_used: int = 0
cancelled: bool = False
budget_exhausted: bool = False # stopped at effective_max_steps
def _payload(self) -> Dict[str, Any]:
return {"final_text": self.final_text, "steps_used": self.steps_used,
"cancelled": self.cancelled, "budget_exhausted": self.budget_exhausted}
@dataclass(frozen=True)
class ErrorEvent(AgentEvent):
"""The turn hit an error.
``recoverable`` separates "this turn is over" from "something failed but the
loop carried on" — a distinction the current code loses, because both end up
as a bare ``except Exception`` plus a text bubble.
"""
EVENT_TYPE: ClassVar[str] = "error"
message: str = ""
recoverable: bool = False
def _payload(self) -> Dict[str, Any]:
return {"message": self.message, "recoverable": self.recoverable}
__all__ = [
"NOTICE_INFO", "NOTICE_WARNING", "NOTICE_PROGRESS",
"AgentEvent", "ToolPreview", "PlanStep",
"TextChunkEvent", "ReasoningChunkEvent", "AssistantMessageCompletedEvent",
"ToolCallStartedEvent", "ToolOutputChunkEvent", "ToolCallFinishedEvent",
"PlanUpdatedEvent", "NoticeEvent", "OutputsAddedEvent", "OutputsRemovedEvent",
"HistoryReadyEvent", "TurnCompletedEvent", "ErrorEvent",
]
+123
View File
@@ -0,0 +1,123 @@
"""Legacy dict -> typed :mod:`agent_event` translation (R04-T02).
Kept in its own module for two reasons. It is a **temporary compatibility
shim**: once R08-T01 turns ``ui/chat_panel.py::_on_event`` into an event
renderer that consumes typed events directly, nothing needs to parse dicts any
more and this whole file gets deleted — a deletion that stays trivial only while
it is isolated. And it keeps ``agent_event.py`` inside the 400-LOC limit the
architecture rules impose, without diluting either file's single job: one
declares the vocabulary, the other bridges it to the old wire format.
Serialisation the other way lives on the events themselves
(``AgentEvent.to_legacy_dict``), because an event has to be emittable without
anyone importing a codec.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from .agent_event import (
AgentEvent,
AssistantMessageCompletedEvent,
ErrorEvent,
HistoryReadyEvent,
NOTICE_INFO,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
ToolPreview,
TurnCompletedEvent,
)
def _plan_steps_from_legacy(raw: Any) -> Tuple[PlanStep, ...]:
"""Parse the legacy ``steps`` list, dropping anything unusable.
A step with no title cannot be rendered or ticked off, so it is discarded
instead of becoming a blank row in the Plan panel.
"""
if not isinstance(raw, list):
return ()
steps: List[PlanStep] = []
for item in raw:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
if not title:
continue
steps.append(PlanStep(title=title, status=str(item.get("status", "pending"))))
return tuple(steps)
def _parse_tool_started(raw: Dict[str, Any]) -> ToolCallStartedEvent:
"""Rebuild a ``tool_proposed`` event, mapping ``id``/``args`` to typed names."""
args = raw.get("args")
return ToolCallStartedEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
arguments=dict(args) if isinstance(args, dict) else {},
preview=ToolPreview.from_dict(raw.get("preview")),
)
def _parse_tool_finished(raw: Dict[str, Any]) -> ToolCallFinishedEvent:
"""Rebuild a ``tool_result`` event; the optional file keys may be absent."""
return ToolCallFinishedEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
ok=bool(raw.get("ok", False)), output=str(raw.get("output", "")),
path=str(raw.get("path", "") or ""), produced=raw.get("produced") or (),
)
# One parser per wire name. A table (rather than an if/elif chain) keeps adding
# an event a single-line change and makes the supported set introspectable.
_PARSERS = {
TextChunkEvent.EVENT_TYPE: lambda raw: TextChunkEvent(delta=str(raw.get("delta", ""))),
ReasoningChunkEvent.EVENT_TYPE: lambda raw: ReasoningChunkEvent(
delta=str(raw.get("delta", ""))),
AssistantMessageCompletedEvent.EVENT_TYPE: lambda raw: AssistantMessageCompletedEvent(
content=str(raw.get("content", ""))),
ToolCallStartedEvent.EVENT_TYPE: _parse_tool_started,
ToolOutputChunkEvent.EVENT_TYPE: lambda raw: ToolOutputChunkEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
delta=str(raw.get("delta", ""))),
ToolCallFinishedEvent.EVENT_TYPE: _parse_tool_finished,
PlanUpdatedEvent.EVENT_TYPE: lambda raw: PlanUpdatedEvent(
steps=_plan_steps_from_legacy(raw.get("steps"))),
NoticeEvent.EVENT_TYPE: lambda raw: NoticeEvent(
text=str(raw.get("text", "")), level=str(raw.get("level", NOTICE_INFO))),
OutputsAddedEvent.EVENT_TYPE: lambda raw: OutputsAddedEvent(paths=raw.get("paths") or ()),
OutputsRemovedEvent.EVENT_TYPE: lambda raw: OutputsRemovedEvent(paths=raw.get("paths") or ()),
HistoryReadyEvent.EVENT_TYPE: lambda raw: HistoryReadyEvent(
session_id=str(raw.get("session_id", ""))),
TurnCompletedEvent.EVENT_TYPE: lambda raw: TurnCompletedEvent(
final_text=str(raw.get("final_text", "")), steps_used=int(raw.get("steps_used", 0) or 0),
cancelled=bool(raw.get("cancelled", False)),
budget_exhausted=bool(raw.get("budget_exhausted", False))),
ErrorEvent.EVENT_TYPE: lambda raw: ErrorEvent(
message=str(raw.get("message", "")), recoverable=bool(raw.get("recoverable", False))),
}
def from_legacy_dict(payload: Any) -> Optional[AgentEvent]:
"""Parse an emitted dict into a typed event, or ``None`` if it isn't ours.
``None`` (rather than an exception) is the contract that makes incremental
adoption possible: a bridge sitting between the runtime and the widget can
type the events it recognises and forward everything else — Co4E's node
events, or anything a future emitter adds — completely untouched.
"""
if not isinstance(payload, dict):
return None
parser = _PARSERS.get(str(payload.get("type", "")))
return parser(payload) if parser is not None else None
__all__ = ["from_legacy_dict"]
+86
View File
@@ -0,0 +1,86 @@
"""What one finished turn produced (R04-T03).
The outcome of a turn is currently spread over three shapes: ``run_cowork``
returns the mutated message list, ``task_executors._run_agent`` returns a
``(answer_text, timed_out, incomplete_reason)`` tuple, and the UI reconstructs
the rest (did it get cancelled? did it hit the ceiling?) from side effects. Each
caller therefore knows a slightly different amount about the same turn.
:class:`AgentResult` is the single answer. Frozen, like the request that started
the turn, so a result cannot be edited into disagreeing with what actually
happened.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
layer — standard library plus sibling domain types only.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Tuple
from .agent_event import PlanStep, TurnCompletedEvent
@dataclass(frozen=True)
class AgentResult:
"""The outcome of one conversation turn."""
# The conversation AFTER the turn (system prompt, history, the new user
# message, every assistant reply and tool result).
messages: Tuple[Dict[str, Any], ...] = ()
steps_used: int = 0 # provider calls this turn consumed
cancelled: bool = False # the user pressed Stop
budget_exhausted: bool = False # stopped at effective_max_steps
# The agent's final checklist, so a caller can ask "did it really finish?"
# (``core/plan.py::plan_incomplete_reason``) without replaying the events.
plan_steps: Tuple[PlanStep, ...] = ()
# Non-empty when the turn ended on a failure. A string rather than the
# exception: the domain layer must not depend on where the error came from,
# and the message is what every consumer (bubble, error.txt, audit) shows.
error: str = ""
def __post_init__(self) -> None:
"""Freeze the collections the runtime hands over.
Both arrive as live lists that the caller keeps appending to after the
turn (the UI merges messages back into its own history), so copying here
is what keeps a result a record rather than a moving target.
"""
object.__setattr__(self, "messages", tuple(self.messages or ()))
object.__setattr__(self, "plan_steps", tuple(self.plan_steps or ()))
@property
def final_text(self) -> str:
"""The answer to show the user.
Scans backwards for the last assistant message with real content, which
is not the same as ``messages[-1]``: a turn that was cancelled or that
ran out of steps mid-loop ends on a tool message, and a reasoning-only
reply leaves a blank assistant message behind. Same rule as
``core/task_executors.py::_last_assistant_text``, which this replaces.
"""
for message in reversed(self.messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@property
def ok(self) -> bool:
"""Whether the turn ran to a normal end.
Hitting the step ceiling still counts as ok: the agent did work and
produced an answer, it just was not allowed to keep going — which the
transcript says in its own note rather than by failing the turn.
"""
return not self.error and not self.cancelled
def to_turn_completed_event(self) -> TurnCompletedEvent:
"""The end-of-turn event carrying this outcome to subscribers."""
return TurnCompletedEvent(
final_text=self.final_text, steps_used=self.steps_used,
cancelled=self.cancelled, budget_exhausted=self.budget_exhausted,
)
__all__ = ["AgentResult"]
@@ -0,0 +1,222 @@
"""The immutable snapshot of ONE chat turn (R04-T01).
Today a turn's inputs live in a closure plus a 15-key ``ctx`` dict built inside
``ui/chat_panel.py::_start_turn``, and the worker thread reads the widget back
(``self._model``, ``self.title``, ``self.project_id``) while it runs. That is
the mechanism behind the whole class of "I changed the model mid-answer and the
running turn behaved oddly" reports: the turn has no snapshot of its own, so
every later click on the UI is visible to work already in flight.
:class:`ConversationExecutionRequest` is that missing snapshot. Everything the
runtime needs for one turn is captured once, on the UI thread, at submit time,
and then handed to code that runs on a worker thread. Frozen, so no caller —
widget or service — can retroactively change a decision the turn already acted
on.
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this is
the domain layer, so standard library only. No PySide6, no ``requests``, no
filesystem access, and deliberately no import of ``core/*`` — a request only
*describes* a turn; running it is the application layer's job
(``application/conversations/``).
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
# Separator between an instruction prefix (a ``/skill`` block, an ``/agent``
# persona) and the user's own request. Kept as a constant because the prefix is
# assembled in the presentation layer while the body is only known later on the
# worker thread — both halves must agree on the exact separator or the model
# sees a different prompt shape than it did before this refactor.
PREFIX_SEPARATOR = "\n\n---\n\n"
@dataclass(frozen=True)
class ConversationExecutionRequest:
"""Everything needed to execute one conversation turn.
Frozen for the reason above; use :meth:`with_model` / :meth:`with_output_dir`
to derive an adjusted copy rather than mutating one another thread may be
reading.
Note on depth: ``messages`` is a *shallow* snapshot (a tuple holding the
same message dicts the caller passed). That matches the existing
``snapshot = list(self.messages)`` semantics in ``_start_turn`` exactly —
the turn is protected from the history list being appended to or replaced,
which is what actually happens between turns. Making it deep would silently
change how ``_finalize_turn`` merges the turn's messages back, so the
stronger guarantee is left to R04-T03 where that merge moves.
"""
# -- identity ------------------------------------------------------- #
turn_id: str # unique within a session ("t1", "t2", ...)
session_id: str # the conversation this turn belongs to
surface: str = "cowork" # routing/mode key: "cowork" | "co4e" | "ai_edit"
project_id: str = "" # workspace the turn is confined to
title: str = "" # conversation title; also names saved files
# -- what the user asked -------------------------------------------- #
# The typed request, already stripped of any ``/skill`` or ``/agent``
# directive (those become ``instruction_prefix``).
prompt: str = ""
instruction_prefix: str = "" # skill rules + agent persona for this turn
# Prepended when the model/agent was switched mid-conversation, asking the
# model to re-check the previous step before continuing. Invisible in the
# chat bubble — it only travels in the payload sent to the provider.
review_note: str = ""
# Attachment PATHS, not their text: extracting a .docx can pip-install a
# parser or shell out to LibreOffice, which must not run on the UI thread.
# The runtime reads them later and passes the result to :meth:`user_content`.
attachments: Tuple[str, ...] = ()
# Conversation history as of submit time; the new user message is NOT part
# of it (the runtime appends it once the body is composed).
messages: Tuple[Dict[str, Any], ...] = ()
# -- which model answers -------------------------------------------- #
# Already resolved upstream: an Admin-agent pin, the tab's own picker, or a
# routing override published by ``RoutingApplicationService`` (R03). The
# runtime does not re-decide, so a switch cannot land mid-turn.
provider_id: str = ""
model: str = "" # "" = the provider's configured default
# -- standing instructions ------------------------------------------ #
project_context: str = "" # Claude-Projects-style shared instructions
session_notes: str = "" # e.g. files this conversation already produced
# -- tool scope and turn limits -------------------------------------- #
# None = every enabled built-in tool. An explicit (possibly empty) tuple
# restricts the ADVERTISED tools, which is how a "read-only" step is made
# literally unable to write.
allowed_tools: Optional[Tuple[str, ...]] = None
max_steps: int = 30 # interactive cap
completion_max_steps: int = 200 # runaway ceiling for run-to-completion work
run_to_completion: bool = False # Co4E flow steps need the higher ceiling
enforce_rules: bool = True # False for sandboxed Co4E runs
gate_mode: str = "auto" # "confirm" -> ask before run_command/install
agent_role: str = "cowork" # audit-log attribution ("cowork" | "task" | ...)
# -- where its files go ---------------------------------------------- #
output_dir: Optional[Path] = None # this turn's isolated sandbox
home_output_root: Optional[Path] = None # conversation Output root to promote into
# -- unattended execution (Schedule Task) ----------------------------- #
unattended: bool = False # no human watching; plan tracking is enforced
timeout_sec: Optional[int] = None # None = no wall-clock limit
# Escape hatch for surface-specific data a future task needs to thread
# through without another schema change (same role as
# ``ProviderDescriptor.extras``).
extras: Dict[str, Any] = field(default_factory=dict)
# -- validation / normalisation --------------------------------------- #
def __post_init__(self) -> None:
"""Reject unusable requests and freeze the mutable inputs.
Validation lives here (not at the call site) so a request that exists is
always safe to key by: the audit log, the History autosave and the
per-turn output folder are all named from ``session_id``/``turn_id``.
Normalisation matters just as much: the caller hands us the composer's
own attachment LIST and the live history LIST, and both get cleared or
appended to for the next turn. Copying them into tuples here is what
actually makes the snapshot a snapshot. ``object.__setattr__`` is the
standard way to do this in a frozen dataclass.
"""
if not (self.turn_id or "").strip():
raise ValueError("ConversationExecutionRequest.turn_id must not be empty")
if not (self.session_id or "").strip():
raise ValueError("ConversationExecutionRequest.session_id must not be empty")
object.__setattr__(self, "attachments", tuple(self.attachments or ()))
object.__setattr__(self, "messages", tuple(self.messages or ()))
# None must survive: it means "no restriction", while an empty tuple
# means "deny every built-in tool" — two very different turns.
if self.allowed_tools is not None:
object.__setattr__(self, "allowed_tools", tuple(self.allowed_tools))
# Accept str paths so a call site holding a config value does not have to
# wrap it; everything downstream can then assume Path.
for name in ("output_dir", "home_output_root"):
value = getattr(self, name)
if value is not None and not isinstance(value, Path):
object.__setattr__(self, name, Path(value))
# -- derived turn policy ---------------------------------------------- #
@property
def has_prompt(self) -> bool:
"""Whether the user actually typed something (an attachment-only turn
legitimately has none). Mirrors ``RoutingRequest.has_prompt`` so both
DTOs answer the "is there anything to work with?" question the same way.
"""
return bool((self.prompt or "").strip())
@property
def effective_max_steps(self) -> int:
"""The tool-use budget for this turn.
Run-to-completion work (a Co4E flow step whose single instruction may
need many tool calls) gets the higher ceiling; interactive chat keeps the
tight cap. Either way the turn still ends the moment the model stops
calling tools — this is only the runaway limit.
"""
return self.completion_max_steps if self.run_to_completion else self.max_steps
@property
def requires_permission_gate(self) -> bool:
"""Whether ``run_command``/``install_package`` must be approved first.
Resolved by the caller (per-workspace Auto-run override, else the global
"confirm before running commands" setting) and frozen here, so toggling
the setting mid-turn cannot change the rules the turn started under.
"""
return self.gate_mode == "confirm"
# -- prompt composition ------------------------------------------------ #
def user_content(self, body: str = "") -> str:
"""The exact ``content`` to send as this turn's user message.
``body`` is the request text AFTER attachment extraction, which happens
on the worker thread — hence a method taking it as an argument rather
than a stored field. The assembly order reproduces the closure in
``_start_turn`` byte for byte, because changing what a model receives is
a behaviour change, not a refactor:
1. session notes are appended after the body;
2. the instruction prefix goes in front, behind a fixed separator;
3. the model-switch review note goes ahead of everything.
"""
content = body or ""
notes = self.session_notes or ""
if notes:
# Guard the empty-body case (attachment-only turn) so the payload
# never opens with a stray blank line.
content = f"{content}\n\n{notes}" if content else notes
prefix = self.instruction_prefix or ""
if prefix:
content = f"{prefix}{PREFIX_SEPARATOR}{content}"
review = self.review_note or ""
if review:
content = f"{review}\n\n{content}"
return content
# -- derivation --------------------------------------------------------- #
def with_model(self, provider_id: str = "", model: str = "") -> "ConversationExecutionRequest":
"""A copy pinned to another provider/model.
Needed when a decision lands between building the request and running it
(a routing override, an Admin-agent pin). Deriving a new request keeps
the "one turn, one immutable snapshot" rule intact instead of patching a
request another thread may already hold.
"""
return replace(self, provider_id=provider_id or self.provider_id,
model=model or self.model)
def with_output_dir(self, output_dir) -> "ConversationExecutionRequest":
"""A copy writing into a different sandbox — used when the caller only
learns the per-turn folder after the request is assembled."""
return replace(self, output_dir=output_dir)
__all__ = ["PREFIX_SEPARATOR", "ConversationExecutionRequest"]
+110
View File
@@ -0,0 +1,110 @@
"""Cổng chính sách cho lời gọi tool — hình dạng dữ liệu, chưa phải cài đặt.
BẢN ĐỀ XUẤT, chờ Team Hoa xác nhận
==================================
Sơ đồ phân hệ trong ``plan.md`` giao ``domain/security/`` cho Team Gamma và
``application/conversations/tool_policy_gateway.py`` cho Team Hoa. Nên Gamma
định nghĩa *hình dạng*, Hoa *cài đặt*.
Viết trước vì N3 (Co4E) cần gọi tool và Team Hoa chưa bắt đầu. Không có nó thì
N3 phải tự phỏng đoán rồi sửa lại sau — mà phỏng đoán của một người thì tệ hơn
một đề xuất viết ra để cả hai bên soi.
Nếu Hoa thấy khác, sửa file này chứ đừng đẻ kiểu thứ hai. Đổi sớm rẻ hơn đổi
muộn: hiện chỉ N3 dùng.
Mô hình bám theo code đang chạy, không bịa:
* ``core/agent_security.py::SecurityVerdict`` — allowed / reason / layer
* ``ui/permission_dialog.py`` — hộp thoại hỏi người dùng khi
``ctx.project_confirm_commands()`` bật (``ui/chat_panel.py:1312``)
Điểm khác biệt duy nhất so với hôm nay: gộp hai thứ đó thành **một câu trả lời
ba trạng thái**, thay vì code gọi phải tự nhớ hỏi cả hai nơi.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, Protocol, runtime_checkable
class PolicyOutcome(str, Enum):
"""Ba trạng thái. ``ASK`` là thứ hệ thống hiện tại đã có (hộp thoại xin
phép) nhưng chưa được coi là một kết quả chính thức."""
ALLOW = "allow"
DENY = "deny"
ASK = "ask"
@dataclass(frozen=True)
class ToolCallRequest:
"""Một lời gọi tool đang chờ được duyệt.
``surface`` cho biết chỗ phát sinh — ``"cowork"``, ``"code"``, ``"co4e"``,
``"task"``. Chính sách khác nhau theo màn: Co4E chạy nền nên không thể bật
hộp thoại hỏi giữa chừng như Cowork.
"""
name: str
arguments: Dict[str, Any] = field(default_factory=dict)
surface: str = "cowork"
project_id: str = ""
#: True nếu tool đến từ MCP server ngoài, False nếu là tool dựng sẵn.
external: bool = False
@dataclass(frozen=True)
class PolicyDecision:
"""Câu trả lời của cổng.
``reason`` bắt buộc có khi DENY hoặc ASK — người dùng phải biết vì sao bị
chặn, và ``core/audit_log.py`` cần nó để ghi lại.
``layer`` giữ đúng từ vựng của ``SecurityVerdict``: ``"prompt"`` |
``"attachment"`` | ``"command"``, cộng thêm ``"policy"`` cho quyết định của
chính cổng này.
"""
outcome: PolicyOutcome
reason: str = ""
layer: str = "policy"
@property
def allowed(self) -> bool:
"""Tương thích với chỗ đang đọc ``SecurityVerdict.allowed``.
Chú ý: ``ASK`` KHÔNG phải allowed — còn phải hỏi người dùng đã.
"""
return self.outcome is PolicyOutcome.ALLOW
def __post_init__(self):
if self.outcome is not PolicyOutcome.ALLOW and not self.reason:
raise ValueError("DENY và ASK bắt buộc có reason — người dùng và "
"audit log đều cần biết vì sao")
def allow() -> PolicyDecision:
return PolicyDecision(PolicyOutcome.ALLOW)
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
return PolicyDecision(PolicyOutcome.ASK, reason, layer)
@runtime_checkable
class ToolPolicyGateway(Protocol):
"""Hỏi trước khi chạy tool. Cài đặt thật: Team Hoa (R07, hạn 29/08)."""
def check(self, request: ToolCallRequest) -> PolicyDecision:
"""Được chạy tool này không.
KHÔNG được tự bật hộp thoại bên trong — cổng chỉ *trả lời*, còn hỏi ai
và hỏi thế nào là việc của tầng giao diện. Có vậy thì Co4E chạy nền mới
dùng chung cổng được với Cowork chạy tương tác.
"""
...
View File
+1 -1
View File
@@ -1 +1 @@
"""Infrastructure Layer: External system adapters, persistence, and SDK clients."""
"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface."""
+104
View File
@@ -0,0 +1,104 @@
"""Cấu hình ứng dụng — interface, chưa phải cài đặt.
Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi
``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi
``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày
đầu ngồi không.
Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong
``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng
thật, xếp theo số lần gọi.
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Protocol, runtime_checkable
@runtime_checkable
class ConfigRepository(Protocol):
"""Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02)."""
# ---- provider ------------------------------------------------------
@property
def active_provider(self) -> str:
"""Tên provider đang chọn (24 lời gọi)."""
...
def set_active_provider(self, name: str) -> None:
...
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
"""Cấu hình của một provider (9 lời gọi).
CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``:
dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5
nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy.
"""
...
# ---- đường dẫn -----------------------------------------------------
@property
def shared_dir(self) -> str:
"""Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi)."""
...
def history_dir(self) -> Path:
"""Thư mục lịch sử chat của project đang chọn (7 lời gọi)."""
...
def cowork_output_dir(self) -> Path:
"""Thư mục Cowork ghi kết quả ra (6 lời gọi)."""
...
# ---- giao diện -----------------------------------------------------
@property
def theme(self) -> str:
"""``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi)."""
...
def set_theme(self, value: str) -> None:
...
@property
def language(self) -> str:
"""``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi)."""
...
def set_language(self, value: str) -> None:
...
# ---- các nhóm cấu hình còn lại -------------------------------------
@property
def routing(self) -> Dict[str, Any]:
"""Cấu hình định tuyến model (7 lời gọi)."""
...
@property
def auth(self) -> Dict[str, Any]:
"""Cấu hình đăng nhập (6 lời gọi)."""
...
@property
def agent_security(self) -> Dict[str, Any]:
"""Chính sách an toàn cho agent (5 lời gọi)."""
...
@property
def tools_disabled(self) -> list[str]:
"""Tool bị tắt (2 lời gọi)."""
...
def set_tool_enabled(self, name: str, enabled: bool) -> None:
...
# ---- ghi ------------------------------------------------------------
def save(self) -> None:
"""Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế —
nên tắt máy giữa chừng không làm hỏng file (R02-T01).
"""
...
@@ -0,0 +1,197 @@
"""ConfigRepository chạy trên file JSON — R02-T02.
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
đều là thứ ta muốn:
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
cấu hình (R02-T01).
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
nhìn từ ngoài phải y hệt.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Dict
from ..persistence.json.atomic_json_file import AtomicJsonFile
from ..secrets.secret_store import SecretStore, provider_key
from .schema_migration import CURRENT_VERSION, migrate
class JsonConfigRepository:
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
(R02-T05) chứ không phải đổi một phát cả app.
"""
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
defaults: Dict[str, Any] | None = None,
env_overrides=None):
self._file = AtomicJsonFile(path)
self._secrets = secrets
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
if defaults is None or env_overrides is None:
from ... import config as legacy
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
env_overrides = env_overrides or legacy._apply_env_overrides
self._defaults = defaults
self._env_overrides = env_overrides
self.data: Dict[str, Any] = self._load()
# ---- nạp ------------------------------------------------------------
def _load(self) -> Dict[str, Any]:
merged = copy.deepcopy(self._defaults)
stored = self._file.read(default=None)
if isinstance(stored, dict):
# Nâng cấp TRƯỚC khi trộn với mặc định: bước v1→v2 gỡ api_key khỏi
# đĩa, mà mặc định thì không có khoá nào để gỡ.
stored, changed = migrate(stored, secrets=self._secrets,
path=self._file.path)
merged = _deep_merge(merged, stored)
if changed:
self.data = merged
self.save() # ghi ngay, để lần sau khỏi chuyển lại
merged = self._env_overrides(merged)
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
merged.setdefault("ms365", {})["unlocked"] = False
return merged
def reload(self) -> None:
self.data = self._load()
# ---- provider --------------------------------------------------------
@property
def active_provider(self) -> str:
return self.data.get("active_provider", "")
def set_active_provider(self, name: str) -> None:
self.data["active_provider"] = name
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
"""Cấu hình provider, có sẵn ``api_key``.
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
"""
name = name or self.active_provider
conf = dict(self.data.get("providers", {}).get(name, {}))
if self._secrets is not None:
stored = self._secrets.get(provider_key(name))
if stored:
conf["api_key"] = stored
return conf
def set_api_key(self, name: str, value: str) -> None:
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
"""
if self._secrets is not None:
self._secrets.set(provider_key(name), value)
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
else:
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
# ---- đường dẫn -------------------------------------------------------
@property
def shared_dir(self) -> str:
return self.data.get("shared_dir", "")
def history_dir(self) -> Path:
rt = self.data.get("_project_history_dir")
if rt:
return Path(rt)
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
if custom:
return Path(custom).expanduser()
from ...config import CONFIG_DIR
return CONFIG_DIR / "history"
def cowork_output_dir(self) -> Path:
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
if custom:
return Path(custom).expanduser()
from ... import paths
from ...config import CONFIG_DIR
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "output"
return CONFIG_DIR / "output" / "cowork"
# ---- giao diện -------------------------------------------------------
@property
def theme(self) -> str:
return self.data.get("theme", "dark")
def set_theme(self, value: str) -> None:
self.data["theme"] = value
@property
def language(self) -> str:
return self.data.get("language", "vi")
def set_language(self, value: str) -> None:
self.data["language"] = value
# ---- nhóm cấu hình ---------------------------------------------------
@property
def routing(self) -> Dict[str, Any]:
return self.data.setdefault("routing", {})
@property
def auth(self) -> Dict[str, Any]:
return self.data.setdefault("auth", {})
@property
def agent_security(self) -> Dict[str, Any]:
return self.data.setdefault("agent_security", {})
@property
def tools_disabled(self) -> list[str]:
return list(self.data.get("tools_disabled", []))
def set_tool_enabled(self, name: str, enabled: bool) -> None:
disabled = list(self.data.get("tools_disabled", []))
if enabled:
disabled = [t for t in disabled if t != name]
elif name not in disabled:
disabled.append(name)
self.data["tools_disabled"] = disabled
# ---- ghi -------------------------------------------------------------
def save(self) -> None:
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
to_write = self.data
if self.data.get("ms365", {}).get("unlocked"):
to_write = copy.deepcopy(self.data)
to_write["ms365"]["unlocked"] = False
to_write.pop("_project_history_dir", None)
to_write["schema_version"] = CURRENT_VERSION
self._file.write(to_write)
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
"""
out = copy.deepcopy(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], value)
else:
out[key] = value
return out
+134
View File
@@ -0,0 +1,134 @@
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
khoá ``office`` nghĩa là file cũ".
Ở đây đặt luật rõ:
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
file mới có thể hỏng — phải còn đường về.
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
đoán ngược.
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
``SecretStore``.
"""
from __future__ import annotations
import copy
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict
from ..secrets.secret_store import SecretStore, provider_key
log = logging.getLogger(__name__)
#: Phiên bản app hiện đang ghi ra.
CURRENT_VERSION = 2
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
ASSUMED_VERSION = 1
def read_version(data: Dict[str, Any]) -> int:
try:
return int(data.get("schema_version", ASSUMED_VERSION))
except (TypeError, ValueError):
return ASSUMED_VERSION
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
"""Chuyển API key từ file sang kho bí mật — R02-T05.
Không có kho bí mật thì **không chuyển**: 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. File giữ
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
"""
if secrets is None or not getattr(secrets, "available", True):
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
return data
out = copy.deepcopy(data)
moved = []
for name, conf in (out.get("providers") or {}).items():
if not isinstance(conf, dict):
continue
key = (conf.get("api_key") or "").strip()
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
# dung. Đẩy nó vào keyring chỉ tổ rác.
if not key or key == "ollama":
continue
secrets.set(provider_key(name), key)
conf["api_key"] = ""
moved.append(name)
out["schema_version"] = 2
if moved:
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
return out
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
1: _v1_to_v2,
}
def backup(path: Path) -> Path | None:
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
if not path.exists():
return None
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
try:
shutil.copy2(path, target)
return target
except OSError as exc:
log.warning("không sao lưu được %s: %s", path, exc)
return None
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
khỏi phải ghi lại đĩa.
"""
version = read_version(data)
if version > CURRENT_VERSION:
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
version, CURRENT_VERSION)
return data, False
if version == CURRENT_VERSION:
return data, False
if path is not None:
backup(path)
changed = False
while version < CURRENT_VERSION:
step = STEPS.get(version)
if step is None:
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
break
data = step(data, secrets)
new_version = read_version(data)
if new_version <= version:
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
# keyring). Dừng, đừng lặp vô hạn.
break
version = new_version
changed = True
return data, changed
+178
View File
@@ -0,0 +1,178 @@
"""Khung nhìn có kiểu cho từng nhóm cấu hình — R02-T03.
Vấn đề đang có: khắp nơi viết ``ctx.config.routing.get("switch_mode", "off")``.
Gõ sai một chữ thì lặng lẽ nhận giá trị mặc định, không ai biết cho tới khi
tính năng "không hiểu sao không chạy". Đếm được **156 lời gọi ``ctx.config.*``
trong 29 file** kiểu đó.
Ở đây mỗi nhóm cấu hình có một lớp: gõ sai tên thuộc tính là lỗi ngay, và kiểu
dữ liệu ghi rõ ràng nên đọc code là biết ``confirm_timeout_sec`` là số giây
chứ không phải mili giây.
Cố ý KHÔNG dùng dataclass đông cứng: đây là *khung nhìn* lên dict cấu hình
sống, sửa qua đây là sửa vào dict rồi ``save()`` là xuống đĩa. Sao chép thành
dataclass thì lại sinh chuyện đồng bộ hai chiều.
"""
from __future__ import annotations
from typing import Any, Dict
class _View:
"""Khung nhìn lên một nhánh của dict cấu hình."""
def __init__(self, data: Dict[str, Any]):
self._d = data
def _get(self, key: str, default: Any) -> Any:
value = self._d.get(key, default)
return default if value is None else value
def raw(self) -> Dict[str, Any]:
"""Dict gốc — dùng khi cần đọc khoá chưa được đưa vào khung nhìn.
Có mặt để không ai bị kẹt: thiếu thuộc tính thì dùng tạm ``raw()`` rồi
mở issue bổ sung, chứ đừng vòng lại ``ctx.config.data``.
"""
return self._d
class ProviderSettings(_View):
"""Một provider: đi đâu, model nào, khoá nào.
``api_key`` ở đây là thứ ``JsonConfigRepository.provider_conf()`` đã ghép
sẵn từ kho bí mật — xem đường A trong ``GammaTeam_decisions.md``.
"""
@property
def base_url(self) -> str:
return str(self._get("base_url", ""))
@property
def model(self) -> str:
return str(self._get("model", ""))
@property
def api_key(self) -> str:
return str(self._get("api_key", ""))
@property
def configured(self) -> bool:
"""Đủ thông tin để gọi được chưa.
Ollama chạy cục bộ nên không cần khoá — đó là lý do điều kiện là
"có base_url và model", không phải "có api_key".
"""
return bool(self.base_url and self.model)
class RoutingSettings(_View):
"""Định tuyến model tự động (``core/routing/``)."""
@property
def switch_mode(self) -> str:
"""``"off"`` | ``"auto"`` | ``"manual"``."""
return str(self._get("switch_mode", "off"))
@switch_mode.setter
def switch_mode(self, value: str) -> None:
self._d["switch_mode"] = value
@property
def enabled(self) -> bool:
return self.switch_mode != "off"
@property
def policy(self) -> str:
"""``"balanced"`` | ``"cheap"`` | ``"quality"``…"""
return str(self._get("policy", "balanced"))
@property
def min_score_gain(self) -> float:
"""Phải hơn model hiện tại bao nhiêu điểm mới đáng đổi."""
return float(self._get("min_score_gain", 0.05))
@property
def confirm_timeout_sec(self) -> int:
"""GIÂY, không phải mili giây — đọc tên là biết, khỏi phải mò."""
return int(self._get("confirm_timeout_sec", 60))
@property
def reassess_interval_hours(self) -> int:
return int(self._get("reassess_interval_hours", 24))
@property
def per_provider_concurrency(self) -> int:
return int(self._get("per_provider_concurrency", 2))
@property
def judge_provider(self) -> str:
return str(self._get("judge_provider", ""))
@property
def judge_model(self) -> str:
return str(self._get("judge_model", ""))
class SecuritySettings(_View):
"""Chính sách an toàn cho agent (``core/agent_security.py``)."""
@property
def enabled(self) -> bool:
return bool(self._get("enabled", True))
@property
def validate_prompt(self) -> bool:
return bool(self._get("validate_prompt", True))
@property
def validate_attachments(self) -> bool:
return bool(self._get("validate_attachments", True))
@property
def validate_commands(self) -> bool:
return bool(self._get("validate_commands", True))
@property
def command_ai_check(self) -> bool:
return bool(self._get("command_ai_check", False))
@property
def cowork_confirm_commands(self) -> bool:
"""Có hỏi trước khi chạy lệnh không.
Ứng với ``PolicyOutcome.ASK`` trong
``domain/security/tool_policy.py``.
"""
return bool(self._get("cowork_confirm_commands", True))
@property
def rules_onedrive_url(self) -> str:
return str(self._get("rules_onedrive_url", ""))
@property
def admin_email(self) -> str:
return str(self._get("admin_email", ""))
class Settings:
"""Cửa vào duy nhất cho các nhóm cấu hình có kiểu.
>>> s = Settings(repo)
>>> if s.routing.enabled and s.provider().configured:
... ...
"""
def __init__(self, repo):
self._repo = repo
def provider(self, name: str | None = None) -> ProviderSettings:
return ProviderSettings(self._repo.provider_conf(name))
@property
def routing(self) -> RoutingSettings:
return RoutingSettings(self._repo.routing)
@property
def security(self) -> SecuritySettings:
return SecuritySettings(self._repo.agent_security)
@@ -0,0 +1,131 @@
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
— và người dùng mất toàn bộ cấu hình.
Cách làm ở đây theo đúng thứ tự bắt buộc:
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
bộ đệm của hệ điều hành
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
bao giờ có trạng thái ghi dở.
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
đúng như ``config.py`` hiện tại đang làm.
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from datetime import datetime
from pathlib import Path
from typing import Any
class AtomicJsonFile:
"""Một file JSON, đọc ghi an toàn.
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
>>> f.write({"theme": "dark"})
>>> f.read(default={})
{'theme': 'dark'}
"""
def __init__(self, path: Path, *, indent: int = 2):
self.path = Path(path)
self.indent = indent
# ---- đọc ------------------------------------------------------------
def read(self, default: Any = None) -> Any:
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
"""
if not self.path.exists():
return default
try:
return json.loads(self.path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
self._quarantine()
return default
except OSError:
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
# có thể vẫn tốt nguyên.
return default
def _quarantine(self) -> Path | None:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
try:
os.replace(self.path, target)
return target
except OSError:
return None
# ---- ghi ------------------------------------------------------------
#: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây).
_REPLACE_TRIES = 6
_REPLACE_BACKOFF = 0.02
@classmethod
def _replace_ben_bi(cls, src: Path, dst: Path) -> None:
"""``os.replace`` có thử lại — bắt buộc trên Windows.
MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ
handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là
Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục
mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết.
Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức
khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh
thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện.
POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt.
"""
for lan in range(cls._REPLACE_TRIES):
try:
os.replace(src, dst)
return
except PermissionError:
if lan == cls._REPLACE_TRIES - 1:
raise
time.sleep(cls._REPLACE_BACKOFF * (2 ** lan))
def write(self, data: Any) -> None:
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
self.path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
# thành copy + delete — mất luôn tính nguyên tử.
fd, tmp_name = tempfile.mkstemp(
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại
except BaseException:
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
# để rác .tmp nằm lại cạnh file cấu hình.
tmp.unlink(missing_ok=True)
raise
# ---- tiện ích -------------------------------------------------------
def exists(self) -> bool:
return self.path.exists()
def __repr__(self) -> str:
return f"AtomicJsonFile({self.path})"
View File
+86
View File
@@ -0,0 +1,86 @@
"""SecretStore chạy trên OS Keyring — R02-T04.
Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret
Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong
``config.json`` — đó là điều kiện để qua CASAN Check 1.
Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có
Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao
giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về
None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app.
"""
from __future__ import annotations
import logging
log = logging.getLogger(__name__)
#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây.
SERVICE = "cowork-local"
class KeyringAdapter:
"""Cài đặt :class:`SecretStore` bằng thư viện ``keyring``.
>>> store = KeyringAdapter()
>>> if store.available:
... store.set("provider:openai", "sk-...")
"""
def __init__(self, service: str = SERVICE):
self.service = service
self._backend = None
self._available = False
try:
import keyring
from keyring.backends.fail import Keyring as FailKeyring
backend = keyring.get_keyring()
# backend "fail" là cái keyring trả về khi không tìm được kho nào
# dùng được — gọi vào chỉ tổ ném lỗi.
if not isinstance(backend, FailKeyring):
self._backend = keyring
self._available = True
else:
log.info("keyring không có kho khả dụng trên máy này")
except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus…
log.info("keyring không dùng được: %s", exc)
@property
def available(self) -> bool:
"""Có kho bí mật dùng được không.
Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key
không lưu được, thay vì im lặng làm mất khoá họ vừa nhập.
"""
return self._available
# ---- SecretStore ----------------------------------------------------
def get(self, key: str) -> str | None:
if not self._available:
return None
try:
return self._backend.get_password(self.service, key)
except Exception as exc: # noqa: BLE001
log.warning("đọc khoá %r thất bại: %s", key, exc)
return None
def set(self, key: str, value: str) -> None:
if not self._available:
log.warning("không lưu được %r: máy này không có kho bí mật", key)
return
try:
self._backend.set_password(self.service, key, value)
except Exception as exc: # noqa: BLE001
log.warning("lưu khoá %r thất bại: %s", key, exc)
def delete(self, key: str) -> None:
if not self._available:
return
try:
self._backend.delete_password(self.service, key)
except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua
pass
def has(self, key: str) -> bool:
return self.get(key) is not None
+46
View File
@@ -0,0 +1,46 @@
"""Nơi cất credential — interface, chưa phải cài đặt.
Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không
phải đợi bản Keyring thật (R02-T04, hạn 26/08).
Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring —
chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật.
Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ.
Quy ước đặt key: ``"provider:<tên>"`` cho API key của provider, ví dụ
``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
def provider_key(name: str) -> str:
"""Key chuẩn cho API key của một provider."""
return f"provider:{name}"
@runtime_checkable
class SecretStore(Protocol):
"""Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04)."""
def get(self, key: str) -> str | None:
"""Giá trị của ``key``, hoặc None nếu chưa có.
Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người
dùng chưa nhập API key), không phải sự cố.
"""
...
def set(self, key: str, value: str) -> None:
"""Lưu ``value``. Ghi đè nếu key đã tồn tại."""
...
def delete(self, key: str) -> None:
"""Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi."""
...
def has(self, key: str) -> bool:
"""Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không
cần đọc chính giá trị bí mật ra."""
...
+1 -1
View File
@@ -1 +1 @@
"""Presentation Layer: PySide6 UI widgets, dialogs, and shell views (<400 LOC per file)."""
"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure."""
+204
View File
@@ -0,0 +1,204 @@
"""CASAN Check 1 — không được có credential nào nằm phơi trong repo.
Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên
tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới
chạy lần đầu rồi mới biết còn sót.
Quét gì:
* file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env``
* mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret
Tìm hai loại:
1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…)
2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder
Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng
không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung).
Chạy: python scripts/audit_security.py [--json]
Mã thoát: 0 = sạch, 1 = có phát hiện.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build",
"dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"}
CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"}
# tên trường coi là nhạy cảm
SENSITIVE = re.compile(
r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|"
r"access[_-]?key|private[_-]?key|credential)", re.I)
# hình dạng credential thật — bắt được kể cả khi tên trường vô hại
SHAPES = [
("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")),
("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")),
("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")),
("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
]
#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng
#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm.
ALLOW_MARK = re.compile(r"#\s*casan:\s*allow")
#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng
#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ.
LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test
#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo.
MIN_SECRET_LEN = 12
#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt…
ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off",
"true", "false", "yes", "no", "allow", "deny", "block", "ask",
"always", "never", "auto", "default", "disabled", "enabled"}
# giá trị vô hại — không tính là phát hiện
PLACEHOLDER = re.compile(
r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|"
r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|"
r"sk-\.\.\.|\.\.\.)$", re.I)
# gán chuỗi trong Python: api_key = "..."
PY_ASSIGN = re.compile(
r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*"""
r"""["']([^"']*)["']""", re.I)
def _is_placeholder(value: str) -> bool:
v = value.strip()
if PLACEHOLDER.match(v) or v.lower() in ENUMISH:
return True
if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output"
return True
# quá ngắn để là credential thật
return len(v) < MIN_SECRET_LEN
def _walk():
for path in REPO.rglob("*"):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.parts):
continue
if path.suffix in CONFIG_SUFFIX or path.suffix == ".py":
yield path
def scan() -> list[dict]:
findings: list[dict] = []
for path in _walk():
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
rel = path.relative_to(REPO).as_posix()
for lineno, line in enumerate(text.splitlines(), 1):
if ALLOW_MARK.search(line):
continue
# 1. hình dạng credential thật
for label, pattern in SHAPES:
m = pattern.search(line)
if m:
findings.append({
"file": rel, "line": lineno, "kind": f"{label} credential",
"evidence": m.group(0)[:12] + "…",
})
# 2. trường nhạy cảm có giá trị
for m in PY_ASSIGN.finditer(line):
field, value = m.group(1), m.group(2)
if not SENSITIVE.search(field) or _is_placeholder(value):
continue
findings.append({
"file": rel, "line": lineno,
"kind": f"trường '{field}' có giá trị",
"evidence": value[:6] + "…" if len(value) > 6 else value,
})
return findings
def _self_test() -> int:
"""Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không."""
import tempfile
bad = {
"OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script
"GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script
"AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script
"Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script
}
ok = {
"rỗng": '"api_key": ""',
"placeholder": '"api_key": "your-key-here"',
"ollama": '"api_key": "ollama"',
"test ngắn": 'api_key = "x"',
"hằng liệt kê": '"secret_in_output": "warning"',
}
global REPO
keep = REPO
passed = True
with tempfile.TemporaryDirectory() as tmp:
REPO = Path(tmp)
for label, line in {**bad, **ok}.items():
(REPO / "probe.py").write_text(line + "\n", encoding="utf-8")
found = bool(scan())
want = label in bad
mark = "OK " if found == want else "SAI"
if found != want:
passed = False
verb = "bắt được" if found else "bỏ qua"
print(f" [{mark}] {label:14} -> {verb}")
REPO = keep
print()
print("Tự kiểm: " + ("script phân biệt đúng." if passed
else "*** script phân biệt SAI ***"))
return 0 if passed else 1
def main() -> int:
ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ")
ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON")
ap.add_argument("--self-test", action="store_true",
help="cắm credential giả vào file tạm, kiểm script có bắt được")
args = ap.parse_args()
if args.self_test:
return _self_test()
findings = scan()
if args.json:
print(json.dumps(findings, ensure_ascii=False, indent=2))
else:
n_files = sum(1 for _ in _walk())
print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/")
if not findings:
print("\n0 credential lưu plaintext. PASS.")
else:
print(f"\n*** {len(findings)} phát hiện ***\n")
for f in findings:
print(f" {f['file']}:{f['line']}")
print(f" {f['kind']} — {f['evidence']}")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
+13 -4
View File
@@ -1,5 +1,14 @@
"""Test doubles and offline fakes package for Cowork Local test pyramid."""
from .fake_provider import FakeProvider
from .fake_tool_executor import FakeToolExecutor
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.
__all__ = ["FakeProvider", "FakeToolExecutor"]
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
"""
+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
+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",
]
@@ -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,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"]
+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"
+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"
+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"),)
@@ -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}]
+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 == ""
+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"
+58 -19
View File
@@ -346,19 +346,45 @@ class CoworkTab(ChatPanel):
self._apply_output_folder_label() # picks up edits made via Settings too
def build_job(self, text: str, messages, out_dir):
# Each turn writes into its OWN isolated folder (out_dir) and works on its
# OWN message list, so several turns can run in parallel without clobbering
# each other's files or history. Deliverables are moved up to the session
# Output root when the turn finishes (see _cleanup_turn).
"""This turn's job: a frozen request run through the conversation service.
Since R04-T04 the widget no longer drives the turn loop. Every value a
turn depends on is read HERE, on the UI thread at submit time, and packed
into an immutable ``ConversationExecutionRequest`` — so clicking a
different model or switching workspace mid-answer cannot reach work
already in flight.
"""
output_dir = out_dir or self._session_output_dir()
# The sandbox folder is named by the turn id ('.turns/t3'); with no
# sandbox the session id identifies the turn well enough for the audit log.
turn_id = out_dir.name if out_dir is not None else self.session_id
session_id = self.session_id
title = self.title
project_id = self.project_id
home_output_root = self.workspace_dir()
# Captured at submit time (UI thread): the Admin-defined agent
# preset's instructions, if one is selected in the Agent picker.
agent_prompt = self.admin_agent_prompt()
# Per-workspace Auto-run override wins, else the global "confirm before
# running commands" setting. Frozen now, so a Settings change mid-turn
# cannot flip the rules this turn started under.
confirm_commands = self.ctx.project_confirm_commands()
# What the turn is recorded as running on. A routing override (R03) wins
# over the tab's own picker; '' means the provider's configured default.
# Informational only — an Admin-agent preset builds its own provider
# below, so treat these as the record, not the decision.
provider_id = self._routed_provider or self.ctx.config.active_provider
model = self._routed_model or self._model or ""
def job(worker: AgentWorker):
from ..core.chat_agent import run_cowork
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from ..application.conversations.cowork_turn_request import (
build_cowork_turn_request,
)
from ..application.conversations.turn_runtime import combine_instructions
from ..core.projects import load_project, project_context_text
provider = self.build_provider() # this tab's selected agent/model
@@ -367,23 +393,36 @@ class CoworkTab(ChatPanel):
# built-in MCP server auto-registered while signed in, see
# AppContext._ms365_builtin_connection / mcp_servers/ms365_server.py).
extra_tools, extra_exec = self.ctx.build_mcp_tools()
# Shared project instructions (Claude-Projects style) — refreshed
# each turn so edits in the Workspace screen apply immediately.
proj_ctx = project_context_text(load_project(project_id))
if agent_prompt:
proj_ctx = f"{proj_ctx}\n\n{agent_prompt}" if proj_ctx else agent_prompt
# Shared project instructions (Claude-Projects style) plus the Admin
# agent's persona, refreshed each turn so edits in the Workspace
# screen apply immediately.
instructions = combine_instructions(
project_context_text(load_project(project_id)), agent_prompt)
# Permission Management (Sandbox Security Layer): off by default —
# matches the pre-existing auto-run behavior. Now resolved PER
# WORKSPACE: this project's Auto-run override wins, else the global
# "confirm before running commands" setting (project_confirm_commands).
# matches the pre-existing auto-run behavior. The gate lives on the
# worker because the UI resolves it from the main thread.
gate = None
if self.ctx.project_confirm_commands():
if confirm_commands:
gate = worker.new_gate("confirm", agent_role=agent_roles.COWORK)
run_cowork(provider, messages, output_dir, worker.emit_event,
worker.is_cancelled, title=title,
extra_tools=extra_tools, extra_executor=extra_exec,
project_context=proj_ctx, security_config=self.ctx.config,
gate=gate)
service = build_cowork_conversation_service(
provider, output_dir, worker.emit_event, title=title,
project_context=instructions, extra_tools=extra_tools,
extra_executor=extra_exec, security_config=self.ctx.config,
gate=gate, agent_role=agent_roles.COWORK,
)
request = build_cowork_turn_request(
turn_id=turn_id, session_id=session_id, surface=self.kind,
project_id=project_id, title=title, messages=messages,
provider_id=provider_id, model=model, instructions=instructions,
output_dir=output_dir, home_output_root=home_output_root,
confirm_commands=gate is not None, agent_role=agent_roles.COWORK,
)
# Hand the widget's own list over: _reattach_running_turn replays
# from it while the turn is still running, and _finalize_turn slices
# it afterwards, so the service must append into that very object.
service.execute(request, legacy_event_sink(worker.emit_event),
cancel=worker.is_cancelled, messages=messages)
return {"messages": messages, "turn_dir": str(output_dir)}
return job