## 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:
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,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)
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user