Compare commits
17
Commits
@@ -39,3 +39,32 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests -q
|
||||
|
||||
# --- CASAN Verification Gate -------------------------------------
|
||||
# Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để
|
||||
# biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng.
|
||||
#
|
||||
# Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3
|
||||
# (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để
|
||||
# thêm cổng không làm đỏ CI của hai team kia.
|
||||
|
||||
- name: "CASAN Check 1 — không có credential lộ (Team Gamma)"
|
||||
run: |
|
||||
python scripts/audit_security.py --self-test
|
||||
python scripts/audit_security.py
|
||||
|
||||
- name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)"
|
||||
run: |
|
||||
if [ -f scripts/check_loc.py ]; then
|
||||
python scripts/check_loc.py
|
||||
else
|
||||
echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua."
|
||||
fi
|
||||
|
||||
- name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)"
|
||||
run: |
|
||||
if [ -f scripts/check_imports.py ]; then
|
||||
python scripts/check_imports.py
|
||||
else
|
||||
echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua."
|
||||
fi
|
||||
|
||||
+11
-5
@@ -28,7 +28,10 @@ bower_components/
|
||||
.env.preview
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ
|
||||
# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải
|
||||
# bí mật) khỏi repo suốt 21-22/08.
|
||||
/secrets/
|
||||
credentials.json
|
||||
.npmrc
|
||||
.yarnrc
|
||||
@@ -36,9 +39,11 @@ credentials.json
|
||||
# =============================================================================
|
||||
# Build & Distribution
|
||||
# =============================================================================
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
# Neo vao goc — mau khong neo se nuot moi thu muc trung ten o moi do sau,
|
||||
# ke ca ma nguon. Da mac dung loi do voi secrets/ (xem khoi Credentials).
|
||||
/dist/
|
||||
/build/
|
||||
/out/
|
||||
.next/
|
||||
.nuxt/
|
||||
.output/
|
||||
@@ -73,7 +78,8 @@ desktop.ini
|
||||
# Logs & Debug
|
||||
# =============================================================================
|
||||
*.log
|
||||
logs/
|
||||
# Neo vao goc: infrastructure/logs/ la ma nguon, khong phai log chay may.
|
||||
/logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""adapters/ — Adapter riêng cho Qt (clock, thread, timer).
|
||||
|
||||
Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy
|
||||
bất kỳ script nào từ thư mục gốc repo (``python tools/...``,
|
||||
``python scripts/...``) thì ``platform/`` **che khuất module ``platform``
|
||||
của thư viện chuẩn**, và ``import keyring`` chết ngay với
|
||||
``AttributeError: module 'platform' has no attribute 'system'``.
|
||||
Repo có 26 script chạy đúng kiểu đó.
|
||||
|
||||
Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao
|
||||
giờ chạy python từ thư mục gốc".
|
||||
"""
|
||||
@@ -1 +1 @@
|
||||
"""Application Layer: Pure Python use cases and application services."""
|
||||
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1 +1,54 @@
|
||||
"""Application model routing package: model route decisions and multi-provider balancing."""
|
||||
"""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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,159 @@
|
||||
# Mô hình chính sách an toàn — CoworkLocal
|
||||
|
||||
R09-T01 · Team Gamma · viết 22/08/2026
|
||||
|
||||
Tài liệu này mô tả **hệ thống đang chạy**, không phải hệ thống mong muốn. Mọi
|
||||
khẳng định đều chỉ tới file và dòng cụ thể để đối chiếu được.
|
||||
|
||||
---
|
||||
|
||||
## 1. Câu hỏi quan trọng nhất: đây có phải rào chắn an ninh không
|
||||
|
||||
**Không.** `core/agent_security.py` nói thẳng ngay ở đầu file:
|
||||
|
||||
> *"this is a business productivity tool, not a hard security boundary"*
|
||||
|
||||
Điều đó quyết định mọi thứ còn lại. Cụ thể: **mọi tầng dùng AI đều mở khi
|
||||
hỏng** (`allowed=True` khi không gọi được validator, `core/agent_security.py:150`).
|
||||
Mạng chập chờn hay gateway trục trặc thì agent vẫn chạy, không bị khoá cứng.
|
||||
|
||||
Đánh đổi có chủ đích: chọn *dùng được* thay vì *chặn tuyệt đối*. Ai đọc tài
|
||||
liệu này để đánh giá rủi ro cần hiểu đúng điều đó — đây là lớp giảm tai nạn,
|
||||
không phải lớp chống kẻ tấn công có chủ đích.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hai loại quy tắc, đừng lẫn
|
||||
|
||||
| | Quy tắc xác định | Quy tắc do AI phán |
|
||||
|---|---|---|
|
||||
| Cách hoạt động | So khớp mẫu cố định | Hỏi một model |
|
||||
| Kết quả | Luôn giống nhau | Có thể khác nhau giữa hai lần |
|
||||
| Khi hỏng | Vẫn chạy | **Mở** (cho qua) |
|
||||
| Tắt được không | Không — luôn bật | Có, từng tầng một |
|
||||
| Ở đâu | Bộ phân loại mẫu chặn + sandbox | 3 tầng validate |
|
||||
|
||||
Câu ở `core/agent_security.py:250` nói rõ ranh giới:
|
||||
|
||||
> *"always-on block-pattern classifier + sandbox still apply regardless"*
|
||||
|
||||
Nghĩa là **tắt hết ba tầng AI thì vẫn còn hai lớp xác định**. Đây là điểm dễ
|
||||
hiểu nhầm nhất khi đọc màn Cài đặt: mấy công tắc ở đó **chỉ tắt phần AI**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ba tầng AI
|
||||
|
||||
Bật/tắt độc lập trong `agent_security` của `config.json`.
|
||||
|
||||
| Tầng | Kiểm cái gì | Khoá cấu hình | Khi nào chạy |
|
||||
|---|---|---|---|
|
||||
| Prompt | Yêu cầu của chính người dùng | `validate_prompt` | Trước khi agent làm gì |
|
||||
| Attachment | Văn bản trích ra từ tệp đính kèm | `validate_attachments` | Trước khi vào ngữ cảnh model |
|
||||
| Command | `run_command` / `install_package` | `validate_commands` | Trước khi thực thi |
|
||||
|
||||
Cả ba đọc chung một bộ luật: file cục bộ `core/security_rules.py` cộng thêm
|
||||
tài liệu quản trị viên đặt trên OneDrive (nếu có cấu hình). Riêng agent Code
|
||||
dùng bộ luật khác — `RULEforCode.md` thay vì `RULEBASE.md`.
|
||||
|
||||
Công tắc tổng `agent_security.enabled` tắt cả ba.
|
||||
|
||||
---
|
||||
|
||||
## 4. Chuyện gì xảy ra khi bị chặn
|
||||
|
||||
Theo đúng thứ tự trong `core/agent_security.py:266-273`:
|
||||
|
||||
1. Hiện thông báo trong khung chat — người dùng thấy ngay, kèm lý do
|
||||
2. Ghi `audit_log.record("security_block", …)` — vào nhật ký kiểm toán
|
||||
3. `notify_admin(...)` — gửi email quản trị viên
|
||||
4. Ném `SecurityBlocked` — dừng lượt chạy
|
||||
|
||||
Ba bước đầu **không được phép ném lỗi**. `audit_log.record()` có ghi rõ trong
|
||||
docstring: *"never raises — audit logging must never break a chat turn"*. Ghi
|
||||
nhật ký hỏng không được kéo theo cả phiên làm việc.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hỏi người dùng: trạng thái thứ ba
|
||||
|
||||
Ngoài cho/chặn còn một trạng thái nữa mà hệ thống hiện tại **có nhưng chưa gọi
|
||||
tên**: hỏi người dùng.
|
||||
|
||||
`ui/chat_panel.py:1312` kiểm `ctx.project_confirm_commands()` rồi bật
|
||||
`PermissionDialog`. Đó là một quyết định chính sách thật, nhưng nằm rải ở tầng
|
||||
giao diện chứ không phải một kết quả chính thức.
|
||||
|
||||
`domain/security/tool_policy.py` (đề xuất, chờ Team Hoa xác nhận) gộp lại
|
||||
thành ba trạng thái:
|
||||
|
||||
| | Nghĩa |
|
||||
|---|---|
|
||||
| `ALLOW` | Chạy |
|
||||
| `DENY` | Không chạy, có lý do |
|
||||
| `ASK` | Hỏi người dùng đã |
|
||||
|
||||
**`ASK` không phải là `allowed`.** Coi ASK như ALLOW nghĩa là tool chạy trước
|
||||
khi có ai đồng ý — bẫy dễ mắc nhất, đã có test riêng chặn.
|
||||
|
||||
Cổng chính sách **không tự bật hộp thoại**. Nó chỉ trả lời; hỏi ai và hỏi thế
|
||||
nào là việc của tầng giao diện. Nhờ vậy Co4E chạy nền mới dùng chung cổng được
|
||||
với Cowork chạy tương tác — Co4E không hỏi được thì đổi `ASK` thành `DENY`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bí mật
|
||||
|
||||
Từ 21/08 (R02-T05), API key **không còn nằm trong `config.json`**:
|
||||
|
||||
* Lưu trong kho của hệ điều hành qua `KeyringAdapter` — Windows Credential
|
||||
Manager, macOS Keychain, Linux Secret Service
|
||||
* `provider_conf()` đọc từ kho rồi ghép vào dict trả về, nên chỗ gọi không
|
||||
đổi (đường A, `GammaTeam_decisions.md`)
|
||||
* File cũ tự chuyển ở lần mở đầu tiên, có sao lưu trước khi chuyển
|
||||
|
||||
Máy không có kho bí mật (Linux headless, CI) thì **không chuyển** — thà để
|
||||
khoá trong file còn hơn xoá đi rồi người dùng mất khoá.
|
||||
|
||||
Kiểm bằng `python scripts/audit_security.py`, chạy tự động trong CI.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sandbox
|
||||
|
||||
`core/sandbox_manager.py` chạy lệnh trong môi trường hạn chế. Luôn bật, không
|
||||
tắt được, không phụ thuộc công tắc AI nào.
|
||||
|
||||
Năng lực khác nhau theo hệ điều hành — ma trận đầy đủ sẽ nằm ở
|
||||
`infrastructure/sandbox/sandbox_capabilities.py` (R09-T06, Hiệp phụ trách).
|
||||
Chỗ này cập nhật khi task đó xong.
|
||||
|
||||
---
|
||||
|
||||
## 8. Những chỗ đã biết là yếu
|
||||
|
||||
Ghi ra để người sau khỏi tưởng đã kín:
|
||||
|
||||
1. **Mở khi hỏng.** Gateway chết là ba tầng AI cho qua hết. Có chủ đích, nhưng
|
||||
nghĩa là không chống được kẻ tấn công biết cách làm validator ngừng trả lời.
|
||||
2. **Bí mật vẫn đi trong bộ nhớ.** Đường A ghép khoá vào dict `provider_conf()`
|
||||
trả về, nên khoá vẫn có thể lọt vào log gỡ lỗi hay ảnh chụp màn hình. Đường
|
||||
B (bỏ hẳn khỏi dict) đã ghi vào nợ kỹ thuật.
|
||||
3. **Bộ luật lấy từ OneDrive không ký số.** Ai sửa được tài liệu đó là sửa được
|
||||
luật.
|
||||
4. **`ASK` chưa được nối vào Co4E.** Co4E chạy nền, chưa có đường hỏi người
|
||||
dùng — hiện phải chọn giữa cho qua hết hoặc chặn hết.
|
||||
|
||||
---
|
||||
|
||||
## Đối chiếu nhanh
|
||||
|
||||
| Nội dung | Nguồn |
|
||||
|---|---|
|
||||
| Ba tầng AI, mở khi hỏng | `core/agent_security.py:1-25` |
|
||||
| Phân loại mẫu + sandbox luôn bật | `core/agent_security.py:250` |
|
||||
| Thứ tự khi bị chặn | `core/agent_security.py:266-273` |
|
||||
| Nhật ký không được ném lỗi | `core/audit_log.py:46` |
|
||||
| Hỏi người dùng | `ui/chat_panel.py:1312` |
|
||||
| Ba trạng thái chính sách | `domain/security/tool_policy.py` |
|
||||
| Bí mật | `infrastructure/secrets/keyring_adapter.py` |
|
||||
@@ -0,0 +1,768 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Phân Việc Refactor Team Gamma</title>
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
:root {
|
||||
--ground: #FAF9FC; --surface: #FFFFFF; --surface-2: #F3F0F8;
|
||||
--ink: #191325; --muted: #665E7C; --line: #E4DEEE;
|
||||
--accent: #6D3A9E;
|
||||
--lead: #6D3A9E; --m1: #14707F; --m2: #A65418;
|
||||
--warn: #A81F1A; --ok: #1B6B40;
|
||||
--lead-wash: #F1E9F9; --m1-wash: #E2F1F3; --m2-wash: #F8EDE2;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
||||
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
||||
--accent: #C08CF0;
|
||||
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
||||
--warn: #F08A84; --ok: #6FD69C;
|
||||
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
||||
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
||||
--accent: #C08CF0;
|
||||
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
||||
--warn: #F08A84; --ok: #6FD69C;
|
||||
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--ground); color: var(--ink);
|
||||
font-family: "Segoe UI", -apple-system, system-ui, "Helvetica Neue", sans-serif;
|
||||
font-size: 15px; line-height: 1.62; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 28px 96px; }
|
||||
code, .mono, td.day, th.day, .num {
|
||||
font-family: Consolas, "Cascadia Mono", "SF Mono", ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
header.top { border-bottom: 2px solid var(--ink); padding: 56px 0 22px; margin-bottom: 34px; }
|
||||
.eyebrow { font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--accent); font-weight: 700; margin: 0 0 14px; }
|
||||
h1 { font-size: clamp(30px, 4.4vw, 44px); line-height: 1.1; margin: 0 0 16px;
|
||||
font-weight: 700; letter-spacing: -.02em; text-wrap: balance; }
|
||||
.lede { font-size: 17px; color: var(--muted); margin: 0; max-width: 64ch; }
|
||||
.alias { margin: 18px 0 0; padding: 12px 16px; max-width: 74ch;
|
||||
background: var(--surface-2); border-left: 3px solid var(--accent);
|
||||
border-radius: 3px; font-size: 14px; color: var(--muted); }
|
||||
.alias b { color: var(--ink); }
|
||||
.facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 26px;
|
||||
padding-top: 20px; border-top: 1px solid var(--line); }
|
||||
.fact .k { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||
color: var(--muted); display: block; margin-bottom: 3px; }
|
||||
.fact .v { font-size: 15px; font-weight: 600; }
|
||||
|
||||
h2 { font-size: 23px; margin: 52px 0 6px; letter-spacing: -.01em; font-weight: 700; text-wrap: balance; }
|
||||
h2 + .sub { color: var(--muted); margin: 0 0 22px; max-width: 70ch; }
|
||||
h3 { font-size: 17px; margin: 30px 0 10px; font-weight: 700; }
|
||||
|
||||
/* gate = việc phải xong trước khi chia nhánh */
|
||||
.gatebox { background: var(--surface); border: 1px solid var(--line);
|
||||
border-left: 4px solid var(--warn); border-radius: 3px; padding: 4px 26px 22px; }
|
||||
.gatebox h2 { margin-top: 22px; }
|
||||
|
||||
.steps { list-style: none; counter-reset: s; padding: 0; margin: 0; }
|
||||
.steps > li { counter-increment: s; position: relative; padding: 14px 0 14px 46px;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
.steps > li:last-child { border-bottom: none; }
|
||||
.steps > li::before {
|
||||
content: counter(s); position: absolute; left: 0; top: 14px;
|
||||
width: 26px; height: 26px; border-radius: 50%; background: var(--accent);
|
||||
color: #fff; font-size: 13px; font-weight: 700; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: Consolas, ui-monospace, monospace;
|
||||
}
|
||||
.steps b { display: block; margin-bottom: 2px; }
|
||||
.steps small { color: var(--muted); font-size: 13.5px; display: block; }
|
||||
.est { float: right; font-size: 12px; color: var(--muted); font-weight: 600;
|
||||
font-family: Consolas, ui-monospace, monospace; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||
@media (max-width: 940px) { .cards { grid-template-columns: 1fr; } }
|
||||
.card { background: var(--surface); border: 1px solid var(--line);
|
||||
border-top: 3px solid var(--c); border-radius: 3px; padding: 20px;
|
||||
display: flex; flex-direction: column; }
|
||||
.card.lead { --c: var(--lead); --w: var(--lead-wash); }
|
||||
.card.one { --c: var(--m1); --w: var(--m1-wash); }
|
||||
.card.two { --c: var(--m2); --w: var(--m2-wash); }
|
||||
.card .tag { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||
font-weight: 700; color: var(--c); margin-bottom: 6px; }
|
||||
.card h3 { margin: 0 0 4px; font-size: 18px; }
|
||||
.card .who { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
|
||||
.card .branch { font-size: 12.5px; background: var(--w); color: var(--c);
|
||||
padding: 5px 9px; border-radius: 3px; display: inline-block;
|
||||
margin-bottom: 16px; word-break: break-all; font-weight: 600; }
|
||||
.card h4 { font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 16px 0 7px; font-weight: 700; }
|
||||
.card ul { margin: 0; padding-left: 17px; font-size: 14px; }
|
||||
.card li { margin-bottom: 6px; }
|
||||
.tid { font-size: 12px; font-weight: 700; color: var(--c);
|
||||
font-family: Consolas, ui-monospace, monospace; }
|
||||
.paths { list-style: none; padding: 0; margin: 0; font-size: 12.5px; }
|
||||
.paths li { padding: 3px 0; border-bottom: 1px dotted var(--line);
|
||||
font-family: Consolas, ui-monospace, monospace; color: var(--muted); word-break: break-all; }
|
||||
.paths li:last-child { border-bottom: none; }
|
||||
.weight { margin-top: auto; padding-top: 16px; font-size: 12.5px; color: var(--muted); }
|
||||
.weight b { color: var(--ink); font-size: 15px; }
|
||||
|
||||
.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 3px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13.5px; background: var(--surface); }
|
||||
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
thead th { background: var(--surface-2); font-size: 11px; letter-spacing: .1em;
|
||||
text-transform: uppercase; color: var(--muted); font-weight: 700; white-space: nowrap; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
td.day, th.day { white-space: nowrap; font-weight: 700; font-size: 13px; }
|
||||
td.cl { border-left: 3px solid var(--lead); }
|
||||
td.c1 { border-left: 3px solid var(--m1); }
|
||||
td.c2 { border-left: 3px solid var(--m2); }
|
||||
tr.mark td { background: var(--surface-2); font-weight: 600; }
|
||||
td small { color: var(--muted); display: block; font-size: 12.5px; }
|
||||
.pill { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 7px;
|
||||
border-radius: 2px; letter-spacing: .04em; white-space: nowrap; }
|
||||
.pill.cp { background: var(--m1-wash); color: var(--m1); }
|
||||
.pill.gate { background: var(--m2-wash); color: var(--m2); }
|
||||
.pill.ship { background: var(--lead-wash); color: var(--lead); }
|
||||
|
||||
.rules { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
@media (max-width: 760px) { .rules { grid-template-columns: 1fr; } }
|
||||
.rule { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 3px; padding: 18px 20px; border-left: 3px solid var(--c, var(--line)); }
|
||||
.rule.hard { --c: var(--warn); }
|
||||
.rule.soft { --c: var(--ok); }
|
||||
.rule h3 { margin: 0 0 8px; font-size: 15px; }
|
||||
.rule p { margin: 0; font-size: 14px; color: var(--muted); }
|
||||
.rule code { color: var(--ink); }
|
||||
|
||||
|
||||
/* tóm tắt: đọc 30 giây là nắm được, trước khi vào chi tiết */
|
||||
.tldr {
|
||||
display: grid; grid-template-columns: 1.35fr 1fr; gap: 0;
|
||||
border: 1px solid var(--line); border-radius: 3px; overflow: hidden;
|
||||
margin-bottom: 8px; background: var(--surface);
|
||||
}
|
||||
@media (max-width: 820px) { .tldr { grid-template-columns: 1fr; } }
|
||||
.tldr > div { padding: 20px 24px; }
|
||||
.tldr .right { background: var(--surface-2); border-left: 1px solid var(--line); }
|
||||
@media (max-width: 820px) { .tldr .right { border-left: none; border-top: 1px solid var(--line); } }
|
||||
.tldr .cap {
|
||||
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--muted); font-weight: 700; margin: 0 0 12px;
|
||||
}
|
||||
.flow { list-style: none; padding: 0; margin: 0; font-size: 14px; }
|
||||
.flow li { padding: 7px 0; border-bottom: 1px dotted var(--line); display: flex; gap: 10px; }
|
||||
.flow li:last-child { border-bottom: none; }
|
||||
.flow .b {
|
||||
flex: 0 0 auto; font-size: 11.5px; font-weight: 700; padding: 1px 7px; border-radius: 2px;
|
||||
background: var(--w2); color: var(--c2); height: fit-content; margin-top: 2px;
|
||||
font-family: Consolas, ui-monospace, monospace;
|
||||
}
|
||||
.flow li.f0 { --c2: var(--warn); --w2: var(--surface-2); }
|
||||
.flow li.f1 { --c2: var(--lead); --w2: var(--lead-wash); }
|
||||
.flow li.f2 { --c2: var(--m1); --w2: var(--m1-wash); }
|
||||
.flow li.f3 { --c2: var(--m2); --w2: var(--m2-wash); }
|
||||
.flow .t { flex: 1; }
|
||||
.flow .t b { display: block; }
|
||||
.flow .t small { color: var(--muted); font-size: 12.5px; }
|
||||
.must { margin: 0; padding-left: 18px; font-size: 14px; }
|
||||
.must li { margin-bottom: 8px; }
|
||||
.must li:last-child { margin-bottom: 0; }
|
||||
.must b { color: var(--ink); }
|
||||
|
||||
/* input / output từng người */
|
||||
.io { display: grid; gap: 18px; }
|
||||
.iorow { background: var(--surface); border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--c); border-radius: 3px; overflow: hidden; }
|
||||
.iorow.n1 { --c: var(--lead); --w: var(--lead-wash); }
|
||||
.iorow.n2 { --c: var(--m1); --w: var(--m1-wash); }
|
||||
.iorow.n3 { --c: var(--m2); --w: var(--m2-wash); }
|
||||
.iohead { padding: 14px 20px; background: var(--w); display: flex;
|
||||
align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.iohead b { color: var(--c); font-size: 15px; }
|
||||
.iohead span { color: var(--muted); font-size: 13px; }
|
||||
.iogrid { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 860px) { .iogrid { grid-template-columns: 1fr; } }
|
||||
.iogrid > div { padding: 16px 20px; }
|
||||
.iogrid > div + div { border-left: 1px solid var(--line); }
|
||||
@media (max-width: 860px) {
|
||||
.iogrid > div + div { border-left: none; border-top: 1px solid var(--line); }
|
||||
}
|
||||
.iocap { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||
color: var(--muted); font-weight: 700; margin: 0 0 10px; }
|
||||
.iolist { list-style: none; margin: 0; padding: 0; font-size: 13.5px; }
|
||||
.iolist li { padding: 5px 0; border-bottom: 1px dotted var(--line); }
|
||||
.iolist li:last-child { border-bottom: none; }
|
||||
.iolist code { font-size: 12.5px; }
|
||||
.frm { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 6px;
|
||||
border-radius: 2px; background: var(--surface-2); color: var(--muted);
|
||||
margin-right: 6px; font-family: Consolas, ui-monospace, monospace; }
|
||||
.frm.done { background: var(--m1-wash); color: var(--m1); }
|
||||
.frm.risk { background: var(--m2-wash); color: var(--m2); }
|
||||
|
||||
footer { margin-top: 64px; padding-top: 20px; border-top: 1px solid var(--line);
|
||||
font-size: 13px; color: var(--muted); }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header class="top">
|
||||
<p class="eyebrow">Team Gamma · Automation, Workflows & Governance</p>
|
||||
<h1>Một nhánh chung, ba làn không đụng nhau</h1>
|
||||
<p class="lede">
|
||||
Toàn bộ phần việc refactor 10 ngày của Team Gamma — Nam, Hiệp, Lâm. Cả ba đẩy chung vào <code>gamma/refactor</code>. Nam làm thêm một mục chung —
|
||||
khung kiến trúc, hợp đồng dữ liệu, cổng kiểm duyệt — nằm ngoài ba nhánh; xong mục đó thì
|
||||
ba người vào ba nhánh tính năng ngang nhau, không ai phải sửa chung file với ai.
|
||||
</p>
|
||||
<p class="alias">
|
||||
Ba tài liệu refactor gọi team này là <b>“Team Nam”</b> (theo tên lead). Cùng một team, cùng
|
||||
phạm vi R02 · R08 · R09 · R07-T06. Nhánh của team dùng tiền tố <code>gamma/</code>; ba tài liệu refactor viết
|
||||
<code>nam/workflow-governance-*</code> theo tên lead — cùng một thứ.
|
||||
</p>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="k">Thời hạn</span><span class="v mono">21/08 → 31/08</span></div>
|
||||
<div class="fact"><span class="k">Người</span><span class="v mono">Nam · Hiệp · Lâm</span></div>
|
||||
<div class="fact"><span class="k">Nhánh</span><span class="v mono">gamma/refactor</span></div>
|
||||
<div class="fact"><span class="k">Code phải bóc</span><span class="v mono">~6.500 dòng</span></div>
|
||||
<div class="fact"><span class="k">Cổng phải qua</span><span class="v mono">CASAN Check 1</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<section class="tldr">
|
||||
<div>
|
||||
<p class="cap">Tóm tắt · thứ tự làm</p>
|
||||
<ul class="flow">
|
||||
<li class="f0">
|
||||
<span class="b">CHUNG</span>
|
||||
<span class="t"><b>Nam làm trước, nửa ngày</b>
|
||||
<small>Dựng khung 5 thư mục (đang là 0 file) · interface + fake cho Config/Secrets ·
|
||||
chốt <code>api_key</code> và báo Team Duy · script CASAN Check 1 · đưa 3 check vào CI ·
|
||||
quyết số phận 24 checker UI. Merge xong mới chia nhánh.</small></span>
|
||||
</li>
|
||||
<li class="f1">
|
||||
<span class="b">N1</span>
|
||||
<span class="t"><b>N1 — Nam · Cấu hình, Bí mật, Vỏ ứng dụng</b>
|
||||
<small>R02 (6 task) · settings 4 widget · bootstrap + MainWindow · policy doc.
|
||||
Giữ luôn <code>app.py</code>, <code>config.py</code>, <code>theme.py</code>,
|
||||
<code>i18n.py</code>. ~2.700 dòng.</small></span>
|
||||
</li>
|
||||
<li class="f2">
|
||||
<span class="b">N2</span>
|
||||
<span class="t"><b>N2 — Hiệp · Giám sát</b>
|
||||
<small>7 tab Monitoring · CanonicalAuditLogger · MonitoringQueryService ·
|
||||
2 vòng lặp import · ma trận Sandbox. ~2.650 dòng.</small></span>
|
||||
</li>
|
||||
<li class="f3">
|
||||
<span class="b">N3</span>
|
||||
<span class="t"><b>N3 — Lâm · Co4E Studio</b>
|
||||
<small>Co4EWorkflowService · tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code>
|
||||
thành 5 phần. ~2.880 dòng, file to nhất team.</small></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="right">
|
||||
<p class="cap">Ba điều bắt buộc</p>
|
||||
<ol class="must">
|
||||
<li><b>Không chạm file dùng chung.</b> Cần thêm chuỗi hay màu thì nhắn nhóm trưởng, đừng tự sửa.</li>
|
||||
<li><b>Nộp factory, không tự lắp vào <code>app.py</code>.</b> N1 lắp trong <code>bootstrap.py</code> ngày 28/08.</li>
|
||||
<li><b>Bị chặn thì dùng fake, báo ngay trong ngày.</b> Không ngồi đợi ai.</li>
|
||||
</ol>
|
||||
<p class="cap" style="margin-top:20px">Nghiệm thu</p>
|
||||
<p style="margin:0;font-size:14px;color:var(--muted)">
|
||||
Trên <code>gamma/refactor</code>: không file nào được sửa bởi hai người khác nhau.
|
||||
Có là quy ước <b style="color:var(--ink)">số 1</b> đang bị vi phạm.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="gatebox">
|
||||
<h2>Mục chung — Nam làm, xong hai người kia mới bắt đầu</h2>
|
||||
<p class="sub">
|
||||
Sáu việc dưới đây không thuộc làn nào — chúng là thứ cả ba người cùng đụng
|
||||
vào. Nam làm một lần và đẩy lên <code>gamma/refactor</code>, rồi hai người kia
|
||||
mới bắt đầu. Ước tính nửa ngày.
|
||||
</p>
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<span class="est">~30 phút</span>
|
||||
<b>Dựng khung thư mục</b>
|
||||
<small>
|
||||
<code>domain/</code> <code>application/</code> <code>infrastructure/</code>
|
||||
<code>presentation/</code> <code>platform/</code> <code>tests/fakes/</code> —
|
||||
hiện tại <b>chưa tồn tại, 0 file</b>. Mọi task của cả ba người đều ghi vào đây; để ba
|
||||
người tự tạo là đụng nhau ở <code>__init__.py</code> ngay ngày đầu.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~45 phút</span>
|
||||
<b>Viết interface + fake cho Config và Secrets</b>
|
||||
<small>
|
||||
<code>SecretStore</code>, <code>ConfigRepository</code>, kèm
|
||||
<code>FakeSecretStore</code> và <code>FakeConfigRepository</code>. Chỉ chữ ký, chưa cần
|
||||
thân hàm. Đây là thứ gỡ chốt cho cả hai người kia — <b>156 lời gọi
|
||||
<code>ctx.config.*</code> trong 29 file</b> đang chờ nó.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~20 phút</span>
|
||||
<b>Chốt số phận <code>api_key</code> và báo Team Duy</b>
|
||||
<small>
|
||||
<code>provider_conf()</code> còn trả <code>api_key</code> bên trong, hay tách hẳn sang
|
||||
<code>SecretStore</code>? Có 5 nơi đọc trực tiếp, <b>3 trong số đó nằm trong
|
||||
<code>providers/</code> của Team Duy</b>. Quyết một mình rồi im lặng là làm vỡ code
|
||||
team bạn.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~30 phút</span>
|
||||
<b>Viết <code>scripts/audit_security.py</code> (CASAN Check 1)</b>
|
||||
<small>
|
||||
Gamma chủ trì check này ngày 30/08. Viết ngay hôm nay thì lead tự kiểm được trong suốt
|
||||
quá trình chuyển API key, thay vì tới ngày cổng mới chạy lần đầu và phát hiện vấn đề.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~20 phút</span>
|
||||
<b>Thêm 3 check CASAN vào CI</b>
|
||||
<small>
|
||||
CI hiện chỉ chạy <code>pytest tests -q</code>. Ba check (secret · ≤400 dòng · import
|
||||
guard) không nằm trong CI, nên tới 30/08 mới biết ai vi phạm. Đưa vào CI thì mỗi PR tự
|
||||
báo.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~30 phút</span>
|
||||
<b>Quyết số phận 24 checker UI, rồi thông báo</b>
|
||||
<small>
|
||||
Chúng bám vào <code>cowork_local.config</code> (34 chỗ) và <code>cowork_local.app</code>
|
||||
(16 chỗ) — <b>sẽ chết ngay khi lead đụng <code>config.py</code></b>. Đây là lưới an toàn
|
||||
duy nhất cho phần UI vừa làm xong. Xem mục quy ước bên dưới.
|
||||
</small>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<h2>Ba làn</h2>
|
||||
<p class="sub">
|
||||
Ba làn ngang nhau, mỗi làn khoảng 2.700 dòng phải bóc tách, <b>cùng đẩy vào một
|
||||
nhánh</b> <code>gamma/refactor</code>. Nam nhận làn N1 vì đó là làn chạm tới file
|
||||
dùng chung nhiều nhất. Cột “sở hữu” là danh sách file <em>chỉ</em> người đó được
|
||||
sửa — trên nhánh chung, đây là thứ duy nhất giữ cho ba người không giẫm chân.
|
||||
</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card lead">
|
||||
<div class="tag">Làn N1 · Nam</div>
|
||||
<h3>Cấu hình, Bí mật & Vỏ ứng dụng</h3>
|
||||
<p class="who">Nam giữ — làn chạm nhiều file dùng chung nhất</p>
|
||||
<div class="branch">gamma/refactor</div>
|
||||
|
||||
<h4>Việc</h4>
|
||||
<ul>
|
||||
<li><span class="tid">R02-T01…T06</span> AtomicJsonFile · ConfigRepository · Typed Settings Facade · SecretStore + Keyring · chuyển API key · schema versioning</li>
|
||||
<li><span class="tid">R08-T07</span> tách <code>settings_dialog.py</code> → 4 section widget</li>
|
||||
<li><span class="tid">R08-T10</span> <code>bootstrap.py</code> + tách <code>MainWindow</code> → shell · tray · lifecycle <em>(cuối sprint, lắp factory của hai người kia)</em></li>
|
||||
<li><span class="tid">R09-T01</span> tài liệu Security Policy Model</li>
|
||||
<li>Chủ trì <b>CASAN Check 1</b> · giữ CI · duyệt PR của hai người</li>
|
||||
</ul>
|
||||
|
||||
<h4>Sở hữu độc quyền</h4>
|
||||
<ul class="paths">
|
||||
<li>config.py</li>
|
||||
<li>app.py → presentation/shell/</li>
|
||||
<li>bootstrap.py</li>
|
||||
<li>theme.py · i18n.py</li>
|
||||
<li>infrastructure/config/ · secrets/ · persistence/</li>
|
||||
<li>ui/settings_dialog.py → presentation/settings/</li>
|
||||
<li>scripts/ · .gitea/workflows/</li>
|
||||
</ul>
|
||||
|
||||
<p class="weight"><b>~2.700 dòng</b> · 727 settings + 1.352 app + 616 config<br>+ mục chung ở trên</p>
|
||||
</div>
|
||||
|
||||
<div class="card one">
|
||||
<div class="tag">Làn N2 · Hiệp</div>
|
||||
<h3>Giám sát & Quan trắc</h3>
|
||||
<p class="who">Hiệp — 7 tab, việc lặp cần kỷ luật</p>
|
||||
<div class="branch">gamma/refactor</div>
|
||||
|
||||
<h4>Việc</h4>
|
||||
<ul>
|
||||
<li><span class="tid">R08-T08</span> tách <code>monitoring_tab.py</code> → 7 tab độc lập</li>
|
||||
<li><span class="tid">R09-T04</span> <code>CanonicalAuditLogger</code></li>
|
||||
<li><span class="tid">R09-T05</span> <code>MonitoringQueryService</code> read-only, phân trang</li>
|
||||
<li><span class="tid">R09-T02</span> gỡ vòng lặp <code>model_pricing</code> ↔ <code>usage_tracker</code></li>
|
||||
<li><span class="tid">R09-T03</span> gỡ vòng lặp <code>agent_security</code> ↔ <code>alert</code></li>
|
||||
<li><span class="tid">R09-T06</span> ma trận Sandbox theo hệ điều hành</li>
|
||||
</ul>
|
||||
|
||||
<h4>Sở hữu độc quyền</h4>
|
||||
<ul class="paths">
|
||||
<li>ui/monitoring_tab.py → presentation/monitoring/</li>
|
||||
<li>application/monitoring/</li>
|
||||
<li>infrastructure/telemetry/ · sandbox/</li>
|
||||
<li>core/audit_log.py</li>
|
||||
<li>core/model_pricing.py · usage_tracker.py</li>
|
||||
<li>core/agent_security*.py</li>
|
||||
</ul>
|
||||
|
||||
<p class="weight"><b>~2.650 dòng</b> · 1.545 monitoring + ~1.100 core</p>
|
||||
</div>
|
||||
|
||||
<div class="card two">
|
||||
<div class="tag">Làn N3 · Lâm</div>
|
||||
<h3>Co4E Studio</h3>
|
||||
<p class="who">Lâm — canvas và luồng chạy workflow</p>
|
||||
<div class="branch">gamma/refactor</div>
|
||||
|
||||
<h4>Việc</h4>
|
||||
<ul>
|
||||
<li><span class="tid">R07-T06</span> <code>Co4EWorkflowService</code> thuần Python</li>
|
||||
<li><span class="tid">R08-T09</span> tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code> → canvas · node property · run control · chat view · agent list</li>
|
||||
<li>Gọi tool qua <code>ToolPolicyGateway</code> của Team Hoa — dùng fake, không chờ</li>
|
||||
</ul>
|
||||
|
||||
<h4>Sở hữu độc quyền</h4>
|
||||
<ul class="paths">
|
||||
<li>ui/co4e_tab.py → presentation/co4e/</li>
|
||||
<li>ui/co4e_canvas.py</li>
|
||||
<li>ui/co4e_config_panel.py</li>
|
||||
<li>application/workflows/</li>
|
||||
<li>domain/workflows/</li>
|
||||
<li>core/co4e_run_manager.py</li>
|
||||
</ul>
|
||||
|
||||
<p class="weight"><b>~2.880 dòng</b> · file to nhất của cả team</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h2>Tám quy ước</h2>
|
||||
<p class="sub">
|
||||
Tám điều dưới đây là luật của team, Nam chốt. Bốn điều đầu là bắt buộc — trên một
|
||||
nhánh chung, vi phạm không chỉ hại mình mà chặn cả hai người kia.
|
||||
</p>
|
||||
|
||||
<div class="rules">
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>1 · Không chạm file dùng chung</h3>
|
||||
<p>
|
||||
<code>app.py</code>, <code>theme.py</code>, <code>i18n.py</code>, <code>config.py</code>,
|
||||
<code>bootstrap.py</code> thuộc nhánh N1 của Nam. Cần thêm chuỗi hay token màu thì
|
||||
<b>nhắn, đừng sửa</b> — Nam thêm trong ngày. Đây là ba file duy nhất có thể gây conflict
|
||||
thật, và luật này xoá hẳn khả năng đó.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>2 · Nộp factory, không tự lắp vào app</h3>
|
||||
<p>
|
||||
Mỗi nhánh expose một hàm dựng widget với chữ ký chốt từ ngày đầu, ví dụ
|
||||
<code>build_monitoring_tab(ctx, query_service) -> QWidget</code>. Nam gọi nó trong <code>bootstrap.py</code> ngày 28/08. Không ai tự sửa chỗ khởi tạo trong
|
||||
<code>app.py</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>3 · Bị chặn thì dùng fake, không ngồi đợi</h3>
|
||||
<p>
|
||||
Chưa có <code>ConfigRepository</code> bản thật thì dùng <code>FakeConfigRepository</code>.
|
||||
Chưa có <code>ToolPolicyGateway</code> của Team Hoa thì đã có fake sẵn. <b>Báo ngay trong ngày</b>
|
||||
nếu thiếu fake nào — đó là việc của nhóm trưởng, không phải lý do dừng tay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>4 · Nhánh chung: kéo trước khi đẩy, đừng để nhánh đỏ</h3>
|
||||
<p>
|
||||
Cả ba đẩy vào <code>gamma/refactor</code>, nên không còn nhánh riêng làm vùng
|
||||
đệm. Ba việc bắt buộc: <code>git pull --rebase</code> trước mỗi lần đẩy;
|
||||
commit nhỏ và đẩy trong ngày, đừng ôm 500 dòng ba hôm; và
|
||||
<b>không bao giờ đẩy thứ làm <code>pytest tests -q</code> đỏ</b> — nhánh hỏng
|
||||
là hai người kia đứng hình. Lỡ đẩy nhầm thì sửa ngay hoặc
|
||||
<code>git revert</code>, đừng để qua đêm.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>5 · Commit nhỏ, mỗi ngày một lần</h3>
|
||||
<p>
|
||||
Một PR cho một sub-widget hoặc một service, không dồn 7 tab vào một PR cuối tuần. Nhóm
|
||||
trưởng duyệt trong ngày. PR càng to thì rủi ro càng dồn về ngày 28/08.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>6 · Mỗi commit kèm test, và không làm đỏ 90 test cũ</h3>
|
||||
<p>
|
||||
Baseline hiện tại: <b>102 test xanh trong 3,4 giây</b>. Chạy <code>pytest tests -q</code>
|
||||
trước mỗi lần đẩy. Đây là lưới an toàn cho phần logic — giữ nó xanh suốt 10 ngày.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>7 · File mới ≤ 400 dòng, không import PySide6 vào lõi</h3>
|
||||
<p>
|
||||
Hai điều kiện của CASAN Check 2 và 3. Tự kiểm trước khi đẩy — CI sẽ báo, nhưng biết
|
||||
sớm thì đỡ phải tách lại lần hai.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>8 · Checker UI thuộc phạm vi ai, người đó cập nhật</h3>
|
||||
<p>
|
||||
24 checker sẽ vỡ khi file bị dời. Ai dời file thì sửa checker tương ứng ngay trong
|
||||
commit đó — tốn thêm khoảng 15% thời gian, đổi lại giữ được lưới an toàn cho phần
|
||||
UI vừa làm xong.
|
||||
<b>Đã chốt 21/08: đường A. Nam chịu trách nhiệm nếu đổi ý.</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<h2>Mỗi người nhận gì, giao gì</h2>
|
||||
<p class="sub">
|
||||
Cột trái là thứ phải có trong tay mới làm được, kèm nguồn. Cột phải là thứ bắt
|
||||
buộc giao ra, kèm người nhận. Nhãn <span class="frm done">có rồi</span> nghĩa là
|
||||
mục chung đã làm xong.
|
||||
</p>
|
||||
|
||||
<div class="io">
|
||||
|
||||
<div class="iorow n1">
|
||||
<div class="iohead"><b>N1 · Cấu hình, Bí mật & Vỏ</b><span>Nam · nhóm trưởng</span></div>
|
||||
<div class="iogrid">
|
||||
<div>
|
||||
<p class="iocap">Input — cần có</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm">mã cũ</span><code>config.py</code> 616 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>ui/settings_dialog.py</code> 727 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>app.py</code> 1.352 dòng</li>
|
||||
<li><span class="frm risk">tự chốt</span>Quyết định <code>api_key</code> — trước 26/08</li>
|
||||
<li><span class="frm">từ Hiệp</span>Chữ ký <code>build_monitoring_tab()</code> — trước 28/08</li>
|
||||
<li><span class="frm">từ Lâm</span>Chữ ký <code>build_co4e_tab()</code> — trước 28/08</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="iocap">Output — phải giao</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm done">có rồi</span><code>SecretStore</code> · <code>ConfigRepository</code> + fake → <b>cho Hiệp và Lâm</b></li>
|
||||
<li><span class="frm done">có rồi</span><code>scripts/audit_security.py</code> → cho CI</li>
|
||||
<li><code>infrastructure/persistence/json/atomic_json_file.py</code></li>
|
||||
<li><code>infrastructure/config/</code> — cài đặt thật + settings facade</li>
|
||||
<li><code>infrastructure/secrets/keyring_adapter.py</code></li>
|
||||
<li><code>presentation/settings/</code> — 4 widget</li>
|
||||
<li><code>bootstrap.py</code> + <code>presentation/shell/</code> — 3 file</li>
|
||||
<li><code>docs/architecture/security-policy.md</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="iorow n2">
|
||||
<div class="iohead"><b>N2 · Giám sát</b><span>Hiệp</span></div>
|
||||
<div class="iogrid">
|
||||
<div>
|
||||
<p class="iocap">Input — cần có</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm">mã cũ</span><code>ui/monitoring_tab.py</code> 1.545 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>core/usage_tracker.py</code> 524 · <code>sandbox_manager.py</code> 335</li>
|
||||
<li><span class="frm">mã cũ</span><code>core/model_pricing.py</code> 284 · <code>agent_security.py</code> 272 · <code>audit_log.py</code> 115</li>
|
||||
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code> — dùng được ngay</li>
|
||||
<li><span class="frm risk">tự chốt</span>Giữ nguyên 9 trường log, báo Duy và Hoa</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="iocap">Output — phải giao</p>
|
||||
<ul class="iolist">
|
||||
<li><code>build_monitoring_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
||||
<li><code>FakeAuditLogger</code> · <code>FakeMonitoringQueryService</code> → <b>cho cả team</b></li>
|
||||
<li><code>presentation/monitoring/</code> — 7 tab + shell</li>
|
||||
<li><code>application/monitoring/monitoring_query_service.py</code></li>
|
||||
<li><code>infrastructure/telemetry/audit_logger.py</code></li>
|
||||
<li><code>infrastructure/sandbox/sandbox_capabilities.py</code></li>
|
||||
<li><b>0 circular import</b> ở pricing ↔ usage và security ↔ alert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="iorow n3">
|
||||
<div class="iohead"><b>N3 · Co4E Studio</b><span>Lâm</span></div>
|
||||
<div class="iogrid">
|
||||
<div>
|
||||
<p class="iocap">Input — cần có</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm">mã cũ</span><code>ui/co4e_tab.py</code> 2.089 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>ui/co4e_canvas.py</code> 791 · <code>co4e_config_panel.py</code></li>
|
||||
<li><span class="frm">mã cũ</span><code>core/co4e_run_manager.py</code> 331</li>
|
||||
<li><span class="frm">có sẵn</span><code>core/co4e.py</code> — dataclass Workflow/Node/Edge đã có</li>
|
||||
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code></li>
|
||||
<li><span class="frm risk">từ Team Hoa</span>DTO <code>ToolPolicyGateway</code> — <b>rủi ro liên team cao nhất</b>, lấy trong hôm nay</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="iocap">Output — phải giao</p>
|
||||
<ul class="iolist">
|
||||
<li><code>build_co4e_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
||||
<li><code>FakeCo4EWorkflowService</code> → <b>cho cả team</b></li>
|
||||
<li><code>domain/workflows/</code> — DTO chốt ngày đầu</li>
|
||||
<li><code>application/workflows/co4e_workflow_service.py</code></li>
|
||||
<li><code>presentation/co4e/</code> — 5 phần</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h3>Output bắt buộc với cả ba, mỗi lần đẩy</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Điều kiện</th><th>Ngưỡng</th><th>Tự kiểm bằng</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>File mới sau khi tách</td><td class="mono">≤ 400 dòng</td><td class="mono">wc -l</td></tr>
|
||||
<tr><td><code>domain/</code> và <code>application/</code> import PySide6</td><td class="mono">0</td><td class="mono">grep -r PySide6</td></tr>
|
||||
<tr><td>Test hiện có</td><td class="mono">102 xanh</td><td class="mono">pytest tests -q</td></tr>
|
||||
<tr><td>Credential lộ</td><td class="mono">0</td><td class="mono">python scripts/audit_security.py</td></tr>
|
||||
<tr><td>Checker UI trong phạm vi mình dời</td><td>đã cập nhật</td><td class="mono">python tools/check_<tên>.py</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Lịch từng ngày</h2>
|
||||
<p class="sub">Ba hàng chạy độc lập. Hàng tô nền là lúc cả ba phải gặp nhau.</p>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day">Ngày</th>
|
||||
<th>N1 · Nam</th>
|
||||
<th>N2 · Hiệp</th>
|
||||
<th>N3 · Lâm</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="day">21/08<br><small>T6</small></td>
|
||||
<td class="cl"><b>Mục chung</b> · dựng khung · interface + fake · chốt api_key · CASAN script<small>Merge trước khi hai người kia bắt đầu</small></td>
|
||||
<td class="c1">Chốt schema log 9 trường<small>Giữ nguyên định dạng cũ để 24 chỗ gọi không phải sửa</small></td>
|
||||
<td class="c2">Chốt chữ ký <code>Co4EWorkflowService</code><small>Nộp cho lead để lắp bootstrap sau</small></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">22–23/08<br><small>T7–CN</small></td>
|
||||
<td class="cl">AtomicJsonFile · ConfigRepository · Typed Settings Facade</td>
|
||||
<td class="c1">CanonicalAuditLogger · gỡ vòng lặp pricing ↔ usage</td>
|
||||
<td class="c2">Co4EWorkflowService — CRUD & validate, test không cần Qt</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">23/08<br><small>17:00</small></td>
|
||||
<td colspan="3"><span class="pill cp">Checkpoint 1</span> 100% DTO và fake xong · <code>pytest</code> xanh · không ai bị chặn</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">24/08<br><small>T2</small></td>
|
||||
<td class="cl">Tách settings: provider + connector widget</td>
|
||||
<td class="c1">3 tab đầu: overview · sandbox · security events</td>
|
||||
<td class="c2">node_property_panel · agent_list_panel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">25/08<br><small>T3</small></td>
|
||||
<td class="cl">Tách settings: routing + general widget</td>
|
||||
<td class="c1">4 tab còn lại: MCP · action logs · agent status · security settings</td>
|
||||
<td class="c2">co4e_canvas_widget — thao tác node</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">26/08<br><small>T4</small></td>
|
||||
<td class="cl">Chuyển API key sang SecretStore<small>Báo Team Duy trước khi đụng providers/</small></td>
|
||||
<td class="c1">Lắp shell MonitoringTab · query service bản thật</td>
|
||||
<td class="c2">Run control · chat view</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">27/08<br><small>T5</small></td>
|
||||
<td class="cl">Schema versioning · recovery policy</td>
|
||||
<td class="c1">Ma trận Sandbox · gỡ vòng lặp agent_security</td>
|
||||
<td class="c2">Lắp container Co4ETab · thay fake bằng service thật</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">28/08<br><small>T6</small></td>
|
||||
<td class="cl"><b>bootstrap.py + tách MainWindow</b><small>Nhận factory của Hiệp và Lâm để lắp</small></td>
|
||||
<td class="c1">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
||||
<td class="c2">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">28/08<br><small>17:00</small></td>
|
||||
<td colspan="3"><span class="pill cp">Checkpoint 2</span> Tách xong 100% god file · 0 circular import</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">29/08<br><small>T7</small></td>
|
||||
<td class="cl">Tài liệu Security Policy · integration test Settings</td>
|
||||
<td class="c1">Integration test Monitoring</td>
|
||||
<td class="c2">Integration test luồng Co4E đầu-cuối</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">30/08<br><small>CN 17:00</small></td>
|
||||
<td colspan="3"><span class="pill gate">CASAN Gate</span> <b>Nam chủ trì Check 1</b> — quét toàn bộ config/JSON, phải ra 0 secret plaintext. Hiệp và Lâm sửa ngay phần của mình nếu script bắt được.</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">31/08<br><small>T2 15:00</small></td>
|
||||
<td colspan="3"><span class="pill ship">Bàn giao</span> Fix tồn đọng · cập nhật tài liệu kiến trúc · merge PR cuối · smoke test 5 luồng chính</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Nghiệm thu: làm sao biết đã thật sự song song</h2>
|
||||
<p class="sub">Không phải “đã họp xong” mà là chạy được. Ba câu hỏi, trả lời bằng lệnh.</p>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Câu hỏi</th><th>Cách trả lời</th><th>Khi nào</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Hiệp có chạy được khi chưa có config bản thật?</td>
|
||||
<td>Dựng một tab Monitoring, chạy test của nó, <b>không import <code>cowork_local.config</code></b> dòng nào — chỉ dùng <code>FakeConfigRepository</code></td>
|
||||
<td class="mono">21/08</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Lâm có chạy được khi Team Hoa chưa xong gateway?</td>
|
||||
<td>Test <code>Co4EWorkflowService</code> xanh với <code>FakeToolPolicyGateway</code></td>
|
||||
<td class="mono">23/08</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ba người có đụng file nhau không?</td>
|
||||
<td><code>git log --name-only --pretty=%an</code> trên <code>gamma/refactor</code> —
|
||||
không file nào được xuất hiện dưới hai tên khác nhau</td>
|
||||
<td class="mono">mỗi ngày</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Nguồn: <code>docs/refactor/plan.md</code>, <code>Refactoring_Checklist.md</code>,
|
||||
<code>Feature_Architecture_Proposal.md</code>. Số dòng code, số lời gọi và baseline test đo
|
||||
trực tiếp trên nhánh <code>main</code> ngày 21/08.
|
||||
Mục chung, cách chia ba nhánh, bảy quy ước và mục nghiệm thu là đề xuất — không có trong
|
||||
tài liệu gốc.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,189 @@
|
||||
# Quyết định của Team Gamma
|
||||
|
||||
Team: **Nam** (nhóm trưởng, nhánh N1) · **Hiệp** (N2) · **Lâm** (N3).
|
||||
|
||||
Ghi ở đây thay vì chôn trong comment, vì cả ba đều ảnh hưởng ra ngoài phạm vi
|
||||
một người.
|
||||
|
||||
| # | Việc | Trạng thái |
|
||||
|---|---|---|
|
||||
| 1 | `provider_conf()` còn trả `api_key` | **Chốt 21/08 — đường A** |
|
||||
| 2 | Số phận 24 checker UI | **Chốt 21/08 — đường A** |
|
||||
| 3 | DTO `ToolPolicyGateway` viết hộ Team Hoa | Đã làm, chờ Hoa xác nhận |
|
||||
|
||||
---
|
||||
|
||||
## Quyết định 1 — `provider_conf()` còn trả `api_key` hay không
|
||||
|
||||
### Vì sao phải quyết trước khi code
|
||||
|
||||
R02-T05 chuyển API key sang Keyring. Câu hỏi là sau khi chuyển, dict do
|
||||
`provider_conf()` trả về **còn chứa `api_key` không**.
|
||||
|
||||
Có 5 nơi đang đọc trực tiếp — đo trên `main` ngày 21/08:
|
||||
|
||||
| Nơi đọc | Thuộc |
|
||||
|---|---|
|
||||
| `providers/anthropic.py:26` | **Team Duy** |
|
||||
| `providers/openai_compat.py:36` | **Team Duy** |
|
||||
| `core/image_gen.py:50` | Team Duy (routing/model) |
|
||||
| `core/ext_connectors.py:98` | Team Hoa |
|
||||
| `ui/ext_connector_dialog.py:87` | Team Gamma |
|
||||
|
||||
Ba trong năm nằm ngoài team. Quyết một mình rồi im lặng là làm vỡ code người khác.
|
||||
|
||||
### Hai đường
|
||||
|
||||
**A. Giữ `api_key` trong dict, `ConfigRepository` tự lấy từ `SecretStore` rồi ghép vào**
|
||||
|
||||
- 5 nơi đọc **không phải sửa dòng nào**
|
||||
- Không cần báo team khác, không cần đồng bộ lịch
|
||||
- Đổi lại: bí mật vẫn đi lang thang trong dict, dễ lọt vào log hoặc màn hình debug
|
||||
- CASAN Check 1 vẫn PASS vì nó quét **file trên đĩa**, không quét bộ nhớ
|
||||
|
||||
**B. Bỏ `api_key` khỏi dict, ai cần thì gọi `secrets.get(provider_key(name))`**
|
||||
|
||||
- Sạch về nguyên tắc: bí mật chỉ xuất hiện đúng chỗ cần
|
||||
- Đổi lại: **5 nơi phải sửa**, 3 trong đó phải chờ team khác xếp lịch
|
||||
- Rủi ro: quên một chỗ thì mất API key lúc chạy thật, mà test có fake nên không bắt được
|
||||
|
||||
### Đề xuất
|
||||
|
||||
**Đường A cho sprint này, đường B ghi vào nợ kỹ thuật.**
|
||||
|
||||
Lý do: mục tiêu của cổng CASAN là *không còn secret nằm trên đĩa*, và đường A
|
||||
đạt được điều đó. Đường B giải quyết thêm chuyện secret trong bộ nhớ — đúng
|
||||
nhưng không phải việc của 10 ngày này, và nó kéo hai team khác vào một thay đổi
|
||||
họ không lên kế hoạch.
|
||||
|
||||
Nếu chọn B thì **phải báo Team Duy và Team Hoa trong hôm nay**, không phải lúc
|
||||
đã sửa xong.
|
||||
|
||||
> **Nam chốt 21/08: đường A.**
|
||||
>
|
||||
> Việc kèm theo: `ConfigRepository` bản thật phải đọc key từ `SecretStore` rồi
|
||||
> ghép vào dict do `provider_conf()` trả về. Năm nơi đọc không đổi một dòng,
|
||||
> nên **không cần báo Duy và Hoa**.
|
||||
>
|
||||
> Nợ kỹ thuật đã ghi: đường B (bỏ `api_key` khỏi dict) để sau sprint này.
|
||||
|
||||
---
|
||||
|
||||
## Quyết định 2 — số phận 24 checker UI
|
||||
|
||||
### Vấn đề
|
||||
|
||||
`tools/check_*.py` là bộ kiểm tra giao diện viết trong 2 tuần vừa rồi, hiện
|
||||
**24 file**. Chúng bám vào đường dẫn cũ:
|
||||
|
||||
| Import | Số chỗ |
|
||||
|---|---|
|
||||
| `cowork_local.config` | 34 |
|
||||
| `cowork_local.app` | 16 |
|
||||
| `cowork_local.state` | 22 |
|
||||
| `cowork_local.ui.*` | ~12 |
|
||||
|
||||
R08 dời hết những module đó sang `presentation/`. Nghĩa là **cả 24 checker chết
|
||||
ngay ngày N1 đụng `config.py`** — và đó là lưới an toàn duy nhất cho phần giao
|
||||
diện, vì `pytest` không kiểm giao diện (90 test hiện tại là logic).
|
||||
|
||||
### Ba đường
|
||||
|
||||
**A. Ai dời file thì cập nhật checker tương ứng, ngay trong PR đó**
|
||||
|
||||
- Giữ được lưới suốt 10 ngày
|
||||
- Tốn thêm ~15% thời gian mỗi PR
|
||||
- Rủi ro: người sửa vội có thể nới lỏng phép kiểm cho nó xanh — đã xảy ra một
|
||||
lần trong quá trình làm UI, khi một checker được sửa thành *không thể đỏ*
|
||||
|
||||
**B. Đóng băng: bỏ khỏi CI, sửa một lượt ngày 31/08**
|
||||
|
||||
- Nhanh nhất trong 10 ngày
|
||||
- Đổi lại: **không có gì canh hồi quy giao diện** suốt cả sprint. Refactor là lúc
|
||||
dễ vỡ giao diện nhất
|
||||
- Rủi ro cuối sprint: sửa 24 file cùng lúc, không ai nhớ cái nào đo gì
|
||||
|
||||
**C. Bỏ hẳn**
|
||||
|
||||
Không khuyến nghị. Vứt đi hai tuần công sức kiểm chứng, và ba tài liệu refactor
|
||||
không có gì thay thế cho phần giao diện.
|
||||
|
||||
### Đề xuất
|
||||
|
||||
**Đường A**, kèm một ràng buộc: PR nào *sửa* checker phải nói rõ trong mô tả
|
||||
**sửa gì và vì sao** — để việc nới lỏng phép kiểm không lọt qua review.
|
||||
|
||||
`tools/check_probes_bite.py` đã có sẵn cơ chế chứng minh checker còn cắn được;
|
||||
chạy nó sau mỗi đợt sửa là bắt được ngay chuyện đó.
|
||||
|
||||
> **Chốt 21/08: đường A** — ai dời file thì cập nhật checker tương ứng ngay
|
||||
> trong PR đó.
|
||||
>
|
||||
> Kèm hai ràng buộc, vì rủi ro của đường A là người sửa vội nới lỏng phép kiểm:
|
||||
>
|
||||
> 1. PR nào *sửa* checker phải nói rõ trong mô tả **sửa gì và vì sao**.
|
||||
> 2. Sửa xong chạy `python tools/check_probes_bite.py` — nó cắm lỗi cố ý vào
|
||||
> code rồi kiểm checker có bắt được không. Chính công cụ này đã từng bắt
|
||||
> được một checker bị sửa thành *không thể đỏ*.
|
||||
>
|
||||
> Không đưa 24 checker vào CI trong sprint này: chúng dựng `MainWindow` thật,
|
||||
> mỗi lần chạy tốn hàng chục giây và thỉnh thoảng sập lúc Qt dọn dẹp. Chạy tay
|
||||
> theo phạm vi mình đụng là đủ.
|
||||
|
||||
---
|
||||
|
||||
## Quyết định 3 — Gamma viết hộ DTO `ToolPolicyGateway` cho Team Hoa
|
||||
|
||||
**Đã làm, chờ Hoa xác nhận.** Ngày: 21/08.
|
||||
|
||||
### Vì sao làm thay
|
||||
|
||||
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường:
|
||||
|
||||
| | Hệ quả |
|
||||
|---|---|
|
||||
| N3 ngồi đợi Hoa | Mất mấy ngày, trái nguyên tắc "không team nào chặn team nào" |
|
||||
| N3 tự phỏng đoán | Phỏng đoán của một người, không ai soi, sửa lại chắc chắn |
|
||||
| **Gamma viết bản đề xuất** | N3 chạy ngay, Hoa có cái cụ thể để duyệt hoặc sửa |
|
||||
|
||||
### Ranh giới không lấn
|
||||
|
||||
Sơ đồ phân hệ trong `plan.md` giao `domain/security/` cho **Team Gamma**, còn
|
||||
`application/conversations/tool_policy_gateway.py` cho **Team Hoa**.
|
||||
|
||||
Nên chia đúng như vậy:
|
||||
|
||||
- **Gamma định nghĩa hình dạng** → `domain/security/tool_policy.py`
|
||||
- **Hoa cài đặt gateway** → `application/conversations/tool_policy_gateway.py`,
|
||||
nối vào `core/mcp_client.py` và tool dựng sẵn
|
||||
|
||||
Không đụng file nào của Hoa.
|
||||
|
||||
### Đã bám vào code đang chạy, không bịa
|
||||
|
||||
| Nguồn | Lấy gì |
|
||||
|---|---|
|
||||
| `core/agent_security.py::SecurityVerdict` | `allowed` · `reason` · `layer` |
|
||||
| `ui/permission_dialog.py` + `chat_panel.py:1312` | trạng thái "hỏi người dùng" |
|
||||
|
||||
Khác biệt duy nhất: gộp thành **một câu trả lời ba trạng thái**
|
||||
(`ALLOW` / `DENY` / `ASK`) thay vì bắt chỗ gọi tự nhớ hỏi hai nơi.
|
||||
|
||||
Hai ràng buộc đưa vào có chủ đích:
|
||||
|
||||
1. `DENY` và `ASK` **bắt buộc có `reason`** — người dùng cần biết vì sao, và
|
||||
`audit_log` cần ghi lại. Thiếu là ném lỗi ngay lúc dựng, không phải lúc chạy.
|
||||
2. `ASK` **không phải** `allowed` — bẫy dễ mắc nhất là coi ASK như ALLOW rồi tool
|
||||
chạy mà chưa ai đồng ý. Có test riêng cho chuyện này.
|
||||
|
||||
### Gửi Hoa cái gì
|
||||
|
||||
> Bên mình viết trước bản đề xuất `ToolPolicyGateway` ở
|
||||
> `domain/security/tool_policy.py` vì N3 cần gọi tool mà bên Hoa chưa bắt đầu —
|
||||
> để N3 khỏi phải tự đoán. Ba kiểu: `ToolCallRequest`, `PolicyDecision`,
|
||||
> `ToolPolicyGateway`. Phần cài đặt vẫn để bên Hoa ở
|
||||
> `application/conversations/tool_policy_gateway.py`, bọn mình không đụng.
|
||||
> Thấy chỗ nào không hợp thì sửa thẳng file đó, đừng tạo kiểu thứ hai. Đổi bây
|
||||
> giờ còn rẻ vì mới mình N3 dùng.
|
||||
|
||||
> Đã gửi Hoa: ☐ — ngày ____ Hoa xác nhận: ☐ đồng ý ☐ có sửa
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -230,16 +290,20 @@
|
||||
| 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` | `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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **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)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
"""Domain Layer: Pure Python domain entities, value objects, and events."""
|
||||
"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng."""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Cổng chính sách cho lời gọi tool — hình dạng dữ liệu, chưa phải cài đặt.
|
||||
|
||||
BẢN ĐỀ XUẤT, chờ Team Hoa xác nhận
|
||||
==================================
|
||||
Sơ đồ phân hệ trong ``plan.md`` giao ``domain/security/`` cho Team Gamma và
|
||||
``application/conversations/tool_policy_gateway.py`` cho Team Hoa. Nên Gamma
|
||||
định nghĩa *hình dạng*, Hoa *cài đặt*.
|
||||
|
||||
Viết trước vì N3 (Co4E) cần gọi tool và Team Hoa chưa bắt đầu. Không có nó thì
|
||||
N3 phải tự phỏng đoán rồi sửa lại sau — mà phỏng đoán của một người thì tệ hơn
|
||||
một đề xuất viết ra để cả hai bên soi.
|
||||
|
||||
Nếu Hoa thấy khác, sửa file này chứ đừng đẻ kiểu thứ hai. Đổi sớm rẻ hơn đổi
|
||||
muộn: hiện chỉ N3 dùng.
|
||||
|
||||
Mô hình bám theo code đang chạy, không bịa:
|
||||
* ``core/agent_security.py::SecurityVerdict`` — allowed / reason / layer
|
||||
* ``ui/permission_dialog.py`` — hộp thoại hỏi người dùng khi
|
||||
``ctx.project_confirm_commands()`` bật (``ui/chat_panel.py:1312``)
|
||||
|
||||
Điểm khác biệt duy nhất so với hôm nay: gộp hai thứ đó thành **một câu trả lời
|
||||
ba trạng thái**, thay vì code gọi phải tự nhớ hỏi cả hai nơi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class PolicyOutcome(str, Enum):
|
||||
"""Ba trạng thái. ``ASK`` là thứ hệ thống hiện tại đã có (hộp thoại xin
|
||||
phép) nhưng chưa được coi là một kết quả chính thức."""
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallRequest:
|
||||
"""Một lời gọi tool đang chờ được duyệt.
|
||||
|
||||
``surface`` cho biết chỗ phát sinh — ``"cowork"``, ``"code"``, ``"co4e"``,
|
||||
``"task"``. Chính sách khác nhau theo màn: Co4E chạy nền nên không thể bật
|
||||
hộp thoại hỏi giữa chừng như Cowork.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arguments: Dict[str, Any] = field(default_factory=dict)
|
||||
surface: str = "cowork"
|
||||
project_id: str = ""
|
||||
#: True nếu tool đến từ MCP server ngoài, False nếu là tool dựng sẵn.
|
||||
external: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
"""Câu trả lời của cổng.
|
||||
|
||||
``reason`` bắt buộc có khi DENY hoặc ASK — người dùng phải biết vì sao bị
|
||||
chặn, và ``core/audit_log.py`` cần nó để ghi lại.
|
||||
|
||||
``layer`` giữ đúng từ vựng của ``SecurityVerdict``: ``"prompt"`` |
|
||||
``"attachment"`` | ``"command"``, cộng thêm ``"policy"`` cho quyết định của
|
||||
chính cổng này.
|
||||
"""
|
||||
|
||||
outcome: PolicyOutcome
|
||||
reason: str = ""
|
||||
layer: str = "policy"
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
"""Tương thích với chỗ đang đọc ``SecurityVerdict.allowed``.
|
||||
|
||||
Chú ý: ``ASK`` KHÔNG phải allowed — còn phải hỏi người dùng đã.
|
||||
"""
|
||||
return self.outcome is PolicyOutcome.ALLOW
|
||||
|
||||
def __post_init__(self):
|
||||
if self.outcome is not PolicyOutcome.ALLOW and not self.reason:
|
||||
raise ValueError("DENY và ASK bắt buộc có reason — người dùng và "
|
||||
"audit log đều cần biết vì sao")
|
||||
|
||||
|
||||
def allow() -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.ALLOW)
|
||||
|
||||
|
||||
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
|
||||
|
||||
|
||||
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.ASK, reason, layer)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolPolicyGateway(Protocol):
|
||||
"""Hỏi trước khi chạy tool. Cài đặt thật: Team Hoa (R07, hạn 29/08)."""
|
||||
|
||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||
"""Được chạy tool này không.
|
||||
|
||||
KHÔNG được tự bật hộp thoại bên trong — cổng chỉ *trả lời*, còn hỏi ai
|
||||
và hỏi thế nào là việc của tầng giao diện. Có vậy thì Co4E chạy nền mới
|
||||
dùng chung cổng được với Cowork chạy tương tác.
|
||||
"""
|
||||
...
|
||||
@@ -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 +1 @@
|
||||
"""Infrastructure Layer: External system adapters, persistence, and SDK clients."""
|
||||
"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface."""
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Cấu hình ứng dụng — interface, chưa phải cài đặt.
|
||||
|
||||
Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi
|
||||
``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi
|
||||
``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày
|
||||
đầu ngồi không.
|
||||
|
||||
Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong
|
||||
``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng
|
||||
thật, xếp theo số lần gọi.
|
||||
|
||||
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
|
||||
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
|
||||
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigRepository(Protocol):
|
||||
"""Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02)."""
|
||||
|
||||
# ---- provider ------------------------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
"""Tên provider đang chọn (24 lời gọi)."""
|
||||
...
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
...
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
"""Cấu hình của một provider (9 lời gọi).
|
||||
|
||||
CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``:
|
||||
dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5
|
||||
nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy.
|
||||
"""
|
||||
...
|
||||
|
||||
# ---- đường dẫn -----------------------------------------------------
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
"""Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi)."""
|
||||
...
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
"""Thư mục lịch sử chat của project đang chọn (7 lời gọi)."""
|
||||
...
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
"""Thư mục Cowork ghi kết quả ra (6 lời gọi)."""
|
||||
...
|
||||
|
||||
# ---- giao diện -----------------------------------------------------
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
"""``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi)."""
|
||||
...
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
...
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi)."""
|
||||
...
|
||||
|
||||
def set_language(self, value: str) -> None:
|
||||
...
|
||||
|
||||
# ---- các nhóm cấu hình còn lại -------------------------------------
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
"""Cấu hình định tuyến model (7 lời gọi)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
"""Cấu hình đăng nhập (6 lời gọi)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
"""Chính sách an toàn cho agent (5 lời gọi)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list[str]:
|
||||
"""Tool bị tắt (2 lời gọi)."""
|
||||
...
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
...
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế —
|
||||
nên tắt máy giữa chừng không làm hỏng file (R02-T01).
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,197 @@
|
||||
"""ConfigRepository chạy trên file JSON — R02-T02.
|
||||
|
||||
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
|
||||
đều là thứ ta muốn:
|
||||
|
||||
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
|
||||
cấu hình (R02-T01).
|
||||
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
|
||||
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
|
||||
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
|
||||
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
|
||||
|
||||
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
|
||||
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
|
||||
nhìn từ ngoài phải y hệt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..persistence.json.atomic_json_file import AtomicJsonFile
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
from .schema_migration import CURRENT_VERSION, migrate
|
||||
|
||||
|
||||
class JsonConfigRepository:
|
||||
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
|
||||
|
||||
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
|
||||
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
|
||||
(R02-T05) chứ không phải đổi một phát cả app.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
|
||||
defaults: Dict[str, Any] | None = None,
|
||||
env_overrides=None):
|
||||
self._file = AtomicJsonFile(path)
|
||||
self._secrets = secrets
|
||||
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
|
||||
if defaults is None or env_overrides is None:
|
||||
from ... import config as legacy
|
||||
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
|
||||
env_overrides = env_overrides or legacy._apply_env_overrides
|
||||
self._defaults = defaults
|
||||
self._env_overrides = env_overrides
|
||||
self.data: Dict[str, Any] = self._load()
|
||||
|
||||
# ---- nạp ------------------------------------------------------------
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
merged = copy.deepcopy(self._defaults)
|
||||
stored = self._file.read(default=None)
|
||||
if isinstance(stored, dict):
|
||||
# Nâng cấp TRƯỚC khi trộn với mặc định: bước v1→v2 gỡ api_key khỏi
|
||||
# đĩa, mà mặc định thì không có khoá nào để gỡ.
|
||||
stored, changed = migrate(stored, secrets=self._secrets,
|
||||
path=self._file.path)
|
||||
merged = _deep_merge(merged, stored)
|
||||
if changed:
|
||||
self.data = merged
|
||||
self.save() # ghi ngay, để lần sau khỏi chuyển lại
|
||||
merged = self._env_overrides(merged)
|
||||
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
|
||||
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
|
||||
merged.setdefault("ms365", {})["unlocked"] = False
|
||||
return merged
|
||||
|
||||
def reload(self) -> None:
|
||||
self.data = self._load()
|
||||
|
||||
# ---- provider --------------------------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
return self.data.get("active_provider", "")
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
self.data["active_provider"] = name
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
"""Cấu hình provider, có sẵn ``api_key``.
|
||||
|
||||
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
|
||||
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
|
||||
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
|
||||
"""
|
||||
name = name or self.active_provider
|
||||
conf = dict(self.data.get("providers", {}).get(name, {}))
|
||||
if self._secrets is not None:
|
||||
stored = self._secrets.get(provider_key(name))
|
||||
if stored:
|
||||
conf["api_key"] = stored
|
||||
return conf
|
||||
|
||||
def set_api_key(self, name: str, value: str) -> None:
|
||||
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
|
||||
|
||||
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
|
||||
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
|
||||
"""
|
||||
if self._secrets is not None:
|
||||
self._secrets.set(provider_key(name), value)
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
|
||||
else:
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
|
||||
|
||||
# ---- đường dẫn -------------------------------------------------------
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
return self.data.get("shared_dir", "")
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
rt = self.data.get("_project_history_dir")
|
||||
if rt:
|
||||
return Path(rt)
|
||||
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from ...config import CONFIG_DIR
|
||||
return CONFIG_DIR / "history"
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from ... import paths
|
||||
from ...config import CONFIG_DIR
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "output"
|
||||
return CONFIG_DIR / "output" / "cowork"
|
||||
|
||||
# ---- giao diện -------------------------------------------------------
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
return self.data.get("theme", "dark")
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
return self.data.get("language", "vi")
|
||||
|
||||
def set_language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
# ---- nhóm cấu hình ---------------------------------------------------
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("routing", {})
|
||||
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("auth", {})
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("agent_security", {})
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list[str]:
|
||||
return list(self.data.get("tools_disabled", []))
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
disabled = list(self.data.get("tools_disabled", []))
|
||||
if enabled:
|
||||
disabled = [t for t in disabled if t != name]
|
||||
elif name not in disabled:
|
||||
disabled.append(name)
|
||||
self.data["tools_disabled"] = disabled
|
||||
|
||||
# ---- ghi -------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
||||
to_write = self.data
|
||||
if self.data.get("ms365", {}).get("unlocked"):
|
||||
to_write = copy.deepcopy(self.data)
|
||||
to_write["ms365"]["unlocked"] = False
|
||||
to_write.pop("_project_history_dir", None)
|
||||
to_write["schema_version"] = CURRENT_VERSION
|
||||
self._file.write(to_write)
|
||||
|
||||
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
|
||||
|
||||
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
|
||||
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
|
||||
"""
|
||||
out = copy.deepcopy(base)
|
||||
for key, value in (override or {}).items():
|
||||
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
||||
out[key] = _deep_merge(out[key], value)
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
|
||||
|
||||
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
|
||||
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
|
||||
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
|
||||
khoá ``office`` nghĩa là file cũ".
|
||||
|
||||
Ở đây đặt luật rõ:
|
||||
|
||||
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
|
||||
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
|
||||
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
|
||||
file mới có thể hỏng — phải còn đường về.
|
||||
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
|
||||
đoán ngược.
|
||||
|
||||
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
|
||||
``SecretStore``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Phiên bản app hiện đang ghi ra.
|
||||
CURRENT_VERSION = 2
|
||||
|
||||
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
|
||||
ASSUMED_VERSION = 1
|
||||
|
||||
|
||||
def read_version(data: Dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(data.get("schema_version", ASSUMED_VERSION))
|
||||
except (TypeError, ValueError):
|
||||
return ASSUMED_VERSION
|
||||
|
||||
|
||||
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
|
||||
"""Chuyển API key từ file sang kho bí mật — R02-T05.
|
||||
|
||||
Không có kho bí mật thì **không chuyển**: thà để khoá nằm nguyên trong file
|
||||
còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. File giữ
|
||||
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
|
||||
"""
|
||||
if secrets is None or not getattr(secrets, "available", True):
|
||||
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
||||
return data
|
||||
|
||||
out = copy.deepcopy(data)
|
||||
moved = []
|
||||
for name, conf in (out.get("providers") or {}).items():
|
||||
if not isinstance(conf, dict):
|
||||
continue
|
||||
key = (conf.get("api_key") or "").strip()
|
||||
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
|
||||
# dung. Đẩy nó vào keyring chỉ tổ rác.
|
||||
if not key or key == "ollama":
|
||||
continue
|
||||
secrets.set(provider_key(name), key)
|
||||
conf["api_key"] = ""
|
||||
moved.append(name)
|
||||
|
||||
out["schema_version"] = 2
|
||||
if moved:
|
||||
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
|
||||
return out
|
||||
|
||||
|
||||
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
|
||||
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
|
||||
1: _v1_to_v2,
|
||||
}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path | None:
|
||||
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
|
||||
if not path.exists():
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
|
||||
try:
|
||||
shutil.copy2(path, target)
|
||||
return target
|
||||
except OSError as exc:
|
||||
log.warning("không sao lưu được %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
|
||||
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
|
||||
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
|
||||
|
||||
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
|
||||
khỏi phải ghi lại đĩa.
|
||||
"""
|
||||
version = read_version(data)
|
||||
|
||||
if version > CURRENT_VERSION:
|
||||
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
|
||||
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
|
||||
version, CURRENT_VERSION)
|
||||
return data, False
|
||||
|
||||
if version == CURRENT_VERSION:
|
||||
return data, False
|
||||
|
||||
if path is not None:
|
||||
backup(path)
|
||||
|
||||
changed = False
|
||||
while version < CURRENT_VERSION:
|
||||
step = STEPS.get(version)
|
||||
if step is None:
|
||||
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
|
||||
break
|
||||
data = step(data, secrets)
|
||||
new_version = read_version(data)
|
||||
if new_version <= version:
|
||||
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
|
||||
# keyring). Dừng, đừng lặp vô hạn.
|
||||
break
|
||||
version = new_version
|
||||
changed = True
|
||||
|
||||
return data, changed
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Khung nhìn có kiểu cho từng nhóm cấu hình — R02-T03.
|
||||
|
||||
Vấn đề đang có: khắp nơi viết ``ctx.config.routing.get("switch_mode", "off")``.
|
||||
Gõ sai một chữ thì lặng lẽ nhận giá trị mặc định, không ai biết cho tới khi
|
||||
tính năng "không hiểu sao không chạy". Đếm được **156 lời gọi ``ctx.config.*``
|
||||
trong 29 file** kiểu đó.
|
||||
|
||||
Ở đây mỗi nhóm cấu hình có một lớp: gõ sai tên thuộc tính là lỗi ngay, và kiểu
|
||||
dữ liệu ghi rõ ràng nên đọc code là biết ``confirm_timeout_sec`` là số giây
|
||||
chứ không phải mili giây.
|
||||
|
||||
Cố ý KHÔNG dùng dataclass đông cứng: đây là *khung nhìn* lên dict cấu hình
|
||||
sống, sửa qua đây là sửa vào dict rồi ``save()`` là xuống đĩa. Sao chép thành
|
||||
dataclass thì lại sinh chuyện đồng bộ hai chiều.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class _View:
|
||||
"""Khung nhìn lên một nhánh của dict cấu hình."""
|
||||
|
||||
def __init__(self, data: Dict[str, Any]):
|
||||
self._d = data
|
||||
|
||||
def _get(self, key: str, default: Any) -> Any:
|
||||
value = self._d.get(key, default)
|
||||
return default if value is None else value
|
||||
|
||||
def raw(self) -> Dict[str, Any]:
|
||||
"""Dict gốc — dùng khi cần đọc khoá chưa được đưa vào khung nhìn.
|
||||
|
||||
Có mặt để không ai bị kẹt: thiếu thuộc tính thì dùng tạm ``raw()`` rồi
|
||||
mở issue bổ sung, chứ đừng vòng lại ``ctx.config.data``.
|
||||
"""
|
||||
return self._d
|
||||
|
||||
|
||||
class ProviderSettings(_View):
|
||||
"""Một provider: đi đâu, model nào, khoá nào.
|
||||
|
||||
``api_key`` ở đây là thứ ``JsonConfigRepository.provider_conf()`` đã ghép
|
||||
sẵn từ kho bí mật — xem đường A trong ``GammaTeam_decisions.md``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return str(self._get("base_url", ""))
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
return str(self._get("model", ""))
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return str(self._get("api_key", ""))
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
"""Đủ thông tin để gọi được chưa.
|
||||
|
||||
Ollama chạy cục bộ nên không cần khoá — đó là lý do điều kiện là
|
||||
"có base_url và model", không phải "có api_key".
|
||||
"""
|
||||
return bool(self.base_url and self.model)
|
||||
|
||||
|
||||
class RoutingSettings(_View):
|
||||
"""Định tuyến model tự động (``core/routing/``)."""
|
||||
|
||||
@property
|
||||
def switch_mode(self) -> str:
|
||||
"""``"off"`` | ``"auto"`` | ``"manual"``."""
|
||||
return str(self._get("switch_mode", "off"))
|
||||
|
||||
@switch_mode.setter
|
||||
def switch_mode(self, value: str) -> None:
|
||||
self._d["switch_mode"] = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.switch_mode != "off"
|
||||
|
||||
@property
|
||||
def policy(self) -> str:
|
||||
"""``"balanced"`` | ``"cheap"`` | ``"quality"``…"""
|
||||
return str(self._get("policy", "balanced"))
|
||||
|
||||
@property
|
||||
def min_score_gain(self) -> float:
|
||||
"""Phải hơn model hiện tại bao nhiêu điểm mới đáng đổi."""
|
||||
return float(self._get("min_score_gain", 0.05))
|
||||
|
||||
@property
|
||||
def confirm_timeout_sec(self) -> int:
|
||||
"""GIÂY, không phải mili giây — đọc tên là biết, khỏi phải mò."""
|
||||
return int(self._get("confirm_timeout_sec", 60))
|
||||
|
||||
@property
|
||||
def reassess_interval_hours(self) -> int:
|
||||
return int(self._get("reassess_interval_hours", 24))
|
||||
|
||||
@property
|
||||
def per_provider_concurrency(self) -> int:
|
||||
return int(self._get("per_provider_concurrency", 2))
|
||||
|
||||
@property
|
||||
def judge_provider(self) -> str:
|
||||
return str(self._get("judge_provider", ""))
|
||||
|
||||
@property
|
||||
def judge_model(self) -> str:
|
||||
return str(self._get("judge_model", ""))
|
||||
|
||||
|
||||
class SecuritySettings(_View):
|
||||
"""Chính sách an toàn cho agent (``core/agent_security.py``)."""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._get("enabled", True))
|
||||
|
||||
@property
|
||||
def validate_prompt(self) -> bool:
|
||||
return bool(self._get("validate_prompt", True))
|
||||
|
||||
@property
|
||||
def validate_attachments(self) -> bool:
|
||||
return bool(self._get("validate_attachments", True))
|
||||
|
||||
@property
|
||||
def validate_commands(self) -> bool:
|
||||
return bool(self._get("validate_commands", True))
|
||||
|
||||
@property
|
||||
def command_ai_check(self) -> bool:
|
||||
return bool(self._get("command_ai_check", False))
|
||||
|
||||
@property
|
||||
def cowork_confirm_commands(self) -> bool:
|
||||
"""Có hỏi trước khi chạy lệnh không.
|
||||
|
||||
Ứng với ``PolicyOutcome.ASK`` trong
|
||||
``domain/security/tool_policy.py``.
|
||||
"""
|
||||
return bool(self._get("cowork_confirm_commands", True))
|
||||
|
||||
@property
|
||||
def rules_onedrive_url(self) -> str:
|
||||
return str(self._get("rules_onedrive_url", ""))
|
||||
|
||||
@property
|
||||
def admin_email(self) -> str:
|
||||
return str(self._get("admin_email", ""))
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Cửa vào duy nhất cho các nhóm cấu hình có kiểu.
|
||||
|
||||
>>> s = Settings(repo)
|
||||
>>> if s.routing.enabled and s.provider().configured:
|
||||
... ...
|
||||
"""
|
||||
|
||||
def __init__(self, repo):
|
||||
self._repo = repo
|
||||
|
||||
def provider(self, name: str | None = None) -> ProviderSettings:
|
||||
return ProviderSettings(self._repo.provider_conf(name))
|
||||
|
||||
@property
|
||||
def routing(self) -> RoutingSettings:
|
||||
return RoutingSettings(self._repo.routing)
|
||||
|
||||
@property
|
||||
def security(self) -> SecuritySettings:
|
||||
return SecuritySettings(self._repo.agent_security)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
||||
|
||||
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
||||
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
||||
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
||||
— và người dùng mất toàn bộ cấu hình.
|
||||
|
||||
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
||||
|
||||
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
||||
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
||||
bộ đệm của hệ điều hành
|
||||
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
||||
|
||||
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
||||
bao giờ có trạng thái ghi dở.
|
||||
|
||||
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
||||
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
||||
đúng như ``config.py`` hiện tại đang làm.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AtomicJsonFile:
|
||||
"""Một file JSON, đọc ghi an toàn.
|
||||
|
||||
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
||||
>>> f.write({"theme": "dark"})
|
||||
>>> f.read(default={})
|
||||
{'theme': 'dark'}
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, indent: int = 2):
|
||||
self.path = Path(path)
|
||||
self.indent = indent
|
||||
|
||||
# ---- đọc ------------------------------------------------------------
|
||||
def read(self, default: Any = None) -> Any:
|
||||
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
||||
|
||||
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
||||
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
||||
"""
|
||||
if not self.path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
self._quarantine()
|
||||
return default
|
||||
except OSError:
|
||||
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
||||
# có thể vẫn tốt nguyên.
|
||||
return default
|
||||
|
||||
def _quarantine(self) -> Path | None:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
|
||||
try:
|
||||
os.replace(self.path, target)
|
||||
return target
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
#: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây).
|
||||
_REPLACE_TRIES = 6
|
||||
_REPLACE_BACKOFF = 0.02
|
||||
|
||||
@classmethod
|
||||
def _replace_ben_bi(cls, src: Path, dst: Path) -> None:
|
||||
"""``os.replace`` có thử lại — bắt buộc trên Windows.
|
||||
|
||||
MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ
|
||||
handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là
|
||||
Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục
|
||||
mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết.
|
||||
|
||||
Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức
|
||||
khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh
|
||||
thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện.
|
||||
|
||||
POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt.
|
||||
"""
|
||||
for lan in range(cls._REPLACE_TRIES):
|
||||
try:
|
||||
os.replace(src, dst)
|
||||
return
|
||||
except PermissionError:
|
||||
if lan == cls._REPLACE_TRIES - 1:
|
||||
raise
|
||||
time.sleep(cls._REPLACE_BACKOFF * (2 ** lan))
|
||||
|
||||
def write(self, data: Any) -> None:
|
||||
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
||||
|
||||
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
||||
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
||||
# thành copy + delete — mất luôn tính nguyên tử.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
||||
self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại
|
||||
except BaseException:
|
||||
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
||||
# để rác .tmp nằm lại cạnh file cấu hình.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# ---- tiện ích -------------------------------------------------------
|
||||
def exists(self) -> bool:
|
||||
return self.path.exists()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AtomicJsonFile({self.path})"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""SecretStore chạy trên OS Keyring — R02-T04.
|
||||
|
||||
Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret
|
||||
Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong
|
||||
``config.json`` — đó là điều kiện để qua CASAN Check 1.
|
||||
|
||||
Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có
|
||||
Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao
|
||||
giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về
|
||||
None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây.
|
||||
SERVICE = "cowork-local"
|
||||
|
||||
|
||||
class KeyringAdapter:
|
||||
"""Cài đặt :class:`SecretStore` bằng thư viện ``keyring``.
|
||||
|
||||
>>> store = KeyringAdapter()
|
||||
>>> if store.available:
|
||||
... store.set("provider:openai", "sk-...")
|
||||
"""
|
||||
|
||||
def __init__(self, service: str = SERVICE):
|
||||
self.service = service
|
||||
self._backend = None
|
||||
self._available = False
|
||||
try:
|
||||
import keyring
|
||||
from keyring.backends.fail import Keyring as FailKeyring
|
||||
|
||||
backend = keyring.get_keyring()
|
||||
# backend "fail" là cái keyring trả về khi không tìm được kho nào
|
||||
# dùng được — gọi vào chỉ tổ ném lỗi.
|
||||
if not isinstance(backend, FailKeyring):
|
||||
self._backend = keyring
|
||||
self._available = True
|
||||
else:
|
||||
log.info("keyring không có kho khả dụng trên máy này")
|
||||
except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus…
|
||||
log.info("keyring không dùng được: %s", exc)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Có kho bí mật dùng được không.
|
||||
|
||||
Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key
|
||||
không lưu được, thay vì im lặng làm mất khoá họ vừa nhập.
|
||||
"""
|
||||
return self._available
|
||||
|
||||
# ---- SecretStore ----------------------------------------------------
|
||||
def get(self, key: str) -> str | None:
|
||||
if not self._available:
|
||||
return None
|
||||
try:
|
||||
return self._backend.get_password(self.service, key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("đọc khoá %r thất bại: %s", key, exc)
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
if not self._available:
|
||||
log.warning("không lưu được %r: máy này không có kho bí mật", key)
|
||||
return
|
||||
try:
|
||||
self._backend.set_password(self.service, key, value)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("lưu khoá %r thất bại: %s", key, exc)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
if not self._available:
|
||||
return
|
||||
try:
|
||||
self._backend.delete_password(self.service, key)
|
||||
except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua
|
||||
pass
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return self.get(key) is not None
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Nơi cất credential — interface, chưa phải cài đặt.
|
||||
|
||||
Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không
|
||||
phải đợi bản Keyring thật (R02-T04, hạn 26/08).
|
||||
|
||||
Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring —
|
||||
chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật.
|
||||
Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ.
|
||||
|
||||
Quy ước đặt key: ``"provider:<tên>"`` cho API key của provider, ví dụ
|
||||
``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
def provider_key(name: str) -> str:
|
||||
"""Key chuẩn cho API key của một provider."""
|
||||
return f"provider:{name}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SecretStore(Protocol):
|
||||
"""Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04)."""
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
"""Giá trị của ``key``, hoặc None nếu chưa có.
|
||||
|
||||
Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người
|
||||
dùng chưa nhập API key), không phải sự cố.
|
||||
"""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
"""Lưu ``value``. Ghi đè nếu key đã tồn tại."""
|
||||
...
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi."""
|
||||
...
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không
|
||||
cần đọc chính giá trị bí mật ra."""
|
||||
...
|
||||
@@ -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 +1 @@
|
||||
"""Presentation Layer: PySide6 UI widgets, dialogs, and shell views (<400 LOC per file)."""
|
||||
"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure."""
|
||||
|
||||
+19
-7
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""CASAN Check 1 — không được có credential nào nằm phơi trong repo.
|
||||
|
||||
Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên
|
||||
tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới
|
||||
chạy lần đầu rồi mới biết còn sót.
|
||||
|
||||
Quét gì:
|
||||
* file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env``
|
||||
* mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret
|
||||
|
||||
Tìm hai loại:
|
||||
1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…)
|
||||
2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder
|
||||
|
||||
Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng
|
||||
không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung).
|
||||
|
||||
Chạy: python scripts/audit_security.py [--json]
|
||||
Mã thoát: 0 = sạch, 1 = có phát hiện.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build",
|
||||
"dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"}
|
||||
CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"}
|
||||
|
||||
# tên trường coi là nhạy cảm
|
||||
SENSITIVE = re.compile(
|
||||
r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|"
|
||||
r"access[_-]?key|private[_-]?key|credential)", re.I)
|
||||
|
||||
# hình dạng credential thật — bắt được kể cả khi tên trường vô hại
|
||||
SHAPES = [
|
||||
("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")),
|
||||
("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
|
||||
("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
|
||||
("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")),
|
||||
("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
||||
("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
||||
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")),
|
||||
("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
||||
]
|
||||
|
||||
#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng
|
||||
#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm.
|
||||
ALLOW_MARK = re.compile(r"#\s*casan:\s*allow")
|
||||
|
||||
#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng
|
||||
#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ.
|
||||
LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
||||
|
||||
#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test
|
||||
#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo.
|
||||
MIN_SECRET_LEN = 12
|
||||
|
||||
#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt…
|
||||
ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off",
|
||||
"true", "false", "yes", "no", "allow", "deny", "block", "ask",
|
||||
"always", "never", "auto", "default", "disabled", "enabled"}
|
||||
|
||||
# giá trị vô hại — không tính là phát hiện
|
||||
PLACEHOLDER = re.compile(
|
||||
r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|"
|
||||
r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|"
|
||||
r"sk-\.\.\.|\.\.\.)$", re.I)
|
||||
|
||||
# gán chuỗi trong Python: api_key = "..."
|
||||
PY_ASSIGN = re.compile(
|
||||
r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*"""
|
||||
r"""["']([^"']*)["']""", re.I)
|
||||
|
||||
|
||||
def _is_placeholder(value: str) -> bool:
|
||||
v = value.strip()
|
||||
if PLACEHOLDER.match(v) or v.lower() in ENUMISH:
|
||||
return True
|
||||
if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output"
|
||||
return True
|
||||
# quá ngắn để là credential thật
|
||||
return len(v) < MIN_SECRET_LEN
|
||||
|
||||
|
||||
def _walk():
|
||||
for path in REPO.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if any(part in SKIP_DIRS for part in path.parts):
|
||||
continue
|
||||
if path.suffix in CONFIG_SUFFIX or path.suffix == ".py":
|
||||
yield path
|
||||
|
||||
|
||||
def scan() -> list[dict]:
|
||||
findings: list[dict] = []
|
||||
for path in _walk():
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
rel = path.relative_to(REPO).as_posix()
|
||||
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if ALLOW_MARK.search(line):
|
||||
continue
|
||||
# 1. hình dạng credential thật
|
||||
for label, pattern in SHAPES:
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
findings.append({
|
||||
"file": rel, "line": lineno, "kind": f"{label} credential",
|
||||
"evidence": m.group(0)[:12] + "…",
|
||||
})
|
||||
|
||||
# 2. trường nhạy cảm có giá trị
|
||||
for m in PY_ASSIGN.finditer(line):
|
||||
field, value = m.group(1), m.group(2)
|
||||
if not SENSITIVE.search(field) or _is_placeholder(value):
|
||||
continue
|
||||
findings.append({
|
||||
"file": rel, "line": lineno,
|
||||
"kind": f"trường '{field}' có giá trị",
|
||||
"evidence": value[:6] + "…" if len(value) > 6 else value,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _self_test() -> int:
|
||||
"""Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
|
||||
biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không."""
|
||||
import tempfile
|
||||
|
||||
bad = {
|
||||
"OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script
|
||||
"GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script
|
||||
"AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script
|
||||
"Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script
|
||||
}
|
||||
ok = {
|
||||
"rỗng": '"api_key": ""',
|
||||
"placeholder": '"api_key": "your-key-here"',
|
||||
"ollama": '"api_key": "ollama"',
|
||||
"test ngắn": 'api_key = "x"',
|
||||
"hằng liệt kê": '"secret_in_output": "warning"',
|
||||
}
|
||||
global REPO
|
||||
keep = REPO
|
||||
passed = True
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
REPO = Path(tmp)
|
||||
for label, line in {**bad, **ok}.items():
|
||||
(REPO / "probe.py").write_text(line + "\n", encoding="utf-8")
|
||||
found = bool(scan())
|
||||
want = label in bad
|
||||
mark = "OK " if found == want else "SAI"
|
||||
if found != want:
|
||||
passed = False
|
||||
verb = "bắt được" if found else "bỏ qua"
|
||||
print(f" [{mark}] {label:14} -> {verb}")
|
||||
REPO = keep
|
||||
print()
|
||||
print("Tự kiểm: " + ("script phân biệt đúng." if passed
|
||||
else "*** script phân biệt SAI ***"))
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ")
|
||||
ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON")
|
||||
ap.add_argument("--self-test", action="store_true",
|
||||
help="cắm credential giả vào file tạm, kiểm script có bắt được")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.self_test:
|
||||
return _self_test()
|
||||
|
||||
findings = scan()
|
||||
if args.json:
|
||||
print(json.dumps(findings, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
n_files = sum(1 for _ in _walk())
|
||||
print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/")
|
||||
if not findings:
|
||||
print("\n0 credential lưu plaintext. PASS.")
|
||||
else:
|
||||
print(f"\n*** {len(findings)} phát hiện ***\n")
|
||||
for f in findings:
|
||||
print(f" {f['file']}:{f['line']}")
|
||||
print(f" {f['kind']} — {f['evidence']}")
|
||||
return 1 if findings else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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)
|
||||
|
||||
+69
-4
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
+13
-4
@@ -1,5 +1,14 @@
|
||||
"""Test doubles and offline fakes package for Cowork Local test pyramid."""
|
||||
from .fake_provider import FakeProvider
|
||||
from .fake_tool_executor import FakeToolExecutor
|
||||
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.
|
||||
|
||||
__all__ = ["FakeProvider", "FakeToolExecutor"]
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ.
|
||||
|
||||
Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi
|
||||
bản thật xong ngày 23/08 và 26/08.
|
||||
|
||||
Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài
|
||||
mili giây.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class FakeSecretStore:
|
||||
"""SecretStore trong bộ nhớ.
|
||||
|
||||
>>> s = FakeSecretStore({"provider:openai": "sk-test"})
|
||||
>>> s.get("provider:openai")
|
||||
'sk-test'
|
||||
>>> s.get("provider:chua-co") is None
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self, seed: Dict[str, str] | None = None):
|
||||
self._items: Dict[str, str] = dict(seed or {})
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
return self._items.get(key)
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
self._items[key] = value
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._items.pop(key, None)
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return key in self._items
|
||||
|
||||
|
||||
class FakeConfigRepository:
|
||||
"""ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý.
|
||||
|
||||
Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống
|
||||
mình cần::
|
||||
|
||||
cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung")
|
||||
"""
|
||||
|
||||
def __init__(self, *, active_provider: str = "ollama",
|
||||
providers: Dict[str, Dict[str, Any]] | None = None,
|
||||
shared_dir: str = "", theme: str = "dark", language: str = "vi",
|
||||
routing: Dict[str, Any] | None = None,
|
||||
auth: Dict[str, Any] | None = None,
|
||||
agent_security: Dict[str, Any] | None = None,
|
||||
tools_disabled: list[str] | None = None,
|
||||
history_dir: Path | None = None,
|
||||
output_dir: Path | None = None):
|
||||
self._active_provider = active_provider
|
||||
self._providers = providers or {
|
||||
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"},
|
||||
}
|
||||
self._shared_dir = shared_dir
|
||||
self._theme = theme
|
||||
self._language = language
|
||||
self._routing = routing or {"mode": "off"}
|
||||
self._auth = auth or {}
|
||||
self._agent_security = agent_security or {"cowork_confirm_commands": True}
|
||||
self._tools_disabled = list(tools_disabled or [])
|
||||
self._history_dir = history_dir or Path("/fake/history")
|
||||
self._output_dir = output_dir or Path("/fake/workspace")
|
||||
#: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa
|
||||
self.saves = 0
|
||||
|
||||
# ---- provider ------------------------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
return self._active_provider
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
self._active_provider = name
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
return dict(self._providers.get(name or self._active_provider, {}))
|
||||
|
||||
# ---- đường dẫn -----------------------------------------------------
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
return self._shared_dir
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
return self._history_dir
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
return self._output_dir
|
||||
|
||||
# ---- giao diện -----------------------------------------------------
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
return self._theme
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
self._theme = value
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
return self._language
|
||||
|
||||
def set_language(self, value: str) -> None:
|
||||
self._language = value
|
||||
|
||||
# ---- nhóm cấu hình --------------------------------------------------
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
return self._routing
|
||||
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
return self._auth
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
return self._agent_security
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list[str]:
|
||||
return list(self._tools_disabled)
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
if enabled:
|
||||
self._tools_disabled = [t for t in self._tools_disabled if t != name]
|
||||
elif name not in self._tools_disabled:
|
||||
self._tools_disabled.append(name)
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
self.saves += 1
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ToolPolicyGateway giả — để N3 (Co4E) chạy được khi Team Hoa chưa cài đặt.
|
||||
|
||||
Mặc định cho qua hết, vì phần lớn test Co4E quan tâm tới luồng workflow chứ
|
||||
không phải chính sách. Test nào cần kiểm nhánh bị chặn thì lập trình câu trả
|
||||
lời::
|
||||
|
||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict
|
||||
|
||||
from cowork_local.domain.security.tool_policy import (
|
||||
PolicyDecision, ToolCallRequest, allow,
|
||||
)
|
||||
|
||||
|
||||
class FakeToolPolicyGateway:
|
||||
"""Cổng chính sách trong bộ nhớ, có ghi lại đã hỏi những gì."""
|
||||
|
||||
def __init__(self, rules: Dict[str, PolicyDecision] | None = None,
|
||||
default: PolicyDecision | None = None,
|
||||
decide: Callable[[ToolCallRequest], PolicyDecision] | None = None):
|
||||
#: {tên tool: quyết định} — tra trước default
|
||||
self.rules = dict(rules or {})
|
||||
self.default = default or allow()
|
||||
#: hàm tự quyết, dùng khi cần logic phức tạp hơn tra bảng
|
||||
self._decide = decide
|
||||
#: mọi lời gọi đã đi qua — để test khẳng định "có hỏi cổng không"
|
||||
self.seen: list[ToolCallRequest] = []
|
||||
|
||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||
self.seen.append(request)
|
||||
if self._decide is not None:
|
||||
return self._decide(request)
|
||||
return self.rules.get(request.name, self.default)
|
||||
|
||||
# ---- tiện cho test --------------------------------------------------
|
||||
def asked_for(self, name: str) -> bool:
|
||||
return any(r.name == name for r in self.seen)
|
||||
|
||||
@property
|
||||
def call_count(self) -> int:
|
||||
return len(self.seen)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Offline test doubles for the R04 turn runtime seams.
|
||||
|
||||
Sits beside ``fake_provider.py``/``fake_tool_executor.py`` (R01-T02) and plays
|
||||
the same role one level up: those fake a *provider*, these fake the ports
|
||||
``ConversationApplicationService`` is driven through
|
||||
(``application/conversations/turn_runtime.py``).
|
||||
|
||||
Deliberately dumb — they record what they were asked and return canned answers.
|
||||
A failing test then points at the service under test rather than at a mock
|
||||
framework's configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from cowork_local.domain.agents.agent_event import ToolPreview
|
||||
from cowork_local.domain.agents.conversation_execution_request import (
|
||||
ConversationExecutionRequest,
|
||||
)
|
||||
|
||||
|
||||
class FakeSpec:
|
||||
"""An advertised tool. The service only ever reads ``.name`` off a spec."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
|
||||
class FakeReply:
|
||||
"""One programmed provider answer."""
|
||||
|
||||
def __init__(self, content: str = "", tool_calls=None, chunks=None, reasoning: str = ""):
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls or []
|
||||
# Default to streaming the whole content as a single chunk, which is what
|
||||
# a non-streaming gateway effectively does.
|
||||
self.chunks = chunks if chunks is not None else ([content] if content else [])
|
||||
self.reasoning = reasoning
|
||||
|
||||
|
||||
class FakeModelCall:
|
||||
""":class:`ModelCallPort` returning programmed replies in order.
|
||||
|
||||
A programmed entry may be an exception instead of a reply, which is how a
|
||||
test simulates the gateway dying mid-turn.
|
||||
"""
|
||||
|
||||
def __init__(self, replies: List[Any]) -> None:
|
||||
self.replies = list(replies)
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
|
||||
# Snapshot the messages: the service keeps mutating its own list, so
|
||||
# storing it by reference would make every recorded call look identical.
|
||||
self.calls.append({"messages": [dict(m) for m in messages],
|
||||
"tool_names": [getattr(t, "name", "") for t in tools]})
|
||||
reply = self.replies.pop(0) if self.replies else FakeReply(content="(default)")
|
||||
if isinstance(reply, BaseException):
|
||||
raise reply
|
||||
if reply.reasoning and on_reasoning:
|
||||
on_reasoning(reply.reasoning)
|
||||
for chunk in reply.chunks:
|
||||
if on_text and chunk:
|
||||
on_text(chunk)
|
||||
assistant: Dict[str, Any] = {"role": "assistant", "content": reply.content}
|
||||
if reply.tool_calls:
|
||||
assistant["tool_calls"] = reply.tool_calls
|
||||
return assistant
|
||||
|
||||
|
||||
class FakeToolRuntime:
|
||||
""":class:`ToolRuntimePort` over an imaginary output folder."""
|
||||
|
||||
def __init__(self, specs=("save_file", "run_command", "update_plan"),
|
||||
results: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
removed: Tuple[str, ...] = (), added: Tuple[str, ...] = ()) -> None:
|
||||
self._specs = [FakeSpec(n) for n in specs]
|
||||
self._results = results or {}
|
||||
self._removed, self._added = removed, added
|
||||
self.executed: List[Tuple[str, Dict[str, Any]]] = []
|
||||
self.finalize_calls: List[Dict[str, Any]] = []
|
||||
# When set, every executed tool streams this string through ``on_output``.
|
||||
self.emit_output: Optional[str] = None
|
||||
|
||||
def specs(self, allowed_tools=None):
|
||||
if allowed_tools is None:
|
||||
return list(self._specs)
|
||||
return [s for s in self._specs if s.name in allowed_tools]
|
||||
|
||||
def preview(self, name, args):
|
||||
return ToolPreview(kind="info", title=name, text=str(args))
|
||||
|
||||
def execute(self, name, args, on_output=None, cancel=None):
|
||||
self.executed.append((name, dict(args)))
|
||||
if self.emit_output and on_output:
|
||||
on_output(self.emit_output)
|
||||
return dict(self._results.get(name, {"ok": True, "output": f"{name} ok"}))
|
||||
|
||||
def snapshot(self):
|
||||
return "before"
|
||||
|
||||
def finalize(self, before, cancelled=False):
|
||||
self.finalize_calls.append({"before": before, "cancelled": cancelled})
|
||||
return list(self._removed), list(self._added)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Small helpers shared by the turn tests.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def make_request(**overrides) -> ConversationExecutionRequest:
|
||||
"""A minimal valid request; each test overrides only what it exercises."""
|
||||
base: Dict[str, Any] = {"turn_id": "t1", "session_id": "s1", "prompt": "do it"}
|
||||
base.update(overrides)
|
||||
return ConversationExecutionRequest(**base)
|
||||
|
||||
|
||||
def run_turn(service, request=None, cancel=None):
|
||||
"""Execute a turn and return ``(result, events)``."""
|
||||
events: List[Any] = []
|
||||
result = service.execute(request or make_request(), events.append, cancel=cancel)
|
||||
return result, events
|
||||
|
||||
|
||||
def events_of_type(events, cls):
|
||||
"""Every emitted event of one type, in order."""
|
||||
return [e for e in events if isinstance(e, cls)]
|
||||
|
||||
|
||||
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
|
||||
"""A turn that calls one tool and then answers — ``(model, tools)``."""
|
||||
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
|
||||
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
|
||||
FakeReply(content="done")])
|
||||
return model, FakeToolRuntime(**tool_kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FakeSpec", "FakeReply", "FakeModelCall", "FakeToolRuntime",
|
||||
"make_request", "run_turn", "events_of_type", "tool_turn",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""AtomicJsonFile — R02-T01. Test tiêm lỗi, đúng như cột nghiệm thu của plan.md.
|
||||
|
||||
Cách kiểm: cắt ngang giữa lúc ghi rồi khẳng định file cũ **còn nguyên**. Nếu
|
||||
chỉ test "ghi rồi đọc lại thấy đúng" thì `path.write_text()` cũ cũng qua — mà
|
||||
đó chính là thứ ta đang thay.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||
|
||||
|
||||
def test_ghi_roi_doc_lai(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "cau_hinh.json")
|
||||
f.write({"theme": "dark", "ngôn ngữ": "vi"})
|
||||
assert f.read() == {"theme": "dark", "ngôn ngữ": "vi"}
|
||||
|
||||
|
||||
def test_chua_co_file_thi_tra_mac_dinh(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "chua-ton-tai.json")
|
||||
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
||||
assert f.exists() is False
|
||||
|
||||
|
||||
def test_chet_giua_luc_ghi_thi_file_cu_con_nguyen(tmp_path, monkeypatch):
|
||||
"""Lõi của R02-T01.
|
||||
|
||||
Giả lập mất điện đúng lúc: cho ``os.replace`` ném lỗi. Đây là bước cuối
|
||||
cùng, tức là dữ liệu mới đã nằm trong file tạm rồi — nếu cài đặt sai theo
|
||||
kiểu ghi đè thẳng, file đích lúc này đã hỏng.
|
||||
"""
|
||||
path = tmp_path / "cau_hinh.json"
|
||||
f = AtomicJsonFile(path)
|
||||
f.write({"phiên bản": 1, "quan trọng": "đừng mất"})
|
||||
|
||||
def no_dien(*args, **kwargs):
|
||||
raise OSError("mô phỏng mất điện")
|
||||
|
||||
monkeypatch.setattr(os, "replace", no_dien)
|
||||
with pytest.raises(OSError):
|
||||
f.write({"phiên bản": 2})
|
||||
|
||||
# bản cũ phải còn y nguyên
|
||||
assert f.read() == {"phiên bản": 1, "quan trọng": "đừng mất"}
|
||||
|
||||
|
||||
def test_khong_de_lai_rac_tmp_khi_ghi_hong(tmp_path, monkeypatch):
|
||||
path = tmp_path / "cau_hinh.json"
|
||||
f = AtomicJsonFile(path)
|
||||
f.write({"a": 1})
|
||||
|
||||
monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("x")))
|
||||
with pytest.raises(OSError):
|
||||
f.write({"a": 2})
|
||||
|
||||
con_lai = [p.name for p in tmp_path.iterdir()]
|
||||
assert con_lai == ["cau_hinh.json"], f"còn rác: {con_lai}"
|
||||
|
||||
|
||||
def test_file_hong_thi_cach_ly_va_tra_mac_dinh(tmp_path):
|
||||
"""Hỏng cấu hình không được chặn khởi động — giữ đúng hành vi config.py
|
||||
hiện tại, nhưng thêm phần giữ lại bản hỏng để còn cứu."""
|
||||
path = tmp_path / "cau_hinh.json"
|
||||
path.write_text("{ đây không phải json", encoding="utf-8")
|
||||
f = AtomicJsonFile(path)
|
||||
|
||||
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
||||
assert not path.exists(), "file hỏng phải được dời đi"
|
||||
bad = list(tmp_path.glob("*.bad-*"))
|
||||
assert len(bad) == 1, "phải giữ lại bản hỏng để cứu tay"
|
||||
assert "đây không phải json" in bad[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_ghi_de_nhieu_lan_van_dung(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "dem.json")
|
||||
for i in range(20):
|
||||
f.write({"lần": i})
|
||||
assert f.read() == {"lần": 19}
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_giu_nguyen_tieng_viet_khong_escape(tmp_path):
|
||||
"""config.py hiện dùng ensure_ascii=False — giữ nguyên để file đọc được
|
||||
bằng mắt và git diff không thành một đống \\uXXXX."""
|
||||
path = tmp_path / "vi.json"
|
||||
AtomicJsonFile(path).write({"tên": "Nguyễn Văn Đức"})
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
assert "Nguyễn Văn Đức" in raw
|
||||
assert "\\u" not in raw
|
||||
|
||||
|
||||
def test_tao_thu_muc_cha_neu_chua_co(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "sâu" / "hơn" / "nữa" / "c.json")
|
||||
f.write({"ok": True})
|
||||
assert f.read() == {"ok": True}
|
||||
|
||||
|
||||
def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path):
|
||||
path = tmp_path / "c.json"
|
||||
AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]}
|
||||
|
||||
|
||||
# ---- Windows: os.replace bị Defender/Indexer chặn tạm thời -----------------
|
||||
|
||||
def test_thu_lai_khi_windows_chan_tam_thoi(tmp_path, monkeypatch):
|
||||
"""Hỏng 2 lần đầu rồi thành công — phải ghi được, không ném lỗi.
|
||||
|
||||
Đây là lỗi thật bắt được ngày 25/08: chạy vòng 20 lần ghi thì cứ 7 lượt
|
||||
lại có 1 lượt văng ``PermissionError: [WinError 5]`` ở ``os.replace``.
|
||||
"""
|
||||
that = os.replace
|
||||
con_hong = [2]
|
||||
|
||||
def replace_do_dong(src, dst):
|
||||
if con_hong[0]:
|
||||
con_hong[0] -= 1
|
||||
raise PermissionError(5, "Access is denied")
|
||||
return that(src, dst)
|
||||
|
||||
monkeypatch.setattr(os, "replace", replace_do_dong)
|
||||
AtomicJsonFile(tmp_path / "a.json").write({"x": 1})
|
||||
|
||||
assert con_hong[0] == 0, "phải thật sự có thử lại, không phải may mà qua"
|
||||
assert json.loads((tmp_path / "a.json").read_text(encoding="utf-8")) == {"x": 1}
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_hong_that_thi_van_nem_loi_va_khong_de_lai_rac(tmp_path, monkeypatch):
|
||||
"""Thử lại không được phép nuốt lỗi quyền thật — hết lượt là ném."""
|
||||
def luon_hong(src, dst):
|
||||
raise PermissionError(5, "Access is denied")
|
||||
|
||||
monkeypatch.setattr(os, "replace", luon_hong)
|
||||
with pytest.raises(PermissionError):
|
||||
AtomicJsonFile(tmp_path / "b.json").write({"x": 1})
|
||||
|
||||
assert list(tmp_path.glob("*.tmp")) == [], "phải dọn file tạm"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""JsonConfigRepository — R02-T02.
|
||||
|
||||
Hai nhóm bài:
|
||||
* **round-trip** — ghi rồi nạp lại phải ra đúng thứ đã ghi (cột nghiệm thu
|
||||
của plan.md cho ngày 22-23/08)
|
||||
* **đường A** — ``provider_conf()`` vẫn trả ``api_key``, nhưng file JSON
|
||||
trên đĩa thì không có, để qua CASAN Check 1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.config.config_repository import ConfigRepository
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
||||
|
||||
DEFAULTS = {
|
||||
"active_provider": "ollama",
|
||||
"providers": {
|
||||
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3",
|
||||
"api_key": "ollama"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini",
|
||||
"api_key": ""},
|
||||
},
|
||||
"theme": "dark", "language": "vi", "shared_dir": "",
|
||||
"routing": {"mode": "off"}, "auth": {}, "agent_security": {},
|
||||
"tools_disabled": [], "history": {}, "cowork": {}, "ms365": {},
|
||||
}
|
||||
|
||||
|
||||
def _repo(tmp_path, secrets=None):
|
||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
||||
|
||||
|
||||
def test_khop_hop_dong(tmp_path):
|
||||
assert isinstance(_repo(tmp_path), ConfigRepository)
|
||||
|
||||
|
||||
def test_chua_co_file_thi_dung_mac_dinh(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
assert cfg.active_provider == "ollama"
|
||||
assert cfg.theme == "dark"
|
||||
|
||||
|
||||
def test_round_trip(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.set_theme("light")
|
||||
cfg.set_language("en")
|
||||
cfg.set_active_provider("openai")
|
||||
cfg.set_tool_enabled("run_command", False)
|
||||
cfg.save()
|
||||
|
||||
lai = _repo(tmp_path)
|
||||
assert lai.theme == "light"
|
||||
assert lai.language == "en"
|
||||
assert lai.active_provider == "openai"
|
||||
assert lai.tools_disabled == ["run_command"]
|
||||
|
||||
|
||||
def test_gia_tri_luu_trong_file_trum_len_mac_dinh_nhung_giu_phan_con_thieu(tmp_path):
|
||||
"""Trộn sâu: file cũ thiếu khoá mới thì lấy mặc định, không mất phần cũ."""
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"theme": "light", "providers": {"openai": {"model": "gpt-5"}}}),
|
||||
encoding="utf-8")
|
||||
cfg = _repo(tmp_path)
|
||||
assert cfg.theme == "light" # từ file
|
||||
assert cfg.language == "vi" # từ mặc định
|
||||
assert cfg.provider_conf("openai")["model"] == "gpt-5" # từ file
|
||||
assert "api.openai.com" in cfg.provider_conf("openai")["base_url"] # mặc định
|
||||
|
||||
|
||||
# ---- đường A: khoá vào kho bí mật, nhưng dict vẫn có ------------------------
|
||||
|
||||
def test_provider_conf_van_tra_api_key_sau_khi_chuyen_vao_kho(tmp_path):
|
||||
"""Điểm mấu chốt của quyết định A: 5 nơi đọc conf['api_key'] không đổi."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
||||
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-that-bi-mat"
|
||||
|
||||
|
||||
def test_khoa_khong_bao_gio_nam_tren_dia(tmp_path):
|
||||
"""Điều kiện qua CASAN Check 1."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
||||
cfg.save()
|
||||
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-that-bi-mat" not in raw
|
||||
assert secrets.get("provider:openai") == "sk-that-bi-mat"
|
||||
|
||||
|
||||
def test_sua_dict_tra_ve_khong_lam_ban_cau_hinh(tmp_path):
|
||||
"""provider_conf trả bản sao — nếu trả tham chiếu thì khoá vừa ghép vào sẽ
|
||||
lẫn ngược vào self.data rồi theo save() xuống đĩa."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-bi-mat")
|
||||
|
||||
conf = cfg.provider_conf("openai")
|
||||
conf["model"] = "bị sửa bậy"
|
||||
cfg.save()
|
||||
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "bị sửa bậy" not in raw
|
||||
assert "sk-bi-mat" not in raw
|
||||
|
||||
|
||||
def test_khong_co_kho_bi_mat_thi_van_chay_nhu_cu(tmp_path):
|
||||
"""Máy không có keyring: hành vi lùi về đúng như config.py hôm nay."""
|
||||
cfg = _repo(tmp_path, secrets=None)
|
||||
cfg.set_api_key("openai", "sk-nam-trong-file")
|
||||
cfg.save()
|
||||
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-nam-trong-file"
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-nam-trong-file" in raw # đúng như cũ, có đánh đổi rõ ràng
|
||||
|
||||
|
||||
# ---- giữ nguyên hành vi cũ --------------------------------------------------
|
||||
|
||||
def test_ms365_unlocked_khong_bao_gio_xuong_dia(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.data["ms365"]["unlocked"] = True
|
||||
cfg.save()
|
||||
|
||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
assert raw["ms365"]["unlocked"] is False
|
||||
assert cfg.data["ms365"]["unlocked"] is True # trong bộ nhớ vẫn giữ
|
||||
|
||||
assert _repo(tmp_path).data["ms365"]["unlocked"] is False
|
||||
|
||||
|
||||
def test_ghi_hong_giua_chung_khong_lam_mat_cau_hinh(tmp_path, monkeypatch):
|
||||
"""Thừa hưởng từ AtomicJsonFile — kiểm lại ở tầng này cho chắc."""
|
||||
import os
|
||||
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.set_theme("light")
|
||||
cfg.save()
|
||||
|
||||
monkeypatch.setattr(os, "replace",
|
||||
lambda *a, **k: (_ for _ in ()).throw(OSError("mất điện")))
|
||||
cfg.set_theme("hỏng")
|
||||
with pytest.raises(OSError):
|
||||
cfg.save()
|
||||
|
||||
assert _repo(tmp_path).theme == "light"
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Hợp đồng của mục chung có thật sự gỡ chốt cho N2 và N3 không.
|
||||
|
||||
Đây là bài nghiệm thu, không phải test cho vui: nếu ba bài dưới đây xanh thì
|
||||
hai nhánh kia code được ngay hôm nay mà không cần chờ ``ConfigRepository`` hay
|
||||
``KeyringAdapter`` bản thật.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cowork_local.infrastructure.config.config_repository import ConfigRepository
|
||||
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
|
||||
from cowork_local.tests.fakes.fake_config import FakeConfigRepository, FakeSecretStore
|
||||
|
||||
REPO_PARENT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_fake_config_khop_hop_dong():
|
||||
"""Fake phải cài đủ interface — thiếu một hàm là hai nhánh kia gọi vào sẽ vỡ."""
|
||||
assert isinstance(FakeConfigRepository(), ConfigRepository)
|
||||
|
||||
|
||||
def test_fake_secret_store_khop_hop_dong():
|
||||
assert isinstance(FakeSecretStore(), SecretStore)
|
||||
|
||||
|
||||
def test_secret_store_thieu_key_thi_tra_none_chu_khong_nem_loi():
|
||||
"""Thiếu API key là chuyện thường (người dùng chưa nhập), không phải sự cố."""
|
||||
store = FakeSecretStore()
|
||||
assert store.get(provider_key("openai")) is None
|
||||
assert store.has(provider_key("openai")) is False
|
||||
store.delete(provider_key("openai")) # xoá cái không có: im lặng
|
||||
|
||||
store.set(provider_key("openai"), "sk-test")
|
||||
assert store.get(provider_key("openai")) == "sk-test"
|
||||
assert store.has(provider_key("openai")) is True
|
||||
|
||||
|
||||
def test_config_gia_ghi_nhan_save_ma_khong_cham_dia():
|
||||
cfg = FakeConfigRepository(theme="light")
|
||||
assert cfg.theme == "light"
|
||||
cfg.set_theme("dark")
|
||||
cfg.save()
|
||||
assert cfg.theme == "dark"
|
||||
assert cfg.saves == 1
|
||||
|
||||
|
||||
def test_bat_duoc_tool_bi_tat():
|
||||
cfg = FakeConfigRepository(tools_disabled=["run_command"])
|
||||
assert cfg.tools_disabled == ["run_command"]
|
||||
cfg.set_tool_enabled("run_command", True)
|
||||
assert cfg.tools_disabled == []
|
||||
cfg.set_tool_enabled("write_file", False)
|
||||
assert cfg.tools_disabled == ["write_file"]
|
||||
|
||||
|
||||
def test_dung_duoc_fake_ma_khong_hề_nap_config_that():
|
||||
"""Bài nghiệm thu chính của mục chung.
|
||||
|
||||
N2 và N3 phải dựng được màn hình và chạy test của mình mà KHÔNG kéo theo
|
||||
``cowork_local.config`` — module nặng, đọc đĩa, và đang bị N1 viết lại.
|
||||
Kiểm bằng tiến trình riêng để không dính module đã nạp sẵn ở test khác.
|
||||
"""
|
||||
snippet = (
|
||||
"import sys\n"
|
||||
"from cowork_local.tests.fakes.fake_config import "
|
||||
"FakeConfigRepository, FakeSecretStore\n"
|
||||
"cfg = FakeConfigRepository(active_provider='openai')\n"
|
||||
"assert cfg.provider_conf()['model'] == 'gpt-4o-mini'\n"
|
||||
"assert FakeSecretStore().get('x') is None\n"
|
||||
"assert 'cowork_local.config' not in sys.modules, "
|
||||
"'fake keo theo config that -> van con phu thuoc'\n"
|
||||
"assert 'PySide6' not in sys.modules, 'fake keo theo Qt -> test se cham'\n"
|
||||
"print('OK')\n"
|
||||
)
|
||||
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO_PARENT,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert "OK" in out.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolPolicyGateway — bản đề xuất Gamma viết hộ, chờ Team Hoa xác nhận.
|
||||
# N3 (Co4E) code dựa vào đây từ hôm nay thay vì tự phỏng đoán.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from cowork_local.domain.security.tool_policy import ( # noqa: E402
|
||||
PolicyOutcome, ToolCallRequest, ToolPolicyGateway, allow, ask, deny,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_tool_policy import ( # noqa: E402
|
||||
FakeToolPolicyGateway,
|
||||
)
|
||||
|
||||
|
||||
def test_fake_gateway_khop_hop_dong():
|
||||
assert isinstance(FakeToolPolicyGateway(), ToolPolicyGateway)
|
||||
|
||||
|
||||
def test_mac_dinh_cho_qua_va_co_ghi_lai_da_hoi():
|
||||
gate = FakeToolPolicyGateway()
|
||||
d = gate.check(ToolCallRequest(name="read_file", surface="co4e"))
|
||||
assert d.outcome is PolicyOutcome.ALLOW
|
||||
assert d.allowed is True
|
||||
assert gate.asked_for("read_file")
|
||||
assert gate.call_count == 1
|
||||
|
||||
|
||||
def test_chan_theo_ten_tool():
|
||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
||||
assert gate.check(ToolCallRequest(name="run_command")).outcome is PolicyOutcome.DENY
|
||||
assert gate.check(ToolCallRequest(name="read_file")).allowed is True
|
||||
|
||||
|
||||
def test_ask_khong_phai_la_duoc_phep():
|
||||
"""Bẫy dễ mắc nhất: coi ASK như ALLOW thì tool chạy mà chưa ai đồng ý."""
|
||||
d = ask("cần người dùng xác nhận")
|
||||
assert d.outcome is PolicyOutcome.ASK
|
||||
assert d.allowed is False
|
||||
|
||||
|
||||
def test_deny_va_ask_bat_buoc_co_ly_do():
|
||||
"""Người dùng phải biết vì sao bị chặn, và audit log cần ghi lại."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
deny("")
|
||||
with pytest.raises(ValueError):
|
||||
ask("")
|
||||
allow() # ALLOW thì không cần lý do
|
||||
|
||||
|
||||
def test_chinh_sach_khac_nhau_theo_man():
|
||||
"""Co4E chạy nền nên không bật được hộp thoại — chặn thẳng thay vì hỏi."""
|
||||
def by_surface(req: ToolCallRequest):
|
||||
if req.surface == "co4e" and req.name == "run_command":
|
||||
return deny("Co4E chạy nền, không hỏi được người dùng")
|
||||
return ask("cần xác nhận") if req.name == "run_command" else allow()
|
||||
|
||||
gate = FakeToolPolicyGateway(decide=by_surface)
|
||||
assert gate.check(ToolCallRequest("run_command", surface="co4e")).outcome is PolicyOutcome.DENY
|
||||
assert gate.check(ToolCallRequest("run_command", surface="cowork")).outcome is PolicyOutcome.ASK
|
||||
@@ -0,0 +1,93 @@
|
||||
"""KeyringAdapter — R02-T04.
|
||||
|
||||
Không đụng vào keyring thật của máy chạy test: tiêm một backend giả. Test mà
|
||||
ghi vào Credential Manager thật thì để lại rác trên máy người khác, và trên CI
|
||||
thì không có kho nào để ghi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.secrets.keyring_adapter import KeyringAdapter
|
||||
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
|
||||
|
||||
|
||||
class _KeyringGia:
|
||||
"""Đủ giống thư viện keyring để adapter dùng được."""
|
||||
|
||||
def __init__(self, hong: bool = False):
|
||||
self.kho: dict[tuple[str, str], str] = {}
|
||||
self.hong = hong
|
||||
|
||||
def get_password(self, service, key):
|
||||
if self.hong:
|
||||
raise RuntimeError("kho bí mật không phản hồi")
|
||||
return self.kho.get((service, key))
|
||||
|
||||
def set_password(self, service, key, value):
|
||||
if self.hong:
|
||||
raise RuntimeError("kho bí mật không phản hồi")
|
||||
self.kho[(service, key)] = value
|
||||
|
||||
def delete_password(self, service, key):
|
||||
if self.hong:
|
||||
raise RuntimeError("kho bí mật không phản hồi")
|
||||
del self.kho[(service, key)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
a = KeyringAdapter(service="test-cowork")
|
||||
a._backend = _KeyringGia()
|
||||
a._available = True
|
||||
return a
|
||||
|
||||
|
||||
def test_khop_hop_dong_secret_store(store):
|
||||
assert isinstance(store, SecretStore)
|
||||
|
||||
|
||||
def test_luu_doc_xoa(store):
|
||||
k = provider_key("openai")
|
||||
assert store.get(k) is None
|
||||
assert store.has(k) is False
|
||||
|
||||
store.set(k, "sk-that-la-bi-mat")
|
||||
assert store.get(k) == "sk-that-la-bi-mat"
|
||||
assert store.has(k) is True
|
||||
|
||||
store.delete(k)
|
||||
assert store.get(k) is None
|
||||
|
||||
|
||||
def test_moi_provider_mot_khoa_rieng(store):
|
||||
store.set(provider_key("openai"), "khoa-openai")
|
||||
store.set(provider_key("anthropic"), "khoa-anthropic")
|
||||
assert store.get(provider_key("openai")) == "khoa-openai"
|
||||
assert store.get(provider_key("anthropic")) == "khoa-anthropic"
|
||||
|
||||
|
||||
def test_may_khong_co_kho_thi_im_lang_chu_khong_sap():
|
||||
"""Linux headless và CI không có Secret Service. App vẫn phải chạy."""
|
||||
a = KeyringAdapter(service="test-cowork")
|
||||
a._backend = None
|
||||
a._available = False
|
||||
|
||||
assert a.available is False
|
||||
assert a.get("bat-ky") is None
|
||||
a.set("bat-ky", "gia-tri") # không ném lỗi
|
||||
a.delete("bat-ky") # không ném lỗi
|
||||
assert a.has("bat-ky") is False
|
||||
|
||||
|
||||
def test_kho_loi_giua_chung_thi_khong_lam_sap_app(store):
|
||||
"""Keyring có thể hỏng lúc đang chạy — mất DBus, người dùng khoá máy."""
|
||||
store._backend.hong = True
|
||||
|
||||
assert store.get("x") is None # nuốt lỗi, trả None
|
||||
store.set("x", "y") # nuốt lỗi
|
||||
store.delete("x") # nuốt lỗi
|
||||
|
||||
|
||||
def test_xoa_khoa_khong_ton_tai_thi_bo_qua(store):
|
||||
store.delete(provider_key("chua-bao-gio-luu")) # không ném lỗi
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Không file mã nguồn nào được nằm ngoài repo vì `.gitignore`.
|
||||
|
||||
Bài này sinh ra từ một lỗi thật, mất hai ngày mới lộ:
|
||||
|
||||
``.gitignore`` dòng 31 ghi ``secrets/`` — mẫu **không neo**, nên git bỏ qua
|
||||
mọi thư mục tên ``secrets`` ở mọi độ sâu, kể cả ``infrastructure/secrets/``
|
||||
vốn là **mã nguồn**. Ba file trong đó chưa bao giờ lên repo. Máy người viết
|
||||
vẫn chạy 150 test xanh, nhưng ai clone sạch về thì 4 file test đỏ ngay lúc
|
||||
thu thập.
|
||||
|
||||
Trên máy đã có file thì không cách nào nhận ra: ``pytest`` đọc đĩa, không đọc
|
||||
git. Nên phải hỏi thẳng git.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: Thư mục chứa mã nguồn của ứng dụng — file .py ở đây bắt buộc phải vào repo.
|
||||
SOURCE_DIRS = ["domain", "application", "infrastructure", "presentation",
|
||||
"adapters", "core", "ui", "providers", "scripts", "tools", "tests"]
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
out = subprocess.run(["git", *args], cwd=REPO, capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
return out.stdout
|
||||
|
||||
|
||||
def test_khong_file_py_nao_bi_gitignore_nuot():
|
||||
"""File .py có trên đĩa nhưng git không thấy — vừa chưa theo dõi, vừa bị
|
||||
bỏ qua. Đó chính là hình dạng của lỗi ``secrets/``."""
|
||||
existing = []
|
||||
for d in SOURCE_DIRS:
|
||||
root = REPO / d
|
||||
if root.is_dir():
|
||||
existing.append(d)
|
||||
assert existing, "không thấy thư mục mã nguồn nào — kiểm lại SOURCE_DIRS"
|
||||
|
||||
ignored = _git("ls-files", "--others", "--ignored", "--exclude-standard",
|
||||
"--", *existing).splitlines()
|
||||
ignored_py = [p for p in ignored
|
||||
if p.endswith(".py") and "__pycache__" not in p]
|
||||
|
||||
assert not ignored_py, (
|
||||
"File mã nguồn bị .gitignore nuốt — clone sạch sẽ thiếu:\n "
|
||||
+ "\n ".join(ignored_py)
|
||||
+ "\nChạy `git check-ignore -v <file>` để biết dòng nào gây ra."
|
||||
)
|
||||
|
||||
|
||||
def test_khong_file_py_nao_bi_bo_quen_chua_theo_doi():
|
||||
"""Chưa bị ignore nhưng cũng chưa `git add` — quên, không phải cố ý."""
|
||||
untracked = _git("ls-files", "--others", "--exclude-standard").splitlines()
|
||||
forgotten = [p for p in untracked
|
||||
if p.endswith(".py")
|
||||
and p.split("/")[0] in SOURCE_DIRS
|
||||
and "__pycache__" not in p]
|
||||
|
||||
assert not forgotten, (
|
||||
"File mã nguồn chưa được git add — clone sạch sẽ thiếu:\n "
|
||||
+ "\n ".join(forgotten)
|
||||
)
|
||||
|
||||
|
||||
def test_moi_module_duoc_import_deu_co_trong_repo():
|
||||
"""Bắt theo hướng ngược: đi từ những gì code THỰC SỰ import.
|
||||
|
||||
Hai bài trên quét theo thư mục; bài này bắt cả trường hợp file nằm ngoài
|
||||
danh sách đó mà vẫn được import.
|
||||
"""
|
||||
tracked = set(_git("ls-files").splitlines())
|
||||
missing = []
|
||||
for d in ("domain", "application", "infrastructure", "adapters"):
|
||||
root = REPO / d
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for f in root.rglob("*.py"):
|
||||
rel = f.relative_to(REPO).as_posix()
|
||||
if "__pycache__" in rel:
|
||||
continue
|
||||
if rel not in tracked:
|
||||
missing.append(rel)
|
||||
|
||||
assert not missing, (
|
||||
"Module thuộc kiến trúc mới nhưng không có trong repo:\n "
|
||||
+ "\n ".join(missing)
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Không thư mục nào ở gốc repo được trùng tên module thư viện chuẩn.
|
||||
|
||||
Bài này sinh ra từ một lỗi thật: kế hoạch refactor đặt tên một tầng là
|
||||
``platform/``, và ngay khi tạo thư mục đó thì mọi script chạy từ gốc repo —
|
||||
``python tools/check_*.py``, ``python scripts/audit_security.py``, 26 file tất
|
||||
cả — đều nạp nhầm ``platform/`` thay cho ``platform`` của Python. ``keyring``
|
||||
chết ngay với ``AttributeError: module 'platform' has no attribute 'system'``.
|
||||
|
||||
Kiểm bằng tên chứ không phải bằng cách thử import: import chỉ hỏng khi có ai
|
||||
đó thật sự dùng module bị che, nên nó im lặng cho tới lúc muộn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: Không tính: đây là thư mục dữ liệu/tài liệu, không phải package Python.
|
||||
NOT_PACKAGES = {".git", ".gitea", ".vibeflow-preview", "docs", "assets",
|
||||
"__pycache__", ".pytest_cache", "cowork-local-gitea",
|
||||
".cowork_history", ".cowork_local"}
|
||||
|
||||
|
||||
def _top_level_packages() -> list[str]:
|
||||
return [d.name for d in REPO.iterdir()
|
||||
if d.is_dir() and d.name not in NOT_PACKAGES
|
||||
and (d / "__init__.py").exists()]
|
||||
|
||||
|
||||
def test_khong_package_nao_che_khuat_thu_vien_chuan():
|
||||
stdlib = set(sys.stdlib_module_names)
|
||||
clashes = [name for name in _top_level_packages() if name in stdlib]
|
||||
assert not clashes, (
|
||||
"Thư mục ở gốc repo trùng tên module thư viện chuẩn: "
|
||||
+ ", ".join(sorted(clashes))
|
||||
+ ". Chạy script từ gốc repo sẽ nạp nhầm thư mục này. Đổi tên thư mục."
|
||||
)
|
||||
|
||||
|
||||
def test_import_duoc_stdlib_khi_chay_tu_goc_repo():
|
||||
"""Bài trên bắt bằng tên; bài này bắt bằng hành vi thật.
|
||||
|
||||
Chạy tiến trình con với thư mục làm việc là gốc repo — đúng cách 26 script
|
||||
trong ``tools/`` và ``scripts/`` được gọi.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
snippet = (
|
||||
"import platform, json, types, io\n"
|
||||
"assert 'site-packages' not in platform.__file__\n"
|
||||
"assert platform.system(), 'platform.system() phải trả về tên hệ điều hành'\n"
|
||||
"import keyring\n"
|
||||
"print('OK')\n"
|
||||
)
|
||||
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert "OK" in out.stdout
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Đánh số phiên bản + chuyển API key — R02-T06 và R02-T05."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
from cowork_local.infrastructure.config.schema_migration import (
|
||||
CURRENT_VERSION, migrate, read_version,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
||||
|
||||
DEFAULTS = {
|
||||
"active_provider": "openai",
|
||||
"providers": {"openai": {"base_url": "u", "model": "m", "api_key": ""},
|
||||
"ollama": {"base_url": "u", "model": "m", "api_key": "ollama"}},
|
||||
"theme": "dark", "language": "vi", "ms365": {},
|
||||
}
|
||||
|
||||
|
||||
def _repo(tmp_path, secrets=None):
|
||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
||||
|
||||
|
||||
def test_thieu_so_phien_ban_thi_coi_la_v1():
|
||||
assert read_version({}) == 1
|
||||
assert read_version({"schema_version": 2}) == 2
|
||||
assert read_version({"schema_version": "hỏng"}) == 1
|
||||
|
||||
|
||||
def test_v1_sang_v2_chuyen_khoa_vao_kho_bi_mat():
|
||||
secrets = FakeSecretStore()
|
||||
data = {"providers": {"openai": {"api_key": "sk-cu-nam-trong-file"}}} # casan: allow - du lieu test
|
||||
|
||||
out, changed = migrate(data, secrets=secrets)
|
||||
|
||||
assert changed is True
|
||||
assert out["schema_version"] == 2
|
||||
assert out["providers"]["openai"]["api_key"] == ""
|
||||
assert secrets.get("provider:openai") == "sk-cu-nam-trong-file"
|
||||
|
||||
|
||||
def test_khong_day_gia_tri_bu_nhin_cua_ollama_vao_kho():
|
||||
"""Ollama đòi có api_key nhưng bỏ qua nội dung — đẩy vào keyring chỉ tổ rác."""
|
||||
secrets = FakeSecretStore()
|
||||
out, _ = migrate({"providers": {"ollama": {"api_key": "ollama"}}}, secrets=secrets)
|
||||
assert secrets.get("provider:ollama") is None
|
||||
assert out["providers"]["ollama"]["api_key"] == "ollama"
|
||||
|
||||
|
||||
def test_khong_co_kho_bi_mat_thi_KHONG_chuyen():
|
||||
"""Thà để khoá nằm nguyên trong file còn hơn xoá đi rồi người dùng mất
|
||||
khoá mà không hiểu vì sao."""
|
||||
data = {"providers": {"openai": {"api_key": "sk-quy-gia"}}}
|
||||
out, changed = migrate(data, secrets=None)
|
||||
|
||||
assert changed is False
|
||||
assert out["providers"]["openai"]["api_key"] == "sk-quy-gia"
|
||||
assert read_version(out) == 1 # giữ v1, lần sau có keyring sẽ chuyển
|
||||
|
||||
|
||||
def test_da_v2_thi_khong_lam_gi_them():
|
||||
out, changed = migrate({"schema_version": 2}, secrets=FakeSecretStore())
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_file_moi_hon_app_thi_dung_nguyen_trang():
|
||||
"""App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu."""
|
||||
data = {"schema_version": 99, "thu_gi_do_tuong_lai": True}
|
||||
out, changed = migrate(data, secrets=FakeSecretStore())
|
||||
assert changed is False
|
||||
assert out == data
|
||||
|
||||
|
||||
def test_sao_luu_truoc_khi_nang_cap(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}),
|
||||
encoding="utf-8")
|
||||
|
||||
migrate(json.loads(path.read_text(encoding="utf-8")),
|
||||
secrets=FakeSecretStore(), path=path)
|
||||
|
||||
backups = list(tmp_path.glob("*.bak"))
|
||||
assert len(backups) == 1, "phải có bản sao lưu để còn đường lùi"
|
||||
assert "sk-x" in backups[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---- nối vào repository ----------------------------------------------------
|
||||
|
||||
def test_repository_tu_chuyen_khoa_khi_mo_file_cu(tmp_path):
|
||||
"""Cảnh thật: người dùng cập nhật app, mở lên, khoá cũ tự vào keyring."""
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"providers": {"openai": {"api_key": "sk-tu-ban-cu"}}}), # casan: allow - du lieu test
|
||||
encoding="utf-8")
|
||||
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
|
||||
# đọc ra vẫn thấy khoá...
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-tu-ban-cu"
|
||||
# ...nhưng trên đĩa thì hết
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-tu-ban-cu" not in raw
|
||||
assert json.loads(raw)["schema_version"] == CURRENT_VERSION
|
||||
# và có bản sao lưu
|
||||
assert len(list(tmp_path.glob("*.bak"))) == 1
|
||||
|
||||
|
||||
def test_mo_lai_lan_hai_khong_chuyen_lai(tmp_path):
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}), encoding="utf-8")
|
||||
secrets = FakeSecretStore()
|
||||
_repo(tmp_path, secrets)
|
||||
so_ban_sao = len(list(tmp_path.glob("*.bak")))
|
||||
|
||||
_repo(tmp_path, secrets)
|
||||
assert len(list(tmp_path.glob("*.bak"))) == so_ban_sao, "không nâng cấp lại"
|
||||
|
||||
|
||||
def test_save_luon_ghi_so_phien_ban(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.save()
|
||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
assert raw["schema_version"] == CURRENT_VERSION
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Typed Settings Facade — R02-T03."""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.infrastructure.config.settings_facade import (
|
||||
ProviderSettings, RoutingSettings, SecuritySettings, Settings,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
|
||||
|
||||
|
||||
def test_provider_doc_duoc_ba_truong():
|
||||
p = ProviderSettings({"base_url": "http://x/v1", "model": "llama3",
|
||||
"api_key": "sk-abc"})
|
||||
assert p.base_url == "http://x/v1"
|
||||
assert p.model == "llama3"
|
||||
assert p.api_key == "sk-abc"
|
||||
assert p.configured is True
|
||||
|
||||
|
||||
def test_ollama_khong_can_khoa_van_tinh_la_da_cau_hinh():
|
||||
"""Điều kiện là có base_url và model, không phải có api_key — Ollama chạy
|
||||
cục bộ nên không cần khoá."""
|
||||
p = ProviderSettings({"base_url": "http://localhost:11434/v1", "model": "llama3"})
|
||||
assert p.api_key == ""
|
||||
assert p.configured is True
|
||||
|
||||
|
||||
def test_thieu_model_thi_chua_cau_hinh():
|
||||
assert ProviderSettings({"base_url": "http://x/v1"}).configured is False
|
||||
assert ProviderSettings({}).configured is False
|
||||
|
||||
|
||||
def test_gia_tri_None_tra_ve_mac_dinh_chu_khong_None():
|
||||
"""File cấu hình cũ hay có khoá để null. Đọc ra None rồi đem so sánh số là
|
||||
vỡ — nên khung nhìn phải nuốt luôn trường hợp này."""
|
||||
r = RoutingSettings({"switch_mode": None, "min_score_gain": None,
|
||||
"confirm_timeout_sec": None})
|
||||
assert r.switch_mode == "off"
|
||||
assert r.min_score_gain == 0.05
|
||||
assert r.confirm_timeout_sec == 60
|
||||
|
||||
|
||||
def test_routing_kieu_du_lieu_dung():
|
||||
r = RoutingSettings({"switch_mode": "auto", "min_score_gain": "0.2",
|
||||
"confirm_timeout_sec": "90"})
|
||||
assert r.enabled is True
|
||||
assert isinstance(r.min_score_gain, float) and r.min_score_gain == 0.2
|
||||
assert isinstance(r.confirm_timeout_sec, int) and r.confirm_timeout_sec == 90
|
||||
|
||||
|
||||
def test_tat_dinh_tuyen():
|
||||
assert RoutingSettings({"switch_mode": "off"}).enabled is False
|
||||
assert RoutingSettings({}).enabled is False
|
||||
|
||||
|
||||
def test_sua_qua_khung_nhin_la_sua_vao_dict_that():
|
||||
"""Khung nhìn, không phải bản sao — sửa xong gọi save() là xuống đĩa."""
|
||||
d = {"switch_mode": "off"}
|
||||
RoutingSettings(d).switch_mode = "auto"
|
||||
assert d["switch_mode"] == "auto"
|
||||
|
||||
|
||||
def test_raw_de_khong_ai_bi_ket():
|
||||
d = {"switch_mode": "auto", "khoa_chua_dua_vao_khung_nhin": 1}
|
||||
assert RoutingSettings(d).raw()["khoa_chua_dua_vao_khung_nhin"] == 1
|
||||
|
||||
|
||||
def test_security_mac_dinh_la_bat():
|
||||
"""Mặc định an toàn: thiếu cấu hình thì bật kiểm tra, không phải tắt."""
|
||||
s = SecuritySettings({})
|
||||
assert s.enabled is True
|
||||
assert s.validate_prompt is True
|
||||
assert s.validate_commands is True
|
||||
assert s.cowork_confirm_commands is True
|
||||
assert s.command_ai_check is False # trừ cái này: gọi AI, tốn tiền
|
||||
|
||||
|
||||
def test_settings_noi_vao_repo():
|
||||
repo = FakeConfigRepository(active_provider="openai",
|
||||
routing={"switch_mode": "auto"},
|
||||
agent_security={"cowork_confirm_commands": False})
|
||||
s = Settings(repo)
|
||||
assert s.provider().model == "gpt-4o-mini"
|
||||
assert s.routing.enabled is True
|
||||
assert s.security.cowork_confirm_commands is False
|
||||
|
||||
|
||||
def test_doi_provider_thi_khung_nhin_theo_ngay():
|
||||
repo = FakeConfigRepository(active_provider="ollama")
|
||||
s = Settings(repo)
|
||||
assert s.provider().model == "llama3"
|
||||
repo.set_active_provider("openai")
|
||||
assert s.provider().model == "gpt-4o-mini"
|
||||
@@ -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"),))
|
||||
@@ -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"),)
|
||||
@@ -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"
|
||||
@@ -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}]
|
||||
@@ -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)
|
||||
@@ -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 == ""
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user