Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10739f19aa |
@@ -0,0 +1 @@
|
||||
"""Application Layer: Pure Python use cases and application services."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application conversations package: turn lifecycle orchestration and agent execution."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application model routing package: model route decisions and multi-provider balancing."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application monitoring package: Monitoring query service for audit and metrics."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application scheduling package: TaskApplicationService and AI task planning."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application settings package: Settings application service."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application workflows package: Co4E graph execution orchestration."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application workspaces package: File workspace and AI file editor services."""
|
||||
@@ -0,0 +1,103 @@
|
||||
# ADR-001: 4-Tier Clean Architecture for Desktop Local Application
|
||||
|
||||
* **Status**: ACCEPTED / ENFORCED
|
||||
* **Date**: 2026-08-21
|
||||
* **Deciders**: Team Duy (Tech Lead & AI Runtime), Team Nam (Governance & Automation), Team Hoa (Workspace & Scheduling)
|
||||
* **Target Project**: Cowork Local (Cowork-Local BamBOO)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context and Problem Statement
|
||||
|
||||
Cowork Local is a desktop application written in Python using PySide6 (Qt) and designed for local-first execution.
|
||||
Historically, the codebase suffered from architectural coupling across layers:
|
||||
1. **God-Widget Problem**: Monolithic UI widgets (e.g., `ui/chat_panel.py` >1,800 LOC, `ui/co4e_tab.py` >1,400 LOC) mixed UI rendering, network I/O, business rules, filesystem operations, and background worker lifecycle.
|
||||
2. **Untestable Business Logic**: Core algorithms (model routing, conversation turn management, schedule calculation) were tightly coupled to `PySide6` widgets or `QTimer`, making unit testing in headless CI environments impossible without a graphical display server.
|
||||
3. **Circular Dependencies & Global State Leaks**: Uncontrolled module imports (`model_pricing.py` ↔ `usage_tracker.py`, `agent_security.py` ↔ `agent_security_alert.py`) and mutable global state (`state.py::AppContext.active_project_id`) caused race conditions in background task runs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision: 4-Tier Clean Architecture
|
||||
|
||||
We enforce a strict **4-Tier Clean Architecture** based on the Dependency Inversion Principle:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION │
|
||||
│ (PySide6 Widgets, Dialogs, Qt Signals/Slots, View Models) │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ depends on
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION │
|
||||
│ (Use Case Services, Turn Orchestrators, Route Dispatchers) │
|
||||
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ depends on
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DOMAIN & RUNTIME CORE │
|
||||
│ (Entities, Value Objects, Domain Events, Tool Descriptors) │
|
||||
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
|
||||
└──────────────────────────────▲──────────────────────────────┘
|
||||
│ implemented by
|
||||
┌──────────────────────────────┴──────────────────────────────┐
|
||||
│ INFRASTRUCTURE │
|
||||
│ (LLM Providers, Keyring Secrets, Atomic Persistence, MCP) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Layer Definitions and Responsibilities
|
||||
|
||||
### Tier 1: Presentation Layer (`presentation/`)
|
||||
* **Responsibilities**: UI component layout, user event capture, progress display, visual animations, confirmation dialog triggers.
|
||||
* **Allowed Imports**: `PySide6.*`, `application.*`, `domain.*`.
|
||||
* **Forbidden**: Direct database queries, raw LLM API calls, disk writes outside UI cache, executing tool commands directly.
|
||||
* **Constraints**: Every widget file must strictly be **under 400 lines of code (LOC)**.
|
||||
|
||||
### Tier 2: Application Layer (`application/`)
|
||||
* **Responsibilities**: Orchestrate single use cases (e.g. `ConversationApplicationService`, `RoutingApplicationService`, `TaskApplicationService`). Convert UI requests into domain requests, coordinate domain services with infrastructure adapters.
|
||||
* **Allowed Imports**: `domain.*`, `infrastructure.*` interfaces/contracts, standard Python libraries.
|
||||
* **Forbidden**: `PySide6`, `PyQt5`, `PyQt6`, `ui.*`, `app.*`.
|
||||
* **Nature**: **100% Pure Python**. Must be executable and testable in headless CI environments without a display driver.
|
||||
|
||||
### Tier 3: Domain Layer (`domain/`)
|
||||
* **Responsibilities**: Core domain models, frozen DTO snapshots (`ConversationExecutionRequest`), typed event streams (`AgentEvent`), descriptors (`ToolDescriptor`, `ProviderDescriptor`), deterministic calculation algorithms (`ScheduleCalculator`).
|
||||
* **Allowed Imports**: Standard Python library only (`dataclasses`, `typing`, `enum`, `datetime`, `pathlib`, `abc`).
|
||||
* **Forbidden**: `PySide6`, `PyQt*`, `requests`, `sqlalchemy`, filesystem mutations, OS network calls.
|
||||
* **Nature**: Completely isolated and zero-dependency core.
|
||||
|
||||
### Tier 4: Infrastructure Layer (`infrastructure/`)
|
||||
* **Responsibilities**: Adapters for external systems (OpenAI/Anthropic/Ollama/FPT providers, OS Keyring via `SecretStore`, `AtomicJsonFile` persistence, MCP child processes, filesystem tools).
|
||||
* **Allowed Imports**: Third-party SDKs, OS libraries, `domain.*`.
|
||||
* **Forbidden**: `presentation.*`, `PySide6.QtWidgets`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architectural Rules and Non-Negotiable Invariants
|
||||
|
||||
1. **Zero Qt in Business Logic**:
|
||||
- `domain/` and `application/` must never import `PySide6` or `PyQt*`.
|
||||
- Verified via AST parser script `scripts/check_imports.py`.
|
||||
2. **Immutable Request Snapshots**:
|
||||
- Turns are initiated using immutable frozen dataclasses (`ConversationExecutionRequest`) to decouple runtime state from mutable UI state.
|
||||
3. **Thread Safety and Signal Decoupling**:
|
||||
- AI generation and tool calls run asynchronously in worker threads.
|
||||
- UI updates occur strictly on the Qt main thread by consuming `AgentEvent` streams through Qt Signal bridges.
|
||||
4. **Single Responsibility and Modularity**:
|
||||
- Production files must stay within **400 LOC**.
|
||||
5. **English In-Code Comments**:
|
||||
- Every modified or created line/block must include concise English comments explaining design decisions and processing logic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Consequences and Compliance
|
||||
|
||||
* **Positive**:
|
||||
- Full testability: Unit tests run in milliseconds without GUI or network mocks.
|
||||
- Zero circular dependencies: Clear top-down data flow.
|
||||
- Resilience: UI crashes do not corrupt background tasks or files.
|
||||
* **Verification**:
|
||||
- Automated CI gate: `python scripts/check_imports.py` and `python scripts/check_loc.py`.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Danh Mục & Kế Hoạch Cô Lập Mã Nguồn Dormant / Dead Code (Dormant Code Catalog)
|
||||
|
||||
* **Tài liệu**: `docs/architecture/dormant-code.md`
|
||||
* **Thuộc EPIC**: `R01: Architecture Foundation & Characterization`
|
||||
* **Team phụ trách**: 🔵 **Team Duy (Tech Lead)**
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục Đích & Nguyên Tắc Quản Trị
|
||||
|
||||
Trong quá trình phát triển nhanh, một số module, hàm hoặc script đã trở thành mã nguồn không hoạt động (**dormant**), mã nguồn thử nghiệm cũ (**legacy prototypes**), hoặc mã nguồn không còn được sử dụng (**dead code**).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### 🛡️ NGUYÊN TẮC CÔ LẬP MÃ NGUỒN CŨ:
|
||||
> 1. **Tuyệt đối không import vào các tầng mới**: Các tầng `domain/`, `application/`, `infrastructure/` mới được xây dựng **cấm tuyệt đối import bất kỳ module dormant nào**.
|
||||
> 2. **Không xóa vội vàng khi chưa có test bảo vệ**: Giữ nguyên mã nguồn cũ trong giai đoạn tái cấu trúc R01–R08; chỉ dọn dẹp hoặc xóa sau khi bộ kiểm thử khói E2E (EPIC R10) chạy pass 100%.
|
||||
> 3. **Phân loại rõ ràng trạng thái**: Mỗi module dormant phải được gắn nhãn (DEPRECATED / ISOLATED / PENDING_DELETION).
|
||||
|
||||
---
|
||||
|
||||
## 2. Bảng Danh Mục Mã Nguồn Dormant / Dead Code Đã Rà Soát
|
||||
|
||||
| STT | File / Module / Ký Hiệu | Trạng Thái Hiện Tại | Lý Do Phân Loại & Phân Tích Kỹ Thuật | Kế Hoạch Xử Lý & Thời Điểm Gỡ Bỏ |
|
||||
| :---: | :--- | :---: | :--- | :--- |
|
||||
| **1** | `requirements (cloud copy).txt` | `PENDING_DELETION` | File sao chép dự phòng tạm thời trong quá khứ, không được tham chiếu bởi bất kỳ quy trình setup nào. | Gỡ bỏ trong EPIC R10 (Packaging & Clean-up). |
|
||||
| **2** | `preview-desktop` | `ISOLATED` | Script shell rỗng/phác thảo cho môi trường dev container cũ. | Cô lập, không liên kết vào build workflow. |
|
||||
| **3** | `scripts/bootstrap_gitea_repo.py` | `ISOLATED` | Script tiện ích bootstrap kho lưu trữ Gitea nội bộ; không thuộc runtime ứng dụng chính. | Di chuyển vào `docs/gitea/` làm tài liệu tham khảo ops. |
|
||||
| **4** | Hàm routing sao chép tại `ui/chat_panel.py#L638` | `DEPRECATED` | Đoạn code logic chọn model lặp lại từ `core/routing/` nằm trực tiếp trong UI widget. | Thay thế hoàn toàn bằng `RoutingApplicationService` trong EPIC R03. |
|
||||
| **5** | Biến toàn cục `state.py::active_project_id` | `DEPRECATED` | Biến global mutable gây race condition khi chạy background task song song. | Thay thế bằng `WorkspaceSession` trong EPIC R06. |
|
||||
| **6** | Các hàm xử lý UI đồng bộ trong `core/tools.py` | `DEPRECATED` | `core/tools.py` chứa mã monolithic vừa xử lý file vừa gọi dialog xác thực trực tiếp. | Phân rã thành `file_tools.py`, `command_tools.py` và `ToolPolicyGateway` trong EPIC R05. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Quy Trình Cô Lập & Kiểm Soát
|
||||
|
||||
1. **Kiểm tra tự động qua AST Guard**:
|
||||
- Bộ script `scripts/check_imports.py` tự động quét để đảm bảo không có bất kỳ import mới nào trỏ tới các thành phần đã đánh dấu deprecated.
|
||||
2. **Kế hoạch dọn dẹp cuối cùng (Release Phase - 31/08/2026)**:
|
||||
- Sau khi hoàn thành EPIC R10 và pass toàn bộ bài test E2E (`tests/e2e/test_smoke.py`), các file đánh dấu `PENDING_DELETION` sẽ được gỡ bỏ khỏi nhánh `main`.
|
||||
@@ -26,16 +26,16 @@
|
||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Phối hợp cả 3 team
|
||||
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
|
||||
|
||||
- [ ] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
|
||||
*Start: `2026-08-21 18:23` | End: `2026-08-21 18:24`*
|
||||
- [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
|
||||
*Start: `2026-08-21 18:24` | End: `2026-08-21 18:26`*
|
||||
- [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
|
||||
*Start: `2026-08-21 18:26` | End: `2026-08-21 18:28`*
|
||||
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
|
||||
*Start: `2026-08-21 18:28` | End: `2026-08-21 18:32`*
|
||||
- [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
|
||||
*Start: `2026-08-21 18:32` | End: `2026-08-21 18:35`*
|
||||
|
||||
---
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
|
||||
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
|
||||
| :--- | :--- | :---: | :---: | :---: |
|
||||
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 18:23` | `2026-08-21 18:35` | [x] |
|
||||
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# NHẬT KÝ THEO DÕI VÀ PHÒNG NGỪA LỖI TÁI CẤU TRÚC (BUG & LESSONS LEARNED LOG)
|
||||
## DỰ ÁN: COWORK LOCAL (COWORK-LOCAL BAMBOO)
|
||||
|
||||
Tài liệu này dùng để ghi nhận **toàn bộ các lỗi, xung đột kiến trúc và sự cố phát sinh** trong suốt quá trình refactoring của cả 3 team (Team Duy, Team Nam, Team Hoa).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### 🛡️ NGUYÊN TẮC VÀNG VỀ QUẢN TRỊ CHẤT LƯỢNG (ZERO RECURRENCE):
|
||||
> 1. **Ghi nhận ngay lập tức**: Khi gặp bất kỳ lỗi nào (Syntax, Circular Import, Type Error, Test Failure, Thread Freeze, Data Corruption), kỹ sư/AI phải ghi ngay vào tài liệu này trước khi tiếp tục task.
|
||||
> 2. **Phân tích nguyên nhân gốc rễ (Root Cause)**: Không chỉ sửa phần ngọn mà phải giải thích rõ bản chất vì sao lỗi xảy ra.
|
||||
> 3. **Rút ra quy tắc phòng ngừa (Prevention Rule)**: Đặt ra nguyên tắc kỹ thuật để **TUYỆT ĐỐI KHÔNG TÁI PHẠM** ở các task tiếp theo.
|
||||
> 4. **Checklist đầu vào**: Trước khi bắt đầu bất kỳ task mới nào, kỹ sư/AI **bắt buộc phải đọc lại toàn bộ file này**.
|
||||
|
||||
---
|
||||
|
||||
## 📌 BẢNG TỔNG HỢP CÁC LỖI ĐÃ PHÁT HIỆN & KHẮC PHỤC
|
||||
|
||||
| Bug ID | Ngày Phát Hiện | Phân Hệ / File Bị Ảnh Hưởng | Loại Lỗi | Trạng Thái | Team Phụ Trách |
|
||||
| :--- | :---: | :--- | :--- | :---: | :---: |
|
||||
| `BUG-001` | 2026-08-20 | `core/model_pricing.py` ↔ `core/usage_tracker.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Duy & Team Nam |
|
||||
| `BUG-002` | 2026-08-20 | `core/agent_security.py` ↔ `core/agent_security_alert.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Nam |
|
||||
| `BUG-003` | 2026-08-20 | `state.py::active_project_id` & `ui/workspace_tab.py` | Race Condition / Global State Leak | 🟡 Đã có giải pháp (R06) | Team Hoa |
|
||||
| `BUG-004` | 2026-08-20 | `core/task_scheduler.py` ↔ `PySide6.QtCore.QTimer` | Architecture Violation (Qt in Domain/App) | 🟡 Đã có giải pháp (R07) | Team Hoa |
|
||||
| `BUG-005` | 2026-08-20 | `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Code Duplication (Copy Routing Logic) | 🟡 Đã có giải pháp (R03) | Team Duy |
|
||||
| `BUG-006` | 2026-08-21 | `scripts/check_imports.py` | UnicodeEncodeError (Windows CP932 console emoji) | 🟢 Đã khắc phục (R01) | Team Duy |
|
||||
| `BUG-007` | 2026-08-21 | `platform/` ➔ `infrastructure/platform/` | Standard Library Shadowing (`import platform`) | 🟢 Đã khắc phục (R01) | Team Duy |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 CHI TIẾT TỪNG LỖI & QUY TẮC PHÒNG NGỪA
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-001`: Circular Import giữa Module Định Giá (`model_pricing.py`) và Theo Dõi Token (`usage_tracker.py`)
|
||||
|
||||
* **Phân hệ**: `core/model_pricing.py` & `core/usage_tracker.py`
|
||||
* **Triệu chứng (Symptom)**: Lỗi `ImportError: cannot import name 'ModelPricing' from partially initialized module` khi khởi động ứng dụng hoặc chạy test độc lập.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- `model_pricing.py` import `UsageTracker` để cập nhật dữ liệu tiêu thụ.
|
||||
- Ngược lại, `usage_tracker.py` import `ModelPricing` để tính toán chi phí theo từng model ID.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tách Data Transfer Object (DTO) `ModelPricing` sang tầng Domain thuần túy `domain/models/model_pricing.py`.
|
||||
- Cả `model_pricing.py` và `usage_tracker.py` đều import DTO từ `domain/models/`, chuyển quan hệ thành 1 chiều (Dependency Inversion).
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Không bao giờ để 2 service hoặc 2 module nghiệp vụ import lẫn nhau. Mọi cấu trúc dữ liệu dùng chung (DTO/Value Object/Event) **phải được đặt tại tầng `domain/`**.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-002`: Circular Import giữa An Ninh Agent (`agent_security.py`) và Cảnh Báo (`agent_security_alert.py`)
|
||||
|
||||
* **Phân hệ**: `core/agent_security.py` & `core/agent_security_alert.py`
|
||||
* **Triệu chứng (Symptom)**: Lỗi khởi tạo vòng tròn khi runtime bắn ra alert sự kiện bảo mật.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Module security vừa kiểm tra policy vừa khởi tạo trực tiếp instance alert dialog, trong khi alert dialog lại import ngược lại rule security để hiển thị chi tiết mã lỗi.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tách sự kiện cảnh báo thành Event DTO `SecurityAlertEvent` tại `domain/security/security_event.py`.
|
||||
- Tầng Security chỉ phát ra Event (`emit_event`), tầng Presentation/UI tự lắng nghe Event để render Dialog.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Logic an ninh và xử lý nghiệp vụ không bao giờ được gọi trực tiếp UI Dialog. Luôn giao tiếp thông qua cơ chế Event-Driven (`AgentEvent`, `SecurityEvent`).
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-003`: Xung Đột Race Condition do Sử Dụng Biến Toàn Cục `active_project_id` trong `state.py`
|
||||
|
||||
* **Phân hệ**: `state.py`, `ui/workspace_tab.py`, Scheduled Task Runners
|
||||
* **Triệu chứng (Symptom)**: Khi task scheduler chạy ngầm hoặc người dùng chuyển tab nhanh, file bị ghi nhầm vào thư mục dự án khác với dự án đang hiển thị trên màn hình.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Ứng dụng đọc và ghi trực tiếp vào biến toàn cục `AppContext.active_project_id` từ nhiều luồng khác nhau mà không có cơ chế snapshot ngữ cảnh.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Xóa bỏ việc đọc biến toàn cục. Mỗi lần khởi chạy turn hoặc task, tạo một snapshot bất biến `WorkspaceSession(project_id, root_path, allowed_paths)`.
|
||||
- Luồng ngầm chỉ thao tác trên `WorkspaceSession` được truyền vào từ lúc khởi tạo.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Tuyệt đối không dùng biến toàn cục (Global State / Singletons có trạng thái thay đổi) để điều khiển luồng thực thi nền. Mọi ngữ cảnh phải được truyền tường minh qua DTO snapshot.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-004`: Vi Phạm Ranh Giới Kiến Trúc Khi Import `PySide6.QtCore.QTimer` trong Domain / Scheduling Engine
|
||||
|
||||
* **Phân hệ**: `core/task_scheduler.py#L20`
|
||||
* **Triệu chứng (Symptom)**: Không thể viết Unit Test cho thuật toán tính toán lịch chạy (cron/interval) trên môi trường CI/CD (GitHub Actions / Linux Server headless) nếu thiếu driver màn hình X11/Wayland hoặc chưa cài `PySide6`.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Động cơ lập lịch bị gắn chặt cứng với `QTimer` của framework Qt thay vì tách riêng logic tính toán thời gian.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tách thuật toán tính lịch sang `domain/tasks/schedule_calculator.py` (Pure Python 100%).
|
||||
- Tạo `platform/qt/qt_scheduler_clock.py` làm adapter bọc `QTimer` cho app chạy thật, và `tests/fakes/fake_clock.py` cho unit test.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Tầng Domain và Application tuyệt đối không import thư viện GUI (`PySide6`, `PyQt`). Luôn bọc các thành phần phụ thuộc framework bên ngoài qua Adapter Interface.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-005`: Nhân Bản Mã Nguồn (Code Duplication) Logic Routing Mô Hình AI tại Nhiều Màn Hình
|
||||
|
||||
* **Phân hệ**: `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`
|
||||
* **Triệu chứng (Symptom)**: Khi cập nhật thêm model provider mới (như FPT Gateway hay Claude 3.7), phải sửa code thủ công ở 3 file UI khác nhau; phát sinh sai lệch quy tắc fallback giữa các màn hình.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Thiếu một tầng Application Service tập trung, dẫn đến việc lập trình viên copy-paste hàm chọn model từ `ChatPanel` sang các tab khác.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Xây dựng `application/model_routing/routing_application_service.py` duy nhất, cung cấp API `route_request(request) -> ModelRouteDecision`.
|
||||
- Mọi màn hình UI chỉ gọi service này, không tự viết lại logic kiểm tra key hay fallback.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Nghiệp vụ dùng chung giữa các màn hình phải được đưa vào `application/` services. Không bao giờ viết logic nghiệp vụ trực tiếp trong các file Widget UI.
|
||||
|
||||
---
|
||||
|
||||
### 🟢 `BUG-006`: `UnicodeEncodeError` khi in Emojis trên Console Windows (CP932/CP1252)
|
||||
|
||||
* **Phân hệ / File**: `scripts/check_imports.py`
|
||||
* **Triệu chứng (Symptom)**:
|
||||
```text
|
||||
Traceback (most recent call last):
|
||||
File "scripts/check_imports.py", line 127, in main
|
||||
print(f"\U0001f6e1\ufe0f Running Clean Architecture Import Guard...")
|
||||
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f6e1' in position 0: illegal multibyte sequence
|
||||
```
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Trên hệ điều hành Windows sử dụng locale tiếng Nhật (mã trang CP932) hoặc tiếng Anh (CP1252), `sys.stdout` mặc định không hỗ trợ các ký tự Unicode/Emoji ngoài bảng mã, dẫn đến crash khi in log dòng lệnh.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tự động bọc lại `sys.stdout` và `sys.stderr` bằng `io.TextIOWrapper` với `encoding="utf-8"` và `errors="replace"`.
|
||||
- Thay thế các emoji phức tạp bằng các tag văn bản ASCII chuẩn hóa như `[Clean Arch Guard]`, `[PASS]`, `[FAIL]`.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Mọi script CLI (`scripts/*.py`) phải có cơ chế cấu hình `utf-8` stream wrapper và ưu tiên sử dụng text tags (`[INFO]`, `[WARN]`, `[ERROR]`) thay vì emoji Unicode trực tiếp để đảm bảo chạy mượt mà trên mọi môi trường Windows đa ngôn ngữ.
|
||||
|
||||
---
|
||||
|
||||
### 🟢 `BUG-007`: Xung Đột Tên Thư Mục Trùng Với Standard Library (`platform/` Shadowing `import platform`)
|
||||
|
||||
* **Phân hệ / File**: `platform/` ➔ Chuyển thành `infrastructure/platform/`
|
||||
* **Triệu chứng (Symptom)**:
|
||||
```text
|
||||
INTERNALERROR> File "_pytest/terminal.py", line 853: verinfo = platform.python_version()
|
||||
INTERNALERROR> AttributeError: module 'platform' has no attribute 'python_version'
|
||||
```
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Khi tạo một package ở thư mục gốc có tên trùng với module thư viện chuẩn của Python (`platform`, `email`, `test`, `asyncio`, `logging`), Python trên `sys.path` sẽ ưu tiên import thư mục local thay vì thư viện chuẩn của Python runtime, dẫn đến crash toàn bộ pytest runner và các thư viện bên thứ ba.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Xóa bỏ package `platform/` ở root.
|
||||
- Đưa adapter Qt Scheduler Clock vào đúng vị trí hạ tầng: `infrastructure/platform/qt/`.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Tuyệt đối không đặt tên package/thư mục ở root trùng với tên các module built-in của Python (`platform`, `logging`, `types`, `time`, `io`, `os`, `sys`). Mọi platform adapter phải nằm trong `infrastructure/platform/` hoặc `platform_adapters/`.
|
||||
|
||||
---
|
||||
|
||||
## 📝 MẪU GHI NHẬN BUG MỚI (BUG REPORT TEMPLATE)
|
||||
|
||||
Khi gặp bất kỳ bug mới nào trong quá trình làm việc, hãy sao chép khối mẫu sau và điền vào cuối tài liệu:
|
||||
|
||||
```markdown
|
||||
### 🔴 `BUG-XXX`: [Tóm tắt ngắn gọn tên lỗi]
|
||||
|
||||
* **Phân hệ / File**: `[Đường dẫn file bị lỗi]`
|
||||
* **Triệu chứng (Symptom)**: `[Mô tả hiện tượng lỗi, paste thông báo traceback hoặc kết quả test fail]`
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**: `[Giải thích tại sao lỗi lại xảy ra]`
|
||||
* **Giải pháp khắc phục (Resolution)**: `[Mô tả cách sửa, file DTO/Service tạo mới hoặc cách refactor]`
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: `[Nguyên tắc kỹ thuật cụ thể để không bao giờ tái phạm lỗi này]`
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain Layer: Pure Python domain entities, value objects, and events."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain agents package: turn requests, agent events, and role definitions."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain models package: provider descriptors, model pricing, and routing metadata."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain security package: security policies, alert events, and permission types."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain tasks package: task definitions and deterministic schedule calculators."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain tools package: tool descriptors, capability scopes, and registry interfaces."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain workspaces package: immutable WorkspaceSession definitions."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure Layer: External system adapters, persistence, and SDK clients."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure config package: ConfigRepository and typed settings facades."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure filesystem package: Tool handlers (file, command, fetch tools) and execution workspace."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure MCP package: McpToolSourceManager and child process lifecycle."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure persistence package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure JSON persistence package: AtomicJsonFile and repositories."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure platform adapters package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure Qt platform adapters: QtSchedulerClock."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure providers package: LLM provider adapters and ProviderRegistry."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure sandbox package: OS-specific sandbox capability adapters."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure telemetry package: CanonicalAuditLogger and token usage sinks."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation Layer: PySide6 UI widgets, dialogs, and shell views (<400 LOC per file)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation chat package: ChatHistoryWidget, ComposerWidget, AttachmentPicker, AudioRecorderWidget, ChatOutputPanel."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation Co4E package: Co4ECanvasWidget, NodePropertyPanel, RunControlWidget, Co4EChatView."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation dashboard package: TokenUsageCardWidget, UsageChartWidget, HabitsWidget."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation folder package: WorkspaceFileTree, DocumentPreviewManager, AiFileEditorDialog."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation graph package: StructureGraphView and GraphQaWidget."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation monitoring package: 8 modular sub-tab widgets."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation scheduling package: KanbanBoardWidget, CalendarViewWidget, AiTaskCreatorDialog."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation settings package: Section widgets for provider, connector, routing, and general settings."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation shell package: MainWindow shell, TrayManager, LifecycleCoordinator."""
|
||||
@@ -1,9 +0,0 @@
|
||||
PySide6>=6.6
|
||||
pydantic>=2
|
||||
requests
|
||||
psutil
|
||||
pygments
|
||||
openpyxl
|
||||
python-pptx
|
||||
networkx
|
||||
pytest
|
||||
@@ -0,0 +1,166 @@
|
||||
"""AST-based Static Analysis Guard for Clean Architecture Enforcement.
|
||||
|
||||
Scans designated Python packages (such as `domain/` and `application/`) to ensure
|
||||
they remain 100% Pure Python and do not import presentation/GUI frameworks (PySide6, PyQt)
|
||||
or concrete application shells.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, NamedTuple, Set
|
||||
|
||||
# Ensure UTF-8 output on standard console streams across diverse Windows locales (CP932, etc.)
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
try:
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class ImportViolation(NamedTuple):
|
||||
file_path: Path
|
||||
line_number: int
|
||||
imported_module: str
|
||||
rule_description: str
|
||||
|
||||
|
||||
# Disallowed top-level package names in pure business/domain layers
|
||||
FORBIDDEN_MODULE_PREFIXES: Set[str] = {
|
||||
"PySide6",
|
||||
"PySide2",
|
||||
"PyQt6",
|
||||
"PyQt5",
|
||||
"ui",
|
||||
"app",
|
||||
}
|
||||
|
||||
# Default directories that must strictly adhere to Clean Architecture
|
||||
DEFAULT_SCAN_DIRS: List[str] = [
|
||||
"domain",
|
||||
"application",
|
||||
]
|
||||
|
||||
|
||||
class ArchitectureImportVisitor(ast.NodeVisitor):
|
||||
"""AST visitor that checks all Import and ImportFrom statements against forbidden prefixes."""
|
||||
|
||||
def __init__(self, file_path: Path, forbidden: Set[str]) -> None:
|
||||
self.file_path = file_path
|
||||
self.forbidden = forbidden
|
||||
self.violations: List[ImportViolation] = []
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
# Check direct `import x, y` statements
|
||||
for alias in node.names:
|
||||
root_module = alias.name.split(".")[0]
|
||||
if root_module in self.forbidden:
|
||||
self.violations.append(
|
||||
ImportViolation(
|
||||
file_path=self.file_path,
|
||||
line_number=node.lineno,
|
||||
imported_module=alias.name,
|
||||
rule_description=f"Direct import of GUI/shell module '{alias.name}' is prohibited.",
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
# Check `from x import y` statements
|
||||
if node.module:
|
||||
root_module = node.module.split(".")[0]
|
||||
if root_module in self.forbidden:
|
||||
self.violations.append(
|
||||
ImportViolation(
|
||||
file_path=self.file_path,
|
||||
line_number=node.lineno,
|
||||
imported_module=node.module,
|
||||
rule_description=f"Import from GUI/shell module '{node.module}' is prohibited.",
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def scan_file(file_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||
"""Parse a single Python file into AST and return all detected architecture import violations."""
|
||||
try:
|
||||
source_code = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source_code, filename=str(file_path))
|
||||
except (SyntaxError, UnicodeDecodeError) as exc:
|
||||
print(f"[Syntax/Read Warning] Could not parse {file_path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
visitor = ArchitectureImportVisitor(file_path, forbidden)
|
||||
visitor.visit(tree)
|
||||
return visitor.violations
|
||||
|
||||
|
||||
def scan_directory(dir_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||
"""Recursively scan all Python files in a directory."""
|
||||
violations: List[ImportViolation] = []
|
||||
if not dir_path.exists():
|
||||
return violations
|
||||
|
||||
for py_file in dir_path.rglob("*.py"):
|
||||
if py_file.is_file() and "__pycache__" not in py_file.parts:
|
||||
violations.extend(scan_file(py_file, forbidden))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point for CI/pre-commit quality gate checks."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Clean Architecture Import Guard: Verifies zero GUI/Qt dependencies in domain/app layers."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--paths",
|
||||
nargs="*",
|
||||
default=DEFAULT_SCAN_DIRS,
|
||||
help="Paths or directories to scan (defaults to 'domain' and 'application')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default=".",
|
||||
help="Root workspace directory",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root_dir = Path(args.root).resolve()
|
||||
all_violations: List[ImportViolation] = []
|
||||
|
||||
print(f"[Clean Arch Guard] Scanning root: {root_dir}")
|
||||
|
||||
for target in args.paths:
|
||||
target_path = (root_dir / target).resolve()
|
||||
if not target_path.exists():
|
||||
# If the layer directory does not exist yet (during early migration), skip cleanly
|
||||
print(f"[Clean Arch Guard] Directory '{target}' does not exist yet (skipped).")
|
||||
continue
|
||||
|
||||
if target_path.is_file():
|
||||
all_violations.extend(scan_file(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||
else:
|
||||
all_violations.extend(scan_directory(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||
|
||||
if all_violations:
|
||||
print("\n[FAIL] CLEAN ARCHITECTURE VIOLATIONS DETECTED:")
|
||||
print("=" * 70)
|
||||
for v in all_violations:
|
||||
rel_path = v.file_path.relative_to(root_dir) if v.file_path.is_relative_to(root_dir) else v.file_path
|
||||
print(f" • {rel_path}:{v.line_number} -> Forbidden import: '{v.imported_module}'")
|
||||
print(f" Reason: {v.rule_description}")
|
||||
print("=" * 70)
|
||||
print(f"Total Violations: {len(all_violations)}")
|
||||
return 1
|
||||
|
||||
print("\n[PASS] CLEAN ARCHITECTURE CHECK: 0 forbidden imports detected.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Characterization tests for core/chat_agent.py (run_chat and run_cowork runtime seams).
|
||||
|
||||
These tests capture existing behavior as an executable baseline specification,
|
||||
ensuring that future refactoring to ConversationApplicationService does not alter
|
||||
core turn semantics, event emissions, or file handling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from cowork_local.core import chat_agent
|
||||
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||
|
||||
|
||||
def test_run_chat_characterization() -> None:
|
||||
"""Capture baseline behavior of run_chat: system prompt insertion, streaming, and message persistence."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Hello there!", chunks=["Hello ", "there!"])
|
||||
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Hi assistant"}]
|
||||
emitted_events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(event: Dict[str, Any]) -> None:
|
||||
emitted_events.append(event)
|
||||
|
||||
result = chat_agent.run_chat(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
emit=emit,
|
||||
)
|
||||
|
||||
# 1. Verify system prompt was injected at position 0
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "Cowork Local" in messages[0]["content"]
|
||||
|
||||
# 2. Verify returned assistant message
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == "Hello there!"
|
||||
|
||||
# 3. Verify assistant message was appended to messages list
|
||||
assert messages[-1] == result
|
||||
|
||||
# 4. Verify emitted events sequence
|
||||
text_deltas = [e["delta"] for e in emitted_events if e["type"] == "text"]
|
||||
assert "".join(text_deltas) == "Hello there!"
|
||||
assert any(e["type"] == "assistant_done" for e in emitted_events)
|
||||
|
||||
|
||||
def test_run_cowork_save_file_characterization(tmp_path: Path) -> None:
|
||||
"""Capture baseline behavior of run_cowork: tool execution loop and file production."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
provider = FakeProvider()
|
||||
# Step 1: Model requests save_file tool
|
||||
provider.queue_response(
|
||||
content="Saving your requested report.",
|
||||
tool_calls=[{
|
||||
"id": "call_save_1",
|
||||
"name": "save_file",
|
||||
"arguments": {
|
||||
"filename": "report.md",
|
||||
"content": "# Executive Summary\nAll systems nominal.",
|
||||
},
|
||||
}],
|
||||
)
|
||||
# Step 2: Model finishes after tool result
|
||||
provider.queue_response(
|
||||
content="I have created report.md in your output directory.",
|
||||
chunks=["I have created report.md in your output directory."],
|
||||
)
|
||||
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Export report to markdown file"}]
|
||||
emitted_events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(event: Dict[str, Any]) -> None:
|
||||
emitted_events.append(event)
|
||||
|
||||
final_messages = chat_agent.run_cowork(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
output_dir=output_dir,
|
||||
emit=emit,
|
||||
enforce_rules=False,
|
||||
)
|
||||
|
||||
# 1. Verify file was created in output directory with expected content
|
||||
created_file = output_dir / "report.md"
|
||||
assert created_file.exists()
|
||||
assert created_file.read_text(encoding="utf-8") == "# Executive Summary\nAll systems nominal."
|
||||
|
||||
# 2. Verify message history contains user -> assistant (tool_calls) -> tool -> assistant
|
||||
roles = [m["role"] for m in final_messages]
|
||||
assert "system" in roles
|
||||
assert "user" in roles
|
||||
assert "tool" in roles
|
||||
|
||||
# 3. Verify tool result message content
|
||||
tool_msg = next(m for m in final_messages if m["role"] == "tool")
|
||||
assert tool_msg["name"] == "save_file"
|
||||
assert "Saved report.md" in tool_msg["content"]
|
||||
|
||||
|
||||
def test_run_cowork_cancellation_characterization(tmp_path: Path) -> None:
|
||||
"""Capture cancellation behavior in run_cowork."""
|
||||
output_dir = tmp_path / "output_cancel"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Working...")
|
||||
|
||||
is_cancelled = True
|
||||
|
||||
def check_cancel() -> bool:
|
||||
return is_cancelled
|
||||
|
||||
emitted_events: List[Dict[str, Any]] = []
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Please start"}]
|
||||
|
||||
chat_agent.run_cowork(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
output_dir=output_dir,
|
||||
emit=lambda e: emitted_events.append(e),
|
||||
cancel=check_cancel,
|
||||
enforce_rules=False,
|
||||
)
|
||||
|
||||
# Provider should not have executed turns if cancelled right away
|
||||
assert provider.call_count == 0
|
||||
|
||||
|
||||
def test_cleanup_turn_output_characterization(tmp_path: Path) -> None:
|
||||
"""Capture behavior of temporary .scratch folder cleanup and artifact preservation."""
|
||||
output_dir = tmp_path / "output_cleanup"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
scratch_dir = output_dir / ".scratch"
|
||||
scratch_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a generator script and a deliverable inside scratch
|
||||
generator_script = scratch_dir / "gen.py"
|
||||
generator_script.write_text("print('generating')", encoding="utf-8")
|
||||
deliverable = scratch_dir / "data.csv"
|
||||
deliverable.write_text("a,b,c\n1,2,3", encoding="utf-8")
|
||||
|
||||
before_snapshot = chat_agent._snapshot(output_dir)
|
||||
removed, moved = chat_agent._cleanup_cowork_intermediates(output_dir, before_snapshot, cancelled=False)
|
||||
|
||||
# .scratch directory should be removed
|
||||
assert not scratch_dir.exists()
|
||||
# deliverable should be moved to output root
|
||||
root_csv = output_dir / "data.csv"
|
||||
assert root_csv.exists()
|
||||
# script should not be in output root
|
||||
assert not (output_dir / "gen.py").exists()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Test doubles and offline fakes package for Cowork Local test pyramid."""
|
||||
from .fake_provider import FakeProvider
|
||||
from .fake_tool_executor import FakeToolExecutor
|
||||
|
||||
__all__ = ["FakeProvider", "FakeToolExecutor"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Fake LLM Provider for offline unit, contract, and characterization testing.
|
||||
|
||||
Provides deterministic responses, stream simulation, tool-call dispatching,
|
||||
and fault injection without requiring any external network access or API keys.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from providers.base import CancelFn, Provider, ProviderError, TextCallback, ToolSpec
|
||||
|
||||
|
||||
class FakeProvider(Provider):
|
||||
"""Deterministic test double mimicking real LLM Providers (OpenAI, Anthropic, Ollama)."""
|
||||
|
||||
name = "fake"
|
||||
supports_vision = True
|
||||
|
||||
def __init__(self, conf: Optional[Dict[str, Any]] = None) -> None:
|
||||
# Initialize base provider with default configuration if none provided
|
||||
super().__init__(conf or {"model": "fake-model-v1"})
|
||||
# History of all message batches sent across all chat calls
|
||||
self.call_history: List[List[Dict[str, Any]]] = []
|
||||
# Queue of programmed assistant responses to return sequentially
|
||||
self.response_queue: List[Dict[str, Any]] = []
|
||||
# Queue of exceptions to raise on corresponding calls
|
||||
self.error_queue: List[Exception] = []
|
||||
# Default text returned when response queue is empty
|
||||
self.default_text: str = "Fake model response."
|
||||
# Total number of chat invocations
|
||||
self.call_count: int = 0
|
||||
# Recorded tool specs passed into each turn
|
||||
self.last_tools: Optional[List[ToolSpec]] = None
|
||||
|
||||
def queue_response(
|
||||
self,
|
||||
content: str = "",
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
reasoning: Optional[str] = None,
|
||||
chunks: Optional[List[str]] = None,
|
||||
) -> FakeProvider:
|
||||
"""Enqueue a pre-configured response structure for upcoming chat turns."""
|
||||
self.response_queue.append({
|
||||
"content": content,
|
||||
"tool_calls": tool_calls or [],
|
||||
"reasoning": reasoning,
|
||||
"chunks": chunks or ([content] if content else []),
|
||||
})
|
||||
return self
|
||||
|
||||
def queue_error(self, exc: Exception) -> FakeProvider:
|
||||
"""Enqueue an exception to simulate network/API errors on the next turn."""
|
||||
self.error_queue.append(exc)
|
||||
return self
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[ToolSpec]] = None,
|
||||
on_text: Optional[TextCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_reasoning: Optional[TextCallback] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Simulate single LLM turn with full streaming and tool-call support."""
|
||||
self.call_count += 1
|
||||
self.call_history.append([dict(m) for m in messages])
|
||||
self.last_tools = tools
|
||||
|
||||
# 1. Check for injected errors
|
||||
if self.error_queue:
|
||||
raise self.error_queue.pop(0)
|
||||
|
||||
# 2. Check early cancellation before processing
|
||||
if cancel and cancel():
|
||||
raise ProviderError("Execution aborted by user cancel signal before response generation.")
|
||||
|
||||
# 3. Retrieve queued response or construct default response
|
||||
if self.response_queue:
|
||||
resp_spec = self.response_queue.pop(0)
|
||||
content = resp_spec.get("content", "")
|
||||
tool_calls = resp_spec.get("tool_calls", [])
|
||||
reasoning = resp_spec.get("reasoning")
|
||||
chunks = resp_spec.get("chunks", [content] if content else [])
|
||||
else:
|
||||
content = self.default_text
|
||||
tool_calls = []
|
||||
reasoning = None
|
||||
chunks = [content]
|
||||
|
||||
# 4. Stream reasoning chunks if provided
|
||||
if reasoning and on_reasoning:
|
||||
on_reasoning(reasoning)
|
||||
|
||||
# 5. Stream text chunks, checking cancellation between fragments
|
||||
for chunk in chunks:
|
||||
if cancel and cancel():
|
||||
raise ProviderError("Execution cancelled during text chunk streaming.")
|
||||
if on_text and chunk:
|
||||
on_text(chunk)
|
||||
|
||||
# 6. Return canonical assistant message payload
|
||||
assistant_msg: Dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
if tool_calls:
|
||||
assistant_msg["tool_calls"] = tool_calls
|
||||
|
||||
return assistant_msg
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return available mock models for settings and validation tests."""
|
||||
return ["fake-model-v1", "fake-reasoner-pro", "fake-vision-plus"]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Fake Tool Executor for isolated, offline agent tool-call verification.
|
||||
|
||||
Allows tests to verify tool invocation arguments, mock tool return values,
|
||||
and simulate failures/delays without performing unsafe host disk or OS operations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
class FakeToolExecutor:
|
||||
"""Mock execution engine for agent tool-call dispatching."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# History of all executed tool invocations: List of {"name": str, "args": dict, "result": dict}
|
||||
self.call_log: List[Dict[str, Any]] = []
|
||||
# Custom handlers registered per tool name
|
||||
self.handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
|
||||
# Pre-programmed fixed responses keyed by tool name
|
||||
self.mock_responses: Dict[str, Dict[str, Any]] = {}
|
||||
# Default response when no specific handler or response is found
|
||||
self.default_result: Dict[str, Any] = {"ok": True, "output": "Fake tool executed successfully."}
|
||||
|
||||
def register_handler(
|
||||
self,
|
||||
tool_name: str,
|
||||
handler: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||
) -> FakeToolExecutor:
|
||||
"""Register a dynamic handler function for a specific tool name."""
|
||||
self.handlers[tool_name] = handler
|
||||
return self
|
||||
|
||||
def set_mock_response(
|
||||
self,
|
||||
tool_name: str,
|
||||
result: Dict[str, Any],
|
||||
) -> FakeToolExecutor:
|
||||
"""Set a static return payload for a specific tool name."""
|
||||
self.mock_responses[tool_name] = result
|
||||
return self
|
||||
|
||||
def execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Execute a tool call using registered mocks and record invocation details."""
|
||||
# 1. Resolve result from handler, preset response, or default fallback
|
||||
if tool_name in self.handlers:
|
||||
result = self.handlers[tool_name](arguments)
|
||||
elif tool_name in self.mock_responses:
|
||||
result = self.mock_responses[tool_name]
|
||||
else:
|
||||
result = dict(self.default_result)
|
||||
result["tool"] = tool_name
|
||||
result["received_args"] = arguments
|
||||
|
||||
# 2. Record execution trace for post-test assertions
|
||||
self.call_log.append({
|
||||
"name": tool_name,
|
||||
"args": dict(arguments),
|
||||
"result": dict(result),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_calls_for(self, tool_name: str) -> List[Dict[str, Any]]:
|
||||
"""Retrieve all recorded calls for a given tool name."""
|
||||
return [call for call in self.call_log if call["name"] == tool_name]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear recorded logs and registered mock responses."""
|
||||
self.call_log.clear()
|
||||
self.handlers.clear()
|
||||
self.mock_responses.clear()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Unit tests for the Clean Architecture AST Import Guard (check_imports.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from scripts.check_imports import FORBIDDEN_MODULE_PREFIXES, scan_file
|
||||
|
||||
|
||||
def test_clean_python_file_passes(tmp_path: Path) -> None:
|
||||
"""Verify that pure Python code without GUI imports produces 0 violations."""
|
||||
clean_code = """
|
||||
import os
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
@dataclass
|
||||
class UserRequest:
|
||||
id: str
|
||||
prompt: str
|
||||
"""
|
||||
clean_file = tmp_path / "clean_service.py"
|
||||
clean_file.write_text(clean_code, encoding="utf-8")
|
||||
|
||||
violations = scan_file(clean_file, FORBIDDEN_MODULE_PREFIXES)
|
||||
assert len(violations) == 0
|
||||
|
||||
|
||||
def test_forbidden_pyside_import_detected(tmp_path: Path) -> None:
|
||||
"""Verify that PySide6 import is caught with correct line number."""
|
||||
dirty_code = """
|
||||
from dataclasses import dataclass
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
class BadService:
|
||||
pass
|
||||
"""
|
||||
dirty_file = tmp_path / "bad_service.py"
|
||||
dirty_file.write_text(dirty_code, encoding="utf-8")
|
||||
|
||||
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].line_number == 3
|
||||
assert "PySide6" in violations[0].imported_module
|
||||
|
||||
|
||||
def test_forbidden_ui_and_app_import_detected(tmp_path: Path) -> None:
|
||||
"""Verify that importing concrete UI or app modules from domain is caught."""
|
||||
dirty_code = """
|
||||
import ui.chat_panel
|
||||
from app import MainWindow
|
||||
"""
|
||||
dirty_file = tmp_path / "cross_layer_leak.py"
|
||||
dirty_file.write_text(dirty_code, encoding="utf-8")
|
||||
|
||||
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
|
||||
assert len(violations) == 2
|
||||
modules = [v.imported_module for v in violations]
|
||||
assert "ui.chat_panel" in modules
|
||||
assert "app" in modules
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Unit tests for FakeProvider and FakeToolExecutor test doubles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from providers.base import ProviderError
|
||||
from tests.fakes.fake_provider import FakeProvider
|
||||
from tests.fakes.fake_tool_executor import FakeToolExecutor
|
||||
|
||||
|
||||
def test_fake_provider_text_streaming() -> None:
|
||||
"""Verify that FakeProvider streams text chunks to on_text callback."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Hello world", chunks=["Hello ", "world"])
|
||||
|
||||
streamed: list[str] = []
|
||||
response = provider.chat(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
on_text=lambda piece: streamed.append(piece),
|
||||
)
|
||||
|
||||
assert response["role"] == "assistant"
|
||||
assert response["content"] == "Hello world"
|
||||
assert "".join(streamed) == "Hello world"
|
||||
assert provider.call_count == 1
|
||||
|
||||
|
||||
def test_fake_provider_tool_calls_and_reasoning() -> None:
|
||||
"""Verify reasoning streaming and tool_calls payload emission."""
|
||||
provider = FakeProvider()
|
||||
tool_call = {
|
||||
"id": "call_123",
|
||||
"name": "save_file",
|
||||
"arguments": {"filename": "out.txt", "content": "data"},
|
||||
}
|
||||
provider.queue_response(
|
||||
content="Creating file",
|
||||
tool_calls=[tool_call],
|
||||
reasoning="User wants output in a file",
|
||||
)
|
||||
|
||||
reasoning_chunks: list[str] = []
|
||||
response = provider.chat(
|
||||
messages=[{"role": "user", "content": "Save to out.txt"}],
|
||||
on_reasoning=lambda piece: reasoning_chunks.append(piece),
|
||||
)
|
||||
|
||||
assert response["content"] == "Creating file"
|
||||
assert response["tool_calls"] == [tool_call]
|
||||
assert reasoning_chunks == ["User wants output in a file"]
|
||||
|
||||
|
||||
def test_fake_provider_error_injection() -> None:
|
||||
"""Verify that queued exceptions are raised on demand."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_error(ProviderError("Rate limit exceeded (429)"))
|
||||
|
||||
with pytest.raises(ProviderError, match="Rate limit exceeded"):
|
||||
provider.chat(messages=[{"role": "user", "content": "Hi"}])
|
||||
|
||||
|
||||
def test_fake_provider_cancellation() -> None:
|
||||
"""Verify that cancellation stops execution immediately."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Long reply", chunks=["Part 1", "Part 2"])
|
||||
|
||||
is_cancelled = False
|
||||
|
||||
def cancel_fn() -> bool:
|
||||
return is_cancelled
|
||||
|
||||
is_cancelled = True
|
||||
with pytest.raises(ProviderError, match="aborted by user cancel"):
|
||||
provider.chat(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
cancel=cancel_fn,
|
||||
)
|
||||
|
||||
|
||||
def test_fake_tool_executor() -> None:
|
||||
"""Verify that FakeToolExecutor records calls and returns expected mock outputs."""
|
||||
executor = FakeToolExecutor()
|
||||
executor.set_mock_response("read_file", {"ok": True, "content": "file contents"})
|
||||
executor.register_handler("calc", lambda args: {"ok": True, "result": args.get("a", 0) + args.get("b", 0)})
|
||||
|
||||
res1 = executor.execute("read_file", {"path": "test.txt"})
|
||||
assert res1["ok"] is True
|
||||
assert res1["content"] == "file contents"
|
||||
|
||||
res2 = executor.execute("calc", {"a": 5, "b": 10})
|
||||
assert res2["result"] == 15
|
||||
|
||||
assert len(executor.call_log) == 2
|
||||
assert executor.get_calls_for("calc")[0]["args"] == {"a": 5, "b": 10}
|
||||
Reference in New Issue
Block a user