Initial commit
This commit is contained in:
@@ -0,0 +1,910 @@
|
||||
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")
|
||||
assert handler.engine is root
|
||||
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=AsyncMock(return_value=[]),
|
||||
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.await_args.kwargs["project_id"] == "project-b"
|
||||
|
||||
|
||||
@_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())
|
||||
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",
|
||||
role_agent_overrides={"ceo": "codex"},
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
Reference in New Issue
Block a user