merge: kéo Delta epic-R04 (gồm cả R01 và R03) vào gamma/refactor

Nam chốt: không chờ Delta merge vào main, lấy sớm để va chạm nhỏ và sửa
ngay, thay vì dồn một cục lúc cả hai cùng lên main.

R04 chứa trọn R01 và R03 nên một lần merge là đủ cả ba: 96 file, +8260
dòng. Xung đột chỉ 5 file, đều là __init__.py add/add — hai team cùng
dựng khung thư mục nên đụng docstring. Giữ docstring của Gamma (nói rõ
ràng buộc "không import PySide6"), giữ mọi phần code của Delta.

Riêng tests/fakes/__init__.py: bỏ hai dòng import háo hức của Delta
(fake_provider, fake_tool_executor). fake_provider dùng
`from providers.base import ...` — import tuyệt đối, chỉ chạy được khi
cwd là gốc repo — nên nó làm đứt bài test "dùng fake mà không nạp config
thật". Không ai import ở cấp package; test của Delta gọi thẳng module
nên bỏ đi không ảnh hưởng họ. Đã ghi lý do vào docstring của gói.

Delta cũng xoá preview-desktop và "requirements (cloud copy).txt".

430 test xanh sau merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-25 10:20:36 +09:00
co-authored by Claude Opus 5
97 changed files with 8275 additions and 235 deletions
+1
View File
@@ -0,0 +1 @@
"""Application conversations package: turn lifecycle orchestration and agent execution."""
@@ -0,0 +1,322 @@
"""The turn lifecycle, once, in pure Python (R04-T03).
Extracted from ``core/chat_agent.py::run_cowork``, whose 260-line body mixed the
lifecycle (compose the prompt, call the model, dispatch tools, respect the step
ceiling, tidy the sandbox) with the concrete machinery that does each of those
things. The lifecycle is the part with rules worth testing — and the part that
was untestable, because reaching it meant standing up a Qt widget and a worker
thread.
Here it is a plain object driven through the seams in :mod:`turn_runtime`, so a
test states a rule ("the guard runs before the model", "a rejected command never
executes") in three lines. ``core/chat_agent.py`` keeps its signature and
delegates, and the presentation layer keeps receiving the same events via the
legacy codec, so nothing downstream had to change with it.
Behavioural contract: this is a faithful port, not an improvement pass. Where
the original had a quirk (the step-ceiling note only merges into the answer when
the last message is the assistant's), the quirk is preserved and commented —
changing what a user sees belongs in its own change, not smuggled into a move.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
from ...domain.agents.agent_event import (
AssistantMessageCompletedEvent,
ErrorEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
)
from ...domain.agents.agent_result import AgentResult
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
from .turn_runtime import (
BUDGET_NOTE_TEMPLATE,
GATED_TOOLS,
PLAN_TOOL,
REASONING_ONLY_NOTE,
REJECTED_OUTPUT,
AttachmentReader,
CancelFn,
CommandGuard,
ContextCompactor,
EventSink,
ModelCallPort,
PermissionRequest,
PromptGuard,
PromptPreparer,
ToolRuntimePort,
)
logger = logging.getLogger("cowork_local.application.conversations")
class ConversationApplicationService:
"""Runs one :class:`ConversationExecutionRequest` to completion."""
def __init__(
self,
model: ModelCallPort,
tools: ToolRuntimePort,
*,
prepare_prompt: Optional[PromptPreparer] = None,
prompt_guard: Optional[PromptGuard] = None,
command_guard: Optional[CommandGuard] = None,
compact: Optional[ContextCompactor] = None,
permission_request: Optional[PermissionRequest] = None,
attachment_reader: Optional[AttachmentReader] = None,
) -> None:
self._model = model
self._tools = tools
# Every hook is optional so the service degrades to a plain chat turn.
# That is not only a test convenience: a headless caller legitimately has
# no guards (``security_config=None`` today) and no permission dialog.
self._prepare_prompt = prepare_prompt
self._prompt_guard = prompt_guard
self._command_guard = command_guard
self._compact = compact
self._permission_request = permission_request
self._attachment_reader = attachment_reader
# -- public API ------------------------------------------------------ #
def execute(self, request: ConversationExecutionRequest, sink: EventSink,
cancel: Optional[CancelFn] = None,
messages: Optional[List[Dict[str, Any]]] = None) -> AgentResult:
"""Run the turn, streaming events to ``sink``, and report the outcome.
``messages``, when given, is a working list the caller already built —
it MUST already end with this turn's user message, and the service
appends into that very object instead of composing its own. The Cowork
widget needs this: it hands out the same list to
``_reattach_running_turn``, which replays the steps done so far while the
worker is still appending, and to ``_finalize_turn``, which slices it by
the pre-turn snapshot length. A private list would break both silently.
Passing ``None`` (every headless caller) lets the service compose the
list from the request, which is the mode the rest of this class assumes.
Raises whatever the runtime raises (a blocked prompt, a dead gateway):
the caller already has a failure path for that — ``AgentWorker.failed``
in the UI, the artifact writer in Schedule Task — and swallowing the
exception here would silently turn a failed turn into an empty answer.
An :class:`ErrorEvent` is emitted first so subscribers see the failure
on the same stream as everything else.
"""
cancel = cancel or (lambda: False)
# -- pre-flight. Runs BEFORE the output snapshot, so a turn refused here
# leaves the output folder completely untouched (tidying is not a
# read-only operation — see ToolRuntimePort.finalize).
try:
# The caller's list is used by reference on purpose (see above); only
# the self-composed path may build a fresh one.
working = messages if messages is not None else self._compose_messages(request)
tools = list(self._tools.specs(request.allowed_tools))
if self._prepare_prompt is not None:
self._prepare_prompt(working, tuple(getattr(t, "name", "") for t in tools))
if request.enforce_rules and self._prompt_guard is not None:
self._prompt_guard(working)
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
sink(ErrorEvent(message=str(exc)))
raise
before = self._tools.snapshot()
steps_used = 0
plan_steps: Tuple[PlanStep, ...] = ()
completed_naturally = False
try:
for _ in range(request.effective_max_steps):
if cancel():
break
# Auto-compress when nearing the model's context budget; a no-op
# when off or when the conversation is still short.
if self._compact is not None:
self._compact(working, cancel)
assistant = self._model.call(
working, tools,
on_text=lambda piece: sink(TextChunkEvent(delta=piece)),
on_reasoning=lambda piece: sink(ReasoningChunkEvent(delta=piece)),
cancel=cancel,
)
working.append(assistant)
steps_used += 1
tool_calls = assistant.get("tool_calls") or []
if not tool_calls and not (assistant.get("content") or "").strip():
# Written into the message, not just emitted, so the stored
# conversation never ends on a blank assistant turn.
assistant["content"] = REASONING_ONLY_NOTE
sink(TextChunkEvent(delta=REASONING_ONLY_NOTE))
sink(AssistantMessageCompletedEvent(content=assistant.get("content", "")))
if not tool_calls:
completed_naturally = True
break
for call in tool_calls:
if cancel():
break
tool_message, steps = self._dispatch(request, call, sink, cancel)
working.append(tool_message)
if steps is not None:
plan_steps = steps
if not completed_naturally and not cancel():
self._announce_budget_exhausted(request, working, sink)
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
sink(ErrorEvent(message=str(exc)))
raise
finally:
# Always tidy: the sandbox and generator scripts must not survive a
# turn that stopped abruptly. Runs on success, cancel and failure.
self._finalize_outputs(before, sink, cancelled=cancel())
result = AgentResult(
messages=working, steps_used=steps_used, cancelled=cancel(),
budget_exhausted=not completed_naturally and not cancel(),
plan_steps=plan_steps,
)
sink(result.to_turn_completed_event())
return result
# -- internals ------------------------------------------------------- #
def _compose_messages(self, request: ConversationExecutionRequest) -> List[Dict[str, Any]]:
"""History snapshot plus this turn's user message.
The attachment text is read HERE rather than when the request was built,
because extraction is slow enough to freeze the UI thread; the request
deliberately carries paths only.
"""
body = request.prompt
if self._attachment_reader is not None:
body = self._attachment_reader(request.prompt, request.attachments)
messages = [dict(m) for m in request.messages]
messages.append({"role": "user", "content": request.user_content(body)})
return messages
def _dispatch(self, request: ConversationExecutionRequest, call: Dict[str, Any],
sink: EventSink, cancel: CancelFn
) -> Tuple[Dict[str, Any], Optional[Tuple[PlanStep, ...]]]:
"""Run one tool call.
Returns ``(tool_message, plan_steps)`` — the message to append to the
conversation, and the new checklist when this call was the plan tool
(``None`` otherwise, so the caller can tell "no change" from "empty
plan").
"""
call_id = str(call.get("id", ""))
name = str(call.get("name", ""))
args = call.get("arguments") or {}
# The plan tool is invisible in the transcript: it updates the Plan panel
# and nothing else, so it skips preview, guard and gate entirely.
if name == PLAN_TOOL:
outcome = self._tools.execute(name, args, on_output=None, cancel=cancel)
steps = tuple(outcome.get("plan_steps") or ())
sink(PlanUpdatedEvent(steps=steps))
return self._tool_message(call_id, name, outcome.get("output", "")), steps
# Announce first: the user sees the code/command about to run before the
# guard or the approval dialog interrupts them, which is the whole point
# of showing the step CLI-style.
preview = self._tools.preview(name, args)
sink(ToolCallStartedEvent(call_id=call_id, name=name, arguments=dict(args),
preview=preview))
if request.enforce_rules and self._command_guard is not None:
self._command_guard(name, args)
if not self._approved(request, name, args, preview, sink, call_id):
return self._tool_message(call_id, name, REJECTED_OUTPUT), None
outcome = self._tools.execute(
name, args,
on_output=lambda piece: sink(ToolOutputChunkEvent(
call_id=call_id, name=name, delta=piece)),
cancel=cancel,
)
sink(ToolCallFinishedEvent(
call_id=call_id, name=name, ok=bool(outcome.get("ok", False)),
output=str(outcome.get("output", "")), path=str(outcome.get("path", "") or ""),
produced=outcome.get("produced") or (),
))
return self._tool_message(call_id, name, outcome.get("output", "")), None
def _approved(self, request: ConversationExecutionRequest, name: str,
args: Dict[str, Any], preview: Any, sink: EventSink,
call_id: str) -> bool:
"""Whether this call may run.
Only command-shaped tools are gated, and only when the workspace asked
to confirm them: file writes stay inside the turn's own sandbox, so
prompting for those would be noise. A rejection is reported as a failed
tool result — the model needs to read back that it was refused, or it
will simply try the same call again.
"""
if not request.requires_permission_gate or name not in GATED_TOOLS:
return True
if self._permission_request is None:
# Confirm mode with nobody to ask: refusing is the safe direction,
# since auto-running is exactly what confirm mode exists to prevent.
logger.warning("turn: confirm mode without a permission callback — refusing %r", name)
approved = False
else:
approved = bool(self._permission_request({
"name": name, "args": args,
"preview": preview.to_dict() if preview is not None else {},
}))
if not approved:
sink(ToolCallFinishedEvent(call_id=call_id, name=name, ok=False,
output=REJECTED_OUTPUT))
return approved
@staticmethod
def _tool_message(call_id: str, name: str, output: Any) -> Dict[str, Any]:
"""The canonical ``role: tool`` message the model reads back."""
return {"role": "tool", "tool_call_id": call_id, "name": name,
"content": str(output or "")}
@staticmethod
def _announce_budget_exhausted(request: ConversationExecutionRequest,
messages: List[Dict[str, Any]], sink: EventSink) -> None:
"""Report being cut off by the step ceiling.
The note always reaches the transcript. It is merged into the stored
answer only when the last message is the assistant's — which, when the
ceiling is hit, it never is (the turn ends on a tool result). The branch
is kept because it is what the current runtime does, and because it is
the correct behaviour the day a caller ends the loop differently.
"""
note = BUDGET_NOTE_TEMPLATE.format(steps=request.effective_max_steps)
sink(TextChunkEvent(delta=note))
if messages and messages[-1].get("role") == "assistant":
messages[-1]["content"] = (messages[-1].get("content") or "") + note
def _finalize_outputs(self, before: Any, sink: EventSink, cancelled: bool) -> None:
"""Tidy the output folder and report what moved.
Failures are logged, never raised: this runs in a ``finally``, so an
exception here would replace the turn's real error (or its success) with
a housekeeping one.
"""
try:
removed, added = self._tools.finalize(before, cancelled=cancelled)
except Exception: # noqa: BLE001
logger.exception("turn: tidying the output folder failed")
return
if removed:
sink(OutputsRemovedEvent(paths=tuple(removed)))
if added:
sink(OutputsAddedEvent(paths=tuple(added)))
__all__ = ["ConversationApplicationService"]
@@ -0,0 +1,325 @@
"""Wires :class:`ConversationApplicationService` to the existing runtime (R04-T03).
The service is written against the narrow seams in :mod:`turn_runtime` so it can
be tested with plain fakes. This module supplies the real implementations — the
provider call with its recovery pass, the tool/sandbox runtime, the security
guards, context compaction — and is therefore the ONLY file in
``application/conversations/`` that knows ``core/*`` exists. Same shape (and
same reason) as ``application/model_routing/core_routing_adapter.py`` in R03.
Every ``core`` import is deferred into a method body: importing the tool runtime
pulls in ``requests``, ``psutil`` and the sandbox stack, and code that merely
*builds* a service must not pay for that.
Faithfulness notes — two places where this reproduces a quirk of the current
runtime rather than the behaviour one would design fresh. Both are marked
inline: the MS365 system-prompt paragraph keys off the CONFIGURED extra tools
(not the advertised subset), and the ``tool_result`` path falls back to the
call's own ``path`` argument resolved against the workdir.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from ...domain.agents.agent_event import PlanStep, ToolPreview
from .conversation_application_service import ConversationApplicationService
from .turn_runtime import PLAN_TOOL, EventSink
# Legacy emit: the dict-based callback every current caller already owns.
LegacyEmit = Callable[[Dict[str, Any]], None]
def legacy_event_sink(emit: LegacyEmit) -> EventSink:
"""Adapt a typed :class:`EventSink` onto the legacy dict ``emit``.
This is what lets R04 land without touching the presentation layer: the
service thinks in typed events, ``ui/chat_panel.py::_on_event`` keeps
receiving exactly the dicts it already dispatches on. Deleted in R08 once
the widget consumes events directly.
"""
return lambda event: emit(event.to_legacy_dict())
class CoreModelCall:
""":class:`ModelCallPort` over ``code_agent._call_provider_with_recovery``.
Not ``provider.chat`` directly: the recovery wrapper adds the one bounded
retry that hides a dropped connection or a momentarily unreachable gateway,
and losing it would be a visible regression on flaky corporate networks.
"""
def __init__(self, provider: Any) -> None:
self._provider = provider
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
from ...core.code_agent import _call_provider_with_recovery
return _call_provider_with_recovery(self._provider, messages, tools, on_text,
cancel, on_reasoning)
class CoreToolRuntime:
""":class:`ToolRuntimePort` over ``core/tools.py`` + Cowork's file tools."""
def __init__(self, output_dir: Path, *, title: str = "",
extra_tools: Optional[Sequence[Any]] = None, extra_executor=None,
security_config: Any = None, agent_role: str = "") -> None:
self._output_dir = Path(output_dir)
self._title = title
self._extra_tools = list(extra_tools or ())
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
# The connector executor MCP/REST tools are routed to; None when the
# turn has no connectors enabled.
self._extra_executor = extra_executor
self._security_config = security_config
self._agent_role = agent_role
self._ctx: Any = None # built on first use (see _tool_context)
# -- the configured extra tools, for the system-prompt hints ---------- #
@property
def extra_names(self) -> frozenset:
return frozenset(self._extra_names)
def _tool_context(self):
"""The sandboxed ``ToolContext`` every built-in tool call runs inside.
Built once per turn and cached: it carries the resource limits and the
network policy, so re-deriving it mid-turn could let a Settings change
take effect halfway through work already in flight.
"""
if self._ctx is None:
from ...core import agent_security
from ...core.tools import ToolContext
limits, block_network = agent_security.sandbox_settings(self._security_config)
self._ctx = ToolContext(
self._output_dir, flatten_writes=True, # keep every file in the Output root
resource_limits=limits, block_network=block_network,
allow_url_fetch=agent_security.url_fetch_allowed(self._security_config),
jira=(self._security_config.data.get("jira") if self._security_config else None),
)
return self._ctx
# -- ToolRuntimePort -------------------------------------------------- #
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> List[Any]:
"""Advertised tools: Cowork's own two, the enabled built-ins, then MCP.
``allowed_tools`` restricts the list so a read-only step literally cannot
write. ``update_plan`` and the connector tools always survive the filter:
the plan tool has no side effects, and connectors are opted into
explicitly rather than governed by the built-in capability scope.
"""
from ...core.chat_agent import SAVE_FILE_SPEC
from ...core.plan import UPDATE_PLAN_SPEC
from ...core.tools import enabled_tool_specs
specs = ([SAVE_FILE_SPEC, UPDATE_PLAN_SPEC]
+ list(enabled_tool_specs(self._security_config))
+ self._extra_tools)
if allowed_tools is None:
return specs
allow = set(allowed_tools) | {PLAN_TOOL} | self._extra_names
return [t for t in specs if getattr(t, "name", "") in allow]
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
"""What the user sees before the call runs."""
# A connector call has no local diff to show, so it renders as the plain
# argument dump the runtime already used.
if name in self._extra_names:
return ToolPreview(kind="info", title=name, text=str(args))
if name == "save_file":
return self._save_file_preview(args)
from ...core.tools import describe_action
raw = describe_action(self._tool_context(), name, args)
return ToolPreview.from_dict(raw)
def _save_file_preview(self, args: Dict[str, Any]) -> ToolPreview:
"""A before/after diff for the file the agent is about to write.
A brand-new file renders all-green (before is empty); an overwrite shows
the real change, so saving a file reads like editing one.
"""
import difflib
from ...core.chat_agent import _structure_summary, _titled_filename
fname = _titled_filename(self._title, args.get("filename", "output.txt"))
content = str(args.get("content", ""))
summary = _structure_summary(fname, content)
old = ""
existing = self._output_dir / fname
if existing.exists():
try:
old = existing.read_text(encoding="utf-8", errors="replace")
except OSError:
pass # unreadable existing file: show it as a fresh write
diff = "".join(difflib.unified_diff(
old.splitlines(keepends=True), content.splitlines(keepends=True),
fromfile=f"a/{fname}", tofile=f"b/{fname}",
)) or content[:4000]
return ToolPreview(kind="diff", title=f"Save {fname}",
text=f"{summary}\n\n{diff[:4000]}")
def execute(self, name: str, args: Dict[str, Any], on_output=None,
cancel=None) -> Dict[str, Any]:
"""Run one tool call and return the runtime's result mapping."""
if name == PLAN_TOOL:
return self._execute_plan(args)
if name in self._extra_names and self._extra_executor is not None:
# Connector results carry no local file, so no path/produced keys —
# matching what the runtime reports for an MCP call today.
result = self._extra_executor(name, args) or {}
return {"ok": bool(result.get("ok", False)), "output": result.get("output", "")}
if name == "save_file":
from ...core.chat_agent import _do_save_file
return dict(_do_save_file(self._output_dir, self._title, args))
from ...core.tools import execute_tool
ctx = self._tool_context()
result = dict(execute_tool(ctx, name, args, cancel=cancel, on_output=on_output,
agent_role=self._agent_role))
# Quirk preserved: a tool that wrote the file named in its OWN arguments
# (write_file/edit_file) does not report a path, so the runtime derives
# one from the argument. Dropping this would empty the Output list.
if not result.get("path") and isinstance(args, dict) and args.get("path"):
result["path"] = str(ctx.workdir / str(args["path"]))
return result
def _execute_plan(self, args: Dict[str, Any]) -> Dict[str, Any]:
"""Apply an ``update_plan`` call: validate the steps and audit them.
Produces no file and no chat bubble; the service turns the returned
steps into a single plan event.
"""
from ...core import agent_roles, audit_log
from ...core.plan import normalize_plan_steps
steps = normalize_plan_steps(args.get("steps"))
audit_log.record("tool_call", PLAN_TOOL, True, f"{len(steps)} step(s)",
agent_role=agent_roles.PLANNER)
return {"ok": True, "output": "Plan updated.",
"plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]}
def snapshot(self) -> Any:
from ...core.tools import _snapshot
return _snapshot(self._output_dir)
def finalize(self, before: Any, cancelled: bool = False
) -> Tuple[List[str], List[str]]:
"""Drop the scratch sandbox and flatten deliverables into the root.
Returns ``(gone, arrived)``: a file that MOVED counts as both, because
the Output list keys entries by path and must drop the old one.
"""
from ...core.chat_agent import _cleanup_cowork_intermediates
removed, moved = _cleanup_cowork_intermediates(self._output_dir, before,
cancelled=cancelled)
gone = list(removed) + [old for old, _new in moved]
arrived = [new for _old, new in moved]
return gone, arrived
def build_cowork_conversation_service(
provider: Any,
output_dir: Path,
emit: LegacyEmit,
*,
title: str = "",
project_context: str = "",
extra_tools: Optional[Sequence[Any]] = None,
extra_executor=None,
security_config: Any = None,
gate: Any = None,
agent_role: str = "",
) -> ConversationApplicationService:
"""A service wired to the real runtime, ready to execute a Cowork turn.
``emit`` is the legacy dict callback: the guards and the compactor publish
their own notices through it directly (exactly as they do now), while the
service's typed events reach it via :func:`legacy_event_sink`.
``gate`` present means the workspace asked to confirm commands; pass the
request with ``gate_mode="confirm"`` so the two agree. A gate of ``None``
keeps the pre-existing auto-run behaviour.
"""
from ...core import agent_roles
tools = CoreToolRuntime(
output_dir, title=title, extra_tools=extra_tools, extra_executor=extra_executor,
security_config=security_config, agent_role=agent_role or agent_roles.COWORK,
)
def prepare_prompt(messages: List[Dict[str, Any]], advertised: Tuple[str, ...]) -> None:
"""Insert the system prompt, then fold in skills, rules and project text.
``advertised`` is unused on purpose: the runtime decides the MS365
paragraph from the CONFIGURED connector tools, not from the subset a
capability scope left advertised. Changing that changes the prompt the
model sees, so it stays as-is here and belongs to R05's tool-policy work.
"""
from ...core.chat_agent import (
COWORK_TOOL_PROMPT,
OPENDATALOADER_PDF_PROMPT,
_apply_project_context,
_apply_security_rules,
_apply_skills,
)
from ...core.deps import _can_pip
from ...core.java_runtime import find_java
from ...core.security_rules import load_rules
from ...core.skills import active_skills_text
if not messages or messages[0].get("role") != "system":
system = COWORK_TOOL_PROMPT
if any(n.startswith("ms365_") for n in tools.extra_names):
system += ("\nThe user has signed in to Microsoft 365 and enabled some ms365__* "
"tools (Outlook / Teams / OneDrive / SharePoint / meeting transcripts, "
"via the built-in MS365 MCP server). Use them whenever the request "
"involves that data — don't say you can't access it.")
if find_java() is not None and _can_pip():
# Only advertise the Java-backed PDF extractor when BOTH the JVM
# and pip are available, so the agent is never steered into a
# command that cannot work on this machine.
system += "\n\n" + OPENDATALOADER_PDF_PROMPT
messages.insert(0, {"role": "system", "content": system})
_apply_skills(messages, active_skills_text())
_apply_security_rules(messages, load_rules())
_apply_project_context(messages, project_context)
def prompt_guard(messages: List[Dict[str, Any]]) -> None:
from ...core import agent_security
agent_security.enforce_prompt(provider, messages, security_config, emit)
def command_guard(name: str, args: Dict[str, Any]) -> None:
from ...core import agent_security
agent_security.enforce_command(provider, name, args, security_config, emit)
def compact(messages: List[Dict[str, Any]], cancel) -> None:
from ...core import context_budget
context_budget.maybe_compact(provider, messages, security_config,
emit=emit, cancel=cancel)
return ConversationApplicationService(
CoreModelCall(provider), tools,
prepare_prompt=prepare_prompt,
prompt_guard=prompt_guard,
command_guard=command_guard,
compact=compact,
permission_request=(gate.request if gate is not None else None),
)
__all__ = [
"LegacyEmit", "legacy_event_sink", "CoreModelCall", "CoreToolRuntime",
"build_cowork_conversation_service",
]
@@ -0,0 +1,77 @@
"""Turn the Cowork widget's captured state into a request (R04-T04).
``ui/cowork_tab.py::build_job`` reads a dozen values off the widget on the UI
thread and has to translate three of them before a turn can run: which message
is this turn's prompt, which messages are its history, and whether the workspace
wants commands confirmed. Those rules lived inline in the widget, where no test
could reach them — and each fails silently when wrong (a duplicated user message,
or a command that quietly stops asking for approval).
They live here instead, as the mapping step the migration map assigns to the
application layer. The widget keeps only what is genuinely widget-specific:
reading its own state and building the provider.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): pure Python.
Everything arrives as a plain value, so this module never sees a widget.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Sequence
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
def build_cowork_turn_request(
*,
turn_id: str,
session_id: str,
messages: Sequence[Dict[str, Any]],
surface: str = "cowork",
project_id: str = "",
title: str = "",
provider_id: str = "",
model: str = "",
instructions: str = "",
output_dir: Optional[Any] = None,
home_output_root: Optional[Any] = None,
confirm_commands: bool = False,
agent_role: str = "cowork",
) -> ConversationExecutionRequest:
"""Build one Cowork turn's immutable request.
``messages`` is the widget's working list, which ALREADY ends with this
turn's user message (the chat panel composes it — prefix, attachments,
session notes — before the job starts). So the prompt is that last message
and the history is everything before it. The request records both; the
service is handed the same working list and appends into it.
Keyword-only on purpose: a dozen positional strings in a call site is exactly
how a title ends up in the project-id slot.
"""
history = list(messages or ())
# ``pop`` rather than ``[-1]``/``[:-1]`` so the empty-list case needs no
# special branch: a turn with nothing in it yields an empty prompt instead of
# raising IndexError deep inside a worker thread.
last = history.pop() if history else {}
return ConversationExecutionRequest(
turn_id=turn_id,
session_id=session_id,
surface=surface,
project_id=project_id,
title=title,
prompt=str(last.get("content") or ""),
messages=history,
provider_id=provider_id,
model=model,
project_context=instructions,
output_dir=output_dir,
home_output_root=home_output_root,
# The workspace's Auto-run override (or the global setting) decides
# whether run_command/install_package must be approved first.
gate_mode="confirm" if confirm_commands else "auto",
agent_role=agent_role,
)
__all__ = ["build_cowork_turn_request"]
+177
View File
@@ -0,0 +1,177 @@
"""The seams :mod:`conversation_application_service` runs a turn through (R04-T03).
Two Protocols and six callables — chosen deliberately, not by reflex. The
refactor plan forbids giving every class an interface, so a contract exists here
only where there is both a real ``core/*`` implementation AND a test double:
* :class:`ModelCallPort` — one provider round-trip *including* the app's
existing context-overflow recovery, which is why the raw ``Provider.chat``
signature is not enough.
* :class:`ToolRuntimePort` — the tool + output-folder runtime, kept as one
cohesive object because every method operates on the same sandbox.
Everything else is a single function, so it is expressed as a callable type
rather than a class with one method (the same choice R03 made for
``ConfirmationCallback``). All of them are optional: a service built with none
of them still runs a plain chat turn, which is what keeps the unit tests short.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
layer — pure Python. Nothing here imports PySide6, ``core.*``, ``providers.*``
or ``ui.*``; the concrete wiring lives in :mod:`core_runtime_adapter`.
"""
from __future__ import annotations
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Protocol,
Sequence,
Tuple,
runtime_checkable,
)
from ...domain.agents.agent_event import AgentEvent, ToolPreview
# The plan tool is special-cased by the loop: it drives the Plan panel and
# produces no chat bubble and no file. Named here so the check is not a bare
# string literal in the middle of the dispatch.
PLAN_TOOL = "update_plan"
# Tools that need approval before they run when the workspace is in confirm
# mode. R05 replaces this tuple with a real ``ToolPolicyGateway`` keyed on
# ToolCapability; until then it mirrors exactly what the runtime gates today.
GATED_TOOLS = ("run_command", "install_package")
# Shown when the user (or the workspace policy) rejects a proposed command. The
# exact string also becomes the tool message the model reads back, so it must
# stay stable.
REJECTED_OUTPUT = "Rejected by user."
# A reasoning model can answer with thinking only. The note is written into the
# assistant message itself, not merely emitted, so an unattended run does not
# read back an empty answer and report "(no output)".
REASONING_ONLY_NOTE = "*(model returned only its reasoning — try rephrasing)*"
# Emitted when the turn is stopped by its own safety ceiling rather than by the
# model finishing. Never silent: being cut off looks exactly like being done.
BUDGET_NOTE_TEMPLATE = (
"\n\n⚠️ Reached the {steps}-step safety limit before the task signalled "
"completion — stopping here. Re-run to continue if more work remains."
)
def combine_instructions(*blocks: Optional[str]) -> str:
"""Join the standing-instruction blocks of a turn, skipping the absent ones.
A turn's instructions arrive as several independent blocks — the project's
shared context, an Admin agent's persona, a skill's rules, the
"this runs unattended" reminder — and each caller was joining them inline
with its own ``f"{a}\\n\\n{b}" if a else b`` expression. Two call sites now
need the same rule (the Cowork widget in R04-T04 and the task runner in
R04-T05), which is the point at which it stops being an expression.
Whitespace-only blocks count as absent: they would otherwise open the system
prompt with a stray blank line.
"""
return "\n\n".join(b.strip() for b in blocks if b and b.strip())
# --------------------------------------------------------------------------- #
# Callables.
# --------------------------------------------------------------------------- #
# Receives every typed event the turn produces. The caller decides what that
# means — render it, forward it as a legacy dict, autosave on it.
EventSink = Callable[[AgentEvent], None]
# True once the user has asked to stop. Polled between steps and between tool
# calls, the same cadence the current runtime uses.
CancelFn = Callable[[], bool]
# ``(prompt, attachment_paths) -> body``. Runs on the worker thread because
# extracting a .docx may pip-install a parser or call LibreOffice.
AttachmentReader = Callable[[str, Tuple[str, ...]], str]
# ``(messages, advertised_tool_names) -> None`` — inserts the system prompt and
# folds in skills, security rules and project instructions, in place. It needs
# the tool names because the system prompt gains an MS365 paragraph only when
# ms365 tools are actually present.
PromptPreparer = Callable[[List[Dict[str, Any]], Tuple[str, ...]], None]
# Reviews the assembled request; raises to refuse the turn outright.
PromptGuard = Callable[[List[Dict[str, Any]]], None]
# Reviews one proposed tool call; raises to refuse it.
CommandGuard = Callable[[str, Dict[str, Any]], None]
# ``(messages, cancel) -> None``. Summarises old turns in place when the
# conversation nears the model's context budget; a no-op when compaction is off
# or the conversation is short. It takes the cancel signal because compacting
# calls the model itself, so Stop has to reach it too.
ContextCompactor = Callable[[List[Dict[str, Any]], "CancelFn"], None]
# ``(action) -> approved``. Blocks the worker thread while a human decides.
PermissionRequest = Callable[[Dict[str, Any]], bool]
# --------------------------------------------------------------------------- #
# Ports.
# --------------------------------------------------------------------------- #
@runtime_checkable
class ModelCallPort(Protocol):
"""One call to the model, with the app's retry/recovery behaviour applied."""
def call(self, messages: List[Dict[str, Any]], tools: Sequence[Any],
on_text: Optional[Callable[[str], None]] = None,
on_reasoning: Optional[Callable[[str], None]] = None,
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
"""Return the canonical assistant message (content plus tool calls)."""
@runtime_checkable
class ToolRuntimePort(Protocol):
"""The tools a turn may call, and the folder its files land in."""
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> Sequence[Any]:
"""Tool specs to advertise to the model, already filtered.
Returns opaque objects (the provider layer's ``ToolSpec``); the service
only ever reads ``.name`` off them, which is what keeps this layer free
of a provider import.
"""
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
"""Human-readable description of a call that is about to run."""
def execute(self, name: str, args: Dict[str, Any],
on_output: Optional[Callable[[str], None]] = None,
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
"""Run one tool call.
Returns the runtime's own result mapping: ``ok``, ``output``, optionally
``path``/``produced`` for files it created, and ``plan_steps`` for the
plan tool.
"""
def snapshot(self) -> Any:
"""Opaque record of the output folder before the turn started."""
def finalize(self, before: Any, cancelled: bool = False
) -> Tuple[Sequence[str], Sequence[str]]:
"""Tidy the output folder; return ``(removed_paths, added_paths)``.
Not read-only — it deletes the scratch sandbox and flattens sub-folders —
so the service only calls it for a turn that actually started.
"""
__all__ = [
"PLAN_TOOL", "GATED_TOOLS", "REJECTED_OUTPUT", "REASONING_ONLY_NOTE",
"BUDGET_NOTE_TEMPLATE", "combine_instructions",
"EventSink", "CancelFn", "AttachmentReader", "PromptPreparer", "PromptGuard",
"CommandGuard", "ContextCompactor", "PermissionRequest",
"ModelCallPort", "ToolRuntimePort",
]
+54
View File
@@ -0,0 +1,54 @@
"""Application model routing package: model route decisions and multi-provider balancing.
Public surface (R03-T03 — the single routing entry point every chat surface uses):
* :class:`RoutingApplicationService` — decides one turn's provider/model.
* :class:`RoutingRequest` / :class:`RoutingOutcome` — the immutable DTOs in and out.
* :class:`RoutingMode` — Off / Auto / Manual / Fallback.
* :func:`build_routing_application_service` — wires the service to a live
``AppContext`` (engine + per-workspace mode + confirm timeout).
Typical call site (see ``ui/chat_panel.py::_apply_routing``)::
service = build_routing_application_service(self.ctx)
outcome = service.resolve(
RoutingRequest(surface="cowork", prompt=text,
current_provider=provider, current_model=model),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
Only ``core_routing_adapter`` touches ``core/routing``; the service and the DTOs
stay pure Python so the whole rule set is testable without Qt or the engine.
"""
from .core_routing_adapter import (
AppContextModeResolver,
CoreRoutingEngine,
build_routing_application_service,
)
from .routing_application_service import (
ConfirmationCallback,
ModeResolver,
RoutingApplicationService,
RoutingDecisionPort,
)
from .routing_models import (
RouteEvaluation,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
__all__ = [
"AppContextModeResolver",
"ConfirmationCallback",
"CoreRoutingEngine",
"ModeResolver",
"RouteEvaluation",
"RoutingApplicationService",
"RoutingDecisionPort",
"RoutingMode",
"RoutingOutcome",
"RoutingRequest",
"build_routing_application_service",
]
@@ -0,0 +1,169 @@
"""Adapters that plug the existing routing engine into the application service.
:mod:`routing_application_service` is written against two narrow ports so it can
be unit-tested with plain fakes. This module supplies the real implementations —
the assessment/scoring engine in ``core/routing`` and the per-workspace mode
lookup on ``AppContext`` — and is therefore the ONLY file in
``application/model_routing/`` that knows those concrete types exist.
All engine imports are deferred into method bodies. Importing the routing stack
pulls in Pydantic models and the on-disk assessment store, and the UI must be
able to import this module during startup without paying that cost (the same
lazy-wiring reason ``state.py::AppContext.routing`` gives).
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from .routing_application_service import RoutingApplicationService
from .routing_models import RouteEvaluation, RoutingMode, RoutingRequest
logger = logging.getLogger("cowork_local.application.model_routing")
class CoreRoutingEngine:
""":class:`RoutingDecisionPort` backed by ``core/routing/service.py``.
Translates in both directions: application DTOs in, and the engine's
``RouteResult``/``SwitchDecision``/``TaskType`` flattened back out into a
:class:`RouteEvaluation`, so no ``core.routing`` type ever escapes into the
application service or the UI call sites.
"""
def __init__(self, routing_service: Any) -> None:
self._routing_service = routing_service
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
"""Rank candidates for this turn and report the engine's verdict."""
from ...core.routing.models import TaskType, candidate_key
result = self._routing_service.route(
request.surface,
request.prompt,
request.current_provider,
request.current_model,
# The engine only knows off/auto/manual; FALLBACK was already mapped
# to AUTO upstream so the value handed over here is always valid.
mode_override=mode.value,
required_capabilities=list(request.required_capabilities) or None,
task_type=self._parse_task_type(request.task_type, TaskType),
)
decision = result.decision
target = result.target() # (provider, model_id) or None
current_key = (
candidate_key(request.current_provider, request.current_model)
if request.current_model
else ""
)
return RouteEvaluation(
task_type=self._task_type_value(result.task_type),
should_switch=bool(result.should_switch),
target_provider=target[0] if target else None,
target_model=target[1] if target else None,
score_gain=float(getattr(decision, "score_gain", 0.0) or 0.0),
reason=str(getattr(decision, "reason", "") or ""),
current_is_usable=self._current_is_usable(result, current_key),
decision=decision,
)
# -- translation helpers --------------------------------------------- #
@staticmethod
def _parse_task_type(raw: Optional[str], task_type_enum) -> Optional[Any]:
"""Coerce a task-type string to the engine's enum.
``None`` (the common case) means "let the engine classify the prompt".
An unrecognised string is also downgraded to ``None`` rather than
raising, so a stale value in a saved workspace cannot break a turn.
"""
if raw is None:
return None
if isinstance(raw, task_type_enum):
return raw
try:
return task_type_enum(str(raw).strip().lower())
except ValueError:
logger.warning("routing: unknown task type %r — classifying from the prompt", raw)
return None
@staticmethod
def _task_type_value(task_type: Any) -> str:
"""The plain string form of the engine's task type enum."""
return str(getattr(task_type, "value", task_type) or "")
@staticmethod
def _current_is_usable(result: Any, current_key: str) -> bool:
"""Whether the currently selected model can still serve this task.
This is the signal FALLBACK mode acts on. A model is usable when the
ranking scored it above zero; ``rank_models`` already drops candidates
that are unavailable, lack a probe for this task type, or failed their
last probe, so "absent from the ranking" is precisely "cannot serve it".
With no ranking (routing off, or the engine's internal error path) or no
current model, we answer True: absence of evidence must not trigger a
surprise switch in a mode whose whole promise is not to surprise.
"""
ranking = getattr(result, "ranking", None)
if ranking is None or not current_key:
return True
try:
return float(ranking.score_of(current_key)) > 0.0
except Exception: # noqa: BLE001 — defensive: never fail a turn on telemetry-ish data
logger.debug("routing: could not score current model %r", current_key, exc_info=True)
return True
class AppContextModeResolver:
""":class:`ModeResolver` backed by the active workspace's settings.
Reads through ``AppContext.project_routing_mode``, which already layers the
workspace override on top of the global default — so per-workspace routing
modes keep working unchanged now that the mode lookup moved out of the
widgets.
"""
def __init__(self, ctx: Any) -> None:
self._ctx = ctx
def mode_for(self, surface: str) -> RoutingMode:
"""Effective mode for ``surface`` in the active workspace."""
return RoutingMode.parse(self._ctx.project_routing_mode(surface))
def build_routing_application_service(ctx: Any) -> RoutingApplicationService:
"""The shared :class:`RoutingApplicationService` for this app context.
Cached on the context (like ``AppContext.routing()`` caches the engine) so
every surface talks to the same instance and a future stateful addition —
per-surface cool-down, switch history — is shared rather than duplicated per
widget. Falls back to a fresh instance if the context refuses attribute
assignment, which keeps tests using lightweight stand-ins working.
"""
cached = getattr(ctx, "_routing_app_service", None)
if cached is not None:
return cached
service = RoutingApplicationService(
CoreRoutingEngine(ctx.routing()),
AppContextModeResolver(ctx),
# Read at call time: the user can change the confirm timeout in Settings
# between two turns and the next Manual dialog should honour it.
confirm_timeout_sec=lambda: float(
(ctx.config.routing or {}).get("confirm_timeout_sec", 60) or 60
),
)
try:
ctx._routing_app_service = service
except Exception: # noqa: BLE001 — read-only/slotted stand-ins stay supported
logger.debug("routing: could not cache the application service on the context", exc_info=True)
return service
__all__ = [
"AppContextModeResolver",
"CoreRoutingEngine",
"build_routing_application_service",
]
@@ -0,0 +1,236 @@
"""The one place that decides how a turn is routed (R03-T03).
Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and
``ui/folder_tab.py`` each carried their own copy of the same eight-step dance:
clear last turn's override → read the surface's mode → bail on "off" → call the
routing engine → check ``should_switch`` → resolve the target → show the Manual
confirm dialog → publish the override and a status line. Three copies meant
three chances to drift, and none of them could be tested without a Qt widget.
The dance now lives here, once, in pure Python:
* the routing engine is reached through :class:`RoutingDecisionPort`;
* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`;
* the Manual-mode confirmation through a ``confirm`` callback supplied per call,
so the Qt dialog stays in the presentation layer where it belongs.
Every failure path degrades to "keep the current model": a routing problem must
never be the reason a user cannot send a message.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional, Protocol, runtime_checkable
from .routing_models import (
RouteEvaluation,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
logger = logging.getLogger("cowork_local.application.model_routing")
# Asks the user to approve a Manual-mode switch. Receives the underlying
# decision object (for rendering) plus the timeout in seconds; returns True to
# approve. Supplied by the caller so this module never imports a UI toolkit.
ConfirmationCallback = Callable[[Any, float], bool]
@runtime_checkable
class RoutingDecisionPort(Protocol):
"""The routing engine, as this service needs it.
Narrowed to a single method on purpose: the concrete engine
(``core/routing/service.py::RoutingService``) exposes assessment,
persistence and scheduling too, none of which a turn-time decision needs.
"""
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
"""Rank candidates for ``request`` and report whether to switch."""
@runtime_checkable
class ModeResolver(Protocol):
"""Resolves the effective routing mode for a surface.
In the app this reads the active workspace's per-surface override with the
global default behind it (``AppContext.project_routing_mode``); in tests it
is a two-line stub.
"""
def mode_for(self, surface: str) -> RoutingMode:
"""Effective mode for ``surface``."""
class RoutingApplicationService:
"""Turn-time routing decisions for every chat surface."""
# Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when
# no timeout provider is wired, so a bare service is still usable in tests.
DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0
def __init__(
self,
decision_port: RoutingDecisionPort,
mode_resolver: Optional[ModeResolver] = None,
*,
confirm_timeout_sec: Optional[Callable[[], float]] = None,
) -> None:
self._decision_port = decision_port
self._mode_resolver = mode_resolver
# A callable rather than a number: the timeout lives in mutable config
# the user can change in Settings between two turns.
self._confirm_timeout_sec = confirm_timeout_sec
# -- public API ------------------------------------------------------ #
def resolve(
self,
request: RoutingRequest,
confirm: Optional[ConfirmationCallback] = None,
) -> RoutingOutcome:
"""Decide this turn's provider/model.
Returns a :class:`RoutingOutcome`; ``provider``/``model`` are ``None``
whenever the surface should keep its own selection. Never raises — an
unexpected failure is logged and reported as "keep current", because a
broken assessment store must not block chatting.
"""
mode = request.mode or self._resolve_mode(request.surface)
try:
return self._resolve_unguarded(request, mode, confirm)
except Exception: # noqa: BLE001 — routing must never break a turn
logger.exception("routing.resolve failed — keeping the current model")
return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model")
def confirm_timeout(self) -> float:
"""Seconds to wait for a Manual-mode confirmation.
Falls back to the built-in default when the provider is missing or
returns something unusable, so a corrupted config value cannot produce a
zero-second dialog that instantly declines every switch.
"""
if self._confirm_timeout_sec is None:
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
try:
value = float(self._confirm_timeout_sec())
except (TypeError, ValueError):
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
return value if value > 0 else self.DEFAULT_CONFIRM_TIMEOUT_SEC
# -- internals ------------------------------------------------------- #
def _resolve_mode(self, surface: str) -> RoutingMode:
"""The surface's configured mode, defaulting to OFF when unresolvable —
routing stays opt-in, so "we don't know" must mean "don't switch"."""
if self._mode_resolver is None:
return RoutingMode.OFF
try:
return RoutingMode.parse(self._mode_resolver.mode_for(surface))
except Exception: # noqa: BLE001 — a config read must not break a turn
logger.exception("routing: could not resolve mode for surface %r", surface)
return RoutingMode.OFF
def _resolve_unguarded(
self,
request: RoutingRequest,
mode: RoutingMode,
confirm: Optional[ConfirmationCallback],
) -> RoutingOutcome:
"""The decision flow proper; :meth:`resolve` owns the safety net."""
# 1. Routing disabled, or nothing to classify -> keep the selection.
if mode is RoutingMode.OFF:
return RoutingOutcome.keep_current(mode, reason="routing off")
if not request.has_prompt:
return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route")
# 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because
# it needs the same candidate list; only the accept/reject rule below
# differs, so the engine stays unaware of the extra mode.
engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode
evaluation = self._decision_port.evaluate(request, engine_mode)
# 3. Apply the mode's own accept rule to the engine's verdict.
if mode is RoutingMode.FALLBACK:
accepted, reason = self._fallback_verdict(evaluation)
else:
accepted, reason = evaluation.should_switch, evaluation.reason
if not accepted or not evaluation.has_target:
return RoutingOutcome.keep_current(
mode,
reason=reason or evaluation.reason,
task_type=evaluation.task_type,
decision=evaluation.decision,
)
# 4. Manual mode asks first; a decline or a timeout keeps the current
# model (and is reported as such, so the surface can tell the two
# cases apart from "nothing better was found").
if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm):
return RoutingOutcome.keep_current(
mode,
reason="switch declined by user or confirmation timed out",
task_type=evaluation.task_type,
declined=True,
decision=evaluation.decision,
)
# 5. Publish the override for THIS turn only. The provider falls back to
# the request's current provider when the engine named a model but no
# provider (same-provider switch).
return RoutingOutcome(
mode=mode,
switched=True,
provider=evaluation.target_provider or request.current_provider,
model=evaluation.target_model or "",
task_type=evaluation.task_type,
score_gain=evaluation.score_gain,
reason=reason or evaluation.reason,
decision=evaluation.decision,
)
@staticmethod
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple:
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection.
The user's pinned model wins as long as it can serve the turn, even when
a higher-scoring candidate exists — that is the whole point of the mode.
A switch happens only when the current model is not a usable candidate
(never assessed, marked unavailable, or its last probe failed) and the
engine has something to move to.
"""
if evaluation.current_is_usable:
return False, "fallback mode — current model is healthy, keeping it"
if not evaluation.has_target:
return False, "fallback mode — current model unusable and no replacement available"
return True, "fallback mode — current model unavailable, switching to the best alternative"
def _approved(
self,
evaluation: RouteEvaluation,
confirm: Optional[ConfirmationCallback],
) -> bool:
"""Run the Manual-mode confirmation callback.
No callback means no way to ask, and silently switching in Manual mode
would violate the mode's contract — so a missing callback is treated as
"not approved". A callback that raises is treated the same way, since a
broken dialog must not auto-approve a model change.
"""
if confirm is None:
logger.warning("routing: manual mode without a confirmation callback — keeping current model")
return False
try:
return bool(confirm(evaluation.decision, self.confirm_timeout()))
except Exception: # noqa: BLE001
logger.exception("routing: confirmation callback failed — keeping current model")
return False
__all__ = [
"ConfirmationCallback",
"ModeResolver",
"RoutingApplicationService",
"RoutingDecisionPort",
]
+158
View File
@@ -0,0 +1,158 @@
"""Pure-Python DTOs exchanged with :mod:`routing_application_service`.
These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit)
now speak instead of each re-deriving routing state from raw config lookups and
``core/routing`` internals.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
code is 100% pure Python. Nothing here imports PySide6, and nothing here imports
``core.routing`` either — the concrete routing engine is reached only through
the adapter in :mod:`core_routing_adapter`, which keeps this module trivially
testable with plain fakes.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional, Tuple
class RoutingMode(str, Enum):
"""The four routing behaviours a surface can be in (R03-T03).
``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and
onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and
deliberately NOT an optimisation mode: it keeps whatever model the user
chose and only re-routes when that model cannot serve the turn, which is the
behaviour a resilience-minded workspace wants (never surprise me, but never
leave me stuck either).
"""
OFF = "off"
AUTO = "auto"
MANUAL = "manual"
FALLBACK = "fallback"
@classmethod
def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode":
"""Best-effort coercion from config/UI strings.
Routing must never break a turn, so an unrecognised value degrades to
``default`` (``OFF`` unless told otherwise) instead of raising — the same
defensive posture ``config.routing_mode_for`` already takes.
"""
fallback = default if default is not None else cls.OFF
if isinstance(raw, cls):
return raw
try:
return cls(str(raw or "").strip().lower())
except ValueError:
return fallback
@dataclass(frozen=True)
class RoutingRequest:
"""Everything needed to decide how ONE turn should be routed.
Frozen: the request is captured from live UI state (the selected model, the
typed prompt) and then handed to code that may run on a worker thread. An
immutable snapshot means the user changing the model picker mid-turn cannot
retroactively alter the decision that was already made — the same rationale
behind R04's ``ConversationExecutionRequest``.
"""
surface: str # "cowork" | "co4e" | "ai_edit" | ...
prompt: str # the user's text; drives task classification
current_provider: str # provider the surface would use as-is
current_model: str = "" # model the surface would use ("" = provider default)
mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface
# Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its
# turns are coding work, so it pins this and skips prompt classification.
task_type: Optional[str] = None
required_capabilities: Tuple[str, ...] = () # e.g. ("vision",)
@property
def has_prompt(self) -> bool:
"""Whether there is anything to classify. An empty prompt cannot be
routed meaningfully, so every surface short-circuits on it."""
return bool((self.prompt or "").strip())
@dataclass(frozen=True)
class RouteEvaluation:
"""A routing engine's verdict, normalised away from ``core/routing`` types.
The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain
fields so the application service never touches Pydantic models or enums
owned by another layer. ``decision`` still carries the original object
because the Manual-mode confirm dialog renders its ``reason``.
"""
task_type: str
should_switch: bool
target_provider: Optional[str] = None
target_model: Optional[str] = None
score_gain: float = 0.0
reason: str = ""
# False when the currently selected model is not a usable candidate for this
# task (unranked, unavailable, or failed its last probe) — the single signal
# FALLBACK mode acts on.
current_is_usable: bool = True
decision: Any = None # original SwitchDecision, for the UI dialog
@property
def has_target(self) -> bool:
"""A switch is only actionable when the engine named a model to move to."""
return bool(self.target_model or self.target_provider)
@dataclass(frozen=True)
class RoutingOutcome:
"""What the calling surface should actually do for this turn.
A surface needs exactly three things from routing — "which provider/model do
I build?", "do I tell the user?" and "was I told to stand down?" — so those
are the fields here, and nothing else. ``provider``/``model`` are ``None``
when the surface should keep its own selection untouched.
"""
mode: RoutingMode
switched: bool = False
provider: Optional[str] = None
model: Optional[str] = None
task_type: str = ""
score_gain: float = 0.0
reason: str = ""
# True when Manual mode proposed a switch and the user declined or the
# confirmation timed out. Distinct from "no switch proposed" so a surface
# can tell "routing had nothing to offer" from "the user said no".
declined: bool = False
decision: Any = field(default=None, repr=False)
@property
def should_notify(self) -> bool:
"""Whether the surface should post the "switched model" status bubble.
Only an executed switch is worth interrupting the transcript for."""
return self.switched
@classmethod
def keep_current(
cls,
mode: RoutingMode,
*,
reason: str = "",
task_type: str = "",
declined: bool = False,
decision: Any = None,
) -> "RoutingOutcome":
"""The no-change outcome — the single constructor for every path that
leaves the surface's own model selection in place (routing off, empty
prompt, no better candidate, user declined, internal error)."""
return cls(
mode=mode, switched=False, provider=None, model=None,
task_type=task_type, reason=reason, declined=declined, decision=decision,
)
__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"]
+1
View File
@@ -0,0 +1 @@
"""Application monitoring package: Monitoring query service for audit and metrics."""
+1
View File
@@ -0,0 +1 @@
"""Application scheduling package: TaskApplicationService and AI task planning."""
+1
View File
@@ -0,0 +1 @@
"""Application settings package: Settings application service."""
+1
View File
@@ -0,0 +1 @@
"""Application workflows package: Co4E graph execution orchestration."""
+1
View File
@@ -0,0 +1 @@
"""Application workspaces package: File workspace and AI file editor services."""
+14 -7
View File
@@ -555,19 +555,26 @@ class AppConfig:
d["surface_modes"].setdefault(surface, "")
return d
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual mode for a chat surface.
# The routing modes a surface may be in. "fallback" joined the set in
# R03-T03 (keep the selected model; re-route only when it cannot serve the
# turn) — see application/model_routing/routing_models.py::RoutingMode,
# which is the authority on what each mode means.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
A per-surface override ("auto"/"manual"/"off") wins; an empty override
falls back to the global ``switch_mode``."""
def routing_mode_for(self, surface: str) -> str:
"""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``. Anything unrecognised degrades to "off" so routing
stays opt-in even with a hand-edited config."""
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 mode if mode in self.ROUTING_MODES else "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"
"""Persist a chat surface's routing toggle selection."""
mode = mode if mode in self.ROUTING_MODES else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
+69 -19
View File
@@ -2,7 +2,9 @@
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
- ``cowork`` → ``chat_agent.run_cowork`` (documents/answers, real files)
- ``cowork`` → ``ConversationApplicationService`` (documents/answers, real
files) — the same turn engine the interactive Cowork chat
runs on since R04-T05
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
- ``script`` → local subprocess with a timeout
- ``flow`` → the task's own simple step list, run sequentially, each
@@ -162,6 +164,30 @@ _TIMEOUT_NOTICE_TMPL = (
)
_UNATTENDED_PREFIX = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed."
)
def _unattended_prompt(prompt: str, *, skill_text: str = "",
agent_instructions: str = "") -> str:
"""Assemble the user message an unattended run sends.
The order is load-bearing and used to be encoded as three successive
rebindings of ``prompt``, each prepending its own block: the plan reminder
must lead (it is the instruction that keeps a run without a human watching
honest), then the chosen skill's rules, then the Admin agent's persona, and
the task's own words last. Routing it through ``combine_instructions`` keeps
that order in one readable expression and drops the absent blocks instead of
leaving blank lines behind.
"""
from ..application.conversations.turn_runtime import combine_instructions
return combine_instructions(_UNATTENDED_PREFIX, skill_text, agent_instructions, prompt)
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
@@ -218,36 +244,29 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
# default, see state.build_provider_for). A legacy Admin-agent preset
# (task.admin_agent_id), if still set on an older task, keeps working and
# takes precedence — it pins the provider/model AND prepends instructions.
agent_instructions = ""
if admin_agent is not None:
from .admin_agents import build_agent_provider
provider = build_agent_provider(ctx, admin_agent)
agent_instructions = admin_agent.effective_prompt()
if agent_instructions:
prompt = f"{agent_instructions}\n\n{prompt}"
elif provider_name or model:
# An explicit per-task provider/model override.
provider = ctx.build_provider_for(provider_name or None, model or None)
else:
# Neither overridden → the machine's own Settings default, exactly as before.
provider = ctx.build_active_provider()
# A chosen skill's instructions are prepended so this unattended run follows
# A chosen skill's instructions are applied so this unattended run follows
# them, mirroring how the interactive chat applies /skill.
skill_text = ""
if skill_slug:
from .skills import skill_prefix_for
skill_text = skill_prefix_for(skill_slug)
if skill_text:
prompt = f"{skill_text}\n\n{prompt}"
# This is an UNATTENDED run (no human watching to catch a half-finished
# job) — push the agent to actually use the Plan checklist so completion
# can be verified afterward, instead of just trusting "no exception".
prompt = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
# Assemble reminder + skill + persona + the task's own words in one place
# (see _unattended_prompt for why that order matters).
prompt = _unattended_prompt(prompt, skill_text=skill_text,
agent_instructions=agent_instructions)
messages = [{"role": "user", "content": prompt}]
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
@@ -273,10 +292,41 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
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)
# R04-T05: the unattended run shares the interactive turn engine
# instead of calling run_cowork itself, so there is exactly one place
# where a turn's lifecycle is defined. Everything unattended-specific
# stays here (the plan reminder above, the History autosave in
# emit_and_autosave, the timeout notice below).
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from ..domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
# No extra_tools/extra_executor and no permission gate: a scheduled
# run gets no MCP connectors and nobody is there to approve a
# command, which is exactly what run_cowork was called with.
service = build_cowork_conversation_service(
provider, out_dir, emit_and_autosave, title=title,
project_context=project_context, security_config=ctx.config,
agent_role=agent_roles.TASK,
)
request = ConversationExecutionRequest(
# The artifact folder is named by the run id, which identifies
# this attempt in the audit log.
turn_id=out_dir.name or session_id, session_id=session_id,
surface="task", title=title, project_id=project_id,
prompt=prompt, output_dir=out_dir,
agent_role=agent_roles.TASK, unattended=True,
timeout_sec=timeout_sec,
)
# ``messages`` is handed over so the History autosave in
# emit_and_autosave (and the final save in the finally block below)
# keep reading the live conversation as it grows.
service.execute(request, legacy_event_sink(emit_and_autosave),
cancel=watched_cancel, messages=messages)
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)
+12
View File
@@ -49,6 +49,18 @@ def set_context(source: str, label: str = "") -> None:
_local.label = label
def current_context() -> tuple:
"""The ``(source, label)`` currently tagged on THIS thread.
Public counterpart to :func:`set_context`, added for
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
attribute one event to a different surface must be able to save the
caller's context and put it back afterwards, instead of leaving the worker
thread permanently retagged.
"""
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
# ---- per-thread usage accumulator -----------------------------------------
# A step/run that wants to know its OWN token/cost (not the all-time file total)
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
@@ -0,0 +1,103 @@
# ADR-001: 4-Tier Clean Architecture for Desktop Local Application
* **Status**: ACCEPTED / ENFORCED
* **Date**: 2026-08-21
* **Deciders**: Team Duy (Tech Lead & AI Runtime), Team Nam (Governance & Automation), Team Hoa (Workspace & Scheduling)
* **Target Project**: Cowork Local (Cowork-Local BamBOO)
---
## 1. Context and Problem Statement
Cowork Local is a desktop application written in Python using PySide6 (Qt) and designed for local-first execution.
Historically, the codebase suffered from architectural coupling across layers:
1. **God-Widget Problem**: Monolithic UI widgets (e.g., `ui/chat_panel.py` >1,800 LOC, `ui/co4e_tab.py` >1,400 LOC) mixed UI rendering, network I/O, business rules, filesystem operations, and background worker lifecycle.
2. **Untestable Business Logic**: Core algorithms (model routing, conversation turn management, schedule calculation) were tightly coupled to `PySide6` widgets or `QTimer`, making unit testing in headless CI environments impossible without a graphical display server.
3. **Circular Dependencies & Global State Leaks**: Uncontrolled module imports (`model_pricing.py` ↔ `usage_tracker.py`, `agent_security.py` ↔ `agent_security_alert.py`) and mutable global state (`state.py::AppContext.active_project_id`) caused race conditions in background task runs.
---
## 2. Decision: 4-Tier Clean Architecture
We enforce a strict **4-Tier Clean Architecture** based on the Dependency Inversion Principle:
```text
┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION │
│ (PySide6 Widgets, Dialogs, Qt Signals/Slots, View Models) │
└──────────────────────────────┬──────────────────────────────┘
│ depends on
▼
┌─────────────────────────────────────────────────────────────┐
│ APPLICATION │
│ (Use Case Services, Turn Orchestrators, Route Dispatchers) │
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
└──────────────────────────────┬──────────────────────────────┘
│ depends on
▼
┌─────────────────────────────────────────────────────────────┐
│ DOMAIN & RUNTIME CORE │
│ (Entities, Value Objects, Domain Events, Tool Descriptors) │
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
└──────────────────────────────▲──────────────────────────────┘
│ implemented by
┌──────────────────────────────┴──────────────────────────────┐
│ INFRASTRUCTURE │
│ (LLM Providers, Keyring Secrets, Atomic Persistence, MCP) │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. Layer Definitions and Responsibilities
### Tier 1: Presentation Layer (`presentation/`)
* **Responsibilities**: UI component layout, user event capture, progress display, visual animations, confirmation dialog triggers.
* **Allowed Imports**: `PySide6.*`, `application.*`, `domain.*`.
* **Forbidden**: Direct database queries, raw LLM API calls, disk writes outside UI cache, executing tool commands directly.
* **Constraints**: Every widget file must strictly be **under 400 lines of code (LOC)**.
### Tier 2: Application Layer (`application/`)
* **Responsibilities**: Orchestrate single use cases (e.g. `ConversationApplicationService`, `RoutingApplicationService`, `TaskApplicationService`). Convert UI requests into domain requests, coordinate domain services with infrastructure adapters.
* **Allowed Imports**: `domain.*`, `infrastructure.*` interfaces/contracts, standard Python libraries.
* **Forbidden**: `PySide6`, `PyQt5`, `PyQt6`, `ui.*`, `app.*`.
* **Nature**: **100% Pure Python**. Must be executable and testable in headless CI environments without a display driver.
### Tier 3: Domain Layer (`domain/`)
* **Responsibilities**: Core domain models, frozen DTO snapshots (`ConversationExecutionRequest`), typed event streams (`AgentEvent`), descriptors (`ToolDescriptor`, `ProviderDescriptor`), deterministic calculation algorithms (`ScheduleCalculator`).
* **Allowed Imports**: Standard Python library only (`dataclasses`, `typing`, `enum`, `datetime`, `pathlib`, `abc`).
* **Forbidden**: `PySide6`, `PyQt*`, `requests`, `sqlalchemy`, filesystem mutations, OS network calls.
* **Nature**: Completely isolated and zero-dependency core.
### Tier 4: Infrastructure Layer (`infrastructure/`)
* **Responsibilities**: Adapters for external systems (OpenAI/Anthropic/Ollama/FPT providers, OS Keyring via `SecretStore`, `AtomicJsonFile` persistence, MCP child processes, filesystem tools).
* **Allowed Imports**: Third-party SDKs, OS libraries, `domain.*`.
* **Forbidden**: `presentation.*`, `PySide6.QtWidgets`.
---
## 4. Architectural Rules and Non-Negotiable Invariants
1. **Zero Qt in Business Logic**:
- `domain/` and `application/` must never import `PySide6` or `PyQt*`.
- Verified via AST parser script `scripts/check_imports.py`.
2. **Immutable Request Snapshots**:
- Turns are initiated using immutable frozen dataclasses (`ConversationExecutionRequest`) to decouple runtime state from mutable UI state.
3. **Thread Safety and Signal Decoupling**:
- AI generation and tool calls run asynchronously in worker threads.
- UI updates occur strictly on the Qt main thread by consuming `AgentEvent` streams through Qt Signal bridges.
4. **Single Responsibility and Modularity**:
- Production files must stay within **400 LOC**.
5. **English In-Code Comments**:
- Every modified or created line/block must include concise English comments explaining design decisions and processing logic.
---
## 5. Consequences and Compliance
* **Positive**:
- Full testability: Unit tests run in milliseconds without GUI or network mocks.
- Zero circular dependencies: Clear top-down data flow.
- Resilience: UI crashes do not corrupt background tasks or files.
* **Verification**:
- Automated CI gate: `python scripts/check_imports.py` and `python scripts/check_loc.py`.
+39
View File
@@ -0,0 +1,39 @@
# Danh Mục & Kế Hoạch Cô Lập Mã Nguồn Dormant / Dead Code (Dormant Code Catalog)
* **Tài liệu**: `docs/architecture/dormant-code.md`
* **Thuộc EPIC**: `R01: Architecture Foundation & Characterization`
* **Team phụ trách**: 🔵 **Team Duy (Tech Lead)**
---
## 1. Mục Đích & Nguyên Tắc Quản Trị
Trong quá trình phát triển nhanh, một số module, hàm hoặc script đã trở thành mã nguồn không hoạt động (**dormant**), mã nguồn thử nghiệm cũ (**legacy prototypes**), hoặc mã nguồn không còn được sử dụng (**dead code**).
> [!IMPORTANT]
> ### 🛡️ NGUYÊN TẮC CÔ LẬP MÃ NGUỒN CŨ:
> 1. **Tuyệt đối không import vào các tầng mới**: Các tầng `domain/`, `application/`, `infrastructure/` mới được xây dựng **cấm tuyệt đối import bất kỳ module dormant nào**.
> 2. **Không xóa vội vàng khi chưa có test bảo vệ**: Giữ nguyên mã nguồn cũ trong giai đoạn tái cấu trúc R01–R08; chỉ dọn dẹp hoặc xóa sau khi bộ kiểm thử khói E2E (EPIC R10) chạy pass 100%.
> 3. **Phân loại rõ ràng trạng thái**: Mỗi module dormant phải được gắn nhãn (DEPRECATED / ISOLATED / PENDING_DELETION).
---
## 2. Bảng Danh Mục Mã Nguồn Dormant / Dead Code Đã Rà Soát
| STT | File / Module / Ký Hiệu | Trạng Thái Hiện Tại | Lý Do Phân Loại & Phân Tích Kỹ Thuật | Kế Hoạch Xử Lý & Thời Điểm Gỡ Bỏ |
| :---: | :--- | :---: | :--- | :--- |
| **1** | `requirements (cloud copy).txt` | `PENDING_DELETION` | File sao chép dự phòng tạm thời trong quá khứ, không được tham chiếu bởi bất kỳ quy trình setup nào. | Gỡ bỏ trong EPIC R10 (Packaging & Clean-up). |
| **2** | `preview-desktop` | `ISOLATED` | Script shell rỗng/phác thảo cho môi trường dev container cũ. | Cô lập, không liên kết vào build workflow. |
| **3** | `scripts/bootstrap_gitea_repo.py` | `ISOLATED` | Script tiện ích bootstrap kho lưu trữ Gitea nội bộ; không thuộc runtime ứng dụng chính. | Di chuyển vào `docs/gitea/` làm tài liệu tham khảo ops. |
| **4** | Hàm routing sao chép tại `ui/chat_panel.py#L638` | `DEPRECATED` | Đoạn code logic chọn model lặp lại từ `core/routing/` nằm trực tiếp trong UI widget. | Thay thế hoàn toàn bằng `RoutingApplicationService` trong EPIC R03. |
| **5** | Biến toàn cục `state.py::active_project_id` | `DEPRECATED` | Biến global mutable gây race condition khi chạy background task song song. | Thay thế bằng `WorkspaceSession` trong EPIC R06. |
| **6** | Các hàm xử lý UI đồng bộ trong `core/tools.py` | `DEPRECATED` | `core/tools.py` chứa mã monolithic vừa xử lý file vừa gọi dialog xác thực trực tiếp. | Phân rã thành `file_tools.py`, `command_tools.py` và `ToolPolicyGateway` trong EPIC R05. |
---
## 3. Quy Trình Cô Lập & Kiểm Soát
1. **Kiểm tra tự động qua AST Guard**:
- Bộ script `scripts/check_imports.py` tự động quét để đảm bảo không có bất kỳ import mới nào trỏ tới các thành phần đã đánh dấu deprecated.
2. **Kế hoạch dọn dẹp cuối cùng (Release Phase - 31/08/2026)**:
- Sau khi hoàn thành EPIC R10 và pass toàn bộ bài test E2E (`tests/e2e/test_smoke.py`), các file đánh dấu `PENDING_DELETION` sẽ được gỡ bỏ khỏi nhánh `main`.
+100 -36
View File
@@ -26,16 +26,16 @@
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Phối hợp cả 3 team
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
- [ ] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
*Start: `2026-08-21 18:23` | End: `2026-08-21 18:24`*
- [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
*Start: `2026-08-21 18:24` | End: `2026-08-21 18:26`*
- [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
*Start: `2026-08-21 18:26` | End: `2026-08-21 18:28`*
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
*Start: `2026-08-21 18:28` | End: `2026-08-21 18:32`*
- [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
*Start: `2026-08-21 18:32` | End: `2026-08-21 18:35`*
---
@@ -62,18 +62,72 @@
* **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-22 18:59` | End: `2026-08-22 19:01`*
- [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-22 18:45` | End: `2026-08-22 18:50`*
- [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-22 18:53` | End: `2026-08-22 18:57`*
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
*Start: `2026-08-22 18:57` | End: `2026-08-22 18:58`*
- [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-22 18:58` | End: `2026-08-22 18:59`*
- [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-22 18:50` | End: `2026-08-22 18:53`*
#### 📦 KẾT QUẢ THỰC HIỆN EPIC R03 (Hoàn tất 2026-08-22 19:01 — nhánh `feature/delta-team/epic-R03`)
**File sản phẩm mới (tất cả < 400 dòng, 100% comment tiếng Anh):**
| Task | File | LOC | Nội dung chính |
| :--- | :--- | :---: | :--- |
| T02 | `domain/models/provider_descriptor.py` | 196 | `ProviderDescriptor` (frozen dataclass), `WireProtocol`, `AuthKind`; giá/context để `None` khi chưa biết thay vì đoán bừa |
| T02 | `infrastructure/providers/provider_registry.py` | 287 | `ProviderRegistry` thread-safe: tra cứu theo id/alias, **tra cứu động theo model ID** (`find_by_model`), dựng adapter theo wire protocol; `BUILTIN_DESCRIPTORS` cho 5 provider |
| T03 | `application/model_routing/routing_models.py` | 158 | DTO thuần Python: `RoutingMode` (Off/Auto/Manual/**Fallback**), `RoutingRequest` (immutable snapshot), `RouteEvaluation`, `RoutingOutcome` |
| T03 | `application/model_routing/routing_application_service.py` | 236 | `RoutingApplicationService` — 1 nơi duy nhất quyết định routing; 2 port hẹp (`RoutingDecisionPort`, `ModeResolver`) + callback confirm ⇒ 0 phụ thuộc Qt |
| T03 | `application/model_routing/core_routing_adapter.py` | 169 | `CoreRoutingEngine` (cầu nối sang `core/routing`), `AppContextModeResolver`, `build_routing_application_service(ctx)` (cache 1 instance/ctx) |
| T06 | `infrastructure/telemetry/usage_sink.py` | 288 | `UsageEvent` + `UsageEventSink` (Protocol) + `UsageTrackerSink` / `InMemoryUsageSink` / `CompositeUsageSink`; publish không bao giờ raise |
**File hiện hữu được sửa (đều có comment tiếng Anh tại mọi khối thay đổi):**
| File | Thay đổi |
| :--- | :--- |
| `providers/factory.py` | Bỏ bảng `_REGISTRY` nội bộ, ủy quyền cho `ProviderRegistry`; vẫn raise `ProviderError` để không vỡ call site cũ |
| `providers/openai_compat.py`, `providers/anthropic.py` | Không còn gọi thẳng `core/usage_tracker`; chỉ **publish** `UsageEvent` qua sink (T06) |
| `ui/chat_panel.py` (#L638), `ui/co4e_tab.py`, `ui/folder_tab.py` | Xóa 3 bản sao logic routing (~35 dòng/file) ➔ gọi chung `RoutingApplicationService` (T04, T05); widget chỉ còn dựng `RoutingRequest`, host modal confirm và render kết quả |
| `config.py`, `state.py`, `ui/routing_toggle.py`, `i18n.py` | Mở đường cho chế độ thứ 4 **Fallback**: hằng `AppConfig.ROUTING_MODES`, validate per-workspace, thêm mục trong combo + chuỗi EN/JA/VI |
| `core/usage_tracker.py` | Thêm `current_context()` để sink mượn/trả lại context của thread thay vì gán đè vĩnh viễn |
| `tests/conftest.py`, `tests/routing/conftest.py` | **Sửa lỗi hạ tầng test nghiêm trọng** (xem "Ghi chú" bên dưới) |
**Bộ test bổ sung (tất cả offline, không cần network/Qt):**
| File | Số test | Phạm vi |
| :--- | :---: | :--- |
| `tests/contracts/test_providers.py` (+ `provider_stubs.py`) | 50 | Contract chạy parametrize trên **mọi** provider trong registry: signature `chat()`, canonical assistant message, tool call chuẩn hóa, đóng response, dịch tool schema, `ProviderError`, `list_models`/`test_connection`, đúng 1 `UsageEvent`/turn |
| `tests/unit/test_routing_application_service.py` | 28 | Đủ 4 chế độ + mọi nhánh degrade (engine lỗi, resolver lỗi, dialog lỗi, thiếu callback) |
| `tests/unit/test_provider_registry.py` | 17 | Descriptor + registry + đối chiếu catalogue với `DEFAULT_CONFIG["providers"]` |
| `tests/unit/test_core_routing_adapter.py` | 12 | Dịch `RouteResult` ⇄ DTO, task type sai định dạng, thiếu ranking, cache service |
| `tests/unit/test_usage_sink.py` | 13 | Fan-out, subscriber lỗi, khôi phục thread context, publish không raise |
| `tests/integration/test_routing_unification.py` | 14 | Chạy `RoutingApplicationService` trên **engine `core/routing` thật**; 3 surface (cowork/co4e/ai_edit) cho ra cùng 1 quyết định |
**Kết quả cổng kiểm duyệt (DoD 7 tiêu chí):**
| # | Tiêu chí | Lệnh | Kết quả |
| :---: | :--- | :--- | :--- |
| 1 | LOC < 400 | `wc -l` các file mới | ✅ Lớn nhất 288 dòng (`usage_sink.py`); `openai_compat.py` 374, `anthropic.py` 332 |
| 2 | Clean Architecture | `python scripts/check_imports.py` | ✅ `[PASS] 0 forbidden imports detected` |
| 3 | Comment tiếng Anh | Review thủ công | ✅ 100% khối code mới/sửa có comment giải thích logic + lý do kiến trúc |
| 4 | Có test tự động | `pytest tests/unit tests/contracts tests/integration` | ✅ 134 test mới, pass 100% |
| 5 | No Regression | `pytest tests/` | ✅ **236 passed in ~2.0s** (nền trước R03: 102 passed) |
| 6 | Timestamps | Bảng trên | ✅ Đã ghi Start/End cho T01–T06 |
| 7 | CASAN Gate | `scripts/run_quality_gate.py` | ⚠️ Script **chưa tồn tại** — thuộc R10-T02 (chưa làm). Đã chạy thay bằng `check_imports.py` + `pytest tests/` |
**Ghi chú kỹ thuật cần biết khi review:**
1. **Đã sửa 1 lỗi hạ tầng test có thể gây kết quả sai lệch**: `tests/conftest.py` cũ đẩy thư mục **cha** của repo vào `sys.path`, nên `import cowork_local.*` (dùng bởi `tests/routing/*` và `tests/characterization/*`) trỏ sang **một checkout `cowork_local` khác** nằm cạnh thư mục làm việc — test vẫn báo xanh nhưng chạy trên mã nguồn khác. Nay conftest bind thẳng checkout hiện tại vào `sys.modules["cowork_local"]`.
2. **Chế độ Fallback** là chế độ *chống gãy*, không phải chế độ tối ưu: giữ nguyên model người dùng chọn kể cả khi có model điểm cao hơn, chỉ chuyển khi model đó **không phục vụ được** turn (không có trong ranking / unavailable / probe fail). Engine `core/routing` không cần biết chế độ này — service map Fallback ➔ Auto khi hỏi ranking rồi tự áp luật chấp nhận riêng.
3. **T06 hiện tại**: provider publish `UsageEvent`; khi R04 dựng xong `AgentEvent` bus thì `ConversationApplicationService` sẽ là nơi phát sự kiện, sink giữ nguyên không phải sửa.
4. **Cần cài `mcp>=1.0.0`** (đã có trong `requirements.txt`) để `tests/test_project_context_mcp_template.py` collect được — thiếu gói này toàn bộ suite bị interrupt.
---
@@ -81,16 +135,22 @@
* **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-23 00:56` | End: `2026-08-23 01:00`*
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py` (+ `domain/agents/agent_event_codec.py` — shim dịch legacy dict, tách riêng để giữ LOC < 400 và để xoá gọn sau R08)
*Start: `2026-08-23 01:00` | End: `2026-08-23 01:08`*
- [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` (+ `turn_runtime.py` định nghĩa 2 port/6 callable, `core_runtime_adapter.py` cầu nối sang `core/*`, `domain/agents/agent_result.py`)
*Start: `2026-08-23 01:08` | End: `2026-08-23 07:10`*
Chưa đổi call site nào — `run_cowork` giữ nguyên (Co4E vẫn dùng); việc chuyển call site là T04/T05. Bằng chứng tương đương: `tests/integration/test_conversation_service_parity.py` chạy cùng 1 script provider qua 2 đường và so khớp từng event/message/tool list trên 7 kịch bản.
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `2026-08-23 07:10` | End: `2026-08-23 07:23`*
`build_job` không còn gọi `run_cowork`: nó chụp state widget tại submit time ➔ `build_cowork_turn_request()` (mới, `application/conversations/cowork_turn_request.py`) ➔ `ConversationApplicationService`. Thêm `combine_instructions()` vào `turn_runtime.py` (project context + admin agent, T05 dùng lại) và tham số `messages=` cho `execute()` để service append vào **đúng list của widget** — `_reattach_running_turn` đọc list đó trong lúc turn đang chạy và `_finalize_turn` slice nó sau đó. Kiểm chứng: `tests/integration/test_cowork_tab_turn.py` gọi thẳng `CoworkTab.build_job` (widget stub, không cần Qt) và chạy turn thật với `FakeProvider`.
⚠️ `ui/cowork_tab.py` 416 ➔ 455 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R08.
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `2026-08-23 07:23` | End: `2026-08-23 07:31`*
Nhánh `task_type == "cowork"` của `_run_agent` gọi service thay vì `run_cowork`; 5 hành vi riêng của unattended run giữ nguyên (plan reminder, `history_ready`, autosave History mỗi `assistant_done`, timeout notice, `plan_incomplete_reason`). Tách `_unattended_prompt()` dùng `combine_instructions` để thứ tự reminder → skill → persona → prompt nằm ở 1 chỗ đọc được. Lưới an toàn: `tests/integration/test_task_executor_turn.py` viết **trước** khi migrate và pass 8/8 trên code cũ, vẫn pass sau khi migrate.
⚠️ `core/task_executors.py` 476 ➔ 524 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R07 (`application/scheduling/`).
Còn lại gọi `run_cowork`: `core/co4e_runner.py` (×2) và `ui/co4e_tab.py` — phân hệ Co4E của 🟣 Team Nam, R04 không chạm theo luật 1 file 1 team.
---
@@ -229,17 +289,21 @@
| 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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 18:23` | `2026-08-21 18:35` | [x] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-22 18:45` | `2026-08-22 19:01` | [x] |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-22 18:53` | `2026-08-22 18:57` | [~] |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-22 18:57` | `2026-08-22 18:59` | [~] |
| **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
> **Chú thích trạng thái**: `[~]` = hoàn tất **phần thuộc EPIC R03**, phần còn lại của dòng đó thuộc EPIC khác nên chưa đóng.
> - Dòng **24/08**: đã xong `RoutingApplicationService` (R03-T03); phần `ComposerWidget`/`AttachmentPicker` thuộc R08-T01/T02 — chưa làm.
> - Dòng **28/08**: đã xóa copy routing trong `ui/chat_panel.py` (R03-T04) **và** cả `ui/co4e_tab.py`, `ui/folder_tab.py` (R03-T05); phần circular import `model_pricing` ↔ `usage_tracker` thuộc R09-T02 — chưa làm.
---
### 🟣 TEAM NAM (Automation Workflows, Co4E, Monitoring & Governance)
+155
View File
@@ -0,0 +1,155 @@
# NHẬT KÝ THEO DÕI VÀ PHÒNG NGỪA LỖI TÁI CẤU TRÚC (BUG & LESSONS LEARNED LOG)
## DỰ ÁN: COWORK LOCAL (COWORK-LOCAL BAMBOO)
Tài liệu này dùng để ghi nhận **toàn bộ các lỗi, xung đột kiến trúc và sự cố phát sinh** trong suốt quá trình refactoring của cả 3 team (Team Duy, Team Nam, Team Hoa).
> [!IMPORTANT]
> ### 🛡️ NGUYÊN TẮC VÀNG VỀ QUẢN TRỊ CHẤT LƯỢNG (ZERO RECURRENCE):
> 1. **Ghi nhận ngay lập tức**: Khi gặp bất kỳ lỗi nào (Syntax, Circular Import, Type Error, Test Failure, Thread Freeze, Data Corruption), kỹ sư/AI phải ghi ngay vào tài liệu này trước khi tiếp tục task.
> 2. **Phân tích nguyên nhân gốc rễ (Root Cause)**: Không chỉ sửa phần ngọn mà phải giải thích rõ bản chất vì sao lỗi xảy ra.
> 3. **Rút ra quy tắc phòng ngừa (Prevention Rule)**: Đặt ra nguyên tắc kỹ thuật để **TUYỆT ĐỐI KHÔNG TÁI PHẠM** ở các task tiếp theo.
> 4. **Checklist đầu vào**: Trước khi bắt đầu bất kỳ task mới nào, kỹ sư/AI **bắt buộc phải đọc lại toàn bộ file này**.
---
## 📌 BẢNG TỔNG HỢP CÁC LỖI ĐÃ PHÁT HIỆN & KHẮC PHỤC
| Bug ID | Ngày Phát Hiện | Phân Hệ / File Bị Ảnh Hưởng | Loại Lỗi | Trạng Thái | Team Phụ Trách |
| :--- | :---: | :--- | :--- | :---: | :---: |
| `BUG-001` | 2026-08-20 | `core/model_pricing.py` ↔ `core/usage_tracker.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Duy & Team Nam |
| `BUG-002` | 2026-08-20 | `core/agent_security.py` ↔ `core/agent_security_alert.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Nam |
| `BUG-003` | 2026-08-20 | `state.py::active_project_id` & `ui/workspace_tab.py` | Race Condition / Global State Leak | 🟡 Đã có giải pháp (R06) | Team Hoa |
| `BUG-004` | 2026-08-20 | `core/task_scheduler.py` ↔ `PySide6.QtCore.QTimer` | Architecture Violation (Qt in Domain/App) | 🟡 Đã có giải pháp (R07) | Team Hoa |
| `BUG-005` | 2026-08-20 | `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Code Duplication (Copy Routing Logic) | 🟡 Đã có giải pháp (R03) | Team Duy |
| `BUG-006` | 2026-08-21 | `scripts/check_imports.py` | UnicodeEncodeError (Windows CP932 console emoji) | 🟢 Đã khắc phục (R01) | Team Duy |
| `BUG-007` | 2026-08-21 | `platform/` ➔ `infrastructure/platform/` | Standard Library Shadowing (`import platform`) | 🟢 Đã khắc phục (R01) | Team Duy |
---
## 🔍 CHI TIẾT TỪNG LỖI & QUY TẮC PHÒNG NGỪA
---
### 🔴 `BUG-001`: Circular Import giữa Module Định Giá (`model_pricing.py`) và Theo Dõi Token (`usage_tracker.py`)
* **Phân hệ**: `core/model_pricing.py` & `core/usage_tracker.py`
* **Triệu chứng (Symptom)**: Lỗi `ImportError: cannot import name 'ModelPricing' from partially initialized module` khi khởi động ứng dụng hoặc chạy test độc lập.
* **Nguyên nhân gốc rễ (Root Cause)**:
- `model_pricing.py` import `UsageTracker` để cập nhật dữ liệu tiêu thụ.
- Ngược lại, `usage_tracker.py` import `ModelPricing` để tính toán chi phí theo từng model ID.
* **Giải pháp khắc phục (Resolution)**:
- Tách Data Transfer Object (DTO) `ModelPricing` sang tầng Domain thuần túy `domain/models/model_pricing.py`.
- Cả `model_pricing.py` và `usage_tracker.py` đều import DTO từ `domain/models/`, chuyển quan hệ thành 1 chiều (Dependency Inversion).
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Không bao giờ để 2 service hoặc 2 module nghiệp vụ import lẫn nhau. Mọi cấu trúc dữ liệu dùng chung (DTO/Value Object/Event) **phải được đặt tại tầng `domain/`**.
---
### 🔴 `BUG-002`: Circular Import giữa An Ninh Agent (`agent_security.py`) và Cảnh Báo (`agent_security_alert.py`)
* **Phân hệ**: `core/agent_security.py` & `core/agent_security_alert.py`
* **Triệu chứng (Symptom)**: Lỗi khởi tạo vòng tròn khi runtime bắn ra alert sự kiện bảo mật.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Module security vừa kiểm tra policy vừa khởi tạo trực tiếp instance alert dialog, trong khi alert dialog lại import ngược lại rule security để hiển thị chi tiết mã lỗi.
* **Giải pháp khắc phục (Resolution)**:
- Tách sự kiện cảnh báo thành Event DTO `SecurityAlertEvent` tại `domain/security/security_event.py`.
- Tầng Security chỉ phát ra Event (`emit_event`), tầng Presentation/UI tự lắng nghe Event để render Dialog.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Logic an ninh và xử lý nghiệp vụ không bao giờ được gọi trực tiếp UI Dialog. Luôn giao tiếp thông qua cơ chế Event-Driven (`AgentEvent`, `SecurityEvent`).
---
### 🔴 `BUG-003`: Xung Đột Race Condition do Sử Dụng Biến Toàn Cục `active_project_id` trong `state.py`
* **Phân hệ**: `state.py`, `ui/workspace_tab.py`, Scheduled Task Runners
* **Triệu chứng (Symptom)**: Khi task scheduler chạy ngầm hoặc người dùng chuyển tab nhanh, file bị ghi nhầm vào thư mục dự án khác với dự án đang hiển thị trên màn hình.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Ứng dụng đọc và ghi trực tiếp vào biến toàn cục `AppContext.active_project_id` từ nhiều luồng khác nhau mà không có cơ chế snapshot ngữ cảnh.
* **Giải pháp khắc phục (Resolution)**:
- Xóa bỏ việc đọc biến toàn cục. Mỗi lần khởi chạy turn hoặc task, tạo một snapshot bất biến `WorkspaceSession(project_id, root_path, allowed_paths)`.
- Luồng ngầm chỉ thao tác trên `WorkspaceSession` được truyền vào từ lúc khởi tạo.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tuyệt đối không dùng biến toàn cục (Global State / Singletons có trạng thái thay đổi) để điều khiển luồng thực thi nền. Mọi ngữ cảnh phải được truyền tường minh qua DTO snapshot.
---
### 🔴 `BUG-004`: Vi Phạm Ranh Giới Kiến Trúc Khi Import `PySide6.QtCore.QTimer` trong Domain / Scheduling Engine
* **Phân hệ**: `core/task_scheduler.py#L20`
* **Triệu chứng (Symptom)**: Không thể viết Unit Test cho thuật toán tính toán lịch chạy (cron/interval) trên môi trường CI/CD (GitHub Actions / Linux Server headless) nếu thiếu driver màn hình X11/Wayland hoặc chưa cài `PySide6`.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Động cơ lập lịch bị gắn chặt cứng với `QTimer` của framework Qt thay vì tách riêng logic tính toán thời gian.
* **Giải pháp khắc phục (Resolution)**:
- Tách thuật toán tính lịch sang `domain/tasks/schedule_calculator.py` (Pure Python 100%).
- Tạo `platform/qt/qt_scheduler_clock.py` làm adapter bọc `QTimer` cho app chạy thật, và `tests/fakes/fake_clock.py` cho unit test.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tầng Domain và Application tuyệt đối không import thư viện GUI (`PySide6`, `PyQt`). Luôn bọc các thành phần phụ thuộc framework bên ngoài qua Adapter Interface.
---
### 🔴 `BUG-005`: Nhân Bản Mã Nguồn (Code Duplication) Logic Routing Mô Hình AI tại Nhiều Màn Hình
* **Phân hệ**: `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`
* **Triệu chứng (Symptom)**: Khi cập nhật thêm model provider mới (như FPT Gateway hay Claude 3.7), phải sửa code thủ công ở 3 file UI khác nhau; phát sinh sai lệch quy tắc fallback giữa các màn hình.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Thiếu một tầng Application Service tập trung, dẫn đến việc lập trình viên copy-paste hàm chọn model từ `ChatPanel` sang các tab khác.
* **Giải pháp khắc phục (Resolution)**:
- Xây dựng `application/model_routing/routing_application_service.py` duy nhất, cung cấp API `route_request(request) -> ModelRouteDecision`.
- Mọi màn hình UI chỉ gọi service này, không tự viết lại logic kiểm tra key hay fallback.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Nghiệp vụ dùng chung giữa các màn hình phải được đưa vào `application/` services. Không bao giờ viết logic nghiệp vụ trực tiếp trong các file Widget UI.
---
### 🟢 `BUG-006`: `UnicodeEncodeError` khi in Emojis trên Console Windows (CP932/CP1252)
* **Phân hệ / File**: `scripts/check_imports.py`
* **Triệu chứng (Symptom)**:
```text
Traceback (most recent call last):
File "scripts/check_imports.py", line 127, in main
print(f"\U0001f6e1\ufe0f Running Clean Architecture Import Guard...")
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f6e1' in position 0: illegal multibyte sequence
```
* **Nguyên nhân gốc rễ (Root Cause)**:
- Trên hệ điều hành Windows sử dụng locale tiếng Nhật (mã trang CP932) hoặc tiếng Anh (CP1252), `sys.stdout` mặc định không hỗ trợ các ký tự Unicode/Emoji ngoài bảng mã, dẫn đến crash khi in log dòng lệnh.
* **Giải pháp khắc phục (Resolution)**:
- Tự động bọc lại `sys.stdout` và `sys.stderr` bằng `io.TextIOWrapper` với `encoding="utf-8"` và `errors="replace"`.
- Thay thế các emoji phức tạp bằng các tag văn bản ASCII chuẩn hóa như `[Clean Arch Guard]`, `[PASS]`, `[FAIL]`.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Mọi script CLI (`scripts/*.py`) phải có cơ chế cấu hình `utf-8` stream wrapper và ưu tiên sử dụng text tags (`[INFO]`, `[WARN]`, `[ERROR]`) thay vì emoji Unicode trực tiếp để đảm bảo chạy mượt mà trên mọi môi trường Windows đa ngôn ngữ.
---
### 🟢 `BUG-007`: Xung Đột Tên Thư Mục Trùng Với Standard Library (`platform/` Shadowing `import platform`)
* **Phân hệ / File**: `platform/` ➔ Chuyển thành `infrastructure/platform/`
* **Triệu chứng (Symptom)**:
```text
INTERNALERROR> File "_pytest/terminal.py", line 853: verinfo = platform.python_version()
INTERNALERROR> AttributeError: module 'platform' has no attribute 'python_version'
```
* **Nguyên nhân gốc rễ (Root Cause)**:
- Khi tạo một package ở thư mục gốc có tên trùng với module thư viện chuẩn của Python (`platform`, `email`, `test`, `asyncio`, `logging`), Python trên `sys.path` sẽ ưu tiên import thư mục local thay vì thư viện chuẩn của Python runtime, dẫn đến crash toàn bộ pytest runner và các thư viện bên thứ ba.
* **Giải pháp khắc phục (Resolution)**:
- Xóa bỏ package `platform/` ở root.
- Đưa adapter Qt Scheduler Clock vào đúng vị trí hạ tầng: `infrastructure/platform/qt/`.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tuyệt đối không đặt tên package/thư mục ở root trùng với tên các module built-in của Python (`platform`, `logging`, `types`, `time`, `io`, `os`, `sys`). Mọi platform adapter phải nằm trong `infrastructure/platform/` hoặc `platform_adapters/`.
---
## 📝 MẪU GHI NHẬN BUG MỚI (BUG REPORT TEMPLATE)
Khi gặp bất kỳ bug mới nào trong quá trình làm việc, hãy sao chép khối mẫu sau và điền vào cuối tài liệu:
```markdown
### 🔴 `BUG-XXX`: [Tóm tắt ngắn gọn tên lỗi]
* **Phân hệ / File**: `[Đường dẫn file bị lỗi]`
* **Triệu chứng (Symptom)**: `[Mô tả hiện tượng lỗi, paste thông báo traceback hoặc kết quả test fail]`
* **Nguyên nhân gốc rễ (Root Cause)**: `[Giải thích tại sao lỗi lại xảy ra]`
* **Giải pháp khắc phục (Resolution)**: `[Mô tả cách sửa, file DTO/Service tạo mới hoặc cách refactor]`
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: `[Nguyên tắc kỹ thuật cụ thể để không bao giờ tái phạm lỗi này]`
```
+1
View File
@@ -0,0 +1 @@
"""Domain agents package: turn requests, agent events, and role definitions."""
+358
View File
@@ -0,0 +1,358 @@
"""Typed events a turn emits while it runs (R04-T02).
The runtime currently speaks in bare dicts: ``emit({"type": "tool_result", "id":
..., "ok": ...})``. Nothing declares which keys a given type carries, so the
only specification is the 130-line ``if/elif`` chain in
``ui/chat_panel.py::_on_event`` — and a typo in an emitter surfaces as a widget
that silently renders nothing.
This module makes the vocabulary explicit. Each event is a frozen dataclass with
real fields, and each one knows how to serialise itself back to the exact legacy
dict the widget already reads (:meth:`AgentEvent.to_legacy_dict`), with
:func:`from_legacy_dict` parsing the other way. That two-way bridge is what lets
R04 introduce typed events WITHOUT touching the presentation layer — decomposing
``_on_event`` into a renderer is R08-T01's job, and forcing both changes into one
PR is exactly the "rewrite everything at once" the refactor plan forbids.
Scope note: this covers the interactive/scheduled **Cowork turn** vocabulary
(the ``run_cowork`` path R04 unifies). Co4E's own node events (``node_status``,
``stage_text``, ``run_done``) belong to ``Co4EWorkflowService`` in R07-T06 and
are deliberately left as dicts here — :func:`from_legacy_dict` returns ``None``
for them so a bridge can pass them straight through.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
layer, standard library only. No PySide6, no ``core/*`` imports.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple
# Notice levels. "progress" is special-cased by the UI (it retargets the live
# thinking indicator instead of adding a bubble), so the vocabulary is pinned
# here rather than left to each emitter's string literal.
NOTICE_INFO = "info"
NOTICE_WARNING = "warning"
NOTICE_PROGRESS = "progress"
# --------------------------------------------------------------------------- #
# Value objects shared by several events.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolPreview:
"""The human-readable preview of a proposed tool call.
Mirrors ``core/tools.py::describe_action``'s return shape exactly (three
string keys, nothing else), so wrapping it in a type is lossless. ``kind``
drives which bubble the UI renders: "diff" -> coloured before/after,
"command" -> terminal block, "info" -> plain text.
"""
kind: str = "info"
title: str = ""
text: str = ""
def to_dict(self) -> Dict[str, str]:
return {"kind": self.kind, "title": self.title, "text": self.text}
@classmethod
def from_dict(cls, raw: Any) -> Optional["ToolPreview"]:
"""Parse a legacy preview dict; ``None`` when there was none.
A non-dict value degrades to ``None`` rather than raising: a malformed
preview must cost the user a nicer bubble, never the whole turn.
"""
if not isinstance(raw, dict) or not raw:
return None
return cls(kind=str(raw.get("kind", "info")), title=str(raw.get("title", "")),
text=str(raw.get("text", "")))
@dataclass(frozen=True)
class PlanStep:
"""One entry of the agent's ``update_plan`` checklist.
``status`` is kept a plain string on purpose: ``core/plan.py`` already owns
validation (clamping anything unknown to "pending" against
pending/running/done/error), and duplicating that vocabulary here would give
the app two sources of truth to drift apart.
"""
title: str
status: str = "pending"
def to_dict(self) -> Dict[str, str]:
return {"title": self.title, "status": self.status}
def _as_str_tuple(values: Iterable[Any]) -> Tuple[str, ...]:
"""Freeze an iterable of paths into a tuple of strings.
Emitters hand us live lists (``record["outputs"]``, ``_cleanup``'s result);
copying decouples the event from later mutation of that list.
"""
return tuple(str(v) for v in (values or ()))
# --------------------------------------------------------------------------- #
# Base class.
# --------------------------------------------------------------------------- #
class AgentEvent:
"""Base for every turn event.
Not a dataclass itself (it holds no data) — subclasses are the frozen
dataclasses. ``EVENT_TYPE`` is the legacy wire name, which stays the single
identifier shared between the typed world and the dict world.
"""
EVENT_TYPE: ClassVar[str] = ""
def _payload(self) -> Dict[str, Any]:
"""Type-specific keys of the legacy dict (without ``type``)."""
return {}
def to_legacy_dict(self) -> Dict[str, Any]:
"""The exact dict shape ``ui/chat_panel.py::_on_event`` dispatches on."""
return {"type": self.EVENT_TYPE, **self._payload()}
# --------------------------------------------------------------------------- #
# Streaming events.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TextChunkEvent(AgentEvent):
"""A fragment of the assistant's visible answer."""
EVENT_TYPE: ClassVar[str] = "text"
delta: str = ""
def _payload(self) -> Dict[str, Any]:
return {"delta": self.delta}
@dataclass(frozen=True)
class ReasoningChunkEvent(AgentEvent):
"""A fragment of a reasoning model's thinking, shown in a collapsed box."""
EVENT_TYPE: ClassVar[str] = "reasoning"
delta: str = ""
def _payload(self) -> Dict[str, Any]:
return {"delta": self.delta}
@dataclass(frozen=True)
class AssistantMessageCompletedEvent(AgentEvent):
"""One assistant message finished streaming.
Emitted once per provider call, so a tool-using turn produces SEVERAL of
these — it marks an autosave point, not the end of the turn. The end of the
turn is :class:`TurnCompletedEvent`.
"""
EVENT_TYPE: ClassVar[str] = "assistant_done"
content: str = ""
def _payload(self) -> Dict[str, Any]:
return {"content": self.content}
# --------------------------------------------------------------------------- #
# Tool-call lifecycle.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolCallStartedEvent(AgentEvent):
"""A tool call is about to run (after any security/permission gate).
Field names are the typed ones (``call_id``, ``arguments``); the legacy keys
``id``/``args`` are produced only at the serialisation boundary, so new code
never has to shadow the ``id`` builtin.
"""
EVENT_TYPE: ClassVar[str] = "tool_proposed"
call_id: str = ""
name: str = ""
arguments: Dict[str, Any] = field(default_factory=dict)
preview: Optional[ToolPreview] = None
def _payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"args": dict(self.arguments)}
# Omitted rather than sent as None: the widget does
# ``preview = ev.get("preview") or {}`` and an absent key is the shape it
# already handles for tools without a preview.
if self.preview is not None:
payload["preview"] = self.preview.to_dict()
return payload
@dataclass(frozen=True)
class ToolOutputChunkEvent(AgentEvent):
"""Live stdout/stderr from a running command, appended to its step bubble."""
EVENT_TYPE: ClassVar[str] = "tool_output"
call_id: str = ""
name: str = ""
delta: str = ""
def _payload(self) -> Dict[str, Any]:
return {"id": self.call_id, "name": self.name, "delta": self.delta}
@dataclass(frozen=True)
class ToolCallFinishedEvent(AgentEvent):
"""A tool call returned. ``path``/``produced`` name files it created."""
EVENT_TYPE: ClassVar[str] = "tool_result"
call_id: str = ""
name: str = ""
ok: bool = False
output: str = ""
path: str = "" # the single file this call wrote, if any
produced: Tuple[str, ...] = () # extra deliverables a command produced
def __post_init__(self) -> None:
# Callers pass a live list; freeze it so the event cannot change later.
object.__setattr__(self, "produced", _as_str_tuple(self.produced))
def _payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
# Both keys stay ABSENT when empty, matching what chat_agent emits today:
# downstream code tests them with ``ev.get(...)`` truthiness and iterates
# ``ev.get("produced", [])``, so adding empty values would be a change.
if self.path:
payload["path"] = self.path
if self.produced:
payload["produced"] = list(self.produced)
return payload
# --------------------------------------------------------------------------- #
# Side-channel events (plan, notices, output folder).
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PlanUpdatedEvent(AgentEvent):
"""The agent published a new version of its step checklist (full list)."""
EVENT_TYPE: ClassVar[str] = "plan_set"
steps: Tuple[PlanStep, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "steps", tuple(self.steps or ()))
def _payload(self) -> Dict[str, Any]:
return {"steps": [s.to_dict() for s in self.steps]}
@dataclass(frozen=True)
class NoticeEvent(AgentEvent):
"""An aside outside the model's own answer.
Three sources today: context auto-compaction (info), a blocked
security check (warning), and attachment reading progress (progress).
"""
EVENT_TYPE: ClassVar[str] = "notice"
text: str = ""
level: str = NOTICE_INFO
def _payload(self) -> Dict[str, Any]:
return {"level": self.level, "text": self.text}
@dataclass(frozen=True)
class OutputsAddedEvent(AgentEvent):
"""Deliverables appeared in the turn's output folder."""
EVENT_TYPE: ClassVar[str] = "outputs_added"
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
return {"paths": list(self.paths)}
@dataclass(frozen=True)
class OutputsRemovedEvent(AgentEvent):
"""Intermediate/generator files were cleaned up — drop them from Output."""
EVENT_TYPE: ClassVar[str] = "outputs_removed"
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
return {"paths": list(self.paths)}
@dataclass(frozen=True)
class HistoryReadyEvent(AgentEvent):
"""The turn's conversation now exists on disk and can be opened.
Emitted by the unattended (Schedule Task) path so the scheduler refreshes
History only once the session is really there.
"""
EVENT_TYPE: ClassVar[str] = "history_ready"
session_id: str = ""
def _payload(self) -> Dict[str, Any]:
return {"session_id": self.session_id}
# --------------------------------------------------------------------------- #
# Turn-level events introduced by R04 (no legacy consumer yet).
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TurnCompletedEvent(AgentEvent):
"""The whole turn ended — exactly once per turn.
Nothing consumes ``"turn_completed"`` yet: the widget's ``if/elif`` chain
simply has no branch for it, so emitting it is inert until R08 wires a
renderer. It exists now because the state it carries (was the turn
cancelled? did it hit the step ceiling?) is currently reconstructed by the
UI from side effects rather than being told to it.
"""
EVENT_TYPE: ClassVar[str] = "turn_completed"
final_text: str = ""
steps_used: int = 0
cancelled: bool = False
budget_exhausted: bool = False # stopped at effective_max_steps
def _payload(self) -> Dict[str, Any]:
return {"final_text": self.final_text, "steps_used": self.steps_used,
"cancelled": self.cancelled, "budget_exhausted": self.budget_exhausted}
@dataclass(frozen=True)
class ErrorEvent(AgentEvent):
"""The turn hit an error.
``recoverable`` separates "this turn is over" from "something failed but the
loop carried on" — a distinction the current code loses, because both end up
as a bare ``except Exception`` plus a text bubble.
"""
EVENT_TYPE: ClassVar[str] = "error"
message: str = ""
recoverable: bool = False
def _payload(self) -> Dict[str, Any]:
return {"message": self.message, "recoverable": self.recoverable}
__all__ = [
"NOTICE_INFO", "NOTICE_WARNING", "NOTICE_PROGRESS",
"AgentEvent", "ToolPreview", "PlanStep",
"TextChunkEvent", "ReasoningChunkEvent", "AssistantMessageCompletedEvent",
"ToolCallStartedEvent", "ToolOutputChunkEvent", "ToolCallFinishedEvent",
"PlanUpdatedEvent", "NoticeEvent", "OutputsAddedEvent", "OutputsRemovedEvent",
"HistoryReadyEvent", "TurnCompletedEvent", "ErrorEvent",
]
+123
View File
@@ -0,0 +1,123 @@
"""Legacy dict -> typed :mod:`agent_event` translation (R04-T02).
Kept in its own module for two reasons. It is a **temporary compatibility
shim**: once R08-T01 turns ``ui/chat_panel.py::_on_event`` into an event
renderer that consumes typed events directly, nothing needs to parse dicts any
more and this whole file gets deleted — a deletion that stays trivial only while
it is isolated. And it keeps ``agent_event.py`` inside the 400-LOC limit the
architecture rules impose, without diluting either file's single job: one
declares the vocabulary, the other bridges it to the old wire format.
Serialisation the other way lives on the events themselves
(``AgentEvent.to_legacy_dict``), because an event has to be emittable without
anyone importing a codec.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from .agent_event import (
AgentEvent,
AssistantMessageCompletedEvent,
ErrorEvent,
HistoryReadyEvent,
NOTICE_INFO,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
ToolPreview,
TurnCompletedEvent,
)
def _plan_steps_from_legacy(raw: Any) -> Tuple[PlanStep, ...]:
"""Parse the legacy ``steps`` list, dropping anything unusable.
A step with no title cannot be rendered or ticked off, so it is discarded
instead of becoming a blank row in the Plan panel.
"""
if not isinstance(raw, list):
return ()
steps: List[PlanStep] = []
for item in raw:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
if not title:
continue
steps.append(PlanStep(title=title, status=str(item.get("status", "pending"))))
return tuple(steps)
def _parse_tool_started(raw: Dict[str, Any]) -> ToolCallStartedEvent:
"""Rebuild a ``tool_proposed`` event, mapping ``id``/``args`` to typed names."""
args = raw.get("args")
return ToolCallStartedEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
arguments=dict(args) if isinstance(args, dict) else {},
preview=ToolPreview.from_dict(raw.get("preview")),
)
def _parse_tool_finished(raw: Dict[str, Any]) -> ToolCallFinishedEvent:
"""Rebuild a ``tool_result`` event; the optional file keys may be absent."""
return ToolCallFinishedEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
ok=bool(raw.get("ok", False)), output=str(raw.get("output", "")),
path=str(raw.get("path", "") or ""), produced=raw.get("produced") or (),
)
# One parser per wire name. A table (rather than an if/elif chain) keeps adding
# an event a single-line change and makes the supported set introspectable.
_PARSERS = {
TextChunkEvent.EVENT_TYPE: lambda raw: TextChunkEvent(delta=str(raw.get("delta", ""))),
ReasoningChunkEvent.EVENT_TYPE: lambda raw: ReasoningChunkEvent(
delta=str(raw.get("delta", ""))),
AssistantMessageCompletedEvent.EVENT_TYPE: lambda raw: AssistantMessageCompletedEvent(
content=str(raw.get("content", ""))),
ToolCallStartedEvent.EVENT_TYPE: _parse_tool_started,
ToolOutputChunkEvent.EVENT_TYPE: lambda raw: ToolOutputChunkEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
delta=str(raw.get("delta", ""))),
ToolCallFinishedEvent.EVENT_TYPE: _parse_tool_finished,
PlanUpdatedEvent.EVENT_TYPE: lambda raw: PlanUpdatedEvent(
steps=_plan_steps_from_legacy(raw.get("steps"))),
NoticeEvent.EVENT_TYPE: lambda raw: NoticeEvent(
text=str(raw.get("text", "")), level=str(raw.get("level", NOTICE_INFO))),
OutputsAddedEvent.EVENT_TYPE: lambda raw: OutputsAddedEvent(paths=raw.get("paths") or ()),
OutputsRemovedEvent.EVENT_TYPE: lambda raw: OutputsRemovedEvent(paths=raw.get("paths") or ()),
HistoryReadyEvent.EVENT_TYPE: lambda raw: HistoryReadyEvent(
session_id=str(raw.get("session_id", ""))),
TurnCompletedEvent.EVENT_TYPE: lambda raw: TurnCompletedEvent(
final_text=str(raw.get("final_text", "")), steps_used=int(raw.get("steps_used", 0) or 0),
cancelled=bool(raw.get("cancelled", False)),
budget_exhausted=bool(raw.get("budget_exhausted", False))),
ErrorEvent.EVENT_TYPE: lambda raw: ErrorEvent(
message=str(raw.get("message", "")), recoverable=bool(raw.get("recoverable", False))),
}
def from_legacy_dict(payload: Any) -> Optional[AgentEvent]:
"""Parse an emitted dict into a typed event, or ``None`` if it isn't ours.
``None`` (rather than an exception) is the contract that makes incremental
adoption possible: a bridge sitting between the runtime and the widget can
type the events it recognises and forward everything else — Co4E's node
events, or anything a future emitter adds — completely untouched.
"""
if not isinstance(payload, dict):
return None
parser = _PARSERS.get(str(payload.get("type", "")))
return parser(payload) if parser is not None else None
__all__ = ["from_legacy_dict"]
+86
View File
@@ -0,0 +1,86 @@
"""What one finished turn produced (R04-T03).
The outcome of a turn is currently spread over three shapes: ``run_cowork``
returns the mutated message list, ``task_executors._run_agent`` returns a
``(answer_text, timed_out, incomplete_reason)`` tuple, and the UI reconstructs
the rest (did it get cancelled? did it hit the ceiling?) from side effects. Each
caller therefore knows a slightly different amount about the same turn.
:class:`AgentResult` is the single answer. Frozen, like the request that started
the turn, so a result cannot be edited into disagreeing with what actually
happened.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
layer — standard library plus sibling domain types only.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Tuple
from .agent_event import PlanStep, TurnCompletedEvent
@dataclass(frozen=True)
class AgentResult:
"""The outcome of one conversation turn."""
# The conversation AFTER the turn (system prompt, history, the new user
# message, every assistant reply and tool result).
messages: Tuple[Dict[str, Any], ...] = ()
steps_used: int = 0 # provider calls this turn consumed
cancelled: bool = False # the user pressed Stop
budget_exhausted: bool = False # stopped at effective_max_steps
# The agent's final checklist, so a caller can ask "did it really finish?"
# (``core/plan.py::plan_incomplete_reason``) without replaying the events.
plan_steps: Tuple[PlanStep, ...] = ()
# Non-empty when the turn ended on a failure. A string rather than the
# exception: the domain layer must not depend on where the error came from,
# and the message is what every consumer (bubble, error.txt, audit) shows.
error: str = ""
def __post_init__(self) -> None:
"""Freeze the collections the runtime hands over.
Both arrive as live lists that the caller keeps appending to after the
turn (the UI merges messages back into its own history), so copying here
is what keeps a result a record rather than a moving target.
"""
object.__setattr__(self, "messages", tuple(self.messages or ()))
object.__setattr__(self, "plan_steps", tuple(self.plan_steps or ()))
@property
def final_text(self) -> str:
"""The answer to show the user.
Scans backwards for the last assistant message with real content, which
is not the same as ``messages[-1]``: a turn that was cancelled or that
ran out of steps mid-loop ends on a tool message, and a reasoning-only
reply leaves a blank assistant message behind. Same rule as
``core/task_executors.py::_last_assistant_text``, which this replaces.
"""
for message in reversed(self.messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@property
def ok(self) -> bool:
"""Whether the turn ran to a normal end.
Hitting the step ceiling still counts as ok: the agent did work and
produced an answer, it just was not allowed to keep going — which the
transcript says in its own note rather than by failing the turn.
"""
return not self.error and not self.cancelled
def to_turn_completed_event(self) -> TurnCompletedEvent:
"""The end-of-turn event carrying this outcome to subscribers."""
return TurnCompletedEvent(
final_text=self.final_text, steps_used=self.steps_used,
cancelled=self.cancelled, budget_exhausted=self.budget_exhausted,
)
__all__ = ["AgentResult"]
@@ -0,0 +1,222 @@
"""The immutable snapshot of ONE chat turn (R04-T01).
Today a turn's inputs live in a closure plus a 15-key ``ctx`` dict built inside
``ui/chat_panel.py::_start_turn``, and the worker thread reads the widget back
(``self._model``, ``self.title``, ``self.project_id``) while it runs. That is
the mechanism behind the whole class of "I changed the model mid-answer and the
running turn behaved oddly" reports: the turn has no snapshot of its own, so
every later click on the UI is visible to work already in flight.
:class:`ConversationExecutionRequest` is that missing snapshot. Everything the
runtime needs for one turn is captured once, on the UI thread, at submit time,
and then handed to code that runs on a worker thread. Frozen, so no caller —
widget or service — can retroactively change a decision the turn already acted
on.
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this is
the domain layer, so standard library only. No PySide6, no ``requests``, no
filesystem access, and deliberately no import of ``core/*`` — a request only
*describes* a turn; running it is the application layer's job
(``application/conversations/``).
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
# Separator between an instruction prefix (a ``/skill`` block, an ``/agent``
# persona) and the user's own request. Kept as a constant because the prefix is
# assembled in the presentation layer while the body is only known later on the
# worker thread — both halves must agree on the exact separator or the model
# sees a different prompt shape than it did before this refactor.
PREFIX_SEPARATOR = "\n\n---\n\n"
@dataclass(frozen=True)
class ConversationExecutionRequest:
"""Everything needed to execute one conversation turn.
Frozen for the reason above; use :meth:`with_model` / :meth:`with_output_dir`
to derive an adjusted copy rather than mutating one another thread may be
reading.
Note on depth: ``messages`` is a *shallow* snapshot (a tuple holding the
same message dicts the caller passed). That matches the existing
``snapshot = list(self.messages)`` semantics in ``_start_turn`` exactly —
the turn is protected from the history list being appended to or replaced,
which is what actually happens between turns. Making it deep would silently
change how ``_finalize_turn`` merges the turn's messages back, so the
stronger guarantee is left to R04-T03 where that merge moves.
"""
# -- identity ------------------------------------------------------- #
turn_id: str # unique within a session ("t1", "t2", ...)
session_id: str # the conversation this turn belongs to
surface: str = "cowork" # routing/mode key: "cowork" | "co4e" | "ai_edit"
project_id: str = "" # workspace the turn is confined to
title: str = "" # conversation title; also names saved files
# -- what the user asked -------------------------------------------- #
# The typed request, already stripped of any ``/skill`` or ``/agent``
# directive (those become ``instruction_prefix``).
prompt: str = ""
instruction_prefix: str = "" # skill rules + agent persona for this turn
# Prepended when the model/agent was switched mid-conversation, asking the
# model to re-check the previous step before continuing. Invisible in the
# chat bubble — it only travels in the payload sent to the provider.
review_note: str = ""
# Attachment PATHS, not their text: extracting a .docx can pip-install a
# parser or shell out to LibreOffice, which must not run on the UI thread.
# The runtime reads them later and passes the result to :meth:`user_content`.
attachments: Tuple[str, ...] = ()
# Conversation history as of submit time; the new user message is NOT part
# of it (the runtime appends it once the body is composed).
messages: Tuple[Dict[str, Any], ...] = ()
# -- which model answers -------------------------------------------- #
# Already resolved upstream: an Admin-agent pin, the tab's own picker, or a
# routing override published by ``RoutingApplicationService`` (R03). The
# runtime does not re-decide, so a switch cannot land mid-turn.
provider_id: str = ""
model: str = "" # "" = the provider's configured default
# -- standing instructions ------------------------------------------ #
project_context: str = "" # Claude-Projects-style shared instructions
session_notes: str = "" # e.g. files this conversation already produced
# -- tool scope and turn limits -------------------------------------- #
# None = every enabled built-in tool. An explicit (possibly empty) tuple
# restricts the ADVERTISED tools, which is how a "read-only" step is made
# literally unable to write.
allowed_tools: Optional[Tuple[str, ...]] = None
max_steps: int = 30 # interactive cap
completion_max_steps: int = 200 # runaway ceiling for run-to-completion work
run_to_completion: bool = False # Co4E flow steps need the higher ceiling
enforce_rules: bool = True # False for sandboxed Co4E runs
gate_mode: str = "auto" # "confirm" -> ask before run_command/install
agent_role: str = "cowork" # audit-log attribution ("cowork" | "task" | ...)
# -- where its files go ---------------------------------------------- #
output_dir: Optional[Path] = None # this turn's isolated sandbox
home_output_root: Optional[Path] = None # conversation Output root to promote into
# -- unattended execution (Schedule Task) ----------------------------- #
unattended: bool = False # no human watching; plan tracking is enforced
timeout_sec: Optional[int] = None # None = no wall-clock limit
# Escape hatch for surface-specific data a future task needs to thread
# through without another schema change (same role as
# ``ProviderDescriptor.extras``).
extras: Dict[str, Any] = field(default_factory=dict)
# -- validation / normalisation --------------------------------------- #
def __post_init__(self) -> None:
"""Reject unusable requests and freeze the mutable inputs.
Validation lives here (not at the call site) so a request that exists is
always safe to key by: the audit log, the History autosave and the
per-turn output folder are all named from ``session_id``/``turn_id``.
Normalisation matters just as much: the caller hands us the composer's
own attachment LIST and the live history LIST, and both get cleared or
appended to for the next turn. Copying them into tuples here is what
actually makes the snapshot a snapshot. ``object.__setattr__`` is the
standard way to do this in a frozen dataclass.
"""
if not (self.turn_id or "").strip():
raise ValueError("ConversationExecutionRequest.turn_id must not be empty")
if not (self.session_id or "").strip():
raise ValueError("ConversationExecutionRequest.session_id must not be empty")
object.__setattr__(self, "attachments", tuple(self.attachments or ()))
object.__setattr__(self, "messages", tuple(self.messages or ()))
# None must survive: it means "no restriction", while an empty tuple
# means "deny every built-in tool" — two very different turns.
if self.allowed_tools is not None:
object.__setattr__(self, "allowed_tools", tuple(self.allowed_tools))
# Accept str paths so a call site holding a config value does not have to
# wrap it; everything downstream can then assume Path.
for name in ("output_dir", "home_output_root"):
value = getattr(self, name)
if value is not None and not isinstance(value, Path):
object.__setattr__(self, name, Path(value))
# -- derived turn policy ---------------------------------------------- #
@property
def has_prompt(self) -> bool:
"""Whether the user actually typed something (an attachment-only turn
legitimately has none). Mirrors ``RoutingRequest.has_prompt`` so both
DTOs answer the "is there anything to work with?" question the same way.
"""
return bool((self.prompt or "").strip())
@property
def effective_max_steps(self) -> int:
"""The tool-use budget for this turn.
Run-to-completion work (a Co4E flow step whose single instruction may
need many tool calls) gets the higher ceiling; interactive chat keeps the
tight cap. Either way the turn still ends the moment the model stops
calling tools — this is only the runaway limit.
"""
return self.completion_max_steps if self.run_to_completion else self.max_steps
@property
def requires_permission_gate(self) -> bool:
"""Whether ``run_command``/``install_package`` must be approved first.
Resolved by the caller (per-workspace Auto-run override, else the global
"confirm before running commands" setting) and frozen here, so toggling
the setting mid-turn cannot change the rules the turn started under.
"""
return self.gate_mode == "confirm"
# -- prompt composition ------------------------------------------------ #
def user_content(self, body: str = "") -> str:
"""The exact ``content`` to send as this turn's user message.
``body`` is the request text AFTER attachment extraction, which happens
on the worker thread — hence a method taking it as an argument rather
than a stored field. The assembly order reproduces the closure in
``_start_turn`` byte for byte, because changing what a model receives is
a behaviour change, not a refactor:
1. session notes are appended after the body;
2. the instruction prefix goes in front, behind a fixed separator;
3. the model-switch review note goes ahead of everything.
"""
content = body or ""
notes = self.session_notes or ""
if notes:
# Guard the empty-body case (attachment-only turn) so the payload
# never opens with a stray blank line.
content = f"{content}\n\n{notes}" if content else notes
prefix = self.instruction_prefix or ""
if prefix:
content = f"{prefix}{PREFIX_SEPARATOR}{content}"
review = self.review_note or ""
if review:
content = f"{review}\n\n{content}"
return content
# -- derivation --------------------------------------------------------- #
def with_model(self, provider_id: str = "", model: str = "") -> "ConversationExecutionRequest":
"""A copy pinned to another provider/model.
Needed when a decision lands between building the request and running it
(a routing override, an Admin-agent pin). Deriving a new request keeps
the "one turn, one immutable snapshot" rule intact instead of patching a
request another thread may already hold.
"""
return replace(self, provider_id=provider_id or self.provider_id,
model=model or self.model)
def with_output_dir(self, output_dir) -> "ConversationExecutionRequest":
"""A copy writing into a different sandbox — used when the caller only
learns the per-turn folder after the request is assembled."""
return replace(self, output_dir=output_dir)
__all__ = ["PREFIX_SEPARATOR", "ConversationExecutionRequest"]
+1
View File
@@ -0,0 +1 @@
"""Domain models package: provider descriptors, model pricing, and routing metadata."""
+196
View File
@@ -0,0 +1,196 @@
"""Provider catalog metadata — the domain-layer description of ONE LLM provider.
Before R03 the answer to "which providers exist, what do they cost, what can
they do?" was spread over three places: the class table in
``providers/factory.py``, the hand-maintained pricing table in
``core/routing/metadata.py`` and a handful of ``if provider == "anthropic"``
branches in the UI. :class:`ProviderDescriptor` is the single declarative
record those call sites now read from.
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this
module is 100% pure Python — no PySide6, no ``requests``, no filesystem, and no
import of the concrete ``providers/*`` adapters. It only *describes* a provider;
constructing one is the infrastructure layer's job
(``infrastructure/providers/provider_registry.py``).
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import Any, Dict, Optional, Tuple
class AuthKind(str, Enum):
"""How a provider authenticates, so Settings/onboarding can ask for the
right thing instead of hard-coding per-provider form fields.
Inherits ``str`` so a descriptor round-trips through JSON unchanged (the
value is written as a plain string), matching how the routing models in
``core/routing/models.py`` already serialize their enums.
"""
NONE = "none" # local runtimes (Ollama) — nothing to supply
API_KEY = "api_key" # bearer/x-api-key style secret
OAUTH_TOKEN = "oauth" # token minted by an external login flow (Copilot)
class WireProtocol(str, Enum):
"""The on-the-wire dialect a provider speaks.
Several *distinct* providers share one protocol (Ollama, Codex, GitHub
Copilot and generic gateways are all OpenAI Chat Completions), which is
exactly why protocol is a separate field from the provider id: the registry
picks the adapter class from the protocol, while everything user-facing
keys off the id.
"""
OPENAI_COMPAT = "openai_compat"
ANTHROPIC = "anthropic"
@dataclass(frozen=True)
class ProviderDescriptor:
"""Immutable metadata for one provider the app can route work to.
Frozen because descriptors are shared process-wide by the registry, the
routing service and (eventually) the Settings screen; making them read-only
removes any chance one caller mutates the catalog another caller is
iterating. Use :meth:`with_models` to derive an updated copy instead.
Unknown pricing/context values stay ``None`` rather than being guessed —
the routing scorer needs to distinguish "free" from "we don't know", the
same contract ``core/routing/models.py::ModelMetadata`` already follows.
"""
provider_id: str # config key, e.g. "anthropic"
display_name: str # human label for Settings/UI
wire_protocol: WireProtocol # which adapter class implements it
auth_kind: AuthKind = AuthKind.API_KEY
default_model: str = "" # used when no model is selected
models: Tuple[str, ...] = () # known model ids (may be empty)
max_context: Optional[int] = None # tokens; None = unknown
cost_per_1k_input: Optional[float] = None # USD per 1K input tokens
cost_per_1k_output: Optional[float] = None # USD per 1K output tokens
supports_vision: bool = False
supports_tools: bool = True
supports_streaming: bool = True
requires_base_url: bool = False # gateway endpoints must be configured
# Extra ids that should resolve to this descriptor (renames/aliases kept for
# backwards compatibility with configs written by older app versions).
aliases: Tuple[str, ...] = ()
# Free-form extension point so a team can attach provider-specific hints
# without another schema migration.
extras: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Reject descriptors that could never be looked up.
Raising here (rather than at registration time) means a malformed
descriptor cannot exist at all, so every consumer downstream may assume
``provider_id`` is a usable dict key.
"""
if not self.provider_id:
raise ValueError("ProviderDescriptor.provider_id must not be empty")
if not isinstance(self.wire_protocol, WireProtocol):
raise TypeError("ProviderDescriptor.wire_protocol must be a WireProtocol")
# -- identity ------------------------------------------------------- #
@property
def identifiers(self) -> Tuple[str, ...]:
"""Every id this descriptor answers to (canonical id first)."""
return (self.provider_id, *self.aliases)
def matches(self, provider_id: str) -> bool:
"""Case-insensitive id/alias match — config files and CLI flags are
typed by humans, so lookup must not be case sensitive."""
needle = (provider_id or "").strip().lower()
return any(needle == known.lower() for known in self.identifiers)
# -- capability queries --------------------------------------------- #
def knows_model(self, model_id: str) -> bool:
"""Whether ``model_id`` is in this provider's declared catalog.
A miss is NOT proof the model is unusable: gateways expose models we
cannot enumerate offline, so callers treat this as a hint (used to
resolve a bare model id back to its provider) and never as a gate that
blocks a request.
"""
needle = (model_id or "").strip().lower()
return any(needle == known.strip().lower() for known in self.models)
def has_capability(self, capability: str) -> bool:
"""Capability check by name, mirroring the vocabulary the routing
selector already filters on (``"vision"``, ``"tools"``, ``"streaming"``)
so a descriptor can be fed straight into ``rank_models``."""
return capability in self.capabilities
@property
def capabilities(self) -> frozenset:
"""Capability set in the same vocabulary as
``core/routing/models.py::ModelMetadata.capabilities``."""
caps = set()
if self.supports_vision:
caps.add("vision")
if self.supports_tools:
caps.add("tools")
if self.supports_streaming:
caps.add("streaming")
return frozenset(caps)
@property
def avg_cost_per_1k(self) -> Optional[float]:
"""Blended input/output price, or ``None`` when either side is unknown.
Uses the same 1:3 input:output weighting as
``ModelMetadata.avg_cost_per_1k`` so a descriptor and an assessment
never disagree about what a model costs.
"""
ci, co = self.cost_per_1k_input, self.cost_per_1k_output
if ci is None or co is None:
return None
return (ci + 3.0 * co) / 4.0
def resolve_model(self, requested: str = "") -> str:
"""The model id to actually call: the caller's choice when they made
one, otherwise this provider's default. Centralised here because every
surface (chat, Co4E, AI-Edit) previously re-implemented the same
``model or config_default`` fallback inline."""
return (requested or "").strip() or self.default_model
# -- derivation / serialization ------------------------------------- #
def with_models(self, models, *, default_model: str = "") -> "ProviderDescriptor":
"""A copy carrying a freshly discovered model list.
Providers can enumerate their models at runtime (``list_models()``);
because the descriptor is frozen, discovery produces a NEW descriptor
that the registry swaps in atomically instead of mutating one that other
threads may be reading.
"""
ordered = tuple(dict.fromkeys(m for m in models if m)) # de-dup, keep order
chosen = default_model or self.default_model
# Keep the default pointing at something real: fall back to the first
# discovered model when the configured default vanished from the catalog.
if ordered and chosen not in ordered:
chosen = ordered[0]
return replace(self, models=ordered, default_model=chosen)
def to_dict(self) -> Dict[str, Any]:
"""JSON-friendly view for config persistence and the Settings UI."""
return {
"provider_id": self.provider_id,
"display_name": self.display_name,
"wire_protocol": self.wire_protocol.value,
"auth_kind": self.auth_kind.value,
"default_model": self.default_model,
"models": list(self.models),
"max_context": self.max_context,
"cost_per_1k_input": self.cost_per_1k_input,
"cost_per_1k_output": self.cost_per_1k_output,
"capabilities": sorted(self.capabilities),
"requires_base_url": self.requires_base_url,
"aliases": list(self.aliases),
}
__all__ = ["AuthKind", "WireProtocol", "ProviderDescriptor"]
+1
View File
@@ -0,0 +1 @@
"""Domain security package: security policies, alert events, and permission types."""
+1
View File
@@ -0,0 +1 @@
"""Domain tasks package: task definitions and deterministic schedule calculators."""
+1
View File
@@ -0,0 +1 @@
"""Domain tools package: tool descriptors, capability scopes, and registry interfaces."""
+1
View File
@@ -0,0 +1 @@
"""Domain workspaces package: immutable WorkspaceSession definitions."""
+6 -3
View File
@@ -583,10 +583,13 @@ STRINGS: Dict[str, Dict[str, str]] = {
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
# Fallback (R03-T03): resilience mode -- never switches for a better
# score, only to rescue a selected model that cannot serve the turn.
"routing.mode_fallback": {"en": "Fallback", "ja": "フォールバック", "vi": "Dự phòng"},
"routing.toggle_tooltip": {
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.",
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。",
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.",
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.\nFallback: keep the selected model, switch only if it is unavailable.",
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。\nフォールバック: 選択モデルを維持し、利用できない場合のみ切替。",
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.\nDự phòng: giữ model đã chọn, chỉ chuyển khi model đó không dùng được.",
},
"routing.confirm_title": {
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
+1
View File
@@ -0,0 +1 @@
"""Infrastructure config package: ConfigRepository and typed settings facades."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure filesystem package: Tool handlers (file, command, fetch tools) and execution workspace."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure MCP package: McpToolSourceManager and child process lifecycle."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure persistence package."""
@@ -0,0 +1 @@
"""Infrastructure JSON persistence package: AtomicJsonFile and repositories."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure platform adapters package."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure Qt platform adapters: QtSchedulerClock."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure providers package: LLM provider adapters and ProviderRegistry."""
@@ -0,0 +1,287 @@
"""Central registry of every LLM provider the app can talk to.
Replaces the bare ``{name: class}`` dict in ``providers/factory.py`` as the
single catalogue of providers. Two responsibilities, kept deliberately narrow:
1. **Lookup** — resolve a provider id (or one of its aliases, or a bare model
id) to its :class:`~domain.models.provider_descriptor.ProviderDescriptor`.
2. **Construction** — instantiate the concrete adapter class that speaks the
descriptor's wire protocol.
This is infrastructure, not domain: it is allowed to import the concrete
``providers/*`` adapters (which pull in ``requests``). The adapters are imported
lazily inside :meth:`build` so that merely *reading the catalogue* — which the
pure routing service does on every turn — never drags the HTTP stack into the
process.
"""
from __future__ import annotations
import threading
from typing import Any, Dict, Iterable, List, Optional
from ...domain.models.provider_descriptor import (
AuthKind,
ProviderDescriptor,
WireProtocol,
)
# --------------------------------------------------------------------------- #
# Built-in catalogue.
#
# Mirrors DEFAULT_CONFIG["providers"] in config.py (ids + default models) and
# providers/factory.py (id -> wire protocol). Prices are intentionally absent:
# core/routing/metadata.py owns cost, and a guessed price is worse than a
# known-unknown (see that module's docstring).
# --------------------------------------------------------------------------- #
BUILTIN_DESCRIPTORS: tuple = (
ProviderDescriptor(
provider_id="openai_compat",
display_name="OpenAI-compatible gateway",
wire_protocol=WireProtocol.OPENAI_COMPAT,
auth_kind=AuthKind.API_KEY,
default_model="gpt-4o-mini",
supports_vision=True,
# A generic gateway has no fixed host, so the endpoint MUST be
# configured before the provider can be used at all.
requires_base_url=True,
),
ProviderDescriptor(
provider_id="anthropic",
display_name="Anthropic Claude",
wire_protocol=WireProtocol.ANTHROPIC,
auth_kind=AuthKind.API_KEY,
default_model="claude-sonnet-4-6",
# Kept in sync with AnthropicProvider._FALLBACK_MODELS — the list the
# provider itself falls back to when /v1/models cannot be reached.
models=("claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
max_context=200000,
supports_vision=True,
),
ProviderDescriptor(
provider_id="ollama",
display_name="Ollama (local)",
wire_protocol=WireProtocol.OPENAI_COMPAT,
# A local runtime needs no credential; Settings must not demand one.
auth_kind=AuthKind.NONE,
default_model="llama3.1",
supports_vision=False,
requires_base_url=True,
),
ProviderDescriptor(
provider_id="github_copilot",
display_name="GitHub Copilot",
wire_protocol=WireProtocol.OPENAI_COMPAT,
# The credential is a Copilot token minted by an external login flow,
# not a self-service API key.
auth_kind=AuthKind.OAUTH_TOKEN,
default_model="gpt-4o",
models=("gpt-4o", "gpt-4o-mini"),
max_context=128000,
supports_vision=True,
),
ProviderDescriptor(
provider_id="codex",
display_name="OpenAI",
wire_protocol=WireProtocol.OPENAI_COMPAT,
auth_kind=AuthKind.API_KEY,
default_model="gpt-4o-mini",
models=("gpt-4o", "gpt-4o-mini", "o1", "o3"),
max_context=128000,
supports_vision=True,
# Historic config key: early builds stored this provider as "openai".
aliases=("openai",),
),
)
class ProviderNotFoundError(LookupError):
"""Raised when no descriptor answers to the requested provider id.
A dedicated type (rather than bare ``KeyError``) lets callers distinguish
"this provider is not in the catalogue" from an unrelated dict miss, and
keeps the message actionable by listing what IS registered.
"""
class ProviderRegistry:
"""Thread-safe catalogue of :class:`ProviderDescriptor` records.
Thread-safety matters because model discovery runs on background worker
threads (the routing prober, Settings' "Load models") and republishes an
updated descriptor via :meth:`replace`, while chat turns on other threads
are reading the catalogue concurrently.
"""
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
# Keyed by canonical id; alias resolution walks the values so an alias
# can never shadow a real provider id.
self._by_id: Dict[str, ProviderDescriptor] = {}
self._lock = threading.RLock()
for descriptor in descriptors or ():
self.register(descriptor)
# -- registration --------------------------------------------------- #
def register(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
"""Add a descriptor. Refuses to silently overwrite an existing id so a
typo in a plugin cannot hijack a built-in provider; use :meth:`replace`
when an update is the actual intent."""
with self._lock:
existing = self._by_id.get(descriptor.provider_id)
if existing is not None and existing != descriptor:
raise ValueError(
f"Provider '{descriptor.provider_id}' is already registered; "
"call replace() to update it."
)
self._by_id[descriptor.provider_id] = descriptor
return descriptor
def replace(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
"""Register or update a descriptor unconditionally — the path model
discovery uses to publish a freshly enumerated model list."""
with self._lock:
self._by_id[descriptor.provider_id] = descriptor
return descriptor
# -- lookup ---------------------------------------------------------- #
def get(self, provider_id: str) -> ProviderDescriptor:
"""Descriptor for ``provider_id`` (canonical id or alias).
Raises :class:`ProviderNotFoundError` rather than returning ``None`` so
a misconfigured provider fails loudly at the call site instead of
surfacing later as an ``AttributeError`` on ``None``.
"""
found = self.find(provider_id)
if found is None:
known = ", ".join(sorted(self._by_id)) or "<empty registry>"
raise ProviderNotFoundError(
f"Unsupported provider: {provider_id!r}. Registered: {known}"
)
return found
def find(self, provider_id: str) -> Optional[ProviderDescriptor]:
"""Non-raising :meth:`get` — ``None`` when nothing matches."""
needle = (provider_id or "").strip()
if not needle:
return None
with self._lock:
direct = self._by_id.get(needle)
if direct is not None:
return direct
# Fall back to a case-insensitive id/alias scan; order is stable
# because dicts preserve insertion order, so the earliest-registered
# provider wins a tie.
for descriptor in self._by_id.values():
if descriptor.matches(needle):
return descriptor
return None
def find_by_model(self, model_id: str) -> Optional[ProviderDescriptor]:
"""Resolve a bare model id back to the provider that serves it.
This is the "dynamic lookup by model ID" R03-T02 calls for: routing
decisions and saved conversations sometimes carry only a model name, and
the caller still needs to know which provider to build. Returns ``None``
when the model belongs to a gateway whose catalogue we cannot enumerate
offline — callers then fall back to the configured active provider.
"""
needle = (model_id or "").strip()
if not needle:
return None
with self._lock:
for descriptor in self._by_id.values():
if descriptor.knows_model(needle):
return descriptor
return None
def all(self) -> List[ProviderDescriptor]:
"""Every registered descriptor, in registration order (snapshot copy —
safe to iterate while another thread registers)."""
with self._lock:
return list(self._by_id.values())
def ids(self) -> List[str]:
"""Canonical provider ids, sorted for stable UI/reporting output."""
with self._lock:
return sorted(self._by_id)
def __contains__(self, provider_id: object) -> bool:
return isinstance(provider_id, str) and self.find(provider_id) is not None
def __len__(self) -> int:
with self._lock:
return len(self._by_id)
# -- construction ---------------------------------------------------- #
def adapter_class(self, provider_id: str):
"""Concrete ``Provider`` subclass implementing this provider's protocol.
The adapters are imported here (not at module import) so the pure
routing/domain code can consult the catalogue without loading
``requests`` and the whole HTTP stack.
"""
descriptor = self.get(provider_id)
from ...providers.anthropic import AnthropicProvider
from ...providers.openai_compat import OpenAICompatProvider
protocol_to_class = {
WireProtocol.OPENAI_COMPAT: OpenAICompatProvider,
WireProtocol.ANTHROPIC: AnthropicProvider,
}
adapter = protocol_to_class.get(descriptor.wire_protocol)
if adapter is None: # pragma: no cover — unreachable while the map is total
raise ProviderNotFoundError(
f"No adapter implements wire protocol {descriptor.wire_protocol!r}"
)
return adapter
def build(self, provider_id: str, conf: Dict[str, Any]):
"""Instantiate a ready-to-use provider adapter.
The descriptor's ``default_model`` fills in a missing/blank ``model`` so
a half-written config still produces a working provider instead of an
empty model id that only fails once the request hits the gateway.
"""
descriptor = self.get(provider_id)
adapter = self.adapter_class(descriptor.provider_id)
merged = dict(conf or {})
merged["model"] = descriptor.resolve_model(merged.get("model", ""))
return adapter(merged)
# --------------------------------------------------------------------------- #
# Process-wide default registry.
#
# Built lazily under a lock: several UI screens can ask for it during startup
# from different threads, and double-construction would hand out two catalogues
# whose discovered model lists then drift apart.
# --------------------------------------------------------------------------- #
_default_registry: Optional[ProviderRegistry] = None
_default_lock = threading.Lock()
def default_registry() -> ProviderRegistry:
"""The shared registry seeded with :data:`BUILTIN_DESCRIPTORS`."""
global _default_registry
if _default_registry is None:
with _default_lock:
if _default_registry is None:
_default_registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
return _default_registry
def reset_default_registry() -> None:
"""Drop the cached registry — test-support hook so one test's registrations
cannot leak into the next."""
global _default_registry
with _default_lock:
_default_registry = None
__all__ = [
"BUILTIN_DESCRIPTORS",
"ProviderNotFoundError",
"ProviderRegistry",
"default_registry",
"reset_default_registry",
]
+1
View File
@@ -0,0 +1 @@
"""Infrastructure sandbox package: OS-specific sandbox capability adapters."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure telemetry package: CanonicalAuditLogger and token usage sinks."""
+288
View File
@@ -0,0 +1,288 @@
"""Token-usage telemetry as a publish/subscribe seam (R03-T06).
Before this module every provider adapter reached straight into
``core/usage_tracker.py`` and wrote a dashboard row itself, which meant the
provider layer owned a telemetry policy decision ("where do usage numbers go?")
and no test could observe a turn's token accounting without touching the real
``~/.cowork_local/usage/`` files.
Now a provider only *describes what happened* — it publishes an immutable
:class:`UsageEvent` — and subscribers decide what to do with it. The default
subscriber, :class:`UsageTrackerSink`, forwards to the existing usage tracker so
the Dashboard keeps working byte-for-byte; tests swap in
:class:`InMemoryUsageSink` and assert on the events directly.
Every publish path is failure-tolerant on purpose: telemetry must never be the
reason a chat turn dies, which is the same contract
``usage_tracker.record()`` already documents.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
logger = logging.getLogger("cowork_local.telemetry.usage")
@dataclass(frozen=True)
class UsageEvent:
"""One provider turn's token accounting.
Frozen so a subscriber cannot mutate an event the next subscriber in the
chain is about to receive. ``source``/``label`` stay optional: the usage
tracker already derives them from thread-local context set by whoever ran
the turn, and a provider adapter has no business knowing which UI surface
invoked it.
"""
provider: str
model: str
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
# True when the counts are a ~4-chars-per-token approximation because the
# gateway never sent a usage block. Surfaced in the Dashboard so users know
# which rows are measured and which are guessed.
estimated: bool = False
source: Optional[str] = None # None -> tracker's thread-local context
label: Optional[str] = None # None -> tracker's thread-local context
extras: Dict[str, Any] = field(default_factory=dict)
@property
def total_tokens(self) -> int:
"""Billable token count for this turn (cached tokens are already part
of the input count reported by every gateway we support, so adding them
again would double-count)."""
return int(self.input_tokens) + int(self.output_tokens)
def to_dict(self) -> Dict[str, Any]:
"""JSON-friendly view, using the same short keys as the usage tracker's
on-disk rows so a caller can diff an event against a stored row."""
return {
"provider": self.provider,
"model": self.model,
"in": int(self.input_tokens),
"out": int(self.output_tokens),
"cache": int(self.cached_tokens),
"estimated": bool(self.estimated),
"source": self.source or "",
"label": self.label or "",
}
@runtime_checkable
class UsageEventSink(Protocol):
"""Anything that can receive :class:`UsageEvent`s.
A ``Protocol`` rather than a base class so a plain object (or a test double,
or a Qt-side adapter that re-emits a signal) qualifies without inheriting
from infrastructure code.
"""
def emit(self, event: UsageEvent) -> None:
"""Handle one usage event. Implementations MUST NOT raise."""
class UsageTrackerSink:
"""Default subscriber: writes each event through ``core/usage_tracker.py``.
Keeps the existing Dashboard/telemetry pipeline (daily JSONL files, shared
cross-machine mirror, per-thread accumulator) as the single writer, so
routing this through an event seam changed the plumbing without changing
a single stored byte.
"""
def __init__(self, recorder=None) -> None:
# The recorder is injectable so a test can verify the forwarding
# contract without importing the real tracker (and its config paths).
self._recorder = recorder
def _resolve_recorder(self):
"""Late-bind ``usage_tracker.record``.
Imported on first use rather than at module import so telemetry stays
out of the import graph of anything that merely *declares* a sink.
"""
if self._recorder is None:
from ...core import usage_tracker as tracker
self._recorder = tracker.record
return self._recorder
def emit(self, event: UsageEvent) -> None:
"""Forward one event; swallow every failure (telemetry is never fatal)."""
try:
record = self._resolve_recorder()
if event.source is None:
# Normal path: the worker thread already tagged its own
# source/label via set_context(), so record() attributes the row.
record(
event.provider, event.model,
int(event.input_tokens), int(event.output_tokens),
int(event.cached_tokens), estimated=bool(event.estimated),
)
return
# Event carries its own attribution: apply it for this single write
# and restore the thread's previous context afterwards, so a
# re-attributed event cannot silently relabel every later turn that
# runs on the same worker thread.
from ...core import usage_tracker as tracker
previous_source, previous_label = tracker.current_context()
tracker.set_context(event.source, event.label or "")
try:
record(
event.provider, event.model,
int(event.input_tokens), int(event.output_tokens),
int(event.cached_tokens), estimated=bool(event.estimated),
)
finally:
tracker.set_context(previous_source, previous_label)
except Exception: # noqa: BLE001 — usage tracking must never break a turn
logger.debug("usage sink: forwarding to usage_tracker failed", exc_info=True)
class InMemoryUsageSink:
"""Collects events in a list — the test double for usage assertions."""
def __init__(self) -> None:
self.events: List[UsageEvent] = []
self._lock = threading.Lock()
def emit(self, event: UsageEvent) -> None:
"""Append under a lock: parallel Co4E flows publish from several worker
threads at once and ``list.append`` alone would still be atomic, but the
lock also makes :meth:`snapshot` a consistent read."""
with self._lock:
self.events.append(event)
def snapshot(self) -> List[UsageEvent]:
"""A copy of everything received so far."""
with self._lock:
return list(self.events)
def clear(self) -> None:
with self._lock:
self.events.clear()
@property
def total_tokens(self) -> int:
return sum(e.total_tokens for e in self.snapshot())
class CompositeUsageSink:
"""Fans one event out to several subscribers.
This is what makes the seam useful beyond the Dashboard: a future consumer
(per-workspace budget guard, live cost meter) subscribes alongside the
tracker instead of patching provider code again. One failing subscriber is
logged and skipped so it cannot starve the others.
"""
def __init__(self, sinks=None) -> None:
self._sinks: List[UsageEventSink] = list(sinks or ())
self._lock = threading.RLock()
def add(self, sink: UsageEventSink) -> None:
with self._lock:
self._sinks.append(sink)
def remove(self, sink: UsageEventSink) -> None:
"""Detach a subscriber; a sink that was never added is ignored so
teardown code can call this unconditionally."""
with self._lock:
if sink in self._sinks:
self._sinks.remove(sink)
def sinks(self) -> List[UsageEventSink]:
with self._lock:
return list(self._sinks)
def emit(self, event: UsageEvent) -> None:
for sink in self.sinks():
try:
sink.emit(event)
except Exception: # noqa: BLE001 — one bad subscriber must not stop the rest
logger.debug("usage sink: subscriber %r failed", sink, exc_info=True)
# --------------------------------------------------------------------------- #
# Process-wide sink.
#
# Providers publish through the module-level helpers below rather than holding a
# sink reference, because a provider instance is created fresh for every turn
# (see AppContext.build_provider_for) and would otherwise have to be handed the
# telemetry wiring on every construction.
# --------------------------------------------------------------------------- #
_sink_lock = threading.RLock()
_sink: Optional[CompositeUsageSink] = None
def get_usage_sink() -> CompositeUsageSink:
"""The shared sink, seeded with :class:`UsageTrackerSink` on first use."""
global _sink
if _sink is None:
with _sink_lock:
if _sink is None:
_sink = CompositeUsageSink([UsageTrackerSink()])
return _sink
def set_usage_sink(sink: Optional[CompositeUsageSink]) -> None:
"""Replace the shared sink (``None`` restores the default on next use).
Used by tests and by the app shell when it wants a different fan-out; kept
explicit so nothing silently reconfigures telemetry mid-run.
"""
global _sink
with _sink_lock:
_sink = sink
def subscribe(sink: UsageEventSink) -> UsageEventSink:
"""Attach an extra subscriber to the shared sink and return it (so callers
can keep the handle for a later :func:`unsubscribe`)."""
get_usage_sink().add(sink)
return sink
def unsubscribe(sink: UsageEventSink) -> None:
"""Detach a subscriber previously passed to :func:`subscribe`."""
get_usage_sink().remove(sink)
def publish(event: UsageEvent) -> None:
"""Publish one usage event to every subscriber.
Never raises: called from inside a provider's streaming loop, where an
exception would abort an otherwise successful turn.
"""
try:
get_usage_sink().emit(event)
except Exception: # noqa: BLE001
logger.debug("usage sink: publish failed", exc_info=True)
def estimate_tokens(text: str) -> int:
"""~4 chars per token approximation, re-exported so provider adapters need
exactly ONE telemetry import instead of also importing the tracker."""
return max(0, len(text or "") // 4)
__all__ = [
"UsageEvent",
"UsageEventSink",
"UsageTrackerSink",
"InMemoryUsageSink",
"CompositeUsageSink",
"get_usage_sink",
"set_usage_sink",
"subscribe",
"unsubscribe",
"publish",
"estimate_tokens",
]
+1
View File
@@ -0,0 +1 @@
"""Presentation chat package: ChatHistoryWidget, ComposerWidget, AttachmentPicker, AudioRecorderWidget, ChatOutputPanel."""
+1
View File
@@ -0,0 +1 @@
"""Presentation Co4E package: Co4ECanvasWidget, NodePropertyPanel, RunControlWidget, Co4EChatView."""
+1
View File
@@ -0,0 +1 @@
"""Presentation dashboard package: TokenUsageCardWidget, UsageChartWidget, HabitsWidget."""
+1
View File
@@ -0,0 +1 @@
"""Presentation folder package: WorkspaceFileTree, DocumentPreviewManager, AiFileEditorDialog."""
+1
View File
@@ -0,0 +1 @@
"""Presentation graph package: StructureGraphView and GraphQaWidget."""
+1
View File
@@ -0,0 +1 @@
"""Presentation monitoring package: 8 modular sub-tab widgets."""
+1
View File
@@ -0,0 +1 @@
"""Presentation scheduling package: KanbanBoardWidget, CalendarViewWidget, AiTaskCreatorDialog."""
+1
View File
@@ -0,0 +1 @@
"""Presentation settings package: Section widgets for provider, connector, routing, and general settings."""
+1
View File
@@ -0,0 +1 @@
"""Presentation shell package: MainWindow shell, TrayManager, LifecycleCoordinator."""
View File
+19 -7
View File
@@ -292,19 +292,31 @@ 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.
# Usage event — real counts from the stream's usage events, else a
# ~4 chars/token estimate. Published to the telemetry sink (R03-T06)
# rather than written straight to the Dashboard store, so the provider
# stays a pure transport adapter. Never breaks the turn.
try:
from ..core import usage_tracker as ut
from ..infrastructure.telemetry import usage_sink
if usage_seen:
ut.record(self.name, self.model, usage_seen.get("in", 0),
usage_seen.get("out", 0), usage_seen.get("cache", 0))
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_seen.get("in", 0),
output_tokens=usage_seen.get("out", 0),
cached_tokens=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)
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass
+24 -17
View File
@@ -1,25 +1,32 @@
"""Build a provider instance from the application config."""
"""Build a provider instance from the application config.
Kept as the historic entry point (``providers.build_provider``) that call sites
across the app already import, but it no longer owns a provider table of its
own: since R03-T02 the catalogue lives in
``infrastructure/providers/provider_registry.py`` so provider ids, wire
protocols, default models and capabilities are declared exactly once.
"""
from __future__ import annotations
from typing import Any, Dict
from .anthropic import AnthropicProvider
from .base import Provider, ProviderError
from .openai_compat import OpenAICompatProvider
_REGISTRY = {
"openai_compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
# All OpenAI-compatible endpoints (Ollama's /v1 server, the Copilot chat API,
# and OpenAI itself) speak the same Chat Completions protocol.
"ollama": OpenAICompatProvider,
"github_copilot": OpenAICompatProvider,
"codex": OpenAICompatProvider,
}
def build_provider(name: str, conf: Dict[str, Any]) -> Provider:
cls = _REGISTRY.get(name)
if cls is None:
raise ProviderError(f"Unsupported provider: {name}")
return cls(conf)
"""Construct the adapter registered for ``name``.
Delegates to the central registry and translates its lookup failure into
:class:`ProviderError`, because every existing call site (chat turns,
Settings' connection test, the routing prober) already handles that type —
changing the exception would ripple into unrelated error handling.
"""
from ..infrastructure.providers.provider_registry import (
ProviderNotFoundError,
default_registry,
)
try:
return default_registry().build(name, conf)
except ProviderNotFoundError as exc:
raise ProviderError(f"Unsupported provider: {name}") from exc
+26 -10
View File
@@ -266,22 +266,38 @@ class OpenAICompatProvider(Provider):
return _assemble_assistant(text_parts, tool_acc)
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."""
"""Publish one usage event per turn: real counts when the server's final
chunk carried a "usage" block, a ~4 chars/token estimate otherwise.
Since R03-T06 this only *describes* what the turn consumed and hands the
event to ``infrastructure/telemetry/usage_sink.py``; deciding where the
numbers land (Dashboard files, cost meters, tests) belongs to the
subscribers, not to a provider adapter. Never breaks the turn.
"""
try:
from ..core import usage_tracker as ut
from ..infrastructure.telemetry import usage_sink
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))
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_seen.get("prompt_tokens", 0),
output_tokens=usage_seen.get("completion_tokens", 0),
cached_tokens=(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0),
))
else:
# No usage block from the gateway — approximate from the exact
# bytes we sent and received so the Dashboard still shows a
# (clearly flagged) figure instead of a silent zero.
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)
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass
-9
View File
@@ -1,9 +0,0 @@
PySide6>=6.6
pydantic>=2
requests
psutil
pygments
openpyxl
python-pptx
networkx
pytest
+166
View File
@@ -0,0 +1,166 @@
"""AST-based Static Analysis Guard for Clean Architecture Enforcement.
Scans designated Python packages (such as `domain/` and `application/`) to ensure
they remain 100% Pure Python and do not import presentation/GUI frameworks (PySide6, PyQt)
or concrete application shells.
"""
from __future__ import annotations
import argparse
import ast
import io
import sys
from pathlib import Path
from typing import List, NamedTuple, Set
# Ensure UTF-8 output on standard console streams across diverse Windows locales (CP932, etc.)
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
class ImportViolation(NamedTuple):
file_path: Path
line_number: int
imported_module: str
rule_description: str
# Disallowed top-level package names in pure business/domain layers
FORBIDDEN_MODULE_PREFIXES: Set[str] = {
"PySide6",
"PySide2",
"PyQt6",
"PyQt5",
"ui",
"app",
}
# Default directories that must strictly adhere to Clean Architecture
DEFAULT_SCAN_DIRS: List[str] = [
"domain",
"application",
]
class ArchitectureImportVisitor(ast.NodeVisitor):
"""AST visitor that checks all Import and ImportFrom statements against forbidden prefixes."""
def __init__(self, file_path: Path, forbidden: Set[str]) -> None:
self.file_path = file_path
self.forbidden = forbidden
self.violations: List[ImportViolation] = []
def visit_Import(self, node: ast.Import) -> None:
# Check direct `import x, y` statements
for alias in node.names:
root_module = alias.name.split(".")[0]
if root_module in self.forbidden:
self.violations.append(
ImportViolation(
file_path=self.file_path,
line_number=node.lineno,
imported_module=alias.name,
rule_description=f"Direct import of GUI/shell module '{alias.name}' is prohibited.",
)
)
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
# Check `from x import y` statements
if node.module:
root_module = node.module.split(".")[0]
if root_module in self.forbidden:
self.violations.append(
ImportViolation(
file_path=self.file_path,
line_number=node.lineno,
imported_module=node.module,
rule_description=f"Import from GUI/shell module '{node.module}' is prohibited.",
)
)
self.generic_visit(node)
def scan_file(file_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
"""Parse a single Python file into AST and return all detected architecture import violations."""
try:
source_code = file_path.read_text(encoding="utf-8")
tree = ast.parse(source_code, filename=str(file_path))
except (SyntaxError, UnicodeDecodeError) as exc:
print(f"[Syntax/Read Warning] Could not parse {file_path}: {exc}", file=sys.stderr)
return []
visitor = ArchitectureImportVisitor(file_path, forbidden)
visitor.visit(tree)
return visitor.violations
def scan_directory(dir_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
"""Recursively scan all Python files in a directory."""
violations: List[ImportViolation] = []
if not dir_path.exists():
return violations
for py_file in dir_path.rglob("*.py"):
if py_file.is_file() and "__pycache__" not in py_file.parts:
violations.extend(scan_file(py_file, forbidden))
return violations
def main() -> int:
"""CLI entry point for CI/pre-commit quality gate checks."""
parser = argparse.ArgumentParser(
description="Clean Architecture Import Guard: Verifies zero GUI/Qt dependencies in domain/app layers."
)
parser.add_argument(
"--paths",
nargs="*",
default=DEFAULT_SCAN_DIRS,
help="Paths or directories to scan (defaults to 'domain' and 'application')",
)
parser.add_argument(
"--root",
default=".",
help="Root workspace directory",
)
args = parser.parse_args()
root_dir = Path(args.root).resolve()
all_violations: List[ImportViolation] = []
print(f"[Clean Arch Guard] Scanning root: {root_dir}")
for target in args.paths:
target_path = (root_dir / target).resolve()
if not target_path.exists():
# If the layer directory does not exist yet (during early migration), skip cleanly
print(f"[Clean Arch Guard] Directory '{target}' does not exist yet (skipped).")
continue
if target_path.is_file():
all_violations.extend(scan_file(target_path, FORBIDDEN_MODULE_PREFIXES))
else:
all_violations.extend(scan_directory(target_path, FORBIDDEN_MODULE_PREFIXES))
if all_violations:
print("\n[FAIL] CLEAN ARCHITECTURE VIOLATIONS DETECTED:")
print("=" * 70)
for v in all_violations:
rel_path = v.file_path.relative_to(root_dir) if v.file_path.is_relative_to(root_dir) else v.file_path
print(f" • {rel_path}:{v.line_number} -> Forbidden import: '{v.imported_module}'")
print(f" Reason: {v.rule_description}")
print("=" * 70)
print(f"Total Violations: {len(all_violations)}")
return 1
print("\n[PASS] CLEAN ARCHITECTURE CHECK: 0 forbidden imports detected.")
return 0
if __name__ == "__main__":
sys.exit(main())
+9 -5
View File
@@ -73,14 +73,18 @@ class AppContext:
return load_project(pid)
def project_routing_mode(self, surface: str) -> str:
"""Effective Off/Auto/Manual routing mode for a chat ``surface`` in the
ACTIVE workspace: the workspace's own override wins; otherwise the
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
in the ACTIVE workspace: the workspace's own override wins; otherwise the
global default (``config.routing_mode_for``). This is what makes each
workspace keep its own routing mode."""
workspace keep its own routing mode.
The accepted set is taken from ``AppConfig.ROUTING_MODES`` rather than
repeated here, so adding a mode (as R03-T03 did with "fallback") stays a
one-line change instead of a hunt through every validation site."""
project = self._current_project()
if project is not None:
mode = (project.routing_modes or {}).get(surface, "")
if mode in ("off", "auto", "manual"):
if mode in self.config.ROUTING_MODES:
return mode
return self.config.routing_mode_for(surface)
@@ -88,7 +92,7 @@ class AppContext:
"""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"
mode = mode if mode in self.config.ROUTING_MODES else "off"
project = self._current_project()
if project is None:
self.config.set_routing_mode_for(surface, mode)
+157
View File
@@ -0,0 +1,157 @@
"""Characterization tests for core/chat_agent.py (run_chat and run_cowork runtime seams).
These tests capture existing behavior as an executable baseline specification,
ensuring that future refactoring to ConversationApplicationService does not alter
core turn semantics, event emissions, or file handling.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
from cowork_local.core import chat_agent
from cowork_local.tests.fakes.fake_provider import FakeProvider
def test_run_chat_characterization() -> None:
"""Capture baseline behavior of run_chat: system prompt insertion, streaming, and message persistence."""
provider = FakeProvider()
provider.queue_response(content="Hello there!", chunks=["Hello ", "there!"])
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Hi assistant"}]
emitted_events: List[Dict[str, Any]] = []
def emit(event: Dict[str, Any]) -> None:
emitted_events.append(event)
result = chat_agent.run_chat(
provider=provider,
messages=messages,
emit=emit,
)
# 1. Verify system prompt was injected at position 0
assert messages[0]["role"] == "system"
assert "Cowork Local" in messages[0]["content"]
# 2. Verify returned assistant message
assert result["role"] == "assistant"
assert result["content"] == "Hello there!"
# 3. Verify assistant message was appended to messages list
assert messages[-1] == result
# 4. Verify emitted events sequence
text_deltas = [e["delta"] for e in emitted_events if e["type"] == "text"]
assert "".join(text_deltas) == "Hello there!"
assert any(e["type"] == "assistant_done" for e in emitted_events)
def test_run_cowork_save_file_characterization(tmp_path: Path) -> None:
"""Capture baseline behavior of run_cowork: tool execution loop and file production."""
output_dir = tmp_path / "output"
output_dir.mkdir(parents=True, exist_ok=True)
provider = FakeProvider()
# Step 1: Model requests save_file tool
provider.queue_response(
content="Saving your requested report.",
tool_calls=[{
"id": "call_save_1",
"name": "save_file",
"arguments": {
"filename": "report.md",
"content": "# Executive Summary\nAll systems nominal.",
},
}],
)
# Step 2: Model finishes after tool result
provider.queue_response(
content="I have created report.md in your output directory.",
chunks=["I have created report.md in your output directory."],
)
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Export report to markdown file"}]
emitted_events: List[Dict[str, Any]] = []
def emit(event: Dict[str, Any]) -> None:
emitted_events.append(event)
final_messages = chat_agent.run_cowork(
provider=provider,
messages=messages,
output_dir=output_dir,
emit=emit,
enforce_rules=False,
)
# 1. Verify file was created in output directory with expected content
created_file = output_dir / "report.md"
assert created_file.exists()
assert created_file.read_text(encoding="utf-8") == "# Executive Summary\nAll systems nominal."
# 2. Verify message history contains user -> assistant (tool_calls) -> tool -> assistant
roles = [m["role"] for m in final_messages]
assert "system" in roles
assert "user" in roles
assert "tool" in roles
# 3. Verify tool result message content
tool_msg = next(m for m in final_messages if m["role"] == "tool")
assert tool_msg["name"] == "save_file"
assert "Saved report.md" in tool_msg["content"]
def test_run_cowork_cancellation_characterization(tmp_path: Path) -> None:
"""Capture cancellation behavior in run_cowork."""
output_dir = tmp_path / "output_cancel"
output_dir.mkdir(parents=True, exist_ok=True)
provider = FakeProvider()
provider.queue_response(content="Working...")
is_cancelled = True
def check_cancel() -> bool:
return is_cancelled
emitted_events: List[Dict[str, Any]] = []
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Please start"}]
chat_agent.run_cowork(
provider=provider,
messages=messages,
output_dir=output_dir,
emit=lambda e: emitted_events.append(e),
cancel=check_cancel,
enforce_rules=False,
)
# Provider should not have executed turns if cancelled right away
assert provider.call_count == 0
def test_cleanup_turn_output_characterization(tmp_path: Path) -> None:
"""Capture behavior of temporary .scratch folder cleanup and artifact preservation."""
output_dir = tmp_path / "output_cleanup"
output_dir.mkdir(parents=True, exist_ok=True)
scratch_dir = output_dir / ".scratch"
scratch_dir.mkdir(parents=True, exist_ok=True)
# Create a generator script and a deliverable inside scratch
generator_script = scratch_dir / "gen.py"
generator_script.write_text("print('generating')", encoding="utf-8")
deliverable = scratch_dir / "data.csv"
deliverable.write_text("a,b,c\n1,2,3", encoding="utf-8")
before_snapshot = chat_agent._snapshot(output_dir)
removed, moved = chat_agent._cleanup_cowork_intermediates(output_dir, before_snapshot, cancelled=False)
# .scratch directory should be removed
assert not scratch_dir.exists()
# deliverable should be moved to output root
root_csv = output_dir / "data.csv"
assert root_csv.exists()
# script should not be in output root
assert not (output_dir / "gen.py").exists()
+69 -4
View File
@@ -1,10 +1,75 @@
"""Make the repository package importable when pytest runs from the repo root."""
"""Make THIS checkout importable as the ``cowork_local`` package during tests.
Why this is not just a ``sys.path`` insert
------------------------------------------
Test modules import the app in two different styles:
* top-level (``from providers.base import ...``) — resolved by the repository
root already sitting on ``sys.path`` when pytest is launched from it;
* fully qualified (``from cowork_local.core.routing.service import ...``) —
which only resolves when a directory literally named ``cowork_local`` is
importable.
Simply appending the repository's PARENT directory to ``sys.path`` (the previous
behaviour) makes the second style resolve against *whatever* sibling folder
happens to be called ``cowork_local`` — on a developer machine that is often an
unrelated older checkout, so the whole suite silently exercises the wrong code
while still reporting green. Instead we bind the name ``cowork_local`` in
``sys.modules`` to the package rooted at THIS repository, so both import styles
always reach the working copy under test regardless of the checkout's directory
name.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
REPOSITORY_PARENT = Path(__file__).resolve().parents[2]
if str(REPOSITORY_PARENT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_PARENT))
# .../<checkout>/tests/conftest.py -> .../<checkout>
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_NAME = "cowork_local"
# The repository root must stay importable so the top-level import style
# (``providers``/``domain``/``application``/``tests``) keeps working.
if str(PACKAGE_ROOT) not in sys.path:
sys.path.insert(0, str(PACKAGE_ROOT))
def _bind_checkout_as_package() -> None:
"""Register this checkout in ``sys.modules`` under the canonical package name.
Executed at import time of the conftest (i.e. before any test module is
imported) so that a stale same-named directory elsewhere on ``sys.path`` can
never win the lookup. A no-op when the package is already bound to this very
directory, which keeps repeated conftest loads (pytest-xdist, sub-sessions)
idempotent.
"""
existing = sys.modules.get(PACKAGE_NAME)
if existing is not None:
# Already bound. Only rebind when it points at a DIFFERENT checkout,
# otherwise re-executing the package __init__ would duplicate module
# state that tests may already hold references to.
origin = getattr(existing, "__file__", "") or ""
if Path(origin).resolve().parent == PACKAGE_ROOT:
return
spec = importlib.util.spec_from_file_location(
PACKAGE_NAME,
PACKAGE_ROOT / "__init__.py",
# Declaring the search locations is what turns the module into a real
# package, so ``cowork_local.core.routing`` and friends resolve as
# sub-modules of this directory.
submodule_search_locations=[str(PACKAGE_ROOT)],
)
if spec is None or spec.loader is None: # pragma: no cover — defensive
return
module = importlib.util.module_from_spec(spec)
# Insert BEFORE executing so that a circular ``import cowork_local`` from
# inside the package body resolves to the partially-initialised module
# instead of restarting the import (standard CPython import semantics).
sys.modules[PACKAGE_NAME] = module
spec.loader.exec_module(module)
_bind_checkout_as_package()
+7
View File
@@ -0,0 +1,7 @@
"""Contract tests: one shared specification every interchangeable adapter must satisfy.
Unlike unit tests (which pin ONE implementation's behaviour), a contract test is
parametrised over every implementation of an interface, so adding a new provider
means adding a row — not writing a new test file — and a provider that quietly
breaks the canonical shape fails here rather than in production.
"""
+178
View File
@@ -0,0 +1,178 @@
"""Offline transport doubles + per-protocol stream scripts for the provider contract tests.
Kept in its own module so ``test_providers.py`` stays a readable list of
assertions instead of a wall of SSE fixtures, and so the LOC ceiling (400 lines
per production file, applied here too) is comfortably met by both halves.
Nothing in here touches the network: :class:`FakeStreamResponse` mimics just
enough of ``requests.Response`` for the streaming loops in
``providers/openai_compat.py`` and ``providers/anthropic.py`` — status code,
mutable ``encoding``, ``iter_lines`` and ``close``.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
# Canonical turn every protocol script below must produce, so the contract test
# can assert one expected result no matter which provider produced it.
EXPECTED_TEXT = "Hello world"
EXPECTED_TOOL_CALL = {"id": "call-1", "name": "read_file", "arguments": {"path": "a.txt"}}
EXPECTED_INPUT_TOKENS = 11
EXPECTED_OUTPUT_TOKENS = 7
EXPECTED_CACHED_TOKENS = 3
class FakeStreamResponse:
"""A minimal stand-in for a streaming ``requests.Response``.
``iter_lines`` replays pre-baked SSE lines; ``closed`` records that the
provider released the connection, which the contract asserts because a
provider that leaks the response leaks a socket per turn.
"""
def __init__(
self,
lines: Optional[List[str]] = None,
status_code: int = 200,
body: str = "",
headers: Optional[Dict[str, str]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> None:
self.status_code = status_code
self._lines = list(lines or ())
self.text = body
self.headers = dict(headers or {})
self._payload = payload
self.closed = False
# Providers force UTF-8 on the response before reading it; the attribute
# simply has to exist and be writable.
self.encoding = None
def iter_lines(self, decode_unicode: bool = False):
for line in self._lines:
yield line
def json(self) -> Any:
if self._payload is None:
raise ValueError("no JSON payload configured on this fake response")
return self._payload
def close(self) -> None:
self.closed = True
def _sse(payload: Dict[str, Any]) -> str:
"""One SSE ``data:`` line carrying a JSON event."""
return "data: " + json.dumps(payload, ensure_ascii=False)
def openai_stream_lines() -> List[str]:
"""A complete OpenAI Chat Completions stream: text, one tool call, usage.
Split across several deltas on purpose — chunk boundaries are where naive
stream parsers break, so the contract exercises them.
"""
return [
_sse({"choices": [{"delta": {"content": "Hello "}}]}),
_sse({"choices": [{"delta": {"content": "world"}}]}),
_sse({"choices": [{"delta": {"tool_calls": [{
"index": 0, "id": "call-1",
"function": {"name": "read_file", "arguments": '{"path":'},
}]}}]}),
# Arguments arrive fragmented; the provider must concatenate before parsing.
_sse({"choices": [{"delta": {"tool_calls": [{
"index": 0, "function": {"arguments": '"a.txt"}'},
}]}}]}),
_sse({
"choices": [{"delta": {}}],
"usage": {
"prompt_tokens": EXPECTED_INPUT_TOKENS,
"completion_tokens": EXPECTED_OUTPUT_TOKENS,
"prompt_tokens_details": {"cached_tokens": EXPECTED_CACHED_TOKENS},
},
}),
"data: [DONE]",
]
def anthropic_stream_lines() -> List[str]:
"""The same canonical turn expressed as an Anthropic Messages stream."""
return [
_sse({"type": "message_start", "message": {"usage": {
"input_tokens": EXPECTED_INPUT_TOKENS,
"cache_read_input_tokens": EXPECTED_CACHED_TOKENS,
}}}),
_sse({"type": "content_block_start", "index": 0,
"content_block": {"type": "text"}}),
_sse({"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "Hello "}}),
_sse({"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "world"}}),
_sse({"type": "content_block_start", "index": 1, "content_block": {
"type": "tool_use", "id": "call-1", "name": "read_file"}}),
_sse({"type": "content_block_delta", "index": 1,
"delta": {"type": "input_json_delta", "partial_json": '{"path":'}}),
_sse({"type": "content_block_delta", "index": 1,
"delta": {"type": "input_json_delta", "partial_json": '"a.txt"}'}}),
_sse({"type": "message_delta",
"usage": {"output_tokens": EXPECTED_OUTPUT_TOKENS}}),
_sse({"type": "message_stop"}),
]
# Per wire protocol: how to script a successful turn, and the model-list payload
# ``list_models()`` expects. Keyed by the descriptor's wire protocol value so a
# new provider that reuses an existing protocol needs no new entry here.
PROTOCOL_FIXTURES = {
"openai_compat": {
"stream_lines": openai_stream_lines,
"models_payload": {"data": [{"id": "gpt-4o-mini"}, {"id": "gpt-4o"}]},
"expected_models": ["gpt-4o-mini", "gpt-4o"],
},
"anthropic": {
"stream_lines": anthropic_stream_lines,
"models_payload": {"data": [{"id": "claude-sonnet-4-6"}]},
"expected_models": ["claude-sonnet-4-6"],
},
}
class ScriptedTransport:
"""Replaces ``Provider._request`` and hands back scripted responses.
Records every call so a test can assert *how* the provider talked to the
endpoint (method, url, JSON payload) without a socket ever being opened.
"""
def __init__(self, responses: List[FakeStreamResponse]) -> None:
self._responses = list(responses)
self.calls: List[Dict[str, Any]] = []
def __call__(self, method: str, url: str, **kwargs) -> FakeStreamResponse:
self.calls.append({"method": method, "url": url, **kwargs})
if not self._responses:
raise AssertionError(f"unexpected extra request: {method} {url}")
# Pop in order: a provider that retries gets the NEXT scripted response,
# which is how the retry/error paths are driven.
return self._responses.pop(0)
@property
def last_payload(self) -> Dict[str, Any]:
"""The JSON body of the most recent request."""
return self.calls[-1].get("json") or {}
__all__ = [
"EXPECTED_CACHED_TOKENS",
"EXPECTED_INPUT_TOKENS",
"EXPECTED_OUTPUT_TOKENS",
"EXPECTED_TEXT",
"EXPECTED_TOOL_CALL",
"FakeStreamResponse",
"PROTOCOL_FIXTURES",
"ScriptedTransport",
"anthropic_stream_lines",
"openai_stream_lines",
]
+279
View File
@@ -0,0 +1,279 @@
"""R03-T01 — the contract every LLM provider adapter must satisfy.
Parametrised over EVERY provider in the central registry
(``infrastructure/providers/provider_registry.py``), so registering a new
provider automatically subjects it to the same specification and a provider that
drifts from the canonical shapes fails here.
The contract, in one list:
* construction — the registry builds a real ``Provider`` for every id;
* ``chat()`` — canonical signature, canonical assistant message, streamed text
delivered through ``on_text``, tool calls normalised to
``{"id", "name", "arguments": dict}``, response always closed;
* tool schema translation matches the adapter's wire protocol;
* failures raise ``ProviderError`` — never a bare transport exception;
* ``list_models()`` / ``test_connection()`` report a reason instead of a silent
empty list;
* telemetry — exactly one ``UsageEvent`` per turn (R03-T06), with the real
counts when the stream reports them.
Everything runs offline: ``Provider._request`` is replaced by a scripted
transport, so the suite needs no network, no API key and no Qt event loop.
"""
from __future__ import annotations
import pytest
import requests
from cowork_local.infrastructure.providers.provider_registry import (
BUILTIN_DESCRIPTORS,
ProviderRegistry,
)
from cowork_local.infrastructure.telemetry import usage_sink
from cowork_local.providers.base import Provider, ProviderError, ToolSpec
from cowork_local.tests.contracts.provider_stubs import (
EXPECTED_CACHED_TOKENS,
EXPECTED_INPUT_TOKENS,
EXPECTED_OUTPUT_TOKENS,
EXPECTED_TEXT,
EXPECTED_TOOL_CALL,
PROTOCOL_FIXTURES,
FakeStreamResponse,
ScriptedTransport,
)
# Every provider id in the catalogue — the parametrisation that makes this a
# contract suite rather than a per-adapter unit test.
PROVIDER_IDS = [d.provider_id for d in BUILTIN_DESCRIPTORS]
# Minimal config: enough for any adapter to build a URL and headers offline.
BASE_CONF = {"base_url": "https://gateway.test/v1", "api_key": "test-key"}
SAMPLE_MESSAGES = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Say hello"},
]
SAMPLE_TOOL = ToolSpec(
name="read_file",
description="Read a file from disk",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
)
@pytest.fixture()
def registry() -> ProviderRegistry:
"""A private registry per test so registrations never leak between tests."""
return ProviderRegistry(BUILTIN_DESCRIPTORS)
@pytest.fixture()
def collected_usage(monkeypatch) -> usage_sink.InMemoryUsageSink:
"""Swap the process-wide telemetry sink for an in-memory one.
Restored by monkeypatch after each test, so a contract run never appends to
the developer's real ``~/.cowork_local/usage/`` files.
"""
sink = usage_sink.InMemoryUsageSink()
monkeypatch.setattr(usage_sink, "_sink", usage_sink.CompositeUsageSink([sink]))
return sink
def _fixtures_for(registry: ProviderRegistry, provider_id: str) -> dict:
"""The stream/model-list script matching this provider's wire protocol."""
protocol = registry.get(provider_id).wire_protocol.value
return PROTOCOL_FIXTURES[protocol]
def _build(registry: ProviderRegistry, provider_id: str, transport=None) -> Provider:
"""Build a provider and (optionally) replace its transport with a script."""
provider = registry.build(provider_id, dict(BASE_CONF))
if transport is not None:
# Patch the INSTANCE, not the class: parallel parametrised cases must
# not see each other's scripted transport.
provider._request = transport
return provider
# --------------------------------------------------------------------------- #
# Construction & interface shape
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_registry_builds_a_provider_for_every_registered_id(registry, provider_id) -> None:
"""Every catalogued provider must be constructible — a descriptor with no
working adapter is a broken entry, not a feature flag."""
provider = _build(registry, provider_id)
assert isinstance(provider, Provider)
# The registry fills in the descriptor's default model when config omits it,
# so a half-configured provider still names a concrete model.
assert provider.model, f"{provider_id} built without a model id"
assert provider.describe() == f"{provider.name}:{provider.model}"
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_chat_signature_is_uniform(registry, provider_id) -> None:
"""All adapters accept the same call, so the agent runtime can swap
providers without knowing which one it holds."""
import inspect
provider = _build(registry, provider_id)
params = list(inspect.signature(provider.chat).parameters)
assert params == ["messages", "tools", "on_text", "cancel", "on_reasoning"]
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_tool_schema_matches_the_wire_protocol(registry, provider_id) -> None:
"""A ToolSpec must translate into the exact shape the endpoint expects."""
descriptor = registry.get(provider_id)
if descriptor.wire_protocol.value == "anthropic":
translated = SAMPLE_TOOL.to_anthropic()
assert translated["input_schema"] == SAMPLE_TOOL.parameters
assert translated["name"] == "read_file"
else:
translated = SAMPLE_TOOL.to_openai()
assert translated["type"] == "function"
assert translated["function"]["parameters"] == SAMPLE_TOOL.parameters
# --------------------------------------------------------------------------- #
# The turn itself
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_chat_returns_the_canonical_assistant_message(registry, provider_id, collected_usage) -> None:
"""Whatever the wire format, one turn yields the same canonical result."""
fixtures = _fixtures_for(registry, provider_id)
response = FakeStreamResponse(lines=fixtures["stream_lines"]())
transport = ScriptedTransport([response])
provider = _build(registry, provider_id, transport)
streamed: list = []
result = provider.chat(
SAMPLE_MESSAGES, tools=[SAMPLE_TOOL], on_text=streamed.append,
)
assert result["role"] == "assistant"
assert result["content"] == EXPECTED_TEXT
# Text must arrive incrementally, not only in the final message — the chat
# UI streams from these callbacks.
assert "".join(streamed) == EXPECTED_TEXT
assert len(streamed) >= 2
# Tool calls are normalised: parsed arguments, never the raw JSON fragments.
assert result["tool_calls"] == [EXPECTED_TOOL_CALL]
assert response.closed, "provider left the streaming response open"
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_chat_publishes_exactly_one_usage_event(registry, provider_id, collected_usage) -> None:
"""R03-T06: a turn reports its token usage through the telemetry sink, with
the server's real counts when the stream carried them."""
fixtures = _fixtures_for(registry, provider_id)
transport = ScriptedTransport([FakeStreamResponse(lines=fixtures["stream_lines"]())])
provider = _build(registry, provider_id, transport)
provider.chat(SAMPLE_MESSAGES, tools=[SAMPLE_TOOL])
events = collected_usage.snapshot()
assert len(events) == 1, "a turn must publish exactly one usage event"
event = events[0]
assert event.provider == provider.name
assert event.model == provider.model
assert event.input_tokens == EXPECTED_INPUT_TOKENS
assert event.output_tokens == EXPECTED_OUTPUT_TOKENS
assert event.cached_tokens == EXPECTED_CACHED_TOKENS
# Real counts were available, so the event must NOT be flagged as a guess.
assert event.estimated is False
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_usage_is_estimated_when_the_stream_reports_none(registry, provider_id, collected_usage) -> None:
"""Gateways that never send usage still produce a dashboard row — clearly
flagged as an estimate rather than silently recorded as zero."""
# Only text; no usage block anywhere in the stream.
silent_stream = ['data: ' + '{"choices": [{"delta": {"content": "hi"}}]}', "data: [DONE]"]
if registry.get(provider_id).wire_protocol.value == "anthropic":
silent_stream = [
'data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}',
'data: {"type": "content_block_delta", "index": 0,'
' "delta": {"type": "text_delta", "text": "hi"}}',
]
transport = ScriptedTransport([FakeStreamResponse(lines=silent_stream)])
provider = _build(registry, provider_id, transport)
provider.chat(SAMPLE_MESSAGES)
events = collected_usage.snapshot()
assert len(events) == 1
assert events[0].estimated is True
# An estimate still has to be a positive number to be worth showing.
assert events[0].total_tokens > 0
# --------------------------------------------------------------------------- #
# Failure behaviour
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_http_error_becomes_provider_error(registry, provider_id, collected_usage) -> None:
"""Callers handle exactly one exception type; adapters must not leak
transport- or JSON-level errors past their boundary."""
failing = FakeStreamResponse(status_code=401, body='{"error": {"message": "bad key"}}')
transport = ScriptedTransport([failing])
provider = _build(registry, provider_id, transport)
with pytest.raises(ProviderError):
provider.chat(SAMPLE_MESSAGES)
assert failing.closed, "provider left a failed response open"
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_list_models_and_test_connection_report_a_reason(registry, provider_id) -> None:
"""A failed model load must explain itself: ``last_error`` is what Settings
shows instead of an unexplained empty dropdown."""
def _boom(*_args, **_kwargs):
# A transport failure, i.e. what actually happens when the gateway is
# unreachable — adapters translate this class of error, not arbitrary
# programming errors, which must still surface as bugs.
raise requests.ConnectionError("network down")
provider = _build(registry, provider_id, _boom)
models = provider.list_models()
assert provider.last_error, f"{provider_id} swallowed a model-load failure"
ok, message = provider.test_connection()
assert ok is False
assert message
# Anthropic answers with a built-in fallback catalogue; a gateway answers
# with nothing. Both are acceptable — the contract is only that a failure is
# never reported as success.
assert isinstance(models, list)
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_list_models_returns_ids_on_success(registry, provider_id) -> None:
"""The happy path returns plain model-id strings, not raw API objects."""
fixtures = _fixtures_for(registry, provider_id)
transport = ScriptedTransport([
FakeStreamResponse(status_code=200, payload=fixtures["models_payload"]),
])
provider = _build(registry, provider_id, transport)
models = provider.list_models()
assert models == fixtures["expected_models"]
assert provider.last_error == ""
assert all(isinstance(m, str) for m in models)
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
def test_strip_think_removes_inline_reasoning(registry, provider_id) -> None:
"""Reasoning must never leak into a final answer, whichever adapter ran."""
provider = _build(registry, provider_id)
cleaned = provider.strip_think("<think>secret plan</think>Visible answer")
assert cleaned == "Visible answer"
+14 -1
View File
@@ -1 +1,14 @@
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt."""
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.
Gói này cố ý **không** import sẵn fake nào. Import ở đây là import háo hức:
chạm vào bất kỳ fake nào là kéo theo mọi phụ thuộc của nó, nên chỉ cần một
fake lỡ import module cần sys.path đặc biệt là cả gói hỏng trong môi trường
cô lập. Đã xảy ra thật khi merge Delta: `fake_provider` dùng
`from providers.base import ...` (import tuyệt đối) làm đứt bài kiểm
"dùng fake mà không nạp config thật".
Import thẳng module cần dùng:
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
from cowork_local.tests.fakes.fake_provider import FakeProvider
"""
+113
View File
@@ -0,0 +1,113 @@
"""Fake LLM Provider for offline unit, contract, and characterization testing.
Provides deterministic responses, stream simulation, tool-call dispatching,
and fault injection without requiring any external network access or API keys.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from providers.base import CancelFn, Provider, ProviderError, TextCallback, ToolSpec
class FakeProvider(Provider):
"""Deterministic test double mimicking real LLM Providers (OpenAI, Anthropic, Ollama)."""
name = "fake"
supports_vision = True
def __init__(self, conf: Optional[Dict[str, Any]] = None) -> None:
# Initialize base provider with default configuration if none provided
super().__init__(conf or {"model": "fake-model-v1"})
# History of all message batches sent across all chat calls
self.call_history: List[List[Dict[str, Any]]] = []
# Queue of programmed assistant responses to return sequentially
self.response_queue: List[Dict[str, Any]] = []
# Queue of exceptions to raise on corresponding calls
self.error_queue: List[Exception] = []
# Default text returned when response queue is empty
self.default_text: str = "Fake model response."
# Total number of chat invocations
self.call_count: int = 0
# Recorded tool specs passed into each turn
self.last_tools: Optional[List[ToolSpec]] = None
def queue_response(
self,
content: str = "",
tool_calls: Optional[List[Dict[str, Any]]] = None,
reasoning: Optional[str] = None,
chunks: Optional[List[str]] = None,
) -> FakeProvider:
"""Enqueue a pre-configured response structure for upcoming chat turns."""
self.response_queue.append({
"content": content,
"tool_calls": tool_calls or [],
"reasoning": reasoning,
"chunks": chunks or ([content] if content else []),
})
return self
def queue_error(self, exc: Exception) -> FakeProvider:
"""Enqueue an exception to simulate network/API errors on the next turn."""
self.error_queue.append(exc)
return self
def chat(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[ToolSpec]] = None,
on_text: Optional[TextCallback] = None,
cancel: Optional[CancelFn] = None,
on_reasoning: Optional[TextCallback] = None,
) -> Dict[str, Any]:
"""Simulate single LLM turn with full streaming and tool-call support."""
self.call_count += 1
self.call_history.append([dict(m) for m in messages])
self.last_tools = tools
# 1. Check for injected errors
if self.error_queue:
raise self.error_queue.pop(0)
# 2. Check early cancellation before processing
if cancel and cancel():
raise ProviderError("Execution aborted by user cancel signal before response generation.")
# 3. Retrieve queued response or construct default response
if self.response_queue:
resp_spec = self.response_queue.pop(0)
content = resp_spec.get("content", "")
tool_calls = resp_spec.get("tool_calls", [])
reasoning = resp_spec.get("reasoning")
chunks = resp_spec.get("chunks", [content] if content else [])
else:
content = self.default_text
tool_calls = []
reasoning = None
chunks = [content]
# 4. Stream reasoning chunks if provided
if reasoning and on_reasoning:
on_reasoning(reasoning)
# 5. Stream text chunks, checking cancellation between fragments
for chunk in chunks:
if cancel and cancel():
raise ProviderError("Execution cancelled during text chunk streaming.")
if on_text and chunk:
on_text(chunk)
# 6. Return canonical assistant message payload
assistant_msg: Dict[str, Any] = {
"role": "assistant",
"content": content,
}
if tool_calls:
assistant_msg["tool_calls"] = tool_calls
return assistant_msg
def list_models(self) -> List[str]:
"""Return available mock models for settings and validation tests."""
return ["fake-model-v1", "fake-reasoner-pro", "fake-vision-plus"]
+71
View File
@@ -0,0 +1,71 @@
"""Fake Tool Executor for isolated, offline agent tool-call verification.
Allows tests to verify tool invocation arguments, mock tool return values,
and simulate failures/delays without performing unsafe host disk or OS operations.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
class FakeToolExecutor:
"""Mock execution engine for agent tool-call dispatching."""
def __init__(self) -> None:
# History of all executed tool invocations: List of {"name": str, "args": dict, "result": dict}
self.call_log: List[Dict[str, Any]] = []
# Custom handlers registered per tool name
self.handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
# Pre-programmed fixed responses keyed by tool name
self.mock_responses: Dict[str, Dict[str, Any]] = {}
# Default response when no specific handler or response is found
self.default_result: Dict[str, Any] = {"ok": True, "output": "Fake tool executed successfully."}
def register_handler(
self,
tool_name: str,
handler: Callable[[Dict[str, Any]], Dict[str, Any]],
) -> FakeToolExecutor:
"""Register a dynamic handler function for a specific tool name."""
self.handlers[tool_name] = handler
return self
def set_mock_response(
self,
tool_name: str,
result: Dict[str, Any],
) -> FakeToolExecutor:
"""Set a static return payload for a specific tool name."""
self.mock_responses[tool_name] = result
return self
def execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a tool call using registered mocks and record invocation details."""
# 1. Resolve result from handler, preset response, or default fallback
if tool_name in self.handlers:
result = self.handlers[tool_name](arguments)
elif tool_name in self.mock_responses:
result = self.mock_responses[tool_name]
else:
result = dict(self.default_result)
result["tool"] = tool_name
result["received_args"] = arguments
# 2. Record execution trace for post-test assertions
self.call_log.append({
"name": tool_name,
"args": dict(arguments),
"result": dict(result),
})
return result
def get_calls_for(self, tool_name: str) -> List[Dict[str, Any]]:
"""Retrieve all recorded calls for a given tool name."""
return [call for call in self.call_log if call["name"] == tool_name]
def reset(self) -> None:
"""Clear recorded logs and registered mock responses."""
self.call_log.clear()
self.handlers.clear()
self.mock_responses.clear()
+141
View File
@@ -0,0 +1,141 @@
"""Offline test doubles for the R04 turn runtime seams.
Sits beside ``fake_provider.py``/``fake_tool_executor.py`` (R01-T02) and plays
the same role one level up: those fake a *provider*, these fake the ports
``ConversationApplicationService`` is driven through
(``application/conversations/turn_runtime.py``).
Deliberately dumb — they record what they were asked and return canned answers.
A failing test then points at the service under test rather than at a mock
framework's configuration.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from cowork_local.domain.agents.agent_event import ToolPreview
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
class FakeSpec:
"""An advertised tool. The service only ever reads ``.name`` off a spec."""
def __init__(self, name: str) -> None:
self.name = name
class FakeReply:
"""One programmed provider answer."""
def __init__(self, content: str = "", tool_calls=None, chunks=None, reasoning: str = ""):
self.content = content
self.tool_calls = tool_calls or []
# Default to streaming the whole content as a single chunk, which is what
# a non-streaming gateway effectively does.
self.chunks = chunks if chunks is not None else ([content] if content else [])
self.reasoning = reasoning
class FakeModelCall:
""":class:`ModelCallPort` returning programmed replies in order.
A programmed entry may be an exception instead of a reply, which is how a
test simulates the gateway dying mid-turn.
"""
def __init__(self, replies: List[Any]) -> None:
self.replies = list(replies)
self.calls: List[Dict[str, Any]] = []
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
# Snapshot the messages: the service keeps mutating its own list, so
# storing it by reference would make every recorded call look identical.
self.calls.append({"messages": [dict(m) for m in messages],
"tool_names": [getattr(t, "name", "") for t in tools]})
reply = self.replies.pop(0) if self.replies else FakeReply(content="(default)")
if isinstance(reply, BaseException):
raise reply
if reply.reasoning and on_reasoning:
on_reasoning(reply.reasoning)
for chunk in reply.chunks:
if on_text and chunk:
on_text(chunk)
assistant: Dict[str, Any] = {"role": "assistant", "content": reply.content}
if reply.tool_calls:
assistant["tool_calls"] = reply.tool_calls
return assistant
class FakeToolRuntime:
""":class:`ToolRuntimePort` over an imaginary output folder."""
def __init__(self, specs=("save_file", "run_command", "update_plan"),
results: Optional[Dict[str, Dict[str, Any]]] = None,
removed: Tuple[str, ...] = (), added: Tuple[str, ...] = ()) -> None:
self._specs = [FakeSpec(n) for n in specs]
self._results = results or {}
self._removed, self._added = removed, added
self.executed: List[Tuple[str, Dict[str, Any]]] = []
self.finalize_calls: List[Dict[str, Any]] = []
# When set, every executed tool streams this string through ``on_output``.
self.emit_output: Optional[str] = None
def specs(self, allowed_tools=None):
if allowed_tools is None:
return list(self._specs)
return [s for s in self._specs if s.name in allowed_tools]
def preview(self, name, args):
return ToolPreview(kind="info", title=name, text=str(args))
def execute(self, name, args, on_output=None, cancel=None):
self.executed.append((name, dict(args)))
if self.emit_output and on_output:
on_output(self.emit_output)
return dict(self._results.get(name, {"ok": True, "output": f"{name} ok"}))
def snapshot(self):
return "before"
def finalize(self, before, cancelled=False):
self.finalize_calls.append({"before": before, "cancelled": cancelled})
return list(self._removed), list(self._added)
# --------------------------------------------------------------------------- #
# Small helpers shared by the turn tests.
# --------------------------------------------------------------------------- #
def make_request(**overrides) -> ConversationExecutionRequest:
"""A minimal valid request; each test overrides only what it exercises."""
base: Dict[str, Any] = {"turn_id": "t1", "session_id": "s1", "prompt": "do it"}
base.update(overrides)
return ConversationExecutionRequest(**base)
def run_turn(service, request=None, cancel=None):
"""Execute a turn and return ``(result, events)``."""
events: List[Any] = []
result = service.execute(request or make_request(), events.append, cancel=cancel)
return result, events
def events_of_type(events, cls):
"""Every emitted event of one type, in order."""
return [e for e in events if isinstance(e, cls)]
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
"""A turn that calls one tool and then answers — ``(model, tools)``."""
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
FakeReply(content="done")])
return model, FakeToolRuntime(**tool_kwargs)
__all__ = [
"FakeSpec", "FakeReply", "FakeModelCall", "FakeToolRuntime",
"make_request", "run_turn", "events_of_type", "tool_turn",
]
+7
View File
@@ -0,0 +1,7 @@
"""Integration tests: several real layers wired together, still fully offline.
Where unit tests pin one class against fakes and contract tests pin an interface
across implementations, these exercise a real path end to end — e.g. the
application routing service on top of the real ``core/routing`` engine — so a
seam that only works against a mock is caught here.
"""
@@ -0,0 +1,113 @@
"""R04-T02 — the typed event vocabulary vs. what the real runtime emits.
The unit tests pin each event against the shape I *read* out of
``core/chat_agent.py``. This one removes the reading: it runs the actual
``run_cowork`` loop offline (FakeProvider, real tool execution, real cleanup)
and asserts every dict it emits is recognised by :func:`from_legacy_dict` and
survives a round trip byte-for-byte.
That makes it a guard against the two failure modes a hand-written vocabulary
has: an event type nobody modelled, and a key that silently changes meaning.
Either one would surface here as a failure instead of as a blank chat bubble
after R04-T03 starts routing events through the typed layer.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import pytest
from cowork_local.core import chat_agent
from cowork_local.domain.agents.agent_event_codec import from_legacy_dict
from cowork_local.tests.fakes.fake_provider import FakeProvider
def _run_turn_and_collect(tmp_path: Path, provider: FakeProvider) -> List[Dict[str, Any]]:
"""Run one real ``run_cowork`` turn offline and return every emitted dict."""
output_dir = tmp_path / "output"
output_dir.mkdir(parents=True, exist_ok=True)
emitted: List[Dict[str, Any]] = []
chat_agent.run_cowork(
provider=provider,
messages=[{"role": "user", "content": "make me a report"}],
output_dir=output_dir,
emit=emitted.append,
# security_config=None disables the AI guardrail layers, which is the
# documented behaviour for headless callers and keeps this test offline.
security_config=None,
title="Report",
)
return emitted
def _reporting_turn(tmp_path: Path) -> List[Dict[str, Any]]:
"""A turn that streams text, calls save_file, then answers — the common path."""
provider = FakeProvider()
provider.queue_response(
content="Writing it now.",
chunks=["Writing ", "it now."],
tool_calls=[{"id": "call_1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved to report.md.", chunks=["Saved to report.md."])
return _run_turn_and_collect(tmp_path, provider)
def test_the_runtime_emits_only_event_types_the_domain_layer_models(tmp_path: Path) -> None:
emitted = _reporting_turn(tmp_path)
unmodelled = sorted({e["type"] for e in emitted if from_legacy_dict(e) is None})
assert unmodelled == [], f"run_cowork emits event types R04-T02 does not model: {unmodelled}"
def test_every_emitted_event_round_trips_without_losing_a_key(tmp_path: Path) -> None:
emitted = _reporting_turn(tmp_path)
assert emitted, "the turn produced no events at all — the fixture is wrong"
for raw in emitted:
event = from_legacy_dict(raw)
assert event is not None, raw
assert event.to_legacy_dict() == raw, f"round trip changed the {raw['type']} event"
def test_a_tool_using_turn_really_exercises_the_tool_events(tmp_path: Path) -> None:
# Guards the test above from passing trivially: if the fixture ever stopped
# calling a tool, the round-trip check would only cover text events.
types = {e["type"] for e in _reporting_turn(tmp_path)}
assert {"text", "assistant_done", "tool_proposed", "tool_result"} <= types
def test_reasoning_events_from_a_thinking_model_round_trip(tmp_path: Path) -> None:
# A separate fixture because only reasoning models emit these, and the
# common-path turn above would otherwise never cover the event.
provider = FakeProvider()
provider.queue_response(content="42", chunks=["42"], reasoning="Let me think...")
emitted = _run_turn_and_collect(tmp_path, provider)
reasoning_events = [e for e in emitted if e["type"] == "reasoning"]
assert reasoning_events, "a reasoning model produced no reasoning event"
for raw in reasoning_events:
assert from_legacy_dict(raw).to_legacy_dict() == raw
def test_plan_events_from_the_real_update_plan_tool_round_trip(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Planning.",
tool_calls=[{"id": "call_1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"},
{"title": "Review", "status": "pending"}]}}],
)
provider.queue_response(content="Done.")
emitted = _run_turn_and_collect(tmp_path, provider)
plan_events = [e for e in emitted if e["type"] == "plan_set"]
assert plan_events, "update_plan did not produce a plan_set event"
for raw in plan_events:
assert from_legacy_dict(raw).to_legacy_dict() == raw
@@ -0,0 +1,207 @@
"""R04-T03 (c) — the service must behave exactly like ``run_cowork``.
The unit tests prove the loop follows the rules I wrote down. They cannot prove
those rules are the ones the shipped runtime actually follows. This file does:
each test scripts one provider, runs the SAME turn twice — once through
``core/chat_agent.py::run_cowork``, once through
``ConversationApplicationService`` wired by ``core_runtime_adapter`` — and
compares the emitted event stream, the resulting conversation and the tool list
the model was shown.
Anything the port got wrong (a missing event, a reordered guard, a different
tool set, a changed message) fails here rather than in front of a user. The only
allowed difference is the extra ``turn_completed`` event R04 introduces, which
has no legacy consumer.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from cowork_local.application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from cowork_local.core import chat_agent
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
from cowork_local.tests.fakes.fake_provider import FakeProvider
_USER_TURN = [{"role": "user", "content": "make me a report"}]
class _FakeGate:
"""Stands in for ``core/permissions.py::PermissionGate``."""
def __init__(self, approve: bool) -> None:
self.approve = approve
self.requests: List[Dict[str, Any]] = []
def request(self, action: Dict[str, Any]) -> bool:
self.requests.append(action)
return self.approve
def _normalise(events: List[Dict[str, Any]], out_dir: Path) -> List[Dict[str, Any]]:
"""Replace the run's own output path with a placeholder.
The two runs write into different temp folders, so absolute paths in
``tool_result``/``outputs_*`` events differ by construction. Everything else
must match verbatim.
"""
marker, raw = "<OUT>", str(out_dir)
def scrub(value: Any) -> Any:
if isinstance(value, str):
return value.replace(raw, marker).replace(raw.replace("\\", "/"), marker)
if isinstance(value, list):
return [scrub(v) for v in value]
if isinstance(value, dict):
return {k: scrub(v) for k, v in value.items()}
return value
return [scrub(e) for e in events]
def _run_legacy(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
gate: Optional[_FakeGate] = None, max_steps: int = 30
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
"""Run the turn through the existing ``run_cowork``."""
out_dir = tmp_path / "legacy"
out_dir.mkdir(parents=True, exist_ok=True)
events: List[Dict[str, Any]] = []
messages = [dict(m) for m in _USER_TURN]
chat_agent.run_cowork(
provider, messages, out_dir, events.append, title="Report",
security_config=None, allowed_tools=allowed_tools, gate=gate, max_steps=max_steps,
)
tool_names = [t.name for t in (provider.last_tools or [])]
return _normalise(events, out_dir), messages, tool_names
def _run_service(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
gate: Optional[_FakeGate] = None, max_steps: int = 30
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
"""Run the same turn through the application service."""
out_dir = tmp_path / "service"
out_dir.mkdir(parents=True, exist_ok=True)
events: List[Dict[str, Any]] = []
service = build_cowork_conversation_service(
provider, out_dir, events.append, title="Report", security_config=None, gate=gate)
request = ConversationExecutionRequest(
turn_id="t1", session_id="s1",
# run_cowork receives the user message already appended; the request
# carries the history and this turn's prompt separately.
messages=_USER_TURN[:-1], prompt=_USER_TURN[-1]["content"],
output_dir=out_dir, allowed_tools=allowed_tools, max_steps=max_steps,
gate_mode="confirm" if gate is not None else "auto",
)
result = service.execute(request, legacy_event_sink(events.append))
# The end-of-turn event is new in R04 and has no legacy counterpart.
kept = [e for e in events if e.get("type") != "turn_completed"]
tool_names = [t.name for t in (provider.last_tools or [])]
return _normalise(kept, out_dir), list(result.messages), tool_names
def _assert_parity(tmp_path: Path, script, *, approve: Optional[bool] = None, **kwargs) -> None:
"""Script two identical providers, run both paths, compare everything."""
legacy_provider, service_provider = FakeProvider(), FakeProvider()
script(legacy_provider)
script(service_provider)
legacy_gate = _FakeGate(approve) if approve is not None else None
service_gate = _FakeGate(approve) if approve is not None else None
legacy_events, legacy_messages, legacy_tools = _run_legacy(
tmp_path, legacy_provider, gate=legacy_gate, **kwargs)
service_events, service_messages, service_tools = _run_service(
tmp_path, service_provider, gate=service_gate, **kwargs)
assert service_events == legacy_events
assert service_messages == legacy_messages
assert service_tools == legacy_tools
if legacy_gate is not None and service_gate is not None:
assert [r["name"] for r in service_gate.requests] == \
[r["name"] for r in legacy_gate.requests]
# --------------------------------------------------------------------------- #
# Scenarios.
# --------------------------------------------------------------------------- #
def test_a_plain_answer_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
_assert_parity(tmp_path, script)
def test_a_save_file_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Writing it.",
tool_calls=[{"id": "c1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved.")
_assert_parity(tmp_path, script)
def test_an_update_plan_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Planning.",
tool_calls=[{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"},
{"title": "Ship", "status": "pending"}]}}],
)
provider.queue_response(content="Done.")
_assert_parity(tmp_path, script)
def test_a_reasoning_only_reply_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="", reasoning="thinking hard")
_assert_parity(tmp_path, script)
def test_restricting_the_tool_scope_advertises_the_same_tools(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="ok")
_assert_parity(tmp_path, script, allowed_tools=["save_file"])
def test_a_rejected_command_behaves_identically(tmp_path: Path) -> None:
# The security-critical path: the gate says no, so the command must never
# run and the model must read back the same refusal in both designs.
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Running it.",
tool_calls=[{"id": "c1", "name": "run_command",
"arguments": {"command": "echo hi"}}],
)
provider.queue_response(content="Understood.")
_assert_parity(tmp_path, script, approve=False)
def test_hitting_the_step_ceiling_behaves_identically(tmp_path: Path) -> None:
# The model never stops calling tools, so both paths must stop at the same
# place and say so the same way.
def script(provider: FakeProvider) -> None:
for i in range(4):
provider.queue_response(
content=f"step {i}",
tool_calls=[{"id": f"c{i}", "name": "save_file",
"arguments": {"filename": f"f{i}.md", "content": "x"}}],
)
_assert_parity(tmp_path, script, max_steps=2)
+220
View File
@@ -0,0 +1,220 @@
"""R04-T04 — the migrated Cowork call site, exercised end to end without Qt.
``CoworkTab.build_job`` only ever *reads attributes* off its widget, so the real
production method can be invoked against a stand-in that supplies those
attributes. That is what happens here: the actual ``build_job`` body runs, builds
a request, wires the service through ``core_runtime_adapter``, and drives a real
turn (real tool execution, real output-folder cleanup) against ``FakeProvider``.
Why it matters: this is the only automated check that the widget's contract with
the service still holds — that the worker's list is appended to in place (the
transcript re-render and history merge both read it), that events still arrive as
legacy dicts, and that a produced file really lands in the turn's folder. None of
it needs a display server, so it runs in CI like every other test.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.tests.fakes.fake_provider import FakeProvider
class _FakeWorker:
"""The parts of ``core/worker.py::AgentWorker`` a job actually touches."""
def __init__(self, approve_commands: bool = True) -> None:
self.events: List[Dict[str, Any]] = []
self.gate: Optional[Any] = None
self._approve = approve_commands
self.cancelled = False
def emit_event(self, event: Dict[str, Any]) -> None:
self.events.append(event)
def is_cancelled(self) -> bool:
return self.cancelled
def new_gate(self, mode: str, agent_role: str = "") -> Any:
# Mirrors AgentWorker.new_gate: the gate is stored on the worker so the
# UI thread can resolve it, and answers request() from the worker thread.
worker = self
class _Gate:
requests: List[Dict[str, Any]] = []
def request(self, action: Dict[str, Any]) -> bool:
self.requests.append(action)
return worker._approve
self.gate = _Gate()
return self.gate
class _FakeCtx:
"""The ``AppContext`` surface ``build_job`` uses."""
def __init__(self, config: AppConfig, confirm_commands: bool = False) -> None:
self.config = config
self._confirm = confirm_commands
def project_confirm_commands(self) -> bool:
return self._confirm
def build_mcp_tools(self):
return [], None
class _WidgetStub:
"""Stands in for the CoworkTab instance ``build_job`` reads its state from."""
kind = "cowork"
def __init__(self, out_root: Path, ctx: _FakeCtx, provider: FakeProvider) -> None:
self._out_root = out_root
self.ctx = ctx
self._provider = provider
self.title = "Report"
self.session_id = "s1"
self.project_id = "" # the auto-seeded default workspace
self._model = ""
self._routed_provider = None
self._routed_model = None
def _session_output_dir(self) -> Path:
return self._out_root
def workspace_dir(self) -> Path:
return self._out_root
def admin_agent_prompt(self) -> str:
return ""
def build_provider(self) -> FakeProvider:
return self._provider
def _config() -> AppConfig:
"""A real AppConfig that never touches ``~/.cowork_local``.
The AI security guardrails are switched off: they would call the model to
review the prompt, which is a separate feature with its own tests and would
make this one depend on what the fake answers.
"""
data = copy.deepcopy(DEFAULT_CONFIG)
data["agent_security"]["enabled"] = False
return AppConfig(data)
def _run_turn(tmp_path: Path, provider: FakeProvider, messages: List[Dict[str, Any]],
*, confirm_commands: bool = False, approve: bool = True):
"""Invoke the real ``CoworkTab.build_job`` against the stub and run its job."""
from cowork_local.ui.cowork_tab import CoworkTab
out_dir = tmp_path / ".turns" / "t1"
out_dir.mkdir(parents=True, exist_ok=True)
widget = _WidgetStub(tmp_path, _FakeCtx(_config(), confirm_commands), provider)
worker = _FakeWorker(approve_commands=approve)
job = CoworkTab.build_job(widget, "make me a report", messages, out_dir)
result = job(worker)
return result, worker
def test_the_turn_runs_and_reports_its_folder(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
messages = [{"role": "user", "content": "make me a report"}]
result, worker = _run_turn(tmp_path, provider, messages)
assert result["turn_dir"] == str(tmp_path / ".turns" / "t1")
assert [e["type"] for e in worker.events] == [
"text", "text", "assistant_done", "turn_completed"]
def test_the_worker_list_is_appended_to_in_place(tmp_path: Path) -> None:
# _reattach_running_turn replays from this very list while the turn runs, and
# _finalize_turn slices it by the pre-turn length afterwards.
provider = FakeProvider()
provider.queue_response(content="Done.")
user = {"role": "user", "content": "make me a report"}
messages = [user]
result, _ = _run_turn(tmp_path, provider, messages)
assert result["messages"] is messages
# Identity, not just equality: _reattach_running_turn locates the turn's user
# message with ``m is ctx["user_msg"]`` to replay the steps after it.
assert any(m is user for m in messages)
# Several system blocks are expected — the tool prompt plus the tagged
# skills/security-rules blocks the runtime refreshes on every turn.
assert [m["role"] for m in messages if m["role"] != "system"] == ["user", "assistant"]
assert messages[-1]["content"] == "Done."
def test_a_saved_file_lands_in_the_turn_folder(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Writing it.",
tool_calls=[{"id": "c1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved.")
messages = [{"role": "user", "content": "make me a report"}]
_, worker = _run_turn(tmp_path, provider, messages)
produced = list((tmp_path / ".turns" / "t1").glob("*.md"))
assert len(produced) == 1
assert produced[0].read_text(encoding="utf-8") == "# Report\n"
results = [e for e in worker.events if e["type"] == "tool_result"]
assert results and results[0]["ok"] is True
def test_auto_run_mode_never_creates_a_permission_gate(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="ok")
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "hi"}],
confirm_commands=False)
assert worker.gate is None
def test_confirm_mode_creates_the_gate_and_a_refusal_stops_the_command(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Running it.",
tool_calls=[{"id": "c1", "name": "run_command",
"arguments": {"command": "echo hi"}}],
)
provider.queue_response(content="Understood.")
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "run it"}],
confirm_commands=True, approve=False)
assert worker.gate is not None
refusals = [e for e in worker.events
if e["type"] == "tool_result" and e["output"] == "Rejected by user."]
assert len(refusals) == 1
def test_cancelling_before_the_turn_starts_calls_no_model(tmp_path: Path) -> None:
from cowork_local.ui.cowork_tab import CoworkTab
provider = FakeProvider()
provider.queue_response(content="never")
out_dir = tmp_path / ".turns" / "t1"
out_dir.mkdir(parents=True)
widget = _WidgetStub(tmp_path, _FakeCtx(_config()), provider)
worker = _FakeWorker()
worker.cancelled = True
CoworkTab.build_job(widget, "x", [{"role": "user", "content": "x"}], out_dir)(worker)
assert provider.call_count == 0
@@ -0,0 +1,249 @@
"""R03-T03/T04/T05 — the unified routing path over the REAL routing engine.
The unit tests drive ``RoutingApplicationService`` against fakes; this suite
proves the same service produces correct outcomes on top of the actual
``core/routing`` stack (classifier → assessment store → scorer → selector →
switch controller), which is what the three chat surfaces now call.
Offline by construction: a fake probe client answers benchmarks and judging, and
the assessment store is a temp file — no network, no Qt, no ``$HOME`` writes.
"""
from __future__ import annotations
import copy
import pytest
from cowork_local.application.model_routing import (
AppContextModeResolver,
CoreRoutingEngine,
RoutingApplicationService,
RoutingMode,
RoutingRequest,
)
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.core import projects as projects_mod
from cowork_local.core.routing.clients import CompletionResult
from cowork_local.core.routing.service import RoutingService
from cowork_local.core.routing.store import AssessmentStore
from cowork_local.state import AppContext
STRONG_ANSWER = "STRONG-DETAILED-CORRECT-ANSWER"
WEAK_ANSWER = "weak"
class FakeProbeClient:
"""Deterministic stand-in for the provider layer used during assessment.
Mirrors ``tests/routing/test_service.py``'s client: benchmark prompts get a
per-model canned answer, and judge prompts are graded by looking up that
answer, so scores are stable and no model is ever really called.
"""
def __init__(self, answers, quality) -> None:
self.answers = answers
self.quality = quality
def complete(self, provider, model_id, messages) -> CompletionResult:
text = messages[0]["content"]
if "grading an AI assistant" in text: # the judge rubric prompt
score = 0.0
for answer, value in self.quality.items():
if answer and answer in text:
score = value
break
return CompletionResult(text='{"score": %s}' % score)
answer = self.answers.get((provider, model_id))
if answer is None:
return CompletionResult(error="unavailable")
return CompletionResult(text=answer, tokens_out=len(answer) // 4)
@pytest.fixture()
def ctx(tmp_path, monkeypatch):
"""An AppContext with two assessable models and temp-only persistence."""
# Keep workspace load/save off the developer's real ~/.cowork_local.
monkeypatch.setattr(projects_mod, "PROJECTS_DIR", tmp_path / "projects")
data = copy.deepcopy(DEFAULT_CONFIG)
data["providers"] = {
"anthropic": {"base_url": "x", "api_key": "x", "model": "strong-model"},
}
data["routing"]["candidates"] = [
{"provider": "anthropic", "model_id": "strong-model", "tier": "powerful"},
{"provider": "anthropic", "model_id": "weak-model", "tier": "fast"},
]
data["routing"]["judge_provider"] = "anthropic"
data["routing"]["judge_model"] = "judge-model"
data["routing"]["policy"] = "quality"
data["routing"]["min_score_gain"] = 0.05
return AppContext(AppConfig(data=data, path=tmp_path / "config.json"))
@pytest.fixture()
def routing_service(ctx, tmp_path) -> RoutingService:
"""A real RoutingService with a populated assessment store."""
client = FakeProbeClient(
answers={
("anthropic", "strong-model"): STRONG_ANSWER,
("anthropic", "weak-model"): WEAK_ANSWER,
},
quality={STRONG_ANSWER: 0.95, WEAK_ANSWER: 0.35},
)
store = AssessmentStore(store_path=tmp_path / "assess.json",
history_dir=tmp_path / "history")
service = RoutingService(ctx, store=store, client=client)
service.reassess() # populate real probe results + fit scores
return service
@pytest.fixture()
def app_service(ctx, routing_service) -> RoutingApplicationService:
"""The application service wired exactly the way the UI wires it."""
return RoutingApplicationService(
CoreRoutingEngine(routing_service),
AppContextModeResolver(ctx),
confirm_timeout_sec=lambda: float(ctx.config.routing["confirm_timeout_sec"]),
)
def coding_request(**overrides) -> RoutingRequest:
"""A coding turn currently pinned to the weaker model."""
fields = dict(
surface="cowork",
prompt="Write a Python function to reverse a linked list",
current_provider="anthropic",
current_model="weak-model",
)
fields.update(overrides)
return RoutingRequest(**fields)
# --------------------------------------------------------------------------- #
# Auto / Off / Manual over the real engine
# --------------------------------------------------------------------------- #
def test_auto_switches_to_the_better_assessed_model(app_service) -> None:
"""The real scorer must rank the strong model first and the service must
hand that model back as this turn's override."""
outcome = app_service.resolve(coding_request(mode=RoutingMode.AUTO))
assert outcome.switched is True
assert outcome.provider == "anthropic"
assert outcome.model == "strong-model"
assert outcome.task_type == "coding" # classified from the prompt
assert outcome.score_gain > 0
def test_off_keeps_the_pinned_model(app_service) -> None:
"""Off must not switch even when a clearly better model is assessed."""
outcome = app_service.resolve(coding_request(mode=RoutingMode.OFF))
assert outcome.switched is False
assert outcome.provider is None
def test_manual_asks_before_switching(app_service) -> None:
"""The confirm callback receives the engine's own decision object, which is
what ``ui/routing_toggle.py::confirm_switch`` renders."""
seen: list = []
outcome = app_service.resolve(
coding_request(mode=RoutingMode.MANUAL),
confirm=lambda decision, timeout: seen.append((decision, timeout)) or True,
)
assert outcome.switched is True
decision, timeout = seen[0]
assert decision.to_model == "anthropic/strong-model"
assert decision.reason # human-readable explanation
assert timeout == pytest.approx(60.0) # from DEFAULT_CONFIG
def test_manual_decline_keeps_the_pinned_model(app_service) -> None:
outcome = app_service.resolve(
coding_request(mode=RoutingMode.MANUAL),
confirm=lambda decision, timeout: False,
)
assert outcome.switched is False
assert outcome.declined is True
def test_already_best_model_is_left_alone(app_service) -> None:
"""No pointless churn: being on the best model is not a switch."""
outcome = app_service.resolve(
coding_request(mode=RoutingMode.AUTO, current_model="strong-model"))
assert outcome.switched is False
# --------------------------------------------------------------------------- #
# Fallback over the real engine
# --------------------------------------------------------------------------- #
def test_fallback_keeps_an_assessed_model_even_though_a_better_one_exists(app_service) -> None:
"""weak-model IS usable (it has a real probe score), so Fallback stays put
where Auto would switch — the behavioural difference between the modes."""
outcome = app_service.resolve(coding_request(mode=RoutingMode.FALLBACK))
assert outcome.switched is False
def test_fallback_rescues_a_model_the_engine_cannot_serve(app_service) -> None:
"""A model absent from the ranking (never assessed / unavailable) is exactly
the situation Fallback exists for."""
outcome = app_service.resolve(
coding_request(mode=RoutingMode.FALLBACK, current_model="ghost-model"))
assert outcome.switched is True
assert outcome.model == "strong-model"
# --------------------------------------------------------------------------- #
# Surface parity — the point of R03-T04/T05
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("surface", ["cowork", "co4e", "ai_edit"])
def test_every_surface_gets_the_same_decision(app_service, surface) -> None:
"""Chat, Co4E and AI-Edit used to hold three copies of this logic. Given the
same inputs they must now be indistinguishable."""
outcome = app_service.resolve(coding_request(surface=surface, mode=RoutingMode.AUTO))
assert outcome.switched is True
assert outcome.model == "strong-model"
def test_ai_edit_pinned_task_type_reaches_the_engine(app_service) -> None:
"""AI-Edit pins "coding" instead of classifying; the engine must honour it
even when the instruction text reads like something else entirely."""
outcome = app_service.resolve(coding_request(
surface="ai_edit",
prompt="Write a poem about the ocean", # classifier would say "creative"
task_type="coding",
mode=RoutingMode.AUTO,
))
assert outcome.task_type == "coding"
def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None:
"""With no explicit mode, the service reads the per-workspace setting — the
lookup the widgets used to do themselves."""
ctx.config.data["routing"]["switch_mode"] = "auto"
outcome = app_service.resolve(coding_request())
assert outcome.mode is RoutingMode.AUTO
assert outcome.switched is True
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
"""The new mode must be persistable, or the toggle could never select it."""
ctx.config.set_routing_mode_for("cowork", "fallback")
assert ctx.config.routing_mode_for("cowork") == "fallback"
assert ctx.project_routing_mode("cowork") == "fallback"
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
"""A hand-edited config must not enable routing by accident."""
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
assert ctx.config.routing_mode_for("cowork") == "off"
@@ -0,0 +1,191 @@
"""R04-T05 — the Schedule Task runner's cowork branch, pinned before and after.
Written against the CURRENT ``_run_agent`` first, as the safety net for moving it
onto ``ConversationApplicationService``: an unattended run has five behaviours the
interactive path does not have (the plan reminder prefixed to the prompt, the
session registered in History before the model starts, a re-save after every
assistant message, the timeout notice, and the "did the agent's own checklist
finish?" report), and none of them was covered by a test.
Everything is isolated from the user's real config: history goes to ``tmp_path``
via ``history.custom_dir`` and the AI guardrails are off, so no run touches
``~/.cowork_local`` or calls a model to review a prompt.
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.core import task_executors
from cowork_local.tests.fakes.fake_provider import FakeProvider
class _FakeCtx:
"""The ``AppContext`` surface ``_run_agent`` touches."""
def __init__(self, config: AppConfig, provider: FakeProvider) -> None:
self.config = config
self._provider = provider
def build_active_provider(self) -> FakeProvider:
return self._provider
def build_provider_for(self, name=None, model=None) -> FakeProvider:
return self._provider
def _config(tmp_path: Path) -> AppConfig:
data = copy.deepcopy(DEFAULT_CONFIG)
# Keep the run entirely offline and off the real config dir.
data["agent_security"]["enabled"] = False
data["history"]["custom_dir"] = str(tmp_path / "history")
return AppConfig(data)
def _run(tmp_path: Path, provider: FakeProvider, *, prompt: str = "write the report",
timeout_sec: Optional[int] = None, admin_agent: Any = None):
"""Run one cowork task and return ``(result_tuple, events, config)``."""
out_dir = tmp_path / "run"
out_dir.mkdir(parents=True, exist_ok=True)
config = _config(tmp_path)
events: List[Dict[str, Any]] = []
result = task_executors._run_agent(
_FakeCtx(config, provider), "cowork", prompt, out_dir,
events.append, lambda: False, title="Weekly report",
timeout_sec=timeout_sec, admin_agent=admin_agent,
)
return result, events, config
def _saved_conversation(config: AppConfig) -> Dict[str, Any]:
"""The single conversation the run wrote into the isolated history folder."""
files = list(Path(config.history_dir()).rglob("*.json"))
assert len(files) == 1, f"expected one saved conversation, found {files}"
return json.loads(files[0].read_text(encoding="utf-8"))
# --------------------------------------------------------------------------- #
def test_a_cowork_task_returns_the_final_answer(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="Report is ready.")
(answer, timed_out, incomplete), _events, _config = _run(tmp_path, provider)
assert answer == "Report is ready."
assert timed_out is False
assert incomplete == ""
def test_the_plan_reminder_is_prefixed_to_the_prompt(tmp_path: Path) -> None:
# An unattended run has nobody watching, so the agent is pushed to keep its
# own checklist honest. The reminder must lead the message.
provider = FakeProvider()
provider.queue_response(content="ok")
_run(tmp_path, provider, prompt="write the report")
sent = provider.call_history[0][-1]["content"]
assert sent.startswith("This runs unattended (Schedule Task)")
assert sent.endswith("write the report")
def test_an_admin_agent_persona_sits_between_the_reminder_and_the_prompt(
tmp_path: Path) -> None:
class _Agent:
# An admin agent may pin its own provider/model; blank means "use the
# machine's Settings default", which is what build_agent_provider reads.
provider = ""
model = ""
def effective_prompt(self) -> str:
return "You are the reporting agent."
provider = FakeProvider()
provider.queue_response(content="ok")
_run(tmp_path, provider, prompt="write the report", admin_agent=_Agent())
sent = provider.call_history[0][-1]["content"]
assert sent.index("This runs unattended") < sent.index("You are the reporting agent.")
assert sent.index("You are the reporting agent.") < sent.index("write the report")
def test_the_session_is_announced_once_it_exists_on_disk(tmp_path: Path) -> None:
# The scheduler refreshes History on this event, so it must not fire before
# the conversation is really there.
provider = FakeProvider()
provider.queue_response(content="ok")
_result, events, config = _run(tmp_path, provider)
ready = [e for e in events if e["type"] == "history_ready"]
assert len(ready) == 1
assert ready[0]["session_id"]
assert _saved_conversation(config)["session_id"] == ready[0]["session_id"]
def test_the_saved_conversation_carries_the_answer_and_the_task_title(
tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="Report is ready.")
_result, _events, config = _run(tmp_path, provider)
saved = _saved_conversation(config)
assert saved["title"] == "[Task] Weekly report"
assert saved["messages"][-1] == {"role": "assistant", "content": "Report is ready."}
def test_an_unfinished_checklist_is_reported_back_to_the_scheduler(
tmp_path: Path) -> None:
# The agent ticked no step to done, so the task must not be called finished
# just because no exception was raised.
provider = FakeProvider()
provider.queue_response(
content="Working on it.",
tool_calls=[{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"}]}}],
)
provider.queue_response(content="Stopping here.")
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
assert incomplete
assert "Draft" in incomplete
def test_a_finished_checklist_reports_nothing_outstanding(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Done.",
tool_calls=[{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "done"}]}}],
)
provider.queue_response(content="All done.")
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
assert incomplete == ""
def test_running_out_of_time_appends_the_timeout_notice_to_the_conversation(
tmp_path: Path) -> None:
# A negative timeout puts the deadline in the past, which is the only
# deterministic way to exercise a wall-clock branch in a unit test.
provider = FakeProvider()
provider.queue_response(content="never gets there")
(answer, timed_out, incomplete), events, config = _run(
tmp_path, provider, timeout_sec=-1)
assert timed_out is True
assert incomplete == "" # a timeout is not an unfinished checklist
assert "quá thời gian chờ" in answer
assert any(e["type"] == "assistant_done" and "quá thời gian chờ" in e["content"]
for e in events)
assert "quá thời gian chờ" in _saved_conversation(config)["messages"][-1]["content"]
+5 -13
View File
@@ -1,17 +1,9 @@
"""Pytest fixtures/shared helpers for the routing test suite.
Ensures the ``cowork_local`` package is importable when pytest is invoked from
the package directory itself (so ``import cowork_local.core.routing...`` works
regardless of the working directory the suite is launched from).
Package importability is handled once and for all by ``tests/conftest.py``,
which binds THIS checkout to the ``cowork_local`` name in ``sys.modules``.
This file used to push the checkout's PARENT directory onto ``sys.path``, which
let an unrelated sibling folder named ``cowork_local`` shadow the working copy —
so that logic is intentionally gone; keep it that way.
"""
from __future__ import annotations
import sys
from pathlib import Path
# .../cowork_local/tests/routing/conftest.py → parent of the package dir
_PKG_DIR = Path(__file__).resolve().parents[2] # .../cowork_local
_REPO_ROOT = _PKG_DIR.parent # .../cowork_local_20260722
for p in (str(_REPO_ROOT), str(_PKG_DIR)):
if p not in sys.path:
sys.path.insert(0, p)
+8 -1
View File
@@ -14,7 +14,6 @@ from cowork_local.mcp_servers.project_context.registry import (
)
from cowork_local.mcp_servers.project_context.runtime import require_supported_python
from cowork_local.mcp_servers.project_context.server import dispatch
from mcp import types
EXPECTED_TOOLS = {
"get_project_issue_context",
@@ -88,6 +87,14 @@ def source() -> dict[str, str]:
def test_template_exposes_exactly_three_provider_neutral_tools() -> None:
# The MCP SDK is a RUNTIME dependency (requirements.txt) and is deliberately
# absent from requirements-test.txt, which is all CI installs. Importing it at
# module scope aborted collection for the ENTIRE suite, so the guard lives here,
# inside the only test that touches the SDK. Guarding per-test rather than
# per-module keeps the other cases -- pure-Python contract checks that need no
# SDK -- running on CI instead of silently skipping with it.
types = pytest.importorskip("mcp.types")
assert set(TOOL_NAMES) == EXPECTED_TOOLS
declarations = tool_declarations()
assert {item["name"] for item in declarations} == EXPECTED_TOOLS
+209
View File
@@ -0,0 +1,209 @@
"""R04-T02 — unit tests for the typed agent event stream.
The events replace the untyped ``{"type": ...}`` dicts the runtime emits today,
but ``ui/chat_panel.py::_on_event`` still dispatches on those dicts until R08.
So the contract under test is two-sided: each event must be a real typed value
AND must serialise back to the exact legacy shape the widget already reads —
same wire name, same keys, same optional-key behaviour.
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from cowork_local.domain.agents.agent_event import (
AssistantMessageCompletedEvent,
ErrorEvent,
HistoryReadyEvent,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
ToolPreview,
TurnCompletedEvent,
)
from cowork_local.domain.agents.agent_event_codec import from_legacy_dict
# -- base contract --------------------------------------------------------- #
def test_events_reject_mutation() -> None:
event = TextChunkEvent(delta="hello")
with pytest.raises(FrozenInstanceError):
event.delta = "goodbye"
# -- legacy wire compatibility --------------------------------------------- #
def test_text_chunk_serialises_as_the_legacy_text_event() -> None:
assert TextChunkEvent(delta="hi").to_legacy_dict() == {"type": "text", "delta": "hi"}
def test_reasoning_chunk_serialises_as_the_legacy_reasoning_event() -> None:
assert ReasoningChunkEvent(delta="hmm").to_legacy_dict() == {
"type": "reasoning", "delta": "hmm"}
def test_assistant_message_completed_serialises_as_assistant_done() -> None:
# Fires once per provider call, so several times in a tool-using turn — it
# is NOT the end of the turn (that is TurnCompletedEvent).
assert AssistantMessageCompletedEvent(content="done").to_legacy_dict() == {
"type": "assistant_done", "content": "done"}
def test_tool_call_started_serialises_with_the_legacy_id_and_args_keys() -> None:
event = ToolCallStartedEvent(
call_id="call_1", name="write_file", arguments={"path": "a.md"},
preview=ToolPreview(kind="diff", title="Create file: a.md", text="+ hi"),
)
assert event.to_legacy_dict() == {
"type": "tool_proposed",
"id": "call_1",
"name": "write_file",
"args": {"path": "a.md"},
"preview": {"kind": "diff", "title": "Create file: a.md", "text": "+ hi"},
}
def test_tool_call_started_omits_the_preview_when_there_is_none() -> None:
event = ToolCallStartedEvent(call_id="call_1", name="read_file")
assert "preview" not in event.to_legacy_dict()
def test_tool_output_chunk_serialises_as_the_legacy_tool_output_event() -> None:
event = ToolOutputChunkEvent(call_id="call_1", name="run_command", delta="line\n")
assert event.to_legacy_dict() == {
"type": "tool_output", "id": "call_1", "name": "run_command", "delta": "line\n"}
def test_tool_call_finished_serialises_as_the_legacy_tool_result_event() -> None:
event = ToolCallFinishedEvent(
call_id="call_1", name="save_file", ok=True, output="saved",
path="C:/out/a.md", produced=["C:/out/b.pptx"],
)
assert event.to_legacy_dict() == {
"type": "tool_result",
"id": "call_1",
"name": "save_file",
"ok": True,
"output": "saved",
"path": "C:/out/a.md",
"produced": ["C:/out/b.pptx"],
}
def test_tool_call_finished_omits_path_and_produced_when_empty() -> None:
# chat_agent only sets these keys when they exist; emitting them as None
# would make ``ev.get("path")`` truthy checks read differently downstream.
legacy = ToolCallFinishedEvent(call_id="c", name="read_file", ok=True).to_legacy_dict()
assert "path" not in legacy
assert "produced" not in legacy
def test_plan_updated_serialises_steps_back_to_title_status_dicts() -> None:
event = PlanUpdatedEvent(steps=(PlanStep(title="Read config", status="done"),
PlanStep(title="Patch it", status="running")))
assert event.to_legacy_dict() == {
"type": "plan_set",
"steps": [{"title": "Read config", "status": "done"},
{"title": "Patch it", "status": "running"}],
}
def test_notice_serialises_with_its_level() -> None:
assert NoticeEvent(text="reading page 2/9", level="progress").to_legacy_dict() == {
"type": "notice", "level": "progress", "text": "reading page 2/9"}
def test_notice_defaults_to_the_info_level() -> None:
assert NoticeEvent(text="compacted").to_legacy_dict()["level"] == "info"
def test_outputs_added_and_removed_serialise_their_path_lists() -> None:
assert OutputsAddedEvent(paths=("a.md",)).to_legacy_dict() == {
"type": "outputs_added", "paths": ["a.md"]}
assert OutputsRemovedEvent(paths=("tmp.py",)).to_legacy_dict() == {
"type": "outputs_removed", "paths": ["tmp.py"]}
def test_history_ready_serialises_its_session_id() -> None:
assert HistoryReadyEvent(session_id="s7").to_legacy_dict() == {
"type": "history_ready", "session_id": "s7"}
# -- events introduced by R04 (no legacy consumer) ------------------------- #
def test_turn_completed_carries_the_final_answer_and_step_count() -> None:
event = TurnCompletedEvent(final_text="all done", steps_used=3)
assert event.to_legacy_dict() == {
"type": "turn_completed", "final_text": "all done", "steps_used": 3,
"cancelled": False, "budget_exhausted": False}
def test_error_event_is_fatal_unless_marked_recoverable() -> None:
assert ErrorEvent(message="boom").recoverable is False
assert ErrorEvent(message="rate limited", recoverable=True).recoverable is True
# -- parsing legacy dicts back into events --------------------------------- #
_ROUND_TRIP_CASES = [
TextChunkEvent(delta="hi"),
ReasoningChunkEvent(delta="hmm"),
AssistantMessageCompletedEvent(content="done"),
ToolCallStartedEvent(call_id="c", name="run_command", arguments={"command": "ls"},
preview=ToolPreview(kind="command", title="Run", text="ls")),
ToolCallStartedEvent(call_id="c", name="read_file"),
ToolOutputChunkEvent(call_id="c", name="run_command", delta="out"),
ToolCallFinishedEvent(call_id="c", name="save_file", ok=True, output="ok",
path="a.md", produced=["b.md"]),
ToolCallFinishedEvent(call_id="c", name="read_file", ok=False, output="missing"),
PlanUpdatedEvent(steps=(PlanStep(title="Step", status="pending"),)),
NoticeEvent(text="warned", level="warning"),
OutputsAddedEvent(paths=("a.md",)),
OutputsRemovedEvent(paths=("tmp.py",)),
HistoryReadyEvent(session_id="s7"),
TurnCompletedEvent(final_text="done", steps_used=2, cancelled=True),
ErrorEvent(message="boom", recoverable=True),
]
@pytest.mark.parametrize("event", _ROUND_TRIP_CASES, ids=lambda e: type(e).__name__)
def test_every_event_survives_a_round_trip_through_the_legacy_dict(event) -> None:
assert from_legacy_dict(event.to_legacy_dict()) == event
def test_unknown_event_types_parse_to_none_instead_of_raising() -> None:
# Co4E emits its own vocabulary (node_status, stage_text, run_done) which R04
# deliberately leaves alone; a bridge must be able to pass those through
# untouched rather than crash on them.
assert from_legacy_dict({"type": "node_status", "node_id": "n1"}) is None
assert from_legacy_dict({"type": ""}) is None
assert from_legacy_dict("not a dict") is None
def test_missing_payload_keys_parse_to_empty_values() -> None:
# Defensive: a truncated event from an older emitter must not kill the turn.
assert from_legacy_dict({"type": "text"}) == TextChunkEvent(delta="")
assert from_legacy_dict({"type": "tool_result", "id": "c", "name": "x"}) == (
ToolCallFinishedEvent(call_id="c", name="x", ok=False, output=""))
def test_plan_steps_from_legacy_drop_entries_without_a_title() -> None:
# normalize_plan_steps already clamps upstream; this only guards the parse
# path so a hand-written dict cannot produce a titleless step.
event = from_legacy_dict({"type": "plan_set",
"steps": [{"title": "Real", "status": "done"}, {"status": "done"}]})
assert event == PlanUpdatedEvent(steps=(PlanStep(title="Real", status="done"),))
+101
View File
@@ -0,0 +1,101 @@
"""R04-T03 (a) — unit tests for the value a finished turn returns.
Two callers need different things out of one turn today:
``ui/chat_panel.py::_finalize_turn`` wants the message list, while
``core/task_executors.py::_run_agent`` returns a
``(answer_text, timed_out, incomplete_reason)`` tuple assembled by hand. This
type is what both read instead, so "what happened in that turn?" has one answer
with names on it.
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from cowork_local.domain.agents.agent_event import PlanStep, TurnCompletedEvent
from cowork_local.domain.agents.agent_result import AgentResult
def test_result_rejects_mutation() -> None:
result = AgentResult(steps_used=1)
with pytest.raises(FrozenInstanceError):
result.steps_used = 2
def test_messages_are_frozen_into_a_tuple() -> None:
live = [{"role": "user", "content": "hi"}]
result = AgentResult(messages=live)
live.append({"role": "assistant", "content": "later"})
assert result.messages == ({"role": "user", "content": "hi"},)
def test_final_text_is_the_last_non_empty_assistant_message() -> None:
# A turn ends on a tool message often enough (cancelled mid-loop) that the
# answer cannot simply be messages[-1].
result = AgentResult(messages=[
{"role": "assistant", "content": "first pass"},
{"role": "assistant", "content": "the answer"},
{"role": "tool", "tool_call_id": "c", "name": "read_file", "content": "..."},
])
assert result.final_text == "the answer"
def test_final_text_skips_a_blank_assistant_message() -> None:
result = AgentResult(messages=[
{"role": "assistant", "content": "the answer"},
{"role": "assistant", "content": " "},
])
assert result.final_text == "the answer"
def test_final_text_is_empty_when_the_model_never_answered() -> None:
assert AgentResult(messages=[{"role": "user", "content": "hi"}]).final_text == ""
def test_a_plain_finished_turn_is_ok() -> None:
assert AgentResult(messages=[{"role": "assistant", "content": "done"}]).ok is True
def test_a_cancelled_turn_is_not_ok() -> None:
assert AgentResult(cancelled=True).ok is False
def test_a_failed_turn_is_not_ok_and_keeps_its_message() -> None:
result = AgentResult(error="SecurityBlocked: nope")
assert result.ok is False
assert result.error == "SecurityBlocked: nope"
def test_hitting_the_step_ceiling_is_reported_separately_from_cancelling() -> None:
# "Stopped because the safety limit was reached" and "the user pressed Stop"
# need different wording in the transcript, so they stay separate flags.
result = AgentResult(budget_exhausted=True, steps_used=30)
assert result.budget_exhausted is True
assert result.cancelled is False
def test_result_converts_to_the_turn_completed_event() -> None:
result = AgentResult(
messages=[{"role": "assistant", "content": "done"}],
steps_used=3, cancelled=False, budget_exhausted=True,
)
assert result.to_turn_completed_event() == TurnCompletedEvent(
final_text="done", steps_used=3, cancelled=False, budget_exhausted=True)
def test_plan_steps_are_frozen_into_a_tuple() -> None:
steps = [PlanStep(title="Draft", status="done")]
result = AgentResult(plan_steps=steps)
steps.append(PlanStep(title="Review"))
assert result.plan_steps == (PlanStep(title="Draft", status="done"),)
+59
View File
@@ -0,0 +1,59 @@
"""Unit tests for the Clean Architecture AST Import Guard (check_imports.py)."""
from __future__ import annotations
from pathlib import Path
from scripts.check_imports import FORBIDDEN_MODULE_PREFIXES, scan_file
def test_clean_python_file_passes(tmp_path: Path) -> None:
"""Verify that pure Python code without GUI imports produces 0 violations."""
clean_code = """
import os
import json
from dataclasses import dataclass
from typing import List
@dataclass
class UserRequest:
id: str
prompt: str
"""
clean_file = tmp_path / "clean_service.py"
clean_file.write_text(clean_code, encoding="utf-8")
violations = scan_file(clean_file, FORBIDDEN_MODULE_PREFIXES)
assert len(violations) == 0
def test_forbidden_pyside_import_detected(tmp_path: Path) -> None:
"""Verify that PySide6 import is caught with correct line number."""
dirty_code = """
from dataclasses import dataclass
from PySide6.QtWidgets import QWidget
class BadService:
pass
"""
dirty_file = tmp_path / "bad_service.py"
dirty_file.write_text(dirty_code, encoding="utf-8")
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
assert len(violations) == 1
assert violations[0].line_number == 3
assert "PySide6" in violations[0].imported_module
def test_forbidden_ui_and_app_import_detected(tmp_path: Path) -> None:
"""Verify that importing concrete UI or app modules from domain is caught."""
dirty_code = """
import ui.chat_panel
from app import MainWindow
"""
dirty_file = tmp_path / "cross_layer_leak.py"
dirty_file.write_text(dirty_code, encoding="utf-8")
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
assert len(violations) == 2
modules = [v.imported_module for v in violations]
assert "ui.chat_panel" in modules
assert "app" in modules
@@ -0,0 +1,293 @@
"""R04-T03 (b) — the turn loop: composition, tool dispatch, budget, cancel.
Behaviour that used to be reachable only by running the real widget. Every
dependency is a fake from ``tests/fakes/turn_runtime_fakes.py``, so the file
runs in milliseconds and each test states one rule of the loop.
"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from cowork_local.application.conversations.conversation_application_service import (
ConversationApplicationService,
)
from cowork_local.domain.agents.agent_event import (
AssistantMessageCompletedEvent,
PlanStep,
PlanUpdatedEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
ToolPreview,
TurnCompletedEvent,
)
from cowork_local.tests.fakes.turn_runtime_fakes import (
FakeModelCall,
FakeReply,
FakeToolRuntime,
events_of_type,
make_request,
run_turn,
tool_turn,
)
def _service(model, tools, **overrides) -> ConversationApplicationService:
return ConversationApplicationService(model, tools, **overrides)
# --------------------------------------------------------------------------- #
# The happy path.
# --------------------------------------------------------------------------- #
def test_a_plain_answer_streams_text_then_reports_the_message_and_the_turn() -> None:
model = FakeModelCall([FakeReply(content="Hello there", chunks=["Hello ", "there"])])
result, events = run_turn(_service(model, FakeToolRuntime()))
assert [e.delta for e in events_of_type(events, TextChunkEvent)] == ["Hello ", "there"]
assert events_of_type(events, AssistantMessageCompletedEvent) == [
AssistantMessageCompletedEvent(content="Hello there")]
assert events_of_type(events, TurnCompletedEvent) == [
TurnCompletedEvent(final_text="Hello there", steps_used=1)]
assert result.final_text == "Hello there"
assert result.ok is True
def test_the_composed_user_message_is_appended_before_the_first_call() -> None:
model = FakeModelCall([FakeReply(content="ok")])
request = make_request(prompt="ship it", instruction_prefix="RULES",
session_notes="earlier: a.md",
messages=[{"role": "user", "content": "previous"}])
run_turn(_service(model, FakeToolRuntime()), request)
sent = model.calls[0]["messages"]
assert sent[-1] == {"role": "user",
"content": "RULES\n\n---\n\nship it\n\nearlier: a.md"}
assert sent[-2] == {"role": "user", "content": "previous"}
def test_attachments_are_read_when_the_turn_runs_not_when_it_was_built() -> None:
# Extraction can pip-install a parser or shell out to LibreOffice, so it must
# happen here (worker thread), not while the UI was assembling the request.
seen: List[Tuple[str, Tuple[str, ...]]] = []
def reader(prompt: str, attachments: Tuple[str, ...]) -> str:
seen.append((prompt, attachments))
return f"{prompt}\n\n<contents of {len(attachments)} file(s)>"
model = FakeModelCall([FakeReply(content="ok")])
request = make_request(prompt="summarise", attachments=["a.docx", "b.pdf"])
run_turn(_service(model, FakeToolRuntime(), attachment_reader=reader), request)
assert seen == [("summarise", ("a.docx", "b.pdf"))]
assert "contents of 2 file(s)" in model.calls[0]["messages"][-1]["content"]
def test_the_prompt_preparer_is_told_which_tools_the_turn_advertises() -> None:
# The system prompt gains an MS365 paragraph only when ms365__* tools are
# present, so the preparer has to see the real list.
seen: List[Tuple[str, ...]] = []
model = FakeModelCall([FakeReply(content="ok")])
tools = FakeToolRuntime(specs=("save_file", "ms365__send_mail"))
run_turn(_service(model, tools,
prepare_prompt=lambda messages, names: seen.append(names)))
assert seen == [("save_file", "ms365__send_mail")]
def test_only_the_allowed_tools_are_advertised() -> None:
model = FakeModelCall([FakeReply(content="ok")])
tools = FakeToolRuntime(specs=("save_file", "run_command", "update_plan"))
run_turn(_service(model, tools), make_request(allowed_tools=("save_file", "update_plan")))
assert model.calls[0]["tool_names"] == ["save_file", "update_plan"]
# --------------------------------------------------------------------------- #
# Tool dispatch.
# --------------------------------------------------------------------------- #
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
"""A turn that calls one tool, then answers."""
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
FakeReply(content="done")])
return model, FakeToolRuntime(**tool_kwargs)
def test_a_tool_call_is_announced_executed_and_answered_in_the_message_list() -> None:
model, tools = tool_turn(results={"save_file": {"ok": True, "output": "saved",
"path": "out/a.md"}})
result, events = run_turn(_service(model, tools))
assert events_of_type(events, ToolCallStartedEvent) == [ToolCallStartedEvent(
call_id="c1", name="save_file", arguments={"filename": "a.md"},
preview=ToolPreview(kind="info", title="save_file", text="{'filename': 'a.md'}"))]
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
call_id="c1", name="save_file", ok=True, output="saved", path="out/a.md")]
assert tools.executed == [("save_file", {"filename": "a.md"})]
assert result.messages[-2] == {"role": "tool", "tool_call_id": "c1",
"name": "save_file", "content": "saved"}
def test_live_tool_output_is_streamed_while_the_tool_runs() -> None:
model, tools = tool_turn("run_command", {"command": "ls"})
tools.emit_output = "file-a\n"
_, events = run_turn(_service(model, tools))
assert events_of_type(events, ToolOutputChunkEvent) == [ToolOutputChunkEvent(
call_id="c1", name="run_command", delta="file-a\n")]
def test_the_loop_ends_as_soon_as_the_model_stops_calling_tools() -> None:
model, tools = tool_turn()
result, _ = run_turn(_service(model, tools))
assert result.steps_used == 2
assert result.budget_exhausted is False
def test_the_plan_tool_reports_a_plan_update_and_no_tool_bubble() -> None:
calls = [{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"}]}}]
model = FakeModelCall([FakeReply(content="planning", tool_calls=calls), FakeReply(content="done")])
tools = FakeToolRuntime(results={"update_plan": {
"ok": True, "output": "Plan updated.",
"plan_steps": [PlanStep(title="Draft", status="running")]}})
result, events = run_turn(_service(model, tools))
assert events_of_type(events, PlanUpdatedEvent) == [
PlanUpdatedEvent(steps=(PlanStep(title="Draft", status="running"),))]
assert events_of_type(events, ToolCallStartedEvent) == []
assert events_of_type(events, ToolCallFinishedEvent) == []
assert result.plan_steps == (PlanStep(title="Draft", status="running"),)
# --------------------------------------------------------------------------- #
# Budget, cancellation.
# --------------------------------------------------------------------------- #
def test_running_out_of_steps_is_flagged_and_announced() -> None:
# The model keeps calling tools forever; the ceiling must stop it visibly.
forever = [FakeReply(content=f"step {i}",
tool_calls=[{"id": f"c{i}", "name": "save_file", "arguments": {}}])
for i in range(5)]
model = FakeModelCall(forever)
result, events = run_turn(_service(model, FakeToolRuntime()), make_request(max_steps=2))
assert result.steps_used == 2
assert result.budget_exhausted is True
assert "2-step safety limit" in events_of_type(events, TextChunkEvent)[-1].delta
# The note reaches the transcript but NOT the stored answer: a turn that hits
# the ceiling always ends on a tool message, and the existing runtime only
# merges the note when the last message is the assistant's. Pinned here so a
# future change to that rule is a deliberate decision, not a silent drift.
assert result.final_text == "step 1"
def test_run_to_completion_uses_the_higher_ceiling() -> None:
forever = [FakeReply(content="x", tool_calls=[{"id": "c", "name": "save_file", "arguments": {}}])
for _ in range(6)]
model = FakeModelCall(forever)
result, _ = run_turn(_service(model, FakeToolRuntime()),
make_request(max_steps=2, completion_max_steps=5, run_to_completion=True))
assert result.steps_used == 5
def test_a_turn_cancelled_before_it_starts_never_calls_the_model() -> None:
model = FakeModelCall([FakeReply(content="never")])
result, events = run_turn(_service(model, FakeToolRuntime()), cancel=lambda: True)
assert model.calls == []
assert result.cancelled is True
assert result.budget_exhausted is False
assert events_of_type(events, TurnCompletedEvent) == [TurnCompletedEvent(cancelled=True)]
def test_cancelling_during_a_turn_stops_dispatching_the_remaining_tool_calls() -> None:
calls = [{"id": "c1", "name": "save_file", "arguments": {}},
{"id": "c2", "name": "save_file", "arguments": {}}]
model = FakeModelCall([FakeReply(content="two tools", tool_calls=calls)])
tools = FakeToolRuntime()
stop = {"now": False}
def cancel() -> bool:
return stop["now"]
original_execute = tools.execute
def execute(name, args, on_output=None, cancel=None):
stop["now"] = True # cancel raised while the first tool runs
return original_execute(name, args, on_output=on_output, cancel=cancel)
tools.execute = execute
result, _ = run_turn(_service(model, tools), cancel=cancel)
assert len(tools.executed) == 1
assert result.cancelled is True
# --------------------------------------------------------------------------- #
# Bring-your-own working list.
#
# ``ui/chat_panel.py`` holds the turn's message list in its own turn context and
# reads it WHILE the worker appends (``_reattach_running_turn`` replays the steps
# done so far when the user reopens a running conversation; ``_finalize_turn``
# slices it by ``snapshot_len``). A service that built its own private list would
# silently break both, so a caller can hand its list over instead.
# --------------------------------------------------------------------------- #
def test_a_caller_supplied_list_is_appended_to_in_place() -> None:
model, tools = tool_turn()
live: List[Dict[str, Any]] = [{"role": "user", "content": "already composed"}]
result = ConversationApplicationService(model, tools).execute(
make_request(), lambda event: None, messages=live)
roles = [m["role"] for m in live]
assert roles == ["user", "assistant", "tool", "assistant"]
assert result.messages == tuple(live)
def test_a_caller_supplied_list_is_used_as_is_without_recomposing_the_prompt() -> None:
# The widget already applied the skill prefix and the session notes when it
# built its message; composing again would duplicate them.
model = FakeModelCall([FakeReply(content="ok")])
user = {"role": "user", "content": "already composed"}
live = [user]
ConversationApplicationService(model, FakeToolRuntime()).execute(
make_request(prompt="typed text", instruction_prefix="RULES",
session_notes="notes"),
lambda event: None, messages=live)
assert live[0] is user
assert live[0]["content"] == "already composed"
assert [m["role"] for m in live].count("user") == 1
def test_a_caller_supplied_list_skips_the_attachment_reader() -> None:
# Reading the attachments is what produced the caller's message in the first
# place; doing it again would re-parse every file.
model = FakeModelCall([FakeReply(content="ok")])
calls: List[Any] = []
ConversationApplicationService(
model, FakeToolRuntime(),
attachment_reader=lambda prompt, attachments: calls.append(prompt) or prompt,
).execute(make_request(attachments=["a.docx"]), lambda event: None,
messages=[{"role": "user", "content": "composed"}])
assert calls == []
@@ -0,0 +1,132 @@
"""R04-T01 — unit tests for the immutable turn snapshot.
The snapshot exists so a turn already running cannot be altered by the UI the
user keeps clicking on. These tests pin exactly that: the object refuses
mutation, it copies the mutable collections handed to it at submit time, and it
owns the prompt-composition rules that were inline in
``ui/chat_panel.py::_start_turn``'s worker closure (prefix separator, session
notes, model-switch review note).
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
from pathlib import Path
import pytest
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
def _request(**overrides) -> ConversationExecutionRequest:
"""A minimal valid request; each test overrides only what it exercises."""
base = {"turn_id": "t1", "session_id": "s1"}
base.update(overrides)
return ConversationExecutionRequest(**base)
# -- immutability ---------------------------------------------------------- #
def test_request_rejects_mutation_after_construction() -> None:
request = _request(model="gpt-4o-mini")
with pytest.raises(FrozenInstanceError):
request.model = "claude-sonnet-4-6"
def test_turn_id_is_required() -> None:
with pytest.raises(ValueError):
ConversationExecutionRequest(turn_id="", session_id="s1")
def test_session_id_is_required() -> None:
with pytest.raises(ValueError):
ConversationExecutionRequest(turn_id="t1", session_id="")
# -- snapshotting mutable UI state ---------------------------------------- #
def test_attachments_are_snapshotted_away_from_the_caller_list() -> None:
picked = ["a.docx"]
request = _request(attachments=picked)
picked.append("b.pdf") # the composer clears/refills its own list next turn
assert request.attachments == ("a.docx",)
def test_messages_are_snapshotted_away_from_the_live_history_list() -> None:
history = [{"role": "user", "content": "earlier"}]
request = _request(messages=history)
history.append({"role": "assistant", "content": "later"})
assert len(request.messages) == 1
assert isinstance(request.messages, tuple)
def test_allowed_tools_none_means_every_tool_stays_available() -> None:
# None and () must stay distinguishable: None = no restriction, () = deny
# every built-in tool. Coercing None to () would silently disarm the agent.
assert _request().allowed_tools is None
assert _request(allowed_tools=[]).allowed_tools == ()
def test_output_paths_accept_strings_and_normalise_to_path() -> None:
request = _request(output_dir="out/t1", home_output_root="out")
assert request.output_dir == Path("out/t1")
assert request.home_output_root == Path("out")
# -- derived turn policy --------------------------------------------------- #
def test_effective_max_steps_uses_the_interactive_cap_by_default() -> None:
assert _request(max_steps=30, completion_max_steps=200).effective_max_steps == 30
def test_effective_max_steps_lifts_the_cap_when_running_to_completion() -> None:
request = _request(max_steps=30, completion_max_steps=200, run_to_completion=True)
assert request.effective_max_steps == 200
def test_permission_gate_is_required_only_in_confirm_mode() -> None:
assert _request(gate_mode="confirm").requires_permission_gate is True
assert _request(gate_mode="auto").requires_permission_gate is False
def test_has_prompt_ignores_whitespace_only_input() -> None:
assert _request(prompt=" \n ").has_prompt is False
assert _request(prompt="do it").has_prompt is True
# -- prompt composition (moved out of the widget's worker closure) --------- #
def test_user_content_returns_the_body_unchanged_without_prefix_or_notes() -> None:
assert _request().user_content("the body") == "the body"
def test_user_content_separates_the_instruction_prefix_from_the_body() -> None:
request = _request(instruction_prefix="SKILL RULES")
assert request.user_content("the body") == "SKILL RULES\n\n---\n\nthe body"
def test_user_content_appends_session_notes_after_the_body() -> None:
request = _request(session_notes="Files produced earlier: a.md")
assert request.user_content("the body") == "the body\n\nFiles produced earlier: a.md"
def test_user_content_falls_back_to_session_notes_when_the_body_is_empty() -> None:
# An attachment-only turn has no typed text, so the notes must not be
# prefixed with a stray blank line.
request = _request(session_notes="Files produced earlier: a.md")
assert request.user_content("") == "Files produced earlier: a.md"
def test_user_content_puts_the_review_note_ahead_of_everything_else() -> None:
request = _request(instruction_prefix="SKILL RULES", review_note="[Note: switched]")
content = request.user_content("the body")
assert content == "[Note: switched]\n\nSKILL RULES\n\n---\n\nthe body"
+210
View File
@@ -0,0 +1,210 @@
"""R04-T03 (b) — the turn loop: guards, permission gate, compaction, cleanup.
Split out of ``test_conversation_application_service.py`` to keep each file
inside the 400-LOC limit. Same fakes, same service; this half pins the ORDER of
the safety steps (guard before model, guard before execute, gate before execute)
and the promise that the output sandbox is tidied on the way out.
"""
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from cowork_local.application.conversations.conversation_application_service import (
ConversationApplicationService,
)
from cowork_local.domain.agents.agent_event import (
ErrorEvent,
OutputsAddedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
)
from cowork_local.tests.fakes.turn_runtime_fakes import (
FakeModelCall,
FakeReply,
FakeToolRuntime,
events_of_type,
make_request,
run_turn,
tool_turn,
)
def _service(model, tools, **overrides) -> ConversationApplicationService:
return ConversationApplicationService(model, tools, **overrides)
# --------------------------------------------------------------------------- #
# Guards and the permission gate.
# --------------------------------------------------------------------------- #
def test_the_prompt_guard_runs_before_the_model_is_ever_called() -> None:
order: List[str] = []
model = FakeModelCall([FakeReply(content="ok")])
model_call = model.call
def call(*a, **kw):
order.append("model")
return model_call(*a, **kw)
model.call = call
run_turn(_service(model, FakeToolRuntime(), prompt_guard=lambda messages: order.append("guard")))
assert order == ["guard", "model"]
def test_a_blocked_prompt_propagates_before_the_output_folder_is_touched() -> None:
model = FakeModelCall([FakeReply(content="never")])
tools = FakeToolRuntime()
events: List[Any] = []
def guard(messages) -> None:
raise RuntimeError("SecurityBlocked: nope")
service = _service(model, tools, prompt_guard=guard)
with pytest.raises(RuntimeError, match="SecurityBlocked"):
service.execute(make_request(), events.append)
assert model.calls == []
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="SecurityBlocked: nope")]
# Cleanup is NOT a read-only operation (it deletes a stale .scratch and every
# empty sub-folder), so a turn rejected before it started must not run it.
assert tools.finalize_calls == []
def test_output_cleanup_still_runs_when_the_turn_fails_mid_loop() -> None:
# Once the turn has started producing files, the sandbox must be tidied on
# the way out no matter how the turn ends.
model = FakeModelCall([RuntimeError("gateway exploded")])
tools = FakeToolRuntime()
events: List[Any] = []
with pytest.raises(RuntimeError, match="gateway exploded"):
_service(model, tools).execute(make_request(), events.append)
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="gateway exploded")]
def test_the_command_guard_runs_before_the_tool_executes() -> None:
order: List[str] = []
model, tools = tool_turn("run_command", {"command": "ls"})
original = tools.execute
def execute(name, args, on_output=None, cancel=None):
order.append("execute")
return original(name, args, on_output=on_output, cancel=cancel)
tools.execute = execute
run_turn(_service(model, tools,
command_guard=lambda name, args: order.append(f"guard:{name}")))
assert order == ["guard:run_command", "execute"]
def test_disabling_rule_enforcement_skips_both_guards() -> None:
# Co4E flow steps run inside the workspace sandbox and opt out on purpose.
calls: List[str] = []
model, tools = tool_turn("run_command", {"command": "ls"})
run_turn(_service(model, tools,
prompt_guard=lambda messages: calls.append("prompt"),
command_guard=lambda name, args: calls.append("command")),
make_request(enforce_rules=False))
assert calls == []
def test_the_permission_gate_is_asked_only_for_command_tools() -> None:
asked: List[str] = []
model, tools = tool_turn("save_file", {"filename": "a.md"})
run_turn(_service(model, tools,
permission_request=lambda action: asked.append(action["name"]) or True),
make_request(gate_mode="confirm"))
assert asked == [] # save_file writes into the sandbox: never gated
def test_a_command_tool_in_confirm_mode_asks_before_running() -> None:
asked: List[Dict[str, Any]] = []
model, tools = tool_turn("run_command", {"command": "ls"})
def approve(action: Dict[str, Any]) -> bool:
asked.append(action)
return True
run_turn(_service(model, tools, permission_request=approve), make_request(gate_mode="confirm"))
assert [a["name"] for a in asked] == ["run_command"]
assert tools.executed == [("run_command", {"command": "ls"})]
def test_a_rejected_command_is_reported_as_a_failed_tool_and_never_runs() -> None:
model, tools = tool_turn("run_command", {"command": "rm -rf /"})
result, events = run_turn(_service(model, tools, permission_request=lambda action: False),
make_request(gate_mode="confirm"))
assert tools.executed == []
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
call_id="c1", name="run_command", ok=False, output="Rejected by user.")]
assert result.messages[-2]["content"] == "Rejected by user."
def test_auto_mode_never_asks_even_for_a_command() -> None:
model, tools = tool_turn("run_command", {"command": "ls"})
def refuse(action): # would block the turn if it were consulted
raise AssertionError("the gate must not be consulted in auto mode")
run_turn(_service(model, tools, permission_request=refuse), make_request(gate_mode="auto"))
assert tools.executed == [("run_command", {"command": "ls"})]
# --------------------------------------------------------------------------- #
# Context compaction, reasoning, output cleanup.
# --------------------------------------------------------------------------- #
def test_the_conversation_is_offered_for_compaction_before_every_call() -> None:
compactions: List[int] = []
model, tools = tool_turn()
run_turn(_service(model, tools,
compact=lambda messages, cancel: compactions.append(len(messages))))
assert len(compactions) == 2 # once per provider call
def test_reasoning_is_streamed_as_its_own_event() -> None:
model = FakeModelCall([FakeReply(content="42", reasoning="thinking...")])
_, events = run_turn(_service(model, FakeToolRuntime()))
assert events_of_type(events, ReasoningChunkEvent) == [ReasoningChunkEvent(delta="thinking...")]
def test_a_reasoning_only_reply_gets_a_visible_note_in_the_transcript() -> None:
# Otherwise a Schedule Task run reads back an empty answer and writes
# "(no output)" into its report.
model = FakeModelCall([FakeReply(content="", reasoning="thought hard")])
result, events = run_turn(_service(model, FakeToolRuntime()))
assert "only its reasoning" in events_of_type(events, TextChunkEvent)[-1].delta
assert "only its reasoning" in result.final_text
def test_promoted_and_discarded_output_files_are_reported_at_the_end() -> None:
model = FakeModelCall([FakeReply(content="ok")])
tools = FakeToolRuntime(added=("out/report.pptx",))
_, events = run_turn(_service(model, tools))
assert events_of_type(events, OutputsAddedEvent) == [
OutputsAddedEvent(paths=("out/report.pptx",))]
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
+220
View File
@@ -0,0 +1,220 @@
"""Unit tests for the adapters that bridge the routing engine to the app service.
The integration suite covers the happy path over the real engine; this file pins
the translation edge cases that are hard to provoke there — malformed task
types, a missing ranking, and the service-caching contract.
"""
from __future__ import annotations
import pytest
from cowork_local.application.model_routing import (
AppContextModeResolver,
CoreRoutingEngine,
RoutingApplicationService,
RoutingMode,
RoutingRequest,
)
from cowork_local.application.model_routing.core_routing_adapter import (
build_routing_application_service,
)
from cowork_local.core.routing.models import SwitchDecision, SwitchMode, TaskType
class FakeRanking:
"""Just enough of ``selector.Ranking`` for the adapter's usability check."""
def __init__(self, scores) -> None:
self._scores = dict(scores)
def score_of(self, key: str) -> float:
return self._scores.get(key, 0.0)
class FakeRouteResult:
"""Stands in for ``core.routing.service.RouteResult``."""
def __init__(self, decision, task_type=TaskType.CODING, ranking=None, target=None) -> None:
self.decision = decision
self.task_type = task_type
self.ranking = ranking
self._target = target
@property
def should_switch(self) -> bool:
return self.decision.should_switch
def target(self):
return self._target
class FakeRoutingService:
"""Records the arguments the adapter forwards to the engine."""
def __init__(self, result: FakeRouteResult) -> None:
self.result = result
self.calls: list = []
def route(self, surface, prompt, current_provider, current_model, **kwargs):
self.calls.append({"surface": surface, "prompt": prompt,
"current_provider": current_provider,
"current_model": current_model, **kwargs})
return self.result
def make_decision(**overrides) -> SwitchDecision:
fields = dict(
should_switch=True,
from_model="anthropic/weak-model",
to_model="anthropic/strong-model",
score_gain=0.3,
reason="coding fit 0.9 > current 0.6",
mode=SwitchMode.AUTO,
task_type="coding",
)
fields.update(overrides)
return SwitchDecision(**fields)
def make_request(**overrides) -> RoutingRequest:
fields = dict(surface="cowork", prompt="Fix this bug",
current_provider="anthropic", current_model="weak-model")
fields.update(overrides)
return RoutingRequest(**fields)
# --------------------------------------------------------------------------- #
# CoreRoutingEngine translation
# --------------------------------------------------------------------------- #
def test_engine_flattens_the_route_result() -> None:
"""No ``core.routing`` type may leak past the adapter — the application
service and the widgets only ever see plain fields."""
service = FakeRoutingService(FakeRouteResult(
make_decision(),
ranking=FakeRanking({"anthropic/weak-model": 0.6}),
target=("anthropic", "strong-model"),
))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.task_type == "coding" # str, not TaskType
assert evaluation.should_switch is True
assert evaluation.target_provider == "anthropic"
assert evaluation.target_model == "strong-model"
assert evaluation.score_gain == pytest.approx(0.3)
assert evaluation.current_is_usable is True
def test_engine_forwards_the_mode_as_a_plain_string() -> None:
"""``RoutingService.route`` takes the mode as a string; handing it an enum
would silently fall through to its "unknown mode -> off" branch."""
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert service.calls[0]["mode_override"] == "auto"
def test_engine_reports_an_unranked_model_as_unusable() -> None:
"""This is the signal Fallback acts on: absent from the ranking means the
selector already rejected it (unavailable / no probe / failed probe)."""
service = FakeRoutingService(FakeRouteResult(
make_decision(),
ranking=FakeRanking({"anthropic/strong-model": 0.9}), # current is absent
target=("anthropic", "strong-model"),
))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.current_is_usable is False
def test_engine_assumes_usable_without_a_ranking() -> None:
"""No ranking (routing off, or the engine's own error path) is absence of
evidence — it must not trigger a surprise Fallback switch."""
service = FakeRoutingService(FakeRouteResult(make_decision(), ranking=None))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.current_is_usable is True
def test_engine_assumes_usable_when_the_ranking_misbehaves() -> None:
"""A broken ranking object must not fail the turn."""
class BrokenRanking:
def score_of(self, key):
raise RuntimeError("corrupt ranking")
service = FakeRoutingService(FakeRouteResult(make_decision(), ranking=BrokenRanking()))
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
assert evaluation.current_is_usable is True
@pytest.mark.parametrize(
"raw, expected",
[("coding", TaskType.CODING), ("QA", TaskType.QA), (None, None), ("nonsense", None)],
)
def test_task_type_strings_are_coerced_or_dropped(raw, expected) -> None:
"""A pinned task type is honoured; an unknown one falls back to letting the
engine classify the prompt rather than raising mid-turn."""
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
CoreRoutingEngine(service).evaluate(make_request(task_type=raw), RoutingMode.AUTO)
assert service.calls[0]["task_type"] == expected
def test_required_capabilities_are_passed_as_a_list_or_none() -> None:
"""``rank_models`` filters on a list; an empty tuple must become None so it
is treated as "no filter" rather than "require nothing, but filter"."""
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
engine = CoreRoutingEngine(service)
engine.evaluate(make_request(required_capabilities=("vision",)), RoutingMode.AUTO)
engine.evaluate(make_request(), RoutingMode.AUTO)
assert service.calls[0]["required_capabilities"] == ["vision"]
assert service.calls[1]["required_capabilities"] is None
# --------------------------------------------------------------------------- #
# Mode resolver + wiring
# --------------------------------------------------------------------------- #
def test_mode_resolver_reads_the_per_workspace_mode() -> None:
"""Per-workspace routing keeps working now that the lookup left the widgets."""
class StubCtx:
def project_routing_mode(self, surface):
return "fallback" if surface == "co4e" else "off"
resolver = AppContextModeResolver(StubCtx())
assert resolver.mode_for("co4e") is RoutingMode.FALLBACK
assert resolver.mode_for("cowork") is RoutingMode.OFF
def test_service_is_built_once_and_cached_on_the_context() -> None:
"""Every surface must share one instance, so future per-surface state (a
cool-down, a switch history) is shared rather than duplicated per widget."""
class StubCtx:
def __init__(self):
self.routing_calls = 0
self.config = type("Cfg", (), {"routing": {"confirm_timeout_sec": 45}})()
def routing(self):
self.routing_calls += 1
return FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
def project_routing_mode(self, surface):
return "off"
ctx = StubCtx()
first = build_routing_application_service(ctx)
second = build_routing_application_service(ctx)
assert first is second
assert ctx.routing_calls == 1
assert isinstance(first, RoutingApplicationService)
# The confirm timeout is read from config at call time, not frozen at build.
assert first.confirm_timeout() == pytest.approx(45.0)
+76
View File
@@ -0,0 +1,76 @@
"""R04-T04 — unit tests for the UI-state -> request mapping.
Three small rules used to sit inline in ``ui/cowork_tab.py::build_job``, where no
test could reach them: the turn's prompt is the last message in the working list,
the history is everything before it, and the confirm-commands flag becomes a gate
mode. Getting any of them wrong is silent (a duplicated user message, a command
that stops asking for approval), so they are pinned here.
"""
from __future__ import annotations
from pathlib import Path
from cowork_local.application.conversations.cowork_turn_request import (
build_cowork_turn_request,
)
def _build(**overrides):
base = {
"turn_id": "t3",
"session_id": "s1",
"messages": [{"role": "user", "content": "make me a report"}],
}
base.update(overrides)
return build_cowork_turn_request(**base)
def test_the_last_message_becomes_the_prompt_and_the_rest_the_history() -> None:
request = _build(messages=[
{"role": "user", "content": "earlier"},
{"role": "assistant", "content": "sure"},
{"role": "user", "content": "now this"},
])
assert request.prompt == "now this"
assert request.messages == ({"role": "user", "content": "earlier"},
{"role": "assistant", "content": "sure"})
def test_an_empty_working_list_yields_an_empty_prompt() -> None:
# Defensive: a turn with no message at all must not raise on messages[-1].
request = _build(messages=[])
assert request.prompt == ""
assert request.messages == ()
def test_confirming_commands_puts_the_turn_in_confirm_gate_mode() -> None:
assert _build(confirm_commands=True).gate_mode == "confirm"
assert _build(confirm_commands=False).gate_mode == "auto"
assert _build().gate_mode == "auto" # auto-run is the default
def test_the_captured_widget_state_is_carried_into_the_request() -> None:
request = _build(
surface="cowork", project_id="p7", title="Weekly report",
provider_id="anthropic", model="claude-sonnet-4-6",
instructions="PROJECT RULES", output_dir="out/.turns/t3",
home_output_root="out", agent_role="cowork",
)
assert (request.turn_id, request.session_id) == ("t3", "s1")
assert (request.surface, request.project_id, request.title) == \
("cowork", "p7", "Weekly report")
assert (request.provider_id, request.model) == ("anthropic", "claude-sonnet-4-6")
assert request.project_context == "PROJECT RULES"
assert request.output_dir == Path("out/.turns/t3")
assert request.home_output_root == Path("out")
assert request.agent_role == "cowork"
def test_the_prompt_survives_a_message_whose_content_is_missing() -> None:
request = _build(messages=[{"role": "user"}])
assert request.prompt == ""
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests for FakeProvider and FakeToolExecutor test doubles."""
from __future__ import annotations
import pytest
from providers.base import ProviderError
from tests.fakes.fake_provider import FakeProvider
from tests.fakes.fake_tool_executor import FakeToolExecutor
def test_fake_provider_text_streaming() -> None:
"""Verify that FakeProvider streams text chunks to on_text callback."""
provider = FakeProvider()
provider.queue_response(content="Hello world", chunks=["Hello ", "world"])
streamed: list[str] = []
response = provider.chat(
messages=[{"role": "user", "content": "Hi"}],
on_text=lambda piece: streamed.append(piece),
)
assert response["role"] == "assistant"
assert response["content"] == "Hello world"
assert "".join(streamed) == "Hello world"
assert provider.call_count == 1
def test_fake_provider_tool_calls_and_reasoning() -> None:
"""Verify reasoning streaming and tool_calls payload emission."""
provider = FakeProvider()
tool_call = {
"id": "call_123",
"name": "save_file",
"arguments": {"filename": "out.txt", "content": "data"},
}
provider.queue_response(
content="Creating file",
tool_calls=[tool_call],
reasoning="User wants output in a file",
)
reasoning_chunks: list[str] = []
response = provider.chat(
messages=[{"role": "user", "content": "Save to out.txt"}],
on_reasoning=lambda piece: reasoning_chunks.append(piece),
)
assert response["content"] == "Creating file"
assert response["tool_calls"] == [tool_call]
assert reasoning_chunks == ["User wants output in a file"]
def test_fake_provider_error_injection() -> None:
"""Verify that queued exceptions are raised on demand."""
provider = FakeProvider()
provider.queue_error(ProviderError("Rate limit exceeded (429)"))
with pytest.raises(ProviderError, match="Rate limit exceeded"):
provider.chat(messages=[{"role": "user", "content": "Hi"}])
def test_fake_provider_cancellation() -> None:
"""Verify that cancellation stops execution immediately."""
provider = FakeProvider()
provider.queue_response(content="Long reply", chunks=["Part 1", "Part 2"])
is_cancelled = False
def cancel_fn() -> bool:
return is_cancelled
is_cancelled = True
with pytest.raises(ProviderError, match="aborted by user cancel"):
provider.chat(
messages=[{"role": "user", "content": "Hi"}],
cancel=cancel_fn,
)
def test_fake_tool_executor() -> None:
"""Verify that FakeToolExecutor records calls and returns expected mock outputs."""
executor = FakeToolExecutor()
executor.set_mock_response("read_file", {"ok": True, "content": "file contents"})
executor.register_handler("calc", lambda args: {"ok": True, "result": args.get("a", 0) + args.get("b", 0)})
res1 = executor.execute("read_file", {"path": "test.txt"})
assert res1["ok"] is True
assert res1["content"] == "file contents"
res2 = executor.execute("calc", {"a": 5, "b": 10})
assert res2["result"] == 15
assert len(executor.call_log) == 2
assert executor.get_calls_for("calc")[0]["args"] == {"a": 5, "b": 10}
+204
View File
@@ -0,0 +1,204 @@
"""R03-T02 — unit tests for ProviderDescriptor and the central ProviderRegistry.
Covers what the rest of the app now relies on the catalogue for: resolving ids
and aliases, resolving a bare model id back to its provider, filling in default
models, and refusing to let a duplicate registration silently hijack a built-in.
"""
from __future__ import annotations
import pytest
from cowork_local.domain.models.provider_descriptor import (
AuthKind,
ProviderDescriptor,
WireProtocol,
)
from cowork_local.infrastructure.providers.provider_registry import (
BUILTIN_DESCRIPTORS,
ProviderNotFoundError,
ProviderRegistry,
)
def make_descriptor(**overrides) -> ProviderDescriptor:
"""A minimal valid descriptor; tests override just the field under test."""
fields = dict(
provider_id="demo",
display_name="Demo provider",
wire_protocol=WireProtocol.OPENAI_COMPAT,
default_model="demo-small",
models=("demo-small", "demo-large"),
)
fields.update(overrides)
return ProviderDescriptor(**fields)
# --------------------------------------------------------------------------- #
# ProviderDescriptor
# --------------------------------------------------------------------------- #
def test_descriptor_rejects_an_empty_id() -> None:
"""An id-less descriptor could never be looked up, so it must not exist."""
with pytest.raises(ValueError):
make_descriptor(provider_id="")
def test_descriptor_rejects_a_non_enum_protocol() -> None:
"""The protocol drives adapter selection; a stray string would silently
fall through to "no adapter" at build time instead of failing here."""
with pytest.raises(TypeError):
make_descriptor(wire_protocol="openai_compat")
def test_descriptor_is_immutable() -> None:
"""Descriptors are shared process-wide; a mutation would be visible to every
other reader mid-iteration."""
descriptor = make_descriptor()
with pytest.raises(Exception):
descriptor.default_model = "hacked" # type: ignore[misc]
def test_id_matching_ignores_case_and_honours_aliases() -> None:
"""Provider ids come from hand-edited config files and old app versions."""
descriptor = make_descriptor(aliases=("legacy-demo",))
assert descriptor.matches("DEMO")
assert descriptor.matches(" legacy-demo ")
assert not descriptor.matches("other")
def test_capabilities_use_the_routing_vocabulary() -> None:
"""The set must be feedable straight into the routing selector's filter."""
descriptor = make_descriptor(supports_vision=True, supports_tools=True,
supports_streaming=False)
assert descriptor.capabilities == frozenset({"vision", "tools"})
assert descriptor.has_capability("vision")
assert not descriptor.has_capability("streaming")
def test_average_cost_is_none_when_a_price_is_unknown() -> None:
"""Unknown prices stay unknown — a guessed number would silently skew the
routing scorer's cost term."""
assert make_descriptor(cost_per_1k_input=0.5).avg_cost_per_1k is None
priced = make_descriptor(cost_per_1k_input=1.0, cost_per_1k_output=3.0)
# Same 1:3 input:output weighting as ModelMetadata.avg_cost_per_1k.
assert priced.avg_cost_per_1k == pytest.approx((1.0 + 9.0) / 4.0)
def test_resolve_model_prefers_the_caller_then_the_default() -> None:
"""One place implements the "picked model or provider default" fallback that
every chat surface used to re-implement inline."""
descriptor = make_descriptor()
assert descriptor.resolve_model("demo-large") == "demo-large"
assert descriptor.resolve_model("") == "demo-small"
assert descriptor.resolve_model(" ") == "demo-small"
def test_with_models_repoints_a_default_that_vanished() -> None:
"""After discovery, the default must still name a model that exists."""
descriptor = make_descriptor()
updated = descriptor.with_models(["demo-v2", "demo-v2", "demo-v3"])
assert updated.models == ("demo-v2", "demo-v3") # de-duplicated, order kept
assert updated.default_model == "demo-v2"
assert descriptor.models == ("demo-small", "demo-large"), "original was mutated"
def test_with_models_keeps_a_default_that_survived() -> None:
"""Discovery must not reshuffle a user's working selection."""
updated = make_descriptor().with_models(["demo-large", "demo-small"])
assert updated.default_model == "demo-small"
# --------------------------------------------------------------------------- #
# ProviderRegistry
# --------------------------------------------------------------------------- #
def test_registry_resolves_ids_aliases_and_reports_unknowns() -> None:
"""Lookup must be forgiving about form, but loud about genuinely unknown
providers — a typo should fail at the call site, not as a None later."""
registry = ProviderRegistry([make_descriptor(aliases=("legacy-demo",))])
assert registry.get("demo").provider_id == "demo"
assert registry.get("legacy-demo").provider_id == "demo"
assert registry.find("missing") is None
assert "demo" in registry
with pytest.raises(ProviderNotFoundError):
registry.get("missing")
def test_registry_refuses_to_overwrite_silently_but_replace_works() -> None:
"""A second registration of the same id is almost always a bug; updating a
descriptor is a deliberate act with its own method."""
registry = ProviderRegistry([make_descriptor()])
with pytest.raises(ValueError):
registry.register(make_descriptor(display_name="Impostor"))
registry.replace(make_descriptor(display_name="Renamed"))
assert registry.get("demo").display_name == "Renamed"
assert len(registry) == 1
def test_registry_re_registering_an_identical_descriptor_is_a_no_op() -> None:
"""Idempotent registration keeps repeated bootstrap calls harmless."""
registry = ProviderRegistry([make_descriptor()])
registry.register(make_descriptor())
assert len(registry) == 1
def test_find_by_model_resolves_a_bare_model_id() -> None:
"""Routing decisions and saved conversations sometimes carry only a model
name; the registry is what turns that back into a provider."""
registry = ProviderRegistry([make_descriptor()])
assert registry.find_by_model("demo-large").provider_id == "demo"
# A gateway model we cannot enumerate offline is a miss, not an error — the
# caller falls back to the configured active provider.
assert registry.find_by_model("unknown-model") is None
assert registry.find_by_model("") is None
def test_builtin_catalogue_covers_every_configured_provider() -> None:
"""The catalogue and DEFAULT_CONFIG must not drift: a provider users can
configure but the registry cannot build is a dead Settings entry."""
from cowork_local.config import DEFAULT_CONFIG
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
for provider_id in DEFAULT_CONFIG["providers"]:
assert registry.find(provider_id) is not None, f"{provider_id} missing from registry"
def test_build_fills_in_the_default_model() -> None:
"""A half-written config must still produce a usable provider rather than an
empty model id that only fails once the request reaches the gateway."""
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
provider = registry.build("anthropic", {"api_key": "k"})
assert provider.model == registry.get("anthropic").default_model
def test_build_respects_an_explicit_model() -> None:
"""Per-tab model selection must win over the catalogue default."""
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
provider = registry.build("anthropic", {"api_key": "k", "model": "claude-opus-4-8"})
assert provider.model == "claude-opus-4-8"
def test_factory_still_raises_provider_error_for_unknown_ids() -> None:
"""Existing call sites catch ProviderError; routing lookups through the
registry must not change the exception type they see."""
from cowork_local.providers import build_provider
from cowork_local.providers.base import ProviderError
with pytest.raises(ProviderError):
build_provider("definitely-not-a-provider", {})
@@ -0,0 +1,384 @@
"""R03-T03 — unit tests for the unified routing decision rules.
The point of moving these rules out of the three chat widgets is that they can
now be exercised without Qt, without the assessment store and without a network:
the service talks to two narrow ports, so every mode is driven here by ~10-line
fakes. Each test names the behaviour a chat surface depends on.
"""
from __future__ import annotations
import pytest
from cowork_local.application.model_routing import (
RouteEvaluation,
RoutingApplicationService,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
class FakeDecisionPort:
"""A routing engine that returns a canned verdict and records its input."""
def __init__(self, evaluation: RouteEvaluation) -> None:
self.evaluation = evaluation
self.calls: list = []
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
self.calls.append((request, mode))
return self.evaluation
class ExplodingDecisionPort:
"""An engine that fails — proves routing degrades instead of breaking a turn."""
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
raise RuntimeError("assessment store is corrupt")
class FakeModeResolver:
"""Per-surface mode lookup, standing in for the workspace settings."""
def __init__(self, mode) -> None:
self.mode = mode
self.surfaces: list = []
def mode_for(self, surface: str):
self.surfaces.append(surface)
return self.mode
def make_request(**overrides) -> RoutingRequest:
"""A representative turn: Cowork chat, currently on a cheap OpenAI model."""
fields = dict(
surface="cowork",
prompt="Refactor this function",
current_provider="codex",
current_model="gpt-4o-mini",
)
fields.update(overrides)
return RoutingRequest(**fields)
def switch_evaluation(**overrides) -> RouteEvaluation:
"""An engine verdict that proposes a switch to a better coding model."""
fields = dict(
task_type="coding",
should_switch=True,
target_provider="anthropic",
target_model="claude-sonnet-4-6",
score_gain=0.21,
reason="coding fit 0.88 > current 0.67",
decision=object(),
)
fields.update(overrides)
return RouteEvaluation(**fields)
# --------------------------------------------------------------------------- #
# Off
# --------------------------------------------------------------------------- #
def test_off_mode_never_consults_the_engine() -> None:
"""Off must be free: no ranking, no store read, no decision at all."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.OFF))
outcome = service.resolve(make_request())
assert outcome.switched is False
assert outcome.provider is None and outcome.model is None
assert port.calls == [], "Off mode must not call the routing engine"
def test_missing_mode_resolver_defaults_to_off() -> None:
"""Routing stays opt-in: with no way to read the mode, never switch."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port)
outcome = service.resolve(make_request())
assert outcome.mode is RoutingMode.OFF
assert outcome.switched is False
def test_empty_prompt_is_not_routed() -> None:
"""An empty message carries no signal to classify, so the engine is skipped."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request(prompt=" "))
assert outcome.switched is False
assert port.calls == []
# --------------------------------------------------------------------------- #
# Auto
# --------------------------------------------------------------------------- #
def test_auto_mode_switches_silently() -> None:
"""Auto applies the engine's verdict without asking the user."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is True
assert outcome.provider == "anthropic"
assert outcome.model == "claude-sonnet-4-6"
assert outcome.task_type == "coding"
assert outcome.score_gain == pytest.approx(0.21)
assert outcome.should_notify is True
def test_auto_mode_keeps_current_when_nothing_is_better() -> None:
"""No proposed switch means the surface's own selection is untouched."""
port = FakeDecisionPort(switch_evaluation(
should_switch=False, reason="current model is already best-fit"))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is False
assert outcome.provider is None
assert "already best-fit" in outcome.reason
def test_switch_without_a_target_is_ignored() -> None:
"""A verdict that says "switch" but names nothing is not actionable — a
surface must never be handed an empty model id."""
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model=None))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is False
def test_same_provider_switch_keeps_the_current_provider() -> None:
"""A model-only switch must not blank out the provider the surface uses."""
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model="o3"))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert outcome.switched is True
assert outcome.provider == "codex" # unchanged, from the request
assert outcome.model == "o3"
# --------------------------------------------------------------------------- #
# Manual
# --------------------------------------------------------------------------- #
def test_manual_mode_switches_only_after_approval() -> None:
"""Manual's contract: ask first, then apply exactly what was approved."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(
port, FakeModeResolver(RoutingMode.MANUAL),
confirm_timeout_sec=lambda: 30.0,
)
asked: list = []
def confirm(decision, timeout):
asked.append((decision, timeout))
return True
outcome = service.resolve(make_request(), confirm=confirm)
assert outcome.switched is True
assert len(asked) == 1
# The configured timeout must reach the dialog, not a hard-coded default.
assert asked[0][1] == pytest.approx(30.0)
def test_manual_mode_decline_is_reported_distinctly() -> None:
""""The user said no" must be distinguishable from "nothing better found",
so a surface can stay quiet in one case and explain itself in the other."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
outcome = service.resolve(make_request(), confirm=lambda decision, timeout: False)
assert outcome.switched is False
assert outcome.declined is True
def test_manual_mode_without_a_callback_never_switches() -> None:
"""Silently switching in Manual mode would violate the mode's promise."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
outcome = service.resolve(make_request(), confirm=None)
assert outcome.switched is False
def test_manual_mode_treats_a_broken_dialog_as_a_decline() -> None:
"""A crashing confirm dialog must not auto-approve a model change."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
def confirm(decision, timeout):
raise RuntimeError("dialog blew up")
outcome = service.resolve(make_request(), confirm=confirm)
assert outcome.switched is False
assert outcome.declined is True
# --------------------------------------------------------------------------- #
# Fallback
# --------------------------------------------------------------------------- #
def test_fallback_keeps_a_healthy_model_even_when_a_better_one_exists() -> None:
"""Fallback is a resilience mode, not an optimiser: a usable pinned model
wins over a higher-scoring candidate."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=True))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
outcome = service.resolve(make_request())
assert outcome.switched is False
assert "healthy" in outcome.reason
def test_fallback_switches_when_the_current_model_cannot_serve_the_turn() -> None:
"""The one case Fallback exists for: rescue an unusable selection."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
outcome = service.resolve(make_request())
assert outcome.switched is True
assert outcome.model == "claude-sonnet-4-6"
def test_fallback_asks_the_engine_with_auto_semantics() -> None:
"""The engine only understands off/auto/manual, so Fallback must reach it as
Auto — otherwise the engine would reject the unknown mode and rank nothing."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
service.resolve(make_request())
assert port.calls[0][1] is RoutingMode.AUTO
def test_fallback_never_confirms_with_the_user() -> None:
"""Rescuing an unusable model is not a proposal — it happens silently."""
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
asked: list = []
outcome = service.resolve(
make_request(), confirm=lambda decision, timeout: asked.append(1) or True)
assert outcome.switched is True
assert asked == []
def test_fallback_with_no_replacement_keeps_current() -> None:
"""Nothing to fall back to means keep going with what we have and let the
provider surface the real error, rather than blanking the model."""
port = FakeDecisionPort(switch_evaluation(
current_is_usable=False, target_provider=None, target_model=None))
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
outcome = service.resolve(make_request())
assert outcome.switched is False
# --------------------------------------------------------------------------- #
# Robustness & plumbing
# --------------------------------------------------------------------------- #
def test_engine_failure_degrades_to_keep_current() -> None:
"""A broken assessment store must never stop a user sending a message."""
service = RoutingApplicationService(
ExplodingDecisionPort(), FakeModeResolver(RoutingMode.AUTO))
outcome = service.resolve(make_request())
assert isinstance(outcome, RoutingOutcome)
assert outcome.switched is False
assert "error" in outcome.reason
def test_mode_resolver_failure_degrades_to_off() -> None:
"""An unreadable workspace config must not enable routing by accident."""
class BrokenResolver:
def mode_for(self, surface):
raise OSError("workspace file unreadable")
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, BrokenResolver())
outcome = service.resolve(make_request())
assert outcome.mode is RoutingMode.OFF
assert port.calls == []
def test_explicit_request_mode_overrides_the_resolver() -> None:
"""A surface may pin the mode for one turn (tests, replay, admin actions)."""
resolver = FakeModeResolver(RoutingMode.OFF)
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, resolver)
outcome = service.resolve(make_request(mode=RoutingMode.AUTO))
assert outcome.switched is True
assert resolver.surfaces == [], "an explicit mode must skip the resolver"
def test_request_is_forwarded_to_the_engine_unchanged() -> None:
"""Surface, prompt and pinned task type must survive the hand-off — AI-Edit
relies on its "coding" pin reaching the engine."""
port = FakeDecisionPort(switch_evaluation())
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
request = make_request(surface="ai_edit", task_type="coding",
required_capabilities=("vision",))
service.resolve(request)
forwarded = port.calls[0][0]
assert forwarded is request
assert forwarded.surface == "ai_edit"
assert forwarded.task_type == "coding"
assert forwarded.required_capabilities == ("vision",)
@pytest.mark.parametrize(
"raw, expected",
[
("auto", RoutingMode.AUTO),
("MANUAL", RoutingMode.MANUAL),
(" fallback ", RoutingMode.FALLBACK),
("nonsense", RoutingMode.OFF),
("", RoutingMode.OFF),
(None, RoutingMode.OFF),
],
)
def test_mode_parsing_is_forgiving(raw, expected) -> None:
"""Config values are hand-edited; an unknown one must degrade, not raise."""
assert RoutingMode.parse(raw) is expected
def test_confirm_timeout_falls_back_to_the_default_when_unusable() -> None:
"""A corrupted timeout must not produce a zero-second dialog that declines
every switch before the user can read it."""
service = RoutingApplicationService(
FakeDecisionPort(switch_evaluation()),
FakeModeResolver(RoutingMode.MANUAL),
confirm_timeout_sec=lambda: 0.0,
)
assert service.confirm_timeout() == RoutingApplicationService.DEFAULT_CONFIRM_TIMEOUT_SEC
def test_routing_request_is_immutable() -> None:
"""The snapshot must not change under a turn that is already in flight."""
request = make_request()
with pytest.raises(Exception):
request.prompt = "something else" # type: ignore[misc]
+34
View File
@@ -0,0 +1,34 @@
"""R04-T05 — unit tests for the unattended-run prompt assembly.
``_run_agent`` used to build this by rebinding ``prompt`` three times, each with
its own ``f"{block}\n\n{prompt}"``. The ORDER that produced is load-bearing (the
plan reminder has to lead, the task's own words have to trail) and it was
readable only by replaying the rebindings in your head.
"""
from __future__ import annotations
from cowork_local.core.task_executors import _unattended_prompt
def test_the_plan_reminder_leads_and_the_task_prompt_trails() -> None:
built = _unattended_prompt("write the report")
assert built.startswith("This runs unattended (Schedule Task)")
assert built.endswith("write the report")
def test_a_skill_block_sits_between_the_reminder_and_the_agent_persona() -> None:
built = _unattended_prompt("write the report", skill_text="SKILL",
agent_instructions="PERSONA")
assert built.index("This runs unattended") < built.index("SKILL")
assert built.index("SKILL") < built.index("PERSONA")
assert built.index("PERSONA") < built.index("write the report")
def test_absent_blocks_leave_no_extra_blank_lines() -> None:
built = _unattended_prompt("do it", skill_text="", agent_instructions=None)
assert "\n\n\n" not in built
assert built.count("do it") == 1
+36
View File
@@ -0,0 +1,36 @@
"""R04-T04 — unit tests for the shared turn-runtime helpers.
``combine_instructions`` is the small rule the UI applied inline: a turn's
standing instructions are several independent blocks (project context, an Admin
agent's persona, a skill's rules, an unattended-run reminder) that must be joined
with one blank line, skipping whatever is absent. Two call sites need it (T04's
widget and T05's task runner), which is exactly when a rule stops being an inline
expression.
"""
from __future__ import annotations
from cowork_local.application.conversations.turn_runtime import combine_instructions
def test_two_blocks_are_joined_by_a_blank_line() -> None:
assert combine_instructions("PROJECT", "AGENT") == "PROJECT\n\nAGENT"
def test_an_absent_block_leaves_no_blank_line_behind() -> None:
assert combine_instructions("", "AGENT") == "AGENT"
assert combine_instructions("PROJECT", "") == "PROJECT"
assert combine_instructions("PROJECT", None) == "PROJECT"
def test_whitespace_only_blocks_do_not_count_as_instructions() -> None:
assert combine_instructions(" \n ", "AGENT") == "AGENT"
def test_nothing_to_say_produces_an_empty_string() -> None:
assert combine_instructions() == ""
assert combine_instructions("", None, " ") == ""
def test_more_than_two_blocks_keep_their_order() -> None:
assert combine_instructions("A", "B", "C") == "A\n\nB\n\nC"
+184
View File
@@ -0,0 +1,184 @@
"""R03-T06 — unit tests for the token-usage telemetry seam.
The seam exists so provider adapters stop owning telemetry policy. These tests
pin the two properties that makes that safe: events reach every subscriber, and
no telemetry failure can ever propagate back into the turn that produced it.
"""
from __future__ import annotations
import pytest
from cowork_local.infrastructure.telemetry import usage_sink
from cowork_local.infrastructure.telemetry.usage_sink import (
CompositeUsageSink,
InMemoryUsageSink,
UsageEvent,
UsageTrackerSink,
)
@pytest.fixture(autouse=True)
def isolated_sink(monkeypatch):
"""Give every test its own process-wide sink.
Autouse because a leaked sink would let one test's subscriber observe the
next test's events — and, worse, let a test write to the developer's real
usage files through the default tracker sink.
"""
monkeypatch.setattr(usage_sink, "_sink", None)
yield
monkeypatch.setattr(usage_sink, "_sink", None)
def make_event(**overrides) -> UsageEvent:
fields = dict(provider="anthropic", model="claude-sonnet-4-6",
input_tokens=100, output_tokens=40, cached_tokens=10)
fields.update(overrides)
return UsageEvent(**fields)
# --------------------------------------------------------------------------- #
# UsageEvent
# --------------------------------------------------------------------------- #
def test_event_is_immutable() -> None:
"""A subscriber must not be able to edit the event the next one receives."""
event = make_event()
with pytest.raises(Exception):
event.input_tokens = 0 # type: ignore[misc]
def test_total_tokens_does_not_double_count_cache_reads() -> None:
"""Every gateway we support already reports cached tokens inside the input
count, so adding them again would inflate the dashboard."""
assert make_event().total_tokens == 140
def test_to_dict_uses_the_stored_row_keys() -> None:
"""Matching the tracker's short keys lets a caller diff an event against a
persisted row without a translation table."""
row = make_event(source="cowork", label="Refactor chat").to_dict()
assert row["in"] == 100 and row["out"] == 40 and row["cache"] == 10
assert row["source"] == "cowork" and row["label"] == "Refactor chat"
assert row["estimated"] is False
# --------------------------------------------------------------------------- #
# Fan-out
# --------------------------------------------------------------------------- #
def test_publish_reaches_every_subscriber() -> None:
"""The whole point of the seam: extra consumers attach without patching
provider code."""
first, second = InMemoryUsageSink(), InMemoryUsageSink()
usage_sink.set_usage_sink(CompositeUsageSink([first, second]))
usage_sink.publish(make_event())
assert len(first.snapshot()) == 1
assert len(second.snapshot()) == 1
def test_one_failing_subscriber_does_not_starve_the_others() -> None:
"""A buggy consumer must not silently disable the Dashboard."""
class Exploding:
def emit(self, event):
raise RuntimeError("subscriber is broken")
healthy = InMemoryUsageSink()
usage_sink.set_usage_sink(CompositeUsageSink([Exploding(), healthy]))
usage_sink.publish(make_event())
assert len(healthy.snapshot()) == 1
def test_subscribe_and_unsubscribe_round_trip() -> None:
"""Teardown code calls unsubscribe unconditionally, so removing a sink that
was never added must be harmless."""
extra = InMemoryUsageSink()
usage_sink.subscribe(extra)
usage_sink.publish(make_event())
usage_sink.unsubscribe(extra)
usage_sink.unsubscribe(extra) # second removal is a no-op
usage_sink.publish(make_event(model="claude-opus-4-8"))
assert [e.model for e in extra.snapshot()] == ["claude-sonnet-4-6"]
def test_default_sink_is_the_usage_tracker() -> None:
"""Out of the box the seam must preserve the existing Dashboard pipeline."""
sinks = usage_sink.get_usage_sink().sinks()
assert any(isinstance(s, UsageTrackerSink) for s in sinks)
def test_in_memory_sink_totals_and_clears() -> None:
"""Test-double conveniences the contract suite relies on."""
sink = InMemoryUsageSink()
sink.emit(make_event())
sink.emit(make_event(input_tokens=1, output_tokens=1, cached_tokens=0))
assert sink.total_tokens == 142
sink.clear()
assert sink.snapshot() == []
# --------------------------------------------------------------------------- #
# UsageTrackerSink forwarding
# --------------------------------------------------------------------------- #
def test_tracker_sink_forwards_the_counts() -> None:
"""The adapter must hand the tracker exactly what the provider measured."""
recorded: list = []
def fake_record(provider, model, tokens_in, tokens_out, cached, estimated=False):
recorded.append((provider, model, tokens_in, tokens_out, cached, estimated))
UsageTrackerSink(recorder=fake_record).emit(make_event(estimated=True))
assert recorded == [("anthropic", "claude-sonnet-4-6", 100, 40, 10, True)]
def test_tracker_sink_restores_the_thread_context_it_borrowed() -> None:
"""An event carrying its own attribution must relabel ONE row, not every
later turn that happens to run on the same worker thread."""
from cowork_local.core import usage_tracker as tracker
tracker.set_context("cowork", "original chat")
seen: list = []
UsageTrackerSink(recorder=lambda *a, **k: seen.append(tracker.current_context())).emit(
make_event(source="co4e", label="flow run"))
assert seen == [("co4e", "flow run")], "event attribution was not applied"
assert tracker.current_context() == ("cowork", "original chat")
def test_tracker_sink_swallows_recorder_failures() -> None:
"""Telemetry is never allowed to abort an otherwise successful turn."""
def boom(*_args, **_kwargs):
raise OSError("usage directory is read-only")
UsageTrackerSink(recorder=boom).emit(make_event()) # must not raise
def test_publish_never_raises_even_with_a_broken_sink() -> None:
"""Last line of defence: providers call publish() inside their stream loop."""
class Hostile:
def emit(self, event):
raise RuntimeError("nope")
def sinks(self):
raise RuntimeError("nope")
usage_sink.set_usage_sink(Hostile())
usage_sink.publish(make_event()) # must not raise
def test_estimate_tokens_matches_the_tracker_heuristic() -> None:
"""Re-exported so adapters need one telemetry import; it must not drift."""
from cowork_local.core import usage_tracker as tracker
for text in ("", "a", "hello world", "x" * 4001):
assert usage_sink.estimate_tokens(text) == tracker.estimate_tokens(text)
+35 -28
View File
@@ -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.
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.
Since R03-T04 the Off/Auto/Manual/Fallback rules live in
``application/model_routing/routing_application_service.py``; the copy
that used to sit here (and again in Co4E and AI-Edit) is gone. What
remains is the widget's own job: snapshot the tab's provider/model into
a request, host the Manual-mode modal, and render the outcome by setting
``self._routed_provider``/``self._routed_model`` for THIS turn (honoured
by :meth:`build_provider`) plus a status bubble.
Never raises — a routing failure must never block sending a message; it
just falls back to the tab's own model.
"""
# Recompute fresh each message; clear any previous turn's override.
self._routed_provider = None
@@ -651,33 +655,36 @@ 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():
return
try:
mode = self.ctx.project_routing_mode(self.kind) # per-workspace mode
if mode == "off":
return
service = self.ctx.routing()
from ..application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from .routing_toggle import confirm_switch
# The model the tab WOULD use without routing — the picker's choice,
# or the provider's configured default when nothing is picked.
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
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface=self.kind, # per-workspace mode key ("cowork"/…)
prompt=text,
current_provider=cur_provider,
current_model=cur_model,
),
# Manual mode only: the modal stays in the presentation layer so
# the application service never imports Qt.
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return # off / nothing better / declined → keep the tab's model
self._routed_provider = outcome.provider
self._routed_model = outcome.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}"))
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
turn["bubbles"].append(notice)
except Exception: # noqa: BLE001 — routing must never block a chat turn
self._routed_provider = None
+31 -24
View File
@@ -1849,36 +1849,43 @@ class Co4ETab(QWidget):
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."""
a cross-provider switch is chosen.
R03-T05: the Off/Auto/Manual/Fallback rules are no longer re-implemented
here — they come from the shared ``RoutingApplicationService``, so Co4E,
the Cowork chat and AI-Edit can never drift apart again. This method only
adapts between Co4E's state and the service's DTOs. Never raises — falls
back to the default model on any error.
"""
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()
from ..application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from .routing_toggle import confirm_switch
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
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="co4e",
prompt=request,
current_provider=cur_provider,
current_model=cur_model,
),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return "" # '' keeps the provider's configured default model
# Remembered so the worker's build_provider_for() can follow a
# cross-provider switch, not just a model change.
self._co4e_routed_provider = outcome.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
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
return outcome.model
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
self._co4e_routed_provider = None
return ""
+58 -19
View File
@@ -346,19 +346,45 @@ 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).
"""This turn's job: a frozen request run through the conversation service.
Since R04-T04 the widget no longer drives the turn loop. Every value a
turn depends on is read HERE, on the UI thread at submit time, and packed
into an immutable ``ConversationExecutionRequest`` — so clicking a
different model or switching workspace mid-answer cannot reach work
already in flight.
"""
output_dir = out_dir or self._session_output_dir()
# The sandbox folder is named by the turn id ('.turns/t3'); with no
# sandbox the session id identifies the turn well enough for the audit log.
turn_id = out_dir.name if out_dir is not None else self.session_id
session_id = self.session_id
title = self.title
project_id = self.project_id
home_output_root = self.workspace_dir()
# Captured at submit time (UI thread): the Admin-defined agent
# preset's instructions, if one is selected in the Agent picker.
agent_prompt = self.admin_agent_prompt()
# Per-workspace Auto-run override wins, else the global "confirm before
# running commands" setting. Frozen now, so a Settings change mid-turn
# cannot flip the rules this turn started under.
confirm_commands = self.ctx.project_confirm_commands()
# What the turn is recorded as running on. A routing override (R03) wins
# over the tab's own picker; '' means the provider's configured default.
# Informational only — an Admin-agent preset builds its own provider
# below, so treat these as the record, not the decision.
provider_id = self._routed_provider or self.ctx.config.active_provider
model = self._routed_model or self._model or ""
def job(worker: AgentWorker):
from ..core.chat_agent import run_cowork
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from ..application.conversations.cowork_turn_request import (
build_cowork_turn_request,
)
from ..application.conversations.turn_runtime import combine_instructions
from ..core.projects import load_project, project_context_text
provider = self.build_provider() # this tab's selected agent/model
@@ -367,23 +393,36 @@ class CoworkTab(ChatPanel):
# 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
# Shared project instructions (Claude-Projects style) plus the Admin
# agent's persona, refreshed each turn so edits in the Workspace
# screen apply immediately.
instructions = combine_instructions(
project_context_text(load_project(project_id)), 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).
# matches the pre-existing auto-run behavior. The gate lives on the
# worker because the UI resolves it from the main thread.
gate = None
if self.ctx.project_confirm_commands():
if 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)
service = build_cowork_conversation_service(
provider, output_dir, worker.emit_event, title=title,
project_context=instructions, extra_tools=extra_tools,
extra_executor=extra_exec, security_config=self.ctx.config,
gate=gate, agent_role=agent_roles.COWORK,
)
request = build_cowork_turn_request(
turn_id=turn_id, session_id=session_id, surface=self.kind,
project_id=project_id, title=title, messages=messages,
provider_id=provider_id, model=model, instructions=instructions,
output_dir=output_dir, home_output_root=home_output_root,
confirm_commands=gate is not None, agent_role=agent_roles.COWORK,
)
# Hand the widget's own list over: _reattach_running_turn replays
# from it while the turn is still running, and _finalize_turn slices
# it afterwards, so the service must append into that very object.
service.execute(request, legacy_event_sink(worker.emit_event),
cancel=worker.is_cancelled, messages=messages)
return {"messages": messages, "turn_dir": str(output_dir)}
return job
+26 -27
View File
@@ -923,43 +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."""
R03-T05: routes through the shared ``RoutingApplicationService`` instead
of repeating the Off/Auto/Manual/Fallback rules locally. Sets
``self._ai_routed_provider``/``_ai_routed_model`` for this run;
:meth:`_ai_provider` honours them. Never raises."""
self._ai_routed_provider = None
self._ai_routed_model = None
if not (instruction or "").strip():
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()
from ..application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from .routing_toggle import confirm_switch
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,
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="ai_edit",
prompt=instruction,
current_provider=cur_provider,
current_model=cur_model,
# AI-Edit turns are always code edits, so the task type is
# pinned rather than classified from the instruction text.
task_type="coding",
),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not result.should_switch:
if not outcome.switched:
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_routed_provider = outcome.provider
self._ai_routed_model = outcome.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}"))
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
except Exception: # noqa: BLE001 — routing must never block an edit
self._ai_routed_provider = None
self._ai_routed_model = None
+9 -5
View File
@@ -1,12 +1,13 @@
"""Off/Auto/Manual routing toggle + Auto-run toggle + Manual-mode confirm dialog.
"""Off/Auto/Manual/Fallback routing toggle + Auto-run toggle + confirm dialog.
Dropped into every chat surface's composer (Cowork / Co4E / AI-Edit). By
default a :class:`RoutingToggle` reads/writes the **per-workspace** mode via
``AppContext.project_routing_mode`` / ``set_project_routing_mode`` (so each
workspace keeps its own mode), but the storage is fully injectable through
``get_mode``/``set_mode`` callables — all the real decision logic lives in
``core/routing``. Call :meth:`refresh` when the active workspace changes so the
control shows that workspace's mode.
``application/model_routing`` (which the surfaces call through
``RoutingApplicationService``). Call :meth:`refresh` when the active workspace
changes so the control shows that workspace's mode.
"""
from __future__ import annotations
@@ -39,7 +40,7 @@ class RoutingToggle(QWidget):
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
"""
mode_changed = Signal(str) # "off" | "auto" | "manual"
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
def __init__(
self,
@@ -65,11 +66,14 @@ class RoutingToggle(QWidget):
self._label.setObjectName("hint")
self._combo = QComboBox()
self._combo.setToolTip(tr("routing.toggle_tooltip"))
# (data value, i18n key) — data is the persisted mode string.
# (data value, i18n key) — data is the persisted mode string. Order is
# least-to-most autonomous, with Fallback (R03-T03) last because it is
# the "only when something breaks" mode rather than a stronger Auto.
self._modes = [
("off", "routing.mode_off"),
("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"),
("fallback", "routing.mode_fallback"),
]
for value, key in self._modes:
self._combo.addItem(tr(key), value)