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>
917 lines
35 KiB
Python
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",
|
|
)
|