feat(scheduler): add scheduled jobs with channel-aware delivery

Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns,
and admin CRUD/edit UI. Route proactive reminders via the originating chat
channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and
PG-compatible polling, and fix chat UI to show each scheduled reminder separately.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-26 15:56:10 +08:00
parent 5336f8835f
commit 47813f6a94
45 changed files with 4956 additions and 43 deletions

View file

@ -0,0 +1,114 @@
from __future__ import annotations
import hashlib
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fastapi.testclient import TestClient
from interfaces.http.fastapi_app import create_app
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class AdminScheduledJobsApiTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched_admin.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t1 = self.store.create_tenant("TeamA")
self.tenant_id = str(t1["id"])
admin = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
is_active=True,
)
self.user_id = str(admin["id"])
self.client = TestClient(create_app())
self.client.post("/admin/api/auth/bootstrap", json={})
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def _login(self) -> str:
resp = self.client.post(
"/admin/api/auth/login",
json={
"tenant_id": self.tenant_id,
"username": "administrator",
"password": "test-admin-pass",
"purpose": "console",
},
)
data = resp.json()
self.assertTrue(data.get("ok"), data)
return str(data.get("token") or "")
def test_scheduled_jobs_crud(self) -> None:
token = self._login()
headers = {"authorization": f"Bearer {token}"}
once_at = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
create = self.client.post(
"/admin/api/scheduled-jobs",
headers=headers,
json={
"name": "Test job",
"prompt_text": "Say hello",
"schedule_kind": "once",
"schedule_expr": once_at,
"specialist": "generalist",
},
)
self.assertEqual(create.status_code, 200, create.text)
body = create.json()
self.assertTrue(body.get("ok"), body)
job_id = str((body.get("job") or {}).get("id") or "")
self.assertTrue(job_id)
listed = self.client.get("/admin/api/scheduled-jobs", headers=headers)
self.assertTrue(listed.json().get("ok"))
self.assertGreaterEqual(len(listed.json().get("items") or []), 1)
pause = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/pause", headers=headers, json={})
self.assertTrue(pause.json().get("ok"))
resume = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/resume", headers=headers, json={})
self.assertTrue(resume.json().get("ok"))
runs = self.client.get(f"/admin/api/scheduled-jobs/{job_id}/runs", headers=headers)
self.assertTrue(runs.json().get("ok"))
patch = self.client.patch(
f"/admin/api/scheduled-jobs/{job_id}",
headers=headers,
json={
"name": "Updated job",
"schedule_kind": "interval",
"schedule_expr": "7200",
"prompt_text": "Stand up hourly",
"specialist": "generalist",
},
)
self.assertEqual(patch.status_code, 200, patch.text)
patched = patch.json()
self.assertTrue(patched.get("ok"), patched)
job = patched.get("job") or {}
self.assertEqual(str(job.get("name") or ""), "Updated job")
self.assertEqual(str(job.get("schedule_kind") or ""), "interval")
self.assertEqual(str(job.get("schedule_expr") or ""), "7200")
self.assertEqual(str(job.get("prompt_text") or ""), "Stand up hourly")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from interfaces.gateway.http_adapter import dispatch_gateway_http_method
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class GatewayCronServiceTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "cron.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("default")
self.tenant_id = str(t["id"])
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_cron_add_and_list(self) -> None:
once_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
add = dispatch_gateway_http_method(
"cron.add",
{
"name": "gw job",
"schedule": once_at,
"schedule_kind": "once",
"tenant_id": self.tenant_id,
"prompt": "hello",
},
)
self.assertTrue(add.get("ok"), add)
lst = dispatch_gateway_http_method("cron.list", {"tenant_id": self.tenant_id})
self.assertTrue(lst.get("ok"), lst)
payload = lst.get("payload") or {}
self.assertGreaterEqual(len(payload.get("items") or []), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,20 @@
from __future__ import annotations
import unittest
from runtime.application.gateway.inbound_service import _parse_schedule_duration_seconds
class ScheduleDurationParseTests(unittest.TestCase):
def test_minutes(self) -> None:
self.assertEqual(_parse_schedule_duration_seconds("5分钟"), 300)
def test_hours(self) -> None:
self.assertEqual(_parse_schedule_duration_seconds("1小时"), 3600)
def test_seconds(self) -> None:
self.assertEqual(_parse_schedule_duration_seconds("120秒"), 120)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,59 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool, schedule_list_tool
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class ScheduleToolsTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "tools.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_schedule_create_and_list(self) -> None:
create = schedule_create_tool()
out = create.handler(
{
"tenant_id": self.tenant_id,
"owner_user_id": self.user_id,
"name": "Reminder",
"prompt_text": "Check inbox",
"schedule_kind": "interval",
"schedule_expr": "900",
"specialist": "ops",
"interaction_mode": "expert",
}
)
self.assertTrue(out.get("ok"), out)
lst = schedule_list_tool()
listed = lst.handler({"tenant_id": self.tenant_id})
self.assertTrue(listed.get("ok"))
self.assertEqual(len(listed.get("items") or []), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,133 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from runtime.scheduler.worker_turn import resolve_scheduled_outbound_text
from svc.persistence.sqlite_store import SqliteStore
class EphemeralUserContextTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "ctx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
sess = self.store.create_session("test")
self.session_id = str(sess.id)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_build_model_context_injects_ephemeral_user_text(self) -> None:
from runtime.direct_loop import _build_model_context
from svc.llm.chat_models import RuleBasedChatModel
msgs = _build_model_context(
store=self.store,
session_id=self.session_id,
max_messages=20,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="zh",
memory_context=None,
trace_id=None,
parent_span_id=None,
user_text="【定时主动提醒】站起来",
active_turn_uuid="turn-sched-1",
)
roles = [str(m.get("role") or "") for m in msgs]
self.assertIn("user", roles)
self.assertEqual(str(msgs[-1].get("content") or ""), "【定时主动提醒】站起来")
rows = self.store.get_messages(session_id=self.session_id, limit=10)
self.assertEqual(len(rows), 0)
class ScheduledOutboundFallbackTests(unittest.TestCase):
def test_uses_prompt_when_model_reply_empty(self) -> None:
text = resolve_scheduled_outbound_text(
payload={"prompt_text": "站起来活动一下"},
reply_text="",
)
self.assertIn("站起来活动一下", text)
self.assertTrue(text.startswith("⏰"))
def test_keeps_model_reply_when_present(self) -> None:
text = resolve_scheduled_outbound_text(
payload={"prompt_text": "站起来活动一下"},
reply_text="该起来啦!",
)
self.assertEqual(text, "该起来啦!")
class ScheduledAssistantPersistTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "persist.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
sess = self.store.create_session("sched")
self.session_id = str(sess.id)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_finalize_persists_assistant_when_missing(self) -> None:
from runtime.scheduler.worker_turn import finalize_scheduled_turn_success
task = MagicMock()
task.task_type = "scheduled_turn"
payload = {
"tenant_id": "t1",
"job_id": "",
"run_id_scheduled": "",
"session_id": self.session_id,
"prompt_text": "休息",
"delivery": {"weixin": {"enabled": False}},
"resolved_channel": "admin_chat",
}
with unittest.mock.patch(
"runtime.scheduler.channel_delivery.deliver_scheduled_reply",
return_value={"ok": True, "skipped": True},
):
finalize_scheduled_turn_success(
store=self.store,
task=task,
payload={
**payload,
"session_id": self.session_id,
},
base_result={"reply_text": "", "turn_uuid": "tu-1"},
)
rows = self.store.get_messages(session_id=self.session_id, limit=10)
self.assertEqual(len(rows), 1)
self.assertEqual(str(rows[0].role), "assistant")
self.assertEqual(str(getattr(rows[0], "event_type", "") or ""), "assistant_text")
self.assertIn("休息", str(rows[0].content or ""))
def test_updates_empty_assistant_for_same_turn(self) -> None:
from runtime.scheduler.worker_turn import _persist_scheduled_assistant_reply
self.store.add_message(
session_id=self.session_id,
role="assistant",
content="",
turn_uuid="tu-empty",
event_type="assistant_text",
)
_persist_scheduled_assistant_reply(
self.store,
session_id=self.session_id,
turn_uuid="tu-empty",
reply_text="⏰ 提醒:站起来",
)
rows = self.store.get_messages(session_id=self.session_id, limit=10)
self.assertEqual(len(rows), 1)
self.assertIn("站起来", str(rows[0].content or ""))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,130 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
from svc.persistence.sqlite_store import SqliteStore
class SchedulerExpressionsTests(unittest.TestCase):
def test_normalize_schedule_kind(self) -> None:
self.assertEqual(normalize_schedule_kind("CRON"), "cron")
self.assertEqual(normalize_schedule_kind("bad"), "cron")
def test_interval_next_run(self) -> None:
base = datetime(2026, 6, 26, 10, 0, 0, tzinfo=timezone.utc)
nxt = compute_next_run_at(
schedule_kind="interval",
schedule_expr="120",
timezone_name="Asia/Shanghai",
from_dt=base,
)
self.assertEqual(nxt, (base + timedelta(seconds=120)).isoformat())
def test_once_future(self) -> None:
future = datetime.now(timezone.utc) + timedelta(hours=2)
nxt = compute_next_run_at(
schedule_kind="once",
schedule_expr=future.isoformat(),
timezone_name="Asia/Shanghai",
)
self.assertIsNotNone(nxt)
class ScheduledJobStoreTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
def tearDown(self) -> None:
self._tmp.cleanup()
def test_row_to_job_dict_row(self) -> None:
from svc.persistence.scheduled_job_store import _row_to_job
row = {
"id": "j1",
"tenant_id": self.tenant_id,
"name": "Test",
"description": "",
"status": "active",
"schedule_kind": "interval",
"schedule_expr": "300",
"timezone": "Asia/Shanghai",
"prompt_text": "ping",
"interaction_mode": "expert",
"specialist": "generalist",
"lang": "zh",
"delivery_json": "{}",
"source_session_id": None,
"created_by_user_id": self.user_id,
"source": "chat",
"next_run_at": "2026-06-26T10:00:00+00:00",
"last_run_at": None,
"last_run_status": "",
"created_at": "2026-06-26T09:00:00+00:00",
"updated_at": "2026-06-26T09:00:00+00:00",
}
job = _row_to_job(row)
self.assertEqual(job.id, "j1")
self.assertEqual(job.name, "Test")
job = self.store.scheduled_job_create(
tenant_id=self.tenant_id,
name="Daily report",
prompt_text="Summarize alarms",
schedule_kind="interval",
schedule_expr="3600",
created_by_user_id=self.user_id,
)
self.assertEqual(job.status, "active")
self.assertTrue(job.next_run_at)
rows = self.store.scheduled_job_list(tenant_id=self.tenant_id)
self.assertEqual(len(rows), 1)
ok = self.store.scheduled_job_set_status(
tenant_id=self.tenant_id,
job_id=job.id,
status="paused",
)
self.assertTrue(ok)
due = self.store.scheduled_job_list_due(limit=10)
self.assertEqual(due, [])
def test_run_record(self) -> None:
job = self.store.scheduled_job_create(
tenant_id=self.tenant_id,
name="Once",
prompt_text="Ping",
schedule_kind="once",
schedule_expr=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(),
created_by_user_id=self.user_id,
)
run = self.store.scheduled_job_run_create(job_id=job.id, tenant_id=self.tenant_id)
self.assertEqual(run.status, "queued")
updated = self.store.scheduled_job_run_update(
run_id=run.id,
tenant_id=self.tenant_id,
patch={"status": "success", "reply_text": "ok"},
)
self.assertIsNotNone(updated)
self.assertEqual(str(updated.reply_text), "ok")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,180 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
from runtime.scheduler.cron_service import build_delivery_for_session
from runtime.scheduler.session_resolver import resolve_scheduled_session
from runtime.scheduler.turn_text import build_scheduled_turn_instruction
from svc.persistence.sqlite_store import SqliteStore
class ScheduledTurnTextTests(unittest.TestCase):
def test_instruction_is_internal_not_user_facing(self) -> None:
text = build_scheduled_turn_instruction(
prompt_text="站起来活动一下",
mode="scheduled",
lang="zh",
)
self.assertIn("提醒意图", text)
self.assertNotIn("[scheduled:", text)
class ScheduledSessionResolverTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
admin = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.admin_id = str(admin["id"])
sess = self.store.create_session("WeChat session")
self.session_id = str(sess.id)
self.store.ensure_ui_session_owner(
session_id=self.session_id,
tenant_id=self.tenant_id,
user_id=self.admin_id,
)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "weixin", "weixin-default", "wx-user-123", "wx-user-123", self.session_id),
)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_source_session_preserves_weixin_delivery_target(self) -> None:
job = MagicMock()
job.tenant_id = self.tenant_id
job.delivery_json = '{"weixin":{"enabled":true}}'
job.source_session_id = self.session_id
job.name = "rest"
resolved = resolve_scheduled_session(store=self.store, job=job, created_by_user_id=self.admin_id)
self.assertEqual(resolved.channel, "weixin")
self.assertEqual(resolved.external_chat_id, "wx-user-123")
class ScheduledDeliveryChannelTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
def tearDown(self) -> None:
self._tmp.cleanup()
def test_whatsapp_session_disables_weixin_delivery(self) -> None:
sess = self.store.create_session("WA session")
session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "whatsapp", "wa-default", "15551234567@s.whatsapp.net", "15551234567", session_id),
)
delivery = build_delivery_for_session(
self.store,
tenant_id=self.tenant_id,
session_id=session_id,
)
self.assertTrue(delivery["whatsapp"]["enabled"])
self.assertEqual(delivery["whatsapp"]["chat_id"], "15551234567@s.whatsapp.net")
self.assertFalse(delivery["weixin"]["enabled"])
def test_weixin_session_disables_whatsapp_delivery(self) -> None:
sess = self.store.create_session("WX session")
session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "weixin", "weixin-default", "wx-user-9", "wx-user-9", session_id),
)
delivery = build_delivery_for_session(
self.store,
tenant_id=self.tenant_id,
session_id=session_id,
)
self.assertTrue(delivery["weixin"]["enabled"])
self.assertFalse(delivery["whatsapp"]["enabled"])
class ScheduledDeliveryTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
def tearDown(self) -> None:
self._tmp.cleanup()
@patch("runtime.scheduler.channel_delivery.enqueue_weixin_reply")
def test_deliver_uses_stored_context_token(self, mock_enqueue: MagicMock) -> None:
mock_enqueue.return_value = {"ok": True, "channel": "wechat", "queued": True}
self.store.set_channel_context_token(
tenant_id=self.tenant_id,
channel="wechat",
account_id="real-acct",
external_chat_id="wx-user-123",
context_token="ctx-abc",
)
sess = self.store.create_session("wx-deliver")
session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "wechat", "real-acct", "wx-user-123", "wx-user-123", session_id),
)
out = deliver_scheduled_reply(
self.store,
tenant_id=self.tenant_id,
reply_text="该起来活动啦",
delivery_json='{"weixin":{"enabled":true}}',
resolved_channel="wechat",
resolved_chat_id="wx-user-123",
resolved_account_id="weixin-default",
session_id=session_id,
)
self.assertTrue(out.get("ok"))
mock_enqueue.assert_called_once()
kwargs = mock_enqueue.call_args.kwargs
self.assertEqual(kwargs.get("context_token"), "ctx-abc")
self.assertEqual(kwargs.get("chat_id"), "wx-user-123")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,116 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from runtime.scheduler.session_resolver import resolve_scheduled_viewer_username
from runtime.scheduler.service import enqueue_scheduled_job_run
from svc.persistence.sqlite_store import SqliteStore
class ResolveScheduledViewerUsernameTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
self.admin = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.member = self.store.create_user_account(
tenant_id=self.tenant_id,
username="alice",
display_name="Alice",
role="member",
password_hash="x",
is_active=True,
)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_weixin_uses_administrator_pool(self) -> None:
uname = resolve_scheduled_viewer_username(
self.store,
tenant_id=self.tenant_id,
user_id=str(self.member["id"]),
channel="weixin",
)
self.assertEqual(uname, "administrator")
def test_admin_chat_uses_job_owner_username(self) -> None:
uname = resolve_scheduled_viewer_username(
self.store,
tenant_id=self.tenant_id,
user_id=str(self.member["id"]),
channel="admin_chat",
)
self.assertEqual(uname, "alice")
def test_enqueue_payload_includes_viewer_username_for_weixin(self) -> None:
job = MagicMock()
job.tenant_id = self.tenant_id
job.id = "job-weixin"
job.next_run_at = "2026-06-26T10:00:00+00:00"
job.delivery_json = '{"weixin":{"enabled":true}}'
job.prompt_text = "stretch"
job.lang = "zh"
job.interaction_mode = "expert"
job.specialist = "generalist"
job.created_by_user_id = str(self.member["id"])
job.schedule_kind = "interval"
run = MagicMock()
run.id = "run-1"
self.store.scheduled_job_run_create = MagicMock(return_value=run) # type: ignore[method-assign]
self.store.scheduled_job_run_update = MagicMock() # type: ignore[method-assign]
self.store.scheduled_job_reserve_next_run = MagicMock() # type: ignore[method-assign]
captured: dict[str, object] = {}
def _capture_create(**kwargs: object) -> MagicMock:
captured.update(kwargs)
return MagicMock(id="task-1")
self.store.oclaw_task_create = _capture_create # type: ignore[method-assign]
from runtime.scheduler import service as sched_service
from runtime.scheduler.session_resolver import ResolvedSession
resolved = ResolvedSession(
session_id="sess-1",
tenant_id=self.tenant_id,
user_id=str(self.admin["id"]),
channel="weixin",
account_id="weixin-default",
external_chat_id="wx-user-1",
external_user_id="wx-user-1",
is_group=False,
)
original_resolve = sched_service.resolve_scheduled_session
original_worker = sched_service.ensure_worker_started
try:
sched_service.resolve_scheduled_session = MagicMock(return_value=resolved)
sched_service.ensure_worker_started = MagicMock(return_value="worker-1")
enqueue_scheduled_job_run(self.store, job=job, mode="scheduled")
finally:
sched_service.resolve_scheduled_session = original_resolve
sched_service.ensure_worker_started = original_worker
payload = captured.get("payload")
self.assertIsInstance(payload, dict)
assert isinstance(payload, dict)
self.assertEqual(payload.get("viewer_username"), "administrator")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,93 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from runtime.tools.context_inject import enrich_tool_arguments
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class ToolContextInjectTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "ctx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
sess = self.store.create_session_for_user(
title="wx",
tenant_id=self.tenant_id,
user_id=self.user_id,
)
self.session_id = str(sess.id)
self.store.ensure_ui_session_owner(
session_id=self.session_id,
tenant_id=self.tenant_id,
user_id=self.user_id,
)
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_enrich_from_session_owner(self) -> None:
out = enrich_tool_arguments(
store=self.store,
session_id=self.session_id,
tool_name="schedule_create",
arguments={"name": "x"},
)
self.assertEqual(out.get("tenant_id"), self.tenant_id)
self.assertEqual(out.get("owner_user_id"), self.user_id)
self.assertEqual(out.get("session_id"), self.session_id)
def test_schedule_create_strips_llm_user_id(self) -> None:
tool = schedule_create_tool()
res = tool.handler(
{
"session_id": self.session_id,
"user_id": "wx_fake_external_id",
"name": "Rest",
"prompt_text": "提醒休息",
"schedule_kind": "interval",
"schedule_expr": "300",
}
)
self.assertTrue(res.get("ok"), res)
def test_tool_runtime_filters_user_id_for_schedule_schema(self) -> None:
from runtime.tools.tool_validation import filter_arguments_to_schema
tool = schedule_create_tool()
filtered = filter_arguments_to_schema(
tool.parameters,
{
"user_id": "bad",
"name": "x",
"prompt_text": "y",
"schedule_kind": "interval",
"schedule_expr": "60",
},
)
self.assertNotIn("user_id", filtered)
self.assertIn("name", filtered)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,77 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
from runtime.tools.base import ToolRegistry
from svc.llm.transports.base import LLMToolCall
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class ToolRuntimeScheduleValidationTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "rt.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
sess = self.store.create_session_for_user(
title="wx",
tenant_id=self.tenant_id,
user_id=self.user_id,
)
self.session_id = str(sess.id)
self.store.ensure_ui_session_owner(
session_id=self.session_id,
tenant_id=self.tenant_id,
user_id=self.user_id,
)
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_execute_schedule_create_with_hallucinated_user_id(self) -> None:
reg = ToolRegistry([schedule_create_tool()])
ex = ToolExecutor()
ctx = ToolExecutionContext(
store=self.store,
tools=reg,
session_id=self.session_id,
path_policy_tenant_id=self.tenant_id,
path_policy_user_id=self.user_id,
)
tc = LLMToolCall(
id="call1",
name="schedule_create",
arguments={
"user_id": "wechat_external_123",
"name": "Break",
"prompt_text": "Stand up",
"schedule_kind": "interval",
"schedule_expr": "300",
},
)
result, _dur = ex._execute_tool(ctx, tc)
self.assertTrue(result.get("ok"), result)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,148 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from runtime.scheduler.channel_delivery import enqueue_weixin_reply
from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
from svc.persistence.sqlite_store import SqliteStore
class WeixinBridgePollTests(unittest.TestCase):
def test_poll_accepts_wechat_channel_alias(self) -> None:
from interfaces.http.weixin_ilink_api import _BRIDGE
_BRIDGE._events.clear()
_BRIDGE._seq = 0
_BRIDGE.enqueue_reply(
token="",
channel="weixin",
account_id="acct-1",
chat_id="user-1",
text="hello",
context_token="ctx-1",
)
msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="acct-1")
self.assertEqual(len(msgs), 1)
self.assertEqual(str(msgs[0].get("text") or ""), "hello")
def test_poll_accepts_legacy_default_account_id(self) -> None:
from interfaces.http.weixin_ilink_api import _BRIDGE
_BRIDGE._events.clear()
_BRIDGE._seq = 0
_BRIDGE.enqueue_reply(
token="",
channel="wechat",
account_id="weixin-default",
chat_id="user-1",
text="hello",
context_token="ctx-1",
)
msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="real-acct")
self.assertEqual(len(msgs), 1)
class WeixinDeliveryTargetTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "wx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
sess = self.store.create_session("wx")
self.session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "wechat", "real-acct-9", "wx-user-9", "wx-user-9", self.session_id),
)
self.store.set_channel_context_token(
tenant_id=self.tenant_id,
channel="wechat",
account_id="real-acct-9",
external_chat_id="wx-user-9",
context_token="ctx-xyz",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_resolve_account_and_token_from_session(self) -> None:
target = resolve_weixin_delivery_target(
self.store,
tenant_id=self.tenant_id,
session_id=self.session_id,
delivery={"weixin": {"enabled": True}},
resolved_channel="wechat",
resolved_chat_id="",
resolved_account_id="weixin-default",
)
self.assertEqual(target["account_id"], "real-acct-9")
self.assertEqual(target["chat_id"], "wx-user-9")
self.assertEqual(target["context_token"], "ctx-xyz")
class EnqueueWeixinReplyTests(unittest.TestCase):
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="7")
def test_requires_context_token(self, _mock_enqueue: MagicMock) -> None:
out = enqueue_weixin_reply(
channel="wechat",
account_id="acct",
chat_id="user",
text="hi",
context_token="",
)
self.assertFalse(out.get("ok"))
self.assertEqual(out.get("error"), "context_token_missing")
_mock_enqueue.assert_not_called()
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="8")
def test_queues_with_context_token(self, mock_enqueue: MagicMock) -> None:
out = enqueue_weixin_reply(
channel="wechat",
account_id="acct",
chat_id="user",
text="hi",
context_token="ctx-1",
)
self.assertTrue(out.get("ok"))
self.assertTrue(out.get("queued"))
mock_enqueue.assert_called_once()
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="9")
def test_persists_durable_outbound_when_store_available(self, mock_enqueue: MagicMock) -> None:
tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
db = Path(tmp.name) / "wx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(db)
store = SqliteStore(str(db))
try:
out = enqueue_weixin_reply(
channel="wechat",
account_id="acct",
chat_id="user",
text="hi",
context_token="ctx-1",
store=store,
tenant_id="t1",
)
self.assertTrue(out.get("ok"))
self.assertTrue(out.get("durable"))
pending = store.list_pending_weixin_outbound_messages(account_id="acct", limit=10)
self.assertEqual(len(pending), 1)
self.assertEqual(str(pending[0].get("context_token") or ""), "ctx-1")
mock_enqueue.assert_called_once()
finally:
tmp.cleanup()
if __name__ == "__main__":
unittest.main()