Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+29
View File
@@ -0,0 +1,29 @@
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.
Gói này cố ý không import sẵn fake nào để tránh kéo theo phụ thuộc khi chạy cô lập.
Hỗ trợ import trực tiếp từ module con hoặc import lười từ package.
"""
from typing import Any
def __getattr__(name: str) -> Any:
if name in ("FakeProvider", "RecordedCall", "ScriptedTurn"):
from .fake_provider import FakeProvider, RecordedCall, ScriptedTurn
return locals()[name]
if name in ("FakeToolExecutor", "ToolInvocation"):
from .fake_tool_executor import FakeToolExecutor, ToolInvocation
return locals()[name]
if name == "FakeClock":
from .fake_clock import FakeClock
return FakeClock
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"FakeClock",
"FakeProvider",
"FakeToolExecutor",
"RecordedCall",
"ScriptedTurn",
"ToolInvocation",
]
+54
View File
@@ -0,0 +1,54 @@
"""FakeClock - offline stand-in for ``platform/qt/qt_scheduler_clock.py::
QtSchedulerClock`` (R07-T03).
``TaskScheduler`` (``core/task_scheduler.py``) needs a clock that can
``start(interval_ms, callback)`` / ``stop()`` / ``pump()``. In production
that's a real ``QTimer``, which means testing dispatch logic (what runs, in
what order, what gets re-armed) would otherwise require a live Qt event loop
ticking every 30 seconds. This double satisfies the same duck-typed
interface with manual control: ``fire()`` calls the scripted callback once,
synchronously, on whichever thread the test is running on - no timers, no
event loop, no waiting.
"""
from __future__ import annotations
from typing import Callable, Optional
class FakeClock:
"""Scriptable stand-in for :class:`QtSchedulerClock`.
Args:
running: whether ``start()`` has been called and ``stop()`` hasn't
since - a test can assert on this to check lifecycle wiring.
pump_count: how many times ``pump()`` was called - lets a test on
``TaskScheduler.stop()``'s drain loop assert the event loop was
actually pumped while waiting for workers.
"""
def __init__(self) -> None:
self._callback: Optional[Callable[[], None]] = None
self.interval_ms: Optional[int] = None
self.running: bool = False
self.pump_count: int = 0
def start(self, interval_ms: int, callback: Callable[[], None]) -> None:
self.interval_ms = interval_ms
self._callback = callback
self.running = True
def stop(self) -> None:
self.running = False
def pump(self) -> None:
self.pump_count += 1
def fire(self) -> None:
"""Test helper: manually trigger one tick, as if the interval had
elapsed. A no-op when the clock isn't running (matches a real
``QTimer`` never firing after ``stop()``)."""
if self.running and self._callback is not None:
self._callback()
__all__ = ["FakeClock"]
+174
View File
@@ -0,0 +1,174 @@
"""``Co4EWorkflowService`` giả — cho widget Co4E Studio (presentation/) và cho
test khác dùng khi service thật
(``application/workflows/co4e_workflow_service.py``) chưa được ``bootstrap.py``
lắp vào, hoặc khi test không muốn chạm đĩa/AI thật.
Chạy hoàn toàn trong bộ nhớ, đồng bộ, không cần ``runner`` thật (không
``AgentWorker``/``QThread`` nào được tạo): ``start()`` ghi nhận run ở trạng
thái "running" rồi đứng yên — muốn mô phỏng tiến trình thì test tự gọi
``deliver_event``/``mark_finished``/``mark_failed``, giống hệt cách
``tests/characterization/test_co4e_run_manager_behavior.py`` seed tay vào
``Co4ERunManager`` thật rồi gọi ``_on_event``/``_on_finished``/``_on_failed``.
Ví dụ dùng::
>>> from tests.fakes.fake_co4e_workflow_service import FakeCo4EWorkflowService
>>> class _Wf:
... id = "wf1"; name = "Flow"; nodes = []; edges = []
>>> svc = FakeCo4EWorkflowService()
>>> run_id = svc.start(_Wf())
>>> svc.started_workflows[0].id
'wf1'
>>> svc.runs()[0].status
'running'
>>> svc.mark_finished(run_id)
>>> svc.runs()[0].status
'done'
"""
from __future__ import annotations
from pathlib import Path
from typing import Callable, Dict, List, Optional
from cowork_local.domain.workflows.run_record import RunRecord
# Mirror dung gia tri cua STEP_DONE/STEP_ERROR/STEP_PLANNED (core/co4e.py) ma
# khong import core/ o day -- fake nay chi phu thuoc domain/, giu no nhe va
# nhanh de import trong test cua team khac.
_TERMINAL_NODE = {"done", "error", "planned"}
class FakeCo4EWorkflowService:
"""Bản giả của ``Co4EWorkflowService`` — cùng API công khai, ghi lại mọi
lời gọi để test khẳng định được "có gọi service không" và "gọi với gì"."""
def __init__(self):
self._runs: Dict[str, RunRecord] = {}
self._seq = 0
self._project_id: str = ""
self._output_root: Optional[Path] = None
self._changed_callbacks: List[Callable[[], None]] = []
self._event_callbacks: List[Callable[[str, dict], None]] = []
#: moi workflow da duoc start(), dung thu tu goi -- test khang dinh
#: "co goi service.start() khong" ma khong can thuc thi that.
self.started_workflows: list = []
self.stopped_run_ids: List[str] = []
self.removed_run_ids: List[str] = []
self.renamed: List[tuple] = []
# ---- callback thay Signal (giong Co4EWorkflowService that) -------------
def on_changed(self, cb: Callable[[], None]) -> None:
self._changed_callbacks.append(cb)
def on_event(self, cb: Callable[[str, dict], None]) -> None:
self._event_callbacks.append(cb)
def _emit_changed(self) -> None:
for cb in self._changed_callbacks:
cb()
def _emit_event(self, run_id: str, ev: dict) -> None:
for cb in self._event_callbacks:
cb(run_id, ev)
# ---- lifecycle ----------------------------------------------------
def start(self, wf, *, skill_map=None, plan_mode: bool = False, only_nodes=None,
seed_outputs=None, manual: bool = False, label: Optional[str] = None) -> str:
self._seq += 1
run_id = f"run{self._seq}"
nodes = getattr(wf, "nodes", None) or []
total = len(only_nodes) if only_nodes else len(nodes)
record = RunRecord(run_id, getattr(wf, "id", ""), label or getattr(wf, "name", ""),
total, plan_mode, manual, project_id=self._project_id)
self._runs[run_id] = record
self.started_workflows.append(wf)
self._emit_changed()
return run_id
# ---- hook gia lap tien trinh (goi TU TEST, khong phai tu runner that) --
def deliver_event(self, run_id: str, ev: dict) -> None:
"""Mo phong dung ``Co4EWorkflowService._on_event`` that."""
record = self._runs.get(run_id)
if record is not None and isinstance(ev, dict):
t = ev.get("type")
if t == "node_status":
record.node_status[ev.get("node_id")] = ev.get("status")
record.done = sum(1 for s in record.node_status.values() if s in _TERMINAL_NODE)
self._emit_changed()
elif t == "run_done":
if record.status == "running":
record.status = "done" if ev.get("ok", True) else "error"
self._emit_changed()
self._emit_event(run_id, ev)
def mark_finished(self, run_id: str) -> None:
record = self._runs.get(run_id)
if record is not None and record.status == "running":
record.status = "done"
self._emit_changed()
def mark_failed(self, run_id: str, err: str) -> None:
record = self._runs.get(run_id)
if record is not None:
record.status = "error"
record.error = str(err)
self._emit_event(run_id, {"type": "run_error", "error": str(err)})
self._emit_changed()
# ---- control --------------------------------------------------------
def stop(self, run_id: str) -> None:
record = self._runs.get(run_id)
if record is not None and record.running:
record.status = "stopped"
self.stopped_run_ids.append(run_id)
self._emit_changed()
def stop_all(self) -> None:
for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]:
self.stop(run_id)
def rename(self, run_id: str, new_name: str) -> None:
record = self._runs.get(run_id)
new_name = (new_name or "").strip()
if record is None or not new_name or new_name == record.name:
return
record.name = new_name
if record.wf is not None:
record.wf["name"] = new_name
self.renamed.append((run_id, new_name))
self._emit_changed()
def remove(self, run_id: str) -> None:
self._runs.pop(run_id, None)
self.removed_run_ids.append(run_id)
self._emit_changed()
def clear_finished(self) -> None:
for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]:
self._runs.pop(run_id, None)
self._emit_changed()
# ---- queries ----------------------------------------------------------
def _belongs(self, r: RunRecord) -> bool:
return getattr(r, "project_id", "") == self._project_id
def runs(self) -> List[RunRecord]:
return [r for r in self._runs.values() if self._belongs(r)]
def all_runs(self) -> List[RunRecord]:
return list(self._runs.values())
def get(self, run_id: str) -> Optional[RunRecord]:
return self._runs.get(run_id)
def active_count(self) -> int:
return sum(1 for r in self._runs.values() if r.running and self._belongs(r))
def set_current_project(self, project_id: str) -> None:
pid = project_id or ""
if pid != self._project_id:
self._project_id = pid
self._emit_changed()
def set_output_root(self, root) -> None:
self._output_root = Path(root) if root else None
+138
View File
@@ -0,0 +1,138 @@
"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ.
Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi
bản thật xong ngày 23/08 và 26/08.
Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài
mili giây.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
class FakeSecretStore:
"""SecretStore trong bộ nhớ.
>>> s = FakeSecretStore({"provider:openai": "sk-test"})
>>> s.get("provider:openai")
'sk-test'
>>> s.get("provider:chua-co") is None
True
"""
def __init__(self, seed: Dict[str, str] | None = None):
self._items: Dict[str, str] = dict(seed or {})
def get(self, key: str) -> str | None:
return self._items.get(key)
def set(self, key: str, value: str) -> None:
self._items[key] = value
def delete(self, key: str) -> None:
self._items.pop(key, None)
def has(self, key: str) -> bool:
return key in self._items
class FakeConfigRepository:
"""ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý.
Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống
mình cần::
cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung")
"""
def __init__(self, *, active_provider: str = "ollama",
providers: Dict[str, Dict[str, Any]] | None = None,
shared_dir: str = "", theme: str = "dark", language: str = "vi",
routing: Dict[str, Any] | None = None,
auth: Dict[str, Any] | None = None,
agent_security: Dict[str, Any] | None = None,
tools_disabled: list[str] | None = None,
history_dir: Path | None = None,
output_dir: Path | None = None):
self._active_provider = active_provider
self._providers = providers or {
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"},
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"},
}
self._shared_dir = shared_dir
self._theme = theme
self._language = language
self._routing = routing or {"mode": "off"}
self._auth = auth or {}
self._agent_security = agent_security or {"cowork_confirm_commands": True}
self._tools_disabled = list(tools_disabled or [])
self._history_dir = history_dir or Path("/fake/history")
self._output_dir = output_dir or Path("/fake/workspace")
#: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa
self.saves = 0
# ---- provider ------------------------------------------------------
@property
def active_provider(self) -> str:
return self._active_provider
def set_active_provider(self, name: str) -> None:
self._active_provider = name
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
return dict(self._providers.get(name or self._active_provider, {}))
# ---- đường dẫn -----------------------------------------------------
@property
def shared_dir(self) -> str:
return self._shared_dir
def history_dir(self) -> Path:
return self._history_dir
def cowork_output_dir(self) -> Path:
return self._output_dir
# ---- giao diện -----------------------------------------------------
@property
def theme(self) -> str:
return self._theme
def set_theme(self, value: str) -> None:
self._theme = value
@property
def language(self) -> str:
return self._language
def set_language(self, value: str) -> None:
self._language = value
# ---- nhóm cấu hình --------------------------------------------------
@property
def routing(self) -> Dict[str, Any]:
return self._routing
@property
def auth(self) -> Dict[str, Any]:
return self._auth
@property
def agent_security(self) -> Dict[str, Any]:
return self._agent_security
@property
def tools_disabled(self) -> list[str]:
return list(self._tools_disabled)
def set_tool_enabled(self, name: str, enabled: bool) -> None:
if enabled:
self._tools_disabled = [t for t in self._tools_disabled if t != name]
elif name not in self._tools_disabled:
self._tools_disabled.append(name)
# ---- ghi ------------------------------------------------------------
def save(self) -> None:
self.saves += 1
+212
View File
@@ -0,0 +1,212 @@
"""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
import itertools
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
try:
from providers.base import (
CancelFn,
Provider,
ProviderError,
TextCallback,
ToolSpec,
)
except ImportError:
from cowork_local.providers.base import (
CancelFn,
Provider,
ProviderError,
TextCallback,
ToolSpec,
)
ToolCallScript = Tuple[str, Dict[str, Any]]
@dataclass(frozen=True)
class ScriptedTurn:
"""What :class:`FakeProvider` should do for ONE ``chat()`` call."""
text: str = ""
reasoning: str = ""
tool_calls: Sequence[Any] = ()
error: Optional[str] = None
chunk_size: int = 0
@dataclass
class RecordedCall:
"""A snapshot of one ``chat()`` invocation, for assertions after the fact."""
messages: List[Dict[str, Any]]
tool_names: List[str]
cancelled: bool = False
class FakeProvider(Provider):
"""Deterministic test double mimicking real LLM Providers."""
name = "fake"
supports_vision = True
def __init__(
self,
turns: Optional[Sequence[ScriptedTurn]] = None,
*,
model: str = "fake-model",
models: Optional[Sequence[str]] = None,
strict: bool = False,
conf: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(dict(conf or {}, model=model))
self._turns: List[ScriptedTurn] = list(turns or [])
self._models = list(models or [model])
self._strict = strict
self._ids = itertools.count(1)
self.calls: List[RecordedCall] = []
self.call_history: List[List[Dict[str, Any]]] = []
self.response_queue: List[Dict[str, Any]] = []
self.error_queue: List[Exception] = []
self.default_text: str = "Fake model response."
self.last_tools: Optional[List[ToolSpec]] = None
@property
def call_count(self) -> int:
return len(self.calls)
@property
def remaining_turns(self) -> int:
return len(self._turns) + len(self.response_queue)
def last_messages(self) -> List[Dict[str, Any]]:
return self.calls[-1].messages if self.calls else []
def queue_response(
self,
content: str = "",
tool_calls: Optional[List[Dict[str, Any]]] = None,
reasoning: Optional[str] = None,
chunks: Optional[List[str]] = None,
) -> "FakeProvider":
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":
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]:
self.last_tools = tools
self.call_history.append([dict(m) for m in messages])
record = RecordedCall(
messages=[dict(m) for m in messages],
tool_names=[t.name for t in (tools or [])],
)
self.calls.append(record)
if cancel is not None and cancel():
record.cancelled = True
raise ProviderError("Execution aborted by user cancel")
if self.error_queue:
err = self.error_queue.pop(0)
raise err
if self.response_queue:
resp = self.response_queue.pop(0)
if resp.get("reasoning") and on_reasoning:
on_reasoning(resp["reasoning"])
for chunk in resp.get("chunks", []):
if cancel is not None and cancel():
record.cancelled = True
raise ProviderError("Execution aborted by user cancel")
if on_text:
on_text(chunk)
out = {
"role": "assistant",
"content": resp.get("content", ""),
}
if resp.get("tool_calls"):
out["tool_calls"] = resp["tool_calls"]
return out
if self._turns:
turn = self._turns.pop(0)
if self._is_cancelled(cancel):
record.cancelled = True
return {"role": "assistant", "content": ""}
if turn.error:
raise ProviderError(turn.error)
if turn.reasoning and on_reasoning:
on_reasoning(turn.reasoning)
for piece in self._stream_pieces(turn):
if self._is_cancelled(cancel):
record.cancelled = True
break
if on_text:
on_text(piece)
formatted_tool_calls = []
for tc in turn.tool_calls:
if isinstance(tc, dict):
formatted_tool_calls.append(tc)
elif isinstance(tc, (tuple, list)) and len(tc) == 2:
formatted_tool_calls.append({
"id": f"call_{next(self._ids)}",
"name": tc[0],
"arguments": dict(tc[1]),
})
out = {
"role": "assistant",
"content": turn.text,
}
if formatted_tool_calls:
out["tool_calls"] = formatted_tool_calls
return out
if self._strict:
raise AssertionError(
f"FakeProvider script exhausted: chat() was called {len(self.calls)} "
"time(s) but fewer turns were scripted."
)
if on_text:
on_text(self.default_text)
return {
"role": "assistant",
"content": self.default_text,
}
def list_models(self) -> List[str]:
self.last_error = ""
return list(self._models)
@staticmethod
def _stream_pieces(turn: ScriptedTurn) -> List[str]:
if not turn.text:
return []
if turn.chunk_size <= 0:
return [turn.text]
size = turn.chunk_size
return [turn.text[i:i + size] for i in range(0, len(turn.text), size)]
__all__ = ["FakeProvider", "ScriptedTurn", "RecordedCall"]
+71
View File
@@ -0,0 +1,71 @@
"""Fake Tool Executor for isolated, offline agent tool-call verification.
Allows tests to verify tool invocation arguments, mock tool return values,
and simulate failures/delays without performing unsafe host disk or OS operations.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
class FakeToolExecutor:
"""Mock execution engine for agent tool-call dispatching."""
def __init__(self) -> None:
# History of all executed tool invocations: List of {"name": str, "args": dict, "result": dict}
self.call_log: List[Dict[str, Any]] = []
# Custom handlers registered per tool name
self.handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
# Pre-programmed fixed responses keyed by tool name
self.mock_responses: Dict[str, Dict[str, Any]] = {}
# Default response when no specific handler or response is found
self.default_result: Dict[str, Any] = {"ok": True, "output": "Fake tool executed successfully."}
def register_handler(
self,
tool_name: str,
handler: Callable[[Dict[str, Any]], Dict[str, Any]],
) -> FakeToolExecutor:
"""Register a dynamic handler function for a specific tool name."""
self.handlers[tool_name] = handler
return self
def set_mock_response(
self,
tool_name: str,
result: Dict[str, Any],
) -> FakeToolExecutor:
"""Set a static return payload for a specific tool name."""
self.mock_responses[tool_name] = result
return self
def execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a tool call using registered mocks and record invocation details."""
# 1. Resolve result from handler, preset response, or default fallback
if tool_name in self.handlers:
result = self.handlers[tool_name](arguments)
elif tool_name in self.mock_responses:
result = self.mock_responses[tool_name]
else:
result = dict(self.default_result)
result["tool"] = tool_name
result["received_args"] = arguments
# 2. Record execution trace for post-test assertions
self.call_log.append({
"name": tool_name,
"args": dict(arguments),
"result": dict(result),
})
return result
def get_calls_for(self, tool_name: str) -> List[Dict[str, Any]]:
"""Retrieve all recorded calls for a given tool name."""
return [call for call in self.call_log if call["name"] == tool_name]
def reset(self) -> None:
"""Clear recorded logs and registered mock responses."""
self.call_log.clear()
self.handlers.clear()
self.mock_responses.clear()
+44
View File
@@ -0,0 +1,44 @@
"""ToolPolicyGateway giả — để N3 (Co4E) chạy được khi Team Hoa chưa cài đặt.
Mặc định cho qua hết, vì phần lớn test Co4E quan tâm tới luồng workflow chứ
không phải chính sách. Test nào cần kiểm nhánh bị chặn thì lập trình câu trả
lời::
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
"""
from __future__ import annotations
from typing import Callable, Dict
from cowork_local.domain.security.tool_policy import (
PolicyDecision, ToolCallRequest, allow,
)
class FakeToolPolicyGateway:
"""Cổng chính sách trong bộ nhớ, có ghi lại đã hỏi những gì."""
def __init__(self, rules: Dict[str, PolicyDecision] | None = None,
default: PolicyDecision | None = None,
decide: Callable[[ToolCallRequest], PolicyDecision] | None = None):
#: {tên tool: quyết định} — tra trước default
self.rules = dict(rules or {})
self.default = default or allow()
#: hàm tự quyết, dùng khi cần logic phức tạp hơn tra bảng
self._decide = decide
#: mọi lời gọi đã đi qua — để test khẳng định "có hỏi cổng không"
self.seen: list[ToolCallRequest] = []
def check(self, request: ToolCallRequest) -> PolicyDecision:
self.seen.append(request)
if self._decide is not None:
return self._decide(request)
return self.rules.get(request.name, self.default)
# ---- tiện cho test --------------------------------------------------
def asked_for(self, name: str) -> bool:
return any(r.name == name for r in self.seen)
@property
def call_count(self) -> int:
return len(self.seen)
+141
View File
@@ -0,0 +1,141 @@
"""Offline test doubles for the R04 turn runtime seams.
Sits beside ``fake_provider.py``/``fake_tool_executor.py`` (R01-T02) and plays
the same role one level up: those fake a *provider*, these fake the ports
``ConversationApplicationService`` is driven through
(``application/conversations/turn_runtime.py``).
Deliberately dumb — they record what they were asked and return canned answers.
A failing test then points at the service under test rather than at a mock
framework's configuration.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from cowork_local.domain.agents.agent_event import ToolPreview
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
class FakeSpec:
"""An advertised tool. The service only ever reads ``.name`` off a spec."""
def __init__(self, name: str) -> None:
self.name = name
class FakeReply:
"""One programmed provider answer."""
def __init__(self, content: str = "", tool_calls=None, chunks=None, reasoning: str = ""):
self.content = content
self.tool_calls = tool_calls or []
# Default to streaming the whole content as a single chunk, which is what
# a non-streaming gateway effectively does.
self.chunks = chunks if chunks is not None else ([content] if content else [])
self.reasoning = reasoning
class FakeModelCall:
""":class:`ModelCallPort` returning programmed replies in order.
A programmed entry may be an exception instead of a reply, which is how a
test simulates the gateway dying mid-turn.
"""
def __init__(self, replies: List[Any]) -> None:
self.replies = list(replies)
self.calls: List[Dict[str, Any]] = []
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
# Snapshot the messages: the service keeps mutating its own list, so
# storing it by reference would make every recorded call look identical.
self.calls.append({"messages": [dict(m) for m in messages],
"tool_names": [getattr(t, "name", "") for t in tools]})
reply = self.replies.pop(0) if self.replies else FakeReply(content="(default)")
if isinstance(reply, BaseException):
raise reply
if reply.reasoning and on_reasoning:
on_reasoning(reply.reasoning)
for chunk in reply.chunks:
if on_text and chunk:
on_text(chunk)
assistant: Dict[str, Any] = {"role": "assistant", "content": reply.content}
if reply.tool_calls:
assistant["tool_calls"] = reply.tool_calls
return assistant
class FakeToolRuntime:
""":class:`ToolRuntimePort` over an imaginary output folder."""
def __init__(self, specs=("save_file", "run_command", "update_plan"),
results: Optional[Dict[str, Dict[str, Any]]] = None,
removed: Tuple[str, ...] = (), added: Tuple[str, ...] = ()) -> None:
self._specs = [FakeSpec(n) for n in specs]
self._results = results or {}
self._removed, self._added = removed, added
self.executed: List[Tuple[str, Dict[str, Any]]] = []
self.finalize_calls: List[Dict[str, Any]] = []
# When set, every executed tool streams this string through ``on_output``.
self.emit_output: Optional[str] = None
def specs(self, allowed_tools=None):
if allowed_tools is None:
return list(self._specs)
return [s for s in self._specs if s.name in allowed_tools]
def preview(self, name, args):
return ToolPreview(kind="info", title=name, text=str(args))
def execute(self, name, args, on_output=None, cancel=None):
self.executed.append((name, dict(args)))
if self.emit_output and on_output:
on_output(self.emit_output)
return dict(self._results.get(name, {"ok": True, "output": f"{name} ok"}))
def snapshot(self):
return "before"
def finalize(self, before, cancelled=False):
self.finalize_calls.append({"before": before, "cancelled": cancelled})
return list(self._removed), list(self._added)
# --------------------------------------------------------------------------- #
# Small helpers shared by the turn tests.
# --------------------------------------------------------------------------- #
def make_request(**overrides) -> ConversationExecutionRequest:
"""A minimal valid request; each test overrides only what it exercises."""
base: Dict[str, Any] = {"turn_id": "t1", "session_id": "s1", "prompt": "do it"}
base.update(overrides)
return ConversationExecutionRequest(**base)
def run_turn(service, request=None, cancel=None):
"""Execute a turn and return ``(result, events)``."""
events: List[Any] = []
result = service.execute(request or make_request(), events.append, cancel=cancel)
return result, events
def events_of_type(events, cls):
"""Every emitted event of one type, in order."""
return [e for e in events if isinstance(e, cls)]
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
"""A turn that calls one tool and then answers — ``(model, tools)``."""
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
FakeReply(content="done")])
return model, FakeToolRuntime(**tool_kwargs)
__all__ = [
"FakeSpec", "FakeReply", "FakeModelCall", "FakeToolRuntime",
"make_request", "run_turn", "events_of_type", "tool_turn",
]