Compare commits
9
Commits
develop
...
8ab29800db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab29800db | ||
|
|
cf542b7416 | ||
|
|
ae4fe72b2e | ||
|
|
6d3217e0b5 | ||
|
|
67b8d2edbb | ||
|
|
15e1d3eb65 | ||
|
|
a53163ebaf | ||
|
|
96bec976e7 | ||
|
|
bbc09f628a |
@@ -0,0 +1,12 @@
|
||||
"""Application layer - pure Python use-case orchestration.
|
||||
|
||||
Sits between ``presentation/`` (Qt widgets) and ``domain/`` (entities). A module
|
||||
here answers "what has to happen, in what order" for one use case - route a
|
||||
turn, run a conversation - without knowing whether a human, a scheduler or a
|
||||
test triggered it.
|
||||
|
||||
Hard rule (ADR-001 I1/I3, enforced by ``scripts/check_imports.py``): no
|
||||
PySide6/PyQt imports and no reach into ``presentation/``/``ui/``. Results travel
|
||||
back up through plain-Python callbacks; turning those into Qt signals is the
|
||||
presentation layer's job.
|
||||
"""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Conversation use case: the lifecycle of one agent turn (EPIC R04) and the
|
||||
tool approval policy every turn's tool calls go through (EPIC R05)."""
|
||||
|
||||
from .conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
TurnResult,
|
||||
)
|
||||
from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway
|
||||
|
||||
__all__ = ["ConversationApplicationService", "TurnResult", "ToolPolicyGateway", "ConfirmGate"]
|
||||
@@ -0,0 +1,328 @@
|
||||
"""ConversationApplicationService - the turn lifecycle, outside the widget (R04-T03).
|
||||
|
||||
What this replaces
|
||||
------------------
|
||||
The lifecycle of one Cowork turn is currently spread across a closure inside
|
||||
``ui/cowork_tab.py::build_job`` and a second, near-identical assembly inside
|
||||
``core/task_executors.py::_run_agent``. Both:
|
||||
|
||||
* read live UI/config state from a worker thread,
|
||||
* build the provider, the MCP tool set and the project context by hand,
|
||||
* call ``core.chat_agent.run_cowork`` with a dozen positional-ish arguments,
|
||||
* consume untyped event dicts.
|
||||
|
||||
Two copies means a fix to one path (say, promoting output files on failure)
|
||||
silently misses the other. This service is the single implementation: it takes
|
||||
an immutable :class:`ConversationExecutionRequest`, runs the turn, and reports
|
||||
typed :class:`AgentEvent` objects.
|
||||
|
||||
What it deliberately does NOT do
|
||||
--------------------------------
|
||||
It does not re-implement the agent loop. ``run_cowork`` stays the engine
|
||||
(strangler fig, ADR-001 section 4) and keeps its characterization tests
|
||||
(``tests/characterization/test_run_cowork.py``). This layer owns the parts that
|
||||
were tangled into the UI: assembling the call, translating events, and giving a
|
||||
turn a well-defined end.
|
||||
|
||||
Pure Python: no Qt import, no config access. Everything it needs arrives through
|
||||
constructor callbacks, so the same service runs a turn from a chat panel, from
|
||||
the scheduler, or from a test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from cowork_local.domain.agents.agent_event import (
|
||||
AgentEvent,
|
||||
ErrorEvent,
|
||||
TurnCompletedEvent,
|
||||
collect_text,
|
||||
event_from_dict,
|
||||
)
|
||||
from cowork_local.domain.agents.conversation_execution_request import (
|
||||
ConversationExecutionRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cowork_local.conversations")
|
||||
|
||||
# Presentation/scheduler supplies these. Kept as plain callables (not objects)
|
||||
# so a test can wire the service with three lambdas.
|
||||
EventCallback = Callable[[AgentEvent], None]
|
||||
CancelFn = Callable[[], bool]
|
||||
ProviderFactory = Callable[[str, str], Any] # (provider_id, model) -> Provider
|
||||
ToolSourceFactory = Callable[[], Tuple[Any, Any]] # () -> (extra_tools, extra_executor)
|
||||
GateFactory = Callable[[ConversationExecutionRequest], Any] # -> PermissionGate or None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
"""What a finished turn produced.
|
||||
|
||||
``messages`` is the conversation AFTER the turn (system prompt inserted,
|
||||
assistant and tool messages appended) - the caller persists this as the new
|
||||
history. ``final_text`` is the visible answer, reasoning excluded.
|
||||
"""
|
||||
|
||||
request: ConversationExecutionRequest
|
||||
messages: List[Dict[str, Any]] = field(default_factory=list)
|
||||
events: List[AgentEvent] = field(default_factory=list)
|
||||
final_text: str = ""
|
||||
cancelled: bool = False
|
||||
error: str = ""
|
||||
# The original exception, kept alongside its message so a caller that needs
|
||||
# to preserve legacy failure handling can re-raise the SAME object rather
|
||||
# than a lookalike (SecurityBlocked, for instance, carries context that a
|
||||
# re-wrapped RuntimeError would lose).
|
||||
exception: Optional[BaseException] = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""True when the turn completed without an error and without a Stop."""
|
||||
return not self.error and not self.cancelled
|
||||
|
||||
def raise_if_failed(self) -> None:
|
||||
"""Re-raise the turn's failure, if any.
|
||||
|
||||
Callers that already have failure handling built around an exception
|
||||
(the Qt worker turns one into its ``failed`` signal) use this to keep
|
||||
that path intact while still getting a TurnResult on success."""
|
||||
if self.exception is not None:
|
||||
raise self.exception
|
||||
|
||||
def output_dir(self) -> Optional[Path]:
|
||||
"""This turn's output folder, or None when it could not write files."""
|
||||
return Path(self.request.output_dir) if self.request.output_dir else None
|
||||
|
||||
|
||||
class ConversationApplicationService:
|
||||
"""Runs one agent turn from an immutable request.
|
||||
|
||||
Args:
|
||||
provider_factory: ``(provider_id, model) -> Provider``. Production passes
|
||||
``AppContext.build_provider_for``; tests pass a lambda returning a
|
||||
:class:`FakeProvider`.
|
||||
tool_source: ``() -> (extra_tools, extra_executor)`` for MCP/connector
|
||||
tools. Optional - a turn with no external tools passes nothing.
|
||||
gate_factory: ``(request) -> PermissionGate | None``, consulted when the
|
||||
request asks to confirm commands. Optional for the same reason.
|
||||
runner: the turn engine. Defaults to ``core.chat_agent.run_cowork``,
|
||||
imported lazily so this module stays importable (and testable)
|
||||
without pulling in the whole legacy tool stack.
|
||||
security_config: the app config the security layers read. ``None``
|
||||
disables them, which is what headless callers already rely on.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_factory: ProviderFactory,
|
||||
*,
|
||||
tool_source: Optional[ToolSourceFactory] = None,
|
||||
gate_factory: Optional[GateFactory] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
security_config: Any = None,
|
||||
) -> None:
|
||||
self._provider_factory = provider_factory
|
||||
self._tool_source = tool_source
|
||||
self._gate_factory = gate_factory
|
||||
self._runner = runner
|
||||
self._security_config = security_config
|
||||
|
||||
# -- main entry point -------------------------------------------------- #
|
||||
def run_turn(
|
||||
self,
|
||||
request: ConversationExecutionRequest,
|
||||
on_event: Optional[EventCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
) -> TurnResult:
|
||||
"""Execute one turn and return everything it produced.
|
||||
|
||||
Never raises: a provider or tool failure becomes an :class:`ErrorEvent`
|
||||
plus ``TurnResult.error``. Callers run this on a worker thread and have
|
||||
no good way to handle an exception crossing that boundary - today an
|
||||
escaped error kills the worker and the UI just stops updating, with no
|
||||
message shown.
|
||||
|
||||
Exactly one :class:`TurnCompletedEvent` is always emitted last, whether
|
||||
the turn succeeded, failed or was cancelled. That is the end-of-turn
|
||||
signal the legacy engine never had.
|
||||
"""
|
||||
return self.execute_turn(self.begin_turn(request), on_event=on_event, cancel=cancel)
|
||||
|
||||
def begin_turn(self, request: ConversationExecutionRequest) -> TurnResult:
|
||||
"""Create the (still empty) result a turn will fill in.
|
||||
|
||||
Exposed separately from :meth:`run_turn` because some callers need the
|
||||
LIVE message list while the turn is running, not only afterwards: the
|
||||
scheduler re-saves the conversation to History after every assistant
|
||||
message so a long unattended run shows live progress when reopened.
|
||||
Handing them ``result.messages`` - the very list the engine appends to -
|
||||
is what makes that possible without leaking the engine into the caller.
|
||||
"""
|
||||
return TurnResult(request=request, messages=request.message_list())
|
||||
|
||||
def execute_turn(
|
||||
self,
|
||||
result: TurnResult,
|
||||
on_event: Optional[EventCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
) -> TurnResult:
|
||||
"""Run a turn previously created by :meth:`begin_turn`. See
|
||||
:meth:`run_turn` for the error/cancellation contract."""
|
||||
request = result.request
|
||||
emit = self._make_emitter(result, on_event)
|
||||
cancel = cancel or (lambda: False)
|
||||
|
||||
try:
|
||||
self._execute(request, result, emit, cancel)
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
result.error = str(exc) or exc.__class__.__name__
|
||||
result.exception = exc
|
||||
logger.exception("turn %s failed", request.turn_id)
|
||||
emit(ErrorEvent(message=result.error,
|
||||
recoverable=self._is_recoverable(exc)))
|
||||
|
||||
result.cancelled = bool(cancel())
|
||||
result.final_text = collect_text(result.events) or self._last_assistant_text(result.messages)
|
||||
emit(TurnCompletedEvent(content=result.final_text, cancelled=result.cancelled))
|
||||
return result
|
||||
|
||||
# -- internals --------------------------------------------------------- #
|
||||
def _execute(self, request: ConversationExecutionRequest, result: TurnResult,
|
||||
emit: Callable[[AgentEvent], None], cancel: CancelFn) -> None:
|
||||
"""Assemble the engine call from the request snapshot and run it."""
|
||||
provider = self._provider_factory(request.provider, request.model)
|
||||
extra_tools, extra_executor = self._resolve_tools()
|
||||
gate = self._resolve_gate(request)
|
||||
|
||||
# The engine speaks untyped dicts; bridge them into typed events at this
|
||||
# single point rather than at every consumer.
|
||||
def legacy_emit(payload: Dict[str, Any]) -> None:
|
||||
event = event_from_dict(payload)
|
||||
if event is not None:
|
||||
emit(event)
|
||||
|
||||
run = self._resolve_runner()
|
||||
run(
|
||||
provider,
|
||||
result.messages, # mutated in place by the engine, as before
|
||||
self._output_dir(request),
|
||||
legacy_emit,
|
||||
cancel,
|
||||
title=request.title,
|
||||
extra_tools=extra_tools,
|
||||
extra_executor=extra_executor,
|
||||
project_context=request.project_context,
|
||||
security_config=self._security_config,
|
||||
gate=gate,
|
||||
allowed_tools=list(request.allowed_tools) if request.allowed_tools is not None else None,
|
||||
max_steps=request.max_steps,
|
||||
run_to_completion=request.run_to_completion,
|
||||
completion_max_steps=request.completion_max_steps,
|
||||
enforce_rules=request.enforce_rules,
|
||||
**self._role_kwargs(request),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_emitter(result: TurnResult,
|
||||
on_event: Optional[EventCallback]) -> Callable[[AgentEvent], None]:
|
||||
"""Record every event on the result AND forward it to the caller.
|
||||
|
||||
Recording is unconditional so a headless caller (the scheduler) can read
|
||||
the full event list afterwards without having to supply a callback just
|
||||
to collect it - which is exactly what task_executors does today with an
|
||||
ad-hoc list.
|
||||
"""
|
||||
def emit(event: AgentEvent) -> None:
|
||||
result.events.append(event)
|
||||
if on_event is None:
|
||||
return
|
||||
try:
|
||||
on_event(event)
|
||||
except Exception: # noqa: BLE001
|
||||
# A consumer that throws (a closing widget, say) must not abort
|
||||
# the turn that is feeding it.
|
||||
logger.debug("event consumer raised for %s", event.type, exc_info=True)
|
||||
return emit
|
||||
|
||||
def _resolve_runner(self) -> Callable[..., Any]:
|
||||
"""The turn engine, imported lazily on first use."""
|
||||
if self._runner is None:
|
||||
from cowork_local.core.chat_agent import run_cowork
|
||||
|
||||
self._runner = run_cowork
|
||||
return self._runner
|
||||
|
||||
def _resolve_tools(self) -> Tuple[Any, Any]:
|
||||
"""MCP/connector tools for this turn, or ``(None, None)``.
|
||||
|
||||
A failure here degrades to "no external tools" rather than failing the
|
||||
turn: an MCP server that will not start must not stop the user from
|
||||
chatting, which is the behaviour the chat panel already relies on.
|
||||
"""
|
||||
if self._tool_source is None:
|
||||
return None, None
|
||||
try:
|
||||
return self._tool_source()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("tool source unavailable - running without external tools",
|
||||
exc_info=True)
|
||||
return None, None
|
||||
|
||||
def _resolve_gate(self, request: ConversationExecutionRequest) -> Any:
|
||||
"""The permission gate, when this turn asked to confirm commands."""
|
||||
if not request.confirm_commands or self._gate_factory is None:
|
||||
return None
|
||||
return self._gate_factory(request)
|
||||
|
||||
@staticmethod
|
||||
def _output_dir(request: ConversationExecutionRequest) -> Path:
|
||||
"""The turn's output folder as a Path.
|
||||
|
||||
The request holds it as a string to stay serialisable; converting at the
|
||||
single point of use keeps that decision from leaking into every caller.
|
||||
"""
|
||||
return Path(request.output_dir) if request.output_dir else Path.cwd()
|
||||
|
||||
@staticmethod
|
||||
def _role_kwargs(request: ConversationExecutionRequest) -> Dict[str, Any]:
|
||||
"""``agent_role`` only when the request set one.
|
||||
|
||||
Omitted otherwise so the engine applies its own default (the interactive
|
||||
Cowork role) instead of being handed an empty string, which would land
|
||||
in the audit log as an unattributed tool call.
|
||||
"""
|
||||
return {"agent_role": request.agent_role} if request.agent_role else {}
|
||||
|
||||
@staticmethod
|
||||
def _last_assistant_text(messages: List[Dict[str, Any]]) -> str:
|
||||
"""Fallback answer text when no text events were seen.
|
||||
|
||||
A turn whose whole answer arrived in one non-streamed message still has
|
||||
to report a final answer - the scheduler writes it into output.md, and
|
||||
an empty string there reads as "(no output)".
|
||||
"""
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "assistant" and (message.get("content") or "").strip():
|
||||
return str(message["content"])
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _is_recoverable(exc: Exception) -> bool:
|
||||
"""Whether the user can act on this failure themselves.
|
||||
|
||||
"Model not found" is the motivating case: the chat panel restores the
|
||||
typed message into the composer so the user can switch model and resend
|
||||
instead of retyping it (see providers/base.py::MODEL_NOT_FOUND_HINT).
|
||||
"""
|
||||
try:
|
||||
from cowork_local.providers.base import is_model_not_found_error
|
||||
|
||||
return bool(is_model_not_found_error(str(exc)))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["ConversationApplicationService", "TurnResult"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ToolPolicyGateway - one confirm/deny decision path for every tool call
|
||||
(R05-T03).
|
||||
|
||||
Today "does this tool call need the user's OK first" is answered by a
|
||||
different hand-written check per engine:
|
||||
|
||||
* ``core/chat_agent.py::run_cowork`` — ``name in ("run_command",
|
||||
"install_package")``, a literal tuple.
|
||||
* ``core/code_agent.py::run_code`` — ``name in (WRITE_TOOLS | MS365_WRITE_TOOLS)``,
|
||||
a set built from two other hand-maintained sets.
|
||||
* MCP/connector tools (``core/mcp_client.py``, ``core/ext_connectors.py``) —
|
||||
no check at all; ``chat_agent.py`` calls ``extra_executor(name, args)``
|
||||
directly.
|
||||
|
||||
Three answers to the same question, and the third one is a real gap: an MCP
|
||||
tool that deletes files or calls an external API today runs with zero
|
||||
confirmation even when the user turned "confirm before running commands" on.
|
||||
|
||||
This gateway answers the question from data (:class:`~domain.tools.tool_descriptor.ToolCapability`
|
||||
via a :class:`~domain.tools.tool_registry.ToolRegistry`) instead of a literal
|
||||
name list, so registering a tool with the right capability is what gates it -
|
||||
nothing to remember at each new call site. R05-T04 is what actually registers
|
||||
MCP/connector tools with a capability; this module only needs the mechanism
|
||||
to exist.
|
||||
|
||||
Pure Python: no Qt, no direct dialog. The actual approval prompt stays exactly
|
||||
what it is today - a ``gate`` object with a ``.request(payload) -> bool``
|
||||
method, supplied by the presentation layer (Settings' "confirm before running
|
||||
commands" wires it up, or None for auto-run) - this module only decides
|
||||
WHEN to ask it, never how to render the question.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
|
||||
from cowork_local.domain.tools import ToolCapability, ToolRegistry
|
||||
|
||||
|
||||
class ConfirmGate(Protocol):
|
||||
"""Shape of the existing ``PermissionGate`` both engines already use."""
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool: ...
|
||||
|
||||
|
||||
class ToolPolicyGateway:
|
||||
"""Decides whether a tool call needs approval, for ONE calling surface.
|
||||
|
||||
``gated_capabilities`` is what makes this per-surface: Cowork only ever
|
||||
asked about ``run_command``/``install_package`` (capability ``EXECUTE``),
|
||||
while the Code tab additionally confirms plain file writes (capability
|
||||
``WRITE``). Passing the wrong set here would silently change which tools
|
||||
prompt for approval - see the callers in ``core/chat_agent.py`` and
|
||||
``core/code_agent.py`` for the exact sets that preserve today's behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None:
|
||||
self._registry = registry
|
||||
self._gated_capabilities = gated_capabilities
|
||||
|
||||
def requires_confirmation(self, name: str) -> bool:
|
||||
"""True when ``name``'s declared capabilities overlap this surface's
|
||||
gated set. An unregistered tool never requires confirmation through
|
||||
this path - callers that must fail safe on unknown tools check
|
||||
``name in registry`` themselves (see R05-T04's MCP wrapping, which
|
||||
registers every tool it exposes before any call can reach here)."""
|
||||
return bool(self._registry.capabilities_for(name) & self._gated_capabilities)
|
||||
|
||||
def allow(self, name: str, gate: Optional[ConfirmGate], payload: Dict[str, Any]) -> bool:
|
||||
"""True when the call may proceed.
|
||||
|
||||
``gate is None`` preserves each engine's existing "no gate wired -
|
||||
auto-run" behavior; a tool outside ``gated_capabilities`` is never
|
||||
asked about, matching read-only tools "never confirm" today.
|
||||
``payload`` is whatever ``gate.request(...)`` already expects at that
|
||||
call site (the two engines use slightly different dict shapes) - this
|
||||
gateway only decides WHETHER to call it, never reshapes the payload.
|
||||
"""
|
||||
if gate is None or not self.requires_confirmation(name):
|
||||
return True
|
||||
return bool(gate.request(payload))
|
||||
|
||||
|
||||
__all__ = ["ToolPolicyGateway", "ConfirmGate"]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Model routing use case: pick the best-fit model for one turn (EPIC R03)."""
|
||||
|
||||
from .routing_application_service import (
|
||||
RoutingApplicationService,
|
||||
RoutingDecision,
|
||||
RoutingMode,
|
||||
is_valid_mode,
|
||||
normalize_mode,
|
||||
)
|
||||
|
||||
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
|
||||
"normalize_mode", "is_valid_mode"]
|
||||
@@ -0,0 +1,353 @@
|
||||
"""RoutingApplicationService - one routing flow for every surface (R03-T03).
|
||||
|
||||
Before this service, the same routing algorithm existed three times:
|
||||
|
||||
* ``ui/chat_panel.py::_apply_routing`` (Cowork chat)
|
||||
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E studio)
|
||||
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
|
||||
|
||||
The three copies had already drifted - each one resolves the "current model"
|
||||
differently and each one has its own private notion of what to do when the user
|
||||
declines - and every one of them lives inside a Qt widget, so none of the logic
|
||||
could be tested without building a window.
|
||||
|
||||
This module is the single implementation. It is pure Python: no Qt import, no
|
||||
config access, no network. The presentation layer supplies a confirm callback
|
||||
and renders the notice; everything else happens here.
|
||||
|
||||
Modes (:class:`RoutingMode`)
|
||||
----------------------------
|
||||
* ``OFF`` - never switch. The user's pinned model always wins.
|
||||
* ``AUTO`` - switch silently when the best candidate clears the gain threshold.
|
||||
* ``MANUAL`` - propose the switch and switch only if the confirm callback approves.
|
||||
* ``FALLBACK`` - never switch pre-emptively; switch only AFTER the current model
|
||||
fails, to the next-best candidate. This is the mode a user wants when they
|
||||
trust their own model choice but still want the turn to survive an outage.
|
||||
|
||||
Migration note (ADR-001 section 4): the scoring/ranking engine is NOT rewritten.
|
||||
This service depends on the small :class:`RoutingPort` interface, and production
|
||||
wires the existing, already-tested ``core.routing.service.RoutingService`` into
|
||||
it. Tests wire a fake.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, List, Optional, Protocol, Sequence, Tuple
|
||||
|
||||
|
||||
class RoutingMode(str, Enum):
|
||||
"""Per-surface routing behaviour.
|
||||
|
||||
The first three values match ``core.routing.models.SwitchMode`` string for
|
||||
string, so a mode read from the existing config round-trips unchanged.
|
||||
"""
|
||||
|
||||
OFF = "off"
|
||||
AUTO = "auto"
|
||||
MANUAL = "manual"
|
||||
FALLBACK = "fallback"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: Any) -> "RoutingMode":
|
||||
"""Best-effort parse of a config value.
|
||||
|
||||
Unknown or empty values become ``OFF``: routing is an optimisation, and
|
||||
the safe reading of a corrupt setting is "leave the user's model alone"
|
||||
rather than "silently move their work to another model".
|
||||
"""
|
||||
try:
|
||||
return cls(str(raw or "off").strip().lower())
|
||||
except ValueError:
|
||||
return cls.OFF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingDecision:
|
||||
"""The outcome of routing one turn - an immutable instruction for the caller.
|
||||
|
||||
``provider``/``model`` are ALWAYS filled with what the turn should actually
|
||||
run on, switched or not, so a call site never has to re-derive the fallback
|
||||
itself (the bug that made the three UI copies diverge).
|
||||
"""
|
||||
|
||||
mode: RoutingMode
|
||||
provider: str
|
||||
model: str
|
||||
switched: bool = False
|
||||
task_type: str = ""
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
declined: bool = False # Manual mode: a switch was offered and refused
|
||||
# What the turn would have run on without routing. Carried so the Manual
|
||||
# confirm dialog can show "from X to Y" without re-deriving the current
|
||||
# model itself - re-deriving it differently per screen is exactly how the
|
||||
# three legacy copies drifted apart.
|
||||
previous_provider: str = ""
|
||||
previous_model: str = ""
|
||||
|
||||
@property
|
||||
def should_notify(self) -> bool:
|
||||
"""True when the UI should show the "switched model" notice - i.e. only
|
||||
when a switch really happened."""
|
||||
return self.switched
|
||||
|
||||
def target(self) -> Tuple[str, str]:
|
||||
"""``(provider, model)`` to run this turn on."""
|
||||
return self.provider, self.model
|
||||
|
||||
@property
|
||||
def from_model(self) -> str:
|
||||
"""Candidate key (``provider/model``) of the model being switched away
|
||||
from, or "" when nothing was selected yet.
|
||||
|
||||
Named to match ``core.routing.models.SwitchDecision`` so the existing
|
||||
Manual-mode dialog (``ui/routing_toggle.py::confirm_switch``) accepts
|
||||
this object unchanged - the dialog moves to the new shape in EPIC R08.
|
||||
"""
|
||||
if not self.previous_model:
|
||||
return ""
|
||||
return f"{self.previous_provider}/{self.previous_model}"
|
||||
|
||||
@property
|
||||
def to_model(self) -> str:
|
||||
"""Candidate key (``provider/model``) of the model to run on. See
|
||||
:attr:`from_model` for why the name matches the legacy decision."""
|
||||
return f"{self.provider}/{self.model}" if self.model else ""
|
||||
|
||||
|
||||
def is_valid_mode(raw: Any) -> bool:
|
||||
"""True when ``raw`` names a mode the routing service understands.
|
||||
|
||||
Distinct from :func:`normalize_mode` because callers need to tell "the user
|
||||
chose off" apart from "this stored value is unrecognised" - the per-workspace
|
||||
lookup falls back to the global setting only in the second case.
|
||||
"""
|
||||
try:
|
||||
RoutingMode(str(raw or "").strip().lower())
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def normalize_mode(raw: Any) -> str:
|
||||
"""Canonical mode string for persistence, or ``"off"`` when unrecognised.
|
||||
|
||||
Exists so the mode vocabulary is defined exactly once. It used to be
|
||||
hard-coded as a ``("off", "auto", "manual")`` tuple in four separate places
|
||||
(config.py twice, state.py twice); adding FALLBACK meant finding all four,
|
||||
and missing one silently downgraded the user's choice back to "off".
|
||||
"""
|
||||
return RoutingMode.parse(raw).value
|
||||
|
||||
|
||||
class RoutingPort(Protocol):
|
||||
"""The slice of the routing engine this service needs.
|
||||
|
||||
Declared as a Protocol so the application layer states its requirement
|
||||
without importing the implementation - which is what lets the whole service
|
||||
be tested against a 20-line fake, and lets ``core.routing`` be replaced later
|
||||
without touching this file.
|
||||
"""
|
||||
|
||||
def route(self, surface: str, prompt: str, current_provider: str, current_model: str,
|
||||
*, mode_override: Optional[str] = None,
|
||||
required_capabilities: Optional[List[str]] = None,
|
||||
task_type: Optional[Any] = None) -> Any:
|
||||
"""Return a route result exposing ``should_switch``, ``target()``,
|
||||
``task_type`` and ``decision``."""
|
||||
|
||||
|
||||
# Presentation supplies this to ask the human. Receives the proposal so the
|
||||
# dialog can explain it; returns True to approve. Manual mode only.
|
||||
ConfirmFn = Callable[[RoutingDecision], bool]
|
||||
|
||||
|
||||
class RoutingApplicationService:
|
||||
"""Decides which provider/model one turn runs on.
|
||||
|
||||
Args:
|
||||
router: the scoring engine (see :class:`RoutingPort`).
|
||||
mode_reader: ``surface -> mode string``; production passes the per-workspace
|
||||
lookup ``AppContext.project_routing_mode``. Injected rather than read
|
||||
from config here so this layer stays free of config plumbing that
|
||||
EPIC R02 is rewriting in parallel.
|
||||
"""
|
||||
|
||||
def __init__(self, router: RoutingPort,
|
||||
mode_reader: Optional[Callable[[str], str]] = None) -> None:
|
||||
self._router = router
|
||||
self._mode_reader = mode_reader
|
||||
|
||||
# -- main entry point -------------------------------------------------- #
|
||||
def route_turn(
|
||||
self,
|
||||
surface: str,
|
||||
prompt: str,
|
||||
current_provider: str,
|
||||
current_model: str,
|
||||
*,
|
||||
mode: Optional[str] = None,
|
||||
confirm: Optional[ConfirmFn] = None,
|
||||
required_capabilities: Optional[Sequence[str]] = None,
|
||||
task_type: Optional[Any] = None,
|
||||
) -> RoutingDecision:
|
||||
"""Decide what to run this turn on. Never raises.
|
||||
|
||||
A routing failure must never block a message: any unexpected error
|
||||
degrades to "keep the current model", which is exactly what all three
|
||||
legacy copies did with a bare ``except`` - made explicit and testable here.
|
||||
"""
|
||||
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
|
||||
keep = self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="routing off - keeping current model")
|
||||
|
||||
# An empty prompt carries no signal to classify, so routing cannot make a
|
||||
# meaningful choice; the same guard exists in all three legacy copies.
|
||||
if resolved_mode is RoutingMode.OFF or not (prompt or "").strip():
|
||||
return keep
|
||||
|
||||
# FALLBACK never switches up front - it only reacts to a failure, which
|
||||
# the caller reports through fallback_after_failure().
|
||||
if resolved_mode is RoutingMode.FALLBACK:
|
||||
return self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="fallback mode - switching only after a failure")
|
||||
|
||||
try:
|
||||
result = self._router.route(
|
||||
surface, prompt, current_provider, current_model,
|
||||
mode_override=resolved_mode.value,
|
||||
required_capabilities=list(required_capabilities) if required_capabilities else None,
|
||||
task_type=task_type,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - routing must never break a turn
|
||||
return self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="routing engine failed - keeping current model")
|
||||
|
||||
proposal = self._to_decision(result, resolved_mode, current_provider, current_model)
|
||||
if not proposal.switched:
|
||||
return proposal
|
||||
|
||||
# Manual mode: the proposal only becomes a switch once a human approves.
|
||||
if resolved_mode is RoutingMode.MANUAL:
|
||||
if confirm is None or not self._ask(confirm, proposal):
|
||||
return self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="switch declined - keeping current model",
|
||||
task_type=proposal.task_type, declined=True)
|
||||
return proposal
|
||||
|
||||
# -- failure recovery -------------------------------------------------- #
|
||||
def fallback_after_failure(
|
||||
self,
|
||||
surface: str,
|
||||
prompt: str,
|
||||
failed_provider: str,
|
||||
failed_model: str,
|
||||
*,
|
||||
mode: Optional[str] = None,
|
||||
required_capabilities: Optional[Sequence[str]] = None,
|
||||
task_type: Optional[Any] = None,
|
||||
) -> Optional[RoutingDecision]:
|
||||
"""Pick a replacement after ``failed_provider/failed_model`` failed.
|
||||
|
||||
Returns None when there is nothing to fall back to, so the caller can
|
||||
surface the original error instead of retrying forever. Available in
|
||||
AUTO and FALLBACK; OFF and MANUAL keep the user's model on failure too,
|
||||
because silently moving work to another model is exactly what those two
|
||||
modes exist to prevent.
|
||||
"""
|
||||
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
|
||||
if resolved_mode not in (RoutingMode.AUTO, RoutingMode.FALLBACK):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Asked in AUTO so the engine ranks candidates rather than short-
|
||||
# circuiting on FALLBACK's "never switch up front" rule; the failed
|
||||
# model is passed as current so any positive gain beats it.
|
||||
result = self._router.route(
|
||||
surface, prompt, failed_provider, failed_model,
|
||||
mode_override=RoutingMode.AUTO.value,
|
||||
required_capabilities=list(required_capabilities) if required_capabilities else None,
|
||||
task_type=task_type,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a broken router must not mask the real error
|
||||
return None
|
||||
|
||||
decision = self._to_decision(result, resolved_mode, failed_provider, failed_model)
|
||||
# A "switch" back to the model that just failed would retry the outage.
|
||||
if not decision.switched or (decision.provider, decision.model) == (failed_provider, failed_model):
|
||||
return None
|
||||
return RoutingDecision(
|
||||
mode=resolved_mode, provider=decision.provider, model=decision.model,
|
||||
switched=True, task_type=decision.task_type, score_gain=decision.score_gain,
|
||||
reason=f"{failed_provider}/{failed_model} failed - falling back to "
|
||||
f"{decision.provider}/{decision.model}",
|
||||
previous_provider=failed_provider, previous_model=failed_model,
|
||||
)
|
||||
|
||||
# -- internals --------------------------------------------------------- #
|
||||
def _read_mode(self, surface: str) -> str:
|
||||
"""Per-surface mode from the injected reader ('off' when none supplied)."""
|
||||
if self._mode_reader is None:
|
||||
return RoutingMode.OFF.value
|
||||
try:
|
||||
return self._mode_reader(surface) or RoutingMode.OFF.value
|
||||
except Exception: # noqa: BLE001 - a config read must not break a turn
|
||||
return RoutingMode.OFF.value
|
||||
|
||||
@staticmethod
|
||||
def _keep(mode: RoutingMode, provider: str, model: str, *, reason: str,
|
||||
task_type: str = "", declined: bool = False) -> RoutingDecision:
|
||||
"""A no-switch decision that still names the model to run on."""
|
||||
return RoutingDecision(mode=mode, provider=provider, model=model, switched=False,
|
||||
task_type=task_type, reason=reason, declined=declined,
|
||||
previous_provider=provider, previous_model=model)
|
||||
|
||||
@staticmethod
|
||||
def _ask(confirm: ConfirmFn, proposal: RoutingDecision) -> bool:
|
||||
"""Run the confirm callback, treating any failure as "declined".
|
||||
|
||||
The callback opens a modal dialog in production; if that raises (window
|
||||
already closing, for instance) the safe answer is to keep the user's own
|
||||
model rather than to switch without consent.
|
||||
"""
|
||||
try:
|
||||
return bool(confirm(proposal))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _to_decision(result: Any, mode: RoutingMode,
|
||||
current_provider: str, current_model: str) -> RoutingDecision:
|
||||
"""Translate the engine's route result into a :class:`RoutingDecision`.
|
||||
|
||||
Defensive about the result shape on purpose: this is the seam between the
|
||||
new layer and a legacy module still under refactor, and a missing
|
||||
attribute must degrade to "keep current model" instead of raising into
|
||||
the middle of a chat turn.
|
||||
"""
|
||||
inner = getattr(result, "decision", None)
|
||||
task_type = getattr(getattr(result, "task_type", None), "value", "") or ""
|
||||
gain = float(getattr(inner, "score_gain", 0.0) or 0.0)
|
||||
reason = str(getattr(inner, "reason", "") or "")
|
||||
|
||||
target = None
|
||||
if getattr(result, "should_switch", False):
|
||||
getter = getattr(result, "target", None)
|
||||
target = getter() if callable(getter) else None
|
||||
|
||||
if not target:
|
||||
return RoutingDecision(mode=mode, provider=current_provider, model=current_model,
|
||||
switched=False, task_type=task_type, score_gain=gain,
|
||||
reason=reason or "no better model - keeping current",
|
||||
previous_provider=current_provider,
|
||||
previous_model=current_model)
|
||||
|
||||
provider, model = target
|
||||
return RoutingDecision(mode=mode, provider=provider or current_provider, model=model,
|
||||
switched=True, task_type=task_type, score_gain=gain, reason=reason,
|
||||
previous_provider=current_provider, previous_model=current_model)
|
||||
|
||||
|
||||
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
|
||||
"RoutingPort", "normalize_mode", "is_valid_mode"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Workspace file operations for non-agent-loop callers (EPIC R06)."""
|
||||
|
||||
from .file_workspace_service import FileWorkspaceService
|
||||
|
||||
__all__ = ["FileWorkspaceService"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""FileWorkspaceService - the safe file operations File Explorer and the AI
|
||||
File Editor need, outside the agent tool loop (R06-T05).
|
||||
|
||||
``ui/folder_tab.py`` (File Explorer) and the AI File Editor dialog need the
|
||||
exact same guarantees the agent's tools already have — path containment
|
||||
inside the workspace, precise context-anchored edits, syntax warnings on a
|
||||
bad Python write — but today that logic only exists wired to a model's tool
|
||||
call (``core/tools.py::execute_tool``). A UI action that isn't a tool call
|
||||
(browsing the tree, applying an AI-suggested diff from a review dialog) has
|
||||
no equivalent entry point of its own.
|
||||
|
||||
This service IS that entry point. It reuses ``core/tools.py::execute_tool``
|
||||
verbatim - same dispatch table, same ``ToolContext`` containment check, same
|
||||
audit-log entry, same Python-syntax warning on write/edit - rather than
|
||||
re-implementing any of it, so a fix to one path fixes both. It only adds the
|
||||
:class:`~domain.workspaces.workspace_session.WorkspaceSession` seam: which
|
||||
workspace root a call is scoped to is decided by the session, not by
|
||||
whichever folder a widget happens to have open.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class FileWorkspaceService:
|
||||
"""File operations scoped to one :class:`WorkspaceSession`.
|
||||
|
||||
Read-only by name (``list_tree``/``read_preview``) vs. writing
|
||||
(``write_file``/``apply_edit``) mirrors the same READ/WRITE split
|
||||
``domain/tools/tool_registry.py`` uses for the agent's own tools - a
|
||||
caller that only wants to browse never accidentally has write access.
|
||||
"""
|
||||
|
||||
def __init__(self, session) -> None: # WorkspaceSession - see module docstring
|
||||
self._session = session
|
||||
|
||||
def list_tree(self, rel: str = ".") -> Dict[str, Any]:
|
||||
"""Entries at ``rel`` (default: the workspace root)."""
|
||||
return self._execute("list_dir", {"path": rel})
|
||||
|
||||
def read_preview(self, rel: str) -> Dict[str, Any]:
|
||||
"""A text file's content (truncated by
|
||||
``infrastructure/filesystem/file_tools.py::MAX_READ_BYTES``, same as
|
||||
the agent's ``read_file`` tool)."""
|
||||
return self._execute("read_file", {"path": rel})
|
||||
|
||||
def write_file(self, rel: str, content: str) -> Dict[str, Any]:
|
||||
"""Create or fully overwrite ``rel``."""
|
||||
return self._execute("write_file", {"path": rel, "content": content})
|
||||
|
||||
def apply_edit(self, rel: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet in an existing file - the same
|
||||
context-anchored algorithm the agent's ``edit_file`` tool uses, so an
|
||||
AI-suggested diff applies with the same precision and the same
|
||||
"old_string not found / ambiguous" failure messages either path
|
||||
would give the caller."""
|
||||
return self._execute("edit_file", {
|
||||
"path": rel, "old_string": old_string, "new_string": new_string,
|
||||
"replace_all": replace_all,
|
||||
})
|
||||
|
||||
# -- internals --------------------------------------------------------- #
|
||||
def _tool_context(self):
|
||||
"""A ``ToolContext`` scoped to this session's workspace root.
|
||||
``flatten_writes=False`` (unlike Cowork's agent context) - File
|
||||
Explorer must preserve whatever subfolder structure the user is
|
||||
actually browsing, not collapse every write into the root."""
|
||||
from cowork_local.infrastructure.filesystem.tool_context import ToolContext
|
||||
|
||||
return ToolContext(self._session.workspace_root, flatten_writes=False)
|
||||
|
||||
def _execute(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Dispatch through ``core/tools.py::execute_tool`` - see the module
|
||||
docstring for why this delegates instead of reimplementing."""
|
||||
from cowork_local.core.tools import execute_tool
|
||||
|
||||
return execute_tool(self._tool_context(), name, args)
|
||||
|
||||
|
||||
__all__ = ["FileWorkspaceService"]
|
||||
@@ -552,19 +552,25 @@ class AppConfig:
|
||||
return d
|
||||
|
||||
def routing_mode_for(self, surface: str) -> str:
|
||||
"""Effective Off/Auto/Manual mode for a chat surface.
|
||||
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
|
||||
|
||||
A per-surface override wins; an empty override falls back to the global
|
||||
``switch_mode``. The value is validated through
|
||||
``application.model_routing.normalize_mode`` so the accepted vocabulary
|
||||
is defined in exactly one place (R03-T03) - it used to be a literal
|
||||
tuple repeated here and in state.py, and adding a mode to one copy but
|
||||
not the others silently downgraded the user's choice to "off"."""
|
||||
from .application.model_routing import normalize_mode
|
||||
|
||||
A per-surface override ("auto"/"manual"/"off") wins; an empty override
|
||||
falls back to the global ``switch_mode``."""
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in ("off", "auto", "manual") else "off"
|
||||
return normalize_mode(override or routing.get("switch_mode", "off"))
|
||||
|
||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||
"""Persist a chat surface's Off/Auto/Manual toggle selection."""
|
||||
mode = mode if mode in ("off", "auto", "manual") else "off"
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
"""Persist a chat surface's routing toggle selection."""
|
||||
from .application.model_routing import normalize_mode
|
||||
|
||||
self.routing.setdefault("surface_modes", {})[surface] = normalize_mode(mode)
|
||||
self.save()
|
||||
|
||||
@property
|
||||
|
||||
+46
-10
@@ -11,6 +11,8 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
|
||||
from ..domain.tools import ToolCapability, default_registry
|
||||
from ..providers.base import Provider, ToolSpec
|
||||
from . import agent_roles
|
||||
from . import agent_security
|
||||
@@ -27,6 +29,13 @@ from .tools import TOOL_SPECS, ToolContext, _snapshot, describe_action, execute_
|
||||
# Generator / helper scripts — never a final deliverable in Cowork's output.
|
||||
_SCRIPT_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".sh", ".bat", ".ps1", ".rb", ".pl"}
|
||||
|
||||
# R05-T03/T04: replaces the literal ``name in ("run_command",
|
||||
# "install_package")`` check below with a capability lookup — EXECUTE is
|
||||
# exactly the capability those two (and only those two) built-in tools carry
|
||||
# (see domain/tools/tool_registry.py::BUILT_IN_CAPABILITIES). Copied per-turn
|
||||
# into ``turn_tool_policy`` inside run_cowork() once extra_tools are known.
|
||||
_COWORK_TOOL_REGISTRY = default_registry(TOOL_SPECS)
|
||||
|
||||
EmitFn = Callable[[Dict[str, Any]], None]
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
@@ -388,6 +397,19 @@ def run_cowork(
|
||||
jira=(security_config.data.get("jira") if security_config else None))
|
||||
extra_tools = extra_tools or []
|
||||
extra_names = {t.name for t in extra_tools}
|
||||
# R05-T04: MCP servers (core/mcp_client.py) and unified connectors
|
||||
# (core/ext_connectors.py) — everything that arrives here as extra_tools —
|
||||
# advertise no standard risk metadata, so each is tagged with the same
|
||||
# conservative default (WRITE|EXECUTE|NETWORK) domain/tools/tool_registry.py
|
||||
# uses for any unclassified tool. Copying the built-in registry per turn
|
||||
# (cheap - under 20 entries) rather than mutating the shared module-level
|
||||
# one keeps different turns' extra_tools from leaking into each other.
|
||||
from ..domain.tools import ToolDescriptor, ToolRegistry
|
||||
from ..domain.tools.tool_registry import UNKNOWN_SOURCE_CAPABILITIES
|
||||
_turn_registry = ToolRegistry(_COWORK_TOOL_REGISTRY.all())
|
||||
for _spec in extra_tools:
|
||||
_turn_registry.register(ToolDescriptor.from_spec(_spec, UNKNOWN_SOURCE_CAPABILITIES))
|
||||
turn_tool_policy = ToolPolicyGateway(_turn_registry, ToolCapability.EXECUTE)
|
||||
# update_plan drives the Plan panel (above Output); it produces no file.
|
||||
# Built-in tools the admin disabled (Monitoring → Tools) are filtered out.
|
||||
from .tools import enabled_tool_specs
|
||||
@@ -489,6 +511,18 @@ def run_cowork(
|
||||
preview = {"kind": "info", "title": name, "text": str(args)}
|
||||
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
|
||||
"preview": preview})
|
||||
# R05-T04: MCP/connector tools used to run with NO permission
|
||||
# check at all — this is what closes that gap. Same policy,
|
||||
# same gate object as the built-in tools below.
|
||||
if not turn_tool_policy.allow(
|
||||
name, gate, {"name": name, "args": args, "preview": preview}
|
||||
):
|
||||
result = {"ok": False, "output": "Rejected by user."}
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]})
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
|
||||
"content": result["output"]})
|
||||
continue
|
||||
result = extra_executor(name, args)
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": result.get("ok", False), "output": result.get("output", "")})
|
||||
@@ -528,16 +562,18 @@ def run_cowork(
|
||||
# Permission Management (Sandbox Security Layer) — only when a
|
||||
# gate was actually supplied (Settings: "confirm before running
|
||||
# commands"); None preserves the pre-existing auto-run behavior.
|
||||
if gate is not None and name in ("run_command", "install_package"):
|
||||
approved = gate.request({"name": name, "args": args, "preview": preview})
|
||||
if not approved:
|
||||
result = {"ok": False, "output": "Rejected by user."}
|
||||
evt = {"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]}
|
||||
emit(evt)
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id,
|
||||
"name": name, "content": result["output"]})
|
||||
continue
|
||||
# R05-T03: gating is now capability-driven (see
|
||||
# turn_tool_policy above) instead of a literal name tuple.
|
||||
if not turn_tool_policy.allow(
|
||||
name, gate, {"name": name, "args": args, "preview": preview}
|
||||
):
|
||||
result = {"ok": False, "output": "Rejected by user."}
|
||||
evt = {"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]}
|
||||
emit(evt)
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id,
|
||||
"name": name, "content": result["output"]})
|
||||
continue
|
||||
|
||||
if name == "save_file":
|
||||
result = _do_save_file(output_dir, title, args)
|
||||
|
||||
+15
-4
@@ -12,6 +12,8 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
|
||||
from ..domain.tools import ToolCapability, ToolDescriptor, ToolRegistry
|
||||
from ..providers.base import Provider
|
||||
from . import agent_roles
|
||||
from . import agent_security
|
||||
@@ -225,6 +227,14 @@ def run_code(
|
||||
# read/list ms365 tools count as "read-only, never confirm". Names are
|
||||
# the MCP-qualified "ms365__*" form the agent sees (see ms365_tools.py).
|
||||
gated_tools = WRITE_TOOLS | MS365_WRITE_TOOLS
|
||||
# R05-T03/T04: ``gated_tools`` stays the authoritative name set (unchanged),
|
||||
# but the actual confirm decision now goes through the same
|
||||
# ToolPolicyGateway class run_cowork uses, instead of a separate
|
||||
# hand-rolled ``if name in gated_tools`` + direct ``gate.request(...)``.
|
||||
code_tool_policy = ToolPolicyGateway(
|
||||
ToolRegistry(ToolDescriptor(n, "", {}, ToolCapability.WRITE) for n in gated_tools),
|
||||
ToolCapability.WRITE,
|
||||
)
|
||||
# In PLAN mode, don't advertise write/run tools (analysis only).
|
||||
advertised = [t for t in all_tools if t.name not in gated_tools] if plan else all_tools
|
||||
has_memory = any(t.name.startswith("cmem_") for t in extra_tools)
|
||||
@@ -297,10 +307,11 @@ def run_code(
|
||||
agent_security.enforce_command(provider, name, args, security_config, emit,
|
||||
agent_kind="code")
|
||||
|
||||
if name in gated_tools:
|
||||
approved = gate.request({"id": tc_id, "name": name, "args": args, "preview": preview})
|
||||
else:
|
||||
approved = True # read-only tools (incl. codebase memory) never confirm
|
||||
# read-only tools (incl. codebase memory) never consult the gate —
|
||||
# code_tool_policy.requires_confirmation(name) is False for them.
|
||||
approved = code_tool_policy.allow(
|
||||
name, gate, {"id": tc_id, "name": name, "args": args, "preview": preview}
|
||||
)
|
||||
|
||||
if cancel():
|
||||
return messages
|
||||
|
||||
+9
-3
@@ -66,7 +66,9 @@ def save_conversation(
|
||||
"outputs": list(outputs or []),
|
||||
"messages": messages,
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, payload)
|
||||
return path
|
||||
|
||||
|
||||
@@ -78,15 +80,19 @@ def delete_conversation(path) -> None:
|
||||
|
||||
|
||||
def rename_conversation(path, new_title: str) -> None:
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
|
||||
data = load_conversation(path)
|
||||
data["title"] = new_title
|
||||
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
write_json(Path(path), data)
|
||||
|
||||
|
||||
def set_pinned(path, pinned: bool) -> None:
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
|
||||
data = load_conversation(path)
|
||||
data["pinned"] = bool(pinned)
|
||||
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
write_json(Path(path), data)
|
||||
|
||||
|
||||
def load_conversation(path: Path) -> Dict[str, Any]:
|
||||
|
||||
@@ -106,6 +106,13 @@ class McpServerConnection:
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""True while the connection's background thread (and therefore its
|
||||
event loop and subprocess) is still running — used by
|
||||
``infrastructure/mcp/mcp_source_manager.py`` (R05-T05) to tell a live
|
||||
cached connection from one whose subprocess already died."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
# ---- tools -----------------------------------------------------------
|
||||
def list_tool_specs(self) -> List[ToolSpec]:
|
||||
"""The server's tools, wrapped as :class:`ToolSpec` — the same shape
|
||||
|
||||
+5
-3
@@ -116,10 +116,12 @@ def new_project(name: str, description: str = "", instructions: str = "",
|
||||
|
||||
def save_project(project: Project, directory: Path = None) -> Path:
|
||||
directory = directory or PROJECTS_DIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{project.project_id}.json"
|
||||
path.write_text(json.dumps(asdict(project), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
# R06-T02: atomic write — a crash/kill between truncate and write used to
|
||||
# leave a half-written project.json that load_project() then silently
|
||||
# treats as "missing" (see infrastructure/persistence/json/atomic_write.py).
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, asdict(project))
|
||||
return path
|
||||
|
||||
|
||||
|
||||
+38
-6
@@ -248,9 +248,34 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
# One immutable snapshot of this run, then the shared turn service (R04-T05).
|
||||
# The Schedule Task path used to assemble the run_cowork call itself, in
|
||||
# parallel with ui/cowork_tab.py doing the same thing slightly differently -
|
||||
# so a fix to one path silently missed the other. Both now go through
|
||||
# ConversationApplicationService.
|
||||
from ..application.conversations import ConversationApplicationService
|
||||
from ..domain.agents import ConversationExecutionRequest
|
||||
|
||||
session_id = new_session_id()
|
||||
project_id = project.project_id if project is not None else ""
|
||||
project_context = projects.project_context_text(project)
|
||||
conversation_service = ConversationApplicationService(
|
||||
# The provider was already resolved above (admin agent / per-task
|
||||
# override / machine default), so the factory just hands it back.
|
||||
lambda _provider_id, _model: provider,
|
||||
security_config=ctx.config,
|
||||
)
|
||||
turn = conversation_service.begin_turn(ConversationExecutionRequest.create(
|
||||
prompt, [{"role": "user", "content": prompt}],
|
||||
output_dir=str(out_dir), session_id=session_id, surface="task",
|
||||
title=title, project_id=project_id, project_context=project_context,
|
||||
# Tags every tool call in the audit log as a scheduled task rather than
|
||||
# as the interactive Cowork tab.
|
||||
agent_role=agent_roles.TASK,
|
||||
))
|
||||
# The LIVE list the engine appends to - History is re-saved from it after
|
||||
# every assistant message so a long run shows progress when reopened.
|
||||
messages = turn.messages
|
||||
_save_history_session(ctx, task_type, title, messages, session_id, project_id)
|
||||
# Tell the scheduler the session now genuinely EXISTS on disk — it
|
||||
# refreshes History on this, not on the earlier "task_started" signal
|
||||
@@ -269,14 +294,21 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
elif ev.get("type") == "plan_set":
|
||||
last_plan_steps[:] = ev.get("steps") or []
|
||||
|
||||
project_context = projects.project_context_text(project)
|
||||
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
|
||||
try:
|
||||
if task_type == "cowork":
|
||||
from .chat_agent import run_cowork
|
||||
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
|
||||
security_config=ctx.config, agent_role=agent_roles.TASK,
|
||||
project_context=project_context)
|
||||
# Typed events are rendered back into the legacy dict shape this
|
||||
# module's autosave/plan tracking already consumes; it moves to
|
||||
# AgentEvent directly once the scheduler UI migrates (EPIC R07/R08).
|
||||
result = conversation_service.execute_turn(
|
||||
turn,
|
||||
on_event=lambda event: emit_and_autosave(event.to_dict()),
|
||||
cancel=watched_cancel,
|
||||
)
|
||||
# This module's callers handle a failed run through an exception
|
||||
# (execute_task writes error.txt from it), so re-raise the ORIGINAL
|
||||
# error rather than reporting a silently empty answer.
|
||||
result.raise_if_failed()
|
||||
else:
|
||||
from .code_agent import run_code
|
||||
limits, block_network = agent_security.sandbox_settings(ctx.config)
|
||||
|
||||
+38
-312
@@ -3,79 +3,29 @@
|
||||
Every path is resolved relative to the working directory and must stay inside
|
||||
it (path-traversal is rejected). ``run_command`` executes inside the workdir
|
||||
with a timeout and captured output.
|
||||
|
||||
R05-T02: the actual handlers (``read_file``/``list_dir``/``write_file``/
|
||||
``edit_file``/``run_command``/``install_package``/``fetch_url``/
|
||||
``jira_search``/``jira_get_issue``) now live in
|
||||
``infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py``, split
|
||||
out of what used to be one big if/elif chain here. This module is the
|
||||
strangler-fig shim (ADR-001 section 4): it re-exports ``ToolContext``/
|
||||
``ToolError`` (actually defined in
|
||||
``infrastructure/filesystem/tool_context.py`` now) so every existing
|
||||
``from .tools import ToolContext`` keeps working, and ``execute_tool``
|
||||
dispatches through a small ``{name: handler}`` table built from the moved
|
||||
modules instead of the chain itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import difflib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..infrastructure.filesystem import command_tools, fetch_tools, file_tools
|
||||
from ..infrastructure.filesystem.command_tools import _snapshot # noqa: F401 - re-export, core/chat_agent.py imports this name
|
||||
from ..infrastructure.filesystem.tool_context import CancelFn, ToolContext, ToolError # noqa: F401 - re-export
|
||||
from ..providers.base import ToolSpec
|
||||
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
MAX_READ_BYTES = 200_000
|
||||
COMMAND_TIMEOUT = 120 # seconds
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _flatten_rel(rel: str) -> str:
|
||||
"""Collapse a sub-folder path down to a bare filename so the file lands in the
|
||||
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
|
||||
|
||||
Used by the Cowork agent (flatten_writes=True) so it can never create a
|
||||
per-session / per-chat / per-task output sub-folder: every deliverable stays
|
||||
directly in the single configured Output folder."""
|
||||
parts = Path(rel).parts
|
||||
if parts and parts[0] == ".scratch":
|
||||
return rel # temporary sandbox is allowed (and cleaned up afterwards)
|
||||
return Path(rel).name or rel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
workdir: Path
|
||||
flatten_writes: bool = False # Cowork: force every write into the workdir root
|
||||
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
|
||||
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
|
||||
# disk_mb), applied to every run_command/install_package this context runs.
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
jira: Optional[Dict[str, Any]] = None
|
||||
|
||||
def resolve(self, rel: str) -> Path:
|
||||
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
|
||||
if rel in ("", "."):
|
||||
return self.workdir
|
||||
candidate = (self.workdir / rel).expanduser()
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Invalid path: {rel} ({exc})")
|
||||
root = self.workdir.resolve()
|
||||
if resolved != root and root not in resolved.parents:
|
||||
raise ToolError(
|
||||
f"Refused: '{rel}' is outside the working folder ({root})."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tool specs advertised to the model
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -192,6 +142,23 @@ TOOL_SPECS: List[ToolSpec] = [
|
||||
# Actions gated by the permission gate in confirm mode (auto-approved in Auto-run).
|
||||
WRITE_TOOLS = {"write_file", "edit_file", "run_command", "install_package"}
|
||||
|
||||
# name -> handler(ctx, args[, cancel, on_output]) — built once from the split
|
||||
# infrastructure modules. Replaces the if/elif chain execute_tool used to be.
|
||||
_HANDLERS: Dict[str, Callable[..., Dict[str, Any]]] = {
|
||||
"read_file": file_tools.read_file,
|
||||
"list_dir": file_tools.list_dir,
|
||||
"write_file": file_tools.write_file,
|
||||
"edit_file": file_tools.edit_file,
|
||||
"run_command": command_tools.run_command,
|
||||
"install_package": command_tools.install_package,
|
||||
"fetch_url": fetch_tools.fetch_url,
|
||||
"jira_search": fetch_tools.jira_search,
|
||||
"jira_get_issue": fetch_tools.jira_get_issue,
|
||||
}
|
||||
# Handlers that accept the long-running (cancel, on_output) signature — every
|
||||
# other handler takes just (ctx, args).
|
||||
_CANCELLABLE = {"run_command", "install_package"}
|
||||
|
||||
|
||||
def enabled_tool_specs(security_config=None) -> List[ToolSpec]:
|
||||
"""The built-in TOOL_SPECS minus any the admin turned OFF in Monitoring →
|
||||
@@ -301,27 +268,14 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
|
||||
labels WHICH agent role made it."""
|
||||
from . import audit_log
|
||||
|
||||
handler = _HANDLERS.get(name)
|
||||
try:
|
||||
if name == "read_file":
|
||||
result = _read_file(ctx, args)
|
||||
elif name == "list_dir":
|
||||
result = _list_dir(ctx, args)
|
||||
elif name == "write_file":
|
||||
result = _write_file(ctx, args)
|
||||
elif name == "edit_file":
|
||||
result = _edit_file(ctx, args)
|
||||
elif name == "run_command":
|
||||
result = _run_command(ctx, args, cancel, on_output)
|
||||
elif name == "install_package":
|
||||
result = _install_package(ctx, args, cancel, on_output)
|
||||
elif name == "fetch_url":
|
||||
result = _fetch_url(ctx, args)
|
||||
elif name == "jira_search":
|
||||
result = _jira_search(ctx, args)
|
||||
elif name == "jira_get_issue":
|
||||
result = _jira_get_issue(ctx, args)
|
||||
else:
|
||||
if handler is None:
|
||||
result = {"ok": False, "output": f"Tool not found: {name}"}
|
||||
elif name in _CANCELLABLE:
|
||||
result = handler(ctx, args, cancel, on_output)
|
||||
else:
|
||||
result = handler(ctx, args)
|
||||
except ToolError as exc:
|
||||
result = {"ok": False, "output": str(exc)}
|
||||
except Exception as exc: # defensive: a tool must never crash the agent
|
||||
@@ -331,234 +285,6 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
|
||||
return result
|
||||
|
||||
|
||||
def _fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
use. Honors the Sandbox Security Layer's "Block network" policy."""
|
||||
url = str(args.get("url", "")).strip()
|
||||
if not url:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
"(\"Allow the agent to fetch URLs\").")}
|
||||
# A pasted Jira issue link on the CONNECTED Jira host is read via the
|
||||
# authenticated API (so private issues resolve, not a login page). Public
|
||||
# links / any other URL fall through to the normal fetcher below.
|
||||
from . import jira_tool
|
||||
if jira_tool.is_jira_issue_url(ctx.jira, url):
|
||||
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
|
||||
from .link_fetch import fetch_link_preview
|
||||
|
||||
return {"ok": True, "output": fetch_link_preview(url)}
|
||||
|
||||
|
||||
def _jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from . import jira_tool
|
||||
|
||||
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
|
||||
int(args.get("max_results", 25) or 25))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
|
||||
"output": out}
|
||||
|
||||
|
||||
def _jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from . import jira_tool
|
||||
|
||||
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
|
||||
"output": out}
|
||||
|
||||
|
||||
def _read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
target = ctx.resolve(str(args.get("path", "")))
|
||||
if not target.exists():
|
||||
return {"ok": False, "output": f"File not found: {args.get('path')}"}
|
||||
data = target.read_bytes()[:MAX_READ_BYTES]
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
return {"ok": True, "output": text}
|
||||
|
||||
|
||||
def _list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ".") or ".")
|
||||
target = ctx.resolve(rel)
|
||||
# A missing/not-yet-created path is NOT a tool failure — report it as an
|
||||
# ordinary result so the agent can create it or pick another path and keep
|
||||
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
|
||||
# Co4E flows and could stall a step on a recoverable situation.
|
||||
if not target.exists():
|
||||
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
|
||||
if target.is_file():
|
||||
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
|
||||
entries = []
|
||||
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
marker = "/" if child.is_dir() else ""
|
||||
entries.append(f"{child.name}{marker}")
|
||||
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
|
||||
|
||||
|
||||
def _check_python_syntax(target: Path, content: str) -> str:
|
||||
"""Return a short warning if ``content`` is invalid Python, else ''.
|
||||
|
||||
Catches syntax errors the instant a .py file is written/edited — before the
|
||||
agent wastes a whole run_command round-trip just to get the same error back
|
||||
from a traceback."""
|
||||
if target.suffix.lower() not in (".py", ".pyw"):
|
||||
return ""
|
||||
try:
|
||||
ast.parse(content, filename=str(target))
|
||||
return ""
|
||||
except SyntaxError as exc:
|
||||
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
|
||||
|
||||
|
||||
def _write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
content = str(args.get("content", ""))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A .xlsx is a binary package — build a REAL workbook from the content
|
||||
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
|
||||
if target.suffix.lower() in (".xlsx", ".xlsm"):
|
||||
from . import xlsx_write
|
||||
if xlsx_write.build_xlsx_from_text(target, content):
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote spreadsheet {rel} ({target.name})."}
|
||||
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
|
||||
"write a .csv instead, or use a generator script."}
|
||||
target.write_text(content, encoding="utf-8")
|
||||
warning = _check_python_syntax(target, content)
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
|
||||
|
||||
|
||||
def _edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet inside an existing file (precise patch edit)."""
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
if not target.exists():
|
||||
return {"ok": False,
|
||||
"output": f"File not found: {rel} — use write_file to create it."}
|
||||
old = str(args.get("old_string", ""))
|
||||
new = str(args.get("new_string", ""))
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
if not old:
|
||||
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
|
||||
try:
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "output": f"Could not read file: {exc}"}
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
|
||||
"text to replace, including indentation/whitespace.")}
|
||||
if count > 1 and not replace_all:
|
||||
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
|
||||
"context to make it unique, or set replace_all=true.")}
|
||||
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
n = count if replace_all else 1
|
||||
warning = _check_python_syntax(target, updated)
|
||||
return {"ok": True,
|
||||
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
|
||||
|
||||
|
||||
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
|
||||
on_output: Optional[Callable[[str], None]] = None) -> Optional[str]:
|
||||
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
|
||||
``ctx.sandbox``); returns its python path, or None to use the app's own."""
|
||||
if not ctx.sandbox:
|
||||
return None
|
||||
from .deps import ensure_project_venv
|
||||
|
||||
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
|
||||
return str(py) if py else None
|
||||
|
||||
|
||||
def _install_package(ctx: ToolContext, args: Dict[str, Any], cancel: Optional[CancelFn] = None,
|
||||
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
||||
from .deps import pip_install
|
||||
|
||||
package = str(args.get("package", "")).strip()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
return {"ok": ok, "output": f"{head}\n{detail}"}
|
||||
|
||||
|
||||
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
|
||||
".idea", ".mypy_cache", ".pytest_cache"}
|
||||
|
||||
|
||||
def _snapshot(workdir: Path) -> Dict[str, Any]:
|
||||
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
|
||||
snap: Dict[str, Any] = {}
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(str(workdir)):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
|
||||
for fn in filenames:
|
||||
full = os.path.join(dirpath, fn)
|
||||
try:
|
||||
st = os.stat(full)
|
||||
snap[full] = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
if len(snap) > 5000:
|
||||
return snap
|
||||
except OSError:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _run_command(ctx: ToolContext, args: Dict[str, Any],
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
||||
from .deps import network_blocked_env, run_cancellable, sandbox_env
|
||||
from .sandbox_manager import SandboxManager, ExecutionConfig
|
||||
from ..security.command_risk_classifier import classify_command
|
||||
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return {"ok": False, "output": "Empty command."}
|
||||
|
||||
# --- Security validation pipeline ---
|
||||
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
|
||||
if risk.blocked:
|
||||
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
|
||||
return {"ok": False, "output": denial}
|
||||
|
||||
# Route through SandboxManager for risk-based isolation
|
||||
mgr = SandboxManager(ExecutionConfig(
|
||||
enabled=True,
|
||||
block_network_by_default=ctx.block_network,
|
||||
is_cowork_mode=ctx.flatten_writes,
|
||||
))
|
||||
sandbox_result = mgr.run(
|
||||
command=command,
|
||||
workdir=str(ctx.workdir),
|
||||
block_network=ctx.block_network,
|
||||
timeout_sec=COMMAND_TIMEOUT,
|
||||
cancel=cancel,
|
||||
)
|
||||
# Sandbox ALWAYS executes (never double-run). Return its result directly.
|
||||
if sandbox_result.get("sandbox") == "blocked":
|
||||
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
|
||||
out = sandbox_result.get("stdout", "").strip() or "(no output)"
|
||||
err = sandbox_result.get("stderr", "")
|
||||
rc = sandbox_result.get("returncode", -1)
|
||||
if err:
|
||||
out = f"{out}\n{err}" if out else err
|
||||
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
|
||||
|
||||
|
||||
def _short_json(obj: Any, limit: int = 500) -> str:
|
||||
import json
|
||||
text = json.dumps(obj, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
|
||||
|
||||
* **Status**: Accepted
|
||||
* **Date**: 2026-08-21
|
||||
* **EPIC / Task**: R01-T01
|
||||
* **Owner**: 🔵 Team Duy (Tech Lead)
|
||||
* **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context (Bối cảnh)
|
||||
|
||||
`cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
|
||||
được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
|
||||
|
||||
| Vấn đề | Bằng chứng cụ thể trong repo |
|
||||
| :--- | :--- |
|
||||
| **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng |
|
||||
| **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` |
|
||||
| **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán |
|
||||
| **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
|
||||
| **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) |
|
||||
|
||||
Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu
|
||||
không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song
|
||||
sẽ hội tụ về đúng cấu trúc rối như cũ.
|
||||
|
||||
## 2. Decision (Quyết định)
|
||||
|
||||
Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ presentation/ PySide6 widgets, Qt signals/slots │
|
||||
│ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ gọi xuống (được phép)
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ application/ Pure Python orchestration │
|
||||
│ (conversations, Điều phối use-case, không biết Qt │
|
||||
│ model_routing…) và không biết HTTP/đĩa cụ thể │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ gọi xuống (được phép)
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ domain/ Pure Python entities & events │
|
||||
│ (agents, models…) Frozen dataclass, enum, quy tắc │
|
||||
│ nghiệp vụ thuần. KHÔNG import gì │
|
||||
│ từ 3 tầng còn lại. │
|
||||
└───────────────────────────▲─────────────────────────────────┘
|
||||
│ implement interface của domain
|
||||
┌───────────────────────────┴─────────────────────────────────┐
|
||||
│ infrastructure/ Adapters: network, keyring, đĩa, │
|
||||
│ (providers, telemetry…) process, Qt-free I/O │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 Quy tắc bất biến (Invariants)
|
||||
|
||||
| # | Quy tắc | Được kiểm bởi |
|
||||
| :--- | :--- | :--- |
|
||||
| **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) |
|
||||
| **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` |
|
||||
| **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` |
|
||||
| **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) |
|
||||
| **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 |
|
||||
| **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test |
|
||||
|
||||
### 2.2 Chiều phụ thuộc được phép
|
||||
|
||||
| Từ tầng | Được import | Bị cấm |
|
||||
| :--- | :--- | :--- |
|
||||
| `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) |
|
||||
| `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 |
|
||||
| `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 |
|
||||
| `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 |
|
||||
|
||||
### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI
|
||||
|
||||
`application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều
|
||||
đi qua **callback thuần Python nhận một `AgentEvent` có kiểu**
|
||||
(`domain/agents/agent_event.py`, R04-T02):
|
||||
|
||||
```python
|
||||
# application layer — pure Python, không biết Qt tồn tại
|
||||
service.run_turn(request, on_event=my_callback)
|
||||
|
||||
# presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
|
||||
def my_callback(event: AgentEvent) -> None:
|
||||
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
|
||||
```
|
||||
|
||||
Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được
|
||||
offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không
|
||||
bao giờ gọi trực tiếp từ worker thread.
|
||||
|
||||
## 3. Vị trí sở hữu theo team
|
||||
|
||||
| Tầng / thư mục | Team | EPIC |
|
||||
| :--- | :--- | :--- |
|
||||
| `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 |
|
||||
| `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 |
|
||||
| `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 |
|
||||
|
||||
## 4. Chiến lược di trú (Strangler Fig, không big-bang)
|
||||
|
||||
Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần:
|
||||
|
||||
1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`).
|
||||
2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter).
|
||||
3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ
|
||||
`application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại
|
||||
scorer/selector đã có test).
|
||||
4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
|
||||
|
||||
Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ
|
||||
team khác refactor xong.
|
||||
|
||||
## 5. Consequences (Hệ quả)
|
||||
|
||||
### Tích cực
|
||||
|
||||
* Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây.
|
||||
* Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
|
||||
* Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`.
|
||||
* Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
|
||||
|
||||
### Tiêu cực / chi phí phải chấp nhận
|
||||
|
||||
* Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
|
||||
* Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
|
||||
* Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety.
|
||||
|
||||
## 6. Alternatives considered (Phương án đã cân nhắc)
|
||||
|
||||
| Phương án | Lý do loại |
|
||||
| :--- | :--- |
|
||||
| **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
|
||||
| **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
|
||||
| **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
|
||||
| **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
|
||||
|
||||
## 7. Enforcement (Thực thi)
|
||||
|
||||
```bash
|
||||
python scripts/check_imports.py # I1, I2, I3 — quét AST
|
||||
python scripts/check_loc.py # I4 — giới hạn 400 dòng
|
||||
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
|
||||
```
|
||||
|
||||
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`.
|
||||
|
||||
## 8. Tài liệu liên quan
|
||||
|
||||
* `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC
|
||||
* `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task
|
||||
* `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)
|
||||
@@ -0,0 +1,85 @@
|
||||
# Dormant / Dead Code Inventory (R01-T05)
|
||||
|
||||
* **Task**: R01-T05 — Phân loại và cô lập mã nguồn cũ
|
||||
* **Owner**: 🔵 Team Duy
|
||||
* **Ngày quét**: 2026-08-21
|
||||
* **Phạm vi quét**: toàn bộ `*.py` production (loại trừ `tests/`, `assets/`, `docs/`, `.git/`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục đích
|
||||
|
||||
Trước khi 3 team refactor song song, cần biết **file nào thật sự đang chạy**. Refactor
|
||||
một module đã chết là lãng phí; xoá nhầm một module chỉ được gọi động là gây sự cố
|
||||
runtime. Tài liệu này phân loại từng ứng viên, kèm **bằng chứng** và **hành động đề xuất**.
|
||||
|
||||
## 2. Phương pháp
|
||||
|
||||
Quét AST toàn repo, dựng đồ thị import, tìm module **không có module nào khác import**.
|
||||
Kết quả thô: **43 module**. Sau đó xác minh thủ công từng ứng viên, vì phân tích tĩnh
|
||||
không thấy 3 kiểu tham chiếu:
|
||||
|
||||
| Kiểu tham chiếu ẩn | Ví dụ thật trong repo |
|
||||
| :--- | :--- |
|
||||
| Chạy như subprocess | `state.py:285` gọi `python -m cowork_local.mcp_servers.ms365_server` |
|
||||
| Entry point của gói | `__main__.py` (chạy bằng `python -m cowork_local`) |
|
||||
| Script chạy tay | `tools/check_*.py`, `scripts/*.py` |
|
||||
|
||||
> ⚠️ **Kết luận quan trọng**: 43 module "không ai import" **KHÔNG** đồng nghĩa 43 module chết.
|
||||
> Sau xác minh, chỉ còn **6 hạng mục (~1.887 dòng)** là dormant thật.
|
||||
|
||||
## 3. Phân loại kết quả
|
||||
|
||||
### 🟥 A. DORMANT THẬT — không có đường nào chạy tới (ứng viên xoá)
|
||||
|
||||
| Module | LOC | Bằng chứng | Rủi ro khi xoá | Hành động |
|
||||
| :--- | ---: | :--- | :--- | :--- |
|
||||
| `ui/accounts_tab.py` | 700 | Chỉ xuất hiện trong comment của `i18n.py:92`; không widget nào khởi tạo `AccountsTab` | Thấp — panel Monitoring → Accounts hiện không có đường vào | Cô lập, chờ xác nhận PO rồi xoá |
|
||||
| `ui/flow_dialog.py` | 596 | Chỉ được nhắc trong docstring `ui/agent_manager_tab.py:4` và comment `i18n.py:2124` | Trung bình — Flow Manager có thể là tính năng tạm ẩn | **Hỏi PO trước**, chưa xoá |
|
||||
| `security/` (cả package) | 296 | `prompt_validator`, `action_validator`, `attachment_validator`, `audit_logger`, `command_risk_classifier` — không file nào ngoài package tự import. Chức năng **trùng** `core/agent_security.py` + `core/security_rules.py` (đang chạy thật) | Trung bình — dễ nhầm đây là lớp bảo mật đang hoạt động | ⚠️ Ưu tiên cao: xoá hoặc hợp nhất trong **R09 (Team Nam)** |
|
||||
| `core/codebase_memory_ui.py` | 123 | Không nơi nào import; `core/codebase_memory.py` (bản không-UI) mới là bản đang dùng | Thấp | Xoá |
|
||||
| `core/graph_server.py` | 115 | Docstring nói phục vụ build không có QtWebEngine, nhưng **không có call site nào**; `ui/structure_graph_view.py` không gọi | Trung bình — có thể là fallback cho bản .exe chưa nối dây | Xác minh với bản đóng gói PyInstaller trước khi xoá |
|
||||
| `ui/mcp_servers_dialog.py` | 57 | Không import; MCP settings hiện nằm trong `ui/settings_dialog.py` | Thấp | Xoá |
|
||||
|
||||
**Tổng: ~1.887 dòng (≈ 3,4% codebase).**
|
||||
|
||||
### 🟨 B. KHÔNG CHẾT — chạy qua đường ẩn (giữ nguyên)
|
||||
|
||||
| Module | Vì sao phân tích tĩnh báo nhầm |
|
||||
| :--- | :--- |
|
||||
| `__main__.py` | Entry point `python -m cowork_local` |
|
||||
| `mcp_servers/ms365_server.py` | Chạy như tiến trình con — `state.py:285` |
|
||||
| `core/routing/__init__.py` | Được import qua đường dẫn con (`from .routing.service import RoutingService`), heuristic theo tên lá không thấy |
|
||||
| `tools/check_*.py` (34 file, 6.608 dòng) | Bộ smoke-test UI chạy tay: `python tools/check_nav.py`. Là **dev tooling**, không phải code chết |
|
||||
| `scripts/bootstrap_gitea_repo.py`, `scripts/check_imports.py` | Script CLI chạy tay / chạy trong CI |
|
||||
|
||||
### 🟩 C. CODE SỐNG NHƯNG "ĐÓNG BĂNG" — đụng vào phải cẩn thận
|
||||
|
||||
| Module | LOC | Ghi chú cho người refactor |
|
||||
| :--- | ---: | :--- |
|
||||
| `core/chat_agent.py::run_cowork` | 580 | Đang có **characterization test** (`tests/characterization/test_run_cowork.py`, R01-T04). Mọi thay đổi hành vi phải làm cùng lúc với cập nhật snapshot |
|
||||
| `providers/base.py` | 401 | Là contract chung của mọi provider; đổi chữ ký = vỡ cả 3 team. Đã có contract test (R03-T01) |
|
||||
| `core/routing/*` | 2.263 | Đã có 79 test đang xanh. R03 **bọc** chứ không viết lại: `application/model_routing/` gọi xuống đây |
|
||||
|
||||
## 4. Quy tắc xử lý (bắt buộc)
|
||||
|
||||
1. **Không xoá trong cùng PR với refactor.** Xoá code chết là một commit riêng, để `git revert` được độc lập khi có sự cố.
|
||||
2. **Cô lập trước, xoá sau.** Đánh dấu module bằng docstring cảnh báo, chạy 1 vòng release; không ai báo lỗi mới xoá.
|
||||
3. **Hạng mục 🟥 A cần một người xác nhận** (PO hoặc chủ tính năng) trước khi xoá — trừ khi rõ ràng là bản trùng lặp (`codebase_memory_ui`, `mcp_servers_dialog`).
|
||||
4. **Không refactor code trong nhóm 🟥 A.** Nếu một file trong danh sách này >400 dòng, nó **không** tính vào CASAN Check 2 — vì đường đi đúng là xoá, không phải tách nhỏ.
|
||||
|
||||
## 5. Việc cần bàn giao
|
||||
|
||||
| Hạng mục | Team nhận | EPIC |
|
||||
| :--- | :--- | :--- |
|
||||
| `security/` trùng lặp với `core/agent_security.py` | 🟣 Nam | R09 |
|
||||
| `ui/accounts_tab.py`, `ui/flow_dialog.py`, `ui/mcp_servers_dialog.py` | 🟣 Nam (sở hữu `presentation/shell/`, `settings/`) | R08 |
|
||||
| `core/graph_server.py`, `core/codebase_memory_ui.py` | 🟢 Hoa (sở hữu `presentation/graph/`) | R06 |
|
||||
|
||||
## 6. Cách chạy lại lần quét này
|
||||
|
||||
```bash
|
||||
python scripts/check_imports.py # ranh giới kiến trúc (R01-T03)
|
||||
# Bản quét đồ thị import dùng cho tài liệu này sẽ được đóng gói thành
|
||||
# scripts/find_dormant.py trong R10-T02 (Testing & Governance tooling).
|
||||
```
|
||||
@@ -0,0 +1,233 @@
|
||||
# BÁO CÁO KẾT QUẢ — TEAM DUY: EPIC R01, R03, R04
|
||||
|
||||
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
|
||||
* **Team**: 🔵 Team Duy — Core AI, Routing, Turn Runtime & Testing (Tech Lead)
|
||||
* **Nhánh**: `feature/deltateam/refactor-plan`
|
||||
* **Thời gian thực hiện**: 21/08/2026, 09:56 ➔ 10:56
|
||||
* **Ngày báo cáo**: 21/08/2026
|
||||
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `DeltaTeam_prompt.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Tóm tắt điều hành
|
||||
|
||||
Hoàn tất **16/16 task** của 3 EPIC được giao trong đợt này: **R01** (nền tảng kiến trúc & lưới an toàn), **R03** (hợp nhất provider & routing), **R04** (vòng đời turn hội thoại). Toàn bộ đã commit và push lên nhánh.
|
||||
|
||||
| Chỉ số | Kết quả |
|
||||
| :--- | :--- |
|
||||
| Task hoàn thành | **16/16** (R01: 5, R03: 6, R04: 5) |
|
||||
| Commit | 5 |
|
||||
| File thay đổi | 48 (37 file mới, 11 file sửa) |
|
||||
| Dòng code | +5.843 / −225 |
|
||||
| Test | **243 pass** / 44s |
|
||||
| Test suite nhanh (unit + contract + characterization + routing) | **218 pass / 1,22s** |
|
||||
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
|
||||
| File production > 400 dòng | **0** |
|
||||
|
||||
**3 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5) — trong đó 1 lỗi deadlock sẽ làm treo ứng dụng ngay ở tin nhắn đầu tiên.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kết quả theo từng EPIC
|
||||
|
||||
### 🔹 EPIC R01 — Architecture Foundation & Characterization (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R01-T01 | `docs/architecture/ADR-001-layered-architecture.md` | Định nghĩa 4 tầng, chiều phụ thuộc, 6 quy tắc bất biến I1–I6, chiến lược di trú Strangler Fig |
|
||||
| R01-T02 | `tests/fakes/fake_provider.py`, `fake_tool_executor.py` | Test double chạy offline, kịch bản hoá, ghi lại mọi lời gọi |
|
||||
| R01-T03 | `scripts/check_imports.py` (239 dòng) | Quét AST, bắt cả import tương đối (`from ...ui import x`) và import trong thân hàm |
|
||||
| R01-T04 | `tests/characterization/test_run_cowork.py` | **13 test** chụp snapshot hành vi hiện tại của `run_cowork` trước khi R04 đụng vào |
|
||||
| R01-T05 | `docs/architecture/dormant-code.md` | Quét đồ thị import: 43 module "không ai import" ➔ xác minh còn **6 hạng mục chết thật (~1.887 dòng)** |
|
||||
|
||||
**Điểm đáng chú ý ở R01-T03**: dùng AST thay vì `grep` là bắt buộc — trong repo có nhiều docstring nhắc tên `PySide6` một cách hợp lệ, `grep` sẽ báo nhầm và đội sẽ học cách tắt cổng kiểm duyệt.
|
||||
|
||||
**Điểm đáng chú ý ở R01-T05**: 43 module không có importer **không** đồng nghĩa 43 module chết. Sau xác minh thủ công: `__main__.py` là entry point, `mcp_servers/ms365_server.py` chạy bằng subprocess (`state.py:285`), 34 file `tools/check_*.py` là dev tooling chạy tay. Chỉ 6 hạng mục là dormant thật.
|
||||
|
||||
### 🔹 EPIC R03 — Model Providers & Routing (6/6)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R03-T01 | `tests/contracts/test_providers.py` | **29 contract test**; chạy được cả 2 adapter thật mà **không cần mạng** nhờ thay `Provider._request` bằng SSE đóng hộp |
|
||||
| R03-T02 | `domain/models/provider_descriptor.py`, `infrastructure/providers/provider_registry.py` | Gom 3 nơi khai báo provider về 1 chỗ |
|
||||
| R03-T03 | `application/model_routing/routing_application_service.py` | Pure Python, 4 chế độ: Off / Auto / Manual / **Fallback (mới)** |
|
||||
| R03-T04, T05 | `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Gỡ 3 bản sao logic routing |
|
||||
| R03-T06 | `infrastructure/telemetry/usage_sink.py` | Tách ghi nhận token usage khỏi provider |
|
||||
|
||||
**Vấn đề gốc đã giải quyết** — cùng một thuật toán routing tồn tại **3 bản gần giống nhau**:
|
||||
|
||||
```
|
||||
ui/chat_panel.py::_apply_routing (~45 dòng)
|
||||
ui/co4e_tab.py::_apply_co4e_routing (~38 dòng)
|
||||
ui/folder_tab.py::_ai_apply_routing (~42 dòng)
|
||||
```
|
||||
|
||||
Cả 3 đều nằm trong widget Qt ➔ **không thể test nếu không dựng cửa sổ**, và đã bắt đầu lệch nhau (mỗi bản xác định "model hiện tại" một kiểu). Nay cả 3 chỉ còn gọi `ctx.routing_application().route_turn(...)` + một callback xác nhận.
|
||||
|
||||
**Chế độ Fallback (mới)**: giữ nguyên model người dùng chọn, **chỉ đổi sau khi model đó lỗi**. Đây là chế độ người dùng cần khi họ tin lựa chọn của mình nhưng vẫn muốn lượt chat sống sót qua sự cố nhà cung cấp.
|
||||
|
||||
**Bộ từ vựng mode**: trước đây tuple `("off", "auto", "manual")` bị lặp ở **4 chỗ** (`config.py` × 2, `state.py` × 2). Thêm một mode mà quên một chỗ sẽ **âm thầm hạ lựa chọn của người dùng về "off"**. Nay tập trung vào `normalize_mode()` / `is_valid_mode()`.
|
||||
|
||||
### 🔹 EPIC R04 — Agent Runtime & Conversation Service (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R04-T01 | `domain/agents/conversation_execution_request.py` | Frozen dataclass, chụp toàn bộ input của 1 turn tại thời điểm submit |
|
||||
| R04-T02 | `domain/agents/agent_event.py` (370 dòng) | **13 event có kiểu** thay cho dict không kiểu, kèm cầu nối 2 chiều |
|
||||
| R04-T03 | `application/conversations/conversation_application_service.py` | Điều phối vòng đời turn, không import Qt |
|
||||
| R04-T04 | `ui/cowork_tab.py::build_job` | Chuyển sang snapshot + service |
|
||||
| R04-T05 | `core/task_executors.py::_run_agent` | Chuyển sang **cùng** service (trước đây là bản lắp ráp thứ hai, hơi khác) |
|
||||
|
||||
**Vấn đề gốc đã giải quyết** — closure trong `build_job` đọc state của widget **từ trong worker thread**:
|
||||
|
||||
```python
|
||||
def job(worker):
|
||||
provider = self.build_provider() # đọc combo box
|
||||
proj_ctx = project_context_text(load_project(project_id))
|
||||
```
|
||||
|
||||
Người dùng có thể đổi model, đổi workspace, sửa chỉ dẫn project **trong lúc turn đang chạy**. Turn khi đó chạy trên hỗn hợp state cũ + mới, và hỗn hợp nào phụ thuộc vào thời điểm luồng — đúng loại bug tái hiện mỗi tuần một lần và không bao giờ tái hiện trong test.
|
||||
|
||||
**`TurnCompletedEvent`** là tín hiệu kết thúc turn mà engine cũ **hoàn toàn không có**: hiện tại mọi consumer suy ra "xong" từ việc worker thread kết thúc, nên **turn bị huỷ và turn thất bại trông giống hệt nhau** với giao diện.
|
||||
|
||||
---
|
||||
|
||||
## 3. Kiến trúc sau refactor
|
||||
|
||||
```text
|
||||
presentation/ ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py, ui/cowork_tab.py
|
||||
│ (chỉ dựng UI, mở dialog xác nhận, render thông báo)
|
||||
▼
|
||||
application/ model_routing/routing_application_service.py ← 4 mode routing
|
||||
conversations/conversation_application_service.py ← vòng đời turn
|
||||
│ (100% pure Python — cổng kiểm duyệt tự động chặn import Qt)
|
||||
▼
|
||||
domain/ agents/conversation_execution_request.py ← snapshot bất biến
|
||||
agents/agent_event.py ← 13 event có kiểu
|
||||
models/provider_descriptor.py ← catalog provider
|
||||
▲
|
||||
infrastructure/ providers/provider_registry.py telemetry/usage_sink.py
|
||||
```
|
||||
|
||||
**Nguyên tắc di trú (ADR-001 mục 4)**: **không viết lại engine**. `core/chat_agent.py::run_cowork` và `core/routing/*` (2.263 dòng, 79 test đang xanh) vẫn là engine bên dưới; tầng application chỉ sở hữu phần trước đây bị trộn vào UI. Nhờ vậy `pytest` luôn xanh giữa các bước và một team có thể merge mà không phải chờ team khác.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bằng chứng kiểm thử
|
||||
|
||||
### Phân bố test
|
||||
|
||||
| Suite | Số test | Thời gian | Vai trò |
|
||||
| :--- | ---: | ---: | :--- |
|
||||
| `tests/unit/` | 97 | | Logic thuần, không Qt/mạng |
|
||||
| `tests/contracts/` | 29 | | Mọi provider phải thoả cùng bộ cam kết |
|
||||
| `tests/characterization/` | 13 | | Chốt hành vi hiện tại của `run_cowork` |
|
||||
| `tests/routing/` | 79 | | Có sẵn từ trước, vẫn xanh |
|
||||
| **Cộng 4 suite nhanh** | **218** | **1,22s** | ✅ đạt CASAN "A — unit < 1s" |
|
||||
| `tests/integration/` | 25 | 42s | Widget Qt thật (offscreen) + provider kịch bản hoá |
|
||||
| **Tổng** | **243** | **44s** | |
|
||||
|
||||
### Đối chiếu Definition of Done (7 tiêu chí, `DeltaTeam_prompt.md`)
|
||||
|
||||
| # | Tiêu chí | Kết quả |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | Mọi file < 400 dòng | ✅ Lớn nhất: `agent_event.py` 370 dòng |
|
||||
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
|
||||
| 3 | Comment tiếng Anh ở mọi khối sửa/mới | ✅ Docstring + giải thích **lý do**, không chỉ mô tả code |
|
||||
| 4 | Có unit/contract test, pass 100% < 1s | ✅ 218 test / 1,22s |
|
||||
| 5 | Không hồi quy | ✅ 79 test routing có sẵn vẫn xanh |
|
||||
| 6 | Ghi Start/End vào Checklist | ✅ 16 task đã tick kèm mốc thời gian |
|
||||
| 7 | Cổng CASAN | ⚠️ `run_quality_gate.py` thuộc **R10-T02**, chưa viết. Check 3 đã có và PASS |
|
||||
|
||||
### Ba đường code đã sửa nhưng ban đầu chưa được thực thi
|
||||
|
||||
Sau khi hoàn tất 16 task, rà soát lại phát hiện 3 đường code đã bị sửa nhưng **không test nào chạy qua**. Đã bổ sung **18 test**:
|
||||
|
||||
| Đường code | Rủi ro nếu bỏ qua | Test bổ sung |
|
||||
| :--- | :--- | ---: |
|
||||
| `task_executors._run_agent` | Autosave History có thể đóng băng ở tin nhắn đầu | 7 |
|
||||
| `_apply_co4e_routing` / `_ai_apply_routing` | Mới chỉ import được, chưa từng gọi hàm | 11 |
|
||||
| `confirm_switch(decision)` Manual mode | Thiếu field ➔ **nổ bên trong modal**, nơi khó phát hiện nhất | (nằm trong 11 ở trên) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Ba lỗi thật phát hiện trong quá trình làm
|
||||
|
||||
### 🔴 Lỗi 1 — Deadlock khi khởi tạo routing service
|
||||
|
||||
`AppContext.routing_application()` giữ `_routing_lock` rồi gọi `routing()`, vốn cũng lấy **chính lock đó**. `threading.Lock` không reentrant ➔ **treo cứng ngay ở tin nhắn đầu tiên**, không có thông báo lỗi.
|
||||
|
||||
*Sửa*: tách `_routing_app_lock` riêng, và resolve engine **trước khi** lấy lock.
|
||||
|
||||
### 🟠 Lỗi 2 — Event `notice` bị cầu nối nuốt mất
|
||||
|
||||
Bản đầu của `agent_event.py` liệt kê 12 loại event nhưng **thiếu `notice`**. Trong khi đó `notice` được phát ra từ 3 nơi trên đường chạy bình thường:
|
||||
|
||||
* `core/agent_security.py` — yêu cầu/lệnh bị Agent Security **chặn**
|
||||
* `core/context_budget.py` — hội thoại vừa bị tự động nén
|
||||
* Bộ đọc file đính kèm — file không xử lý được, và tiến độ "đang đọc trang X/Y"
|
||||
|
||||
Cầu nối bỏ qua event không nhận diện được (đúng thiết kế, để engine có thể thêm event mới) — nên **người dùng sẽ không bao giờ thấy cảnh báo bảo mật**, hoàn toàn im lặng.
|
||||
|
||||
*Sửa*: thêm `NoticeEvent`, **và** thêm test quét mã nguồn engine tìm mọi tag `emit({"type": ...})` rồi bắt lỗi nếu có tag nào chưa có event tương ứng — biến sự im lặng thành test đỏ.
|
||||
|
||||
### 🟡 Lỗi 3 — Test đang chạy trên checkout khác
|
||||
|
||||
`tests/routing/conftest.py` đẩy thư mục cha vào `sys.path`. Vì thư mục checkout tên là `cowork_local_gitea` (không phải `cowork_local`), lệnh `import cowork_local` **ăn nhầm sang `Desktop\cowork_local`** — một bản checkout khác. Suite báo xanh trên mã nguồn **không phải nhánh đang review**.
|
||||
|
||||
*Sửa*: `tests/conftest.py` nạp `__init__.py` theo đường dẫn tuyệt đối và đăng ký vào `sys.modules` trước mọi test.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
|
||||
|
||||
| Cải thiện | Ảnh hưởng |
|
||||
| :--- | :--- |
|
||||
| `ProviderRegistry.build()` đóng dấu `descriptor.id` lên instance | Sửa việc usage của `ollama` / `github_copilot` / `codex` bị ghi nhận nhầm thành `openai_compat` trên Dashboard. **Chưa nối vào production** — xem mục 7. |
|
||||
| `ProviderRegistry.build()` copy config trước khi ghi | Trước đây một model do routing chọn có thể ghi đè lên default đã lưu của người dùng |
|
||||
| `UsageTrackerSink` ghi log ở mức debug khi thất bại | Trước là `except: pass` — mất sạch lý do khi Dashboard hỏng |
|
||||
| `estimate_tokens` được chốt bằng test so với `core.usage_tracker` | Bảo đảm việc tách telemetry **không làm lệch một con số nào** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Còn nợ & cần quyết định
|
||||
|
||||
| # | Nội dung | Người quyết |
|
||||
| :--- | :--- | :--- |
|
||||
| 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 lỗi quy kết usage ở mục 6, **nhưng đổi cách gom dữ liệu lịch sử trên Dashboard**. | Team Duy + PO |
|
||||
| 2 | **Mode `fallback` chưa có trên toggle UI** — config và service đã hỗ trợ đầy đủ; widget `RoutingToggle` thuộc R08. | Team Duy (R08) |
|
||||
| 3 | **Đã sửa 2 dòng trong `config.py`** (`routing_mode_for`, `set_routing_mode_for`) để dùng chung bộ từ vựng mode. File này Team Nam đang refactor ở R02-T02. | ⚠️ **Cần báo Team Nam** |
|
||||
| 4 | **Circular import** `core/model_pricing.py` ↔ `core/usage_tracker.py` chưa xử lý (task ngày 28/08). | Team Duy |
|
||||
| 5 | **2 test đỏ có sẵn từ trước**: `config.py:108` hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py`. Thuộc **EPIC R02 / Team Nam**. | 🟣 Team Nam |
|
||||
| 6 | `tests/integration/test_routing_surfaces.py` mất 41s do dựng `Co4ETab`/`FolderTab`. Nên gắn marker `slow` khi làm R10. | Team Duy (R10) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Phạm vi chưa kiểm thử
|
||||
|
||||
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
|
||||
|
||||
* **Chưa mở ứng dụng bằng tay** — mới chạy widget headless (`QT_QPA_PLATFORM=offscreen`), chưa có ai kiểm tra bằng mắt.
|
||||
* **Chưa gọi provider thật** — toàn bộ dùng `FakeProvider`, không có lưu lượng mạng.
|
||||
* **Chưa chạy 34 script `tools/check_*.py`** — các script này tự `sys.path.insert` thư mục cha nên sẽ import nhầm checkout khác (đúng lỗi 3 ở mục 5). Cần sửa chúng ở R10.
|
||||
|
||||
---
|
||||
|
||||
## 9. Việc kế tiếp của Team Duy
|
||||
|
||||
| EPIC | Nội dung | Điều kiện |
|
||||
| :--- | :--- | :--- |
|
||||
| **R08** (T01 ➔ T06) | Tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng | Sẵn sàng bắt đầu — `AgentEvent` (R04-T02) chính là kênh dữ liệu 6 widget con sẽ dùng thay vì đọc trực tiếp state của `ChatPanel` |
|
||||
| **R10** (T01 ➔ T05) | Testing Pyramid, `run_quality_gate.py`, Contributor Recipes, E2E Smoke | Chờ cả 3 team hoàn tất |
|
||||
|
||||
---
|
||||
|
||||
## 10. Lịch sử commit
|
||||
|
||||
| Commit | Nội dung |
|
||||
| :--- | :--- |
|
||||
| `bbc09f6` | feat(R01): architecture foundation, offline fakes and characterization net |
|
||||
| `96bec97` | feat(R03): unify provider catalogue, routing decisions and usage telemetry |
|
||||
| `a53163e` | feat(R04): immutable turn snapshot, typed agent events, conversation service |
|
||||
| `15e1d3e` | test(R03/R04): cover the three code paths that were changed but never executed |
|
||||
| `67b8d2e` | docs(refactor): correct the Team Duy scope block in the checklist |
|
||||
@@ -0,0 +1,198 @@
|
||||
# BÁO CÁO KẾT QUẢ — TEAM HOA: EPIC R05, R06
|
||||
|
||||
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
|
||||
* **Team**: 🟢 Team Hoa — Workspace, Filesystem, Scheduling & Tool Registry
|
||||
* **Nhánh**: `feature/teamhoa/r05-r06` (tạo từ `origin/feature/deltateam/refactor-plan`, chưa push lên remote — xem mục 7)
|
||||
* **Thời gian thực hiện**: 21/08/2026, 21:40 ➔ 22:57
|
||||
* **Ngày báo cáo**: 22/08/2026
|
||||
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `plan.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Tóm tắt điều hành
|
||||
|
||||
Hoàn tất **10/10 task** của 2 EPIC được giao: **R05** (Tool, MCP & Connector Policy) và **R06** (Workspace, Filesystem & History Isolation). Đã commit 2 commit trên branch cục bộ; **chưa push lên Gitea** — remote từ chối với lỗi quyền ghi (xem mục 7 #1).
|
||||
|
||||
| Chỉ số | Kết quả |
|
||||
| :--- | :--- |
|
||||
| Task hoàn thành | **10/10** (R05: 5, R06: 5) |
|
||||
| Commit | 2 (`ae4fe72`, `cf542b7`) |
|
||||
| File thay đổi | 41 (27 file mới, 14 file sửa — 1 file (`docs/refactor/Refactoring_Checklist.md`) sửa ở cả 2 commit) |
|
||||
| Dòng code | +3.054 / −459 |
|
||||
| Test | **283 pass** / 12,5s (283/287 — 4 fail có sẵn từ trước, không do R05/R06) |
|
||||
| Test suite nhanh (unit + contract + characterization + routing) | **256 pass / 4,5s** |
|
||||
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
|
||||
| File production > 400 dòng (file mới) | **0** — lớn nhất `domain/tools/tool_registry.py` 125 dòng |
|
||||
|
||||
**2 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5): một lỗ hổng bảo mật (MCP/connector tool không qua permission gate) và một race condition (turn chạy ngầm lưu nhầm lịch sử vào project khác).
|
||||
|
||||
---
|
||||
|
||||
## 2. Kết quả theo từng EPIC
|
||||
|
||||
### 🔹 EPIC R05 — Tool, MCP & Connector Policy (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R05-T01 | `domain/tools/tool_descriptor.py`, `tool_registry.py` | `ToolCapability` (Flag: READ/WRITE/EXECUTE/NETWORK, kết hợp được) + `ToolDescriptor` + `ToolRegistry` |
|
||||
| R05-T02 | `infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py` | Tách if/elif dispatcher của `core/tools.py`; `core/tools.py` còn 291 dòng (từ 566), là shim strangler-fig |
|
||||
| R05-T03 | `application/conversations/tool_policy_gateway.py` | `ToolPolicyGateway.allow(name, gate, payload)` — thay 2 chỗ check hardcode riêng biệt (`chat_agent.py`, `code_agent.py`) bằng 1 lookup capability |
|
||||
| R05-T04 | Sửa `core/chat_agent.py`, `core/mcp_client.py` | **Thay đổi hành vi có chủ đích** — xem mục 5, Lỗi 1 |
|
||||
| R05-T05 | `infrastructure/mcp/mcp_source_manager.py` | Tách lifecycle connection MCP khỏi `state.py::AppContext` |
|
||||
|
||||
**Vấn đề gốc đã giải quyết** — cùng một việc "tool này có cần xác nhận trước khi chạy không" tồn tại **3 cách trả lời khác nhau**:
|
||||
|
||||
```
|
||||
core/chat_agent.py::run_cowork name in ("run_command", "install_package")
|
||||
core/code_agent.py::run_code name in (WRITE_TOOLS | MS365_WRITE_TOOLS)
|
||||
core/mcp_client.py / ext_connectors.py (không hỏi gì cả)
|
||||
```
|
||||
|
||||
Cách thứ 3 là một lỗ hổng thật, không phải khác biệt thiết kế — xem mục 5.
|
||||
|
||||
### 🔹 EPIC R06 — Workspace, Filesystem & History Isolation (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R06-T01 | `domain/workspaces/workspace_session.py` | `WorkspaceSession` — snapshot bất biến (project_id/workspace_root/sandbox_dir/allowed_paths) + `is_allowed(path)`, cùng khuôn với `ConversationExecutionRequest` (R04-T01) |
|
||||
| R06-T02 | `infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py` | **Sửa bug thật** — xem mục 5, Lỗi 2 |
|
||||
| R06-T03 | `infrastructure/filesystem/execution_workspace.py` | Đặt tên cho quy ước `.scratch` đã có, không đổi vị trí file |
|
||||
| R06-T04 | Sửa `ui/chat_panel.py` | **Sửa race condition thật** — xem mục 5, Lỗi 3 |
|
||||
| R06-T05 | `application/workspaces/file_workspace_service.py` | File Explorer/AI Editor gọi `core/tools.py::execute_tool` giống agent, không viết lại logic |
|
||||
|
||||
---
|
||||
|
||||
## 3. Kiến trúc sau refactor
|
||||
|
||||
```text
|
||||
presentation/ (chưa đổi ở đợt này — ui/chat_panel.py chỉ thêm 1 field "home_history_dir")
|
||||
│
|
||||
▼
|
||||
application/ conversations/tool_policy_gateway.py ← ALLOW/CONFIRM cho mọi tool call
|
||||
workspaces/file_workspace_service.py ← file ops cho File Explorer/AI Editor
|
||||
│ (100% pure Python — check_imports.py chặn import Qt)
|
||||
▼
|
||||
domain/ tools/{tool_descriptor,tool_registry}.py ← capability + catalogue
|
||||
workspaces/workspace_session.py ← snapshot workspace bất biến
|
||||
▲
|
||||
infrastructure/ filesystem/{file_tools,command_tools,fetch_tools,tool_context,execution_workspace}.py
|
||||
mcp/mcp_source_manager.py ← lifecycle connection MCP
|
||||
persistence/json/{atomic_write,*_repository_impl}.py
|
||||
```
|
||||
|
||||
**Nguyên tắc di trú (ADR-001 mục 4, tiếp nối cách Team Duy làm ở R04)**: **không viết lại engine**. `core/tools.py::execute_tool`, `core/chat_agent.py::run_cowork`, `core/code_agent.py::run_code` vẫn là engine bên dưới — tầng mới chỉ sở hữu phần phân loại rủi ro (R05) và phần định danh workspace (R06) mà trước đây nằm rải rác/hardcode. `pytest` xanh liên tục giữa các bước.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bằng chứng kiểm thử
|
||||
|
||||
### Phân bố test (bao gồm test mới của Team Hoa)
|
||||
|
||||
| Suite | Số test | Ghi chú |
|
||||
| :--- | ---: | :--- |
|
||||
| `tests/unit/` | 137 | +41 test mới (R05: 26, R06: 15 — không tính `test_history_dir_race.py`, ở `integration/`) |
|
||||
| `tests/contracts/` | 29 | có sẵn từ R03, không đổi |
|
||||
| `tests/characterization/` | 13 | có sẵn từ R01, vẫn xanh — xác nhận `run_cowork` không hồi quy sau khi sửa gate |
|
||||
| `tests/routing/` | 79 | có sẵn từ trước, không đụng |
|
||||
| **Cộng 4 suite nhanh** | **256** (4 fail routing-env, không do R05/R06) | 4,5s |
|
||||
| `tests/integration/` | 27 | +2 test mới: `test_history_dir_race.py` — Qt offscreen thật, không phải test double |
|
||||
| **Tổng** | **287** (283 pass) | 12,5s |
|
||||
|
||||
### Đối chiếu Definition of Done (theo `DeltaTeam_prompt.md` / mẫu Team Duy)
|
||||
|
||||
| # | Tiêu chí | Kết quả |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | Mọi file mới < 400 dòng | ✅ Lớn nhất: `domain/tools/tool_registry.py` 125 dòng |
|
||||
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
|
||||
| 3 | Comment tiếng Anh giải thích lý do ở mọi khối sửa/mới | ✅ |
|
||||
| 4 | Có unit/contract/integration test, verify bằng chạy thật | ✅ 41 test mới + 2 test Qt offscreen thật cho race condition |
|
||||
| 5 | Không hồi quy | ✅ 283/287 pass — 4 fail là lỗi có sẵn từ trước R05/R06 (2 EPIC R02, 2 do môi trường máy có Ollama thật) |
|
||||
| 6 | Ghi Start/End vào Checklist | ✅ 10 task đã tick kèm mốc thời gian |
|
||||
| 7 | Cổng CASAN (`run_quality_gate.py`, R10-T02) | ⚠️ Chưa viết (thuộc R10, chưa tới lượt) — Check 3 đã PASS |
|
||||
|
||||
---
|
||||
|
||||
## 5. Hai lỗi thật phát hiện và sửa trong quá trình làm
|
||||
|
||||
### 🔴 Lỗi 1 (R05-T04) — Tool MCP/Connector chạy hoàn toàn không qua permission gate
|
||||
|
||||
`core/chat_agent.py::run_cowork` có 2 nhánh dispatch tool call: nhánh built-in (`read_file`, `run_command`, ...) đi qua gate xác nhận khi Settings bật "confirm before running commands"; nhánh `extra_tools` (mọi tool từ MCP server hoặc Connector — `core/mcp_client.py`, `core/ext_connectors.py`) gọi thẳng:
|
||||
|
||||
```python
|
||||
if name in extra_names and extra_executor is not None:
|
||||
...
|
||||
result = extra_executor(name, args) # KHÔNG có bước xác nhận nào
|
||||
```
|
||||
|
||||
Nghĩa là một MCP server (kể cả server tự cấu hình, hoặc MS365 write-tool như `send_mail`) chạy **auto-run tuyệt đối**, bất kể người dùng đã bật "confirm before running commands" trong Settings hay chưa. Đây không phải khác biệt thiết kế có chủ đích — không có ghi chú, không có toggle riêng cho việc này.
|
||||
|
||||
*Sửa*: mọi `extra_tools` được gắn `ToolCapability` mặc định bảo toàn (`WRITE|EXECUTE|NETWORK` — vì MCP không có chuẩn khai báo rủi ro), đăng ký vào registry của turn, và đi qua CÙNG `ToolPolicyGateway` với built-in tools.
|
||||
|
||||
**Đây là thay đổi hành vi người dùng sẽ thấy**: khi "confirm before running commands" đang bật, tool MCP/connector từ giờ sẽ hỏi xác nhận — giống `run_command`. Verify bằng test `tests/unit/test_cowork_extra_tool_policy.py` (3 test: rejected trước khi executor chạy, approved thì chạy, `gate=None` vẫn auto-run như cũ).
|
||||
|
||||
### 🟠 Lỗi 2 (R06-T04) — Turn chạy ngầm lưu nhầm lịch sử vào project khác
|
||||
|
||||
`ui/chat_panel.py::_persist_session` (lưu hội thoại của một turn **chạy ngầm**, không phải conversation đang xem) gọi:
|
||||
|
||||
```python
|
||||
save_conversation(self.ctx.config.history_dir(), ...)
|
||||
```
|
||||
|
||||
`history_dir()` đọc `config._project_history_dir` — một field **dùng chung** trên `AppContext.config`, được `ui/workspace_tab.py::_load_current` ghi đè mỗi lần người dùng đổi project trong màn Workspace. Nếu một turn ở project A còn đang chạy (ví dụ Scheduled Task, hoặc user gõ câu hỏi rồi chuyển sang xem project B ngay) và người dùng đổi sang project B **trước khi** turn đó lưu xong, hội thoại của project A bị ghi nhầm vào thư mục lịch sử của project B.
|
||||
|
||||
*Sửa*: thêm `"home_history_dir"` vào dict `ctx` mà mỗi turn đã có sẵn (cùng quy ước với `home_id`/`home_messages`/`home_title` — dict này được author code gốc thiết kế đúng cho mục đích này, chỉ thiếu 1 field), chụp giá trị **tại lúc submit** thay vì đọc sống lúc lưu.
|
||||
|
||||
*Kèm 1 phát hiện phụ*: `_save_snapshot` (dùng cho conversation ĐANG XEM) đã có logic đúng từ trước để không ghi đè `project_id` của một turn nền bằng project hiện tại — chỉ riêng **thư mục lưu** là bị bỏ sót, không phải toàn bộ cơ chế bị thiếu.
|
||||
|
||||
Verify bằng test Qt offscreen thật (không phải double): `tests/integration/test_history_dir_race.py` — dựng `ChatPanel` thật, giả lập đổi project giữa lúc turn chạy, xác nhận file được lưu đúng thư mục project A.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
|
||||
|
||||
| Cải thiện | Ảnh hưởng |
|
||||
| :--- | :--- |
|
||||
| `core/projects.py::save_project`, `core/history.py::save_conversation/rename_conversation/set_pinned` chuyển sang ghi atomic (`infrastructure/persistence/json/atomic_write.py`) | Trước đây `path.write_text(json.dumps(...))` không atomic — crash/kill giữa lúc ghi để lại file JSON hỏng, và `load_project`/`load_conversation` coi file hỏng như "không tồn tại" ➔ **mất project hoặc hội thoại âm thầm, không báo lỗi**. Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng (`tests/unit/test_atomic_write_and_repositories.py`) |
|
||||
| `McpServerConnection.is_alive()` (mới, `core/mcp_client.py`) | Nhỏ, cộng thêm — cho `McpToolSourceManager` biết một connection cached đã chết (subprocess crash) để khởi động lại, thay vì cache giữ một connection chết vô thời hạn |
|
||||
|
||||
---
|
||||
|
||||
## 7. Còn nợ & cần quyết định
|
||||
|
||||
| # | Nội dung | Người quyết |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | **Branch chưa lên được Gitea** — `git push` bị từ chối: `User permission denied for writing` (pre-receive hook). Cần cấp quyền push cho tài khoản git đang dùng trên máy này, hoặc push bằng tài khoản khác có quyền. | Admin Gitea |
|
||||
| 2 | **Xung đột file với EPIC R02 (Team Nam)**: R02-T01 giao `infrastructure/persistence/json/atomic_json_file.py`. R06-T02 cần atomic write ngay nên tạo `atomic_write.py` (tên khác, cùng thư mục) — không đụng file của Team Nam, nhưng 2 module cùng mục đích sẽ tồn tại song song cho tới khi hợp nhất. | Team Nam (khi bắt đầu R02-T01) |
|
||||
| 3 | **`WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` chưa có call site thật** — giống tình trạng `ProviderRegistry` của Team Duy ở R03 (mục 7 #1 trong báo cáo Team Duy). Mọi nơi trong production vẫn gọi trực tiếp `core/projects.py`/`core/history.py`/`core/tools.py::execute_tool`. | Team Hoa (nối dây ở EPIC sau) |
|
||||
| 4 | **R06-T04 không sửa đúng y nguyên `ui/workspace_tab.py::_load_current` như mô tả gốc trong `plan.md`** — bug thật nằm ở điểm ĐỌC (`ui/chat_panel.py::_persist_session`), không phải điểm GHI (`_load_current` chỉ set field, tự nó không đọc lại). Đã sửa đúng điểm đọc, có test thật xác nhận. Việc đổi `_load_current` sang "đồng bộ bằng session id" như plan gốc gợi ý cần tách sâu hơn `WorkspaceTab`/`ChatPanel`, thuộc phạm vi R08 (UI/Application Separation). | Team Duy (R08) |
|
||||
| 5 | **2 test đỏ có sẵn từ trước, không do R05/R06**: `tests/test_config_security.py` × 2 (EPIC R02/Team Nam, đã ghi nhận từ báo cáo Team Duy) và `tests/unit/test_routing_wiring.py` × 2 (môi trường máy này có Ollama/llama3.1 thật + config routing cục bộ khác giả định "fresh install" của test — nghi là do máy chạy test có cấu hình routing/Ollama khác máy Team Duy dùng, cần Team Duy xác nhận lại trên máy sạch). | Team Nam (#1), Team Duy (#2) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Phạm vi chưa kiểm thử
|
||||
|
||||
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
|
||||
|
||||
* **R05-T04 (gate cho MCP/connector) chưa test với MCP server thật** — toàn bộ test dùng `ToolSpec` giả (`_EXTRA_SPEC` trong `test_cowork_extra_tool_policy.py`), chưa có tình huống thật với `core/mcp_client.py::McpServerConnection` chạy subprocess thật.
|
||||
* **`McpToolSourceManager` (R05-T05) chưa test với subprocess MCP thật** — test dùng `_FakeConnection`, không spawn tiến trình. Đã smoke-test `AppContext.build_mcp_tools()` thật (không có server nào cấu hình → chỉ trả về ms365 local tools) nhưng chưa thử ensure/restart trên một server thật.
|
||||
* **`ui/folder_tab.py`, `ui/file_edit_dialog.py` chưa được nối vào `FileWorkspaceService` (R06-T05)** — dịch vụ tồn tại và có test unit đầy đủ, nhưng chưa xác nhận bằng cách chạy UI thật (đã mở app kiểm tra sau R05, nhưng không lặp lại cho R06's file explorer flow cụ thể).
|
||||
* **Đã mở app thật 1 lần sau khi sửa `ui/chat_panel.py` (R06-T04)** để xác nhận không crash lúc khởi động — chưa thử tay thao tác "đổi project giữa lúc chat đang trả lời" trên UI thật (chỉ verify bằng test offscreen).
|
||||
|
||||
---
|
||||
|
||||
## 9. Việc kế tiếp của Team Hoa
|
||||
|
||||
| EPIC | Nội dung | Điều kiện |
|
||||
| :--- | :--- | :--- |
|
||||
| **R07** (Scheduling & Workflow Runtime) | Tách `TaskRepository`/`ScheduleCalculator` khỏi `QTimer` (`core/task_scheduler.py`), xây `TaskApplicationService` | Phối hợp 🟣 Team Nam (Co4E Workflows) |
|
||||
| **R08** (T01 ➔ ...) | Phần Team Hoa trong tách UI (`ui/workspace_tab.py`, `ui/folder_tab.py`, `ui/schedule_task_tab.py`, `ui/dashboard_tab.py`, Graph) | Chờ R07 |
|
||||
| Nối `WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` vào call site thật | Xem mục 7 #3 | Có thể làm sớm hơn R07/R08 nếu được yêu cầu |
|
||||
|
||||
---
|
||||
|
||||
## 10. Lịch sử commit
|
||||
|
||||
| Commit | Nội dung |
|
||||
| :--- | :--- |
|
||||
| `ae4fe72` | feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager |
|
||||
| `cf542b7` | feat(R06): workspace session snapshot, atomic persistence, history-dir race fix |
|
||||
@@ -20,22 +20,99 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 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` đỏ
|
||||
>
|
||||
> ### 📍 PHẠM VI TEAM DUY & PHẦN CÒN LẠI
|
||||
> Theo `Feature_Architecture_Proposal.md` (dòng 7) và `DeltaTeam_prompt.md` (dòng 17), Team Duy chủ trì **R01, R03, R04, R08 (phân hệ Chat UI), R10**.
|
||||
> * ✅ **R01, R03, R04** — xong 16/16 task, đã push.
|
||||
> * ⬜ **R08 (R08-T01 ➔ R08-T06)** — chưa bắt đầu: tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng.
|
||||
> * ⬜ **R10** — làm sau cùng, chờ 3 team hoàn tất.
|
||||
> * **R02 thuộc 🟣 Team Nam** (xem mục EPIC R02 bên dưới) — đây là nguyên nhân 2 test đỏ ở trên, không phải việc của Team Duy.
|
||||
>
|
||||
> ### 📄 BÁO CÁO CHI TIẾT
|
||||
> Xem `docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md` — kết quả từng EPIC, bằng chứng kiểm thử, 3 lỗi thật đã phát hiện, và phạm vi **chưa** kiểm thử.
|
||||
>
|
||||
> ### 📌 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ý** (task ngày 28/08).
|
||||
> 5. Việc kế tiếp của Team Duy là **R08 phân hệ Chat UI** (6 widget con), rồi **R10** sau cùng.
|
||||
|
||||
---
|
||||
|
||||
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA (cập nhật `2026-08-21 22:57`)
|
||||
|
||||
> [!NOTE]
|
||||
> ### ✅ ĐÃ HOÀN TẤT: 10/10 task của **R05 + R06** — branch `feature/teamhoa/r05-r06` (tạo từ `origin/feature/deltateam/refactor-plan`, có sẵn nền R01/R03/R04)
|
||||
>
|
||||
> | EPIC | Task | Trạng thái |
|
||||
> | :--- | :--- | :--- |
|
||||
> | **R05** Tool, MCP & Connector Policy | T01 → T05 | ✅ 5/5 |
|
||||
> | **R06** Workspace, Filesystem & History Isolation | T01 → T05 | ✅ 5/5 |
|
||||
>
|
||||
> **Kiểm chứng (chạy thật):**
|
||||
> * `pytest tests/` ➔ **283 pass / 4 fail** (+41 test mới cho R05+R06, gồm 2 test Qt offscreen thật trong `tests/integration/test_history_dir_race.py`)
|
||||
> * 4 fail là **lỗi có sẵn từ trước**, không liên quan R05/R06: 2 trong `test_config_security.py` (EPIC R02, đã ghi nhận bởi Team Duy) + 2 trong `test_routing_wiring.py` (môi trường máy này có Ollama/llama3.1 thật + config routing cục bộ khác "fresh install").
|
||||
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
|
||||
> * Mọi file mới **< 400 dòng** (lớn nhất: `domain/tools/tool_registry.py` 125 dòng). `core/tools.py` giảm từ 566 ➔ 291 dòng.
|
||||
>
|
||||
> ### 📄 BÁO CÁO CHI TIẾT
|
||||
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R06.md` — kết quả từng EPIC, bằng chứng kiểm thử, 2 lỗi thật đã phát hiện (permission gate bị bỏ qua cho MCP tools, race condition lưu nhầm lịch sử), và phạm vi **chưa** kiểm thử.
|
||||
>
|
||||
> ### 🔧 TÓM TẮT R06
|
||||
> * **R06-T01**: `domain/workspaces/workspace_session.py::WorkspaceSession` — snapshot bất biến (project_id, workspace_root, sandbox_dir, allowed_paths) + `is_allowed(path)`.
|
||||
> * **R06-T02**: `infrastructure/persistence/json/{workspace_repository_impl,conversation_repository_impl}.py` bọc `core/projects.py`/`core/history.py`. **Đã sửa bug thật**: `save_project`/`save_conversation`/`rename_conversation`/`set_pinned` trước đây `path.write_text()` không atomic (crash giữa lúc ghi = file JSON hỏng, `load_project`/`load_conversation` coi file hỏng như "không tồn tại" — mất project/hội thoại âm thầm). Giờ cả 4 hàm ghi qua `infrastructure/persistence/json/atomic_write.py::write_json` (temp file + `os.replace`). Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng.
|
||||
> * **R06-T03**: `infrastructure/filesystem/execution_workspace.py::ExecutionWorkspace` — đặt tên cho quy ước `.scratch` đã có sẵn (không đổi vị trí file).
|
||||
> * **R06-T04**: Sửa race trong `ui/chat_panel.py` (không phải trực tiếp `_load_current`, xem "còn nợ" #2). `ChatPanel._persist_session` (lưu hội thoại của turn CHẠY NGẦM, không phải conversation đang xem) trước đây gọi `self.ctx.config.history_dir()` SỐNG tại thời điểm turn xong — nếu user đổi project khi turn còn chạy (`_load_current` ghi `config._project_history_dir`), turn nền lưu nhầm vào thư mục lịch sử của project MỚI. Fix: thêm `"home_history_dir"` vào dict `ctx` per-turn đã có sẵn (cùng quy ước với `home_id`/`home_messages`/`home_title`), chụp tại lúc submit. Test thật bằng Qt offscreen: `tests/integration/test_history_dir_race.py`.
|
||||
> * **R06-T05**: `application/workspaces/file_workspace_service.py::FileWorkspaceService` — cho File Explorer/AI Editor gọi `execute_tool` (list_dir/read_file/write_file/edit_file) giống agent, không tự viết lại logic.
|
||||
>
|
||||
> ### 🔧 TÓM TẮT R05
|
||||
> * **R05-T01/T02**: `core/tools.py`'s if/elif dispatcher tách thành `infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py` + `domain/tools/{tool_descriptor,tool_registry}.py`. `core/tools.py` còn lại là shim strangler-fig (re-export `ToolContext`/`ToolError`, dispatch qua dict).
|
||||
> * **R05-T03**: `application/conversations/tool_policy_gateway.py::ToolPolicyGateway` — thay `if gate is not None and name in ("run_command","install_package")` (chat_agent.py) và `if name in (WRITE_TOOLS|MS365_WRITE_TOOLS)` (code_agent.py) bằng một lookup capability chung. Đã verify bằng test: đúng 2 tool cũ vẫn được gate, không tool nào khác bị ảnh hưởng.
|
||||
> * **R05-T04 — ⚠️ THAY ĐỔI HÀNH VI CÓ CHỦ ĐÍCH**: trước đây MCP/connector/ext-connector tools (`core/mcp_client.py`, `core/ext_connectors.py`) chạy qua `extra_executor(name, args)` **không hề qua permission gate**. Giờ mọi `extra_tools` được gắn capability mặc định (`WRITE|EXECUTE|NETWORK`, vì MCP không có chuẩn khai báo rủi ro) và đi qua CÙNG `ToolPolicyGateway` như built-in tools. Khi Settings có "confirm before running commands" bật, tool MCP/connector giờ sẽ hỏi xác nhận — người dùng SẼ thấy thêm prompt so với trước. Test: `tests/unit/test_cowork_extra_tool_policy.py`.
|
||||
> * **R05-T05**: `infrastructure/mcp/mcp_source_manager.py::McpToolSourceManager` — tách lifecycle connection (cache/lock/start-or-skip) ra khỏi `state.py::AppContext` (trước đây inline trong `_mcp_connections`/`_conn_lock`). `AppContext` giờ chỉ gọi `self._mcp_manager.ensure/stop/stop_all`. `_ext_connections` (Connectors CAD/CAE/MS365/Other) KHÔNG thuộc phạm vi T05, vẫn giữ `_conn_lock` riêng như cũ.
|
||||
>
|
||||
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
|
||||
> 1. **Xung đột file với EPIC R02 (Team Nam)**: R02-T01 giao `infrastructure/persistence/json/atomic_json_file.py` cho Team Nam. R06-T02 cần atomic write NGAY (bug thật, không chờ được) nên đã tạo `infrastructure/persistence/json/atomic_write.py` — tên khác, cùng thư mục, không đụng file của Team Nam. `core/projects.py`/`core/history.py` đang dùng module này trực tiếp. **Cần Team Nam xác nhận khi bắt đầu R02-T01**: nên hợp nhất `atomic_write.py` vào `atomic_json_file.py` (Team Hoa đổi 4 import) hay giữ 2 module riêng (rủi ro trôi giữa 2 cách ghi atomic).
|
||||
> 2. **`WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` chưa có nơi gọi thật** — giống tình trạng `ProviderRegistry` của Team Duy ở R03. Mọi call site sản xuất (`ui/workspace_tab.py`, `ui/folder_tab.py`, `state.py`, task executors) vẫn dùng trực tiếp `core/projects.py`/`core/history.py`/`core/tools.py::execute_tool` — các class mới là seam cho tầng application ở EPIC sau (R07/R08), chưa nối dây.
|
||||
> 3. **R06-T04 phạm vi thực tế khác một chút so với mô tả gốc**: bug không nằm ở `ui/workspace_tab.py::_load_current` (hàm đó chỉ *set* `config._project_history_dir`, không tự đọc lại nó) mà ở `ui/chat_panel.py::_persist_session` — nơi một turn chạy ngầm đọc SỐNG giá trị đó lúc turn xong. Đã sửa đúng điểm đọc, có test Qt offscreen thật (`tests/integration/test_history_dir_race.py`), nhưng chưa đổi kiến trúc `_load_current` như plan gốc gợi ý (dùng session id thay biến toàn cục) — việc đó cần tách `ChatPanel`/`WorkspaceTab` sâu hơn, thuộc phạm vi R08 (UI/Application Separation).
|
||||
> 4. R05/R06 xong toàn bộ — Team Hoa chờ chỉ đạo cho **R07** (Scheduling & Workflow Runtime, phối hợp Team Nam) hoặc merge/review trước khi tiếp tục.
|
||||
|
||||
---
|
||||
|
||||
## 📌 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ệ)
|
||||
* **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 09:56` | End: `2026-08-21 10:00`*
|
||||
- [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 10:00` | End: `2026-08-21 10:02`*
|
||||
- [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 09:58` | End: `2026-08-21 10:05`*
|
||||
- [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 10:02` | End: `2026-08-21 10:04`*
|
||||
- [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 10:04` | End: `2026-08-21 10:05`*
|
||||
|
||||
---
|
||||
|
||||
@@ -62,18 +139,18 @@
|
||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
||||
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
|
||||
|
||||
- [ ] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
|
||||
*Start: `2026-08-21 10:10` | End: `2026-08-21 10:12`*
|
||||
- [x] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
|
||||
*Start: `2026-08-21 10:06` | End: `2026-08-21 10:10`*
|
||||
- [x] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
|
||||
*Start: `2026-08-21 10:12` | End: `2026-08-21 10:15`*
|
||||
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
|
||||
*Start: `2026-08-21 10:17` | End: `2026-08-21 10:20`*
|
||||
- [x] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
|
||||
*Start: `2026-08-21 10:20` | End: `2026-08-21 10:22`*
|
||||
- [x] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
|
||||
*Start: `2026-08-21 10:15` | End: `2026-08-21 10:17`*
|
||||
|
||||
---
|
||||
|
||||
@@ -81,16 +158,16 @@
|
||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
||||
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
|
||||
|
||||
- [ ] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
|
||||
*Start: `2026-08-21 10:23` | End: `2026-08-21 10:25`*
|
||||
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
|
||||
*Start: `2026-08-21 10:22` | End: `2026-08-21 10:23`*
|
||||
- [x] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
|
||||
*Start: `2026-08-21 10:25` | End: `2026-08-21 10:27`*
|
||||
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
|
||||
*Start: `2026-08-21 10:27` | End: `2026-08-21 10:31`*
|
||||
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
|
||||
*Start: `2026-08-21 10:28` | End: `2026-08-21 10:30`*
|
||||
|
||||
---
|
||||
|
||||
@@ -98,16 +175,16 @@
|
||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Phối hợp Team Duy
|
||||
* **Mục tiêu**: Bóc tách monolithic `core/tools.py`, đưa toàn bộ Built-in tools, MCP tools và Connectors qua `ToolPolicyGateway` kiểm tra quyền phân tầng.
|
||||
|
||||
- [ ] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
|
||||
*Start: `2026-08-21 21:40` | End: `2026-08-21 21:47`*
|
||||
- [x] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py`
|
||||
*Start: `2026-08-21 21:47` | End: `2026-08-21 21:56`*
|
||||
- [x] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py`
|
||||
*Start: `2026-08-21 21:56` | End: `2026-08-21 22:04`*
|
||||
- [x] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
|
||||
*Start: `2026-08-21 22:04` | End: `2026-08-21 22:12`*
|
||||
- [x] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py`
|
||||
*Start: `2026-08-21 22:12` | End: `2026-08-21 22:19`*
|
||||
|
||||
---
|
||||
|
||||
@@ -115,16 +192,16 @@
|
||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì)
|
||||
* **Mục tiêu**: Xóa bỏ biến toàn cục `state.py::active_project_id`, đóng gói workspace per-turn thành `WorkspaceSession` bất biến, bảo vệ an toàn đường dẫn sandbox.
|
||||
|
||||
- [ ] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
|
||||
*Start: `2026-08-21 22:19` | End: `2026-08-21 22:24`*
|
||||
- [x] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py`
|
||||
*Start: `2026-08-21 22:24` | End: `2026-08-21 22:35`*
|
||||
- [x] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
|
||||
*Start: `2026-08-21 22:35` | End: `2026-08-21 22:40`*
|
||||
- [x] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
|
||||
*Start: `2026-08-21 22:40` | End: `2026-08-21 22:50`*
|
||||
- [x] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py`
|
||||
*Start: `2026-08-21 22:50` | End: `2026-08-21 22:57`*
|
||||
|
||||
---
|
||||
|
||||
@@ -229,16 +306,16 @@
|
||||
|
||||
| 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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] chờ 3 team hoàn tất |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Domain layer - pure Python entities, value objects and events.
|
||||
|
||||
The innermost layer of the 4-tier architecture (see
|
||||
``docs/architecture/ADR-001-layered-architecture.md``). Modules here describe
|
||||
WHAT the application is about - a turn of conversation, a model candidate, an
|
||||
agent event - and depend on nothing but the standard library.
|
||||
|
||||
Hard rule (ADR-001 I1/I2, enforced by ``scripts/check_imports.py``): no imports
|
||||
of PySide6/PyQt, and no imports from ``application/``, ``infrastructure/``,
|
||||
``presentation/`` or the legacy ``core/``/``ui/`` packages. That is what keeps
|
||||
this layer testable in milliseconds and reusable from a headless scheduler.
|
||||
"""
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Domain entities for one agent turn: the request snapshot and the typed event
|
||||
stream it produces (EPIC R04)."""
|
||||
|
||||
from .agent_event import (
|
||||
AgentEvent,
|
||||
AssistantDoneEvent,
|
||||
ErrorEvent,
|
||||
HistoryReadyEvent,
|
||||
NoticeEvent,
|
||||
OutputsAddedEvent,
|
||||
OutputsRemovedEvent,
|
||||
PlanUpdatedEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
ToolOutputEvent,
|
||||
TurnCompletedEvent,
|
||||
collect_text,
|
||||
event_from_dict,
|
||||
tool_calls,
|
||||
)
|
||||
from .conversation_execution_request import (
|
||||
ConversationExecutionRequest,
|
||||
new_turn_id,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConversationExecutionRequest",
|
||||
"new_turn_id",
|
||||
"AgentEvent",
|
||||
"TextChunkEvent",
|
||||
"ReasoningChunkEvent",
|
||||
"AssistantDoneEvent",
|
||||
"PlanUpdatedEvent",
|
||||
"ToolCallStartedEvent",
|
||||
"ToolOutputEvent",
|
||||
"ToolCallFinishedEvent",
|
||||
"OutputsAddedEvent",
|
||||
"OutputsRemovedEvent",
|
||||
"NoticeEvent",
|
||||
"HistoryReadyEvent",
|
||||
"TurnCompletedEvent",
|
||||
"ErrorEvent",
|
||||
"event_from_dict",
|
||||
"collect_text",
|
||||
"tool_calls",
|
||||
]
|
||||
@@ -0,0 +1,370 @@
|
||||
"""AgentEvent - the typed event stream one agent turn produces (R04-T02).
|
||||
|
||||
Today the turn engine talks to its caller through untyped dicts::
|
||||
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": result.get("ok", False), "output": result.get("output", "")})
|
||||
|
||||
and every consumer re-discovers the vocabulary by reading the producer. There
|
||||
are eleven such shapes across ``core/chat_agent.py``, ``core/code_agent.py`` and
|
||||
``core/task_executors.py``; a consumer that misspells ``"tool_result"`` or reads
|
||||
``"result"`` instead of ``"output"`` fails silently, at runtime, only for the
|
||||
tool path that triggers it.
|
||||
|
||||
This module makes the vocabulary explicit. Each event is a frozen dataclass, so:
|
||||
|
||||
* the set of possible events is enumerable (see :data:`EVENT_TYPES`);
|
||||
* a field name typo is an ``AttributeError`` at the point of use, not a silently
|
||||
missing chat bubble;
|
||||
* an event can cross a thread boundary safely - it cannot be mutated after the
|
||||
producer hands it over, which is exactly what the Qt-signal seam needs.
|
||||
|
||||
Bridging with the legacy dicts is deliberate and two-way: :func:`event_from_dict`
|
||||
adapts what ``run_cowork`` emits today, and :meth:`AgentEvent.to_dict` renders an
|
||||
event back into the legacy shape so existing widgets keep working untouched
|
||||
while the presentation layer migrates screen by screen (EPIC R08).
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEvent:
|
||||
"""Base class for everything a turn can report.
|
||||
|
||||
``type`` is the legacy string tag, kept as a class attribute so the bridge
|
||||
functions can round-trip an event without a separate mapping table.
|
||||
"""
|
||||
|
||||
type: str = field(init=False, default="event")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Render into the legacy ``emit()`` dict shape."""
|
||||
return {"type": self.type}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assistant output
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TextChunkEvent(AgentEvent):
|
||||
"""One fragment of the visible answer, as it streams in."""
|
||||
|
||||
delta: str
|
||||
type: str = field(init=False, default="text")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReasoningChunkEvent(AgentEvent):
|
||||
"""One fragment of the model's PRIVATE reasoning.
|
||||
|
||||
Drives the "Thinking" indicator only. Consumers must never append this to
|
||||
the answer or persist it into conversation history - keeping it a distinct
|
||||
type is what makes that mistake hard to make by accident.
|
||||
"""
|
||||
|
||||
delta: str
|
||||
type: str = field(init=False, default="reasoning")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantDoneEvent(AgentEvent):
|
||||
"""One assistant message finished. A turn with tool calls emits this once
|
||||
per step, not once per turn - see :class:`TurnCompletedEvent`."""
|
||||
|
||||
content: str = ""
|
||||
type: str = field(init=False, default="assistant_done")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "content": self.content}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Planning
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class PlanUpdatedEvent(AgentEvent):
|
||||
"""The agent rewrote its plan (the ``update_plan`` tool)."""
|
||||
|
||||
steps: Tuple[Dict[str, Any], ...] = ()
|
||||
type: str = field(init=False, default="plan_set")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "steps": [dict(s) for s in self.steps]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tool lifecycle
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallStartedEvent(AgentEvent):
|
||||
"""A tool call is about to run, with the preview shown to the user.
|
||||
|
||||
Maps the legacy ``tool_proposed`` event. "Proposed" was a misnomer: by the
|
||||
time it is emitted the call is already going to run unless a permission gate
|
||||
rejects it, and the gate reports that as a finished call with ``ok=False``.
|
||||
"""
|
||||
|
||||
call_id: str
|
||||
name: str
|
||||
args: Dict[str, Any] = field(default_factory=dict)
|
||||
preview: Optional[Dict[str, Any]] = None
|
||||
type: str = field(init=False, default="tool_proposed")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {"type": self.type, "id": self.call_id,
|
||||
"name": self.name, "args": dict(self.args)}
|
||||
if self.preview is not None:
|
||||
out["preview"] = dict(self.preview)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolOutputEvent(AgentEvent):
|
||||
"""A line of live output from a running tool (command stdout, for example)."""
|
||||
|
||||
call_id: str
|
||||
name: str
|
||||
delta: str
|
||||
type: str = field(init=False, default="tool_output")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "id": self.call_id, "name": self.name,
|
||||
"delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallFinishedEvent(AgentEvent):
|
||||
"""A tool call ended, successfully or not.
|
||||
|
||||
``ok=False`` covers every failure mode alike - the tool raised, the sandbox
|
||||
blocked it, or the user rejected it at the permission gate - because the
|
||||
consumer's job is the same in all three: show the failure and let the model
|
||||
react to it.
|
||||
"""
|
||||
|
||||
call_id: str
|
||||
name: str
|
||||
ok: bool = False
|
||||
output: str = ""
|
||||
path: str = "" # file the tool wrote, when it wrote one
|
||||
produced: Tuple[str, ...] = () # extra artefacts (e.g. a generator's outputs)
|
||||
type: str = field(init=False, default="tool_result")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name,
|
||||
"ok": self.ok, "output": self.output}
|
||||
if self.path:
|
||||
out["path"] = self.path
|
||||
if self.produced:
|
||||
out["produced"] = list(self.produced)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Output folder
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class OutputsAddedEvent(AgentEvent):
|
||||
"""Files appeared in the turn's output folder."""
|
||||
|
||||
paths: Tuple[str, ...] = ()
|
||||
type: str = field(init=False, default="outputs_added")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputsRemovedEvent(AgentEvent):
|
||||
"""Files were cleaned up from the turn's output folder (intermediates)."""
|
||||
|
||||
paths: Tuple[str, ...] = ()
|
||||
type: str = field(init=False, default="outputs_removed")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoticeEvent(AgentEvent):
|
||||
"""A UI-visible aside that is not part of the model's answer.
|
||||
|
||||
Three producers today, all reachable from a normal turn:
|
||||
``core/agent_security.py`` (a request or command blocked by the security
|
||||
layer), ``core/context_budget.py`` (the conversation was auto-compressed)
|
||||
and the attachment readers (a file that could not be processed, plus live
|
||||
"reading page X/Y" progress).
|
||||
|
||||
``level`` selects how the UI renders it: ``"progress"`` updates the thinking
|
||||
indicator in place, anything else becomes a warning bubble. Dropping these
|
||||
would silently hide security warnings from the user, which is why the type
|
||||
exists rather than being folded into TextChunkEvent.
|
||||
"""
|
||||
|
||||
text: str
|
||||
level: str = "info"
|
||||
type: str = field(init=False, default="notice")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "level": self.level, "text": self.text}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryReadyEvent(AgentEvent):
|
||||
"""A history session exists for this run and can be opened."""
|
||||
|
||||
session_id: str
|
||||
type: str = field(init=False, default="history_ready")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "session_id": self.session_id}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Turn lifecycle - emitted by the application service, not by the legacy engine
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TurnCompletedEvent(AgentEvent):
|
||||
"""The whole turn finished: no more events will follow.
|
||||
|
||||
New in R04. The legacy engine has no end-of-turn signal at all, so every
|
||||
consumer infers "done" from the worker thread finishing - which is why a
|
||||
cancelled turn and a failed turn look identical to the UI today.
|
||||
"""
|
||||
|
||||
content: str = ""
|
||||
cancelled: bool = False
|
||||
type: str = field(init=False, default="turn_completed")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "content": self.content, "cancelled": self.cancelled}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorEvent(AgentEvent):
|
||||
"""The turn failed. ``recoverable`` marks errors the user can act on
|
||||
(pick another model, shorten the prompt) rather than a hard outage."""
|
||||
|
||||
message: str
|
||||
recoverable: bool = False
|
||||
type: str = field(init=False, default="error")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "message": self.message,
|
||||
"recoverable": self.recoverable}
|
||||
|
||||
|
||||
# The legacy tag -> event class map. Also the authoritative list of what a turn
|
||||
# can emit, which is what makes an exhaustive consumer possible for the first time.
|
||||
EVENT_TYPES: Dict[str, type] = {
|
||||
"text": TextChunkEvent,
|
||||
"reasoning": ReasoningChunkEvent,
|
||||
"assistant_done": AssistantDoneEvent,
|
||||
"plan_set": PlanUpdatedEvent,
|
||||
"tool_proposed": ToolCallStartedEvent,
|
||||
"tool_start": ToolCallStartedEvent,
|
||||
"tool_output": ToolOutputEvent,
|
||||
"tool_result": ToolCallFinishedEvent,
|
||||
"outputs_added": OutputsAddedEvent,
|
||||
"outputs_removed": OutputsRemovedEvent,
|
||||
"notice": NoticeEvent,
|
||||
"history_ready": HistoryReadyEvent,
|
||||
"turn_completed": TurnCompletedEvent,
|
||||
"error": ErrorEvent,
|
||||
}
|
||||
|
||||
|
||||
def event_from_dict(payload: Mapping[str, Any]) -> Optional[AgentEvent]:
|
||||
"""Adapt one legacy ``emit()`` dict into a typed event.
|
||||
|
||||
Returns ``None`` for an unknown tag instead of raising: the legacy engine is
|
||||
still being refactored and may grow an event before this module knows about
|
||||
it. Dropping an unrecognised event degrades the UI by one missing bubble;
|
||||
raising here would abort a turn that had otherwise succeeded.
|
||||
"""
|
||||
kind = str(payload.get("type", ""))
|
||||
cls = EVENT_TYPES.get(kind)
|
||||
if cls is None:
|
||||
return None
|
||||
|
||||
if cls is TextChunkEvent or cls is ReasoningChunkEvent:
|
||||
return cls(delta=str(payload.get("delta", "")))
|
||||
if cls is AssistantDoneEvent:
|
||||
return AssistantDoneEvent(content=str(payload.get("content", "")))
|
||||
if cls is PlanUpdatedEvent:
|
||||
return PlanUpdatedEvent(steps=tuple(payload.get("steps") or ()))
|
||||
if cls is ToolCallStartedEvent:
|
||||
return ToolCallStartedEvent(
|
||||
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
|
||||
args=dict(payload.get("args") or {}), preview=payload.get("preview"),
|
||||
)
|
||||
if cls is ToolOutputEvent:
|
||||
return ToolOutputEvent(call_id=str(payload.get("id", "")),
|
||||
name=str(payload.get("name", "")),
|
||||
delta=str(payload.get("delta", "")))
|
||||
if cls is ToolCallFinishedEvent:
|
||||
return ToolCallFinishedEvent(
|
||||
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
|
||||
ok=bool(payload.get("ok", False)), output=str(payload.get("output", "")),
|
||||
path=str(payload.get("path", "") or ""),
|
||||
produced=tuple(payload.get("produced") or ()),
|
||||
)
|
||||
if cls is OutputsAddedEvent or cls is OutputsRemovedEvent:
|
||||
return cls(paths=tuple(str(p) for p in (payload.get("paths") or ())))
|
||||
if cls is NoticeEvent:
|
||||
return NoticeEvent(text=str(payload.get("text", "")),
|
||||
level=str(payload.get("level", "info")))
|
||||
if cls is HistoryReadyEvent:
|
||||
return HistoryReadyEvent(session_id=str(payload.get("session_id", "")))
|
||||
if cls is TurnCompletedEvent:
|
||||
return TurnCompletedEvent(content=str(payload.get("content", "")),
|
||||
cancelled=bool(payload.get("cancelled", False)))
|
||||
return ErrorEvent(message=str(payload.get("message", "")),
|
||||
recoverable=bool(payload.get("recoverable", False)))
|
||||
|
||||
|
||||
def collect_text(events: Sequence[AgentEvent]) -> str:
|
||||
"""Join every :class:`TextChunkEvent` - the visible answer, reasoning excluded.
|
||||
|
||||
Provided here so no consumer has to re-derive "which events are the answer",
|
||||
the question the untyped dicts made easy to get wrong.
|
||||
"""
|
||||
return "".join(e.delta for e in events if isinstance(e, TextChunkEvent))
|
||||
|
||||
|
||||
def tool_calls(events: Sequence[AgentEvent]) -> List[ToolCallFinishedEvent]:
|
||||
"""Every finished tool call, in order - for audit views and assertions."""
|
||||
return [e for e in events if isinstance(e, ToolCallFinishedEvent)]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"TextChunkEvent",
|
||||
"ReasoningChunkEvent",
|
||||
"AssistantDoneEvent",
|
||||
"PlanUpdatedEvent",
|
||||
"ToolCallStartedEvent",
|
||||
"ToolOutputEvent",
|
||||
"ToolCallFinishedEvent",
|
||||
"OutputsAddedEvent",
|
||||
"OutputsRemovedEvent",
|
||||
"NoticeEvent",
|
||||
"HistoryReadyEvent",
|
||||
"TurnCompletedEvent",
|
||||
"ErrorEvent",
|
||||
"EVENT_TYPES",
|
||||
"event_from_dict",
|
||||
"collect_text",
|
||||
"tool_calls",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
"""ConversationExecutionRequest - an immutable snapshot of one turn (R04-T01).
|
||||
|
||||
``ui/cowork_tab.py::build_job`` currently builds a closure that reads widget
|
||||
state from inside the worker thread::
|
||||
|
||||
def job(worker):
|
||||
provider = self.build_provider() # reads combo boxes
|
||||
extra_tools, extra_exec = self.ctx.build_mcp_tools()
|
||||
proj_ctx = project_context_text(load_project(project_id))
|
||||
...
|
||||
|
||||
Everything that closure touches can change while the turn is running: the user
|
||||
can pick another model, switch workspace, or edit the project instructions. The
|
||||
turn then runs on a mixture of old and new state, and which mixture depends on
|
||||
thread timing - the class of bug that reproduces once a week and never in a test.
|
||||
|
||||
This value object is the fix: the presentation layer captures everything a turn
|
||||
needs ON THE UI THREAD, at submit time, into one frozen object. Whatever happens
|
||||
to the widgets afterwards, the turn keeps running on the state the user actually
|
||||
submitted.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no filesystem access. Paths are held as
|
||||
strings, not ``Path`` objects, so the snapshot stays trivially serialisable -
|
||||
which is what will let a turn be queued, replayed or logged later.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
# Default tool-use budget for an interactive turn, and the higher ceiling a
|
||||
# run-to-completion step (a Co4E flow step) is allowed. Same numbers
|
||||
# ``core.chat_agent.run_cowork`` defaults to - kept here so the policy is
|
||||
# visible in the request rather than buried in a function signature.
|
||||
DEFAULT_MAX_STEPS = 30
|
||||
DEFAULT_COMPLETION_MAX_STEPS = 200
|
||||
|
||||
|
||||
def new_turn_id() -> str:
|
||||
"""A fresh turn id. Short and random: it only has to be unique within a
|
||||
session's lifetime, and it shows up in log lines humans read."""
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationExecutionRequest:
|
||||
"""Everything one agent turn needs, captured at submit time.
|
||||
|
||||
Attributes:
|
||||
prompt: the user's message for this turn (already assembled, including
|
||||
any attachment text the UI inlined).
|
||||
messages: the full conversation to send, oldest first. Held as a tuple
|
||||
so the snapshot cannot be mutated after capture; use
|
||||
:meth:`message_list` to get the mutable copy the engine expects.
|
||||
output_dir: this turn's OWN folder. Each turn writes into an isolated
|
||||
directory so parallel turns cannot clobber each other's files.
|
||||
session_id: the conversation this turn belongs to.
|
||||
turn_id: unique per turn, for logs and for matching events to a turn.
|
||||
surface: which screen submitted it ("cowork", "co4e", "ai_edit", "task").
|
||||
provider / model: what to run on, already resolved (routing included).
|
||||
Empty ``model`` means "the provider's configured default".
|
||||
title: conversation title, used to name generated files.
|
||||
project_id / project_context: the workspace and its shared instructions,
|
||||
snapshotted so a mid-turn workspace switch cannot change them.
|
||||
agent_role: audit-log attribution for every tool call this turn makes.
|
||||
allowed_tools: permission scope. ``None`` means "all enabled tools";
|
||||
a list restricts the ADVERTISED catalogue, so a read-only step
|
||||
literally cannot be offered a writing tool.
|
||||
max_steps / run_to_completion / completion_max_steps: tool-use budget.
|
||||
enforce_rules: run the security rulebase. Co4E sandboxed runs disable it.
|
||||
confirm_commands: ask before run_command/install_package (permission gate).
|
||||
metadata: free-form extras a caller wants carried along (never
|
||||
interpreted here) - e.g. a scheduled task's id.
|
||||
"""
|
||||
|
||||
prompt: str
|
||||
messages: Tuple[Mapping[str, Any], ...] = ()
|
||||
output_dir: str = ""
|
||||
session_id: str = ""
|
||||
turn_id: str = field(default_factory=new_turn_id)
|
||||
surface: str = "cowork"
|
||||
provider: str = ""
|
||||
model: str = ""
|
||||
title: str = ""
|
||||
project_id: str = ""
|
||||
project_context: str = ""
|
||||
agent_role: str = ""
|
||||
allowed_tools: Optional[Tuple[str, ...]] = None
|
||||
max_steps: int = DEFAULT_MAX_STEPS
|
||||
run_to_completion: bool = False
|
||||
completion_max_steps: int = DEFAULT_COMPLETION_MAX_STEPS
|
||||
enforce_rules: bool = True
|
||||
confirm_commands: bool = False
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
# -- construction helpers ------------------------------------------- #
|
||||
@classmethod
|
||||
def create(cls, prompt: str, messages: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
**kwargs: Any) -> "ConversationExecutionRequest":
|
||||
"""Build a request from ordinary mutable inputs.
|
||||
|
||||
The messages list is copied element by element, so a later append by the
|
||||
caller (the chat panel keeps appending to its own list) cannot reach
|
||||
inside a request that is already running.
|
||||
"""
|
||||
snapshot = tuple(dict(m) for m in (messages or ()))
|
||||
allowed = kwargs.pop("allowed_tools", None)
|
||||
return cls(prompt=prompt, messages=snapshot,
|
||||
allowed_tools=tuple(allowed) if allowed is not None else None,
|
||||
**kwargs)
|
||||
|
||||
def with_messages(self, messages: Sequence[Mapping[str, Any]]
|
||||
) -> "ConversationExecutionRequest":
|
||||
"""A copy carrying a different message list, everything else unchanged.
|
||||
|
||||
Used when a caller assembles the system prompt or trims history after
|
||||
building the request - it must produce a NEW snapshot rather than mutate
|
||||
the one a turn may already be running on.
|
||||
"""
|
||||
return replace(self, messages=tuple(dict(m) for m in messages))
|
||||
|
||||
def with_model(self, provider: str, model: str) -> "ConversationExecutionRequest":
|
||||
"""A copy pinned to another provider/model - how a routing switch is
|
||||
applied without touching the user's saved settings."""
|
||||
return replace(self, provider=provider, model=model)
|
||||
|
||||
# -- accessors ------------------------------------------------------ #
|
||||
def message_list(self) -> List[Dict[str, Any]]:
|
||||
"""A fresh mutable copy of the messages, for the engine to append to.
|
||||
|
||||
The legacy engine mutates the list it is given (it inserts the system
|
||||
prompt and appends assistant/tool messages). Handing it a copy is what
|
||||
keeps this snapshot immutable in practice and not just by declaration.
|
||||
"""
|
||||
return [dict(m) for m in self.messages]
|
||||
|
||||
@property
|
||||
def effective_max_steps(self) -> int:
|
||||
"""The tool-use ceiling actually in force for this turn."""
|
||||
return self.completion_max_steps if self.run_to_completion else self.max_steps
|
||||
|
||||
@property
|
||||
def has_output_dir(self) -> bool:
|
||||
"""True when this turn may write files."""
|
||||
return bool(self.output_dir)
|
||||
|
||||
def allows_tool(self, name: str) -> bool:
|
||||
"""Whether ``name`` is inside this turn's permission scope.
|
||||
|
||||
``update_plan`` is always allowed: it has no side effects and drives the
|
||||
Plan panel, so scoping it out would silently break the UI rather than
|
||||
restrict a capability.
|
||||
"""
|
||||
if self.allowed_tools is None:
|
||||
return True
|
||||
return name == "update_plan" or name in self.allowed_tools
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Compact one-line identity for log lines."""
|
||||
target = f"{self.provider}/{self.model}" if self.model else self.provider or "default"
|
||||
return f"turn={self.turn_id} surface={self.surface} model={target}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-safe projection, for logging a turn or persisting it for replay."""
|
||||
return {
|
||||
"turn_id": self.turn_id,
|
||||
"session_id": self.session_id,
|
||||
"surface": self.surface,
|
||||
"prompt": self.prompt,
|
||||
"message_count": len(self.messages),
|
||||
"output_dir": self.output_dir,
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"title": self.title,
|
||||
"project_id": self.project_id,
|
||||
"agent_role": self.agent_role,
|
||||
"allowed_tools": list(self.allowed_tools) if self.allowed_tools is not None else None,
|
||||
"max_steps": self.effective_max_steps,
|
||||
"run_to_completion": self.run_to_completion,
|
||||
"enforce_rules": self.enforce_rules,
|
||||
"confirm_commands": self.confirm_commands,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConversationExecutionRequest",
|
||||
"new_turn_id",
|
||||
"DEFAULT_MAX_STEPS",
|
||||
"DEFAULT_COMPLETION_MAX_STEPS",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain models: provider/model catalogue value objects (EPIC R03)."""
|
||||
|
||||
from .provider_descriptor import ProviderCapability, ProviderDescriptor
|
||||
|
||||
__all__ = ["ProviderDescriptor", "ProviderCapability"]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""ProviderDescriptor - the declarative catalogue entry for one model provider (R03-T02).
|
||||
|
||||
Today the knowledge of "what a provider is" is scattered across three places
|
||||
that must be edited together and can silently drift apart:
|
||||
|
||||
* ``providers/factory.py::_REGISTRY`` - name -> implementation class
|
||||
* ``config.py::DEFAULT_CONFIG["providers"]`` - default base_url / model / api_key
|
||||
* ``config.py::PROVIDER_LABELS`` - the human label shown in Settings
|
||||
|
||||
Adding a provider means remembering all three; forgetting one produces a
|
||||
provider that exists but has no label, or a label with no implementation. This
|
||||
value object folds those facts into a single immutable description that the
|
||||
registry (``infrastructure/providers/provider_registry.py``) and the UI can both
|
||||
read, so a new provider is declared once.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no network, no config access. It describes
|
||||
a provider; building one is infrastructure's job.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple
|
||||
|
||||
|
||||
class ProviderCapability(str, Enum):
|
||||
"""What a provider can do, as advertised by its descriptor.
|
||||
|
||||
Kept as a closed enum rather than free-form strings so a typo
|
||||
(``"vison"``) fails at import time instead of silently disabling a feature
|
||||
at runtime. Inherits ``str`` so existing dict/JSON code that compares against
|
||||
plain strings keeps working during the migration.
|
||||
"""
|
||||
|
||||
STREAMING = "streaming" # can stream answer fragments through on_text
|
||||
TOOLS = "tools" # can be given a ToolSpec catalogue and call tools
|
||||
VISION = "vision" # accepts image content blocks (see providers/base.py)
|
||||
REASONING = "reasoning" # emits a separate private "thinking" stream
|
||||
MODEL_LISTING = "model_listing" # list_models() returns a real catalogue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDescriptor:
|
||||
"""An immutable description of one provider the app can talk to.
|
||||
|
||||
Attributes:
|
||||
id: the config key, e.g. ``"openai_compat"``. Also the ``provider`` half
|
||||
of a routing candidate key (``provider/model_id``).
|
||||
label: human-readable name for Settings and the model picker.
|
||||
protocol: which wire format this provider speaks. Several ids share one
|
||||
protocol - ``ollama``, ``github_copilot`` and ``codex`` are all
|
||||
OpenAI-compatible endpoints - which is exactly why protocol and id
|
||||
must be separate fields.
|
||||
default_model: the model used when the user has not chosen one.
|
||||
capabilities: what the provider supports (see :class:`ProviderCapability`).
|
||||
requires_api_key: whether an empty ``api_key`` makes it unusable.
|
||||
requires_base_url: whether an empty ``base_url`` makes it unusable.
|
||||
local: True when the endpoint runs on the user's own machine. Routing
|
||||
treats local models as zero-cost, and the security layer treats them
|
||||
as not leaving the machine, so this is a real behavioural flag and
|
||||
not just documentation.
|
||||
notes: free-form remark shown in Settings (e.g. "paste a Copilot token").
|
||||
"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
protocol: str
|
||||
default_model: str = ""
|
||||
capabilities: FrozenSet[ProviderCapability] = field(default_factory=frozenset)
|
||||
requires_api_key: bool = True
|
||||
requires_base_url: bool = True
|
||||
local: bool = False
|
||||
notes: str = ""
|
||||
|
||||
# -- capability queries ---------------------------------------------- #
|
||||
def supports(self, capability: ProviderCapability) -> bool:
|
||||
"""True when this provider advertises ``capability``."""
|
||||
return capability in self.capabilities
|
||||
|
||||
@property
|
||||
def supports_vision(self) -> bool:
|
||||
"""Mirrors ``providers.base.Provider.supports_vision`` so callers can ask
|
||||
the descriptor (no instance, no network) before building a provider."""
|
||||
return self.supports(ProviderCapability.VISION)
|
||||
|
||||
@property
|
||||
def supports_tools(self) -> bool:
|
||||
"""True when this provider can run an agent turn with tools. A provider
|
||||
without it can still chat, but must never be routed a tool-using task."""
|
||||
return self.supports(ProviderCapability.TOOLS)
|
||||
|
||||
def capability_names(self) -> List[str]:
|
||||
"""Capabilities as sorted plain strings - the shape the routing layer's
|
||||
``required_capabilities`` filter and the assessment store both use."""
|
||||
return sorted(c.value for c in self.capabilities)
|
||||
|
||||
# -- configuration validation ---------------------------------------- #
|
||||
def missing_settings(self, conf: Mapping[str, Any]) -> List[str]:
|
||||
"""Which required config keys are absent or blank in ``conf``.
|
||||
|
||||
Returned as a list (not a bool) so Settings can tell the user exactly
|
||||
what to fill in, instead of a generic "not configured". A provider that
|
||||
needs nothing returns an empty list.
|
||||
"""
|
||||
missing: List[str] = []
|
||||
if self.requires_api_key and not str(conf.get("api_key", "") or "").strip():
|
||||
missing.append("api_key")
|
||||
if self.requires_base_url and not str(conf.get("base_url", "") or "").strip():
|
||||
missing.append("base_url")
|
||||
return missing
|
||||
|
||||
def is_configured(self, conf: Mapping[str, Any]) -> bool:
|
||||
"""True when ``conf`` carries everything this provider needs to run."""
|
||||
return not self.missing_settings(conf)
|
||||
|
||||
def resolve_model(self, conf: Optional[Mapping[str, Any]] = None,
|
||||
requested: str = "") -> str:
|
||||
"""Pick the model id for a call: explicit request, else configured, else
|
||||
this descriptor's default.
|
||||
|
||||
Centralised here because the same three-step fallback is currently
|
||||
re-implemented at every call site (chat panel, Co4E, AI-edit, scheduler),
|
||||
and each of them gets the precedence subtly different.
|
||||
"""
|
||||
if requested:
|
||||
return requested
|
||||
configured = str((conf or {}).get("model", "") or "").strip()
|
||||
return configured or self.default_model
|
||||
|
||||
def describe(self, conf: Optional[Mapping[str, Any]] = None) -> str:
|
||||
"""One-line summary for logs and the Settings row, e.g.
|
||||
``"anthropic:claude-sonnet-4-6 (Anthropic Claude)"``."""
|
||||
return f"{self.id}:{self.resolve_model(conf)} ({self.label})"
|
||||
|
||||
def candidate_key(self, model_id: str) -> str:
|
||||
"""The ``provider/model_id`` identity the routing layer keys on.
|
||||
|
||||
Defined here so the domain owns the format; ``core.routing.models`` has
|
||||
its own ``candidate_key()`` helper producing the identical string, and
|
||||
keeping them equal is what lets the new registry and the existing
|
||||
assessment store share one keyspace during the migration.
|
||||
"""
|
||||
return f"{self.id}/{model_id}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-safe projection, for persisting a catalogue snapshot or sending
|
||||
the descriptor to a UI layer that must not import domain types."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"protocol": self.protocol,
|
||||
"default_model": self.default_model,
|
||||
"capabilities": self.capability_names(),
|
||||
"requires_api_key": self.requires_api_key,
|
||||
"requires_base_url": self.requires_base_url,
|
||||
"local": self.local,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
def split_candidate_key(key: str) -> Tuple[str, str]:
|
||||
"""Inverse of :meth:`ProviderDescriptor.candidate_key`.
|
||||
|
||||
Splits on the FIRST ``/`` only: some gateways expose model ids that contain
|
||||
a slash (``org/model``), and splitting on the last one would corrupt them.
|
||||
"""
|
||||
provider, _, model_id = key.partition("/")
|
||||
return provider, model_id
|
||||
|
||||
|
||||
__all__ = ["ProviderCapability", "ProviderDescriptor", "split_candidate_key"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Domain entities for tool risk classification and lookup (EPIC R05)."""
|
||||
|
||||
from .tool_descriptor import ToolCapability, ToolDescriptor
|
||||
from .tool_registry import (
|
||||
BUILT_IN_CAPABILITIES,
|
||||
UNKNOWN_SOURCE_CAPABILITIES,
|
||||
ToolRegistry,
|
||||
default_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ToolCapability",
|
||||
"ToolDescriptor",
|
||||
"ToolRegistry",
|
||||
"BUILT_IN_CAPABILITIES",
|
||||
"UNKNOWN_SOURCE_CAPABILITIES",
|
||||
"default_registry",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""ToolCapability / ToolDescriptor - the risk-tagged catalogue entry for one
|
||||
tool the agent loop can call (R05-T01).
|
||||
|
||||
Today a tool is just a name inside ``core/tools.py::TOOL_SPECS`` (a
|
||||
``providers.base.ToolSpec`` — name/description/JSON-schema parameters, with
|
||||
no notion of risk) plus a hand-written membership test wherever gating is
|
||||
needed: ``core/tools.py::WRITE_TOOLS``, ``core/code_agent.py``'s
|
||||
``WRITE_TOOLS | MS365_WRITE_TOOLS``, and ``core/chat_agent.py``'s literal
|
||||
``name in ("run_command", "install_package")``. Three call sites, three
|
||||
independently-maintained lists, and a new tool (or an MCP/connector tool,
|
||||
which has no list membership at all - see ``core/mcp_client.py``) is gated
|
||||
only if someone remembers to add it everywhere.
|
||||
|
||||
``ToolDescriptor`` makes the risk an attribute of the tool itself, declared
|
||||
once, so ``application/conversations/tool_policy_gateway.py`` (R05-T03) can
|
||||
decide ALLOW/CONFIRM/DENY from data instead of a growing set of literal
|
||||
tuples.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O. ``to_spec``/``from_spec`` are
|
||||
the only place this module touches something outside domain/, and that
|
||||
something (``providers.base.ToolSpec``) is itself a plain dataclass with no
|
||||
further dependencies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Flag, auto
|
||||
from typing import Any, Dict
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
|
||||
class ToolCapability(Flag):
|
||||
"""What calling a tool can do to the machine or the network.
|
||||
|
||||
A ``Flag`` (not a plain ``Enum``) because a single tool can combine risks
|
||||
- ``install_package`` writes to the environment, runs pip as a
|
||||
subprocess, AND needs network access. Composing three separate booleans
|
||||
per call site is exactly the duplication this type replaces.
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
READ = auto()
|
||||
WRITE = auto()
|
||||
EXECUTE = auto()
|
||||
NETWORK = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolDescriptor:
|
||||
"""An immutable description of one callable tool.
|
||||
|
||||
Attributes:
|
||||
name: the identifier the model calls (``ToolSpec.name``).
|
||||
description: shown to the model, unchanged from ``ToolSpec``.
|
||||
parameters: JSON-Schema object for the call's arguments.
|
||||
capabilities: the risk this tool carries - see :class:`ToolCapability`.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any] = field(default_factory=dict)
|
||||
capabilities: ToolCapability = ToolCapability.NONE
|
||||
|
||||
def has(self, capability: ToolCapability) -> bool:
|
||||
"""True when this tool carries (any bit of) ``capability``."""
|
||||
return bool(self.capabilities & capability)
|
||||
|
||||
def to_spec(self) -> ToolSpec:
|
||||
"""Project back to the ``ToolSpec`` shape the model-facing catalogue
|
||||
and the provider call actually use - risk tagging is metadata the
|
||||
wire format has no room for."""
|
||||
return ToolSpec(name=self.name, description=self.description,
|
||||
parameters=self.parameters)
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec: ToolSpec,
|
||||
capabilities: ToolCapability = ToolCapability.NONE) -> "ToolDescriptor":
|
||||
"""Wrap an existing ``ToolSpec`` (built-in, MCP, or connector) with a
|
||||
capability tag. The one place callers attach risk to a spec they did
|
||||
not author themselves."""
|
||||
return cls(name=spec.name, description=spec.description,
|
||||
parameters=spec.parameters, capabilities=capabilities)
|
||||
|
||||
|
||||
__all__ = ["ToolCapability", "ToolDescriptor"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""ToolRegistry - the centralised catalogue every tool source registers into
|
||||
(R05-T01).
|
||||
|
||||
Built-in file/command/fetch tools (``core/tools.py``), MCP server tools
|
||||
(``core/mcp_client.py``) and unified connectors (``core/ext_connectors.py``)
|
||||
each produce their own ``List[ToolSpec]`` today, concatenated ad-hoc by
|
||||
``core/tools.py::combine_tool_sources``. None of that concatenation carries
|
||||
risk information, which is exactly why an MCP tool call reaches
|
||||
``core/chat_agent.py`` with no ``ToolDescriptor`` to consult and skips the
|
||||
permission gate entirely (the gap R05-T04 closes).
|
||||
|
||||
``ToolRegistry`` is the one place a :class:`~domain.tools.tool_descriptor.ToolDescriptor`
|
||||
is looked up by name, so a policy gateway - or anything else that needs to ask
|
||||
"what can this tool do" - has a single source of truth instead of re-deriving
|
||||
it from a spec list.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
from .tool_descriptor import ToolCapability, ToolDescriptor
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""An in-memory, name-keyed catalogue of :class:`ToolDescriptor`.
|
||||
|
||||
Deliberately mutable and unordered-by-name-only: a turn builds one
|
||||
registry from whichever tool sources it has (built-ins + whatever MCP
|
||||
servers/connectors are enabled), so re-registering the same name simply
|
||||
replaces the previous descriptor rather than raising - the same
|
||||
"last one wins" behaviour ``combine_tool_sources`` already has for
|
||||
duplicate tool names across sources.
|
||||
"""
|
||||
|
||||
def __init__(self, descriptors: Optional[Iterable[ToolDescriptor]] = None) -> None:
|
||||
self._by_name: Dict[str, ToolDescriptor] = {}
|
||||
for descriptor in descriptors or ():
|
||||
self.register(descriptor)
|
||||
|
||||
def register(self, descriptor: ToolDescriptor) -> None:
|
||||
self._by_name[descriptor.name] = descriptor
|
||||
|
||||
def get(self, name: str) -> Optional[ToolDescriptor]:
|
||||
return self._by_name.get(name)
|
||||
|
||||
def all(self) -> List[ToolDescriptor]:
|
||||
return list(self._by_name.values())
|
||||
|
||||
def specs(self) -> List[ToolSpec]:
|
||||
"""Every registered descriptor, projected back to ``ToolSpec`` - the
|
||||
shape the provider call and the model-facing catalogue need."""
|
||||
return [d.to_spec() for d in self._by_name.values()]
|
||||
|
||||
def capabilities_for(self, name: str) -> ToolCapability:
|
||||
"""The capability set for ``name``, or ``NONE`` for an unknown tool.
|
||||
|
||||
Returning ``NONE`` rather than raising lets a policy gateway treat an
|
||||
unregistered tool the same way as one with no declared risk - the
|
||||
gateway's DENY-on-unknown-name rule is a deliberate, separate check,
|
||||
not something this lookup should pre-empt.
|
||||
"""
|
||||
descriptor = self._by_name.get(name)
|
||||
return descriptor.capabilities if descriptor is not None else ToolCapability.NONE
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._by_name
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._by_name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Default capability map for this app's built-in tools (core/tools.py).
|
||||
# Kept here, next to the registry, rather than inside core/tools.py itself -
|
||||
# core/ is the legacy engine layer being strangled, not where new domain facts
|
||||
# should accumulate.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_CAP = ToolCapability
|
||||
BUILT_IN_CAPABILITIES: Dict[str, ToolCapability] = {
|
||||
"read_file": _CAP.READ,
|
||||
"list_dir": _CAP.READ,
|
||||
"write_file": _CAP.WRITE,
|
||||
"edit_file": _CAP.WRITE,
|
||||
"run_command": _CAP.EXECUTE,
|
||||
"install_package": _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK,
|
||||
"fetch_url": _CAP.NETWORK,
|
||||
"jira_search": _CAP.NETWORK,
|
||||
"jira_get_issue": _CAP.NETWORK,
|
||||
# Advertised by every engine but has no filesystem/process/network effect
|
||||
# of its own - it only drives the Plan panel (see core/chat_agent.py).
|
||||
"update_plan": _CAP.NONE,
|
||||
"save_file": _CAP.WRITE,
|
||||
}
|
||||
|
||||
# Tools with no standard, self-declared risk metadata (every MCP server tool,
|
||||
# every unified connector) are tagged with this conservative default - see
|
||||
# R05-T04. Better to over-gate an unknown remote tool than to silently let it
|
||||
# through as READ-only.
|
||||
UNKNOWN_SOURCE_CAPABILITIES: ToolCapability = _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK
|
||||
|
||||
|
||||
def default_registry(specs: Iterable[ToolSpec]) -> ToolRegistry:
|
||||
"""Build a registry from ``core/tools.py``'s own ``TOOL_SPECS`` (plus
|
||||
``save_file``/``update_plan``, which the engines add separately), using
|
||||
:data:`BUILT_IN_CAPABILITIES`. A spec with no entry in that map falls back
|
||||
to :data:`UNKNOWN_SOURCE_CAPABILITIES` - the same conservative default
|
||||
applied to MCP/connector tools, so a built-in nobody has classified yet
|
||||
fails safe instead of silently ungated."""
|
||||
registry = ToolRegistry()
|
||||
for spec in specs:
|
||||
capability = BUILT_IN_CAPABILITIES.get(spec.name, UNKNOWN_SOURCE_CAPABILITIES)
|
||||
registry.register(ToolDescriptor.from_spec(spec, capability))
|
||||
return registry
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ToolRegistry",
|
||||
"BUILT_IN_CAPABILITIES",
|
||||
"UNKNOWN_SOURCE_CAPABILITIES",
|
||||
"default_registry",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities for workspace/project isolation (EPIC R06)."""
|
||||
|
||||
from .workspace_session import WorkspaceSession
|
||||
|
||||
__all__ = ["WorkspaceSession"]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""WorkspaceSession - an immutable snapshot of which project a turn belongs
|
||||
to and where it may touch the filesystem (R06-T01).
|
||||
|
||||
``state.py::AppContext.active_project_id`` is a single mutable field read by
|
||||
every background worker thread. ``ui/workspace_tab.py::_load_current`` writes
|
||||
it (and the related ``config._project_history_dir``) on the UI thread the
|
||||
moment the user switches projects - while a turn already running on a
|
||||
worker thread may read either field mid-switch and end up acting on the
|
||||
OTHER project's workspace/history for the rest of its run (the race
|
||||
R06-T04 fixes).
|
||||
|
||||
The fix, same shape as R04's ``ConversationExecutionRequest``: capture the
|
||||
workspace facts a turn needs ONCE, on the thread that knows which project is
|
||||
selected, into one frozen object. Whatever the user does to the UI afterwards,
|
||||
the turn keeps using the workspace it was handed at submit time.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no network. It does touch ``Path`` (not
|
||||
plain strings, unlike ``ConversationExecutionRequest``) because its whole job
|
||||
is path-containment checking - a snapshot with no room to answer "is this
|
||||
path mine" would not replace what ``ToolContext.resolve`` currently does
|
||||
inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceSession:
|
||||
"""Everything a turn needs to know about ITS workspace, fixed at the
|
||||
moment it was submitted.
|
||||
|
||||
Attributes:
|
||||
project_id: the project this turn belongs to (``""`` when no project
|
||||
is selected - e.g. the Code tab, which has no project concept).
|
||||
workspace_root: the project's sandbox root (``Project.workspace_dir()``).
|
||||
sandbox_dir: the ``.scratch`` subtree inside ``workspace_root`` used for
|
||||
generator/helper scripts, never a final deliverable (see
|
||||
``infrastructure/filesystem/file_tools.py::_flatten_rel``).
|
||||
allowed_paths: every root a tool call may read/write under. Almost
|
||||
always just ``(workspace_root,)``; a project with a custom
|
||||
``output_dir`` outside the managed workspace tree still resolves
|
||||
to exactly one root - the tuple exists so a future caller (e.g. a
|
||||
step scoped to a shared input folder) can widen it without a
|
||||
shape change.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
workspace_root: Path
|
||||
sandbox_dir: Path
|
||||
allowed_paths: Tuple[Path, ...] = field(default_factory=tuple)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.allowed_paths:
|
||||
object.__setattr__(self, "allowed_paths", (self.workspace_root,))
|
||||
|
||||
@classmethod
|
||||
def from_project(cls, project) -> "WorkspaceSession":
|
||||
"""Build a session from a ``core.projects.Project``. ``project`` is
|
||||
typed loosely (not imported) so this module has no dependency on
|
||||
``core/`` - the caller (``core/projects.py`` itself, or
|
||||
``application/conversations``) already has the Project in hand."""
|
||||
root = Path(project.workspace_dir())
|
||||
return cls(project_id=project.project_id, workspace_root=root,
|
||||
sandbox_dir=root / ".scratch", allowed_paths=(root,))
|
||||
|
||||
@classmethod
|
||||
def unscoped(cls, workspace_root: Path) -> "WorkspaceSession":
|
||||
"""A session for callers with no project concept (e.g. the Code tab,
|
||||
which sandboxes to a plain folder rather than a ``Project``)."""
|
||||
root = Path(workspace_root)
|
||||
return cls(project_id="", workspace_root=root, sandbox_dir=root / ".scratch")
|
||||
|
||||
def is_allowed(self, path: Path) -> bool:
|
||||
"""True when ``path`` resolves inside one of :attr:`allowed_paths`.
|
||||
|
||||
Same containment rule as ``ToolContext.resolve`` (an exact root match
|
||||
or a real descendant), but side-effect-free: it reports the answer
|
||||
instead of raising, so a caller (``FileWorkspaceService``, R06-T05)
|
||||
can decide what "not allowed" means for its own UI instead of
|
||||
catching a ``ToolError``.
|
||||
"""
|
||||
try:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
except OSError:
|
||||
return False
|
||||
for allowed in self.allowed_paths:
|
||||
root = Path(allowed).resolve()
|
||||
if resolved == root or root in resolved.parents:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["WorkspaceSession"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Infrastructure layer - adapters to the outside world.
|
||||
|
||||
Concrete implementations of what the inner layers only describe: HTTP calls to
|
||||
model gateways, the OS keyring, the filesystem, subprocesses, telemetry sinks.
|
||||
May import ``domain/`` (to speak its types) and third-party libraries, but never
|
||||
``presentation/``/``ui/``.
|
||||
"""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Filesystem/process/network tool adapters split out of ``core/tools.py``
|
||||
(EPIC R05) and the sandbox execution context they share."""
|
||||
|
||||
from .tool_context import CancelFn, ToolContext, ToolError
|
||||
|
||||
__all__ = ["CancelFn", "ToolContext", "ToolError"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Command tools - run_command, install_package (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). These
|
||||
two are the ones today's hand-written permission gate in
|
||||
``core/chat_agent.py`` singles out by literal name
|
||||
(``name in ("run_command", "install_package")``) — R05-T03 replaces that
|
||||
tuple with a capability lookup, but the tools themselves are unchanged here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .tool_context import CancelFn, ToolContext
|
||||
|
||||
COMMAND_TIMEOUT = 120 # seconds
|
||||
|
||||
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
|
||||
".idea", ".mypy_cache", ".pytest_cache"}
|
||||
|
||||
|
||||
def _snapshot(workdir: Path) -> Dict[str, Any]:
|
||||
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
|
||||
snap: Dict[str, Any] = {}
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(str(workdir)):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
|
||||
for fn in filenames:
|
||||
full = os.path.join(dirpath, fn)
|
||||
try:
|
||||
st = os.stat(full)
|
||||
snap[full] = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
if len(snap) > 5000:
|
||||
return snap
|
||||
except OSError:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
|
||||
on_output=None) -> Optional[str]:
|
||||
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
|
||||
``ctx.sandbox``); returns its python path, or None to use the app's own."""
|
||||
if not ctx.sandbox:
|
||||
return None
|
||||
from cowork_local.core.deps import ensure_project_venv
|
||||
|
||||
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
|
||||
return str(py) if py else None
|
||||
|
||||
|
||||
def run_command(ctx: ToolContext, args: Dict[str, Any],
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_output=None) -> Dict[str, Any]:
|
||||
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
|
||||
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
|
||||
from cowork_local.security.command_risk_classifier import classify_command
|
||||
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return {"ok": False, "output": "Empty command."}
|
||||
|
||||
# --- Security validation pipeline ---
|
||||
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
|
||||
if risk.blocked:
|
||||
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
|
||||
return {"ok": False, "output": denial}
|
||||
|
||||
# Route through SandboxManager for risk-based isolation
|
||||
mgr = SandboxManager(ExecutionConfig(
|
||||
enabled=True,
|
||||
block_network_by_default=ctx.block_network,
|
||||
is_cowork_mode=ctx.flatten_writes,
|
||||
))
|
||||
sandbox_result = mgr.run(
|
||||
command=command,
|
||||
workdir=str(ctx.workdir),
|
||||
block_network=ctx.block_network,
|
||||
timeout_sec=COMMAND_TIMEOUT,
|
||||
cancel=cancel,
|
||||
)
|
||||
# Sandbox ALWAYS executes (never double-run). Return its result directly.
|
||||
if sandbox_result.get("sandbox") == "blocked":
|
||||
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
|
||||
out = sandbox_result.get("stdout", "").strip() or "(no output)"
|
||||
err = sandbox_result.get("stderr", "")
|
||||
rc = sandbox_result.get("returncode", -1)
|
||||
if err:
|
||||
out = f"{out}\n{err}" if out else err
|
||||
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
|
||||
|
||||
|
||||
def install_package(ctx: ToolContext, args: Dict[str, Any],
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_output=None) -> Dict[str, Any]:
|
||||
from cowork_local.core.deps import pip_install
|
||||
|
||||
package = str(args.get("package", "")).strip()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
return {"ok": ok, "output": f"{head}\n{detail}"}
|
||||
|
||||
|
||||
__all__ = ["COMMAND_TIMEOUT", "run_command", "install_package", "_snapshot"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""ExecutionWorkspace - the output folder vs. the scratch folder for one
|
||||
turn, as two distinct properties instead of a name convention (R06-T03).
|
||||
|
||||
Today the ``.scratch`` subtree is a special case buried inside
|
||||
``_flatten_rel`` (``infrastructure/filesystem/file_tools.py``): a generator
|
||||
script writes there, the deliverable lands in the output root, and
|
||||
``core/chat_agent.py`` cleans ``.scratch`` up after the turn — but nothing
|
||||
NAMES "the scratch folder" as a thing; every call site re-derives
|
||||
``workdir / ".scratch"`` (or checks ``Path(rel).parts[0] == ".scratch"``) by
|
||||
hand. This class gives that convention one home.
|
||||
|
||||
It does not change WHERE files land - ``workspace_root/.scratch`` stays
|
||||
exactly what it always was. It exists so a caller (an application service,
|
||||
R06-T05's ``FileWorkspaceService``, or a future turn-cleanup step) can ask
|
||||
for "the output dir" / "the scratch dir" instead of hand-building the path
|
||||
and hoping the convention hasn't drifted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
|
||||
SCRATCH_DIRNAME = ".scratch"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionWorkspace:
|
||||
"""The two folders a turn actually writes to, derived from a
|
||||
:class:`WorkspaceSession`.
|
||||
|
||||
``output_dir`` is always the session's ``workspace_root`` itself, not a
|
||||
per-turn subfolder - Cowork's whole design is that every deliverable lands
|
||||
directly in the one configured Output folder (see
|
||||
``infrastructure/filesystem/file_tools.py::_flatten_rel``'s docstring).
|
||||
``scratch_dir`` is the SAME flat ``workspace_root/.scratch`` every turn on
|
||||
that workspace already shares today (``core/chat_agent.py``'s
|
||||
``_cleanup_cowork_intermediates`` operates on that exact path) - this
|
||||
class does not introduce per-turn namespacing that doesn't exist in the
|
||||
engine yet, only names the existing convention.
|
||||
|
||||
``turn_id`` is kept as metadata for callers that want to attribute a
|
||||
workspace to the turn that used it (logging, future per-turn scratch
|
||||
namespacing); it does not affect either path today.
|
||||
"""
|
||||
|
||||
session: WorkspaceSession
|
||||
turn_id: str
|
||||
|
||||
@property
|
||||
def output_dir(self) -> Path:
|
||||
return self.session.workspace_root
|
||||
|
||||
@property
|
||||
def scratch_dir(self) -> Path:
|
||||
return self.session.workspace_root / SCRATCH_DIRNAME
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
"""Create both folders if they don't exist yet. Callers that only
|
||||
need one (most do) can skip this and let ``write_file`` create parents
|
||||
on demand, same as today."""
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.scratch_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def cleanup_scratch(self) -> None:
|
||||
"""Unconditionally remove the scratch subtree.
|
||||
|
||||
Coarser than ``core/chat_agent.py::_cleanup_cowork_intermediates``,
|
||||
which rescues any real deliverable a generator script wrote INSIDE
|
||||
``.scratch`` before wiping it - that rescue logic stays there. This
|
||||
is for callers that only need "make the scratch folder go away"
|
||||
(e.g. before starting a fresh run) and know it holds nothing worth
|
||||
saving.
|
||||
"""
|
||||
shutil.rmtree(self.scratch_dir, ignore_errors=True)
|
||||
|
||||
|
||||
__all__ = ["ExecutionWorkspace", "SCRATCH_DIRNAME"]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Fetch tools - fetch_url, jira_search, jira_get_issue (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). The
|
||||
network access these three carry is exactly what the ``ToolCapability.NETWORK``
|
||||
tag added in R05-T01/domain/tools/tool_registry.py describes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
|
||||
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
use. Honors the Sandbox Security Layer's "Block network" policy."""
|
||||
url = str(args.get("url", "")).strip()
|
||||
if not url:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
"(\"Allow the agent to fetch URLs\").")}
|
||||
# A pasted Jira issue link on the CONNECTED Jira host is read via the
|
||||
# authenticated API (so private issues resolve, not a login page). Public
|
||||
# links / any other URL fall through to the normal fetcher below.
|
||||
from cowork_local.core import jira_tool
|
||||
if jira_tool.is_jira_issue_url(ctx.jira, url):
|
||||
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
|
||||
from cowork_local.core.link_fetch import fetch_link_preview
|
||||
|
||||
return {"ok": True, "output": fetch_link_preview(url)}
|
||||
|
||||
|
||||
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
|
||||
int(args.get("max_results", 25) or 25))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
|
||||
"output": out}
|
||||
|
||||
|
||||
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
|
||||
"output": out}
|
||||
|
||||
|
||||
__all__ = ["fetch_url", "jira_search", "jira_get_issue"]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""File tools - read_file, list_dir, write_file, edit_file (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py``, whose ``execute_tool`` used to
|
||||
dispatch to these via a hand-written if/elif chain over every tool name it
|
||||
knew about. Splitting the built-in handlers into per-concern modules
|
||||
(this one, ``command_tools.py``, ``fetch_tools.py``) means adding a tool no
|
||||
longer means growing that one function; ``core/tools.py::execute_tool`` now
|
||||
looks the name up in a dict built from these modules instead.
|
||||
|
||||
Behavior is unchanged from before the split - this is a pure move, not a
|
||||
rewrite. Every existing characterization/contract test that exercises
|
||||
read_file/write_file/edit_file/list_dir through ``core.tools.execute_tool``
|
||||
still exercises the exact same code, just imported from here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
MAX_READ_BYTES = 200_000
|
||||
|
||||
|
||||
def _flatten_rel(rel: str) -> str:
|
||||
"""Collapse a sub-folder path down to a bare filename so the file lands in the
|
||||
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
|
||||
|
||||
Used by the Cowork agent (flatten_writes=True) so it can never create a
|
||||
per-session / per-chat / per-task output sub-folder: every deliverable stays
|
||||
directly in the single configured Output folder."""
|
||||
parts = Path(rel).parts
|
||||
if parts and parts[0] == ".scratch":
|
||||
return rel # temporary sandbox is allowed (and cleaned up afterwards)
|
||||
return Path(rel).name or rel
|
||||
|
||||
|
||||
def _check_python_syntax(target: Path, content: str) -> str:
|
||||
"""Return a short warning if ``content`` is invalid Python, else ''.
|
||||
|
||||
Catches syntax errors the instant a .py file is written/edited — before the
|
||||
agent wastes a whole run_command round-trip just to get the same error back
|
||||
from a traceback."""
|
||||
if target.suffix.lower() not in (".py", ".pyw"):
|
||||
return ""
|
||||
try:
|
||||
ast.parse(content, filename=str(target))
|
||||
return ""
|
||||
except SyntaxError as exc:
|
||||
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
|
||||
|
||||
|
||||
def read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
target = ctx.resolve(str(args.get("path", "")))
|
||||
if not target.exists():
|
||||
return {"ok": False, "output": f"File not found: {args.get('path')}"}
|
||||
data = target.read_bytes()[:MAX_READ_BYTES]
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
return {"ok": True, "output": text}
|
||||
|
||||
|
||||
def list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ".") or ".")
|
||||
target = ctx.resolve(rel)
|
||||
# A missing/not-yet-created path is NOT a tool failure — report it as an
|
||||
# ordinary result so the agent can create it or pick another path and keep
|
||||
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
|
||||
# Co4E flows and could stall a step on a recoverable situation.
|
||||
if not target.exists():
|
||||
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
|
||||
if target.is_file():
|
||||
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
|
||||
entries = []
|
||||
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
marker = "/" if child.is_dir() else ""
|
||||
entries.append(f"{child.name}{marker}")
|
||||
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
|
||||
|
||||
|
||||
def write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
content = str(args.get("content", ""))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A .xlsx is a binary package — build a REAL workbook from the content
|
||||
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
|
||||
if target.suffix.lower() in (".xlsx", ".xlsm"):
|
||||
from cowork_local.core import xlsx_write
|
||||
if xlsx_write.build_xlsx_from_text(target, content):
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote spreadsheet {rel} ({target.name})."}
|
||||
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
|
||||
"write a .csv instead, or use a generator script."}
|
||||
target.write_text(content, encoding="utf-8")
|
||||
warning = _check_python_syntax(target, content)
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
|
||||
|
||||
|
||||
def edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet inside an existing file (precise patch edit)."""
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
if not target.exists():
|
||||
return {"ok": False,
|
||||
"output": f"File not found: {rel} — use write_file to create it."}
|
||||
old = str(args.get("old_string", ""))
|
||||
new = str(args.get("new_string", ""))
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
if not old:
|
||||
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
|
||||
try:
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "output": f"Could not read file: {exc}"}
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
|
||||
"text to replace, including indentation/whitespace.")}
|
||||
if count > 1 and not replace_all:
|
||||
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
|
||||
"context to make it unique, or set replace_all=true.")}
|
||||
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
n = count if replace_all else 1
|
||||
warning = _check_python_syntax(target, updated)
|
||||
return {"ok": True,
|
||||
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
|
||||
|
||||
|
||||
__all__ = ["MAX_READ_BYTES", "read_file", "list_dir", "write_file", "edit_file"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""ToolContext / ToolError / CancelFn - the sandboxed execution context every
|
||||
built-in tool runs against (moved out of ``core/tools.py`` in R05-T02).
|
||||
|
||||
Kept as its own leaf module (no dependency on any sibling in this package) so
|
||||
``file_tools.py``, ``command_tools.py`` and ``fetch_tools.py`` can each import
|
||||
it without creating an import cycle back through ``core/tools.py``, which
|
||||
itself re-exports ``ToolContext``/``ToolError`` from here for the existing
|
||||
callers (``core/chat_agent.py``, ``core/code_agent.py``,
|
||||
``core/task_executors.py``) that do ``from .tools import ToolContext``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
workdir: Path
|
||||
flatten_writes: bool = False # Cowork: force every write into the workdir root
|
||||
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
|
||||
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
|
||||
# disk_mb), applied to every run_command/install_package this context runs.
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
jira: Optional[Dict[str, Any]] = None
|
||||
|
||||
def resolve(self, rel: str) -> Path:
|
||||
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
|
||||
if rel in ("", "."):
|
||||
return self.workdir
|
||||
candidate = (self.workdir / rel).expanduser()
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Invalid path: {rel} ({exc})")
|
||||
root = self.workdir.resolve()
|
||||
if resolved != root and root not in resolved.parents:
|
||||
raise ToolError(
|
||||
f"Refused: '{rel}' is outside the working folder ({root})."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
__all__ = ["CancelFn", "ToolError", "ToolContext"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""MCP server connection lifecycle management (EPIC R05)."""
|
||||
|
||||
from .mcp_source_manager import McpToolSourceManager
|
||||
|
||||
__all__ = ["McpToolSourceManager"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""McpToolSourceManager - the MCP server connection lifecycle, extracted out
|
||||
of ``state.py::AppContext`` (R05-T05).
|
||||
|
||||
Today ``AppContext.build_mcp_tools`` inlines all of this: a ``_mcp_connections``
|
||||
dict, a ``_conn_lock`` guarding check-then-create against concurrent turns (a
|
||||
Cowork tab, a Co4E flow and a Scheduled Task can all call it at once), and a
|
||||
"start it, cache it, skip it on failure" loop repeated for both the
|
||||
admin-configured servers AND the built-in MS365 server
|
||||
(``_ms365_builtin_connection``). None of that logic touches Qt; it was only
|
||||
ever inline because ``AppContext`` is where the config lived.
|
||||
|
||||
This class owns the SAME cache/lock/start-or-skip behavior as a standalone,
|
||||
directly testable object — ``AppContext`` becomes a thin caller (one instance
|
||||
per app, same as it holds one ``RoutingApplicationService``).
|
||||
|
||||
Pure Python: no Qt. It DOES touch the network/filesystem via
|
||||
``core.mcp_client.McpServerConnection`` (a subprocess + asyncio loop), which is
|
||||
exactly what makes it infrastructure rather than domain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from cowork_local.core.mcp_client import McpServerConnection
|
||||
|
||||
|
||||
class McpToolSourceManager:
|
||||
"""Caches and supervises one :class:`McpServerConnection` per server name.
|
||||
|
||||
``connection_factory`` defaults to ``McpServerConnection`` itself; tests
|
||||
substitute a fake so no real subprocess is spawned (see
|
||||
``tests/unit/test_mcp_source_manager.py``).
|
||||
"""
|
||||
|
||||
def __init__(self, connection_factory=McpServerConnection) -> None:
|
||||
self._connections: Dict[str, McpServerConnection] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._connection_factory = connection_factory
|
||||
|
||||
def ensure(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
|
||||
"""Return a live connection for ``name``, starting one if there is
|
||||
none cached or the cached one's subprocess has died.
|
||||
|
||||
Serialized under one lock so two turns racing to build their tool
|
||||
list at the same moment share one subprocess per server instead of
|
||||
each spawning their own (the bug this replaces:
|
||||
``AppContext._conn_lock``'s original docstring). Returns ``None`` -
|
||||
never raises - when the server fails to start, matching the existing
|
||||
"one broken server must not block the turn" behavior.
|
||||
"""
|
||||
with self._lock:
|
||||
existing = self._connections.get(name)
|
||||
if existing is not None and existing.is_alive():
|
||||
return existing
|
||||
if existing is not None:
|
||||
self._connections.pop(name, None)
|
||||
connection = self._connection_factory(name, command, args or [], env)
|
||||
try:
|
||||
connection.start()
|
||||
except Exception: # noqa: BLE001 - one broken server must not block the turn
|
||||
return None
|
||||
self._connections[name] = connection
|
||||
return connection
|
||||
|
||||
def get(self, name: str) -> Optional[McpServerConnection]:
|
||||
"""The cached connection for ``name``, without starting one."""
|
||||
return self._connections.get(name)
|
||||
|
||||
def is_alive(self, name: str) -> bool:
|
||||
connection = self._connections.get(name)
|
||||
return connection is not None and connection.is_alive()
|
||||
|
||||
def restart(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
|
||||
"""Force a fresh connection for ``name`` even if the cached one still
|
||||
looks alive - for a server the caller knows is misbehaving."""
|
||||
with self._lock:
|
||||
self._connections.pop(name, None)
|
||||
return self.ensure(name, command, args, env)
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
"""Stop and forget one connection - used when a server becomes
|
||||
unavailable by configuration (e.g. MS365 signed out) rather than by
|
||||
crashing."""
|
||||
with self._lock:
|
||||
connection = self._connections.pop(name, None)
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.stop()
|
||||
except Exception: # noqa: BLE001 - shutdown must never raise into the caller
|
||||
pass
|
||||
|
||||
def active(self) -> List[McpServerConnection]:
|
||||
"""Every currently cached connection - what
|
||||
``core/mcp_client.py::build_mcp_tools`` merges tool specs from."""
|
||||
return list(self._connections.values())
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Terminate every connection's subprocess - called on app shutdown
|
||||
so none of them linger as orphan processes."""
|
||||
with self._lock:
|
||||
connections = list(self._connections.values())
|
||||
self._connections.clear()
|
||||
for connection in connections:
|
||||
try:
|
||||
connection.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["McpToolSourceManager"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Persistence adapters (EPIC R02/R06)."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""JSON-file persistence adapters: crash-safe writes and the workspace/
|
||||
conversation repositories built on them (EPIC R06)."""
|
||||
|
||||
from .atomic_write import write_json
|
||||
from .conversation_repository_impl import ConversationRepository
|
||||
from .workspace_repository_impl import WorkspaceRepository
|
||||
|
||||
__all__ = ["write_json", "WorkspaceRepository", "ConversationRepository"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""write_json - crash-safe JSON writes (R06-T02).
|
||||
|
||||
``core/projects.py::save_project`` and ``core/history.py``'s
|
||||
``save_conversation``/``rename_conversation``/``set_pinned`` all do a plain
|
||||
``path.write_text(json.dumps(...))`` today. That is two syscalls with a gap in
|
||||
between: a crash, a killed process, or a full disk between the truncate and
|
||||
the write leaves a half-written, unparseable JSON file - the NEXT read of
|
||||
that project/conversation then fails outright (``load_project`` /
|
||||
``load_conversation`` already treat a parse error as "missing", so this isn't
|
||||
even a loud failure - a project can silently vanish).
|
||||
|
||||
``write_json`` fixes this the standard way: write the full content to a
|
||||
temporary file in the SAME directory (so the following replace is on one
|
||||
filesystem, not crossing a mount point), then atomically rename it over the
|
||||
target. Either the old file is still there, or the new one is fully there -
|
||||
never a partial one.
|
||||
|
||||
Transitional note: EPIC R02 (Team Nam, ``docs/refactor/Refactoring_Checklist.md``
|
||||
R02-T01) plans a shared ``infrastructure/persistence/json/atomic_json_file.py``
|
||||
for the SAME purpose across the whole app (config, secrets, ...). This module
|
||||
is deliberately named differently and scoped to R06's two repositories only,
|
||||
so the two EPICs don't edit the same file in parallel; once R02-T01 lands,
|
||||
``WorkspaceRepository``/``ConversationRepository`` should switch to it and
|
||||
this module can go away.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
"""Serialize ``data`` as indented UTF-8 JSON and write it to ``path``
|
||||
atomically. Creates parent directories if needed."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
__all__ = ["write_json"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""ConversationRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/history.py`` (R06-T02). Same rationale as
|
||||
``workspace_repository_impl.py``: the module-level functions in
|
||||
``core/history.py`` are still what production code calls (they now write
|
||||
atomically themselves), this class is the seam for application-layer code
|
||||
that wants an object instead of a directory-parameterised function.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cowork_local.config import HISTORY_DIR
|
||||
from cowork_local.core.history import (
|
||||
delete_conversation,
|
||||
list_conversations,
|
||||
load_conversation,
|
||||
new_session_id,
|
||||
rename_conversation,
|
||||
save_conversation,
|
||||
set_pinned,
|
||||
)
|
||||
|
||||
|
||||
class ConversationRepository:
|
||||
"""CRUD + search over conversation JSON files, scoped to one
|
||||
``directory`` (defaults to the app's real ``HISTORY_DIR``)."""
|
||||
|
||||
def __init__(self, directory: Optional[Path] = None) -> None:
|
||||
self._directory = Path(directory) if directory is not None else HISTORY_DIR
|
||||
|
||||
def new_session_id(self) -> str:
|
||||
return new_session_id()
|
||||
|
||||
def save(self, kind: str, session_id: str, messages: List[Dict[str, Any]], **kwargs) -> Path:
|
||||
return save_conversation(self._directory, kind, session_id, messages, **kwargs)
|
||||
|
||||
def load(self, path: Path) -> Dict[str, Any]:
|
||||
return load_conversation(path)
|
||||
|
||||
def list(self, query: str = "") -> List[Dict[str, Any]]:
|
||||
return list_conversations(self._directory, query)
|
||||
|
||||
def delete(self, path: Path) -> None:
|
||||
delete_conversation(path)
|
||||
|
||||
def rename(self, path: Path, new_title: str) -> None:
|
||||
rename_conversation(path, new_title)
|
||||
|
||||
def set_pinned(self, path: Path, pinned: bool) -> None:
|
||||
set_pinned(path, pinned)
|
||||
|
||||
|
||||
__all__ = ["ConversationRepository"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""WorkspaceRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/projects.py`` (R06-T02).
|
||||
|
||||
``core/projects.py``'s module-level functions (``list_projects``,
|
||||
``load_project``, ``save_project``, ``new_project``, ``delete_project``) are
|
||||
still what every existing call site (``ui/workspace_tab.py``, ``state.py``,
|
||||
task executors) uses, and stay that way - they now write through
|
||||
:func:`atomic_write.write_json` themselves, so the durability fix applies
|
||||
whether or not a caller ever touches this class.
|
||||
|
||||
This repository exists for the application layer (``application/workspaces``,
|
||||
R06-T05) to depend on an interface instead of reaching into ``core/`` -
|
||||
useful once code above ``core/`` starts being written against
|
||||
``domain``/``application`` seams instead of the legacy module functions. It
|
||||
is a thin pass-through today, not a re-implementation: same on-disk format,
|
||||
same directory, same functions underneath.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from cowork_local.core.projects import (
|
||||
PROJECTS_DIR,
|
||||
Project,
|
||||
delete_project,
|
||||
list_projects,
|
||||
load_project,
|
||||
new_project,
|
||||
save_project,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceRepository:
|
||||
"""CRUD over :class:`~cowork_local.core.projects.Project`, scoped to one
|
||||
``directory`` (defaults to the app's real ``PROJECTS_DIR``; tests pass a
|
||||
``tmp_path`` so nothing touches the user's real config folder)."""
|
||||
|
||||
def __init__(self, directory: Optional[Path] = None) -> None:
|
||||
self._directory = directory or PROJECTS_DIR
|
||||
|
||||
def list(self) -> List[Project]:
|
||||
return list_projects(self._directory)
|
||||
|
||||
def get(self, project_id: str) -> Optional[Project]:
|
||||
return load_project(project_id, self._directory)
|
||||
|
||||
def save(self, project: Project) -> Path:
|
||||
return save_project(project, self._directory)
|
||||
|
||||
def create(self, name: str, description: str = "", instructions: str = "",
|
||||
output_dir: str = "") -> Project:
|
||||
return new_project(name, description, instructions, output_dir, self._directory)
|
||||
|
||||
def delete(self, project_id: str) -> bool:
|
||||
return delete_project(project_id, self._directory)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceRepository"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Provider adapters and the central provider catalogue (EPIC R03)."""
|
||||
|
||||
from .provider_registry import ProviderRegistry, default_registry
|
||||
|
||||
__all__ = ["ProviderRegistry", "default_registry"]
|
||||
@@ -0,0 +1,207 @@
|
||||
"""ProviderRegistry - the one place a provider is declared (R03-T02).
|
||||
|
||||
Replaces the three-way split between ``providers/factory.py::_REGISTRY``,
|
||||
``config.py::DEFAULT_CONFIG["providers"]`` and ``config.py::PROVIDER_LABELS``
|
||||
with a single catalogue of :class:`ProviderDescriptor` objects plus the
|
||||
implementation class each one maps to.
|
||||
|
||||
Adding a provider is now one entry in :data:`BUILT_IN_PROVIDERS` (declarative
|
||||
facts) and one line in :data:`_IMPLEMENTATIONS` (which class speaks that
|
||||
protocol) - see ``docs/governance/contributor-recipes.md`` (R10-T04).
|
||||
|
||||
Migration note (strangler fig, ADR-001 section 4): this registry does not
|
||||
re-implement any provider. It builds the SAME classes ``providers/factory.py``
|
||||
builds, so both entry points stay behaviourally identical while call sites move
|
||||
over one at a time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from cowork_local.domain.models.provider_descriptor import (
|
||||
ProviderCapability,
|
||||
ProviderDescriptor,
|
||||
)
|
||||
from cowork_local.providers.base import Provider, ProviderError
|
||||
|
||||
_CAP = ProviderCapability
|
||||
|
||||
# Every provider the app ships with, described once.
|
||||
#
|
||||
# The capability sets are deliberately conservative: a capability listed here is
|
||||
# one the adapter genuinely implements today. Claiming VISION for a provider
|
||||
# whose chat() cannot translate an image block would route an image turn into a
|
||||
# guaranteed failure, so an unimplemented capability must stay off the list.
|
||||
BUILT_IN_PROVIDERS: tuple = (
|
||||
ProviderDescriptor(
|
||||
id="openai_compat",
|
||||
label="OpenAI-compatible (Internal Gateway)",
|
||||
protocol="openai_compat",
|
||||
default_model="gpt-4o-mini",
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
|
||||
_CAP.REASONING, _CAP.MODEL_LISTING}),
|
||||
notes="Any endpoint speaking the OpenAI Chat Completions protocol.",
|
||||
),
|
||||
ProviderDescriptor(
|
||||
id="anthropic",
|
||||
label="Anthropic Claude",
|
||||
protocol="anthropic",
|
||||
default_model="claude-sonnet-4-6",
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
|
||||
_CAP.MODEL_LISTING}),
|
||||
),
|
||||
ProviderDescriptor(
|
||||
id="ollama",
|
||||
label="Ollama (local models)",
|
||||
protocol="openai_compat",
|
||||
default_model="llama3.1",
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.REASONING,
|
||||
_CAP.MODEL_LISTING}),
|
||||
# Ollama ignores the key, but the OpenAI client layer requires a value,
|
||||
# so the default config ships a placeholder rather than an empty string.
|
||||
requires_api_key=False,
|
||||
local=True,
|
||||
notes="Runs on this machine - no data leaves the device, no token cost.",
|
||||
),
|
||||
ProviderDescriptor(
|
||||
id="github_copilot",
|
||||
label="GitHub Copilot",
|
||||
protocol="openai_compat",
|
||||
default_model="gpt-4o",
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.MODEL_LISTING}),
|
||||
notes="Paste a Copilot token as the API key.",
|
||||
),
|
||||
ProviderDescriptor(
|
||||
id="codex",
|
||||
label="OpenAI (Codex / GPT)",
|
||||
protocol="openai_compat",
|
||||
default_model="gpt-4o-mini",
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
|
||||
_CAP.REASONING, _CAP.MODEL_LISTING}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _implementations() -> Dict[str, type]:
|
||||
"""Protocol -> adapter class.
|
||||
|
||||
Imported lazily inside the function because ``providers/anthropic.py`` and
|
||||
``providers/openai_compat.py`` pull in ``requests`` at import time; keeping
|
||||
that out of module import means a test that only inspects descriptors pays
|
||||
no import cost at all.
|
||||
"""
|
||||
from cowork_local.providers.anthropic import AnthropicProvider
|
||||
from cowork_local.providers.openai_compat import OpenAICompatProvider
|
||||
|
||||
return {
|
||||
"openai_compat": OpenAICompatProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
}
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""Catalogue of known providers + the factory that instantiates them.
|
||||
|
||||
Intentionally holds no config and no app context: it is a pure lookup table
|
||||
plus a build step, so it can be constructed in a test with a custom
|
||||
descriptor list and no application running.
|
||||
"""
|
||||
|
||||
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
|
||||
# Dict preserves declaration order (Python 3.7+), which is the order
|
||||
# Settings lists providers in - so the catalogue order is data, not luck.
|
||||
self._by_id: Dict[str, ProviderDescriptor] = {
|
||||
d.id: d for d in (descriptors if descriptors is not None else BUILT_IN_PROVIDERS)
|
||||
}
|
||||
|
||||
# -- catalogue queries ------------------------------------------------ #
|
||||
def ids(self) -> List[str]:
|
||||
"""Known provider ids, in declaration order."""
|
||||
return list(self._by_id)
|
||||
|
||||
def all(self) -> List[ProviderDescriptor]:
|
||||
"""Every descriptor, in declaration order."""
|
||||
return list(self._by_id.values())
|
||||
|
||||
def get(self, provider_id: str) -> Optional[ProviderDescriptor]:
|
||||
"""The descriptor for ``provider_id``, or None when unknown.
|
||||
|
||||
Returns None rather than raising because the caller is often reacting to
|
||||
a config file that may name a provider from a newer version; the UI
|
||||
should be able to skip it, not crash.
|
||||
"""
|
||||
return self._by_id.get(provider_id)
|
||||
|
||||
def require(self, provider_id: str) -> ProviderDescriptor:
|
||||
"""Like :meth:`get` but raises :class:`ProviderError` when unknown.
|
||||
|
||||
Same error type ``providers/factory.py::build_provider`` already raises,
|
||||
so callers that migrate to the registry keep their existing except clause.
|
||||
"""
|
||||
descriptor = self._by_id.get(provider_id)
|
||||
if descriptor is None:
|
||||
known = ", ".join(self._by_id) or "(none)"
|
||||
raise ProviderError(f"Unsupported provider: {provider_id} (known: {known})")
|
||||
return descriptor
|
||||
|
||||
def labels(self) -> Dict[str, str]:
|
||||
"""``{id: label}`` - the drop-in replacement for ``config.PROVIDER_LABELS``."""
|
||||
return {d.id: d.label for d in self._by_id.values()}
|
||||
|
||||
def supporting(self, capability: ProviderCapability) -> List[ProviderDescriptor]:
|
||||
"""Every descriptor advertising ``capability`` - used to answer "which
|
||||
providers could serve this turn?" before any of them is built."""
|
||||
return [d for d in self._by_id.values() if d.supports(capability)]
|
||||
|
||||
def configured(self, providers_conf: Mapping[str, Mapping[str, Any]]
|
||||
) -> List[ProviderDescriptor]:
|
||||
"""Descriptors whose config section is complete enough to actually call.
|
||||
|
||||
``providers_conf`` is ``AppConfig.data["providers"]``. Passing the raw
|
||||
mapping (not the AppConfig object) keeps this layer independent of the
|
||||
config implementation, which EPIC R02 is rewriting in parallel.
|
||||
"""
|
||||
return [d for d in self._by_id.values()
|
||||
if d.is_configured(providers_conf.get(d.id, {}) or {})]
|
||||
|
||||
# -- construction ----------------------------------------------------- #
|
||||
def build(self, provider_id: str, conf: Mapping[str, Any],
|
||||
model: str = "") -> Provider:
|
||||
"""Instantiate the adapter for ``provider_id``.
|
||||
|
||||
``model`` overrides the configured model for this instance only - that is
|
||||
how the routing layer runs one turn on a different model without mutating
|
||||
the user's saved settings.
|
||||
"""
|
||||
descriptor = self.require(provider_id)
|
||||
impl = _implementations().get(descriptor.protocol)
|
||||
if impl is None: # pragma: no cover - only reachable via a bad descriptor
|
||||
raise ProviderError(
|
||||
f"Provider '{provider_id}' declares unknown protocol "
|
||||
f"'{descriptor.protocol}'."
|
||||
)
|
||||
# Copy before mutating: conf is the caller's live config dict, and
|
||||
# writing the routed model into it would silently change the user's
|
||||
# saved default for every later turn.
|
||||
resolved = dict(conf or {})
|
||||
resolved["model"] = descriptor.resolve_model(conf, model)
|
||||
instance = impl(resolved)
|
||||
# The adapter class is shared by several ids (three of them are
|
||||
# OpenAI-compatible), so its class-level `name` cannot identify which
|
||||
# provider this is. Stamping the instance keeps usage records, audit
|
||||
# entries and routing candidate keys attributed to the right provider.
|
||||
instance.name = descriptor.id
|
||||
return instance
|
||||
|
||||
def describe(self, provider_id: str, conf: Optional[Mapping[str, Any]] = None) -> str:
|
||||
"""One-line description used in logs and error messages."""
|
||||
return self.require(provider_id).describe(conf)
|
||||
|
||||
|
||||
# Shared default instance. Callers that need the built-in catalogue use this
|
||||
# instead of constructing a registry each time; tests build their own with an
|
||||
# explicit descriptor list.
|
||||
default_registry = ProviderRegistry()
|
||||
|
||||
|
||||
__all__ = ["ProviderRegistry", "BUILT_IN_PROVIDERS", "default_registry"]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Telemetry sinks: where token usage and turn metrics are recorded (EPIC R03)."""
|
||||
|
||||
from .usage_sink import (
|
||||
NullUsageSink,
|
||||
RecordingUsageSink,
|
||||
UsageEvent,
|
||||
UsageEventSink,
|
||||
UsageTrackerSink,
|
||||
default_sink,
|
||||
set_default_sink,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UsageEvent",
|
||||
"UsageEventSink",
|
||||
"UsageTrackerSink",
|
||||
"NullUsageSink",
|
||||
"RecordingUsageSink",
|
||||
"default_sink",
|
||||
"set_default_sink",
|
||||
]
|
||||
@@ -0,0 +1,229 @@
|
||||
"""UsageEventSink - where a turn's token usage goes (R03-T06).
|
||||
|
||||
Today each provider records its own usage inline, in the middle of the streaming
|
||||
loop::
|
||||
|
||||
# providers/openai_compat.py
|
||||
def _record_usage(self, messages, text_parts, tool_acc, usage_seen):
|
||||
from ..core import usage_tracker as ut
|
||||
...
|
||||
ut.record(self.name, self.model, ...)
|
||||
|
||||
Three problems with that shape:
|
||||
|
||||
1. **Hidden side effect.** ``chat()`` looks like a pure request/response call but
|
||||
also writes to the Dashboard's store, so a test of a provider silently
|
||||
appends rows to the developer's real usage history.
|
||||
2. **Duplicated estimation.** The "no usage block from the server, so estimate
|
||||
at ~4 chars/token" fallback is copy-pasted per provider and can drift.
|
||||
3. **One hard-wired destination.** Usage can only ever go to
|
||||
``core.usage_tracker``; a run that wants to bill a workflow, or a test that
|
||||
wants to assert on token counts, has nowhere to plug in.
|
||||
|
||||
This module introduces the seam: providers build a :class:`UsageEvent` and hand
|
||||
it to a :class:`UsageEventSink`. Production wires :class:`UsageTrackerSink`
|
||||
(same destination, same numbers as before); tests wire
|
||||
:class:`RecordingUsageSink` or :class:`NullUsageSink`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||
|
||||
logger = logging.getLogger("cowork_local.telemetry")
|
||||
|
||||
# Rough characters-per-token ratio used when the gateway sends no usage block.
|
||||
# Matches the constant behaviour of ``core.usage_tracker.estimate_tokens`` so
|
||||
# moving the estimation here does not change a single recorded number.
|
||||
_CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageEvent:
|
||||
"""Token usage for exactly one provider round trip.
|
||||
|
||||
``estimated`` marks a record derived from text length rather than reported by
|
||||
the server. The Dashboard shows the two differently, and conflating them
|
||||
would make cost figures look more precise than they are.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cached_tokens: int = 0
|
||||
estimated: bool = False
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
"""Input + output. Cached tokens are a subset of input, not an addition,
|
||||
so adding them here would double-count a cache hit."""
|
||||
return self.input_tokens + self.output_tokens
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-safe projection for logs and for sinks that persist raw events."""
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"input_tokens": self.input_tokens,
|
||||
"output_tokens": self.output_tokens,
|
||||
"cached_tokens": self.cached_tokens,
|
||||
"estimated": self.estimated,
|
||||
}
|
||||
|
||||
|
||||
class UsageEventSink(Protocol):
|
||||
"""Anything that can absorb a :class:`UsageEvent`.
|
||||
|
||||
Implementations MUST NOT raise: telemetry is observability, and a failure to
|
||||
record usage must never abort the turn that produced it.
|
||||
"""
|
||||
|
||||
def record(self, event: UsageEvent) -> None:
|
||||
"""Absorb one usage event."""
|
||||
|
||||
|
||||
class NullUsageSink:
|
||||
"""Discards everything. The default for tests and headless tooling, so a
|
||||
unit test never writes into the developer's real usage history."""
|
||||
|
||||
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
|
||||
return None
|
||||
|
||||
|
||||
class RecordingUsageSink:
|
||||
"""Keeps events in memory so a test can assert on what was recorded."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: List[UsageEvent] = []
|
||||
|
||||
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
|
||||
self.events.append(event)
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
"""Sum across every recorded event."""
|
||||
return sum(e.total_tokens for e in self.events)
|
||||
|
||||
|
||||
class UsageTrackerSink:
|
||||
"""Forwards to ``core.usage_tracker`` - the Dashboard's store.
|
||||
|
||||
This is the production sink and the only place that still knows about the
|
||||
legacy tracker module, which is what lets EPIC R10 replace the storage
|
||||
without touching a single provider.
|
||||
"""
|
||||
|
||||
def __init__(self, tracker: Optional[Any] = None) -> None:
|
||||
# Injectable for tests; imported lazily otherwise because the tracker
|
||||
# touches the config directory at import time.
|
||||
self._tracker = tracker
|
||||
|
||||
def _resolve(self) -> Any:
|
||||
if self._tracker is None:
|
||||
from cowork_local.core import usage_tracker
|
||||
|
||||
self._tracker = usage_tracker
|
||||
return self._tracker
|
||||
|
||||
def record(self, event: UsageEvent) -> None:
|
||||
"""Write the event to the usage tracker, swallowing any failure.
|
||||
|
||||
The bare except mirrors the behaviour this replaces (each provider
|
||||
already wrapped its ``ut.record`` call in ``try/except: pass``) but logs
|
||||
at debug level instead of discarding the reason entirely, so a broken
|
||||
Dashboard store can at least be diagnosed.
|
||||
"""
|
||||
try:
|
||||
self._resolve().record(
|
||||
event.provider, event.model,
|
||||
event.input_tokens, event.output_tokens, event.cached_tokens,
|
||||
estimated=event.estimated,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - telemetry must never break a turn
|
||||
logger.debug("usage sink: failed to record %s", event.to_dict(), exc_info=True)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Approximate token count for ``text`` (~4 characters per token).
|
||||
|
||||
Deliberately identical to ``core.usage_tracker.estimate_tokens`` so that
|
||||
moving estimation into this layer changes no recorded number. Duplicated
|
||||
rather than imported to keep this module free of the legacy dependency;
|
||||
:class:`UsageTrackerSink` is the only bridge back to it.
|
||||
"""
|
||||
return max(0, len(text or "") // _CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def estimated_event(provider: str, model: str, sent: str, received: str) -> UsageEvent:
|
||||
"""Build an estimated :class:`UsageEvent` from the raw text of a round trip.
|
||||
|
||||
Used when the gateway sends no usage block - most self-hosted OpenAI-compatible
|
||||
servers and Ollama do not.
|
||||
"""
|
||||
return UsageEvent(
|
||||
provider=provider, model=model,
|
||||
input_tokens=estimate_tokens(sent),
|
||||
output_tokens=estimate_tokens(received),
|
||||
cached_tokens=0,
|
||||
estimated=True,
|
||||
)
|
||||
|
||||
|
||||
def openai_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
|
||||
"""Build a reported :class:`UsageEvent` from an OpenAI-style usage block."""
|
||||
details = usage.get("prompt_tokens_details") or {}
|
||||
return UsageEvent(
|
||||
provider=provider, model=model,
|
||||
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
cached_tokens=int(details.get("cached_tokens", 0) or 0),
|
||||
estimated=False,
|
||||
)
|
||||
|
||||
|
||||
def anthropic_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
|
||||
"""Build a reported :class:`UsageEvent` from Anthropic's usage accumulator.
|
||||
|
||||
Anthropic reports input tokens on ``message_start`` and output tokens on
|
||||
``message_delta``, so ``providers/anthropic.py`` accumulates them into a dict
|
||||
keyed ``in``/``out``/``cache`` - this reads that shape.
|
||||
"""
|
||||
return UsageEvent(
|
||||
provider=provider, model=model,
|
||||
input_tokens=int(usage.get("in", 0) or 0),
|
||||
output_tokens=int(usage.get("out", 0) or 0),
|
||||
cached_tokens=int(usage.get("cache", 0) or 0),
|
||||
estimated=False,
|
||||
)
|
||||
|
||||
|
||||
# The sink providers use unless one is injected. A module-level default keeps
|
||||
# the change to the provider classes to a single attribute, and lets a test swap
|
||||
# the destination process-wide with one monkeypatch.
|
||||
default_sink: UsageEventSink = UsageTrackerSink()
|
||||
|
||||
|
||||
def set_default_sink(sink: UsageEventSink) -> UsageEventSink:
|
||||
"""Replace the process-wide default sink; returns the previous one so a
|
||||
caller (or fixture) can restore it."""
|
||||
global default_sink
|
||||
previous = default_sink
|
||||
default_sink = sink
|
||||
return previous
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UsageEvent",
|
||||
"UsageEventSink",
|
||||
"UsageTrackerSink",
|
||||
"NullUsageSink",
|
||||
"RecordingUsageSink",
|
||||
"estimate_tokens",
|
||||
"estimated_event",
|
||||
"openai_usage_event",
|
||||
"anthropic_usage_event",
|
||||
"default_sink",
|
||||
"set_default_sink",
|
||||
]
|
||||
+12
-14
@@ -292,21 +292,19 @@ class AnthropicProvider(Provider):
|
||||
args = {"_raw": b["json"]}
|
||||
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
|
||||
|
||||
# Dashboard usage event — real counts from the stream's usage events,
|
||||
# else a ~4 chars/token estimate. Never breaks the turn.
|
||||
try:
|
||||
from ..core import usage_tracker as ut
|
||||
# Dashboard usage event — real counts from the stream's usage events
|
||||
# (input arrives on message_start, output on message_delta), else a
|
||||
# ~4 chars/token estimate. Delivery is the sink's job (R03-T06), so this
|
||||
# only translates Anthropic's wire shape into a canonical UsageEvent.
|
||||
from ..infrastructure.telemetry import usage_sink as telemetry
|
||||
|
||||
if usage_seen:
|
||||
ut.record(self.name, self.model, usage_seen.get("in", 0),
|
||||
usage_seen.get("out", 0), usage_seen.get("cache", 0))
|
||||
else:
|
||||
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
|
||||
ut.record(self.name, self.model, ut.estimate_tokens(sent),
|
||||
ut.estimate_tokens(got), 0, estimated=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if usage_seen:
|
||||
event = telemetry.anthropic_usage_event(self.name, self.model, usage_seen)
|
||||
else:
|
||||
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
|
||||
event = telemetry.estimated_event(self.name, self.model, sent, got)
|
||||
self._emit_usage(event)
|
||||
|
||||
return {"role": "assistant", "content": "".join(text_parts), "tool_calls": tool_calls}
|
||||
|
||||
|
||||
@@ -224,6 +224,12 @@ class Provider:
|
||||
# silently swallowing the error — Settings' "Test connection" / "Load
|
||||
# models" surfaces this so "model won't load" has a concrete reason.
|
||||
self.last_error = ""
|
||||
# Where this provider's token usage goes (R03-T06). None means "the
|
||||
# process-wide default sink", resolved lazily in _emit_usage so that a
|
||||
# test can swap the destination without rebuilding every provider.
|
||||
# Set it per instance to bill one run somewhere else (a workflow, a
|
||||
# scheduled task) without touching global state.
|
||||
self.usage_sink = None
|
||||
|
||||
def chat(
|
||||
self,
|
||||
@@ -274,6 +280,24 @@ class Provider:
|
||||
return True, f"OK — {len(models)} model(s) available."
|
||||
return False, "No models returned. Check base_url/API key and network access."
|
||||
|
||||
# -- telemetry -----------------------------------------------------
|
||||
def _emit_usage(self, event) -> None:
|
||||
"""Hand one ``UsageEvent`` to this provider's usage sink.
|
||||
|
||||
Never raises: recording how many tokens a turn cost must not be able to
|
||||
fail the turn itself. Falls back to the process-wide default sink so
|
||||
existing call sites keep reporting to the Dashboard exactly as before
|
||||
(see infrastructure/telemetry/usage_sink.py)."""
|
||||
try:
|
||||
sink = self.usage_sink
|
||||
if sink is None:
|
||||
from ..infrastructure.telemetry import usage_sink as telemetry
|
||||
|
||||
sink = telemetry.default_sink
|
||||
sink.record(event)
|
||||
except Exception: # noqa: BLE001 — telemetry is never worth a failed turn
|
||||
pass
|
||||
|
||||
# -- shared helpers ------------------------------------------------
|
||||
@staticmethod
|
||||
def _is_cancelled(cancel) -> bool:
|
||||
|
||||
+18
-15
@@ -268,22 +268,25 @@ class OpenAICompatProvider(Provider):
|
||||
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
|
||||
"""One Dashboard usage event per turn: real counts when the server's
|
||||
final chunk carried a "usage" block, a ~4 chars/token estimate
|
||||
otherwise. Never breaks the turn."""
|
||||
try:
|
||||
from ..core import usage_tracker as ut
|
||||
otherwise.
|
||||
|
||||
if usage_seen:
|
||||
ut.record(self.name, self.model,
|
||||
usage_seen.get("prompt_tokens", 0),
|
||||
usage_seen.get("completion_tokens", 0),
|
||||
(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
|
||||
else:
|
||||
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
|
||||
ut.record(self.name, self.model, ut.estimate_tokens(sent),
|
||||
ut.estimate_tokens(got), 0, estimated=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
Building the event and delivering it are now separate concerns (R03-T06):
|
||||
this method only translates THIS provider's wire shape into a canonical
|
||||
``UsageEvent``; where it ends up is the sink's decision, so a test can
|
||||
assert on token counts without writing to the real Dashboard store."""
|
||||
from ..infrastructure.telemetry import usage_sink as telemetry
|
||||
|
||||
if usage_seen:
|
||||
event = telemetry.openai_usage_event(self.name, self.model, usage_seen)
|
||||
else:
|
||||
# No usage block from the gateway (self-hosted servers and Ollama
|
||||
# never send one) - fall back to estimating from the raw text of
|
||||
# both directions, tool-call arguments included since the model was
|
||||
# billed for generating them.
|
||||
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
|
||||
event = telemetry.estimated_event(self.name, self.model, sent, got)
|
||||
self._emit_usage(event)
|
||||
|
||||
def list_models(self):
|
||||
self.last_error = ""
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Check 3 — Clean Architecture Guard (R01-T03).
|
||||
|
||||
Statically walks the AST of every Python file in the pure-Python layers and
|
||||
fails when a file imports something the layer is not allowed to depend on.
|
||||
|
||||
Why AST instead of ``grep``: a regex over source text cannot tell an import
|
||||
apart from the same words appearing inside a docstring, a comment or a string
|
||||
literal (this repo has several docstrings that legitimately mention
|
||||
``PySide6``). ``ast`` sees only real ``import`` / ``from … import`` nodes, so
|
||||
the check has no false positives and needs no ``# noqa`` escape hatches.
|
||||
|
||||
Rules enforced (see docs/architecture/ADR-001-layered-architecture.md):
|
||||
|
||||
* **I1** ``domain/`` and ``application/`` must be 100% pure Python — no Qt.
|
||||
* **I2** ``domain/`` must not import ``application/``, ``infrastructure/``,
|
||||
``presentation/`` or the legacy ``ui/``.
|
||||
* **I3** ``application/`` must not import ``presentation/`` or ``ui/``.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/check_imports.py # scan the whole repo
|
||||
python scripts/check_imports.py domain # scan one layer only
|
||||
|
||||
Exit code is 0 when clean and 1 when at least one violation is found, so it
|
||||
can be wired straight into CI / ``scripts/run_quality_gate.py`` (R10-T02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Sequence, Tuple
|
||||
|
||||
# Repository root = parent of this scripts/ folder. Everything below is resolved
|
||||
# relative to it so the checker works no matter what the checkout folder is
|
||||
# named or which directory the developer runs it from.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# The distribution package name. Absolute imports may be written either as
|
||||
# ``from cowork_local.ui import x`` or ``from ui import x`` depending on how the
|
||||
# module was reached; we normalise the prefix away so both spellings are caught.
|
||||
PACKAGE_NAME = "cowork_local"
|
||||
|
||||
# Any import whose first dotted segment is one of these is a GUI toolkit.
|
||||
QT_ROOTS = frozenset({"PySide6", "PySide2", "PyQt5", "PyQt6", "shiboken6", "shiboken2"})
|
||||
|
||||
# Per-layer rules: layer directory -> top-level package names it may not import.
|
||||
# Kept as a plain table so adding a layer later is a one-line change and the
|
||||
# rules stay readable next to the ADR they implement.
|
||||
LAYER_RULES: Dict[str, frozenset] = {
|
||||
# I1 + I2: domain is the innermost layer and depends on nothing but stdlib.
|
||||
"domain": frozenset({"application", "infrastructure", "presentation", "ui", "core"}),
|
||||
# I1 + I3: application may use domain, but never anything that draws pixels.
|
||||
"application": frozenset({"presentation", "ui"}),
|
||||
}
|
||||
|
||||
# Directories that are never production code and therefore never scanned.
|
||||
SKIP_DIRS = frozenset({".git", "__pycache__", ".pytest_cache", "tests", "build", "dist"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Violation:
|
||||
"""One forbidden import, carrying enough context to fix it without grepping."""
|
||||
|
||||
path: Path
|
||||
line: int
|
||||
imported: str
|
||||
rule: str
|
||||
|
||||
def render(self) -> str:
|
||||
"""Format as ``file:line: message`` — the shape editors turn into a
|
||||
clickable link, so a CI failure lands the developer on the exact line."""
|
||||
rel = self.path.relative_to(REPO_ROOT).as_posix()
|
||||
# ASCII-only on purpose: this line is printed to a console that may run a
|
||||
# legacy code page (cp932 on the team's Windows boxes), where a non-ASCII
|
||||
# dash raises UnicodeEncodeError and would crash the gate on the very
|
||||
# failure path it exists to report.
|
||||
return f"{rel}:{self.line}: imports '{self.imported}' - {self.rule}"
|
||||
|
||||
|
||||
def iter_python_files(layer_dir: Path) -> Iterable[Path]:
|
||||
"""Yield every production ``.py`` file under ``layer_dir``.
|
||||
|
||||
Test files are excluded on purpose: a test for a pure-Python service is
|
||||
allowed to import Qt (an integration test may need a headless widget), and
|
||||
holding tests to the production rule would push people to disable the gate.
|
||||
"""
|
||||
if not layer_dir.is_dir():
|
||||
return
|
||||
for path in sorted(layer_dir.rglob("*.py")):
|
||||
# Reject a path as soon as ANY of its parent folder names is skippable,
|
||||
# which also covers nested __pycache__ inside a sub-package.
|
||||
if any(part in SKIP_DIRS for part in path.parts):
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def module_parts(path: Path) -> List[str]:
|
||||
"""Dotted package path of ``path`` relative to the repo root, as a list.
|
||||
|
||||
``domain/agents/agent_event.py`` -> ``["domain", "agents", "agent_event"]``
|
||||
``domain/agents/__init__.py`` -> ``["domain", "agents"]``
|
||||
|
||||
Needed to resolve *relative* imports: ``from ..models import X`` inside
|
||||
``domain/agents/foo.py`` really means ``domain.models``, and only the file's
|
||||
own position tells us that.
|
||||
"""
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
parts = list(rel.parts)
|
||||
if parts[-1] == "__init__.py":
|
||||
parts.pop()
|
||||
else:
|
||||
parts[-1] = parts[-1][: -len(".py")]
|
||||
return parts
|
||||
|
||||
|
||||
def resolve_relative(parts: Sequence[str], level: int, module: str) -> str:
|
||||
"""Turn a relative import into the absolute top-level package it points at.
|
||||
|
||||
``level`` is the number of leading dots. Level 1 means "the package this
|
||||
module lives in", so we drop the module's own name plus ``level - 1``
|
||||
further parents. Returns the FIRST segment of the resolved path, because
|
||||
the rules are expressed in terms of top-level layers.
|
||||
|
||||
Walking off the top of the tree (more dots than there are parents) yields
|
||||
an empty string, which simply never matches a rule — a malformed import
|
||||
like that is a syntax/packaging problem, not an architecture violation.
|
||||
"""
|
||||
base = list(parts[:-1]) # the package containing this module
|
||||
if level > 1:
|
||||
drop = level - 1
|
||||
if drop > len(base):
|
||||
return ""
|
||||
base = base[: len(base) - drop]
|
||||
tail = module.split(".") if module else []
|
||||
resolved = base + tail
|
||||
return resolved[0] if resolved else ""
|
||||
|
||||
|
||||
def top_level(name: str) -> str:
|
||||
"""First dotted segment of an absolute import, with the distribution package
|
||||
prefix stripped so ``cowork_local.ui.chat_panel`` and ``ui.chat_panel`` are
|
||||
treated as the same dependency."""
|
||||
segments = name.split(".")
|
||||
if segments and segments[0] == PACKAGE_NAME:
|
||||
segments = segments[1:]
|
||||
return segments[0] if segments else ""
|
||||
|
||||
|
||||
def imported_roots(tree: ast.AST, parts: Sequence[str]) -> Iterable[Tuple[str, int, str]]:
|
||||
"""Yield ``(top_level_package, line_number, as_written)`` for every import.
|
||||
|
||||
``as_written`` is kept so the error message shows what the developer
|
||||
actually typed rather than the normalised root, which makes the violation
|
||||
obvious at a glance.
|
||||
|
||||
``ast.walk`` (not just the module body) is deliberate: this repo defers many
|
||||
heavy imports into function bodies to keep app start-up fast, and a
|
||||
function-local ``from PySide6 import QtWidgets`` breaks the layer exactly
|
||||
the same way a top-level one does.
|
||||
"""
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
yield top_level(alias.name), node.lineno, alias.name
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level:
|
||||
written = "." * node.level + (node.module or "")
|
||||
yield resolve_relative(parts, node.level, node.module or ""), node.lineno, written
|
||||
else:
|
||||
module = node.module or ""
|
||||
yield top_level(module), node.lineno, module
|
||||
|
||||
|
||||
def check_file(path: Path, layer: str, banned: frozenset) -> List[Violation]:
|
||||
"""Collect every rule violation in one file.
|
||||
|
||||
A file that cannot be parsed is reported as a violation rather than skipped:
|
||||
silently passing a file the checker could not read would make the gate lie.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
except (SyntaxError, UnicodeDecodeError) as exc:
|
||||
return [Violation(path, getattr(exc, "lineno", 0) or 0, "<unparseable>",
|
||||
f"cannot be parsed by the architecture guard ({exc})")]
|
||||
|
||||
parts = module_parts(path)
|
||||
out: List[Violation] = []
|
||||
for root, lineno, written in imported_roots(tree, parts):
|
||||
if root in QT_ROOTS:
|
||||
out.append(Violation(path, lineno, written,
|
||||
f"'{layer}/' must be 100% pure Python (ADR-001 I1)"))
|
||||
elif root in banned:
|
||||
out.append(Violation(path, lineno, written,
|
||||
f"'{layer}/' must not depend on '{root}/' (ADR-001 I2/I3)"))
|
||||
return out
|
||||
|
||||
|
||||
def run(layers: Sequence[str]) -> List[Violation]:
|
||||
"""Scan the requested layers and return every violation found, in file order."""
|
||||
found: List[Violation] = []
|
||||
for layer in layers:
|
||||
banned = LAYER_RULES[layer]
|
||||
for path in iter_python_files(REPO_ROOT / layer):
|
||||
found.extend(check_file(path, layer, banned))
|
||||
return found
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="CASAN Check 3 - Clean Architecture Guard (see ADR-001).")
|
||||
parser.add_argument(
|
||||
"layers", nargs="*", choices=sorted(LAYER_RULES) or None, default=None,
|
||||
help="Layers to scan (default: every layer with a rule).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
layers = args.layers or sorted(LAYER_RULES)
|
||||
|
||||
violations = run(layers)
|
||||
scanned = sum(1 for layer in layers for _ in iter_python_files(REPO_ROOT / layer))
|
||||
|
||||
if violations:
|
||||
print(f"FAIL - {len(violations)} architecture violation(s) in {scanned} file(s):\n")
|
||||
for v in violations:
|
||||
print(" " + v.render())
|
||||
# Point at the rationale instead of just the rule id, so someone hitting
|
||||
# this for the first time knows where the decision was made.
|
||||
print("\nSee docs/architecture/ADR-001-layered-architecture.md")
|
||||
return 1
|
||||
|
||||
print(f"PASS - 0 Qt imports in {', '.join(layers)} ({scanned} file(s) scanned)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
from .config import AppConfig
|
||||
from .infrastructure.mcp import McpToolSourceManager
|
||||
|
||||
|
||||
def resolve_agent_default(
|
||||
@@ -38,21 +39,30 @@ class AppContext:
|
||||
def __init__(self, config: AppConfig):
|
||||
self.config = config
|
||||
self.started_at = time.time() # for Monitoring's Sandbox Details "Created"/"Uptime"
|
||||
self._mcp_connections: dict = {} # server name -> McpServerConnection
|
||||
# Admin-configured MCP servers + the built-in MS365 server (R05-T05):
|
||||
# connection caching/lifecycle (check-then-create, restart, shutdown)
|
||||
# now lives in McpToolSourceManager, extracted so it is testable
|
||||
# without an AppContext/Qt. See its docstring for why the check-then-
|
||||
# create race matters — several turns (multiple Cowork tabs, parallel
|
||||
# Co4E flows, scheduled tasks) can call build_mcp_tools() at once.
|
||||
self._mcp_manager = McpToolSourceManager()
|
||||
self._ext_connections: dict = {} # connector id -> McpServerConnection (mcp_stdio mode only)
|
||||
# Guards the two connection caches above. build_mcp_tools() runs on EVERY
|
||||
# chat turn's own AgentWorker thread, so several turns (multiple Cowork
|
||||
# tabs, parallel Co4E flows, scheduled tasks) can enter it at once. The
|
||||
# cache is populated check-then-create ("conn is None → spawn → store");
|
||||
# without this lock two concurrent turns both see None and each spawns a
|
||||
# subprocess for the SAME server — one leaks as an orphan and the wrong
|
||||
# object may be handed out. The lock makes connection setup atomic; the
|
||||
# provider/HTTP path itself is already thread-safe (a fresh provider per
|
||||
# call, module-level `requests`, MCP calls multiplexed on the server's
|
||||
# own event loop), so concurrent model calls never needed serializing.
|
||||
# Guards ``_ext_connections`` only now — unified Connectors (CAD/CAE/
|
||||
# MS365/Other) aren't covered by McpToolSourceManager (R05-T05 scoped
|
||||
# to MCP servers), so this cache still needs its own check-then-create
|
||||
# lock, the same race McpToolSourceManager guards against internally.
|
||||
self._conn_lock = threading.Lock()
|
||||
self._routing_service = None # lazy RoutingService (Auto Model Routing)
|
||||
# Lazy RoutingApplicationService (R03-T03) — the Qt-free decision layer
|
||||
# every chat surface now routes through. Wraps _routing_service, which
|
||||
# stays the scoring/ranking engine underneath.
|
||||
self._routing_application = None
|
||||
self._routing_lock = threading.Lock()
|
||||
# A SEPARATE lock for the application service: building it calls
|
||||
# routing(), which takes _routing_lock. threading.Lock is not
|
||||
# reentrant, so sharing one lock across both accessors deadlocks the
|
||||
# first caller instead of just serialising them.
|
||||
self._routing_app_lock = threading.Lock()
|
||||
# The workspace (project) currently selected in the Workspace screen.
|
||||
# Per-workspace modes (routing + auto-run) resolve against THIS project
|
||||
# so each workspace keeps its own modes. Updated by WorkspaceTab on
|
||||
@@ -79,16 +89,28 @@ class AppContext:
|
||||
workspace keep its own routing mode."""
|
||||
project = self._current_project()
|
||||
if project is not None:
|
||||
# Validated through the single mode vocabulary (R03-T03) rather
|
||||
# than a literal tuple, so a workspace can store any mode the
|
||||
# routing service understands - including "fallback", whose
|
||||
# on-screen toggle arrives in EPIC R08.
|
||||
from .application.model_routing import is_valid_mode, normalize_mode
|
||||
|
||||
mode = (project.routing_modes or {}).get(surface, "")
|
||||
if mode in ("off", "auto", "manual"):
|
||||
return mode
|
||||
# Only a RECOGNISED override wins; an empty or corrupt value falls
|
||||
# through to the global setting, exactly as before. Validation goes
|
||||
# through the routing vocabulary (R03-T03) instead of a literal
|
||||
# tuple, so a new mode works everywhere the moment it is defined.
|
||||
if is_valid_mode(mode):
|
||||
return normalize_mode(mode)
|
||||
return self.config.routing_mode_for(surface)
|
||||
|
||||
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
||||
workspace selected, falls back to the global setting so behaviour
|
||||
outside a project stays global."""
|
||||
mode = mode if mode in ("off", "auto", "manual") else "off"
|
||||
from .application.model_routing import normalize_mode
|
||||
|
||||
mode = normalize_mode(mode)
|
||||
project = self._current_project()
|
||||
if project is None:
|
||||
self.config.set_routing_mode_for(surface, mode)
|
||||
@@ -144,6 +166,34 @@ class AppContext:
|
||||
self._routing_service = RoutingService(self)
|
||||
return self._routing_service
|
||||
|
||||
def routing_application(self):
|
||||
"""The shared :class:`RoutingApplicationService` (R03-T03).
|
||||
|
||||
This is what UI code should call: it owns the Off/Auto/Manual/Fallback
|
||||
policy, the confirm handshake and the never-raise guarantee, while
|
||||
:meth:`routing` remains the scoring engine underneath. Chat, Co4E and
|
||||
AI-Edit all go through this one object, so a change to routing policy is
|
||||
made once instead of three times.
|
||||
|
||||
Built lazily and memoised for the same reason as :meth:`routing`: the
|
||||
pending-switch registry and assessment store must be shared app-wide."""
|
||||
if self._routing_application is None:
|
||||
# Resolve the engine BEFORE taking this lock: routing() takes
|
||||
# _routing_lock, and nesting the two acquisitions is what makes the
|
||||
# ordering fragile in the first place.
|
||||
engine = self.routing()
|
||||
with self._routing_app_lock:
|
||||
if self._routing_application is None:
|
||||
from .application.model_routing import RoutingApplicationService
|
||||
|
||||
self._routing_application = RoutingApplicationService(
|
||||
engine,
|
||||
# Per-workspace mode lookup, so each workspace keeps its
|
||||
# own routing behaviour (see project_routing_mode).
|
||||
mode_reader=self.project_routing_mode,
|
||||
)
|
||||
return self._routing_application
|
||||
|
||||
def build_active_provider(self):
|
||||
"""Construct the currently selected provider (called inside workers)."""
|
||||
return self.build_provider_for(self.config.active_provider)
|
||||
@@ -188,48 +238,41 @@ class AppContext:
|
||||
if not self.config.connect_external:
|
||||
return [], None
|
||||
from .core.ext_connectors import build_ext_connector_tools
|
||||
from .core.mcp_client import McpServerConnection
|
||||
from .core.mcp_client import build_mcp_tools as _merge_mcp_tools
|
||||
from .core.tools import combine_tool_sources
|
||||
|
||||
# Serialize the check-then-create against the connection caches so
|
||||
# concurrent turns share one subprocess per server instead of racing to
|
||||
# spawn duplicates (see _conn_lock in __init__). The lock is held while
|
||||
# connections are established (a one-time cost per server per app run);
|
||||
# once warm, every turn just finds the cached connection and returns.
|
||||
with self._conn_lock:
|
||||
active = []
|
||||
for entry in self.config.mcp_servers:
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
name = entry.get("name", "")
|
||||
command = entry.get("command", "")
|
||||
if not name or not command:
|
||||
continue
|
||||
conn = self._mcp_connections.get(name)
|
||||
if conn is None:
|
||||
conn = McpServerConnection(name, command, entry.get("args") or [],
|
||||
entry.get("env") or None)
|
||||
try:
|
||||
conn.start()
|
||||
except Exception: # noqa: BLE001 - one broken server must not block the turn
|
||||
continue
|
||||
self._mcp_connections[name] = conn
|
||||
# R05-T05: connection caching/check-then-create for admin-configured
|
||||
# servers + the MS365 builtin now lives in McpToolSourceManager (its
|
||||
# own lock guards the race — see its docstring).
|
||||
active = []
|
||||
for entry in self.config.mcp_servers:
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
name = entry.get("name", "")
|
||||
command = entry.get("command", "")
|
||||
if not name or not command:
|
||||
continue
|
||||
conn = self._mcp_manager.ensure(name, command, entry.get("args") or [],
|
||||
entry.get("env") or None)
|
||||
if conn is not None:
|
||||
active.append(conn)
|
||||
builtin = self._ms365_builtin_connection(skip={c.name for c in active})
|
||||
if builtin is not None:
|
||||
active.append(builtin)
|
||||
mcp_tools, mcp_executor = _merge_mcp_tools(active)
|
||||
builtin = self._ms365_builtin_connection(skip={c.name for c in active})
|
||||
if builtin is not None:
|
||||
active.append(builtin)
|
||||
mcp_tools, mcp_executor = _merge_mcp_tools(active)
|
||||
|
||||
# ``_ext_connections`` isn't covered by McpToolSourceManager (T05
|
||||
# scoped to MCP servers) — still serialized under ``_conn_lock``.
|
||||
with self._conn_lock:
|
||||
ext = self.config.ext_connectors
|
||||
all_connectors = [*ext.get("cad", []), *ext.get("cae", []),
|
||||
*ext.get("ms365", []), *ext.get("other", [])]
|
||||
ext_tools, ext_executor = build_ext_connector_tools(all_connectors, self._ext_connections)
|
||||
|
||||
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
|
||||
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
local_tools, local_executor = build_ms365_local_tools(self.config)
|
||||
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
|
||||
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
local_tools, local_executor = build_ms365_local_tools(self.config)
|
||||
|
||||
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
|
||||
(local_tools, local_executor))
|
||||
@@ -259,36 +302,20 @@ class AppContext:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .core.mcp_client import McpServerConnection
|
||||
|
||||
name = self._MS365_BUILTIN
|
||||
if name in skip:
|
||||
return None
|
||||
if not self._ms365_available():
|
||||
stale = self._mcp_connections.pop(name, None)
|
||||
if stale is not None:
|
||||
try:
|
||||
stale.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._mcp_manager.stop(name)
|
||||
return None
|
||||
conn = self._mcp_connections.get(name)
|
||||
if conn is None:
|
||||
# The subprocess must import cowork_local even in a from-source run
|
||||
# (PYTHONPATH=src) — prepend this package's parent dir explicitly.
|
||||
env = dict(os.environ)
|
||||
src_root = str(Path(__file__).resolve().parent.parent)
|
||||
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
|
||||
if env.get("PYTHONPATH") else src_root)
|
||||
conn = McpServerConnection(
|
||||
name, sys.executable,
|
||||
["-m", "cowork_local.mcp_servers.ms365_server"], env)
|
||||
try:
|
||||
conn.start()
|
||||
except Exception: # noqa: BLE001 - MS365 down must not block the turn
|
||||
return None
|
||||
self._mcp_connections[name] = conn
|
||||
return conn
|
||||
# The subprocess must import cowork_local even in a from-source run
|
||||
# (PYTHONPATH=src) — prepend this package's parent dir explicitly.
|
||||
env = dict(os.environ)
|
||||
src_root = str(Path(__file__).resolve().parent.parent)
|
||||
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
|
||||
if env.get("PYTHONPATH") else src_root)
|
||||
return self._mcp_manager.ensure(
|
||||
name, sys.executable, ["-m", "cowork_local.mcp_servers.ms365_server"], env)
|
||||
|
||||
def stop_mcp_connections(self) -> None:
|
||||
"""Terminate every connected MCP server's subprocess (incl. External
|
||||
@@ -296,11 +323,6 @@ class AppContext:
|
||||
them linger as orphan processes."""
|
||||
from .core.ext_connectors import stop_ext_connections
|
||||
|
||||
self._mcp_manager.stop_all()
|
||||
with self._conn_lock:
|
||||
for conn in self._mcp_connections.values():
|
||||
try:
|
||||
conn.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._mcp_connections.clear()
|
||||
stop_ext_connections(self._ext_connections)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Characterization tests: pin the CURRENT behaviour of legacy code (R01-T04).
|
||||
|
||||
These are not specifications of what the code *should* do - they are a snapshot
|
||||
of what it *does* today, written before the refactor so that any behavioural
|
||||
drift introduced while moving logic into ``application/`` shows up as a failing
|
||||
test rather than as a bug report from a user.
|
||||
|
||||
Rule for this folder: when a test here fails during the refactor, do not "fix"
|
||||
the test first. Decide deliberately whether the behaviour change is intended,
|
||||
and only then update the snapshot in the same commit as the change.
|
||||
"""
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Characterization snapshot of ``core.chat_agent.run_cowork`` (R01-T04).
|
||||
|
||||
``run_cowork`` is the turn engine every Cowork surface funnels through (chat tab,
|
||||
Co4E flow steps, Schedule Task runs). EPIC R04 moves its orchestration into
|
||||
``application/conversations/conversation_application_service.py``; these tests
|
||||
lock down the observable contract BEFORE that move so the new service can be
|
||||
proven equivalent:
|
||||
|
||||
* which system prompt ends up in ``messages``
|
||||
* which tools are advertised to the provider
|
||||
* the exact ``emit`` event sequence for a plain turn and for a tool turn
|
||||
* that ``save_file`` produces a real file in the turn's output folder
|
||||
* that ``cancel`` stops the loop without calling the provider
|
||||
|
||||
Everything runs offline: :class:`FakeProvider` replaces the network and the two
|
||||
disk-backed prompt sources (skills, security rules) are stubbed to empty so the
|
||||
snapshot does not depend on the developer's own ``~/.cowork_local`` contents.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core import chat_agent
|
||||
from tests.fakes import FakeProvider, FakeToolExecutor, ScriptedTurn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_agent(monkeypatch, tmp_path: Path):
|
||||
"""Neutralise every ambient input ``run_cowork`` reads from the machine.
|
||||
|
||||
Without this the snapshot would silently depend on whichever skills and
|
||||
security rules the developer happens to have enabled locally, and on the
|
||||
real audit log under ``~/.cowork_local`` - the test would then pass on one
|
||||
laptop and fail on another for reasons unrelated to the code under test.
|
||||
"""
|
||||
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
||||
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
||||
# audit_log is imported lazily inside run_cowork, so patch the module's own
|
||||
# target directory rather than the name chat_agent sees.
|
||||
from cowork_local.core import audit_log
|
||||
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _run(provider, messages, out_dir: Path, **kwargs):
|
||||
"""Run one turn and return ``(returned_messages, emitted_events)``."""
|
||||
events: List[Dict[str, Any]] = []
|
||||
result = chat_agent.run_cowork(provider, messages, out_dir, events.append, **kwargs)
|
||||
return result, events
|
||||
|
||||
|
||||
def _types(events: List[Dict[str, Any]]) -> List[str]:
|
||||
"""Event ``type`` values in order - the shape assertions read on."""
|
||||
return [e.get("type") for e in events]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# A plain answer with no tool calls
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_plain_turn_streams_text_and_appends_assistant_message(isolated_agent):
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([ScriptedTurn(text="Hello there.")])
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
|
||||
|
||||
result, events = _run(provider, messages, out_dir)
|
||||
|
||||
# The loop ends as soon as the model stops calling tools: exactly one call.
|
||||
assert provider.call_count == 1
|
||||
# run_cowork mutates and returns the SAME list the caller passed in - callers
|
||||
# (ui/cowork_tab.py::build_job) rely on this to persist conversation history.
|
||||
assert result is messages
|
||||
assert result[-1]["role"] == "assistant"
|
||||
assert result[-1]["content"] == "Hello there."
|
||||
assert _types(events) == ["text", "assistant_done"]
|
||||
assert events[0]["delta"] == "Hello there."
|
||||
assert events[-1]["content"] == "Hello there."
|
||||
|
||||
|
||||
def test_system_prompt_is_inserted_once_at_the_front(isolated_agent):
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
|
||||
|
||||
result, _ = _run(provider, messages, out_dir)
|
||||
|
||||
assert result[0]["role"] == "system"
|
||||
assert result[0]["content"].startswith("You are Cowork Local")
|
||||
# Exactly one system message: a second turn on the same conversation must not
|
||||
# stack another copy of the prompt (that would grow the context every turn).
|
||||
assert sum(1 for m in result if m.get("role") == "system") == 1
|
||||
|
||||
|
||||
def test_caller_supplied_system_prompt_is_preserved(isolated_agent):
|
||||
"""A caller that already put a system message first keeps its own prompt.
|
||||
|
||||
Co4E flow steps depend on this to give a step its own persona instead of the
|
||||
generic Cowork prompt.
|
||||
"""
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": "CUSTOM PERSONA"},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
|
||||
result, _ = _run(provider, messages, out_dir)
|
||||
|
||||
assert result[0]["content"] == "CUSTOM PERSONA"
|
||||
|
||||
|
||||
def test_reasoning_is_emitted_separately_and_never_joins_the_answer(isolated_agent):
|
||||
"""Reasoning drives the "Thinking" indicator only - it must not become part
|
||||
of the assistant's content, otherwise a reasoning model's private chain of
|
||||
thought would be persisted into conversation history."""
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([ScriptedTurn(text="42", reasoning="let me think...")])
|
||||
|
||||
result, events = _run(provider, [{"role": "user", "content": "q"}], out_dir)
|
||||
|
||||
assert _types(events) == ["reasoning", "text", "assistant_done"]
|
||||
assert result[-1]["content"] == "42"
|
||||
assert "let me think" not in result[-1]["content"]
|
||||
|
||||
|
||||
def test_reasoning_only_reply_gets_a_placeholder_answer(isolated_agent):
|
||||
"""A model that returns only reasoning must not end the turn on a blank
|
||||
bubble - headless callers (Schedule Task) read this content back as the
|
||||
run's final answer and would otherwise write "(no output)"."""
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([ScriptedTurn(text="", reasoning="thinking")])
|
||||
|
||||
result, events = _run(provider, [{"role": "user", "content": "q"}], out_dir)
|
||||
|
||||
assert result[-1]["content"].startswith("*(model returned only its reasoning")
|
||||
assert "text" in _types(events)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tool advertising
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_save_file_and_update_plan_are_always_advertised(isolated_agent):
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
_run(provider, [{"role": "user", "content": "hi"}], out_dir)
|
||||
|
||||
advertised = provider.calls[0].tool_names
|
||||
assert "save_file" in advertised
|
||||
assert "update_plan" in advertised
|
||||
|
||||
|
||||
def test_allowed_tools_scopes_the_catalogue_but_keeps_update_plan(isolated_agent):
|
||||
"""``allowed_tools`` is the permission scope Co4E steps use: a read-only step
|
||||
must literally not be offered a writing tool. ``update_plan`` survives the
|
||||
filter because it has no side effects."""
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
_run(provider, [{"role": "user", "content": "hi"}], out_dir,
|
||||
allowed_tools=["read_file"])
|
||||
|
||||
advertised = set(provider.calls[0].tool_names)
|
||||
assert "save_file" not in advertised
|
||||
assert "update_plan" in advertised
|
||||
|
||||
|
||||
def test_extra_tools_are_advertised_alongside_built_ins(isolated_agent):
|
||||
out_dir = isolated_agent / "out"
|
||||
executor = FakeToolExecutor(results={"ms365_send_mail": {"output": "sent"}})
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
_run(provider, [{"role": "user", "content": "hi"}], out_dir,
|
||||
extra_tools=executor.specs(), extra_executor=executor)
|
||||
|
||||
assert "ms365_send_mail" in provider.calls[0].tool_names
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tool execution
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_save_file_writes_a_real_file_and_reports_it(isolated_agent):
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "note.md",
|
||||
"content": "# Result\n"})]),
|
||||
ScriptedTurn(text="Done."),
|
||||
])
|
||||
|
||||
result, events = _run(provider, [{"role": "user", "content": "make a note"}], out_dir)
|
||||
|
||||
written = [p for p in out_dir.iterdir() if p.is_file()]
|
||||
assert len(written) == 1
|
||||
assert written[0].read_text(encoding="utf-8") == "# Result\n"
|
||||
|
||||
assert _types(events) == [
|
||||
"assistant_done", # first turn: tool call only, no visible text
|
||||
"tool_proposed", # the diff preview shown in the chat
|
||||
"tool_result",
|
||||
"text", # second turn's answer
|
||||
"assistant_done",
|
||||
]
|
||||
assert events[2]["ok"] is True
|
||||
|
||||
# The tool result is fed back as a `tool` message so the model can react to it.
|
||||
roles = [m["role"] for m in result]
|
||||
assert roles == ["system", "user", "assistant", "tool", "assistant"]
|
||||
assert result[3]["name"] == "save_file"
|
||||
|
||||
|
||||
def test_extra_tool_calls_are_routed_to_the_extra_executor(isolated_agent):
|
||||
"""MCP / Microsoft 365 tools bypass the built-in file+command handlers and go
|
||||
to the caller-supplied executor instead."""
|
||||
out_dir = isolated_agent / "out"
|
||||
executor = FakeToolExecutor(results={"ms365_send_mail": {"ok": True, "output": "sent"}})
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("ms365_send_mail", {"to": "a@b.c"})]),
|
||||
ScriptedTurn(text="Mail sent."),
|
||||
])
|
||||
|
||||
result, events = _run(provider, [{"role": "user", "content": "mail them"}], out_dir,
|
||||
extra_tools=executor.specs(), extra_executor=executor)
|
||||
|
||||
assert executor.call_names == ["ms365_send_mail"]
|
||||
assert executor.args_for("ms365_send_mail") == [{"to": "a@b.c"}]
|
||||
assert [e for e in events if e["type"] == "tool_result"][0]["output"] == "sent"
|
||||
assert result[3] == {"role": "tool", "tool_call_id": result[3]["tool_call_id"],
|
||||
"name": "ms365_send_mail", "content": "sent"}
|
||||
|
||||
|
||||
def test_update_plan_drives_the_plan_panel_without_producing_a_file(isolated_agent):
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("update_plan", {"steps": [{"title": "step one"}]})]),
|
||||
ScriptedTurn(text="Planned."),
|
||||
])
|
||||
|
||||
result, events = _run(provider, [{"role": "user", "content": "plan it"}], out_dir)
|
||||
|
||||
plan_events = [e for e in events if e["type"] == "plan_set"]
|
||||
assert len(plan_events) == 1
|
||||
assert plan_events[0]["steps"]
|
||||
# No tool_proposed/tool_result bubbles for a plan update, and no file on disk.
|
||||
assert "tool_proposed" not in _types(events)
|
||||
assert list(out_dir.iterdir()) == []
|
||||
assert result[3]["content"] == "Plan updated."
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cancellation
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_cancel_before_the_first_step_never_calls_the_provider(isolated_agent):
|
||||
"""Stop pressed before the loop starts must cost zero tokens."""
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([], strict=True)
|
||||
|
||||
result, events = _run(provider, [{"role": "user", "content": "hi"}], out_dir,
|
||||
cancel=lambda: True)
|
||||
|
||||
assert provider.call_count == 0
|
||||
assert _types(events) == []
|
||||
# The system prompt is still installed, so the conversation stays well-formed
|
||||
# for a later retry on the same message list.
|
||||
assert result[0]["role"] == "system"
|
||||
|
||||
|
||||
def test_cancel_between_steps_stops_before_the_next_provider_call(isolated_agent):
|
||||
"""After a tool call runs, a Stop must end the turn instead of paying for
|
||||
another round trip."""
|
||||
out_dir = isolated_agent / "out"
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "x"})]),
|
||||
])
|
||||
calls = {"n": 0}
|
||||
|
||||
def cancel() -> bool:
|
||||
# False on the first check (loop entry), True afterwards - i.e. the user
|
||||
# pressed Stop while the first step was running.
|
||||
calls["n"] += 1
|
||||
return calls["n"] > 1
|
||||
|
||||
result, _ = _run(provider, [{"role": "user", "content": "hi"}], out_dir, cancel=cancel)
|
||||
|
||||
assert provider.call_count == 1
|
||||
assert result[-1]["role"] in {"assistant", "tool"}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Root pytest configuration: bind ``cowork_local`` to THIS checkout (R01-T02).
|
||||
|
||||
Why this file exists
|
||||
--------------------
|
||||
The package directory is itself the distribution package (``__init__.py`` sits
|
||||
at the repo root), so ``import cowork_local`` only resolves when the checkout
|
||||
folder happens to be named exactly ``cowork_local``. It frequently is not — this
|
||||
one is checked out as ``cowork_local_gitea``, and developers keep several dated
|
||||
copies side by side (``cowork_local``, ``cowork_local_20260722``, ...).
|
||||
|
||||
Left alone, ``sys.path``-based discovery would import whichever *sibling* folder
|
||||
is named ``cowork_local`` and the whole suite would silently test a DIFFERENT
|
||||
checkout: green here, broken in the branch under review. That is the worst kind
|
||||
of test failure, because it fails to fail.
|
||||
|
||||
So instead of relying on the folder name, we load ``__init__.py`` by absolute
|
||||
path and register the result in ``sys.modules`` under the canonical name before
|
||||
any test imports it. Submodules (``cowork_local.providers.base``, ...) then
|
||||
resolve through this package's own ``__path__``, i.e. always this checkout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# .../<checkout>/tests/conftest.py -> .../<checkout>
|
||||
_PKG_DIR = Path(__file__).resolve().parents[1]
|
||||
_PKG_NAME = "cowork_local"
|
||||
|
||||
|
||||
def _bind_package_to_this_checkout() -> None:
|
||||
"""Make ``import cowork_local`` mean this directory, whatever it is named.
|
||||
|
||||
A no-op when the correct package object is already bound, so running the
|
||||
suite from a folder that IS named ``cowork_local`` costs nothing and the
|
||||
hook stays idempotent across repeated conftest collection.
|
||||
"""
|
||||
existing = sys.modules.get(_PKG_NAME)
|
||||
existing_file = getattr(existing, "__file__", None)
|
||||
if existing_file and Path(existing_file).resolve().parent == _PKG_DIR:
|
||||
return # already the right one
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
_PKG_NAME,
|
||||
_PKG_DIR / "__init__.py",
|
||||
# Setting the search locations is what makes dotted submodule imports
|
||||
# (cowork_local.core.*, cowork_local.providers.*) resolve inside THIS
|
||||
# directory rather than through sys.path.
|
||||
submodule_search_locations=[str(_PKG_DIR)],
|
||||
)
|
||||
if spec is None or spec.loader is None: # pragma: no cover - packaging error
|
||||
raise RuntimeError(f"cannot load {_PKG_NAME} from {_PKG_DIR}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Registered BEFORE exec_module so that a self-referential import inside
|
||||
# __init__.py would find the partially-initialised module instead of
|
||||
# recursing - the same protocol CPython's own import machinery follows.
|
||||
sys.modules[_PKG_NAME] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
_bind_package_to_this_checkout()
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Contract tests: one shared behaviour suite every implementation must satisfy.
|
||||
|
||||
Unlike unit tests (which test one module in isolation) a contract test is
|
||||
parametrised over EVERY implementation of an interface, so a newly added
|
||||
provider either satisfies the same promises as the existing ones or the suite
|
||||
goes red on the day it is added - not months later, in production, on the one
|
||||
code path that assumed the promise held.
|
||||
"""
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Provider contract suite (R03-T01).
|
||||
|
||||
Every provider - the two real adapters and the test double - must honour the
|
||||
same promises declared in ``providers/base.py``:
|
||||
|
||||
1. ``chat()`` returns the canonical assistant message
|
||||
``{"role": "assistant", "content": str, "tool_calls": [...]}``.
|
||||
2. Answer text is streamed through ``on_text`` and equals the returned content.
|
||||
3. Private reasoning goes to ``on_reasoning`` ONLY - it must never leak into the
|
||||
answer, or a reasoning model's chain of thought ends up persisted in history.
|
||||
4. Tool calls come back as ``{"id", "name", "arguments": dict}`` with arguments
|
||||
already parsed - callers must never have to json.loads() them.
|
||||
5. A failure raises ``ProviderError`` and nothing else, so one except clause in
|
||||
the agent loop covers every provider.
|
||||
|
||||
The real adapters are exercised WITHOUT network access by replacing
|
||||
``Provider._request`` with a canned SSE response - which is exactly the seam
|
||||
``providers/base.py`` documents for its TLS retry, so no production code needed
|
||||
changing to make this testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.domain.models.provider_descriptor import ProviderCapability
|
||||
from cowork_local.infrastructure.providers.provider_registry import (
|
||||
BUILT_IN_PROVIDERS,
|
||||
ProviderRegistry,
|
||||
)
|
||||
from cowork_local.providers.anthropic import AnthropicProvider
|
||||
from cowork_local.providers.base import Provider, ProviderError, ToolSpec
|
||||
from cowork_local.providers.openai_compat import OpenAICompatProvider
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
class _StubResponse:
|
||||
"""Minimal stand-in for a streamed ``requests.Response``.
|
||||
|
||||
Only the members the provider code actually touches are implemented; adding
|
||||
more would invite tests that pass against the stub but not against requests.
|
||||
"""
|
||||
|
||||
def __init__(self, lines: List[str], status_code: int = 200, text: str = "") -> None:
|
||||
self._lines = lines
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.headers: Dict[str, str] = {}
|
||||
self.encoding = "utf-8"
|
||||
self.closed = False
|
||||
|
||||
def iter_lines(self, decode_unicode: bool = False):
|
||||
yield from self._lines
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def json(self) -> Any:
|
||||
return json.loads(self.text or "{}")
|
||||
|
||||
|
||||
def _sse(*payloads: Dict[str, Any]) -> List[str]:
|
||||
"""Render payloads as SSE ``data:`` lines, the wire shape both adapters parse."""
|
||||
return [f"data: {json.dumps(p)}" for p in payloads]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canned(monkeypatch):
|
||||
"""Return a helper that makes every provider request answer with ``lines``."""
|
||||
|
||||
def _install(lines: List[str], status_code: int = 200, text: str = "") -> Dict[str, Any]:
|
||||
seen: Dict[str, Any] = {}
|
||||
|
||||
def fake_request(self, method, url, **kwargs):
|
||||
# Capture the outgoing payload so tests can assert on how the
|
||||
# canonical message list was translated to the provider's wire format.
|
||||
seen["method"] = method
|
||||
seen["url"] = url
|
||||
seen["json"] = kwargs.get("json")
|
||||
return _StubResponse(lines, status_code=status_code, text=text)
|
||||
|
||||
monkeypatch.setattr(Provider, "_request", fake_request, raising=True)
|
||||
return seen
|
||||
|
||||
return _install
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Shared base-class behaviour every provider inherits
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _providers_under_test() -> List[Provider]:
|
||||
"""One instance of each implementation, configured but never called."""
|
||||
conf = {"base_url": "https://example.invalid/v1", "api_key": "k", "model": "m"}
|
||||
return [
|
||||
OpenAICompatProvider(dict(conf)),
|
||||
AnthropicProvider(dict(conf)),
|
||||
FakeProvider(),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", _providers_under_test(), ids=lambda p: type(p).__name__)
|
||||
def test_every_provider_exposes_the_base_contract(provider):
|
||||
assert isinstance(provider, Provider)
|
||||
assert callable(provider.chat)
|
||||
assert callable(provider.list_models)
|
||||
assert callable(provider.test_connection)
|
||||
# `name` identifies the provider in usage records and audit entries; an
|
||||
# implementation that forgot to set it would silently report as "base".
|
||||
assert provider.name and provider.name != "base"
|
||||
assert isinstance(provider.supports_vision, bool)
|
||||
assert provider.describe() == f"{provider.name}:{provider.model}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", _providers_under_test(), ids=lambda p: type(p).__name__)
|
||||
def test_strip_think_removes_inline_reasoning_from_a_final_answer(provider):
|
||||
"""Safety net for gateways that fold reasoning into the content stream: the
|
||||
answer stored in history must never contain a <think> block."""
|
||||
assert provider.strip_think("<think>secret</think>Answer") == "Answer"
|
||||
assert provider.strip_think("Plain answer") == "Plain answer"
|
||||
|
||||
|
||||
def test_tool_spec_translates_to_both_wire_formats():
|
||||
"""One ToolSpec must render for both protocols - this is what lets the agent
|
||||
loop build its tool catalogue once and reuse it across providers."""
|
||||
spec = ToolSpec(name="save_file", description="Write a file",
|
||||
parameters={"type": "object", "properties": {}})
|
||||
|
||||
openai_shape = spec.to_openai()
|
||||
anthropic_shape = spec.to_anthropic()
|
||||
|
||||
assert openai_shape["type"] == "function"
|
||||
assert openai_shape["function"]["name"] == "save_file"
|
||||
assert openai_shape["function"]["parameters"] == spec.parameters
|
||||
# Anthropic names the same field `input_schema`; the values must stay equal,
|
||||
# otherwise the same tool would validate differently per provider.
|
||||
assert anthropic_shape["name"] == "save_file"
|
||||
assert anthropic_shape["input_schema"] == spec.parameters
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Streaming contract - real adapters, canned transport
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_openai_compat_streams_text_and_returns_canonical_message(canned):
|
||||
canned(_sse(
|
||||
{"choices": [{"delta": {"content": "Hel"}}]},
|
||||
{"choices": [{"delta": {"content": "lo"}}]},
|
||||
) + ["data: [DONE]"])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
chunks: List[str] = []
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "hi"}], on_text=chunks.append)
|
||||
|
||||
assert "".join(chunks) == "Hello"
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == "Hello"
|
||||
assert result["tool_calls"] == []
|
||||
|
||||
|
||||
def test_openai_compat_keeps_reasoning_out_of_the_answer(canned):
|
||||
canned(_sse(
|
||||
{"choices": [{"delta": {"reasoning_content": "hmm..."}}]},
|
||||
{"choices": [{"delta": {"content": "42"}}]},
|
||||
) + ["data: [DONE]"])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
text: List[str] = []
|
||||
reasoning: List[str] = []
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "q"}],
|
||||
on_text=text.append, on_reasoning=reasoning.append)
|
||||
|
||||
assert reasoning == ["hmm..."]
|
||||
assert result["content"] == "42"
|
||||
assert "hmm" not in result["content"]
|
||||
|
||||
|
||||
def test_openai_compat_returns_tool_calls_with_parsed_arguments(canned):
|
||||
"""Arguments arrive as a JSON string split across chunks; the contract says
|
||||
the caller receives a ready-to-use dict."""
|
||||
canned(_sse(
|
||||
{"choices": [{"delta": {"tool_calls": [
|
||||
{"index": 0, "id": "call_a", "function": {"name": "save_file",
|
||||
"arguments": '{"filename":'}}]}}]},
|
||||
{"choices": [{"delta": {"tool_calls": [
|
||||
{"index": 0, "function": {"arguments": '"a.md"}'}}]}}]},
|
||||
) + ["data: [DONE]"])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "save it"}])
|
||||
|
||||
assert len(result["tool_calls"]) == 1
|
||||
call = result["tool_calls"][0]
|
||||
assert call["id"] == "call_a"
|
||||
assert call["name"] == "save_file"
|
||||
assert call["arguments"] == {"filename": "a.md"}
|
||||
|
||||
|
||||
def test_anthropic_streams_text_and_returns_canonical_message(canned):
|
||||
canned(_sse(
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hel"}},
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "lo"}},
|
||||
{"type": "message_stop"},
|
||||
))
|
||||
provider = AnthropicProvider({"base_url": "https://x.invalid",
|
||||
"api_key": "k", "model": "m"})
|
||||
chunks: List[str] = []
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "hi"}], on_text=chunks.append)
|
||||
|
||||
assert "".join(chunks) == "Hello"
|
||||
assert result["content"] == "Hello"
|
||||
assert result["role"] == "assistant"
|
||||
|
||||
|
||||
def test_anthropic_keeps_extended_thinking_out_of_the_answer(canned):
|
||||
canned(_sse(
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "thinking_delta", "thinking": "reasoning..."}},
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "42"}},
|
||||
{"type": "message_stop"},
|
||||
))
|
||||
provider = AnthropicProvider({"base_url": "https://x.invalid",
|
||||
"api_key": "k", "model": "m"})
|
||||
reasoning: List[str] = []
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "q"}], on_reasoning=reasoning.append)
|
||||
|
||||
assert reasoning == ["reasoning..."]
|
||||
assert result["content"] == "42"
|
||||
|
||||
|
||||
def test_anthropic_returns_tool_calls_with_parsed_arguments(canned):
|
||||
canned(_sse(
|
||||
{"type": "content_block_start", "index": 0,
|
||||
"content_block": {"type": "tool_use", "id": "toolu_1", "name": "save_file"}},
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"filename":"a.md"}'}},
|
||||
{"type": "message_stop"},
|
||||
))
|
||||
provider = AnthropicProvider({"base_url": "https://x.invalid",
|
||||
"api_key": "k", "model": "m"})
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "save"}])
|
||||
|
||||
assert result["tool_calls"] == [
|
||||
{"id": "toolu_1", "name": "save_file", "arguments": {"filename": "a.md"}}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("factory", [
|
||||
lambda: OpenAICompatProvider({"base_url": "https://x.invalid/v1", "api_key": "k", "model": "m"}),
|
||||
lambda: AnthropicProvider({"base_url": "https://x.invalid", "api_key": "k", "model": "m"}),
|
||||
], ids=["openai_compat", "anthropic"])
|
||||
def test_transport_failure_surfaces_as_provider_error(canned, factory):
|
||||
"""Every failure mode must arrive as ProviderError so the agent loop needs
|
||||
exactly one except clause, whichever provider is active."""
|
||||
canned([], status_code=500, text="boom")
|
||||
|
||||
with pytest.raises(ProviderError):
|
||||
factory().chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
|
||||
def test_fake_provider_satisfies_the_same_streaming_contract():
|
||||
"""The double is only useful as a stand-in if it keeps the same promises the
|
||||
real adapters are held to above."""
|
||||
provider = FakeProvider([ScriptedTurn(text="Hello", reasoning="hmm")])
|
||||
text: List[str] = []
|
||||
reasoning: List[str] = []
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "hi"}],
|
||||
on_text=text.append, on_reasoning=reasoning.append)
|
||||
|
||||
assert "".join(text) == result["content"] == "Hello"
|
||||
assert reasoning == ["hmm"]
|
||||
assert result["role"] == "assistant"
|
||||
assert result["tool_calls"] == []
|
||||
|
||||
|
||||
def test_fake_provider_raises_provider_error_like_the_real_ones():
|
||||
provider = FakeProvider([ScriptedTurn(error="gateway exploded")])
|
||||
|
||||
with pytest.raises(ProviderError):
|
||||
provider.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Registry <-> implementation agreement
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("descriptor", BUILT_IN_PROVIDERS, ids=lambda d: d.id)
|
||||
def test_every_descriptor_builds_a_working_provider(descriptor):
|
||||
"""A descriptor that cannot be built is a catalogue lying to the UI: Settings
|
||||
would list the provider and selecting it would fail at the first message."""
|
||||
registry = ProviderRegistry()
|
||||
conf = {"base_url": "https://x.invalid/v1", "api_key": "k"}
|
||||
|
||||
provider = registry.build(descriptor.id, conf)
|
||||
|
||||
assert isinstance(provider, Provider)
|
||||
# The id, not the shared adapter class name: three descriptors map onto
|
||||
# OpenAICompatProvider, and usage/audit records must still tell them apart.
|
||||
assert provider.name == descriptor.id
|
||||
assert provider.model == descriptor.default_model
|
||||
|
||||
|
||||
@pytest.mark.parametrize("descriptor", BUILT_IN_PROVIDERS, ids=lambda d: d.id)
|
||||
def test_declared_vision_capability_matches_the_implementation(descriptor):
|
||||
"""``supports_vision`` decides whether an image block may be sent. A
|
||||
descriptor claiming vision for an adapter that cannot translate the block
|
||||
would route image turns into a guaranteed failure."""
|
||||
provider = ProviderRegistry().build(descriptor.id, {"base_url": "u", "api_key": "k"})
|
||||
|
||||
if descriptor.supports(ProviderCapability.VISION):
|
||||
assert provider.supports_vision is True
|
||||
|
||||
|
||||
def test_registry_build_never_mutates_the_caller_config():
|
||||
"""The routing layer runs one turn on a different model; if build() wrote
|
||||
that model back into the config dict it was handed, the override would
|
||||
silently become the user's saved default."""
|
||||
registry = ProviderRegistry()
|
||||
conf = {"base_url": "u", "api_key": "k", "model": "configured-model"}
|
||||
|
||||
provider = registry.build("openai_compat", conf, model="routed-model")
|
||||
|
||||
assert provider.model == "routed-model"
|
||||
assert conf["model"] == "configured-model"
|
||||
|
||||
|
||||
def test_registry_rejects_an_unknown_provider_with_provider_error():
|
||||
with pytest.raises(ProviderError) as excinfo:
|
||||
ProviderRegistry().build("does_not_exist", {})
|
||||
|
||||
# The message lists what IS known, so a typo in config is fixable from the
|
||||
# error alone without opening the source.
|
||||
assert "openai_compat" in str(excinfo.value)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Offline test doubles for the refactoring safety net (R01-T02).
|
||||
|
||||
Every double here is deliberately Qt-free, network-free and disk-free so the
|
||||
unit/contract suites run in well under a second and give the same answer on a
|
||||
laptop, in CI and on a machine with no API keys configured.
|
||||
|
||||
* :class:`~tests.fakes.fake_provider.FakeProvider` - a scripted
|
||||
``providers.base.Provider`` that streams canned text/tool calls.
|
||||
* :class:`~tests.fakes.fake_tool_executor.FakeToolExecutor` - a scripted stand-in
|
||||
for the ``extra_executor`` callable that ``core.chat_agent.run_cowork`` routes
|
||||
MCP/connector tool calls to.
|
||||
"""
|
||||
from .fake_provider import FakeProvider, ScriptedTurn
|
||||
from .fake_tool_executor import FakeToolExecutor, ToolInvocation
|
||||
|
||||
__all__ = ["FakeProvider", "ScriptedTurn", "FakeToolExecutor", "ToolInvocation"]
|
||||
@@ -0,0 +1,213 @@
|
||||
"""FakeProvider - a scripted, offline stand-in for a real LLM provider (R01-T02).
|
||||
|
||||
The real providers (``providers/openai_compat.py``, ``providers/anthropic.py``)
|
||||
open HTTP connections, need API keys and stream at the mercy of the network, so
|
||||
nothing above them could be tested deterministically. This double implements the
|
||||
same :class:`providers.base.Provider` contract from a list of scripted turns:
|
||||
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "hi"})]),
|
||||
ScriptedTurn(text="Saved it."),
|
||||
])
|
||||
|
||||
Turn 1 asks the agent loop to call a tool, turn 2 ends the loop with plain text -
|
||||
exactly the two-step shape ``run_cowork`` exercises, with zero I/O.
|
||||
|
||||
It records every call it received (:attr:`FakeProvider.calls`) so a test can
|
||||
assert on what the layer above actually sent (message list, tool catalogue),
|
||||
which is how the characterization and contract suites pin current behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from cowork_local.providers.base import (
|
||||
CancelFn,
|
||||
Provider,
|
||||
ProviderError,
|
||||
TextCallback,
|
||||
ToolSpec,
|
||||
)
|
||||
|
||||
# One scripted tool call: (name, arguments). Ids are generated by the provider so
|
||||
# a test never has to invent them, mirroring what a real gateway does.
|
||||
ToolCallScript = Tuple[str, Dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScriptedTurn:
|
||||
"""What :class:`FakeProvider` should do for ONE ``chat()`` call.
|
||||
|
||||
``text`` is streamed through ``on_text`` and returned as the assistant
|
||||
message content. ``reasoning`` goes to ``on_reasoning`` only - it must never
|
||||
leak into the answer, and asserting that is one of this double's jobs.
|
||||
|
||||
``tool_calls`` makes the agent loop run tools and come back for another turn;
|
||||
an empty tuple ends the loop.
|
||||
|
||||
``error``, when set, raises :class:`ProviderError` instead of answering, so
|
||||
error/recovery paths are testable without simulating a network fault.
|
||||
|
||||
``chunk_size`` > 0 splits ``text`` into fixed-size pieces to exercise
|
||||
chunk-boundary handling in stream consumers (the ``<think>`` splitter and the
|
||||
UI's incremental markdown renderer both have boundary logic worth covering).
|
||||
"""
|
||||
|
||||
text: str = ""
|
||||
reasoning: str = ""
|
||||
tool_calls: Sequence[ToolCallScript] = ()
|
||||
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):
|
||||
"""A ``Provider`` that replays :class:`ScriptedTurn` objects.
|
||||
|
||||
Args:
|
||||
turns: the scripted turns, consumed in order.
|
||||
model: the model id reported through ``describe()`` / usage records.
|
||||
models: what :meth:`list_models` returns (Settings' "Load models").
|
||||
strict: when True (default) running past the end of the script raises
|
||||
``AssertionError``. That is intentional noise: a silent extra turn
|
||||
usually means the code under test looped more than the test author
|
||||
expected, and hiding it behind an empty answer would turn a real
|
||||
behaviour change into a passing test.
|
||||
"""
|
||||
|
||||
name = "fake"
|
||||
# The double can accept image content blocks, so vision code paths are
|
||||
# reachable in tests without a real vision-capable gateway.
|
||||
supports_vision = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turns: Optional[Sequence[ScriptedTurn]] = None,
|
||||
*,
|
||||
model: str = "fake-model",
|
||||
models: Optional[Sequence[str]] = None,
|
||||
strict: bool = True,
|
||||
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) # deterministic tool-call ids: call_1, call_2, ...
|
||||
self.calls: List[RecordedCall] = []
|
||||
|
||||
# -- introspection helpers used by tests ---------------------------- #
|
||||
@property
|
||||
def call_count(self) -> int:
|
||||
"""How many times the layer above asked this provider to run a turn."""
|
||||
return len(self.calls)
|
||||
|
||||
@property
|
||||
def remaining_turns(self) -> int:
|
||||
"""Scripted turns not consumed yet - assert 0 to prove the script was
|
||||
fully used (an unused turn means the code stopped earlier than intended)."""
|
||||
return len(self._turns)
|
||||
|
||||
def last_messages(self) -> List[Dict[str, Any]]:
|
||||
"""The message list sent on the most recent call (empty if never called)."""
|
||||
return self.calls[-1].messages if self.calls else []
|
||||
|
||||
# -- Provider contract ---------------------------------------------- #
|
||||
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]:
|
||||
"""Replay the next scripted turn, honouring cancel and both callbacks.
|
||||
|
||||
The message list is deep-ish copied into the recording because the agent
|
||||
loop keeps appending to the SAME list object; without the copy every
|
||||
recorded call would show the final state and assertions on "what was
|
||||
sent at step 1" would be meaningless.
|
||||
"""
|
||||
record = RecordedCall(
|
||||
messages=[dict(m) for m in messages],
|
||||
tool_names=[t.name for t in (tools or [])],
|
||||
)
|
||||
self.calls.append(record)
|
||||
|
||||
turn = self._next_turn()
|
||||
|
||||
# Checked before streaming anything: a provider that already knows the
|
||||
# caller gave up must not spend callbacks on text nobody will render.
|
||||
if self._is_cancelled(cancel):
|
||||
record.cancelled = True
|
||||
return {"role": "assistant", "content": "", "tool_calls": []}
|
||||
|
||||
if turn.error:
|
||||
raise ProviderError(turn.error)
|
||||
|
||||
if turn.reasoning and on_reasoning:
|
||||
on_reasoning(turn.reasoning)
|
||||
|
||||
for piece in self._stream_pieces(turn):
|
||||
# Re-checked between chunks so a mid-stream Stop truncates the answer
|
||||
# the same way a real streamed response does.
|
||||
if self._is_cancelled(cancel):
|
||||
record.cancelled = True
|
||||
break
|
||||
if on_text:
|
||||
on_text(piece)
|
||||
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": turn.text,
|
||||
"tool_calls": [
|
||||
{"id": f"call_{next(self._ids)}", "name": name, "arguments": dict(args)}
|
||||
for name, args in turn.tool_calls
|
||||
],
|
||||
}
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Configured model ids. Clears ``last_error`` so ``test_connection()``
|
||||
reports success, matching how a healthy real provider behaves."""
|
||||
self.last_error = ""
|
||||
return list(self._models)
|
||||
|
||||
# -- internals ------------------------------------------------------- #
|
||||
def _next_turn(self) -> ScriptedTurn:
|
||||
"""Pop the next scripted turn, or fail loudly when the script ran out."""
|
||||
if self._turns:
|
||||
return self._turns.pop(0)
|
||||
if self._strict:
|
||||
raise AssertionError(
|
||||
f"FakeProvider script exhausted: chat() was called {len(self.calls)} "
|
||||
"time(s) but fewer turns were scripted. Add a ScriptedTurn, or pass "
|
||||
"strict=False if the extra call is genuinely expected."
|
||||
)
|
||||
return ScriptedTurn()
|
||||
|
||||
@staticmethod
|
||||
def _stream_pieces(turn: ScriptedTurn) -> List[str]:
|
||||
"""Split a turn's answer into the fragments to stream.
|
||||
|
||||
``chunk_size == 0`` streams the whole answer in one piece (the common
|
||||
case); a positive size slices it so tests can drive chunk-boundary logic.
|
||||
"""
|
||||
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,99 @@
|
||||
"""FakeToolExecutor - offline stand-in for the extra-tool executor (R01-T02).
|
||||
|
||||
``core.chat_agent.run_cowork`` routes any tool call whose name appears in
|
||||
``extra_tools`` to ``extra_executor(name, args)`` and expects back::
|
||||
|
||||
{"ok": bool, "output": str}
|
||||
|
||||
In production that callable reaches MCP servers, Microsoft 365 connectors and
|
||||
subprocesses. This double answers from a table instead, so the agent loop's tool
|
||||
branch is testable with no processes, no sockets and no credentials - and every
|
||||
invocation is recorded for assertions about what the agent actually asked for.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
# A scripted answer is either the literal result dict, or a callable computing it
|
||||
# from the arguments (for tools whose output must depend on the input).
|
||||
ToolResult = Dict[str, Any]
|
||||
ScriptedResult = Union[ToolResult, Callable[[Dict[str, Any]], ToolResult]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolInvocation:
|
||||
"""One recorded ``extra_executor(name, args)`` call."""
|
||||
|
||||
name: str
|
||||
args: Dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeToolExecutor:
|
||||
"""Callable test double for ``run_cowork(extra_executor=...)``.
|
||||
|
||||
Args:
|
||||
results: tool name -> scripted result (dict, or callable taking args).
|
||||
default: what to answer for a tool with no scripted result. ``None``
|
||||
(the default) answers with ``ok=False`` and an explicit message
|
||||
rather than raising - the production executor also reports unknown
|
||||
tools as a failed tool result, and matching that keeps the agent
|
||||
loop on its real code path instead of an exception path it would
|
||||
never take in production.
|
||||
"""
|
||||
|
||||
results: Dict[str, ScriptedResult] = field(default_factory=dict)
|
||||
default: Optional[ScriptedResult] = None
|
||||
calls: List[ToolInvocation] = field(default_factory=list)
|
||||
|
||||
def __call__(self, name: str, args: Dict[str, Any]) -> ToolResult:
|
||||
"""Record the invocation and return its scripted result."""
|
||||
self.calls.append(ToolInvocation(name=name, args=dict(args or {})))
|
||||
scripted = self.results.get(name, self.default)
|
||||
if scripted is None:
|
||||
return {"ok": False, "output": f"No fake result scripted for tool '{name}'."}
|
||||
# A callable lets one entry serve many different arguments (e.g. echo the
|
||||
# path it was asked to read) without scripting every combination.
|
||||
resolved = scripted(dict(args or {})) if callable(scripted) else dict(scripted)
|
||||
resolved.setdefault("ok", True)
|
||||
resolved.setdefault("output", "")
|
||||
return resolved
|
||||
|
||||
# -- introspection helpers used by tests ---------------------------- #
|
||||
@property
|
||||
def call_names(self) -> List[str]:
|
||||
"""Tool names in call order - the usual thing a test asserts on."""
|
||||
return [c.name for c in self.calls]
|
||||
|
||||
def called(self, name: str) -> bool:
|
||||
"""True when ``name`` was invoked at least once."""
|
||||
return any(c.name == name for c in self.calls)
|
||||
|
||||
def args_for(self, name: str) -> List[Dict[str, Any]]:
|
||||
"""Every argument dict this tool was called with, in order."""
|
||||
return [c.args for c in self.calls if c.name == name]
|
||||
|
||||
def specs(self) -> List[ToolSpec]:
|
||||
"""``ToolSpec`` entries for the scripted tools, ready to pass as
|
||||
``run_cowork(extra_tools=...)``.
|
||||
|
||||
The agent loop dispatches to ``extra_executor`` only for names present in
|
||||
``extra_tools``; generating the specs from the same table removes the
|
||||
chance of a test scripting a result the loop can never reach.
|
||||
"""
|
||||
return [
|
||||
ToolSpec(
|
||||
name=name,
|
||||
description=f"Fake tool '{name}' (test double).",
|
||||
# Permissive schema on purpose: these specs exist to register the
|
||||
# name with the agent loop, not to validate arguments.
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
)
|
||||
for name in self.results
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["FakeToolExecutor", "ToolInvocation"]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Integration tests: real widgets, real services, no network (R10-T01 layout).
|
||||
|
||||
These build actual Qt widgets offscreen (``QT_QPA_PLATFORM=offscreen``) and run
|
||||
a turn end to end with a scripted :class:`FakeProvider`. They are slower than
|
||||
the unit suite - a QApplication has to exist - and are what proves the seams
|
||||
introduced by R03/R04 are actually wired into the screens, not just correct in
|
||||
isolation.
|
||||
"""
|
||||
@@ -0,0 +1,201 @@
|
||||
"""End-to-end check that the Cowork screen really runs turns through the
|
||||
application layer (R04-T04).
|
||||
|
||||
The unit tests prove ``ConversationApplicationService`` behaves correctly; this
|
||||
one proves ``ui/cowork_tab.py::build_job`` actually goes through it, on a real
|
||||
(offscreen) widget, with a scripted provider instead of a network call.
|
||||
|
||||
It also pins the property that motivated R04-T01: the turn runs on the state
|
||||
captured at SUBMIT time, so a user editing the conversation while a turn is in
|
||||
flight cannot change what that turn sends.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core import chat_agent # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
from tests.fakes import FakeProvider, ScriptedTurn # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
"""One QApplication for the module - Qt allows only a single instance."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cowork_tab(qt_app, tmp_path: Path, monkeypatch):
|
||||
"""A real CoworkTab on a throwaway config, with ambient inputs neutralised."""
|
||||
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
||||
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
||||
from cowork_local.core import audit_log
|
||||
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
# Agent Security's prompt validation is ON by default and spends an EXTRA
|
||||
# provider call reviewing the request before the agent loop starts (see
|
||||
# core/agent_security.py::enforce_prompt). That is real behaviour - pinned
|
||||
# by its own test below - but it would make every other test here script a
|
||||
# turn that has nothing to do with what it is checking.
|
||||
ctx.config.agent_security["enabled"] = False
|
||||
return CoworkTab(ctx)
|
||||
|
||||
|
||||
class _StubWorker:
|
||||
"""The slice of ``core.worker.AgentWorker`` a job actually touches."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: List[Dict[str, Any]] = []
|
||||
self.gates_requested = 0
|
||||
self._cancelled = False
|
||||
|
||||
def emit_event(self, payload: Dict[str, Any]) -> None:
|
||||
self.events.append(payload)
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
def new_gate(self, _mode: str, **_kwargs) -> Any:
|
||||
self.gates_requested += 1
|
||||
return None
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
|
||||
def _run_job(tab, worker, provider, text="hello", messages=None, out_dir=None):
|
||||
"""Build the tab's job with ``provider`` pinned, then run it like the worker
|
||||
thread would."""
|
||||
tab.build_provider = lambda: provider # what routing/agent selection resolves to
|
||||
job = tab.build_job(text, messages if messages is not None
|
||||
else [{"role": "user", "content": text}], out_dir)
|
||||
return job(worker)
|
||||
|
||||
|
||||
def test_a_turn_runs_through_the_service_and_returns_history(cowork_tab, tmp_path):
|
||||
provider = FakeProvider([ScriptedTurn(text="Hello from the fake.")])
|
||||
worker = _StubWorker()
|
||||
|
||||
result = _run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert provider.call_count == 1
|
||||
# Same return contract as before the refactor - _cleanup_turn reads both keys.
|
||||
assert set(result) == {"messages", "turn_dir"}
|
||||
assert [m["role"] for m in result["messages"]] == ["system", "user", "assistant"]
|
||||
assert result["messages"][-1]["content"] == "Hello from the fake."
|
||||
|
||||
|
||||
def test_the_widget_still_receives_the_legacy_event_dicts(cowork_tab, tmp_path):
|
||||
"""The chat widgets consume dicts and are not migrated until EPIC R08, so
|
||||
the typed events must render back into exactly what they already handle -
|
||||
plus the new end-of-turn signal, which the if/elif dispatch ignores."""
|
||||
provider = FakeProvider([ScriptedTurn(text="Hi")])
|
||||
worker = _StubWorker()
|
||||
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert [e["type"] for e in worker.events] == ["text", "assistant_done", "turn_completed"]
|
||||
assert worker.events[0] == {"type": "text", "delta": "Hi"}
|
||||
|
||||
|
||||
def test_the_turn_ignores_messages_added_after_it_was_submitted(cowork_tab, tmp_path):
|
||||
"""The bug ConversationExecutionRequest exists to prevent: the panel keeps
|
||||
appending to its own list while a turn is in flight."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
worker = _StubWorker()
|
||||
live_messages = [{"role": "user", "content": "first question"}]
|
||||
|
||||
job_result = _run_job(cowork_tab, worker, provider,
|
||||
messages=live_messages, out_dir=tmp_path / "turn")
|
||||
|
||||
# Simulate the user typing a second message DURING the turn by mutating the
|
||||
# list the panel handed over. The already-sent conversation must not include it.
|
||||
live_messages.append({"role": "user", "content": "typed while running"})
|
||||
|
||||
sent = provider.calls[0].messages
|
||||
assert [m["content"] for m in sent if m["role"] == "user"] == ["first question"]
|
||||
assert "typed while running" not in str(job_result["messages"])
|
||||
|
||||
|
||||
def test_a_failing_turn_still_raises_so_the_worker_reports_it(cowork_tab, tmp_path):
|
||||
"""core/worker.py turns an exception into the `failed` signal the chat panel
|
||||
already handles; swallowing it here would show a successful turn with no
|
||||
answer instead of an error."""
|
||||
provider = FakeProvider([ScriptedTurn(error="gateway down"),
|
||||
ScriptedTurn(error="gateway down")])
|
||||
worker = _StubWorker()
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert "gateway down" in str(excinfo.value)
|
||||
# The error was still reported as an event before being re-raised.
|
||||
assert any(e["type"] == "error" for e in worker.events)
|
||||
|
||||
|
||||
def test_a_permission_gate_is_only_requested_when_the_workspace_asks_for_it(
|
||||
cowork_tab, tmp_path, monkeypatch):
|
||||
provider = FakeProvider([ScriptedTurn(text="ok"), ScriptedTurn(text="ok")])
|
||||
worker = _StubWorker()
|
||||
|
||||
monkeypatch.setattr(cowork_tab.ctx, "project_confirm_commands", lambda: False)
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "a")
|
||||
assert worker.gates_requested == 0
|
||||
|
||||
monkeypatch.setattr(cowork_tab.ctx, "project_confirm_commands", lambda: True)
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "b")
|
||||
assert worker.gates_requested == 1
|
||||
|
||||
|
||||
def test_a_tool_turn_writes_into_this_turns_own_output_folder(cowork_tab, tmp_path):
|
||||
"""Turn isolation: each turn writes into its own directory so parallel turns
|
||||
cannot clobber each other's files."""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "n.md", "content": "x"})]),
|
||||
ScriptedTurn(text="Saved."),
|
||||
])
|
||||
worker = _StubWorker()
|
||||
turn_dir = tmp_path / "turn-1"
|
||||
|
||||
result = _run_job(cowork_tab, worker, provider, out_dir=turn_dir)
|
||||
|
||||
assert result["turn_dir"] == str(turn_dir)
|
||||
assert [p.name for p in turn_dir.iterdir()] and turn_dir.exists()
|
||||
assert any(e["type"] == "tool_result" and e["ok"] for e in worker.events)
|
||||
|
||||
|
||||
def test_agent_security_still_reviews_the_request_before_the_turn_runs(
|
||||
cowork_tab, tmp_path):
|
||||
"""Characterisation, not a new behaviour: with Agent Security enabled (the
|
||||
shipped default) a turn costs an EXTRA provider call, because the request is
|
||||
reviewed against the rulebase before the agent loop starts.
|
||||
|
||||
Pinned here because it is invisible from the call site and easy to break -
|
||||
routing a turn through the application layer must not skip the review.
|
||||
"""
|
||||
cowork_tab.ctx.config.agent_security["enabled"] = True
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(text="ALLOW"), # the security pre-flight review
|
||||
ScriptedTurn(text="the answer"), # the turn itself
|
||||
])
|
||||
worker = _StubWorker()
|
||||
|
||||
result = _run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert provider.call_count == 2
|
||||
assert result["messages"][-1]["content"] == "the answer"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""EPIC R06-T04: the race in ``ui/workspace_tab.py::_load_current``.
|
||||
|
||||
``_load_current`` sets ``ctx.config._project_history_dir`` on the SHARED
|
||||
``AppConfig`` every time the user switches projects in the Workspace screen.
|
||||
A background turn (one that isn't the conversation currently displayed) used
|
||||
to resolve its save directory by calling ``ctx.config.history_dir()`` at
|
||||
``_persist_session`` time - i.e. whenever the turn actually finished, not
|
||||
when it started. If the user switched projects while it was still running,
|
||||
the turn's conversation got written into the NEW project's history folder
|
||||
instead of the one it actually belongs to.
|
||||
|
||||
The fix threads a ``home_history_dir`` captured at submit time (same "home_*"
|
||||
snapshot convention ``ui/chat_panel.py`` already uses for session id/title/
|
||||
messages) through to the save call. This test drives the real
|
||||
``ChatPanel._persist_session`` - the actual save path - offscreen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core.history import list_conversations # 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 chat_panel(qt_app, tmp_path: Path):
|
||||
from cowork_local.ui.chat_panel import ChatPanel
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
return ChatPanel(ctx, "cowork", "Test")
|
||||
|
||||
|
||||
def test_background_turn_saves_into_the_project_it_started_in(chat_panel, tmp_path):
|
||||
project_a_dir = tmp_path / "project-a-history"
|
||||
project_b_dir = tmp_path / "project-b-history"
|
||||
chat_panel.ctx.config._project_history_dir = project_a_dir
|
||||
|
||||
# What ChatPanel._start_turn captures into the per-turn ctx dict at
|
||||
# submit time (see the "home_history_dir" entry added there for R06-T04).
|
||||
turn_ctx = {
|
||||
"home_id": chat_panel.session_id,
|
||||
"home_messages": [{"role": "user", "content": "hi"}],
|
||||
"home_title": "Background turn",
|
||||
"home_history_dir": chat_panel.ctx.config.history_dir(),
|
||||
"record": {},
|
||||
}
|
||||
assert turn_ctx["home_history_dir"] == project_a_dir
|
||||
|
||||
# The user switches projects in the Workspace screen WHILE this turn is
|
||||
# still running - exactly what ui/workspace_tab.py::_load_current does.
|
||||
chat_panel.ctx.config._project_history_dir = project_b_dir
|
||||
|
||||
chat_panel._persist_session(turn_ctx)
|
||||
|
||||
assert len(list_conversations(project_a_dir)) == 1
|
||||
assert list_conversations(project_b_dir) == []
|
||||
|
||||
|
||||
def test_the_currently_viewed_conversation_still_follows_live_selection(chat_panel, tmp_path):
|
||||
"""_save_snapshot's OTHER caller (the initial "register it in History right
|
||||
away" call, and _autosave) has no captured history_dir and must keep
|
||||
resolving it live - that path is for the conversation ACTUALLY on screen,
|
||||
which should follow whatever project the user has selected right now."""
|
||||
project_dir = tmp_path / "currently-viewed"
|
||||
chat_panel.ctx.config._project_history_dir = project_dir
|
||||
|
||||
chat_panel._save_snapshot(chat_panel.session_id,
|
||||
[{"role": "user", "content": "hi"}], "Live view")
|
||||
|
||||
assert len(list_conversations(project_dir)) == 1
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Fast, isolated unit tests for the new 4-tier layers (R01/R03/R04, R10-T01).
|
||||
|
||||
Everything in this folder must run offline, without Qt and without touching the
|
||||
real user config directory, so the whole folder stays well under one second.
|
||||
"""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""EPIC R06-T02: atomic JSON writes + the WorkspaceRepository/
|
||||
ConversationRepository facades over core/projects.py and core/history.py.
|
||||
|
||||
The motivating bug: ``core/projects.py::save_project`` used to
|
||||
``path.write_text(json.dumps(...))`` — two syscalls, no atomicity. A failure
|
||||
between them must never leave a half-written file on disk; that is the one
|
||||
property these tests exist to pin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.persistence.json import (
|
||||
ConversationRepository,
|
||||
WorkspaceRepository,
|
||||
write_json,
|
||||
)
|
||||
from cowork_local.infrastructure.persistence.json.atomic_write import write_json as _write_json
|
||||
|
||||
|
||||
def test_write_json_round_trips(tmp_path):
|
||||
path = tmp_path / "a.json"
|
||||
write_json(path, {"hello": "world", "n": 3})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"hello": "world", "n": 3}
|
||||
|
||||
|
||||
def test_write_json_leaves_no_temp_file_behind(tmp_path):
|
||||
write_json(tmp_path / "a.json", {"x": 1})
|
||||
assert list(tmp_path.iterdir()) == [tmp_path / "a.json"]
|
||||
|
||||
|
||||
def test_a_failed_write_never_corrupts_the_existing_file(tmp_path, monkeypatch):
|
||||
"""The whole point of write-temp-then-replace: if the replace step blows
|
||||
up, the ORIGINAL file must still be there and still be readable — not
|
||||
truncated, not half-written."""
|
||||
path = tmp_path / "a.json"
|
||||
write_json(path, {"version": 1})
|
||||
|
||||
import cowork_local.infrastructure.persistence.json.atomic_write as mod
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise OSError("simulated crash between write and replace")
|
||||
|
||||
monkeypatch.setattr(mod.os, "replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
write_json(path, {"version": 2})
|
||||
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"version": 1}
|
||||
# the abandoned temp file was cleaned up, not left orphaned
|
||||
assert list(tmp_path.iterdir()) == [path]
|
||||
|
||||
|
||||
def test_workspace_repository_crud_round_trip(tmp_path):
|
||||
repo = WorkspaceRepository(tmp_path)
|
||||
project = repo.create("My Project", description="d")
|
||||
|
||||
assert [p.project_id for p in repo.list()] == [project.project_id]
|
||||
|
||||
project.description = "updated"
|
||||
repo.save(project)
|
||||
assert repo.get(project.project_id).description == "updated"
|
||||
|
||||
assert repo.delete(project.project_id) is True
|
||||
assert repo.get(project.project_id) is None
|
||||
|
||||
|
||||
def test_conversation_repository_crud_round_trip(tmp_path):
|
||||
repo = ConversationRepository(tmp_path)
|
||||
session_id = repo.new_session_id()
|
||||
path = repo.save("cowork", session_id, [{"role": "user", "content": "hi"}])
|
||||
|
||||
assert [c["session_id"] for c in repo.list()] == [session_id]
|
||||
|
||||
repo.rename(path, "Renamed")
|
||||
repo.set_pinned(path, True)
|
||||
data = repo.load(path)
|
||||
assert data["title"] == "Renamed"
|
||||
assert data["pinned"] is True
|
||||
|
||||
repo.delete(path)
|
||||
assert repo.list() == []
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Unit tests for the Clean Architecture Guard, ``scripts/check_imports.py`` (R01-T03).
|
||||
|
||||
The guard is what makes ADR-001 enforceable rather than aspirational, so it needs
|
||||
its own tests: a guard that silently passes everything is worse than no guard,
|
||||
because the CASAN Gate would then report a green architecture that isn't.
|
||||
|
||||
Both directions are covered - it must FLAG real violations (including the
|
||||
function-local and relative import spellings this codebase actually uses) and it
|
||||
must NOT flag legal code (Qt named only in a docstring, domain importing stdlib).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_GUARD_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check_imports.py"
|
||||
|
||||
|
||||
def _load_guard():
|
||||
"""Import ``scripts/check_imports.py`` by path.
|
||||
|
||||
``scripts/`` is deliberately not a package (it holds standalone CLI tools),
|
||||
so a normal import statement cannot reach it.
|
||||
"""
|
||||
name = "_check_imports_under_test"
|
||||
spec = importlib.util.spec_from_file_location(name, _GUARD_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Registered before exec_module because @dataclass resolves a class's own
|
||||
# module out of sys.modules while processing annotations; without this the
|
||||
# guard's Violation dataclass fails to build under a by-path import.
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
guard = _load_guard()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_repo(tmp_path: Path, monkeypatch):
|
||||
"""A throwaway repo root the guard scans instead of the real one.
|
||||
|
||||
Pointing ``REPO_ROOT`` at a tmp dir keeps these tests independent of the
|
||||
actual state of ``domain/`` and ``application/`` - otherwise adding a real
|
||||
module later could flip a guard test red for no reason.
|
||||
"""
|
||||
monkeypatch.setattr(guard, "REPO_ROOT", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write(root: Path, rel: str, source: str) -> Path:
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Violations that must be caught
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_top_level_qt_import_in_domain_is_flagged(fake_repo):
|
||||
_write(fake_repo, "domain/agents/bad.py", "from PySide6 import QtWidgets\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert "PySide6" in violations[0].imported
|
||||
assert "pure Python" in violations[0].rule
|
||||
|
||||
|
||||
def test_function_local_qt_import_is_flagged(fake_repo):
|
||||
"""This repo defers heavy imports into function bodies to speed up start-up,
|
||||
so the guard walks the whole tree - a deferred Qt import breaks the layer
|
||||
exactly as much as a top-level one."""
|
||||
_write(fake_repo, "application/conversations/bad.py",
|
||||
"def build():\n import PySide6.QtCore\n return PySide6\n")
|
||||
|
||||
violations = guard.run(["application"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0].line == 2
|
||||
|
||||
|
||||
def test_application_importing_ui_is_flagged(fake_repo):
|
||||
_write(fake_repo, "application/conversations/bad.py",
|
||||
"from cowork_local.ui.chat_panel import ChatPanel\n")
|
||||
|
||||
violations = guard.run(["application"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert "ui/" in violations[0].rule
|
||||
|
||||
|
||||
def test_relative_import_that_escapes_the_layer_is_flagged(fake_repo):
|
||||
"""``from ...ui import x`` inside ``domain/agents/`` resolves to the top-level
|
||||
``ui`` package. Only relative-import resolution catches this - the text
|
||||
``ui`` never appears as an absolute module name."""
|
||||
_write(fake_repo, "domain/agents/bad.py", "from ...ui import widgets\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0].imported == "...ui"
|
||||
|
||||
|
||||
def test_domain_importing_core_is_flagged(fake_repo):
|
||||
"""``domain/`` is the innermost layer: it may not reach back into the legacy
|
||||
``core/`` package either, or the dependency arrow would point outward."""
|
||||
_write(fake_repo, "domain/models/bad.py", "from cowork_local.core import history\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
|
||||
|
||||
def test_unparseable_file_is_reported_rather_than_skipped(fake_repo):
|
||||
"""A file the guard cannot read must fail the gate. Skipping it would let a
|
||||
broken file smuggle any import past the check."""
|
||||
_write(fake_repo, "domain/agents/broken.py", "def oops(:\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0].imported == "<unparseable>"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Legal code that must NOT be flagged
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_qt_mentioned_only_in_a_docstring_is_not_flagged(fake_repo):
|
||||
"""The whole reason the guard parses an AST instead of grepping: several
|
||||
real modules explain in prose that they must not import PySide6."""
|
||||
_write(fake_repo, "domain/agents/ok.py",
|
||||
'"""This layer must never import PySide6 or PyQt6."""\n'
|
||||
'QT = "PySide6" # a string, not an import\n')
|
||||
|
||||
assert guard.run(["domain"]) == []
|
||||
|
||||
|
||||
def test_stdlib_and_intra_layer_imports_are_allowed(fake_repo):
|
||||
_write(fake_repo, "domain/agents/ok.py",
|
||||
"import json\n"
|
||||
"from dataclasses import dataclass\n"
|
||||
"from ..models.provider_descriptor import ProviderDescriptor\n")
|
||||
|
||||
assert guard.run(["domain"]) == []
|
||||
|
||||
|
||||
def test_application_may_import_domain_and_infrastructure(fake_repo):
|
||||
"""Application orchestrates: reaching down to domain is the point, and
|
||||
wiring an infrastructure adapter is allowed (only UI is forbidden)."""
|
||||
_write(fake_repo, "application/model_routing/ok.py",
|
||||
"from cowork_local.domain.models import provider_descriptor\n"
|
||||
"from cowork_local.infrastructure.providers import provider_registry\n")
|
||||
|
||||
assert guard.run(["application"]) == []
|
||||
|
||||
|
||||
def test_tests_folder_inside_a_layer_is_not_scanned(fake_repo):
|
||||
"""A test living next to the code may legitimately import Qt; holding tests
|
||||
to the production rule would only teach people to disable the gate."""
|
||||
_write(fake_repo, "domain/tests/test_thing.py", "from PySide6 import QtWidgets\n")
|
||||
|
||||
assert guard.run(["domain"]) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reporting / exit codes - what CI actually consumes
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_main_returns_nonzero_and_prints_ascii_only_on_failure(fake_repo, capsys):
|
||||
"""The team's Windows consoles run a legacy code page (cp932): a non-ASCII
|
||||
character in the failure output would raise UnicodeEncodeError and crash the
|
||||
gate on the very path it exists to report."""
|
||||
_write(fake_repo, "domain/agents/bad.py", "from PySide6 import QtWidgets\n")
|
||||
|
||||
exit_code = guard.main(["domain"])
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 1
|
||||
assert "FAIL" in out
|
||||
assert "domain/agents/bad.py:1" in out
|
||||
out.encode("cp932") # raises if any character is unprintable on the target console
|
||||
|
||||
|
||||
def test_main_returns_zero_on_a_clean_tree(fake_repo, capsys):
|
||||
_write(fake_repo, "domain/agents/ok.py", "import json\n")
|
||||
|
||||
exit_code = guard.main(["domain"])
|
||||
|
||||
assert exit_code == 0
|
||||
assert "PASS" in capsys.readouterr().out
|
||||
@@ -0,0 +1,62 @@
|
||||
"""EPIC R05-T03/T04: ``core/code_agent.py::run_code`` used to gate tool calls
|
||||
with ``if name in (WRITE_TOOLS | MS365_WRITE_TOOLS): gate.request(...)``. This
|
||||
pins that the switch to ``ToolPolicyGateway`` still gates exactly the same
|
||||
calls: ``write_file`` (a WRITE tool) consults the gate; ``list_dir``
|
||||
(read-only) never does.
|
||||
|
||||
Runs the REAL engine (``run_code``) via :class:`FakeProvider`, same approach
|
||||
``tests/characterization/test_run_cowork.py`` uses for the Cowork engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from cowork_local.core.code_agent import run_code
|
||||
from cowork_local.core.tools import ToolContext
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
class _RecordingGate:
|
||||
def __init__(self, approve: bool):
|
||||
self.approve = approve
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool:
|
||||
self.calls.append(payload)
|
||||
return self.approve
|
||||
|
||||
|
||||
def _run(tmp_path, provider, gate):
|
||||
ctx = ToolContext(tmp_path)
|
||||
events: List[Dict[str, Any]] = []
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "do it"}]
|
||||
run_code(provider, messages, ctx, gate, events.append)
|
||||
return events
|
||||
|
||||
|
||||
def test_write_file_consults_the_gate_and_honors_rejection(tmp_path):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("write_file", {"path": "a.txt", "content": "hi"})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=False)
|
||||
events = _run(tmp_path, provider, gate)
|
||||
|
||||
assert len(gate.calls) == 1 and gate.calls[0]["name"] == "write_file"
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is False
|
||||
assert not (tmp_path / "a.txt").exists() # rejected, never actually written
|
||||
|
||||
|
||||
def test_read_only_tool_never_consults_the_gate(tmp_path):
|
||||
(tmp_path / "existing.txt").write_text("x", encoding="utf-8")
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("list_dir", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=False) # would reject if ever asked
|
||||
events = _run(tmp_path, provider, gate)
|
||||
|
||||
assert gate.calls == []
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is True
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Unit tests for EPIC R04: the turn snapshot, the typed events and the service.
|
||||
|
||||
The service tests run against the REAL engine (``core.chat_agent.run_cowork``)
|
||||
driven by :class:`FakeProvider`, not against a stubbed runner. That is
|
||||
deliberate: the whole point of R04 is that the service produces the same turn
|
||||
the widget used to produce, and only an end-to-end path through the real engine
|
||||
can show that. It still costs milliseconds - no Qt, no network, no disk beyond
|
||||
a tmp folder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.conversations import ConversationApplicationService
|
||||
from cowork_local.core import chat_agent
|
||||
from cowork_local.domain.agents import (
|
||||
AssistantDoneEvent,
|
||||
ConversationExecutionRequest,
|
||||
ErrorEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
TurnCompletedEvent,
|
||||
collect_text,
|
||||
event_from_dict,
|
||||
)
|
||||
from tests.fakes import FakeProvider, FakeToolExecutor, ScriptedTurn
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R04-T01 - the immutable request snapshot
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_snapshot_cannot_be_changed_by_the_caller_afterwards():
|
||||
"""The motivating bug: the chat panel keeps appending to its own message
|
||||
list while a turn runs, and the turn must not see those later messages."""
|
||||
live_messages = [{"role": "user", "content": "first"}]
|
||||
request = ConversationExecutionRequest.create("first", live_messages)
|
||||
|
||||
live_messages.append({"role": "user", "content": "typed while running"})
|
||||
live_messages[0]["content"] = "edited"
|
||||
|
||||
assert len(request.messages) == 1
|
||||
assert request.messages[0]["content"] == "first"
|
||||
|
||||
|
||||
def test_message_list_hands_out_a_fresh_mutable_copy():
|
||||
"""The engine appends assistant/tool messages to the list it is given, so a
|
||||
copy is what keeps the snapshot immutable in practice, not just by
|
||||
declaration."""
|
||||
request = ConversationExecutionRequest.create("hi", [{"role": "user", "content": "hi"}])
|
||||
|
||||
first = request.message_list()
|
||||
first.append({"role": "assistant", "content": "reply"})
|
||||
|
||||
assert len(request.message_list()) == 1
|
||||
assert first is not request.message_list()
|
||||
|
||||
|
||||
def test_with_model_produces_a_new_pinned_snapshot():
|
||||
"""A routing switch must not mutate a request a turn may already be running."""
|
||||
original = ConversationExecutionRequest.create("hi", provider="openai_compat", model="a")
|
||||
|
||||
routed = original.with_model("anthropic", "claude")
|
||||
|
||||
assert (original.provider, original.model) == ("openai_compat", "a")
|
||||
assert (routed.provider, routed.model) == ("anthropic", "claude")
|
||||
assert routed.turn_id == original.turn_id # same turn, different target
|
||||
|
||||
|
||||
def test_every_turn_gets_its_own_id():
|
||||
a = ConversationExecutionRequest.create("x")
|
||||
b = ConversationExecutionRequest.create("x")
|
||||
|
||||
assert a.turn_id and b.turn_id and a.turn_id != b.turn_id
|
||||
|
||||
|
||||
def test_run_to_completion_raises_the_step_ceiling():
|
||||
interactive = ConversationExecutionRequest.create("x")
|
||||
flow_step = ConversationExecutionRequest.create("x", run_to_completion=True)
|
||||
|
||||
assert interactive.effective_max_steps == 30
|
||||
assert flow_step.effective_max_steps == 200
|
||||
|
||||
|
||||
def test_permission_scope_always_keeps_update_plan():
|
||||
"""update_plan has no side effects and drives the Plan panel; scoping it out
|
||||
would break the UI rather than restrict a capability."""
|
||||
request = ConversationExecutionRequest.create("x", allowed_tools=["read_file"])
|
||||
|
||||
assert request.allows_tool("read_file") is True
|
||||
assert request.allows_tool("update_plan") is True
|
||||
assert request.allows_tool("save_file") is False
|
||||
# No scope at all means every enabled tool is allowed.
|
||||
assert ConversationExecutionRequest.create("x").allows_tool("save_file") is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R04-T02 - typed events and the legacy bridge
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("payload,expected", [
|
||||
({"type": "text", "delta": "hi"}, TextChunkEvent),
|
||||
({"type": "reasoning", "delta": "hmm"}, ReasoningChunkEvent),
|
||||
({"type": "assistant_done", "content": "done"}, AssistantDoneEvent),
|
||||
({"type": "tool_proposed", "id": "1", "name": "save_file"}, ToolCallStartedEvent),
|
||||
({"type": "tool_result", "id": "1", "name": "save_file", "ok": True}, ToolCallFinishedEvent),
|
||||
])
|
||||
def test_legacy_emit_dicts_map_onto_typed_events(payload, expected):
|
||||
assert isinstance(event_from_dict(payload), expected)
|
||||
|
||||
|
||||
def test_an_unknown_event_tag_is_dropped_rather_than_raising():
|
||||
"""The engine is still being refactored and may grow an event first. Losing
|
||||
one bubble is survivable; aborting a turn that had succeeded is not."""
|
||||
assert event_from_dict({"type": "something_new_in_r08"}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{"type": "text", "delta": "hi"},
|
||||
{"type": "tool_result", "id": "1", "name": "save_file", "ok": False, "output": "boom"},
|
||||
{"type": "plan_set", "steps": [{"title": "a"}]},
|
||||
{"type": "outputs_added", "paths": ["a.md"]},
|
||||
])
|
||||
def test_events_round_trip_back_into_the_legacy_shape(payload):
|
||||
"""Existing widgets still consume dicts; an event must render back into
|
||||
exactly what they already handle (EPIC R08 migrates them)."""
|
||||
event = event_from_dict(payload)
|
||||
|
||||
rendered = event.to_dict()
|
||||
|
||||
assert rendered["type"] == payload["type"]
|
||||
for key, value in payload.items():
|
||||
assert rendered[key] == value
|
||||
|
||||
|
||||
def test_events_are_immutable():
|
||||
"""They cross a thread boundary; a consumer must not be able to edit one
|
||||
out from under another consumer."""
|
||||
event = TextChunkEvent("hi")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
event.delta = "changed" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_collect_text_returns_the_answer_without_the_reasoning():
|
||||
events = [TextChunkEvent("Hel"), ReasoningChunkEvent("secret"), TextChunkEvent("lo")]
|
||||
|
||||
assert collect_text(events) == "Hello"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R04-T03 - the service, running the real engine
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture
|
||||
def isolated(monkeypatch, tmp_path: Path):
|
||||
"""Same ambient isolation the characterization suite uses."""
|
||||
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
||||
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
||||
from cowork_local.core import audit_log
|
||||
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _service(provider, **kwargs) -> ConversationApplicationService:
|
||||
return ConversationApplicationService(lambda _p, _m: provider, **kwargs)
|
||||
|
||||
|
||||
def _request(tmp_path: Path, prompt: str = "hi", **kwargs) -> ConversationExecutionRequest:
|
||||
return ConversationExecutionRequest.create(
|
||||
prompt, [{"role": "user", "content": prompt}],
|
||||
output_dir=str(tmp_path / "out"), **kwargs)
|
||||
|
||||
|
||||
def test_a_plain_turn_reports_text_and_a_final_answer(isolated):
|
||||
provider = FakeProvider([ScriptedTurn(text="Hello there.")])
|
||||
seen: List[Any] = []
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), on_event=seen.append)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "Hello there."
|
||||
assert [e.type for e in seen] == ["text", "assistant_done", "turn_completed"]
|
||||
# The conversation coming back is what the caller persists as new history.
|
||||
assert [m["role"] for m in result.messages] == ["system", "user", "assistant"]
|
||||
|
||||
|
||||
def test_a_turn_always_ends_with_exactly_one_completion_event(isolated):
|
||||
"""The end-of-turn signal the legacy engine never had: without it a
|
||||
cancelled turn and a failed turn look identical to a consumer."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
seen: List[Any] = []
|
||||
|
||||
_service(provider).run_turn(_request(isolated), on_event=seen.append)
|
||||
|
||||
completions = [e for e in seen if isinstance(e, TurnCompletedEvent)]
|
||||
assert len(completions) == 1
|
||||
assert seen[-1] is completions[0]
|
||||
|
||||
|
||||
def test_a_provider_failure_becomes_an_error_event_not_an_exception(isolated):
|
||||
"""Callers run this on a worker thread; an escaped exception kills the
|
||||
worker and the UI simply stops updating with nothing shown.
|
||||
|
||||
Two turns are scripted because the engine makes ONE silent recovery attempt
|
||||
before giving up (core/code_agent.py::_call_provider_with_recovery) - the
|
||||
service must report the failure only after that retry is also exhausted.
|
||||
"""
|
||||
provider = FakeProvider([ScriptedTurn(error="gateway exploded"),
|
||||
ScriptedTurn(error="gateway exploded")])
|
||||
seen: List[Any] = []
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), on_event=seen.append)
|
||||
|
||||
assert provider.call_count == 2 # original + one silent retry
|
||||
assert result.ok is False
|
||||
assert "gateway exploded" in result.error
|
||||
assert any(isinstance(e, ErrorEvent) for e in seen)
|
||||
assert isinstance(seen[-1], TurnCompletedEvent) # still a clean end
|
||||
|
||||
|
||||
def test_a_transient_provider_failure_is_recovered_without_surfacing(isolated):
|
||||
"""The engine's single retry must stay invisible: a turn that succeeds on
|
||||
the second attempt reports no error at all."""
|
||||
provider = FakeProvider([ScriptedTurn(error="connection reset"),
|
||||
ScriptedTurn(text="recovered answer")])
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated))
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "recovered answer"
|
||||
assert not [e for e in result.events if isinstance(e, ErrorEvent)]
|
||||
|
||||
|
||||
def test_a_cancelled_turn_is_reported_as_cancelled_not_failed(isolated):
|
||||
provider = FakeProvider([], strict=True)
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), cancel=lambda: True)
|
||||
|
||||
assert result.cancelled is True
|
||||
assert result.error == ""
|
||||
assert provider.call_count == 0
|
||||
assert result.events[-1].cancelled is True
|
||||
|
||||
|
||||
def test_a_tool_turn_reports_the_full_lifecycle_and_writes_the_file(isolated):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "note.md", "content": "# hi"})]),
|
||||
ScriptedTurn(text="Saved."),
|
||||
])
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated, "make a note"))
|
||||
|
||||
assert [e.type for e in result.events] == [
|
||||
"assistant_done", "tool_proposed", "tool_result",
|
||||
"text", "assistant_done", "turn_completed",
|
||||
]
|
||||
finished = [e for e in result.events if isinstance(e, ToolCallFinishedEvent)]
|
||||
assert finished[0].ok is True and finished[0].name == "save_file"
|
||||
written = list((isolated / "out").iterdir())
|
||||
assert len(written) == 1 and written[0].read_text(encoding="utf-8") == "# hi"
|
||||
|
||||
|
||||
def test_external_tools_are_supplied_through_the_injected_tool_source(isolated):
|
||||
executor = FakeToolExecutor(results={"ms365_send_mail": {"output": "sent"}})
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("ms365_send_mail", {"to": "a@b.c"})]),
|
||||
ScriptedTurn(text="Mail sent."),
|
||||
])
|
||||
service = _service(provider, tool_source=lambda: (executor.specs(), executor))
|
||||
|
||||
result = service.run_turn(_request(isolated, "mail them"))
|
||||
|
||||
assert executor.call_names == ["ms365_send_mail"]
|
||||
assert result.ok is True
|
||||
|
||||
|
||||
def test_a_broken_tool_source_degrades_to_no_external_tools(isolated):
|
||||
"""An MCP server that will not start must not stop the user from chatting -
|
||||
the behaviour the chat panel already relies on today."""
|
||||
def exploding_tool_source():
|
||||
raise RuntimeError("mcp server did not start")
|
||||
|
||||
provider = FakeProvider([ScriptedTurn(text="still works")])
|
||||
service = _service(provider, tool_source=exploding_tool_source)
|
||||
|
||||
result = service.run_turn(_request(isolated))
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "still works"
|
||||
|
||||
|
||||
def test_a_consumer_that_raises_does_not_abort_the_turn(isolated):
|
||||
"""A widget being torn down mid-turn must not take the turn with it."""
|
||||
provider = FakeProvider([ScriptedTurn(text="answer")])
|
||||
|
||||
def bad_consumer(_event):
|
||||
raise RuntimeError("widget already deleted")
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), on_event=bad_consumer)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "answer"
|
||||
|
||||
|
||||
def test_events_are_recorded_even_without_a_callback(isolated):
|
||||
"""Headless callers (the scheduler) read the event list afterwards instead
|
||||
of supplying a callback purely to collect it."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated))
|
||||
|
||||
assert [e.type for e in result.events] == ["text", "assistant_done", "turn_completed"]
|
||||
|
||||
|
||||
def test_the_request_permission_scope_reaches_the_engine(isolated):
|
||||
"""A read-only step must literally not be offered a writing tool - the scope
|
||||
has to survive the trip through the service or the restriction is silently
|
||||
dropped."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
_service(provider).run_turn(_request(isolated, allowed_tools=["read_file"]))
|
||||
|
||||
advertised = set(provider.calls[0].tool_names)
|
||||
assert "save_file" not in advertised
|
||||
assert "update_plan" in advertised
|
||||
|
||||
|
||||
def test_the_permission_gate_is_only_built_when_the_request_asks_for_it(isolated):
|
||||
built: List[Any] = []
|
||||
provider = FakeProvider([ScriptedTurn(text="ok"), ScriptedTurn(text="ok")])
|
||||
service = _service(provider, gate_factory=lambda req: built.append(req) or object())
|
||||
|
||||
service.run_turn(_request(isolated))
|
||||
assert built == []
|
||||
|
||||
service.run_turn(_request(isolated, confirm_commands=True))
|
||||
assert len(built) == 1
|
||||
|
||||
|
||||
def test_a_non_streamed_answer_still_produces_a_final_text(isolated):
|
||||
"""A turn whose answer arrived without text events must still report an
|
||||
answer - the scheduler writes it into output.md, and an empty string there
|
||||
reads to the user as "(no output)"."""
|
||||
provider = FakeProvider([ScriptedTurn(text="")])
|
||||
service = _service(provider)
|
||||
request = _request(isolated)
|
||||
|
||||
result = service.run_turn(request)
|
||||
|
||||
# run_cowork substitutes a placeholder for a reasoning-only reply; the
|
||||
# service must surface that rather than an empty answer.
|
||||
assert result.final_text != ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bridge completeness - the failure mode that motivated this test
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_every_event_the_engine_emits_has_a_typed_counterpart():
|
||||
"""Scan the engine sources for ``emit({"type": "..."})`` tags and assert the
|
||||
bridge knows all of them.
|
||||
|
||||
Written after a real miss: the first version of the bridge had no
|
||||
``notice`` event, so routing turns through the service would have silently
|
||||
swallowed Agent Security warnings and auto-compaction notices - the user
|
||||
would simply never see that a request had been blocked. An unknown tag is
|
||||
dropped by design (see event_from_dict), which is safe for a NEW event but
|
||||
hides a forgotten one; this test is what turns that silence into a failure.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from cowork_local.domain.agents.agent_event import EVENT_TYPES
|
||||
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
sources = ["core/chat_agent.py", "core/code_agent.py", "core/agent_security.py",
|
||||
"core/context_budget.py", "core/task_executors.py"]
|
||||
emitted = set()
|
||||
for rel in sources:
|
||||
text = (repo / rel).read_text(encoding="utf-8")
|
||||
# Only tags inside an emit(...) call; a bare {"type": "object"} in a
|
||||
# JSON-Schema tool definition is not an event.
|
||||
for match in re.finditer(r'emit(?:_and_autosave)?\(\s*\{\s*"type":\s*"([a-z_]+)"', text):
|
||||
emitted.add(match.group(1))
|
||||
|
||||
missing = sorted(emitted - set(EVENT_TYPES))
|
||||
assert not missing, (
|
||||
f"engine emits {missing} but domain/agents/agent_event.py has no typed "
|
||||
"counterpart - those events would be silently dropped by event_from_dict"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""EPIC R05-T04: before this change, ``core/chat_agent.py::run_cowork`` called
|
||||
``extra_executor(name, args)`` directly for any MCP/connector tool — no
|
||||
permission check at all, regardless of the "confirm before running commands"
|
||||
setting. This pins the fix: an extra tool now goes through the same
|
||||
``ToolPolicyGateway`` as ``run_command``, using the conservative default
|
||||
capability (``UNKNOWN_SOURCE_CAPABILITIES``) since MCP tools carry no
|
||||
standard risk metadata.
|
||||
|
||||
Runs the real engine via :class:`FakeProvider`, matching
|
||||
``tests/characterization/test_run_cowork.py``'s approach.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from cowork_local.core.chat_agent import run_cowork
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
class _RecordingGate:
|
||||
def __init__(self, approve: bool):
|
||||
self.approve = approve
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool:
|
||||
self.calls.append(payload)
|
||||
return self.approve
|
||||
|
||||
|
||||
_EXTRA_SPEC = ToolSpec(name="github__delete_repo", description="", parameters={"type": "object"})
|
||||
|
||||
|
||||
def _run(tmp_path, provider, gate, executed: List[str]):
|
||||
events: List[Dict[str, Any]] = []
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
|
||||
|
||||
def extra_executor(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
executed.append(name)
|
||||
return {"ok": True, "output": "done"}
|
||||
|
||||
run_cowork(provider, messages, tmp_path, events.append, gate=gate,
|
||||
extra_tools=[_EXTRA_SPEC], extra_executor=extra_executor)
|
||||
return events
|
||||
|
||||
|
||||
def test_mcp_style_tool_is_rejected_without_ever_calling_the_executor(tmp_path):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("github__delete_repo", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=False)
|
||||
executed: List[str] = []
|
||||
events = _run(tmp_path, provider, gate, executed)
|
||||
|
||||
assert len(gate.calls) == 1 and gate.calls[0]["name"] == "github__delete_repo"
|
||||
assert executed == [] # rejected BEFORE the extra_executor ever ran
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is False
|
||||
|
||||
|
||||
def test_mcp_style_tool_runs_once_approved(tmp_path):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("github__delete_repo", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=True)
|
||||
executed: List[str] = []
|
||||
events = _run(tmp_path, provider, gate, executed)
|
||||
|
||||
assert executed == ["github__delete_repo"]
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is True
|
||||
|
||||
|
||||
def test_no_gate_preserves_auto_run_for_extra_tools(tmp_path):
|
||||
"""``gate=None`` is Cowork's existing "no confirmation configured" state —
|
||||
must still auto-run, exactly like before this EPIC."""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("github__delete_repo", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
executed: List[str] = []
|
||||
events = _run(tmp_path, provider, None, executed)
|
||||
|
||||
assert executed == ["github__delete_repo"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""EPIC R06-T03: ExecutionWorkspace names the output-dir/scratch-dir split
|
||||
that already exists in ``core/chat_agent.py`` (``.scratch`` under the
|
||||
workspace root) without changing where anything lands."""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
from cowork_local.infrastructure.filesystem.execution_workspace import ExecutionWorkspace
|
||||
|
||||
|
||||
def test_output_dir_is_the_workspace_root_itself(tmp_path):
|
||||
session = WorkspaceSession.unscoped(tmp_path)
|
||||
workspace = ExecutionWorkspace(session, turn_id="turn-1")
|
||||
|
||||
assert workspace.output_dir == tmp_path
|
||||
|
||||
|
||||
def test_scratch_dir_matches_the_existing_flat_convention(tmp_path):
|
||||
"""core/chat_agent.py::_cleanup_cowork_intermediates operates on
|
||||
``output_dir / ".scratch"`` with no per-turn subfolder — this must agree,
|
||||
or cleanup_scratch() would target a directory nothing ever wrote to."""
|
||||
session = WorkspaceSession.unscoped(tmp_path)
|
||||
workspace = ExecutionWorkspace(session, turn_id="turn-1")
|
||||
|
||||
assert workspace.scratch_dir == tmp_path / ".scratch"
|
||||
|
||||
|
||||
def test_ensure_dirs_creates_both_folders(tmp_path):
|
||||
session = WorkspaceSession.unscoped(tmp_path / "root")
|
||||
workspace = ExecutionWorkspace(session, turn_id="t")
|
||||
workspace.ensure_dirs()
|
||||
|
||||
assert workspace.output_dir.is_dir()
|
||||
assert workspace.scratch_dir.is_dir()
|
||||
|
||||
|
||||
def test_cleanup_scratch_removes_it_and_leaves_output_dir_alone(tmp_path):
|
||||
session = WorkspaceSession.unscoped(tmp_path)
|
||||
workspace = ExecutionWorkspace(session, turn_id="t")
|
||||
workspace.ensure_dirs()
|
||||
(workspace.scratch_dir / "helper.py").write_text("print(1)", encoding="utf-8")
|
||||
(workspace.output_dir / "deliverable.txt").write_text("done", encoding="utf-8")
|
||||
|
||||
workspace.cleanup_scratch()
|
||||
|
||||
assert not workspace.scratch_dir.exists()
|
||||
assert (workspace.output_dir / "deliverable.txt").exists()
|
||||
|
||||
|
||||
def test_cleanup_scratch_is_a_no_op_when_never_created(tmp_path):
|
||||
workspace = ExecutionWorkspace(WorkspaceSession.unscoped(tmp_path), turn_id="t")
|
||||
workspace.cleanup_scratch() # must not raise
|
||||
@@ -0,0 +1,62 @@
|
||||
"""EPIC R06-T05: FileWorkspaceService gives File Explorer / AI Editor the
|
||||
same safe file operations the agent tool loop already has, via the SAME
|
||||
``core/tools.py::execute_tool`` dispatch (not a reimplementation)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces import FileWorkspaceService
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
|
||||
|
||||
def _service(tmp_path) -> FileWorkspaceService:
|
||||
return FileWorkspaceService(WorkspaceSession.unscoped(tmp_path))
|
||||
|
||||
|
||||
def test_write_then_read_round_trips(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
written = service.write_file("notes.md", "# Hello")
|
||||
assert written["ok"] is True
|
||||
|
||||
read = service.read_preview("notes.md")
|
||||
assert read == {"ok": True, "output": "# Hello"}
|
||||
|
||||
|
||||
def test_list_tree_reflects_written_files(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.write_file("a.txt", "x")
|
||||
listing = service.list_tree()
|
||||
assert listing["ok"] is True and "a.txt" in listing["output"]
|
||||
|
||||
|
||||
def test_apply_edit_uses_the_context_anchored_replace(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.write_file("code.py", "value = 1\n")
|
||||
edited = service.apply_edit("code.py", "value = 1", "value = 2")
|
||||
assert edited["ok"] is True
|
||||
assert service.read_preview("code.py")["output"].strip() == "value = 2"
|
||||
|
||||
|
||||
def test_apply_edit_reports_ambiguous_match_like_the_agent_tool_does(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.write_file("code.py", "x = 1\nx = 1\n")
|
||||
edited = service.apply_edit("code.py", "x = 1", "x = 2")
|
||||
assert edited["ok"] is False
|
||||
assert "appears" in edited["output"]
|
||||
|
||||
|
||||
def test_path_escape_is_refused_not_a_crash(tmp_path):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(tmp_path / "outside.txt").write_text("secret", encoding="utf-8")
|
||||
service = FileWorkspaceService(WorkspaceSession.unscoped(workspace))
|
||||
|
||||
result = service.read_preview("../outside.txt")
|
||||
assert result["ok"] is False
|
||||
assert "outside the working folder" in result["output"]
|
||||
|
||||
|
||||
def test_write_preserves_subfolders_unlike_cowork_flatten_writes(tmp_path):
|
||||
"""File Explorer must not collapse a write into the workspace root the
|
||||
way Cowork's agent context does (flatten_writes=True there, False here)."""
|
||||
service = _service(tmp_path)
|
||||
service.write_file("sub/dir/file.txt", "content")
|
||||
assert (tmp_path / "sub" / "dir" / "file.txt").read_text(encoding="utf-8") == "content"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""EPIC R05-T05: the MCP connection lifecycle extracted out of
|
||||
``state.py::AppContext`` into :class:`McpToolSourceManager`.
|
||||
|
||||
Uses a fake connection (no real subprocess/asyncio loop) so these tests run in
|
||||
milliseconds and don't depend on any actual MCP server being installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from cowork_local.infrastructure.mcp import McpToolSourceManager
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
"""Stands in for ``core.mcp_client.McpServerConnection`` — tracks
|
||||
start/stop calls instead of spawning anything."""
|
||||
|
||||
instances: List["_FakeConnection"] = []
|
||||
|
||||
def __init__(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None):
|
||||
self.name = name
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.env = env
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self._alive = True
|
||||
_FakeConnection.instances.append(self)
|
||||
|
||||
def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stopped = True
|
||||
self._alive = False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._alive
|
||||
|
||||
|
||||
def _manager() -> McpToolSourceManager:
|
||||
_FakeConnection.instances.clear()
|
||||
return McpToolSourceManager(connection_factory=_FakeConnection)
|
||||
|
||||
|
||||
def test_ensure_starts_once_and_caches_the_live_connection():
|
||||
mgr = _manager()
|
||||
first = mgr.ensure("github", "npx", ["-y", "github-mcp"])
|
||||
second = mgr.ensure("github", "npx", ["-y", "github-mcp"])
|
||||
|
||||
assert first is second # same connection reused, not a second subprocess
|
||||
assert len(_FakeConnection.instances) == 1
|
||||
assert first.started is True
|
||||
|
||||
|
||||
def test_two_concurrent_ensures_for_different_servers_dont_collide():
|
||||
mgr = _manager()
|
||||
a = mgr.ensure("server-a", "cmd-a")
|
||||
b = mgr.ensure("server-b", "cmd-b")
|
||||
assert a is not b
|
||||
assert {c.name for c in mgr.active()} == {"server-a", "server-b"}
|
||||
|
||||
|
||||
def test_ensure_restarts_when_the_cached_connection_died():
|
||||
mgr = _manager()
|
||||
first = mgr.ensure("flaky", "cmd")
|
||||
first.stop() # simulate the subprocess crashing
|
||||
assert mgr.is_alive("flaky") is False
|
||||
|
||||
second = mgr.ensure("flaky", "cmd")
|
||||
assert second is not first
|
||||
assert len(_FakeConnection.instances) == 2
|
||||
|
||||
|
||||
def test_a_server_that_fails_to_start_returns_none_and_isnt_cached():
|
||||
class _DyingConnection(_FakeConnection):
|
||||
def start(self) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
mgr = McpToolSourceManager(connection_factory=_DyingConnection)
|
||||
assert mgr.ensure("broken", "cmd") is None
|
||||
assert mgr.get("broken") is None
|
||||
|
||||
|
||||
def test_stop_removes_one_connection_without_touching_others():
|
||||
mgr = _manager()
|
||||
mgr.ensure("keep", "cmd")
|
||||
doomed = mgr.ensure("drop", "cmd")
|
||||
|
||||
mgr.stop("drop")
|
||||
|
||||
assert doomed.stopped is True
|
||||
assert mgr.get("drop") is None
|
||||
assert mgr.get("keep") is not None
|
||||
|
||||
|
||||
def test_stop_all_stops_every_connection_and_clears_the_cache():
|
||||
mgr = _manager()
|
||||
mgr.ensure("a", "cmd")
|
||||
mgr.ensure("b", "cmd")
|
||||
|
||||
mgr.stop_all()
|
||||
|
||||
assert all(c.stopped for c in _FakeConnection.instances)
|
||||
assert mgr.active() == []
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Unit tests for :mod:`application.model_routing` (R03-T03).
|
||||
|
||||
These run against a hand-written fake router rather than ``core.routing``: the
|
||||
point of the service is the DECISION policy around the engine (mode handling,
|
||||
the manual confirm, never-raise behaviour, failure fallback), and mixing in the
|
||||
real scorer would test the wrong thing and drag the suite over its time budget.
|
||||
|
||||
No Qt, no config, no network - the whole file runs in milliseconds, which is the
|
||||
concrete payoff of moving this logic out of ``ui/chat_panel.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.model_routing import (
|
||||
RoutingApplicationService,
|
||||
RoutingDecision,
|
||||
RoutingMode,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test doubles shaped like core.routing's RouteResult / SwitchDecision
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass
|
||||
class _TaskType:
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Decision:
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RouteResult:
|
||||
should_switch: bool
|
||||
to: Optional[Tuple[str, str]] = None
|
||||
task_type: Any = None
|
||||
decision: Any = None
|
||||
|
||||
def target(self) -> Optional[Tuple[str, str]]:
|
||||
return self.to
|
||||
|
||||
|
||||
class _FakeRouter:
|
||||
"""Records every route() call and replays a canned result."""
|
||||
|
||||
def __init__(self, result: Any = None, raises: bool = False) -> None:
|
||||
self._result = result or _RouteResult(should_switch=False, decision=_Decision())
|
||||
self._raises = raises
|
||||
self.calls: List[dict] = []
|
||||
|
||||
def route(self, surface, prompt, current_provider, current_model, **kwargs):
|
||||
self.calls.append({"surface": surface, "prompt": prompt,
|
||||
"provider": current_provider, "model": current_model, **kwargs})
|
||||
if self._raises:
|
||||
raise RuntimeError("assessment store is corrupt")
|
||||
return self._result
|
||||
|
||||
|
||||
def _switch_to(provider: str, model: str, gain: float = 0.2, task: str = "coding") -> _RouteResult:
|
||||
return _RouteResult(
|
||||
should_switch=True, to=(provider, model), task_type=_TaskType(task),
|
||||
decision=_Decision(score_gain=gain, reason=f"{task} fit beats current by {gain}"),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mode parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("off", RoutingMode.OFF),
|
||||
("AUTO", RoutingMode.AUTO),
|
||||
(" manual ", RoutingMode.MANUAL),
|
||||
("fallback", RoutingMode.FALLBACK),
|
||||
])
|
||||
def test_parse_accepts_the_config_spellings(raw, expected):
|
||||
assert RoutingMode.parse(raw) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", None, "nonsense", 0])
|
||||
def test_parse_degrades_unknown_values_to_off(raw):
|
||||
"""A corrupt setting must leave the user's own model alone rather than
|
||||
silently moving their work onto another model."""
|
||||
assert RoutingMode.parse(raw) is RoutingMode.OFF
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OFF
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_off_never_consults_the_engine():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "hi", "openai_compat", "gpt-4o-mini",
|
||||
mode="off")
|
||||
|
||||
assert router.calls == [] # not even scored: OFF costs nothing
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_blank_prompt_is_never_routed():
|
||||
"""An empty message carries no signal to classify; all three legacy copies
|
||||
guarded this and the guard has to survive the move."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", " ", "openai_compat", "m", mode="auto")
|
||||
|
||||
assert router.calls == []
|
||||
assert decision.switched is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AUTO
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_auto_switches_silently_and_reports_the_target():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude-sonnet-4-6", gain=0.31))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "write a function", "openai_compat", "gpt-4o-mini",
|
||||
mode="auto")
|
||||
|
||||
assert decision.switched is True
|
||||
assert decision.target() == ("anthropic", "claude-sonnet-4-6")
|
||||
assert decision.task_type == "coding"
|
||||
assert decision.score_gain == pytest.approx(0.31)
|
||||
assert decision.should_notify is True
|
||||
|
||||
|
||||
def test_auto_keeps_the_current_model_when_no_candidate_wins():
|
||||
router = _FakeRouter(_RouteResult(should_switch=False, decision=_Decision(reason="no gain")))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "hello", "openai_compat", "gpt-4o-mini", mode="auto")
|
||||
|
||||
assert decision.switched is False
|
||||
# The decision still names a model to run on, so the call site never has to
|
||||
# re-derive the fallback itself - the exact drift the three copies suffered.
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
assert decision.should_notify is False
|
||||
|
||||
|
||||
def test_auto_never_asks_for_confirmation():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
asked: List[RoutingDecision] = []
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
service.route_turn("cowork", "q", "openai_compat", "m", mode="auto",
|
||||
confirm=lambda d: asked.append(d) or True)
|
||||
|
||||
assert asked == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MANUAL
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_manual_switches_only_after_the_user_approves():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
seen: List[RoutingDecision] = []
|
||||
|
||||
def confirm(proposal: RoutingDecision) -> bool:
|
||||
seen.append(proposal)
|
||||
return True
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m",
|
||||
mode="manual", confirm=confirm)
|
||||
|
||||
assert decision.switched is True
|
||||
assert decision.target() == ("anthropic", "claude")
|
||||
# The dialog is handed the full proposal so it can explain the trade-off.
|
||||
assert seen[0].model == "claude"
|
||||
assert seen[0].score_gain > 0
|
||||
|
||||
|
||||
def test_manual_keeps_the_current_model_when_declined():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "gpt-4o-mini",
|
||||
mode="manual", confirm=lambda d: False)
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.declined is True
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_manual_without_a_confirm_callback_does_not_switch():
|
||||
"""A headless caller (scheduler) has nobody to ask, so Manual must behave as
|
||||
"not approved" rather than as "approved by default"."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m", mode="manual")
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.declined is True
|
||||
|
||||
|
||||
def test_a_confirm_dialog_that_raises_counts_as_declined():
|
||||
"""If the modal blows up (window closing mid-turn) the safe reading is that
|
||||
the user did NOT consent to running on another model."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
def confirm(_proposal):
|
||||
raise RuntimeError("dialog destroyed")
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m",
|
||||
mode="manual", confirm=confirm)
|
||||
|
||||
assert decision.switched is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# FALLBACK
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_fallback_does_not_switch_up_front():
|
||||
"""The whole point of the mode: honour the user's model choice until it
|
||||
actually fails."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "gpt-4o-mini",
|
||||
mode="fallback")
|
||||
|
||||
assert router.calls == []
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_fallback_switches_after_a_failure():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude", gain=0.4))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.fallback_after_failure("cowork", "q", "openai_compat", "gpt-4o-mini",
|
||||
mode="fallback")
|
||||
|
||||
assert decision is not None
|
||||
assert decision.switched is True
|
||||
assert decision.target() == ("anthropic", "claude")
|
||||
assert "failed" in decision.reason
|
||||
|
||||
|
||||
def test_fallback_never_returns_the_model_that_just_failed():
|
||||
"""Retrying the model that just went down would spin on the outage."""
|
||||
router = _FakeRouter(_switch_to("openai_compat", "gpt-4o-mini"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
assert service.fallback_after_failure(
|
||||
"cowork", "q", "openai_compat", "gpt-4o-mini", mode="fallback") is None
|
||||
|
||||
|
||||
def test_fallback_returns_none_when_there_is_no_alternative():
|
||||
router = _FakeRouter(_RouteResult(should_switch=False, decision=_Decision()))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
assert service.fallback_after_failure("cowork", "q", "openai_compat", "m",
|
||||
mode="auto") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["off", "manual"])
|
||||
def test_off_and_manual_do_not_auto_recover_from_a_failure(mode):
|
||||
"""Both modes exist to keep the user in control of which model runs their
|
||||
work; moving it on failure would break that promise silently."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
assert service.fallback_after_failure("cowork", "q", "openai_compat", "m",
|
||||
mode=mode) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Robustness - routing must never break a chat turn
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_engine_failure_degrades_to_keeping_the_current_model():
|
||||
service = RoutingApplicationService(_FakeRouter(raises=True))
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "gpt-4o-mini", mode="auto")
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_engine_failure_during_fallback_returns_none():
|
||||
"""A broken router must not mask the original provider error with its own."""
|
||||
service = RoutingApplicationService(_FakeRouter(raises=True))
|
||||
|
||||
assert service.fallback_after_failure("cowork", "q", "p", "m", mode="auto") is None
|
||||
|
||||
|
||||
def test_a_malformed_route_result_is_treated_as_no_switch():
|
||||
"""The engine is a legacy module still under refactor; a missing attribute
|
||||
must degrade, not raise into the middle of a turn."""
|
||||
class _Garbage:
|
||||
should_switch = True # claims a switch but exposes no target()
|
||||
|
||||
service = RoutingApplicationService(_FakeRouter(_Garbage()))
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m", mode="auto")
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "m")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Per-surface mode lookup
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_mode_is_read_per_surface_when_not_passed_explicitly():
|
||||
"""Each screen has its own Off/Auto/Manual toggle, and workspaces override
|
||||
it - so the surface, not a global setting, decides."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
modes = {"cowork": "auto", "ai_edit": "off"}
|
||||
service = RoutingApplicationService(router, mode_reader=modes.get)
|
||||
|
||||
assert service.route_turn("cowork", "q", "p", "m").switched is True
|
||||
assert service.route_turn("ai_edit", "q", "p", "m").switched is False
|
||||
|
||||
|
||||
def test_a_failing_mode_reader_falls_back_to_off():
|
||||
def broken(_surface):
|
||||
raise KeyError("config not loaded yet")
|
||||
|
||||
service = RoutingApplicationService(_FakeRouter(_switch_to("a", "b")),
|
||||
mode_reader=broken)
|
||||
|
||||
assert service.route_turn("cowork", "q", "p", "m").switched is False
|
||||
|
||||
|
||||
def test_required_capabilities_are_passed_through_to_the_engine():
|
||||
"""An image turn must only be routed to a vision-capable model; the filter
|
||||
has to reach the scorer or the constraint is silently dropped."""
|
||||
router = _FakeRouter()
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
service.route_turn("cowork", "describe this", "p", "m", mode="auto",
|
||||
required_capabilities=["vision"])
|
||||
|
||||
assert router.calls[0]["required_capabilities"] == ["vision"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Integration test for the AppContext routing wiring (R03-T04 / R03-T05).
|
||||
|
||||
The three chat surfaces now call ``ctx.routing_application()`` instead of each
|
||||
carrying their own copy of the routing algorithm. The unit tests cover the
|
||||
policy; this file covers the WIRING, which unit tests with a fake router cannot
|
||||
see:
|
||||
|
||||
* the service is built and memoised on the context
|
||||
* it reads the per-workspace mode through ``project_routing_mode``
|
||||
* the legacy ``core.routing.RoutingService`` is what sits underneath it
|
||||
* ``fallback`` survives a round trip through the per-workspace mode store
|
||||
|
||||
Still Qt-free: ``AppContext`` itself imports no widgets, and the config is
|
||||
written into a tmp dir so nothing touches ``~/.cowork_local``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.model_routing import (
|
||||
RoutingApplicationService,
|
||||
RoutingMode,
|
||||
)
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(tmp_path: Path) -> AppContext:
|
||||
"""An AppContext backed by a throwaway config file."""
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def test_routing_application_is_built_and_memoised(ctx):
|
||||
"""One instance per app: the pending-switch registry underneath it must be
|
||||
shared by every surface, so a second call has to return the same object."""
|
||||
first = ctx.routing_application()
|
||||
|
||||
assert isinstance(first, RoutingApplicationService)
|
||||
assert ctx.routing_application() is first
|
||||
|
||||
|
||||
def test_the_legacy_engine_sits_underneath_the_new_service():
|
||||
"""Strangler-fig check (ADR-001 section 4): the scoring engine is reused, not
|
||||
reimplemented. If this ever stops holding, the assessment scores the
|
||||
scheduler probes would no longer be the ones routing decisions use."""
|
||||
from cowork_local.core.routing.service import RoutingService
|
||||
|
||||
config = AppConfig.load(Path("does-not-exist.json"))
|
||||
context = AppContext(config)
|
||||
|
||||
service = context.routing_application()
|
||||
|
||||
assert isinstance(service._router, RoutingService)
|
||||
assert service._router is context.routing()
|
||||
|
||||
|
||||
def test_mode_is_read_through_the_per_workspace_lookup(ctx, monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_mode(surface: str) -> str:
|
||||
seen.append(surface)
|
||||
return "off"
|
||||
|
||||
monkeypatch.setattr(ctx, "project_routing_mode", fake_mode)
|
||||
# Built after the patch so the service captures the patched reader.
|
||||
service = RoutingApplicationService(ctx.routing(), mode_reader=ctx.project_routing_mode)
|
||||
|
||||
decision = service.route_turn("co4e", "hello", "openai_compat", "gpt-4o-mini")
|
||||
|
||||
assert seen == ["co4e"]
|
||||
assert decision.switched is False
|
||||
|
||||
|
||||
def test_routing_off_by_default_leaves_the_selected_model_alone(ctx):
|
||||
"""Default config has routing off on every surface, so a fresh install must
|
||||
never move a turn to another model."""
|
||||
decision = ctx.routing_application().route_turn(
|
||||
"cowork", "write a function", "openai_compat", "gpt-4o-mini")
|
||||
|
||||
assert decision.mode is RoutingMode.OFF
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["off", "auto", "manual", "fallback"])
|
||||
def test_every_mode_survives_a_round_trip_through_the_config(ctx, mode):
|
||||
"""``fallback`` is new (R03-T03); the per-surface store used to whitelist
|
||||
only three values and would have silently downgraded it to "off"."""
|
||||
ctx.set_project_routing_mode("cowork", mode)
|
||||
|
||||
assert ctx.project_routing_mode("cowork") == mode
|
||||
|
||||
|
||||
def test_an_unknown_mode_still_falls_back_to_off(ctx):
|
||||
ctx.set_project_routing_mode("cowork", "turbo")
|
||||
|
||||
assert ctx.project_routing_mode("cowork") == "off"
|
||||
|
||||
|
||||
def test_a_real_route_call_never_raises_without_any_assessments(ctx):
|
||||
"""The store is empty on a fresh install. Routing must degrade to "keep the
|
||||
current model" rather than raise into the middle of the first message."""
|
||||
ctx.set_project_routing_mode("cowork", "auto")
|
||||
|
||||
decision = ctx.routing_application().route_turn(
|
||||
"cowork", "hello there", "openai_compat", "gpt-4o-mini")
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Unit tests for EPIC R05: the tool descriptor/registry (R05-T01), the split
|
||||
built-in handlers (R05-T02), and the policy gateway (R05-T03).
|
||||
|
||||
The gateway tests assert the SAME capability set each engine used to hard-code
|
||||
as a name tuple still gets gated after the switch to capability lookup — that
|
||||
equivalence is the whole point of R05-T03, not an incidental detail.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.conversations import ToolPolicyGateway
|
||||
from cowork_local.core.tools import TOOL_SPECS, ToolContext, execute_tool
|
||||
from cowork_local.domain.tools import ToolCapability, ToolDescriptor, default_registry
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R05-T01 - ToolDescriptor / ToolRegistry
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_capability_flags_compose():
|
||||
install = ToolDescriptor("install_package", "", {}, ToolCapability.WRITE | ToolCapability.EXECUTE)
|
||||
assert install.has(ToolCapability.WRITE)
|
||||
assert install.has(ToolCapability.EXECUTE)
|
||||
assert not install.has(ToolCapability.NETWORK)
|
||||
|
||||
|
||||
def test_default_registry_matches_todays_hardcoded_gating_sets():
|
||||
"""The two literal sets this EPIC replaces:
|
||||
``core/tools.py::WRITE_TOOLS`` and ``core/chat_agent.py``'s
|
||||
``("run_command", "install_package")`` tuple. The registry must agree
|
||||
with both, or the capability switch silently changes who gets gated."""
|
||||
registry = default_registry(TOOL_SPECS)
|
||||
|
||||
execute_gated = {d.name for d in registry.all() if d.has(ToolCapability.EXECUTE)}
|
||||
assert execute_gated == {"run_command", "install_package"}
|
||||
|
||||
write_gated = {d.name for d in registry.all() if d.has(ToolCapability.WRITE)}
|
||||
assert write_gated == {"write_file", "edit_file", "install_package"}
|
||||
|
||||
|
||||
def test_unregistered_tool_has_no_capabilities():
|
||||
registry = default_registry(TOOL_SPECS)
|
||||
assert registry.capabilities_for("no_such_tool") is ToolCapability.NONE
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R05-T02 - core/tools.py dispatch, now built from the split infra modules
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_execute_tool_still_dispatches_every_built_in(tmp_path):
|
||||
ctx = ToolContext(tmp_path)
|
||||
written = execute_tool(ctx, "write_file", {"path": "a.txt", "content": "hi"})
|
||||
assert written["ok"] is True
|
||||
read = execute_tool(ctx, "read_file", {"path": "a.txt"})
|
||||
assert read == {"ok": True, "output": "hi"}
|
||||
edited = execute_tool(ctx, "edit_file", {"path": "a.txt", "old_string": "hi", "new_string": "bye"})
|
||||
assert edited["ok"] is True
|
||||
assert execute_tool(ctx, "read_file", {"path": "a.txt"})["output"] == "bye"
|
||||
listing = execute_tool(ctx, "list_dir", {})
|
||||
assert listing["ok"] is True and "a.txt" in listing["output"]
|
||||
|
||||
|
||||
def test_execute_tool_reports_unknown_name(tmp_path):
|
||||
ctx = ToolContext(tmp_path)
|
||||
result = execute_tool(ctx, "not_a_real_tool", {})
|
||||
assert result == {"ok": False, "output": "Tool not found: not_a_real_tool"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R05-T03 - ToolPolicyGateway
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _RecordingGate:
|
||||
def __init__(self, approve: bool):
|
||||
self.approve = approve
|
||||
self.calls: list = []
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool:
|
||||
self.calls.append(payload)
|
||||
return self.approve
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cowork_policy() -> ToolPolicyGateway:
|
||||
"""Same construction as ``core/chat_agent.py``'s module-level
|
||||
``_COWORK_TOOL_POLICY`` - EXECUTE is exactly what Cowork used to gate via
|
||||
the literal ``("run_command", "install_package")`` tuple."""
|
||||
return ToolPolicyGateway(default_registry(TOOL_SPECS), ToolCapability.EXECUTE)
|
||||
|
||||
|
||||
def test_no_gate_means_auto_run(cowork_policy):
|
||||
assert cowork_policy.allow("run_command", None, {}) is True
|
||||
|
||||
|
||||
def test_read_only_tool_never_asks_the_gate(cowork_policy):
|
||||
gate = _RecordingGate(approve=False) # would reject if asked
|
||||
assert cowork_policy.allow("write_file", gate, {}) is True
|
||||
assert gate.calls == [] # never consulted - write_file isn't EXECUTE
|
||||
|
||||
|
||||
def test_gated_capability_consults_the_gate_and_honors_its_answer(cowork_policy):
|
||||
approving = _RecordingGate(approve=True)
|
||||
assert cowork_policy.allow("run_command", approving, {"name": "run_command"}) is True
|
||||
assert approving.calls == [{"name": "run_command"}]
|
||||
|
||||
rejecting = _RecordingGate(approve=False)
|
||||
assert cowork_policy.allow("install_package", rejecting, {}) is False
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Unit tests for :mod:`infrastructure.telemetry.usage_sink` (R03-T06).
|
||||
|
||||
Two things are being protected here:
|
||||
|
||||
1. The **numbers do not change**. Extracting usage recording out of the two
|
||||
providers is only safe if the events built from each wire format carry
|
||||
exactly what ``core.usage_tracker.record`` used to receive - a silent change
|
||||
would corrupt the Dashboard's cost history.
|
||||
2. The **sink can never break a turn**. Telemetry is observability; a broken
|
||||
store must be swallowed (and logged), never raised into a chat turn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.telemetry import usage_sink as telemetry
|
||||
from cowork_local.providers.anthropic import AnthropicProvider
|
||||
from cowork_local.providers.base import Provider
|
||||
from cowork_local.providers.openai_compat import OpenAICompatProvider
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Event construction - one per wire format
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_openai_usage_block_maps_onto_the_canonical_event():
|
||||
event = telemetry.openai_usage_event("openai_compat", "gpt-4o-mini", {
|
||||
"prompt_tokens": 120,
|
||||
"completion_tokens": 45,
|
||||
"prompt_tokens_details": {"cached_tokens": 100},
|
||||
})
|
||||
|
||||
assert (event.input_tokens, event.output_tokens, event.cached_tokens) == (120, 45, 100)
|
||||
assert event.estimated is False
|
||||
# Cached tokens are a SUBSET of input, so adding them would double-count.
|
||||
assert event.total_tokens == 165
|
||||
|
||||
|
||||
def test_anthropic_usage_accumulator_maps_onto_the_canonical_event():
|
||||
"""Anthropic reports input on message_start and output on message_delta, so
|
||||
providers/anthropic.py accumulates them into in/out/cache keys."""
|
||||
event = telemetry.anthropic_usage_event("anthropic", "claude", {
|
||||
"in": 200, "out": 80, "cache": 150,
|
||||
})
|
||||
|
||||
assert (event.input_tokens, event.output_tokens, event.cached_tokens) == (200, 80, 150)
|
||||
assert event.estimated is False
|
||||
|
||||
|
||||
def test_a_missing_usage_block_produces_an_estimated_event():
|
||||
event = telemetry.estimated_event("ollama", "llama3.1", "x" * 400, "y" * 40)
|
||||
|
||||
assert event.estimated is True
|
||||
assert event.input_tokens == 100 # ~4 characters per token
|
||||
assert event.output_tokens == 10
|
||||
assert event.cached_tokens == 0
|
||||
|
||||
|
||||
def test_estimation_matches_the_legacy_tracker_formula():
|
||||
"""The extraction must not shift a single recorded number, so the estimator
|
||||
is pinned against the one it replaced."""
|
||||
from cowork_local.core import usage_tracker
|
||||
|
||||
for text in ("", "short", "x" * 4001, "unicode - tiếng Việt"):
|
||||
assert telemetry.estimate_tokens(text) == usage_tracker.estimate_tokens(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sinks
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_recording_sink_collects_events_for_assertions():
|
||||
sink = telemetry.RecordingUsageSink()
|
||||
|
||||
sink.record(telemetry.UsageEvent("p", "m", input_tokens=10, output_tokens=5))
|
||||
sink.record(telemetry.UsageEvent("p", "m", input_tokens=1, output_tokens=1))
|
||||
|
||||
assert len(sink.events) == 2
|
||||
assert sink.total_tokens == 17
|
||||
|
||||
|
||||
def test_null_sink_discards_without_error():
|
||||
telemetry.NullUsageSink().record(telemetry.UsageEvent("p", "m"))
|
||||
|
||||
|
||||
def test_tracker_sink_forwards_every_field_positionally():
|
||||
"""``core.usage_tracker.record`` takes positional counts plus an ``estimated``
|
||||
keyword; the adapter has to preserve that exact call shape."""
|
||||
seen: Dict[str, Any] = {}
|
||||
|
||||
class _Tracker:
|
||||
@staticmethod
|
||||
def record(provider, model, input_tokens, output_tokens, cached_tokens,
|
||||
estimated=False):
|
||||
# Fields captured explicitly rather than via locals(), which would
|
||||
# also drag in the closed-over `seen` binding itself.
|
||||
seen.update({"provider": provider, "model": model,
|
||||
"input_tokens": input_tokens, "output_tokens": output_tokens,
|
||||
"cached_tokens": cached_tokens, "estimated": estimated})
|
||||
|
||||
telemetry.UsageTrackerSink(tracker=_Tracker()).record(
|
||||
telemetry.UsageEvent("anthropic", "claude", 7, 3, 2, estimated=True))
|
||||
|
||||
assert seen == {"provider": "anthropic", "model": "claude", "input_tokens": 7,
|
||||
"output_tokens": 3, "cached_tokens": 2, "estimated": True}
|
||||
|
||||
|
||||
def test_a_failing_tracker_never_raises_into_the_turn():
|
||||
class _Broken:
|
||||
@staticmethod
|
||||
def record(*_args, **_kwargs):
|
||||
raise OSError("usage store is read-only")
|
||||
|
||||
# Must not raise - the turn that produced this event has already succeeded.
|
||||
telemetry.UsageTrackerSink(tracker=_Broken()).record(telemetry.UsageEvent("p", "m"))
|
||||
|
||||
|
||||
def test_set_default_sink_returns_the_previous_one_for_restoration():
|
||||
replacement = telemetry.RecordingUsageSink()
|
||||
|
||||
previous = telemetry.set_default_sink(replacement)
|
||||
try:
|
||||
assert telemetry.default_sink is replacement
|
||||
finally:
|
||||
telemetry.set_default_sink(previous)
|
||||
assert telemetry.default_sink is previous
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Provider integration - the seam actually being used
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _StubResponse:
|
||||
"""The few members the provider streaming loop touches."""
|
||||
|
||||
def __init__(self, lines: List[str]) -> None:
|
||||
self._lines = lines
|
||||
self.status_code = 200
|
||||
self.headers: Dict[str, str] = {}
|
||||
self.encoding = "utf-8"
|
||||
self.text = ""
|
||||
|
||||
def iter_lines(self, decode_unicode: bool = False):
|
||||
yield from self._lines
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sink(monkeypatch):
|
||||
"""A per-instance recording sink, so nothing touches the real usage store."""
|
||||
return telemetry.RecordingUsageSink()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canned(monkeypatch):
|
||||
def _install(lines: List[str]):
|
||||
monkeypatch.setattr(Provider, "_request",
|
||||
lambda self, method, url, **kw: _StubResponse(lines))
|
||||
return _install
|
||||
|
||||
|
||||
def test_openai_provider_reports_server_counts_to_its_sink(canned, sink):
|
||||
canned([
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "hi"}}],
|
||||
"usage": {"prompt_tokens": 11, "completion_tokens": 2,
|
||||
"prompt_tokens_details": {"cached_tokens": 4}}}),
|
||||
"data: [DONE]",
|
||||
])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "gpt-4o-mini"})
|
||||
provider.usage_sink = sink
|
||||
|
||||
provider.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert len(sink.events) == 1
|
||||
event = sink.events[0]
|
||||
assert (event.input_tokens, event.output_tokens, event.cached_tokens) == (11, 2, 4)
|
||||
assert event.estimated is False
|
||||
assert event.model == "gpt-4o-mini"
|
||||
|
||||
|
||||
def test_openai_provider_estimates_when_the_gateway_sends_no_usage(canned, sink):
|
||||
canned(['data: ' + json.dumps({"choices": [{"delta": {"content": "hello"}}]}),
|
||||
"data: [DONE]"])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
provider.usage_sink = sink
|
||||
|
||||
provider.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert sink.events[0].estimated is True
|
||||
assert sink.events[0].output_tokens >= 1
|
||||
|
||||
|
||||
def test_anthropic_provider_reports_stream_counts_to_its_sink(canned, sink):
|
||||
canned(['data: ' + json.dumps(p) for p in (
|
||||
{"type": "message_start", "message": {"usage": {"input_tokens": 30,
|
||||
"cache_read_input_tokens": 10}}},
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "ok"}},
|
||||
{"type": "message_delta", "usage": {"output_tokens": 5}},
|
||||
{"type": "message_stop"},
|
||||
)])
|
||||
provider = AnthropicProvider({"base_url": "https://x.invalid", "api_key": "k",
|
||||
"model": "claude"})
|
||||
provider.usage_sink = sink
|
||||
|
||||
provider.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
event = sink.events[0]
|
||||
assert (event.input_tokens, event.output_tokens, event.cached_tokens) == (30, 5, 10)
|
||||
assert event.estimated is False
|
||||
|
||||
|
||||
def test_a_provider_without_an_explicit_sink_uses_the_process_default(canned):
|
||||
"""Existing call sites set no sink, so the default has to keep working -
|
||||
that is what makes this extraction a no-op for production behaviour."""
|
||||
canned(['data: ' + json.dumps({"choices": [{"delta": {"content": "x"}}]}),
|
||||
"data: [DONE]"])
|
||||
recorder = telemetry.RecordingUsageSink()
|
||||
previous = telemetry.set_default_sink(recorder)
|
||||
try:
|
||||
OpenAICompatProvider({"base_url": "https://x.invalid/v1", "api_key": "k",
|
||||
"model": "m"}).chat([{"role": "user", "content": "hi"}])
|
||||
finally:
|
||||
telemetry.set_default_sink(previous)
|
||||
|
||||
assert len(recorder.events) == 1
|
||||
|
||||
|
||||
def test_a_sink_that_raises_does_not_fail_the_turn(canned):
|
||||
"""The answer has already been produced by the time usage is recorded;
|
||||
losing the telemetry is strictly better than losing the answer."""
|
||||
canned(['data: ' + json.dumps({"choices": [{"delta": {"content": "x"}}]}),
|
||||
"data: [DONE]"])
|
||||
|
||||
class _Exploding:
|
||||
def record(self, _event):
|
||||
raise RuntimeError("sink is down")
|
||||
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
provider.usage_sink = _Exploding()
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert result["content"] == "x"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""EPIC R06-T01: WorkspaceSession is a frozen snapshot, captured once, that a
|
||||
turn keeps using regardless of what the UI does to the live project
|
||||
selection afterwards - see the module docstring for the race this replaces.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
|
||||
|
||||
class _FakeProject:
|
||||
def __init__(self, project_id: str, root: Path):
|
||||
self.project_id = project_id
|
||||
self._root = root
|
||||
|
||||
def workspace_dir(self) -> Path:
|
||||
return self._root
|
||||
|
||||
|
||||
def test_from_project_derives_sandbox_dir_under_the_workspace_root(tmp_path):
|
||||
project = _FakeProject("proj-a", tmp_path)
|
||||
session = WorkspaceSession.from_project(project)
|
||||
|
||||
assert session.project_id == "proj-a"
|
||||
assert session.workspace_root == tmp_path
|
||||
assert session.sandbox_dir == tmp_path / ".scratch"
|
||||
assert session.allowed_paths == (tmp_path,)
|
||||
|
||||
|
||||
def test_is_allowed_true_for_the_root_and_descendants(tmp_path):
|
||||
session = WorkspaceSession.from_project(_FakeProject("p", tmp_path))
|
||||
nested = tmp_path / "sub" / "file.txt"
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text("x", encoding="utf-8")
|
||||
|
||||
assert session.is_allowed(tmp_path) is True
|
||||
assert session.is_allowed(nested) is True
|
||||
|
||||
|
||||
def test_is_allowed_false_outside_the_workspace(tmp_path):
|
||||
session = WorkspaceSession.from_project(_FakeProject("p", tmp_path / "a"))
|
||||
outside = tmp_path / "b" / "secret.txt"
|
||||
|
||||
assert session.is_allowed(outside) is False
|
||||
|
||||
|
||||
def test_two_sessions_from_different_projects_stay_independent(tmp_path):
|
||||
"""The exact race this snapshot exists to prevent: a turn holding session
|
||||
A must never start accepting paths that belong to session B, no matter
|
||||
what the (mutable, shared) AppContext does after the snapshot was taken."""
|
||||
session_a = WorkspaceSession.from_project(_FakeProject("a", tmp_path / "a"))
|
||||
session_b = WorkspaceSession.from_project(_FakeProject("b", tmp_path / "b"))
|
||||
|
||||
assert session_a.is_allowed(tmp_path / "b" / "file.txt") is False
|
||||
assert session_b.is_allowed(tmp_path / "a" / "file.txt") is False
|
||||
|
||||
|
||||
def test_unscoped_session_has_no_project_id(tmp_path):
|
||||
session = WorkspaceSession.unscoped(tmp_path)
|
||||
assert session.project_id == ""
|
||||
assert session.is_allowed(tmp_path / "code.py") is True
|
||||
+55
-39
@@ -638,12 +638,16 @@ class ChatPanel(QWidget):
|
||||
def _apply_routing(self, text: str, turn: Dict[str, Any]) -> None:
|
||||
"""Auto Model Routing hook — run once per outgoing message.
|
||||
|
||||
Off → no-op. Auto → silently switch to the best-fit model. Manual → ask
|
||||
the user (modal, with the configured confirm timeout) before switching.
|
||||
The decision itself lives in ``application/model_routing`` (R03-T04):
|
||||
this method is now only the presentation half — supply the current
|
||||
model, open the confirm dialog when the service asks for one, and render
|
||||
the notice. Off/Auto/Manual/Fallback semantics, the never-raise
|
||||
guarantee and the "which model do we end up on" fallback are the
|
||||
service's job, and are shared with Co4E and AI-Edit instead of being
|
||||
re-implemented here.
|
||||
|
||||
Sets ``self._routed_provider``/``self._routed_model`` for THIS turn;
|
||||
:meth:`build_provider` honours them. Never raises — a routing failure
|
||||
must never block sending a message; it just falls back to the tab's
|
||||
own model.
|
||||
:meth:`build_provider` honours them.
|
||||
"""
|
||||
# Recompute fresh each message; clear any previous turn's override.
|
||||
self._routed_provider = None
|
||||
@@ -651,37 +655,30 @@ class ChatPanel(QWidget):
|
||||
# An explicitly-pinned Admin agent takes precedence over routing.
|
||||
if getattr(self, "_admin_agent", None) is not None:
|
||||
return
|
||||
if not (text or "").strip():
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
self.kind, text, cur_provider, cur_model, confirm=self._confirm_routing_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return
|
||||
try:
|
||||
mode = self.ctx.project_routing_mode(self.kind) # per-workspace mode
|
||||
if mode == "off":
|
||||
return
|
||||
service = self.ctx.routing()
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
result = service.route(self.kind, text, cur_provider, cur_model, mode_override=mode)
|
||||
if not result.should_switch:
|
||||
return
|
||||
target = result.target()
|
||||
if target is None:
|
||||
return
|
||||
to_provider, to_model = target
|
||||
if mode == "manual":
|
||||
from .routing_toggle import confirm_switch
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
if not confirm_switch(self, result.decision, timeout):
|
||||
return # declined / timed out → keep current model
|
||||
self._routed_provider = to_provider
|
||||
self._routed_model = to_model
|
||||
notice = self.chat_view.add_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=to_model, task=result.task_type.value,
|
||||
gain=f"{result.decision.score_gain:.2f}"))
|
||||
turn["bubbles"].append(notice)
|
||||
except Exception: # noqa: BLE001 — routing must never block a chat turn
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
self._routed_provider, self._routed_model = decision.target()
|
||||
notice = self.chat_view.add_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
turn["bubbles"].append(notice)
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask the user before moving this turn to another model.
|
||||
|
||||
Passed to the routing service as a callback so the pure-Python decision
|
||||
layer never has to know a modal dialog exists. Returning False (declined
|
||||
or timed out) keeps the tab's own model."""
|
||||
from .routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
def _compress_messages(self) -> None:
|
||||
"""Manual compress: keep the system prompt + the last 2 turns verbatim and
|
||||
@@ -1129,6 +1126,15 @@ class ChatPanel(QWidget):
|
||||
"snapshot_len": len(snapshot), "out_dir": out_dir,
|
||||
"home_id": self.session_id, "home_messages": self.messages,
|
||||
"home_title": self.title, "home_out_root": self.workspace_dir(),
|
||||
# R06-T04: captured NOW, at submit time — see _persist_session's
|
||||
# use of this. Without it, a background turn (this session isn't
|
||||
# the one currently displayed) saves into whatever
|
||||
# ctx.config.history_dir() resolves to AT THE TIME IT FINISHES,
|
||||
# which is the *currently viewed* project's history folder if the
|
||||
# user switched projects (ui/workspace_tab.py::_load_current)
|
||||
# while this turn was still running — silently saving one
|
||||
# project's conversation into another project's history folder.
|
||||
"home_history_dir": self.ctx.config.history_dir(),
|
||||
"detached": False,
|
||||
# For re-rendering the in-progress turn if the user reopens this chat:
|
||||
"display_text": text, "partial": "", "plan_steps": [],
|
||||
@@ -1359,10 +1365,18 @@ class ChatPanel(QWidget):
|
||||
return ctx.get("home_id") == self.session_id and not ctx.get("detached")
|
||||
|
||||
def _save_snapshot(self, session_id: str, messages: List[Dict[str, Any]],
|
||||
title: str, inputs: Optional[List[str]] = None) -> None:
|
||||
title: str, inputs: Optional[List[str]] = None,
|
||||
history_dir: Optional[Path] = None) -> None:
|
||||
"""Persist a conversation by id (used both to register it in History the
|
||||
moment it starts and to save a finished background turn). No-op until it has
|
||||
a user message. Never raises into the UI."""
|
||||
a user message. Never raises into the UI.
|
||||
|
||||
``history_dir``, when given, is used INSTEAD of
|
||||
``self.ctx.config.history_dir()`` — see ``_persist_session`` (R06-T04):
|
||||
a background turn must save into the project it started in, not
|
||||
whichever project happens to be selected in the Workspace screen by
|
||||
the time the turn finishes.
|
||||
"""
|
||||
if not self.ctx.config.history.get("autosave", True):
|
||||
return
|
||||
if not any(m.get("role") == "user" for m in messages):
|
||||
@@ -1370,7 +1384,8 @@ class ChatPanel(QWidget):
|
||||
try:
|
||||
from ..core.history import save_conversation
|
||||
save_conversation(
|
||||
self.ctx.config.history_dir(), self.kind, session_id,
|
||||
history_dir if history_dir is not None else self.ctx.config.history_dir(),
|
||||
self.kind, session_id,
|
||||
messages, title, inputs=list(inputs or []), outputs=[],
|
||||
# Only the CURRENT view knows its project for sure; a background
|
||||
# turn's save must not overwrite another conversation's project
|
||||
@@ -1386,7 +1401,8 @@ class ChatPanel(QWidget):
|
||||
view-based _autosave can't). Outputs are rebuilt from disk on reopen."""
|
||||
self._save_snapshot(ctx["home_id"], ctx["home_messages"],
|
||||
ctx.get("home_title", ""),
|
||||
inputs=ctx.get("record", {}).get("inputs", []))
|
||||
inputs=ctx.get("record", {}).get("inputs", []),
|
||||
history_dir=ctx.get("home_history_dir"))
|
||||
self.history_changed.emit()
|
||||
|
||||
def running_session_ids(self):
|
||||
|
||||
+33
-33
@@ -1847,41 +1847,41 @@ class Co4ETab(QWidget):
|
||||
self._run_chat_turn(system_parts, request, model)
|
||||
|
||||
def _apply_co4e_routing(self, request: str) -> str:
|
||||
"""Route this Co4E turn to the best-fit model. Returns the model id to
|
||||
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
|
||||
a cross-provider switch is chosen. Off → no-op. Manual → confirm first.
|
||||
Never raises — falls back to the default model on any error."""
|
||||
"""Route this Co4E turn to the best-fit model.
|
||||
|
||||
Returns the model id to use ('' -> provider default) and sets
|
||||
``self._co4e_routed_provider`` when a cross-provider switch is chosen.
|
||||
|
||||
The decision comes from the shared ``RoutingApplicationService``
|
||||
(R03-T05) - Off/Auto/Manual/Fallback handling, the confirm handshake and
|
||||
the never-raise guarantee are no longer duplicated here. What stays is
|
||||
only the Co4E-specific presentation: the surface key, and where the
|
||||
notice is rendered.
|
||||
"""
|
||||
self._co4e_routed_provider = None
|
||||
if not (request or "").strip():
|
||||
return ""
|
||||
try:
|
||||
mode = self.ctx.project_routing_mode("co4e") # per-workspace mode
|
||||
if mode == "off":
|
||||
return ""
|
||||
service = self.ctx.routing()
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
result = service.route("co4e", request, cur_provider, cur_model, mode_override=mode)
|
||||
if not result.should_switch:
|
||||
return ""
|
||||
target = result.target()
|
||||
if target is None:
|
||||
return ""
|
||||
to_provider, to_model = target
|
||||
if mode == "manual":
|
||||
from .routing_toggle import confirm_switch
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
if not confirm_switch(self, result.decision, timeout):
|
||||
return ""
|
||||
self._co4e_routed_provider = to_provider
|
||||
self._append_chat("system", tr(
|
||||
"routing.switched_notice",
|
||||
model=to_model, task=result.task_type.value,
|
||||
gain=f"{result.decision.score_gain:.2f}"))
|
||||
return to_model
|
||||
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
|
||||
self._co4e_routed_provider = None
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
"co4e", request, cur_provider, cur_model, confirm=self._confirm_routing_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return ""
|
||||
self._co4e_routed_provider = decision.provider
|
||||
self._append_chat("system", tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
return decision.model
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask before moving this Co4E turn to another model.
|
||||
|
||||
Handed to the routing service as a callback so the pure-Python decision
|
||||
layer never needs to know a modal dialog exists."""
|
||||
from .routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
def _extract_agent_directive(self, text: str):
|
||||
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
|
||||
|
||||
+71
-34
@@ -346,45 +346,82 @@ class CoworkTab(ChatPanel):
|
||||
self._apply_output_folder_label() # picks up edits made via Settings too
|
||||
|
||||
def build_job(self, text: str, messages, out_dir):
|
||||
# Each turn writes into its OWN isolated folder (out_dir) and works on its
|
||||
# OWN message list, so several turns can run in parallel without clobbering
|
||||
# each other's files or history. Deliverables are moved up to the session
|
||||
# Output root when the turn finishes (see _cleanup_turn).
|
||||
"""Build the worker job for one Cowork turn (R04-T04).
|
||||
|
||||
Every input the turn needs is captured HERE, on the UI thread, into an
|
||||
immutable ``ConversationExecutionRequest``. Previously the job closure
|
||||
read widget state (selected model, active workspace, project
|
||||
instructions) from inside the worker thread, so a turn could run on a
|
||||
mixture of the state at submit time and the state the user changed while
|
||||
it was running - and which mixture you got depended on thread timing.
|
||||
|
||||
Each turn still writes into its OWN isolated folder (out_dir) and works
|
||||
on its OWN message list, so several turns can run in parallel without
|
||||
clobbering each other's files or history. Deliverables are moved up to
|
||||
the session Output root when the turn finishes (see _cleanup_turn).
|
||||
"""
|
||||
from ..core.projects import load_project, project_context_text
|
||||
from ..domain.agents import ConversationExecutionRequest
|
||||
|
||||
output_dir = out_dir or self._session_output_dir()
|
||||
title = self.title
|
||||
project_id = self.project_id
|
||||
# Captured at submit time (UI thread): the Admin-defined agent
|
||||
# preset's instructions, if one is selected in the Agent picker.
|
||||
# Shared project instructions (Claude-Projects style), read now so edits
|
||||
# made in the Workspace screen mid-turn cannot change this turn's prompt.
|
||||
project_context = project_context_text(load_project(self.project_id))
|
||||
# The Admin-defined agent preset's instructions, if one is selected.
|
||||
agent_prompt = self.admin_agent_prompt()
|
||||
if agent_prompt:
|
||||
project_context = (f"{project_context}\n\n{agent_prompt}"
|
||||
if project_context else agent_prompt)
|
||||
|
||||
request = ConversationExecutionRequest.create(
|
||||
text, messages,
|
||||
output_dir=str(output_dir),
|
||||
session_id=self.session_id,
|
||||
surface=self.kind,
|
||||
title=self.title,
|
||||
project_id=self.project_id,
|
||||
project_context=project_context,
|
||||
agent_role=agent_roles.COWORK,
|
||||
# Permission Management (Sandbox Security Layer): off by default -
|
||||
# matches the pre-existing auto-run behavior. Resolved PER WORKSPACE:
|
||||
# this project's Auto-run override wins, else the global setting.
|
||||
confirm_commands=bool(self.ctx.project_confirm_commands()),
|
||||
)
|
||||
# Built on the UI thread with everything else: it already reflects this
|
||||
# turn's routing decision and the selected agent/model.
|
||||
provider = self.build_provider()
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..core.chat_agent import run_cowork
|
||||
from ..core.projects import load_project, project_context_text
|
||||
from ..application.conversations import ConversationApplicationService
|
||||
|
||||
provider = self.build_provider() # this tab's selected agent/model
|
||||
# 🔌 MCP Layer: every tool source flows through MCP now — the
|
||||
# external servers configured in Settings AND Microsoft 365 (a
|
||||
# built-in MCP server auto-registered while signed in, see
|
||||
# AppContext._ms365_builtin_connection / mcp_servers/ms365_server.py).
|
||||
extra_tools, extra_exec = self.ctx.build_mcp_tools()
|
||||
# Shared project instructions (Claude-Projects style) — refreshed
|
||||
# each turn so edits in the Workspace screen apply immediately.
|
||||
proj_ctx = project_context_text(load_project(project_id))
|
||||
if agent_prompt:
|
||||
proj_ctx = f"{proj_ctx}\n\n{agent_prompt}" if proj_ctx else agent_prompt
|
||||
# Permission Management (Sandbox Security Layer): off by default —
|
||||
# matches the pre-existing auto-run behavior. Now resolved PER
|
||||
# WORKSPACE: this project's Auto-run override wins, else the global
|
||||
# "confirm before running commands" setting (project_confirm_commands).
|
||||
gate = None
|
||||
if self.ctx.project_confirm_commands():
|
||||
gate = worker.new_gate("confirm", agent_role=agent_roles.COWORK)
|
||||
run_cowork(provider, messages, output_dir, worker.emit_event,
|
||||
worker.is_cancelled, title=title,
|
||||
extra_tools=extra_tools, extra_executor=extra_exec,
|
||||
project_context=proj_ctx, security_config=self.ctx.config,
|
||||
gate=gate)
|
||||
return {"messages": messages, "turn_dir": str(output_dir)}
|
||||
service = ConversationApplicationService(
|
||||
# The provider is part of the snapshot, so the factory ignores
|
||||
# the request's provider/model rather than re-resolving them
|
||||
# from live config inside the worker thread.
|
||||
lambda _provider_id, _model: provider,
|
||||
# 🔌 MCP Layer: every tool source flows through MCP now - the
|
||||
# external servers configured in Settings AND Microsoft 365 (a
|
||||
# built-in MCP server auto-registered while signed in, see
|
||||
# AppContext._ms365_builtin_connection / mcp_servers/ms365_server.py).
|
||||
tool_source=self.ctx.build_mcp_tools,
|
||||
gate_factory=lambda req: worker.new_gate("confirm",
|
||||
agent_role=agent_roles.COWORK),
|
||||
security_config=self.ctx.config,
|
||||
)
|
||||
result = service.run_turn(
|
||||
request,
|
||||
# The typed events are rendered back into the legacy dict shape
|
||||
# the chat widgets already consume; they migrate to AgentEvent
|
||||
# directly in EPIC R08.
|
||||
on_event=lambda event: worker.emit_event(event.to_dict()),
|
||||
cancel=worker.is_cancelled,
|
||||
)
|
||||
# The service reports a failure instead of raising, but this worker's
|
||||
# contract is exception-based (core/worker.py turns one into the
|
||||
# `failed` signal that _on_failed already handles), so re-raise the
|
||||
# ORIGINAL exception to keep that path byte-for-byte unchanged.
|
||||
result.raise_if_failed()
|
||||
return {"messages": result.messages, "turn_dir": str(output_dir)}
|
||||
|
||||
return job
|
||||
|
||||
|
||||
+33
-37
@@ -923,46 +923,42 @@ class FolderTab(QWidget):
|
||||
def _ai_apply_routing(self, instruction: str) -> None:
|
||||
"""Auto Model Routing for the AI-Edit surface (always a CODING task).
|
||||
|
||||
Off → no-op. Auto → silently pick the best coding model. Manual → ask
|
||||
first. Sets ``self._ai_routed_provider``/``_ai_routed_model`` for this
|
||||
run; :meth:`_ai_provider` honours them. Never raises."""
|
||||
Sets ``self._ai_routed_provider``/``_ai_routed_model`` for this run;
|
||||
:meth:`_ai_provider` honours them.
|
||||
|
||||
The policy itself lives in the shared ``RoutingApplicationService``
|
||||
(R03-T05). What stays here is genuinely AI-Edit-specific: the task type
|
||||
is pinned to CODING (an edit instruction is never a QA question, so
|
||||
classifying it would only add noise), and the current model comes from
|
||||
this screen's own picker rather than the global active model."""
|
||||
from ..core.routing.models import TaskType
|
||||
|
||||
self._ai_routed_provider = None
|
||||
self._ai_routed_model = None
|
||||
if not (instruction or "").strip():
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None
|
||||
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
"ai_edit", instruction, cur_provider, cur_model,
|
||||
task_type=TaskType.CODING, confirm=self._confirm_routing_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return
|
||||
try:
|
||||
from ..core.routing.models import TaskType
|
||||
mode = self.ctx.project_routing_mode("ai_edit") # per-workspace mode
|
||||
if mode == "off":
|
||||
return
|
||||
service = self.ctx.routing()
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None
|
||||
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
result = service.route(
|
||||
"ai_edit", instruction, cur_provider, cur_model,
|
||||
mode_override=mode, task_type=TaskType.CODING,
|
||||
)
|
||||
if not result.should_switch:
|
||||
return
|
||||
target = result.target()
|
||||
if target is None:
|
||||
return
|
||||
to_provider, to_model = target
|
||||
if mode == "manual":
|
||||
from .routing_toggle import confirm_switch
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
if not confirm_switch(self, result.decision, timeout):
|
||||
return
|
||||
self._ai_routed_provider = to_provider
|
||||
self._ai_routed_model = to_model
|
||||
self.ai_chat.add_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=to_model, task=result.task_type.value,
|
||||
gain=f"{result.decision.score_gain:.2f}"))
|
||||
except Exception: # noqa: BLE001 — routing must never block an edit
|
||||
self._ai_routed_provider = None
|
||||
self._ai_routed_model = None
|
||||
self._ai_routed_provider, self._ai_routed_model = decision.target()
|
||||
self.ai_chat.add_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask before moving this AI-Edit run to another model.
|
||||
|
||||
Passed to the routing service as a callback, keeping the pure-Python
|
||||
decision layer free of any Qt dialog knowledge."""
|
||||
from .routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
def _ai_image_model(self):
|
||||
"""Resolve the model+endpoint for image generation, searching ALL
|
||||
|
||||
Reference in New Issue
Block a user