76c530a9e5
Suite went from 27 failures plus one permanent hang (never finished) to 1846 passed / 0 failed in ~85s, including under FORCE_COLOR. - office_shutdown_lifecycle: construct WSHandler via the real __init__ (helper) instead of hand-copied __new__ stubs that drift from the constructor (#11 added _runtime_status_sync_task and the stubs hung); the formerly-hanging wait now has a 5s wait_for. - import-time patch hygiene: company_recruiter / company_reorg / engine_session_defaults replaced module-level permanent tempfile.TemporaryDirectory monkeypatching with paired setUpModule/tearDownModule, fixing order-dependent sqlite failures in transcript_pagination during full runs. - stale tests updated to current product semantics: resume stubs use status="done" (failed is deliberately non-resumable), fix4 asserts the native review contract through build_company_work_item_contract, delivery fixture carries user_visible/feedback_scope=final, ownership doc names progress_log, session compression calls maybe_compact_session(force=True) explicitly, hard delete removes the work item row, parallel-isolation asserts delegate rebind and stubs _get_project_delegate, role update goes through OrgService on an editable custom org (plus read-only rejection case), collab_rpc patches the single os.name decision point instead of poisoning pathlib, codex no-pty builds inside the patch, identity-guard false positives reworded. - cli_board actions rewritten against the real OfficeServiceFactory seam with a tempdir OPC_HOME (old direct-engine stubs were never consulted and the tests wrote into the real OPC home). - cli_app assertions strip ANSI via _plain_output so a color-forcing shell (FORCE_COLOR) cannot break plain-text expectations. - deleted never-runnable test_org_concurrency (pytest.mark.asyncio without the plugin, stdlib-only assertions) and three dead skipped filesystem-handoff tests. - pyproject: dev extra (pytest, pytest-timeout) and a 300s per-test timeout backstop so a wedged test fails instead of stalling the suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
289 lines
11 KiB
Python
289 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
import uuid
|
|
from unittest import mock
|
|
from unittest.mock import AsyncMock
|
|
|
|
from opc.core.models import ExecutionCheckpoint, Task, TaskStatus
|
|
from opc.plugins.cli_board.services.actions import BoardActions
|
|
|
|
|
|
class _StubStore:
|
|
def __init__(self) -> None:
|
|
self.tasks: dict[str, Task] = {}
|
|
self.checkpoints: list[ExecutionCheckpoint] = []
|
|
self.resolved: list[tuple[str, str]] = []
|
|
|
|
async def save_task(self, task: Task) -> None:
|
|
self.tasks[task.id] = task
|
|
|
|
async def get_task(self, task_id: str) -> Task | None:
|
|
return self.tasks.get(task_id)
|
|
|
|
async def get_tasks(self, **_kw):
|
|
return list(self.tasks.values())
|
|
|
|
async def get_pending_checkpoints(self, **_kw):
|
|
return [checkpoint for checkpoint in self.checkpoints if checkpoint.status == "pending"]
|
|
|
|
async def resolve_execution_checkpoint(self, checkpoint_id: str, status: str = "resolved") -> None:
|
|
self.resolved.append((checkpoint_id, status))
|
|
for checkpoint in self.checkpoints:
|
|
if checkpoint.checkpoint_id == checkpoint_id:
|
|
checkpoint.status = status
|
|
|
|
|
|
class _StubMemory:
|
|
def __init__(self) -> None:
|
|
self.ensure_session = AsyncMock()
|
|
|
|
|
|
class _StubEngine:
|
|
def __init__(self) -> None:
|
|
self.store = _StubStore()
|
|
self.memory = _StubMemory()
|
|
self.process_message = AsyncMock(return_value="ok")
|
|
|
|
|
|
class _StubFacade:
|
|
def __init__(self, engine: _StubEngine) -> None:
|
|
self.project_id = "demo"
|
|
self._engine = engine
|
|
|
|
async def ensure_ready(self):
|
|
return self._engine
|
|
|
|
|
|
class _StubServiceResult:
|
|
def __init__(self, payload: dict) -> None:
|
|
self.payload = payload
|
|
self.events: list = []
|
|
|
|
|
|
class _StubSessionService:
|
|
"""In-memory stand-in for the office SessionService seam.
|
|
|
|
Mirrors the slice of the production contract that ``BoardActions``
|
|
relies on: ``create`` persists a session-backed placeholder Task and
|
|
ensures its memory session, ``send`` routes execution to
|
|
``engine.process_message`` with the origin task's session, and ``stop``
|
|
cancels the target task row. Every call is recorded for assertions.
|
|
"""
|
|
|
|
def __init__(self, engine: _StubEngine, project_id: str) -> None:
|
|
self._engine = engine
|
|
self._project_id = project_id
|
|
self.create_calls: list[dict] = []
|
|
self.send_calls: list[dict] = []
|
|
self.stop_calls: list[dict] = []
|
|
|
|
async def create(self, **kwargs) -> _StubServiceResult:
|
|
self.create_calls.append(dict(kwargs))
|
|
project_id = kwargs.get("project_id", self._project_id)
|
|
title = str(kwargs.get("title", "") or "New Chat")
|
|
task_id = str(uuid.uuid4())
|
|
session_id = str(uuid.uuid4())
|
|
await self._engine.memory.ensure_session(
|
|
session_id=session_id,
|
|
project_id=project_id,
|
|
title=title,
|
|
mode="primary",
|
|
metadata={"interface": kwargs.get("interface", "office_ui")},
|
|
)
|
|
await self._engine.store.save_task(
|
|
Task(
|
|
id=task_id,
|
|
title=title,
|
|
description=str(kwargs.get("description", "") or ""),
|
|
project_id=project_id,
|
|
session_id=session_id,
|
|
metadata={"exec_mode": kwargs.get("exec_mode") or "task"},
|
|
)
|
|
)
|
|
return _StubServiceResult({"project_id": project_id, "task_id": task_id, "session_id": session_id})
|
|
|
|
async def send(self, **kwargs) -> _StubServiceResult:
|
|
self.send_calls.append(dict(kwargs))
|
|
task = await self._engine.store.get_task(str(kwargs.get("task_id", "") or ""))
|
|
if task is None:
|
|
raise ValueError(f"task_not_found: {kwargs.get('task_id')}")
|
|
response = await self._engine.process_message(
|
|
str(kwargs.get("content", "") or ""),
|
|
project_id=kwargs.get("project_id", self._project_id),
|
|
session_id=str(task.session_id or ""),
|
|
mode=kwargs.get("mode", "task"),
|
|
origin_task_id=task.id,
|
|
)
|
|
return _StubServiceResult(
|
|
{
|
|
"project_id": kwargs.get("project_id", self._project_id),
|
|
"task_id": task.id,
|
|
"session_id": str(task.session_id or ""),
|
|
"response": response,
|
|
}
|
|
)
|
|
|
|
async def stop(self, **kwargs) -> _StubServiceResult:
|
|
self.stop_calls.append(dict(kwargs))
|
|
task = await self._engine.store.get_task(str(kwargs.get("task_id", "") or ""))
|
|
if task is None:
|
|
raise ValueError(f"target_not_found: {kwargs.get('task_id')}")
|
|
task.status = TaskStatus.CANCELLED
|
|
await self._engine.store.save_task(task)
|
|
return _StubServiceResult(
|
|
{
|
|
"project_id": kwargs.get("project_id", self._project_id),
|
|
"task_id": task.id,
|
|
"status": "cancelled",
|
|
}
|
|
)
|
|
|
|
|
|
class _StubOfficeServices:
|
|
def __init__(self, engine: _StubEngine, project_id: str = "demo") -> None:
|
|
self.session = _StubSessionService(engine, project_id)
|
|
|
|
|
|
class BoardActionsTests(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
# Safety net: even if a code path slipped past the factory patch it
|
|
# must never touch the real OPC home.
|
|
tmp = tempfile.TemporaryDirectory(prefix="opc-cli-board-actions-test-")
|
|
self.addCleanup(tmp.cleanup)
|
|
env_patcher = mock.patch.dict(os.environ, {"OPC_HOME": tmp.name})
|
|
env_patcher.start()
|
|
self.addCleanup(env_patcher.stop)
|
|
|
|
def _make_actions(self, engine: _StubEngine) -> tuple[BoardActions, _StubOfficeServices]:
|
|
"""Patch the OfficeServiceFactory seam so BoardActions uses our stubs.
|
|
|
|
``BoardActions._run_office_service`` builds a real
|
|
``OfficeServiceFactory`` (and with it a real engine + ui_state.db) per
|
|
operation; the tests replace that seam with an async context manager
|
|
yielding stub services bound to the stub engine.
|
|
"""
|
|
services = _StubOfficeServices(engine)
|
|
|
|
class _StubFactory:
|
|
def __init__(self, **_kwargs) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> _StubOfficeServices:
|
|
return services
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
patcher = mock.patch(
|
|
"opc.plugins.cli_board.services.actions.OfficeServiceFactory",
|
|
_StubFactory,
|
|
)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
return BoardActions(_StubFacade(engine), project_id="demo"), services
|
|
|
|
async def test_create_task_creates_session_backed_placeholder(self) -> None:
|
|
engine = _StubEngine()
|
|
actions, services = self._make_actions(engine)
|
|
|
|
task = await actions.create_task(title="Draft feature", description="Initial plan")
|
|
|
|
self.assertIn(task.id, engine.store.tasks)
|
|
stored = engine.store.tasks[task.id]
|
|
self.assertEqual(stored.title, "Draft feature")
|
|
self.assertEqual(stored.description, "Initial plan")
|
|
self.assertTrue(stored.session_id)
|
|
engine.memory.ensure_session.assert_awaited()
|
|
self.assertEqual(len(services.session.create_calls), 1)
|
|
call = services.session.create_calls[0]
|
|
self.assertEqual(call["project_id"], "demo")
|
|
self.assertEqual(call["title"], "Draft feature")
|
|
self.assertEqual(call["description"], "Initial plan")
|
|
self.assertEqual(call["exec_mode"], "task")
|
|
self.assertEqual(call["interface"], "cli_board")
|
|
|
|
async def test_send_session_message_routes_through_origin_task(self) -> None:
|
|
engine = _StubEngine()
|
|
task = Task(
|
|
id="task-1",
|
|
title="Feature task",
|
|
description="Implement the feature",
|
|
status=TaskStatus.PENDING,
|
|
session_id="session-1",
|
|
project_id="demo",
|
|
)
|
|
await engine.store.save_task(task)
|
|
actions, services = self._make_actions(engine)
|
|
|
|
response = await actions.send_session_message("task-1", "please continue")
|
|
|
|
self.assertEqual(response, "ok")
|
|
self.assertEqual(len(services.session.send_calls), 1)
|
|
call = services.session.send_calls[0]
|
|
self.assertEqual(call["project_id"], "demo")
|
|
self.assertEqual(call["task_id"], "task-1")
|
|
self.assertEqual(call["content"], "please continue")
|
|
self.assertEqual(call["mode"], "task")
|
|
engine.process_message.assert_awaited_once()
|
|
kwargs = engine.process_message.await_args.kwargs
|
|
self.assertEqual(kwargs["session_id"], "session-1")
|
|
self.assertEqual(kwargs["origin_task_id"], "task-1")
|
|
|
|
async def test_cancel_task_marks_related_tasks_and_checkpoints_cancelled(self) -> None:
|
|
engine = _StubEngine()
|
|
started = asyncio.Event()
|
|
release = asyncio.Event() # never set; the run only ends via cancellation
|
|
|
|
async def _blocked_process_message(*_args, **_kwargs):
|
|
started.set()
|
|
await release.wait()
|
|
return "unreachable"
|
|
|
|
engine.process_message = AsyncMock(side_effect=_blocked_process_message)
|
|
root = Task(
|
|
id="root",
|
|
title="Root task",
|
|
description="Run runtime",
|
|
status=TaskStatus.RUNNING,
|
|
session_id="session-root",
|
|
project_id="demo",
|
|
)
|
|
linked = Task(
|
|
id="child",
|
|
title="Child task",
|
|
description="Background child",
|
|
status=TaskStatus.RUNNING,
|
|
session_id="session-child",
|
|
project_id="demo",
|
|
metadata={"origin_task_id": "root"},
|
|
)
|
|
await engine.store.save_task(root)
|
|
await engine.store.save_task(linked)
|
|
engine.store.checkpoints.append(
|
|
ExecutionCheckpoint(
|
|
checkpoint_id="cp-root",
|
|
project_id="demo",
|
|
session_id="session-root",
|
|
task_id="root",
|
|
)
|
|
)
|
|
actions, services = self._make_actions(engine)
|
|
|
|
background = asyncio.create_task(actions.send_session_message("root", "keep going"))
|
|
await asyncio.wait_for(started.wait(), timeout=5)
|
|
|
|
await actions.cancel_task("root")
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await background
|
|
|
|
self.assertEqual(engine.store.tasks["root"].status, TaskStatus.CANCELLED)
|
|
self.assertEqual(engine.store.tasks["child"].status, TaskStatus.CANCELLED)
|
|
self.assertEqual(engine.store.resolved, [("cp-root", "cancelled")])
|
|
self.assertEqual(len(services.session.stop_calls), 1)
|
|
self.assertEqual(services.session.stop_calls[0]["task_id"], "root")
|