Files
OpenOPC/tests/test_parallel_runtime_isolation.py
T
LZH-YS1998 76c530a9e5 test: repair full suite — hang fix, stale-test updates, patch hygiene, timeout backstop
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>
2026-07-26 22:14:47 +08:00

917 lines
35 KiB
Python

from __future__ import annotations
import asyncio
import json
import sqlite3
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from opc.core.models import CompanyMemberSession, ExternalSession, SystemMessage, Task, TaskResult, TaskStatus
from opc.core.config import ExternalAgentConfig
from opc.database.store import OPCStore
from opc.engine import OPCEngine
from opc.layer3_agent.adapters.claude_code import ClaudeCodeAdapter
from opc.layer2_organization.company_mode import CompanyWorkItemExecutor, CompanyWorkItemRuntimePlan
class _MemoryStore:
def __init__(self, tasks: list[Task]) -> None:
self.tasks = {task.id: task for task in tasks}
self.saved: list[Task] = []
self.renewed: list[str] = []
@property
def is_ready(self) -> bool:
return True
async def get_task(self, task_id: str) -> Task | None:
return self.tasks.get(task_id)
async def save_task(self, task: Task) -> None:
self.tasks[task.id] = task
self.saved.append(task)
async def get_session_transcript(self, session_id: str) -> list[dict]:
return []
async def renew_task_lock(self, task_id: str) -> bool:
self.renewed.append(task_id)
return True
def _ui_chat_store() -> SimpleNamespace:
return SimpleNamespace(
create_session_channel=AsyncMock(return_value={}),
backfill_messages=AsyncMock(return_value=[]),
insert_message=AsyncMock(return_value={"message_id": "msg", "created_at": 1.0}),
append_progress=AsyncMock(),
)
def _ui_event_adapter() -> SimpleNamespace:
return SimpleNamespace(
translate=MagicMock(return_value=[]),
parse_progress=MagicMock(return_value=[]),
get_task_display_num=MagicMock(return_value=1),
_resolve_agent_from_task=MagicMock(return_value=None),
_resolve_role_to_agent=MagicMock(return_value=""),
_task_display_map={},
_task_display_counter=0,
task_display_counter=1,
)
def _ui_engine(project_id: str, store: _MemoryStore) -> SimpleNamespace:
return SimpleNamespace(
project_id=project_id,
opc_home=Path(tempfile.gettempdir()),
store=store,
memory=None,
escalation=None,
company_executor=None,
reorg_manager=None,
event_bus=None,
process_message=AsyncMock(return_value="ok"),
get_latest_pending_checkpoint_for_session=AsyncMock(return_value=None),
_ensure_attachment_store=MagicMock(),
)
class _FakeWS:
def __init__(self) -> None:
self.sent_json: list[dict] = []
async def send_json(self, payload: dict) -> None:
self.sent_json.append(payload)
class _FakeClient:
def __init__(self) -> None:
self.sent_str: list[str] = []
async def send_str(self, payload: str) -> None:
self.sent_str.append(payload)
def _async_test(func):
def runner(*args, **kwargs):
return asyncio.run(func(*args, **kwargs))
return runner
def _run_task(run_id: str) -> Task:
return Task(
id=f"task-{run_id}",
title=f"Run {run_id}",
description="simple parallel isolation task",
project_id="proj",
assigned_to="executor",
status=TaskStatus.PENDING,
metadata={
"delegation_run_id": run_id,
"parent_session_id": f"session-{run_id}",
"progress_log": [],
},
parent_session_id=f"session-{run_id}",
session_id=f"session-{run_id}",
)
@_async_test
async def test_company_executor_parallel_runs_keep_context_state_isolated() -> None:
async def execute_task(task: Task) -> TaskResult:
task.status = TaskStatus.DONE
task.result = {"content": f"executed {task.id}"}
return TaskResult(status=TaskStatus.DONE, content=task.result["content"])
executor = CompanyWorkItemExecutor(
org_engine=SimpleNamespace(),
communication=SimpleNamespace(
refresh_waiting_tasks=AsyncMock(return_value=[]),
detect_deadlocks=AsyncMock(return_value=[]),
),
approval_engine=SimpleNamespace(),
memory=None,
execute_task=execute_task,
save_task=AsyncMock(),
)
def active_run_id() -> str:
assert executor._active_tasks
run_ids = {
str((task.metadata or {}).get("delegation_run_id", "") or "")
for task in executor._active_tasks
}
assert len(run_ids) == 1
return next(iter(run_ids))
async def fake_bootstrap(tasks: list[Task]) -> None:
run_id = str((tasks[0].metadata or {}).get("delegation_run_id", "") or "")
executor.runtime.member_sessions = {
f"member-{run_id}": CompanyMemberSession(
member_session_id=f"member-{run_id}",
role_id="executor",
seat_id=f"seat-{run_id}",
status="idle",
resident_status="idle",
metadata={"run_id": run_id},
)
}
executor.runtime._claimed_task_ids = set()
executor.runtime._claimed_work_item_ids = set()
claimed_once: set[str] = set()
async def fake_claim_runnable_tasks(tasks: list[Task], work_items=None):
run_id = active_run_id()
assert run_id == str((tasks[0].metadata or {}).get("delegation_run_id", "") or "")
if run_id in claimed_once:
return []
claimed_once.add(run_id)
task = tasks[0]
session = next(iter(executor.runtime.member_sessions.values()))
executor.runtime._claimed_task_ids.add(task.id)
session.status = session.resident_status = "running"
session.current_task_id = task.id
return [(session, task)]
both_running = asyncio.Event()
running_runs: set[str] = set()
observed_max_parallel = 0
async def fake_run_claimed_work_item(member_session: CompanyMemberSession, task: Task, task_by_projection_id: dict):
nonlocal observed_max_parallel
run_id = str((task.metadata or {}).get("delegation_run_id", "") or "")
running_runs.add(run_id)
observed_max_parallel = max(observed_max_parallel, len(running_runs))
if len(running_runs) == 2:
both_running.set()
await asyncio.wait_for(both_running.wait(), timeout=2)
assert active_run_id() == run_id
assert executor.runtime._claimed_task_ids == {task.id}
result = await execute_task(task)
executor.runtime._claimed_task_ids.discard(task.id)
member_session.status = member_session.resident_status = "idle"
member_session.current_task_id = ""
running_runs.discard(run_id)
return result
executor.runtime.bootstrap = AsyncMock(side_effect=fake_bootstrap)
executor.runtime.refresh_inbox_state = AsyncMock()
executor.runtime.enqueue_runnable_work_items = lambda *args, **kwargs: None
executor.runtime.enqueue_runnable_tasks = lambda *args, **kwargs: None
executor.runtime.claim_runnable_tasks = AsyncMock(side_effect=fake_claim_runnable_tasks)
executor._load_delegation_work_items = AsyncMock(return_value=[])
executor._refresh_ready_work_items = AsyncMock(side_effect=lambda items, tasks=None: items)
executor._materialize_work_item_tasks = AsyncMock(side_effect=lambda tasks, work_items: tasks)
executor._queue_multi_team_response_tasks = AsyncMock(side_effect=lambda tasks, work_items: (tasks, work_items))
executor._reconcile_role_serial_queues = AsyncMock(side_effect=lambda work_items: work_items)
executor._sync_task_projection_from_work_items = lambda tasks, work_items: None
executor._diagnose_work_item_runtime_projection_issues = AsyncMock()
executor._rehydrate_parked_member_sessions = lambda work_items: None
executor._schedule_kanban_notification = lambda: None
executor._summarize_multi_team_org_results = lambda tasks: f"done:{tasks[0].metadata['delegation_run_id']}"
executor._run_claimed_work_item = AsyncMock(side_effect=fake_run_claimed_work_item)
task_a = _run_task("run-a")
task_b = _run_task("run-b")
results = await asyncio.gather(
executor.execute(CompanyWorkItemRuntimePlan(profile="corporate"), [task_a]),
executor.execute(CompanyWorkItemRuntimePlan(profile="corporate"), [task_b]),
)
assert set(results) == {"done:run-a", "done:run-b"}
assert observed_max_parallel == 2
assert task_a.status == TaskStatus.DONE
assert task_b.status == TaskStatus.DONE
assert executor.runtime._claimed_task_ids == set()
@_async_test
async def test_initialized_engine_delegates_cross_project_message_without_mutating_root() -> None:
engine = OPCEngine(project_id="project-a")
engine._initialized = True
engine._refresh_runtime_config_from_disk = AsyncMock()
engine.message_bus.process_single = AsyncMock(
return_value=SystemMessage(
channel="cli",
user_id="owner",
session_id="same-project",
content="same",
)
)
delegate = SimpleNamespace(process_message=AsyncMock(return_value="delegated"))
engine._get_project_delegate = AsyncMock(return_value=delegate)
result = await engine.process_message(
"hello",
project_id="project-b",
session_id="session-b",
preferred_agent="claude_code",
)
assert result == "delegated"
assert engine.project_id == "project-a"
engine._get_project_delegate.assert_awaited_once_with("project-b")
delegate.process_message.assert_awaited_once()
@_async_test
async def test_project_store_rejects_cross_project_runtime_writes() -> None:
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "projects" / "project-a" / "tasks.db"
store = OPCStore(db_path)
await store.initialize()
try:
try:
await store.save_task(Task(id="task-b", title="wrong project", project_id="project-b"))
except RuntimeError as exc:
assert "cross-project" in str(exc)
else:
raise AssertionError("cross-project task write was accepted")
try:
await store.save_external_session(
ExternalSession(agent_type="codex", project_id="project-b", task_id="task-b")
)
except RuntimeError as exc:
assert "cross-project" in str(exc)
else:
raise AssertionError("cross-project external session write was accepted")
finally:
await store.close()
@_async_test
async def test_project_store_purges_cross_project_shadow_rows_before_validation() -> None:
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "projects" / "project-b" / "tasks.db"
store = OPCStore(db_path)
await store.initialize()
await store.close()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"""INSERT INTO tasks (id, title, status, created_at, project_id, metadata)
VALUES (?, ?, ?, ?, ?, ?)""",
(
"shadow-a",
"CEO Intake",
"cancelled",
"2026-01-01T00:00:00",
"project-a",
'{"work_item_runtime": true, "work_item_runtime_version": 1, '
'"work_item_projection_id": "corporate::intake::ceo"}',
),
)
conn.execute(
"""INSERT INTO external_sessions
(session_key, agent_type, project_id, session_id, task_id, status, metadata, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
"codex:project-a:shadow-a",
"codex",
"project-a",
"codex:project-a:shadow-a",
"shadow-a",
"working",
"{}",
"2026-01-01T00:00:00",
),
)
conn.commit()
finally:
conn.close()
reopened = OPCStore(db_path)
await reopened.initialize()
try:
assert await reopened.get_task("shadow-a") is None
async with reopened._db.execute(
"SELECT COUNT(*) FROM external_sessions WHERE project_id != 'project-b'"
) as cursor:
row = await cursor.fetchone()
assert row[0] == 0
finally:
await reopened.close()
@_async_test
async def test_ui_project_switch_uses_delegate_without_cancelling_background_tasks() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = OPCEngine(opc_home=Path(tmp) / ".opc", project_id="project-a")
root._initialized = True
(root.opc_home / "projects" / "project-b").mkdir(parents=True, exist_ok=True)
delegate = SimpleNamespace(
project_id="project-b",
opc_home=root.opc_home,
store=SimpleNamespace(),
memory=None,
escalation=None,
_ensure_attachment_store=MagicMock(),
)
root._get_project_delegate = AsyncMock(return_value=delegate) # type: ignore[method-assign]
from opc.plugins.office_ui.ws_handler import WSHandler
chat_store = SimpleNamespace(
ensure_activity_channel=AsyncMock(),
ensure_secretary_channel=AsyncMock(),
)
handler = WSHandler(root, MagicMock(), chat_store, MagicMock())
handler._send_ack = AsyncMock()
ws = type("FakeWS", (), {"closed": False, "closing": False})()
ws.send_json = AsyncMock()
bg = asyncio.create_task(asyncio.sleep(30))
handler._background_tasks.add(bg)
try:
with patch("opc.plugins.office_ui.ws_handler.build_snapshot", new=AsyncMock(return_value={})), \
patch("opc.plugins.office_ui.ws_handler.build_project_index_sync", new=AsyncMock(return_value={})), \
patch("opc.plugins.office_ui.ws_handler.build_collab_sync", new=AsyncMock(return_value={})):
await handler._handle_switch_project(ws, {"project_id": "project-b", "switch_seq": "seq-1"})
await asyncio.sleep(0)
root._get_project_delegate.assert_awaited_once_with("project-b")
# Project switch deliberately rebinds the handler to the delegate.
assert handler.engine is delegate
assert handler._client_project_ids[ws] == "project-b"
assert handler._client_switch_seq[ws] == "seq-1"
sent_types = [call.args[0]["type"] for call in ws.send_json.await_args_list]
assert sent_types[0] == "project_switched"
assert "project_index_push" in sent_types
assert "collab_sync_push" not in sent_types
handler._send_ack.assert_awaited_once_with(ws, ok=True, project_id="project-b", switch_seq="seq-1")
assert bg in handler._background_tasks
assert not bg.cancelled()
finally:
bg.cancel()
try:
await bg
except asyncio.CancelledError:
pass
@_async_test
async def test_ui_project_switch_snapshot_failure_preserves_current_project_mapping() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = OPCEngine(opc_home=Path(tmp) / ".opc", project_id="project-a")
root._initialized = True
(root.opc_home / "projects" / "project-b").mkdir(parents=True, exist_ok=True)
delegate = SimpleNamespace(
project_id="project-b",
opc_home=root.opc_home,
store=SimpleNamespace(),
memory=None,
escalation=None,
_ensure_attachment_store=MagicMock(),
)
root._get_project_delegate = AsyncMock(return_value=delegate) # type: ignore[method-assign]
from opc.plugins.office_ui.ws_handler import WSHandler
chat_store = SimpleNamespace(
ensure_activity_channel=AsyncMock(),
ensure_secretary_channel=AsyncMock(),
)
handler = WSHandler(root, MagicMock(), chat_store, MagicMock())
handler._send_ack = AsyncMock()
ws = type("FakeWS", (), {"closed": False, "closing": False})()
ws.send_json = AsyncMock()
handler._client_project_ids[ws] = "project-a"
handler._client_switch_seq[ws] = "seq-0"
with patch(
"opc.plugins.office_ui.ws_handler.build_snapshot",
new=AsyncMock(side_effect=RuntimeError("snapshot boom")),
), patch("opc.plugins.office_ui.ws_handler.build_project_index_sync", new=AsyncMock(return_value={})), \
patch("opc.plugins.office_ui.ws_handler.build_collab_sync", new=AsyncMock(return_value={})):
await handler._handle_switch_project(ws, {"project_id": "project-b", "switch_seq": "seq-1"})
await asyncio.sleep(0)
root._get_project_delegate.assert_awaited_once_with("project-b")
assert handler._client_project_ids[ws] == "project-b"
assert handler._client_switch_seq[ws] == "seq-1"
sent_types = [call.args[0]["type"] for call in ws.send_json.await_args_list]
assert sent_types[0] == "project_switched"
assert "project_index_push" in sent_types
assert "collab_sync_push" not in sent_types
chat_store.ensure_activity_channel.assert_awaited_once_with(project_id="project-b")
chat_store.ensure_secretary_channel.assert_awaited_once_with(project_id="project-b")
handler._send_ack.assert_awaited_once_with(ws, ok=True, project_id="project-b", switch_seq="seq-1")
@_async_test
async def test_ui_project_switch_cancels_stale_project_index_task() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = OPCEngine(opc_home=Path(tmp) / ".opc", project_id="project-a")
root._initialized = True
for project_id in ("project-b", "project-c"):
(root.opc_home / "projects" / project_id).mkdir(parents=True, exist_ok=True)
delegates = {
project_id: SimpleNamespace(
project_id=project_id,
opc_home=root.opc_home,
store=SimpleNamespace(),
memory=None,
escalation=None,
_ensure_attachment_store=MagicMock(),
)
for project_id in ("project-b", "project-c")
}
root._get_project_delegate = AsyncMock(side_effect=lambda project_id: delegates[project_id]) # type: ignore[method-assign]
from opc.plugins.office_ui.ws_handler import WSHandler
chat_store = SimpleNamespace(
ensure_activity_channel=AsyncMock(),
ensure_secretary_channel=AsyncMock(),
)
handler = WSHandler(root, MagicMock(), chat_store, MagicMock())
handler._send_ack = AsyncMock()
ws = type("FakeWS", (), {"closed": False, "closing": False})()
ws.send_json = AsyncMock()
stale_started = asyncio.Event()
stale_cancelled = asyncio.Event()
async def fake_project_index(engine, *_args, **_kwargs):
if engine.project_id == "project-b":
stale_started.set()
try:
await asyncio.sleep(30)
except asyncio.CancelledError:
stale_cancelled.set()
raise
return {}
with patch("opc.plugins.office_ui.ws_handler.build_project_index_sync", new=fake_project_index), \
patch("opc.plugins.office_ui.ws_handler.build_snapshot", new=AsyncMock(return_value={})):
await handler._handle_switch_project(ws, {"project_id": "project-b", "switch_seq": "seq-1"})
await asyncio.wait_for(stale_started.wait(), timeout=1.0)
await handler._handle_switch_project(ws, {"project_id": "project-c", "switch_seq": "seq-2"})
await asyncio.wait_for(stale_cancelled.wait(), timeout=1.0)
await asyncio.sleep(0)
index_payloads = [
call.args[0]["payload"]
for call in ws.send_json.await_args_list
if call.args[0]["type"] == "project_index_push"
]
assert [payload["project_id"] for payload in index_payloads] == ["project-c"]
assert handler._client_project_ids[ws] == "project-c"
assert handler._client_switch_seq[ws] == "seq-2"
@_async_test
async def test_session_processing_keeps_captured_project_engine_after_ui_switch() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
task_a = Task(
id="task-a",
title="A",
project_id="project-a",
session_id="session-a",
metadata={"exec_mode": "task", "preferred_agent": "native"},
)
task_b = Task(
id="task-b",
title="B",
project_id="project-b",
session_id="session-b",
metadata={"exec_mode": "task", "preferred_agent": "native"},
)
store_a = _MemoryStore([task_a])
store_b = _MemoryStore([task_b])
engine_a = _ui_engine("project-a", store_a)
engine_b = _ui_engine("project-b", store_b)
chat_store = _ui_chat_store()
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
handler.broadcast = AsyncMock()
async def process_on_a(*args, **kwargs):
handler.engine = engine_b
return "ok-a"
engine_a.process_message.side_effect = process_on_a
await handler._process_session_message(
"task-a",
"continue",
session_id="session-a",
run_engine=engine_a,
run_project_id="project-a",
)
engine_a.process_message.assert_awaited_once()
assert engine_a.process_message.await_args.kwargs["project_id"] == "project-a"
engine_b.process_message.assert_not_awaited()
assert store_a.tasks["task-a"].status == TaskStatus.IDLE
assert store_b.tasks["task-b"].status == TaskStatus.PENDING
chat_store.create_session_channel.assert_awaited_with("task-a", "A", project_id="project-a")
@_async_test
async def test_project_bound_progress_callback_uses_origin_project_after_ui_switch() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
task_a = Task(id="task-a", title="A", project_id="project-a", session_id="session-a")
task_b = Task(id="task-b", title="B", project_id="project-b", session_id="session-b")
engine_a = _ui_engine("project-a", _MemoryStore([task_a]))
engine_b = _ui_engine("project-b", _MemoryStore([task_b]))
chat_store = _ui_chat_store()
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
handler.broadcast = AsyncMock()
handler._wire_engine_callbacks(engine_b)
handler.engine = engine_b
await engine_a.on_progress("[External status] started pid=123", task_id="task-a")
chat_store.insert_message.assert_awaited_once()
assert chat_store.insert_message.await_args.kwargs["project_id"] == "project-a"
assert chat_store.insert_message.await_args.kwargs["channel_id"] == "session:task-a"
@_async_test
async def test_runtime_agent_message_mirror_uses_origin_project_after_ui_switch() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
task_a = Task(id="task-a", title="A", project_id="project-a", session_id="session-a")
task_b = Task(id="task-b", title="B", project_id="project-b", session_id="session-b")
engine_a = _ui_engine("project-a", _MemoryStore([task_a]))
engine_b = _ui_engine("project-b", _MemoryStore([task_b]))
chat_store = _ui_chat_store()
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
handler.broadcast = AsyncMock()
handler.engine = engine_b
await handler.on_opc_event(
SimpleNamespace(
event_type="agent_message_sent",
payload={"from": "executor", "task_id": "task-a", "body": "A update"},
),
runtime_engine=engine_a,
project_id="project-a",
)
chat_store.insert_message.assert_awaited_once()
assert chat_store.insert_message.await_args.kwargs["project_id"] == "project-a"
assert chat_store.insert_message.await_args.kwargs["channel_id"] == "session:task-a"
assert handler.broadcast.await_args_list[-1].args[0]["payload"]["project_id"] == "project-a"
@_async_test
async def test_runtime_escalation_mirror_uses_origin_project_after_ui_switch() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
task_a = Task(id="task-a", title="A", project_id="project-a", session_id="session-a")
task_b = Task(id="task-b", title="B", project_id="project-b", session_id="session-b")
engine_a = _ui_engine("project-a", _MemoryStore([task_a]))
engine_b = _ui_engine("project-b", _MemoryStore([task_b]))
chat_store = _ui_chat_store()
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
handler.broadcast = AsyncMock()
handler.engine = engine_b
await handler.on_opc_event(
SimpleNamespace(
event_type="escalation_created",
payload={
"escalation_id": "esc-a",
"task_id": "task-a",
"message": "Approve A?",
"options": [{"id": "approve_once", "label": "Approve once"}],
},
),
runtime_engine=engine_a,
project_id="project-a",
)
chat_store.insert_message.assert_awaited_once()
assert chat_store.insert_message.await_args.kwargs["project_id"] == "project-a"
assert chat_store.insert_message.await_args.kwargs["channel_id"] == "session:task-a"
@_async_test
async def test_delegate_event_bus_forwards_all_events_once_with_project_context() -> None:
from opc.core.events import EventBus
from opc.core.models import OPCEvent
from opc.plugins.office_ui.ws_handler import WSHandler
task_a = Task(id="task-a", title="A", project_id="project-a", session_id="session-a")
task_b = Task(id="task-b", title="B", project_id="project-b", session_id="session-b")
engine_a = _ui_engine("project-a", _MemoryStore([task_a]))
engine_b = _ui_engine("project-b", _MemoryStore([task_b]))
engine_b.event_bus = EventBus()
async def inherited_runtime_forwarder(event):
raise AssertionError(f"typed runtime forwarder should have been removed: {event}")
engine_b._forward_runtime_event = inherited_runtime_forwarder
engine_b.event_bus.subscribe("runtime_event", inherited_runtime_forwarder)
chat_store = _ui_chat_store()
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
handler._wire_engine_callbacks(engine_b)
assert inherited_runtime_forwarder not in engine_b.event_bus._listeners["runtime_event"]
await engine_b.event_bus.publish(OPCEvent(
event_type="child_session_created",
payload={"task_id": "task-b", "title": "B", "session_id": "session-b"},
))
chat_store.create_session_channel.assert_awaited_with("task-b", "B", project_id="project-b")
@_async_test
async def test_session_detail_routes_by_request_project_id() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
task_b = Task(
id="task-b",
title="Project B Session",
project_id="project-b",
session_id="session-b",
)
engine_a = _ui_engine("project-a", _MemoryStore([]))
engine_b = _ui_engine("project-b", _MemoryStore([task_b]))
chat_store = SimpleNamespace(
create_session_channel=AsyncMock(return_value={"channel_id": "session:task-b"}),
backfill_messages=AsyncMock(return_value=[]),
get_channel_messages_page_info=AsyncMock(return_value={
"messages": [],
"has_more": False,
"total_count": 0,
}),
get_channel_messages=AsyncMock(return_value=[]),
)
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
handler._engine_for_project = AsyncMock(
side_effect=lambda project_id: engine_b if project_id == "project-b" else engine_a,
)
ws = _FakeWS()
await handler._handle_session_detail(
ws,
{
"type": "session_detail",
"project_id": "project-b",
"task_id": "task-b",
"limit": 50,
},
)
payload = ws.sent_json[-1]["payload"]
assert payload["ok"] is True
assert payload["action"] == "session_detail"
assert payload["project_id"] == "project-b"
assert payload["task_id"] == "task-b"
chat_store.create_session_channel.assert_awaited_once_with(
"task-b",
"Project B Session",
project_id="project-b",
)
assert chat_store.get_channel_messages_page_info.await_args.kwargs["project_id"] == "project-b"
assert chat_store.get_channel_messages_page_info.await_args.kwargs["detail_level"] == "summary"
@_async_test
async def test_project_scoped_ws_request_without_project_id_is_rejected() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
engine = _ui_engine("project-a", _MemoryStore([]))
chat_store = _ui_chat_store()
handler = WSHandler(engine, MagicMock(), chat_store, _ui_event_adapter())
handler._send_ack = AsyncMock()
ws = _FakeWS()
await handler._route_message(
ws,
json.dumps({"type": "create_session", "title": "Missing project"}),
)
handler._send_ack.assert_awaited_once_with(
ws,
ok=False,
error="project_id required for project-scoped request",
action="create_session",
)
assert engine.store.tasks == {}
@_async_test
async def test_kanban_create_task_routes_by_request_project_id() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
engine_a = _ui_engine("project-a", _MemoryStore([]))
engine_b = _ui_engine("project-b", _MemoryStore([]))
chat_store = _ui_chat_store()
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
engine_a._get_project_delegate = AsyncMock(return_value=engine_b)
handler._engine_for_project = AsyncMock(
side_effect=lambda project_id: engine_b if project_id == "project-b" else engine_a,
)
handler.broadcast = AsyncMock()
ws = _FakeWS()
await handler._handle_kanban_create_task(
ws,
{
"project_id": "project-b",
"task_id": "task-b",
"title": "B task",
"description": "belongs to B",
"board_id": "project-b",
"column_id": "todo",
},
)
assert "task-b" not in engine_a.store.tasks
assert engine_b.store.tasks["task-b"].project_id == "project-b"
chat_store.create_session_channel.assert_awaited_once_with(
"task-b",
"B task",
project_id="project-b",
)
payloads = [call.args[0]["payload"] for call in handler.broadcast.await_args_list]
assert payloads
assert all(payload.get("project_id") == "project-b" for payload in payloads)
@_async_test
async def test_project_scoped_broadcast_preserves_runtime_project_id() -> None:
from opc.plugins.office_ui.ws_handler import WSHandler
engine_a = _ui_engine("project-a", _MemoryStore([]))
handler = WSHandler(engine_a, MagicMock(), _ui_chat_store(), _ui_event_adapter())
client = _FakeClient()
handler._clients.add(client)
handler._client_project_ids[client] = "project-b"
await handler.broadcast({
"type": "session_created",
"project_id": "project-b",
"payload": {
"task_id": "task-b",
"channel_id": "session:task-b",
"title": "B",
"status": "pending",
"created_at": 1.0,
},
})
envelope = json.loads(client.sent_str[-1])
assert envelope["type"] == "session_created"
assert envelope["payload"]["project_id"] == "project-b"
@_async_test
async def test_explicit_external_agent_failure_does_not_try_other_agents_or_native() -> None:
engine = OPCEngine()
attempted_agents: list[str] = []
native_called = False
class _Adapter:
def __init__(self, name: str) -> None:
self.name = name
self.agent_type = name
def build_invocation(self, task: Task, workspace_path: str | None = None):
return [self.name], {"agent": self.name, "command": self.name}
class _Broker:
async def run(self, adapter, task, workspace_path, on_progress=None, prepared_task=None):
attempted_agents.append(adapter.name)
return TaskResult(status=TaskStatus.FAILED, content=f"{adapter.name} failed", artifacts={})
async def run_native_agent(task: Task) -> TaskResult:
nonlocal native_called
native_called = True
return TaskResult(status=TaskStatus.DONE, content="native")
engine.external_broker = _Broker()
engine._get_external_candidates = lambda task: [("claude_code", _Adapter("claude_code")), ("codex", _Adapter("codex"))] # type: ignore[method-assign]
engine._resolve_external_workspace = lambda task: "/tmp/out" # type: ignore[method-assign]
engine._build_external_agent_task = AsyncMock(side_effect=lambda task: task)
engine._run_native_agent = run_native_agent # type: ignore[method-assign]
task = Task(
id="explicit-claude",
title="Explicit Claude",
project_id="proj",
assigned_external_agent="claude_code",
metadata={
"router_preferred_agent": "claude_code",
"target_output_dir": "/tmp/out",
},
)
result = await engine._run_task_once(task)
assert result.status == TaskStatus.FAILED
assert attempted_agents == ["claude_code"]
assert native_called is False
def test_explicit_agent_overrides_company_role_agent_defaults() -> None:
engine = OPCEngine(project_id="proj")
engine.org_engine = SimpleNamespace(
get_employee=lambda employee_id: None,
ensure_fallback_employee_for_role=lambda role_id, persist=False: None,
get_default_employee_for_role=lambda role_id: None,
list_employees=lambda role_id=None: [],
get_agent=lambda role_id: SimpleNamespace(preferred_external_agent="codex"),
)
topology = {
"seats": [
{
"seat_id": "seat-ceo",
"role_id": "ceo",
}
]
}
decision = SimpleNamespace(preferred_agent="claude_code")
enriched = engine._enrich_runtime_delegation_topology(
runtime_topology=topology,
decision=decision,
project_id="proj",
)
seat = enriched["seats"][0]
assert seat["preferred_external_agent"] == "claude_code"
assert seat["selected_execution_agent"] == "claude_code"
assert seat["execution_agent_locked"] is True
assert seat["selected_execution_agent_source"] == "explicit_user_agent"
@_async_test
async def test_task_mode_external_resume_requires_explicit_session_token() -> None:
engine = OPCEngine(project_id="proj")
engine.store = SimpleNamespace(get_external_session=AsyncMock(return_value=None))
task = Task(
id="first-turn",
title="First turn",
project_id="proj",
session_id="session-new",
assigned_external_agent="claude_code",
metadata={
"mode": "task",
"task_mode_contract": "single_full_capability_main_agent",
},
)
adapter = ClaudeCodeAdapter(config=ExternalAgentConfig(command="claude"))
run_adapter, resume_metadata = await engine._configure_external_adapter_for_task(task, adapter)
assert run_adapter.config.session_mode != "resume"
assert resume_metadata == {}
engine.store.get_external_session.assert_any_await(
"claude_code",
"proj",
opc_session_id="session-new",
)