907 lines
28 KiB
Python
907 lines
28 KiB
Python
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from opensquilla.gateway.rpc import RpcContext
|
|
from opensquilla.gateway.rpc_cron import (
|
|
_build_payload,
|
|
_handle_cron_add,
|
|
_handle_cron_update,
|
|
_resolve_origin_session_key,
|
|
_resolve_session_target,
|
|
_resolve_target_session_key,
|
|
)
|
|
from opensquilla.scheduler.delivery import DeliveryChain
|
|
from opensquilla.scheduler.handlers import (
|
|
_resolve_session_key,
|
|
make_agent_run_handler,
|
|
make_static_message_handler,
|
|
)
|
|
from opensquilla.scheduler.payloads import AGENT_TURN_KIND, REMINDER_KIND, SYSTEM_EVENT_KIND
|
|
from opensquilla.scheduler.types import (
|
|
CronJob,
|
|
DeliveryConfig,
|
|
DeliveryMode,
|
|
ReplyTargetSnapshot,
|
|
SessionTarget,
|
|
)
|
|
|
|
SESSION_KEY = "agent:main:webchat:abc123"
|
|
CRON_SESSION_KEY = "cron:drink:run:def456"
|
|
|
|
|
|
class _FakeScheduler:
|
|
def __init__(self, job: CronJob | None = None) -> None:
|
|
self.added = None
|
|
self.updated = None
|
|
self.job = job
|
|
|
|
async def add_job(self, **kwargs) -> CronJob:
|
|
self.added = kwargs
|
|
return CronJob(
|
|
id="drink",
|
|
name=kwargs["name"],
|
|
cron_expr=kwargs.get("schedule_value") or kwargs.get("schedule_raw", ""),
|
|
schedule_raw=kwargs.get("schedule_value") or kwargs.get("schedule_raw", ""),
|
|
handler_key=kwargs["handler_key"],
|
|
payload=kwargs["payload"],
|
|
session_target=kwargs["session_target"],
|
|
session_key=kwargs["session_key"],
|
|
origin_session_key=kwargs["origin_session_key"],
|
|
delivery=kwargs.get("delivery") or DeliveryConfig(),
|
|
tool_policy=kwargs.get("tool_policy") or {},
|
|
creator_is_owner=bool(kwargs.get("creator_is_owner", False)),
|
|
)
|
|
|
|
async def update_job(self, job_id, **patch) -> CronJob:
|
|
self.updated = patch
|
|
if self.job is None:
|
|
return CronJob(id=job_id, **patch)
|
|
for key, value in patch.items():
|
|
setattr(self.job, key, value)
|
|
return self.job
|
|
|
|
async def get_job(self, job_id) -> CronJob | None:
|
|
if self.job is not None and self.job.id == job_id:
|
|
return self.job
|
|
return None
|
|
|
|
|
|
class _FakeSessionManager:
|
|
def __init__(self) -> None:
|
|
self.created = []
|
|
self.rows = {}
|
|
|
|
async def get_or_create(self, **kwargs):
|
|
self.created.append(kwargs)
|
|
return kwargs
|
|
|
|
async def append_message(self, session_key, role, content):
|
|
row = {"role": role, "content": content}
|
|
self.rows.setdefault(session_key, []).append(row)
|
|
return SimpleNamespace(role=role, content=content)
|
|
|
|
async def read_transcript(self, session_key):
|
|
return list(self.rows.get(session_key, []))
|
|
|
|
|
|
class _FakeTurnRunner:
|
|
def __init__(self, session_manager: _FakeSessionManager, text: str = "drink logged") -> None:
|
|
self.session_manager = session_manager
|
|
self.text = text
|
|
self.calls = []
|
|
|
|
def run(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
|
|
async def events():
|
|
await self.session_manager.append_message(
|
|
kwargs["session_key"],
|
|
role="assistant",
|
|
content=self.text,
|
|
)
|
|
yield SimpleNamespace(kind="message", text=self.text)
|
|
yield SimpleNamespace(kind="done")
|
|
|
|
return events()
|
|
|
|
|
|
class _FakeTaskRuntime:
|
|
def __init__(self, record) -> None:
|
|
self.record = record
|
|
self.enqueued = []
|
|
|
|
async def enqueue(self, route_envelope, task, *, mode, run_kind):
|
|
self.enqueued.append(
|
|
{
|
|
"route_envelope": route_envelope,
|
|
"task": task,
|
|
"mode": mode,
|
|
"run_kind": run_kind,
|
|
}
|
|
)
|
|
return SimpleNamespace(task_id="task-1")
|
|
|
|
async def wait(self, task_id, *, timeout):
|
|
assert task_id == "task-1"
|
|
return self.record
|
|
|
|
|
|
class _RecordingDeliveryChain:
|
|
def __init__(self) -> None:
|
|
self.deliveries = []
|
|
|
|
async def notify_start(self, job, task) -> None:
|
|
return None
|
|
|
|
async def deliver(self, job, **kwargs):
|
|
kwargs["job"] = job
|
|
self.deliveries.append(kwargs)
|
|
return SimpleNamespace(
|
|
channel_status="skipped",
|
|
ws_status="skipped",
|
|
session_status="skipped",
|
|
)
|
|
|
|
|
|
class _FailingChannelAdapter:
|
|
async def send(self, _msg) -> None:
|
|
raise RuntimeError("channel down")
|
|
|
|
|
|
class _RecordingChannelAdapter:
|
|
def __init__(self) -> None:
|
|
self.sent = []
|
|
|
|
async def send(self, msg) -> None:
|
|
self.sent.append(msg)
|
|
|
|
|
|
class _FakeChannelManager:
|
|
def get(self, _name: str):
|
|
return _FailingChannelAdapter()
|
|
|
|
|
|
class _RecordingChannelManager:
|
|
def __init__(self) -> None:
|
|
self.adapter = _RecordingChannelAdapter()
|
|
|
|
def get(self, _name: str):
|
|
return self.adapter
|
|
|
|
|
|
def test_rpc_current_session_params_bind_target_and_origin_session() -> None:
|
|
params = {
|
|
"payloadKind": AGENT_TURN_KIND,
|
|
"sessionTarget": "current",
|
|
"sessionKey": SESSION_KEY,
|
|
"text": "drink water",
|
|
"agentId": "main",
|
|
}
|
|
|
|
session_target = _resolve_session_target(params)
|
|
kind, payload = _build_payload(params, session_target)
|
|
|
|
assert session_target == SessionTarget.CURRENT
|
|
assert _resolve_target_session_key(params, session_target) == SESSION_KEY
|
|
assert _resolve_origin_session_key(params, session_target) == SESSION_KEY
|
|
assert kind == AGENT_TURN_KIND
|
|
assert payload == {
|
|
"kind": AGENT_TURN_KIND,
|
|
"task": "drink water",
|
|
"agent_id": "main",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_create_current_session_job_passes_session_binding_to_scheduler() -> None:
|
|
scheduler = _FakeScheduler()
|
|
|
|
result = await _handle_cron_add(
|
|
{
|
|
"name": "Drink",
|
|
"expression": "*/5 * * * *",
|
|
"payloadKind": AGENT_TURN_KIND,
|
|
"sessionTarget": "current",
|
|
"sessionKey": SESSION_KEY,
|
|
"originSessionKey": SESSION_KEY,
|
|
"text": "drink water",
|
|
"agentId": "main",
|
|
},
|
|
RpcContext(conn_id="test", cron_scheduler=scheduler),
|
|
)
|
|
|
|
assert scheduler.added["session_target"] == SessionTarget.CURRENT
|
|
assert scheduler.added["session_key"] == SESSION_KEY
|
|
assert scheduler.added["origin_session_key"] == SESSION_KEY
|
|
assert scheduler.added["handler_key"] == "agent_run"
|
|
assert scheduler.added["creator_is_owner"] is True
|
|
assert result["sessionTarget"] == "current"
|
|
assert result["targetSessionKey"] == SESSION_KEY
|
|
assert result["originSessionKey"] == SESSION_KEY
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_create_job_round_trips_tool_policy() -> None:
|
|
scheduler = _FakeScheduler()
|
|
|
|
result = await _handle_cron_add(
|
|
{
|
|
"name": "Drink",
|
|
"expression": "*/5 * * * *",
|
|
"payloadKind": AGENT_TURN_KIND,
|
|
"text": "drink water",
|
|
"agentId": "main",
|
|
"toolPolicy": {
|
|
"profile": "minimal",
|
|
"alsoAllow": ["memory_search"],
|
|
"deny": ["web_fetch"],
|
|
},
|
|
},
|
|
RpcContext(conn_id="test", cron_scheduler=scheduler),
|
|
)
|
|
|
|
assert scheduler.added["tool_policy"] == {
|
|
"profile": "minimal",
|
|
"also_allow": ["memory_search"],
|
|
"deny": ["web_fetch"],
|
|
}
|
|
assert result["toolPolicy"] == {
|
|
"profile": "minimal",
|
|
"allow": [],
|
|
"alsoAllow": ["memory_search"],
|
|
"deny": ["web_fetch"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_update_current_session_job_preserves_existing_binding() -> None:
|
|
current_job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.CURRENT,
|
|
session_key=SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
)
|
|
scheduler = _FakeScheduler(job=current_job)
|
|
|
|
result = await _handle_cron_update(
|
|
{
|
|
"id": "drink",
|
|
"text": "drink more water",
|
|
},
|
|
RpcContext(conn_id="test", cron_scheduler=scheduler),
|
|
)
|
|
|
|
assert scheduler.updated["session_target"] == SessionTarget.CURRENT
|
|
assert scheduler.updated["session_key"] == SESSION_KEY
|
|
assert scheduler.updated["origin_session_key"] == SESSION_KEY
|
|
assert result["sessionTarget"] == "current"
|
|
assert result["targetSessionKey"] == SESSION_KEY
|
|
assert result["originSessionKey"] == SESSION_KEY
|
|
assert result["prompt"] == "drink more water"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_update_job_round_trips_tool_policy() -> None:
|
|
current_job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "drink water", "agent_id": "main"},
|
|
)
|
|
scheduler = _FakeScheduler(job=current_job)
|
|
|
|
result = await _handle_cron_update(
|
|
{
|
|
"id": "drink",
|
|
"toolPolicy": {
|
|
"profile": "minimal",
|
|
"alsoAllow": ["memory_search"],
|
|
"deny": ["web_fetch"],
|
|
},
|
|
},
|
|
RpcContext(conn_id="test", cron_scheduler=scheduler),
|
|
)
|
|
|
|
assert scheduler.updated["tool_policy"] == {
|
|
"profile": "minimal",
|
|
"also_allow": ["memory_search"],
|
|
"deny": ["web_fetch"],
|
|
}
|
|
assert current_job.tool_policy == scheduler.updated["tool_policy"]
|
|
assert result["toolPolicy"]["alsoAllow"] == ["memory_search"]
|
|
assert result["toolPolicy"]["deny"] == ["web_fetch"]
|
|
|
|
|
|
def test_rpc_keeps_system_event_main_only() -> None:
|
|
params = {
|
|
"payloadKind": SYSTEM_EVENT_KIND,
|
|
"sessionTarget": "current",
|
|
"sessionKey": SESSION_KEY,
|
|
"text": "drink water",
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="system_event.*main"):
|
|
_build_payload(params, SessionTarget.CURRENT)
|
|
|
|
|
|
def test_rpc_rejects_agent_turn_on_main_session() -> None:
|
|
params = {
|
|
"payloadKind": AGENT_TURN_KIND,
|
|
"sessionTarget": "main",
|
|
"text": "drink water",
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="agent_turn.*main"):
|
|
_build_payload(params, SessionTarget.MAIN)
|
|
|
|
|
|
def test_rpc_defaults_non_main_payload_to_static_reminder() -> None:
|
|
kind, payload = _build_payload({"text": "drink water"}, SessionTarget.ISOLATED)
|
|
|
|
assert kind == REMINDER_KIND
|
|
assert payload == {
|
|
"kind": REMINDER_KIND,
|
|
"text": "drink water",
|
|
"agent_id": "main",
|
|
}
|
|
|
|
|
|
def test_rpc_rejects_reminder_on_main_session() -> None:
|
|
params = {
|
|
"payloadKind": REMINDER_KIND,
|
|
"sessionTarget": "main",
|
|
"text": "drink water",
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="reminder.*main"):
|
|
_build_payload(params, SessionTarget.MAIN)
|
|
|
|
|
|
def test_scheduler_current_session_resolves_bound_session_key() -> None:
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
session_target=SessionTarget.CURRENT,
|
|
session_key=SESSION_KEY,
|
|
)
|
|
|
|
assert _resolve_session_key(job) == SESSION_KEY
|
|
|
|
|
|
def test_scheduler_current_session_falls_back_to_origin_session_key() -> None:
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
session_target=SessionTarget.CURRENT,
|
|
origin_session_key=SESSION_KEY,
|
|
)
|
|
|
|
assert _resolve_session_key(job) == SESSION_KEY
|
|
|
|
|
|
def test_scheduler_current_session_requires_a_bound_key() -> None:
|
|
job = CronJob(id="drink", name="Drink", session_target=SessionTarget.CURRENT)
|
|
|
|
with pytest.raises(ValueError, match="CURRENT target requires"):
|
|
_resolve_session_key(job)
|
|
|
|
|
|
def test_delivery_skips_same_session_forward_for_current_session_jobs() -> None:
|
|
calls = []
|
|
|
|
async def forwarder(**kwargs) -> None:
|
|
calls.append(kwargs)
|
|
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
session_target=SessionTarget.CURRENT,
|
|
session_key=SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
)
|
|
chain = DeliveryChain(session_forwarder=forwarder)
|
|
|
|
status = asyncio.run(chain._forward_to_session(job, "done", SESSION_KEY))
|
|
|
|
assert status == "skipped"
|
|
assert calls == []
|
|
|
|
|
|
def test_delivery_forwards_isolated_job_results_to_origin_session() -> None:
|
|
calls = []
|
|
|
|
async def forwarder(**kwargs) -> None:
|
|
calls.append(kwargs)
|
|
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
session_target=SessionTarget.ISOLATED,
|
|
session_key=CRON_SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
)
|
|
chain = DeliveryChain(session_forwarder=forwarder)
|
|
|
|
status = asyncio.run(chain._forward_to_session(job, "done", CRON_SESSION_KEY))
|
|
|
|
assert status == "delivered"
|
|
assert calls == [
|
|
{
|
|
"origin_session_key": SESSION_KEY,
|
|
"text": "done",
|
|
"provenance": {
|
|
"kind": "cron",
|
|
"source_session_key": CRON_SESSION_KEY,
|
|
"source_tool": "cron:drink",
|
|
},
|
|
}
|
|
]
|
|
assert job.delivery.mode == DeliveryMode.NONE
|
|
|
|
|
|
def test_delivery_sanitizes_reply_directives_across_cron_outputs() -> None:
|
|
forward_calls = []
|
|
ws_events = []
|
|
|
|
async def forwarder(**kwargs) -> None:
|
|
forward_calls.append(kwargs)
|
|
|
|
async def ws_emitter(topic, event, payload) -> int:
|
|
ws_events.append((topic, event, payload))
|
|
return 1
|
|
|
|
cm = _RecordingChannelManager()
|
|
job = CronJob(
|
|
id="poem",
|
|
name="Poem",
|
|
session_target=SessionTarget.ISOLATED,
|
|
session_key=CRON_SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
delivery=DeliveryConfig(
|
|
mode=DeliveryMode.CHANNEL,
|
|
channel_name="feishu",
|
|
channel_id="oc_chat",
|
|
ws_topic="cron:poem",
|
|
),
|
|
)
|
|
chain = DeliveryChain(
|
|
channel_manager_ref=lambda: cm,
|
|
ws_emitter=ws_emitter,
|
|
session_forwarder=forwarder,
|
|
)
|
|
|
|
report = asyncio.run(
|
|
chain.deliver(
|
|
job,
|
|
result_text="[[reply_to_current]]Here is the scheduled reply",
|
|
success=True,
|
|
summary="[[reply_to_current]]Here is the scheduled reply",
|
|
session_key=CRON_SESSION_KEY,
|
|
)
|
|
)
|
|
|
|
assert report.channel_status == "delivered"
|
|
assert report.ws_status == "delivered"
|
|
assert report.session_status == "skipped"
|
|
assert cm.adapter.sent[0].content == "Here is the scheduled reply"
|
|
assert ws_events[0][2]["summary"] == "Here is the scheduled reply"
|
|
assert forward_calls == []
|
|
|
|
forward_job = CronJob(
|
|
id="forward-poem",
|
|
name="Forward Poem",
|
|
session_target=SessionTarget.ISOLATED,
|
|
session_key=CRON_SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
delivery=DeliveryConfig(mode=DeliveryMode.NONE),
|
|
)
|
|
forward_status = asyncio.run(
|
|
chain._forward_to_session(
|
|
forward_job,
|
|
"[[reply_to_current]]Here is the scheduled reply",
|
|
CRON_SESSION_KEY,
|
|
)
|
|
)
|
|
|
|
assert forward_status == "delivered"
|
|
assert forward_calls[0]["text"] == "Here is the scheduled reply"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_current_session_agent_run_uses_bound_session_transcript_without_forwarding() -> None:
|
|
session_manager = _FakeSessionManager()
|
|
turn_runner = _FakeTurnRunner(session_manager)
|
|
forward_calls = []
|
|
|
|
async def forwarder(**kwargs) -> None:
|
|
forward_calls.append(kwargs)
|
|
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.CURRENT,
|
|
session_key=SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
tool_policy={
|
|
"profile": "minimal",
|
|
"also_allow": ["memory_search", "exec_command"],
|
|
"deny": ["web_fetch"],
|
|
},
|
|
)
|
|
handler = make_agent_run_handler(
|
|
DeliveryChain(session_forwarder=forwarder),
|
|
turn_runner_ref=lambda: turn_runner,
|
|
session_manager_ref=lambda: session_manager,
|
|
)
|
|
|
|
result = await handler(job)
|
|
|
|
assert result.session_key == SESSION_KEY
|
|
assert result.summary == "drink logged"
|
|
assert result.delivery_status == "skipped|ws:skipped|fwd:skipped"
|
|
assert session_manager.created == [
|
|
{
|
|
"session_key": SESSION_KEY,
|
|
"agent_id": "main",
|
|
"display_name": "Cron: Drink",
|
|
}
|
|
]
|
|
assert turn_runner.calls[0]["session_key"] == SESSION_KEY
|
|
assert turn_runner.calls[0]["run_kind"] == "cron_turn"
|
|
assert turn_runner.calls[0]["input_provenance"] == {
|
|
"kind": "cron_job",
|
|
"job_id": "drink",
|
|
}
|
|
tool_context = turn_runner.calls[0]["tool_context"]
|
|
assert tool_context.allowed_tools == {"session_status"}
|
|
assert "exec_command" in tool_context.denied_tools
|
|
assert "web_fetch" in tool_context.denied_tools
|
|
assert await session_manager.read_transcript(SESSION_KEY) == [
|
|
{"role": "user", "content": "drink water"},
|
|
{"role": "assistant", "content": "drink logged"},
|
|
]
|
|
assert forward_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_run_handler_sanitizes_reply_directive_from_summary() -> None:
|
|
session_manager = _FakeSessionManager()
|
|
turn_runner = _FakeTurnRunner(
|
|
session_manager,
|
|
text="[[reply_to_current]]Here is the scheduled reply",
|
|
)
|
|
job = CronJob(
|
|
id="poem",
|
|
name="Poem",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "write poems", "agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
delivery=DeliveryConfig(best_effort=True),
|
|
)
|
|
handler = make_agent_run_handler(
|
|
DeliveryChain(),
|
|
turn_runner_ref=lambda: turn_runner,
|
|
session_manager_ref=lambda: session_manager,
|
|
)
|
|
|
|
result = await handler(job)
|
|
|
|
assert result.summary == "Here is the scheduled reply"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_current_webchat_agent_run_treats_same_session_transcript_as_delivery() -> None:
|
|
session_manager = _FakeSessionManager()
|
|
turn_runner = _FakeTurnRunner(session_manager)
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.CURRENT,
|
|
session_key=SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
delivery=DeliveryConfig(
|
|
mode=DeliveryMode.ORIGIN,
|
|
channel_name="webchat",
|
|
channel_id=f"webchat:{SESSION_KEY}",
|
|
originating_reply_target=ReplyTargetSnapshot(
|
|
channel_name="webchat",
|
|
channel_type="webchat",
|
|
to=f"webchat:{SESSION_KEY}",
|
|
),
|
|
),
|
|
)
|
|
handler = make_agent_run_handler(
|
|
DeliveryChain(channel_manager_ref=lambda: _FakeChannelManager()),
|
|
turn_runner_ref=lambda: turn_runner,
|
|
session_manager_ref=lambda: session_manager,
|
|
)
|
|
|
|
result = await handler(job)
|
|
|
|
assert result.session_key == SESSION_KEY
|
|
assert result.summary == "drink logged"
|
|
assert result.delivery_status == "delivered|ws:skipped|fwd:skipped"
|
|
assert await session_manager.read_transcript(SESSION_KEY) == [
|
|
{"role": "user", "content": "drink water"},
|
|
{"role": "assistant", "content": "drink logged"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_static_webchat_reminder_delivers_without_turn_runner() -> None:
|
|
forward_calls = []
|
|
|
|
async def forwarder(**kwargs) -> None:
|
|
forward_calls.append(kwargs)
|
|
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="static_message",
|
|
payload={"kind": REMINDER_KIND, "text": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
origin_session_key=SESSION_KEY,
|
|
delivery=DeliveryConfig(
|
|
mode=DeliveryMode.ORIGIN,
|
|
channel_name="webchat",
|
|
channel_id=f"webchat:{SESSION_KEY}",
|
|
originating_reply_target=ReplyTargetSnapshot(
|
|
channel_name="webchat",
|
|
channel_type="webchat",
|
|
to=f"webchat:{SESSION_KEY}",
|
|
),
|
|
),
|
|
)
|
|
handler = make_static_message_handler(
|
|
DeliveryChain(
|
|
channel_manager_ref=lambda: _FakeChannelManager(),
|
|
session_forwarder=forwarder,
|
|
)
|
|
)
|
|
|
|
result = await handler(job)
|
|
|
|
assert result.summary == "drink water"
|
|
assert result.delivery_status == "delivered|ws:skipped|fwd:skipped"
|
|
assert result.session_key.startswith("cron:drink:run:")
|
|
assert forward_calls == [
|
|
{
|
|
"origin_session_key": SESSION_KEY,
|
|
"text": "drink water",
|
|
"provenance": {
|
|
"kind": "cron",
|
|
"source_session_key": result.session_key,
|
|
"source_tool": "cron:drink",
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_static_reminder_delivery_failure_fails_job_by_default() -> None:
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="static_message",
|
|
payload={"kind": REMINDER_KIND, "text": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
delivery=DeliveryConfig(
|
|
mode=DeliveryMode.CHANNEL,
|
|
channel_name="feishu",
|
|
channel_id="chat-1",
|
|
),
|
|
)
|
|
handler = make_static_message_handler(
|
|
DeliveryChain(channel_manager_ref=lambda: _FakeChannelManager())
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="delivery failed"):
|
|
await handler(job)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_static_reminder_best_effort_delivery_failure_does_not_fail_job() -> None:
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="static_message",
|
|
payload={"kind": REMINDER_KIND, "text": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
delivery=DeliveryConfig(
|
|
mode=DeliveryMode.CHANNEL,
|
|
channel_name="feishu",
|
|
channel_id="chat-1",
|
|
best_effort=True,
|
|
),
|
|
)
|
|
handler = make_static_message_handler(
|
|
DeliveryChain(channel_manager_ref=lambda: _FakeChannelManager())
|
|
)
|
|
|
|
result = await handler(job)
|
|
|
|
assert result.delivery_status == "delivery_failed|ws:skipped|fwd:skipped"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_run_task_runtime_context_exhaustion_delivers_controlled_message() -> None:
|
|
raw_error = (
|
|
"Context overflow is in the current turn's recent tool calls or "
|
|
"reasoning tail; history compaction cannot reduce it."
|
|
)
|
|
task_runtime = _FakeTaskRuntime(
|
|
SimpleNamespace(
|
|
status="failed",
|
|
terminal_reason="error",
|
|
error_class="current_turn_context_exhausted",
|
|
error_message=raw_error,
|
|
)
|
|
)
|
|
delivery_chain = _RecordingDeliveryChain()
|
|
job = CronJob(
|
|
id="research",
|
|
name="Research",
|
|
handler_key="agent_run",
|
|
payload={
|
|
"kind": AGENT_TURN_KIND,
|
|
"task": "research three agent papers",
|
|
"agent_id": "main",
|
|
},
|
|
session_target=SessionTarget.ISOLATED,
|
|
)
|
|
handler = make_agent_run_handler(
|
|
delivery_chain, # type: ignore[arg-type]
|
|
task_runtime_ref=lambda: task_runtime,
|
|
session_manager_ref=lambda: _FakeSessionManager(),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await handler(job)
|
|
|
|
assert raw_error not in str(exc_info.value)
|
|
assert "current_turn_context_exhausted" not in str(exc_info.value)
|
|
assert delivery_chain.deliveries
|
|
delivered_text = delivery_chain.deliveries[-1]["result_text"]
|
|
assert "too large" in delivered_text.lower()
|
|
assert raw_error not in delivered_text
|
|
assert "current_turn_context_exhausted" not in delivered_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_run_runtime_context_exception_delivers_controlled_message() -> None:
|
|
raw_error = (
|
|
"Context overflow is in the current turn's recent tool calls or "
|
|
"reasoning tail; history compaction cannot reduce it."
|
|
)
|
|
|
|
class RaisingTaskRuntime:
|
|
async def enqueue(self, *args, **kwargs):
|
|
raise RuntimeError(raw_error)
|
|
|
|
delivery_chain = _RecordingDeliveryChain()
|
|
job = CronJob(
|
|
id="research",
|
|
name="Research",
|
|
handler_key="agent_run",
|
|
payload={
|
|
"kind": AGENT_TURN_KIND,
|
|
"task": "research three agent papers",
|
|
"agent_id": "main",
|
|
},
|
|
session_target=SessionTarget.ISOLATED,
|
|
)
|
|
handler = make_agent_run_handler(
|
|
delivery_chain, # type: ignore[arg-type]
|
|
task_runtime_ref=lambda: RaisingTaskRuntime(),
|
|
session_manager_ref=lambda: _FakeSessionManager(),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await handler(job)
|
|
|
|
assert raw_error not in str(exc_info.value)
|
|
assert "history compaction cannot reduce it" not in str(exc_info.value)
|
|
assert delivery_chain.deliveries
|
|
delivered_text = delivery_chain.deliveries[-1]["result_text"]
|
|
assert "too large" in delivered_text.lower()
|
|
assert raw_error not in delivered_text
|
|
assert "history compaction cannot reduce it" not in delivered_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_owner_current_session_agent_run_uses_owner_tool_boundary() -> None:
|
|
session_manager = _FakeSessionManager()
|
|
turn_runner = _FakeTurnRunner(session_manager)
|
|
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.CURRENT,
|
|
session_key=SESSION_KEY,
|
|
origin_session_key=SESSION_KEY,
|
|
creator_is_owner=True,
|
|
tool_policy={
|
|
"profile": "minimal",
|
|
"also_allow": ["memory_search", "exec_command"],
|
|
"deny": ["web_fetch"],
|
|
},
|
|
)
|
|
handler = make_agent_run_handler(
|
|
DeliveryChain(session_forwarder=None),
|
|
turn_runner_ref=lambda: turn_runner,
|
|
session_manager_ref=lambda: session_manager,
|
|
)
|
|
|
|
await handler(job)
|
|
|
|
tool_context = turn_runner.calls[0]["tool_context"]
|
|
assert tool_context.is_owner is True
|
|
assert tool_context.allowed_tools is None
|
|
assert tool_context.tool_policy == job.tool_policy
|
|
assert "exec_command" not in tool_context.denied_tools
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_run_delivery_failure_fails_job_by_default() -> None:
|
|
session_manager = _FakeSessionManager()
|
|
turn_runner = _FakeTurnRunner(session_manager)
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
delivery=DeliveryConfig(
|
|
mode=DeliveryMode.CHANNEL,
|
|
channel_name="feishu",
|
|
channel_id="chat-1",
|
|
),
|
|
)
|
|
handler = make_agent_run_handler(
|
|
DeliveryChain(channel_manager_ref=lambda: _FakeChannelManager()),
|
|
turn_runner_ref=lambda: turn_runner,
|
|
session_manager_ref=lambda: session_manager,
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="delivery failed"):
|
|
await handler(job)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_run_best_effort_delivery_failure_does_not_fail_job() -> None:
|
|
session_manager = _FakeSessionManager()
|
|
turn_runner = _FakeTurnRunner(session_manager)
|
|
job = CronJob(
|
|
id="drink",
|
|
name="Drink",
|
|
handler_key="agent_run",
|
|
payload={"kind": AGENT_TURN_KIND, "task": "drink water", "agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
delivery=DeliveryConfig(
|
|
mode=DeliveryMode.CHANNEL,
|
|
channel_name="feishu",
|
|
channel_id="chat-1",
|
|
best_effort=True,
|
|
),
|
|
)
|
|
handler = make_agent_run_handler(
|
|
DeliveryChain(channel_manager_ref=lambda: _FakeChannelManager()),
|
|
turn_runner_ref=lambda: turn_runner,
|
|
session_manager_ref=lambda: session_manager,
|
|
)
|
|
|
|
result = await handler(job)
|
|
|
|
assert result.delivery_status == "delivery_failed|ws:skipped|fwd:skipped"
|