Files
OpenOPC/tests/test_checkpoint_answer_live_dispatcher.py
T
LZH-YS1998 14ee8806de fix(engine): single live dispatcher per run + unified approval decision channel
OBS-4: checkpoint answers for a run whose dispatcher is still live no longer
re-enter _execute_company_mode (re-entry reset live claim registries and the
attempt ledger stamped in-flight cards interrupted until the streak limit
killed them). The executor keeps a _live_run_dispatchers refcount; task and
peer checkpoint resumes deliver the input in place and wake the dispatcher.
Runs without a live dispatcher keep the original re-entry resume semantics.

OBS-7: approval decisions expressed through the chat/checkpoint route now
reach the approval engine instead of being parsed as plain task input
(which _ask_user treated as deny, re-escalating until the card died).
normalize_escalation_reply maps decision tokens/synonyms (never silently
denies free text), escalation_context_for_blocked_tool rebuilds the
allowlist context from pause_request.permission_context, and
_resume_task_checkpoint applies the grant via
apply_deferred_escalation_decision — the same engine path as the UI card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:55:55 +08:00

156 lines
5.6 KiB
Python

"""Engine-integration regression for OBS-4 + OBS-7.
A ``task_user_input`` answer for a run whose dispatcher is live must:
1. apply an explicit approval decision through the approval engine
(OBS-7 — the chat route was input-only, so the blocked tool
re-escalated until the attempt ledger killed the card), and
2. deliver the input in place and wake the live dispatcher instead of
re-entering ``_execute_company_mode`` (OBS-4 — re-entry reset live
claim registries and the ledger stamped in-flight cards interrupted).
When no dispatcher is live the resume must fall through to the legacy
re-entry path unchanged.
"""
from __future__ import annotations
import asyncio
import unittest
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import AsyncMock
from opc.core.config import AutonomyConfig
from opc.core.models import ExecutionCheckpoint, Task, TaskStatus
from opc.database.store import OPCStore
from opc.engine import OPCEngine
from opc.layer2_organization.approval import ApprovalEngine
from opc.layer2_organization.company_mode import CompanyWorkItemExecutor
from opc.layer2_organization.work_item_links import set_linked_work_item_id
class _PreferencesStub:
def get_autonomy_preferences(self, project_id=None):
_ = project_id
return {"learned_actions": {}}
def record_autonomy_feedback(self, **kwargs):
_ = kwargs
class _StoreStub:
async def record_approval(self, **kwargs):
_ = kwargs
class _MemoryStub:
def append_autonomy_event(self, event, project=False):
_ = (event, project)
def _approval_engine() -> ApprovalEngine:
return ApprovalEngine(
llm=object(),
store=_StoreStub(),
preferences=_PreferencesStub(),
memory=_MemoryStub(),
escalation=None,
config=AutonomyConfig(),
)
class CheckpointAnswerLiveDispatcherTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self._tmp = TemporaryDirectory()
self.store = OPCStore(Path(self._tmp.name) / "tasks.db")
await self.store.initialize()
self.engine = OPCEngine(project_id="p")
self.engine.store = self.store
self.engine.approval_engine = _approval_engine()
self.executor = CompanyWorkItemExecutor.__new__(CompanyWorkItemExecutor)
self.executor._live_run_dispatchers = {}
self.executor._dispatcher_wake = asyncio.Event()
self.engine.company_executor = self.executor
self.engine._execute_company_mode = AsyncMock(return_value="re-entered")
self.engine._execute_single_agent = AsyncMock(return_value="single-agent")
async def asyncTearDown(self) -> None:
await self.store.close()
self._tmp.cleanup()
async def _seed(self) -> ExecutionCheckpoint:
task = Task(
id="task-1",
title="blocked worker",
project_id="p",
session_id="s",
status=TaskStatus.AWAITING_HUMAN,
metadata={
"delegation_run_id": "run-1",
"execution_mode": "company_mode",
"work_item_runtime": True,
},
)
set_linked_work_item_id(task, "wi-1")
await self.store.save_task(task)
checkpoint = ExecutionCheckpoint(
checkpoint_id="ckpt-1",
project_id="p",
session_id="s",
checkpoint_type="task_user_input",
task_id="task-1",
status="pending",
payload={
"task_id": "task-1",
"session_id": "s",
"execution_mode": "company_mode",
"task_ids": ["task-1"],
"prompt": "Tool execution blocked by autonomy policy",
"pause_request": {
"requires_user_input": True,
"permission_context": {
"tool_name": "shell_exec",
"candidate": "pip install pandas",
"resolution": "ask",
},
},
},
created_at=datetime.now(),
)
await self.store.save_execution_checkpoint(checkpoint)
return checkpoint
async def test_live_dispatcher_gets_wake_and_approval_applies(self) -> None:
checkpoint = await self._seed()
self.executor._live_run_dispatchers["run-1"] = 1
reply = await self.engine._resume_task_checkpoint(checkpoint, "approve_session")
self.assertIn("live", reply)
self.assertTrue(self.executor._dispatcher_wake.is_set())
self.engine._execute_company_mode.assert_not_awaited()
self.engine._execute_single_agent.assert_not_awaited()
saved = await self.store.get_task("task-1")
injected = str(saved.context_snapshot.get("user_supplied_input", ""))
self.assertIn("Approval decision applied", injected)
self.assertIn("shell_exec", injected)
self.assertEqual(saved.status, TaskStatus.PENDING)
async def test_no_live_dispatcher_falls_through_to_reentry_path(self) -> None:
checkpoint = await self._seed()
reply = await self.engine._resume_task_checkpoint(checkpoint, "please continue")
self.assertFalse(self.executor._dispatcher_wake.is_set())
self.assertEqual(reply, "single-agent")
self.engine._execute_single_agent.assert_awaited()
saved = await self.store.get_task("task-1")
self.assertEqual(
saved.context_snapshot.get("user_supplied_input"),
"please continue",
)
if __name__ == "__main__":
unittest.main()