CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
192 lines
7.2 KiB
Python
192 lines
7.2 KiB
Python
"""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"]
|