Feature/delta team/epic r04 #7
@@ -20,6 +20,37 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM DUY (cập nhật `2026-08-21 10:55`)
|
||||
|
||||
> [!NOTE]
|
||||
> ### ✅ ĐÃ HOÀN TẤT: 16/16 task của **R01, R03, R04** — đã commit & push lên nhánh `feature/deltateam/refactor-plan`
|
||||
>
|
||||
> | EPIC | Task | Trạng thái |
|
||||
> | :--- | :--- | :--- |
|
||||
> | **R01** Architecture Foundation | T01 → T05 | ✅ 5/5 |
|
||||
> | **R03** Providers & Routing | T01 → T06 | ✅ 6/6 |
|
||||
> | **R04** Agent Runtime & Conversation | T01 → T05 | ✅ 5/5 |
|
||||
>
|
||||
> **Kiểm chứng (chạy thật, không phải ước lượng):**
|
||||
> * `pytest tests/` ➔ **243 pass / 2 fail** trong 44s
|
||||
> * Suite nhanh (`unit + contracts + characterization + routing`) ➔ **218 pass trong 1,16s** (đạt yêu cầu CASAN "A – Automated Tests < 1s cho unit")
|
||||
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
|
||||
> * Mọi file production mới **< 400 dòng** (lớn nhất: `routing_application_service.py` 353 dòng)
|
||||
> * 2 test fail là **lỗi có sẵn từ trước**, thuộc EPIC **R02**: `config.py` vẫn hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py` đỏ
|
||||
>
|
||||
> ### ⚠️ ĐIỀU CHỈNH PHẠM VI (theo xác nhận của Team Lead ngày 21/08)
|
||||
> Team Duy nhận **R01, R02, R04, R10** (R10 làm sau cùng, chờ các team khác xong).
|
||||
> * **R03 đã được làm** (6/6 task, đã push) — nằm ngoài phạm vi vừa chốt, nhưng code đã lên nhánh và đã có test bảo vệ. Cần thống nhất bàn giao hay giữ lại.
|
||||
> * **R02 chưa bắt đầu** — đây là EPIC thuộc phạm vi mới của team và đang là nguyên nhân 2 test đỏ ở trên.
|
||||
>
|
||||
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
|
||||
> 1. `ProviderRegistry` **chưa nối** vào `state.build_provider_for` (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa luôn lỗi: usage của `ollama`/`github_copilot`/`codex` hiện bị ghi nhận nhầm thành `openai_compat` trên Dashboard — nhưng làm vậy sẽ **đổi cách gom dữ liệu lịch sử**.
|
||||
> 2. Mode `fallback` đã hỗ trợ ở config + service nhưng **chưa có trên toggle UI** (thuộc R08).
|
||||
> 3. Đã sửa 2 dòng trong `config.py` (`routing_mode_for` / `set_routing_mode_for`) để dùng chung một bộ từ vựng mode — **cần báo Team Nam** vì file này đang được refactor ở R02.
|
||||
> 4. Circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` **chưa xử lý**.
|
||||
|
||||
---
|
||||
|
||||
## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10)
|
||||
|
||||
### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
|
||||
@@ -229,15 +260,15 @@
|
||||
|
||||
| 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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 09:56` | `2026-08-21 10:25` | [x] |
|
||||
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-21 10:06` | `2026-08-21 10:12` | [x] ⚠️ registry chưa nối vào `state.build_provider_for` |
|
||||
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-21 10:12` | `2026-08-21 10:15` | [~] RoutingApplicationService xong; tách widget thuộc R08 |
|
||||
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `2026-08-21 10:25` | `2026-08-21 10:27` | [~] Service xong; tách widget thuộc R08 |
|
||||
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-21 10:17` | `2026-08-21 10:22` | [~] 3 bản copy routing đã gỡ; circular import chưa xử lý |
|
||||
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `2026-08-21 10:35` | `2026-08-21 10:52` | [~] 25 integration test tại `tests/integration/{test_cowork_turn_flow,test_task_executor_flow,test_routing_surfaces}.py` |
|
||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `2026-08-21 09:58` | `2026-08-21 10:05` | [x] PASS |
|
||||
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""The three chat surfaces really route through the shared service (R03-T04/T05).
|
||||
|
||||
The unit suite proves ``RoutingApplicationService`` decides correctly against a
|
||||
fake router. This file proves the three widgets that used to own a private copy
|
||||
of that algorithm now call it, on real (offscreen) widgets:
|
||||
|
||||
* ``ui/chat_panel.py::_apply_routing`` (Cowork)
|
||||
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E)
|
||||
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
|
||||
|
||||
It also pins the Manual-mode handshake, including the field contract the
|
||||
existing confirm dialog reads off the decision - the one place where the new
|
||||
``RoutingDecision`` has to look like the legacy ``SwitchDecision`` it replaced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.application.model_routing import ( # noqa: E402
|
||||
RoutingApplicationService,
|
||||
RoutingDecision,
|
||||
RoutingMode,
|
||||
)
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path) -> AppContext:
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
class _FakeRouteResult:
|
||||
"""Shaped like ``core.routing.service.RouteResult``."""
|
||||
|
||||
def __init__(self, provider: str, model: str, gain: float = 0.4,
|
||||
task: str = "coding") -> None:
|
||||
self.should_switch = True
|
||||
self._target = (provider, model)
|
||||
self.task_type = type("_T", (), {"value": task})()
|
||||
self.decision = type("_D", (), {"score_gain": gain, "reason": "better fit"})()
|
||||
|
||||
def target(self) -> Optional[Tuple[str, str]]:
|
||||
return self._target
|
||||
|
||||
|
||||
class _FakeRouter:
|
||||
"""Minimal RoutingPort: always proposes the same switch, records the surface."""
|
||||
|
||||
def __init__(self, provider="anthropic", model="claude-sonnet-4-6") -> None:
|
||||
self.result = _FakeRouteResult(provider, model)
|
||||
self.surfaces: List[str] = []
|
||||
|
||||
def route(self, surface, prompt, current_provider, current_model, **kwargs):
|
||||
self.surfaces.append(surface)
|
||||
return self.result
|
||||
|
||||
|
||||
def _install(ctx: AppContext, mode: str) -> _FakeRouter:
|
||||
"""Wire a fake router into the context and force ``mode`` on every surface."""
|
||||
router = _FakeRouter()
|
||||
service = RoutingApplicationService(router, mode_reader=lambda _surface: mode)
|
||||
ctx._routing_application = service # already-built instance; accessor returns it
|
||||
return router
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cowork chat
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_cowork_applies_an_auto_switch_to_the_next_turn(ctx):
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = CoworkTab(ctx)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert router.surfaces == [tab.kind]
|
||||
# build_provider() honours these for THIS turn only.
|
||||
assert (tab._routed_provider, tab._routed_model) == ("anthropic", "claude-sonnet-4-6")
|
||||
assert turn["bubbles"], "the user must be told the model was switched"
|
||||
|
||||
|
||||
def test_cowork_leaves_the_model_alone_when_routing_is_off(ctx):
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
router = _install(ctx, "off")
|
||||
tab = CoworkTab(ctx)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert router.surfaces == []
|
||||
assert (tab._routed_provider, tab._routed_model) == (None, None)
|
||||
assert turn["bubbles"] == []
|
||||
|
||||
|
||||
def test_cowork_manual_mode_switches_only_after_the_dialog_approves(ctx, monkeypatch):
|
||||
from cowork_local.ui import chat_panel as chat_panel_module
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
_install(ctx, "manual")
|
||||
tab = CoworkTab(ctx)
|
||||
asked: List[Any] = []
|
||||
monkeypatch.setattr(tab, "_confirm_routing_switch",
|
||||
lambda decision: asked.append(decision) or True)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert len(asked) == 1
|
||||
assert (tab._routed_provider, tab._routed_model) == ("anthropic", "claude-sonnet-4-6")
|
||||
|
||||
|
||||
def test_cowork_manual_mode_keeps_the_model_when_the_dialog_is_declined(ctx, monkeypatch):
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
_install(ctx, "manual")
|
||||
tab = CoworkTab(ctx)
|
||||
monkeypatch.setattr(tab, "_confirm_routing_switch", lambda _decision: False)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert (tab._routed_provider, tab._routed_model) == (None, None)
|
||||
assert turn["bubbles"] == []
|
||||
|
||||
|
||||
def test_a_pinned_admin_agent_still_wins_over_routing(ctx):
|
||||
"""An explicitly chosen Admin agent pins its own provider/model; routing must
|
||||
not override a deliberate user choice."""
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = CoworkTab(ctx)
|
||||
tab._admin_agent = object()
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert router.surfaces == []
|
||||
assert (tab._routed_provider, tab._routed_model) == (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Co4E
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_co4e_routes_on_its_own_surface_key_and_returns_the_model(ctx):
|
||||
from cowork_local.ui.co4e_tab import Co4ETab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = Co4ETab(ctx)
|
||||
|
||||
model = tab._apply_co4e_routing("build me a flow")
|
||||
|
||||
assert router.surfaces == ["co4e"]
|
||||
assert model == "claude-sonnet-4-6"
|
||||
assert tab._co4e_routed_provider == "anthropic"
|
||||
|
||||
|
||||
def test_co4e_returns_an_empty_model_when_routing_is_off(ctx):
|
||||
"""'' means "use the provider default" - the contract _run_chat_turn expects."""
|
||||
from cowork_local.ui.co4e_tab import Co4ETab
|
||||
|
||||
_install(ctx, "off")
|
||||
tab = Co4ETab(ctx)
|
||||
|
||||
assert tab._apply_co4e_routing("build me a flow") == ""
|
||||
assert tab._co4e_routed_provider is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AI-Edit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ai_edit_routes_on_its_own_surface_key(ctx):
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
|
||||
assert router.surfaces == ["ai_edit"]
|
||||
assert (tab._ai_routed_provider, tab._ai_routed_model) == (
|
||||
"anthropic", "claude-sonnet-4-6")
|
||||
|
||||
|
||||
def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
"""An edit instruction is never a QA question, so AI-Edit skips
|
||||
classification entirely - the constraint has to survive the move into the
|
||||
shared service or it is silently dropped."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
|
||||
seen: List[Any] = []
|
||||
|
||||
class _Recorder(_FakeRouter):
|
||||
def route(self, surface, prompt, current_provider, current_model, **kwargs):
|
||||
seen.append(kwargs.get("task_type"))
|
||||
return super().route(surface, prompt, current_provider, current_model, **kwargs)
|
||||
|
||||
ctx._routing_application = RoutingApplicationService(
|
||||
_Recorder(), mode_reader=lambda _s: "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
|
||||
assert seen == [TaskType.CODING]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The confirm dialog's field contract
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_decision_exposes_exactly_what_the_confirm_dialog_reads():
|
||||
"""``ui/routing_toggle.py::confirm_switch`` is not migrated until EPIC R08,
|
||||
so it still reads ``from_model``/``to_model`` as ``provider/model`` candidate
|
||||
keys and splits them. A rename here would blow up inside a modal dialog -
|
||||
the one place a failure is hardest to see in a test run."""
|
||||
from cowork_local.core.routing.models import split_key
|
||||
|
||||
decision = RoutingDecision(
|
||||
mode=RoutingMode.MANUAL, provider="anthropic", model="claude-sonnet-4-6",
|
||||
switched=True, task_type="coding", score_gain=0.31, reason="better fit",
|
||||
previous_provider="openai_compat", previous_model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
assert split_key(decision.from_model)[1] == "gpt-4o-mini"
|
||||
assert split_key(decision.to_model)[1] == "claude-sonnet-4-6"
|
||||
assert decision.task_type == "coding"
|
||||
assert f"{decision.score_gain:.2f}" == "0.31"
|
||||
assert decision.reason == "better fit"
|
||||
|
||||
|
||||
def test_a_first_turn_with_no_current_model_yields_an_empty_from_model():
|
||||
"""split_key() is only called when from_model is truthy, so an unset current
|
||||
model must produce "" rather than a bare "provider/"."""
|
||||
decision = RoutingDecision(mode=RoutingMode.AUTO, provider="anthropic",
|
||||
model="claude", switched=True)
|
||||
|
||||
assert decision.from_model == ""
|
||||
@@ -0,0 +1,178 @@
|
||||
"""End-to-end check of the Schedule Task path after R04-T05.
|
||||
|
||||
``core/task_executors.py::_run_agent`` used to assemble its own ``run_cowork``
|
||||
call, in parallel with ``ui/cowork_tab.py`` doing the same thing slightly
|
||||
differently. It now goes through ``ConversationApplicationService``, and the
|
||||
things most at risk from that change are exactly what this file pins:
|
||||
|
||||
* the unattended run still returns the answer text the scheduler writes to output.md
|
||||
* History is still re-saved from the LIVE message list after every assistant
|
||||
message, so a long run shows progress when reopened mid-flight
|
||||
* ``update_plan`` tracking still works, so a task whose checklist is unfinished
|
||||
is not reported as done
|
||||
* a failed run still raises, because ``execute_task`` writes error.txt from it
|
||||
|
||||
No Qt and no network: the provider is scripted and History is redirected into a
|
||||
tmp folder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.core import audit_log, chat_agent, task_executors
|
||||
from cowork_local.state import AppContext
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task_ctx(tmp_path: Path, monkeypatch):
|
||||
"""An AppContext whose History and audit log live in a tmp folder."""
|
||||
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
||||
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
# Same reason as the Cowork integration suite: the security pre-flight costs
|
||||
# an extra provider call that has nothing to do with what is being tested.
|
||||
ctx.config.agent_security["enabled"] = False
|
||||
monkeypatch.setattr(ctx.config, "history_dir", lambda: tmp_path / "history")
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history_saves(monkeypatch) -> List[List[Dict[str, Any]]]:
|
||||
"""Capture a SNAPSHOT of the messages at each History save.
|
||||
|
||||
Snapshotting matters: the engine keeps appending to the same list, so
|
||||
storing the list itself would make every recorded save look identical to the
|
||||
final state and the "live progress" assertion would prove nothing.
|
||||
"""
|
||||
saves: List[List[Dict[str, Any]]] = []
|
||||
|
||||
def fake_save(_dir, _kind, _session_id, messages, **_kwargs):
|
||||
saves.append([dict(m) for m in messages])
|
||||
|
||||
from cowork_local.core import history
|
||||
|
||||
monkeypatch.setattr(history, "save_conversation", fake_save)
|
||||
return saves
|
||||
|
||||
|
||||
def _run(ctx, provider, prompt="do the thing", out_dir: Path = None, **kwargs):
|
||||
"""Run one unattended cowork task with ``provider`` pinned."""
|
||||
ctx.build_active_provider = lambda: provider
|
||||
events: List[Dict[str, Any]] = []
|
||||
result = task_executors._run_agent(
|
||||
ctx, "cowork", prompt, out_dir, events.append, lambda: False,
|
||||
title=kwargs.pop("title", "T1"), **kwargs)
|
||||
return result, events
|
||||
|
||||
|
||||
def test_an_unattended_cowork_run_returns_the_answer(task_ctx, tmp_path, history_saves):
|
||||
provider = FakeProvider([ScriptedTurn(text="task answer")])
|
||||
|
||||
(answer, timed_out, incomplete), events = _run(task_ctx, provider,
|
||||
out_dir=tmp_path / "out")
|
||||
|
||||
assert answer == "task answer"
|
||||
assert timed_out is False
|
||||
assert incomplete == ""
|
||||
assert provider.call_count == 1
|
||||
|
||||
|
||||
def test_the_scheduler_still_gets_history_ready_before_the_turn_events(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
"""The scheduler refreshes the History panel on this event, so a running
|
||||
task's conversation shows up while it runs."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
_, events = _run(task_ctx, provider, out_dir=tmp_path / "out")
|
||||
|
||||
assert [e["type"] for e in events] == [
|
||||
"history_ready", "text", "assistant_done", "turn_completed"]
|
||||
|
||||
|
||||
def test_history_is_resaved_from_the_live_conversation_during_the_run(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
"""The reason ``begin_turn()`` exists: the service builds its own message
|
||||
list, and the scheduler needs THAT list - not the pre-turn copy - or the
|
||||
mid-run saves would only ever contain the original user message.
|
||||
"""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "x"})]),
|
||||
ScriptedTurn(text="Saved."),
|
||||
])
|
||||
|
||||
_run(task_ctx, provider, out_dir=tmp_path / "out")
|
||||
|
||||
# At least one save DURING the run already carried an assistant message,
|
||||
# and the final save carries the whole conversation.
|
||||
assert len(history_saves) >= 3 # initial + per assistant_done + final
|
||||
assert any(any(m["role"] == "assistant" for m in save)
|
||||
for save in history_saves[1:-1])
|
||||
assert [m["role"] for m in history_saves[-1]] == [
|
||||
"system", "user", "assistant", "tool", "assistant"]
|
||||
|
||||
|
||||
def test_an_unfinished_plan_is_reported_so_the_task_is_not_marked_done(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
"""plan_set tracking runs through the same emit path; losing it would let a
|
||||
task whose own checklist says "not finished" be reported as successful."""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("update_plan", {"steps": [
|
||||
{"title": "step one", "status": "running"}]})]),
|
||||
ScriptedTurn(text="stopping here"),
|
||||
])
|
||||
|
||||
(_answer, _timed_out, incomplete), _events = _run(task_ctx, provider,
|
||||
out_dir=tmp_path / "out")
|
||||
|
||||
assert incomplete != ""
|
||||
|
||||
|
||||
def test_a_completed_plan_reports_no_incompleteness(task_ctx, tmp_path, history_saves):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("update_plan", {"steps": [
|
||||
{"title": "step one", "status": "done"}]})]),
|
||||
ScriptedTurn(text="all done"),
|
||||
])
|
||||
|
||||
(_answer, _timed_out, incomplete), _events = _run(task_ctx, provider,
|
||||
out_dir=tmp_path / "out")
|
||||
|
||||
assert incomplete == ""
|
||||
|
||||
|
||||
def test_a_failed_run_still_raises_so_execute_task_writes_error_txt(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
provider = FakeProvider([ScriptedTurn(error="provider down"),
|
||||
ScriptedTurn(error="provider down")])
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
_run(task_ctx, provider, out_dir=tmp_path / "out")
|
||||
|
||||
assert "provider down" in str(excinfo.value)
|
||||
# The partial conversation is still saved - it is exactly what the user
|
||||
# needs to see after a failure.
|
||||
assert history_saves
|
||||
|
||||
|
||||
def test_a_per_task_provider_override_is_honoured(task_ctx, tmp_path, history_saves):
|
||||
"""A task can pin its own provider/model; the service must use that one, not
|
||||
the machine's Settings default."""
|
||||
default_provider = FakeProvider([], strict=True)
|
||||
task_provider = FakeProvider([ScriptedTurn(text="from the pinned model")])
|
||||
task_ctx.build_active_provider = lambda: default_provider
|
||||
task_ctx.build_provider_for = lambda _name, _model: task_provider
|
||||
|
||||
(answer, _timed_out, _incomplete) = task_executors._run_agent(
|
||||
task_ctx, "cowork", "go", tmp_path / "out", lambda _e: None, lambda: False,
|
||||
title="T", provider_name="anthropic", model="claude")[0:3]
|
||||
|
||||
assert answer == "from the pinned model"
|
||||
assert default_provider.call_count == 0
|
||||
assert task_provider.call_count == 1
|
||||
Reference in New Issue
Block a user