agent 在回合输出里写 goal-convert 块,worker 在轮次边界 排成 /goal 自后续消息、走已有 followup 转换路径把本会话 转成 goal;写 automation-create 块,worker 在轮次边界 创建自动化定义(权限 create_task、配额整轮门、创建人跟 父任务走、name 缺失自动生成、首次时刻已过去单块拒绝、 bind heartbeat 可绑回本会话)。 automations 新增 first_run_at / run_once:首次时刻只管 首次、之后按间隔算;只跑一次跑过后 is_due / due / claim 三处都不再到期(SQL 到期条件与 Python 口径共用同一 _DUE_SQL);API 创建与更新同步接受这两个字段。 刻意不向 LLM 自动注入工具指南:避免 prompt 臃肿,格式 只写进 docs/marker-tools.md 与 README,需要时由用户在 会话里手动告诉 agent;服务端不按关键词再猜意图。 单测 cloud/tests/test_session_goal_automation.py 12 项, 相关回归 218 项全过。
335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""会话内 goal / 自动化标记工具单测:LLM 自己判断意图那条路。
|
||
|
||
不跑真实模型、不联网:
|
||
- 解析层是纯函数(goal-convert、automation-create、
|
||
first_run_at 的 ISO / 无时区 / 毫秒解析与非法跳过);
|
||
- automations 层用内存 SQLite,测 first_run_at 到期与
|
||
run_once 跑过后不再到期(Python 口径与 SQL due/claim
|
||
口径一致);
|
||
- worker 投递用 init_db 建的临时库 + 最小任务行,直接调
|
||
_deliver_marker_tools,不走 run_one 全量流程。
|
||
|
||
注意:本文件不测“LLM 真的会写块”——那是模型行为,
|
||
且本批刻意不向 LLM 自动注入工具指南(避免 prompt
|
||
臃肿);这里测的是块写出来后系统一定按约定执行,且
|
||
非法块一定不静默变调度。工具格式见 docs/marker-tools.md,
|
||
需要时由用户在会话里手动告诉 agent。
|
||
"""
|
||
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
try:
|
||
from cloud.automations import (
|
||
claim_due_automation,
|
||
create_automation,
|
||
due_automations,
|
||
get_automation,
|
||
update_automation,
|
||
)
|
||
from cloud.marker_tools import (
|
||
extract_automation_creates,
|
||
extract_goal_converts,
|
||
parse_first_run_at,
|
||
)
|
||
from cloud.server import init_db
|
||
from cloud.worker import Worker
|
||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from cloud.automations import (
|
||
claim_due_automation,
|
||
create_automation,
|
||
due_automations,
|
||
get_automation,
|
||
update_automation,
|
||
)
|
||
from cloud.marker_tools import (
|
||
extract_automation_creates,
|
||
extract_goal_converts,
|
||
parse_first_run_at,
|
||
)
|
||
from cloud.server import init_db
|
||
from cloud.worker import Worker
|
||
|
||
FAKE_ENV = {"XCM_API_KEY": "test-placeholder"}
|
||
|
||
|
||
class ParseTestCase(unittest.TestCase):
|
||
def test_goal_convert_basic_and_alias(self):
|
||
text = (
|
||
"```goal-convert\n{\"goal\": \"收 xxx\"}\n```\n"
|
||
"```goal_convert\n{\"goal\": \"收 yyy\"}\n```\n")
|
||
converts = extract_goal_converts(text)
|
||
# 每轮最多 1 块,第二个被忽略
|
||
self.assertEqual([c["goal"] for c in converts], ["收 xxx"])
|
||
|
||
def test_goal_convert_invalid_skipped(self):
|
||
text = (
|
||
"```goal-convert\nnot json\n```\n"
|
||
"```goal-convert\n{\"goal\": \" \"}\n```\n"
|
||
"```goal-convert\n{\"goal\": \"ok\"}\n```\n")
|
||
self.assertEqual(
|
||
[c["goal"] for c in extract_goal_converts(text)], ["ok"])
|
||
|
||
def test_first_run_at_formats(self):
|
||
iso = parse_first_run_at("2026-10-30T09:00:00+08:00")
|
||
naive = parse_first_run_at("2026-10-30 09:00")
|
||
self.assertEqual(iso, naive)
|
||
self.assertEqual(parse_first_run_at(iso), iso)
|
||
self.assertEqual(parse_first_run_at(int(iso * 1000)), iso)
|
||
self.assertIsNone(parse_first_run_at("not a time"))
|
||
self.assertIsNone(parse_first_run_at("2026-13-40 09:00"))
|
||
self.assertIsNone(parse_first_run_at(True))
|
||
self.assertIsNone(parse_first_run_at(-1))
|
||
|
||
def test_automation_create_basic(self):
|
||
text = (
|
||
"```automation-create\n"
|
||
"{\"prompt\": \"看 yyy\", \"interval_seconds\": 86400,"
|
||
" \"first_run_at\": \"2026-10-30T09:00:00+08:00\","
|
||
" \"once\": true, \"bind\": \"heartbeat\"}\n```\n")
|
||
blocks = extract_automation_creates(text)
|
||
self.assertEqual(len(blocks), 1)
|
||
block = blocks[0]
|
||
self.assertEqual(block["prompt"], "看 yyy")
|
||
self.assertEqual(block["interval_seconds"], 86400)
|
||
self.assertEqual(
|
||
block["first_run_at"],
|
||
parse_first_run_at("2026-10-30T09:00:00+08:00"))
|
||
self.assertTrue(block["once"])
|
||
self.assertEqual(block["bind"], "heartbeat")
|
||
self.assertIsNone(block["name"])
|
||
|
||
def test_automation_create_invalid_interval_and_bind_skipped(self):
|
||
text = (
|
||
"```automation-create\n{\"prompt\": \"x\","
|
||
" \"interval_seconds\": 30}\n```\n"
|
||
"```automation-create\n{\"prompt\": \"y\","
|
||
" \"bind\": \"weird\"}\n```\n"
|
||
"```automation-create\n{\"prompt\": \"good\"}\n```\n")
|
||
blocks = extract_automation_creates(text)
|
||
self.assertEqual([b["prompt"] for b in blocks], ["good"])
|
||
self.assertEqual(blocks[0]["interval_seconds"], 86400)
|
||
|
||
|
||
class AutomationsTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.conn = sqlite3.connect(":memory:")
|
||
self.conn.row_factory = sqlite3.Row
|
||
|
||
def tearDown(self):
|
||
self.conn.close()
|
||
|
||
def test_first_run_at_not_due_early_then_due(self):
|
||
now = 1700000000.0
|
||
auto = create_automation(
|
||
self.conn, "p", "future", "看 x", "u", now=now,
|
||
first_run_at=now + 3600)
|
||
self.assertFalse(auto["due"])
|
||
self.assertEqual(auto["next_run_at"], now + 3600)
|
||
self.assertEqual(due_automations(self.conn, "p", now=now), [])
|
||
self.assertFalse(
|
||
claim_due_automation(self.conn, auto["id"], now=now))
|
||
self.assertEqual(
|
||
[a["name"] for a in due_automations(
|
||
self.conn, "p", now=now + 3600)],
|
||
["future"])
|
||
self.assertTrue(
|
||
claim_due_automation(self.conn, auto["id"], now=now + 3600))
|
||
|
||
def test_run_once_never_due_after_first_run(self):
|
||
now = 1700000000.0
|
||
auto = create_automation(
|
||
self.conn, "p", "once", "看 y", "u", now=now,
|
||
first_run_at=now + 10, run_once=True)
|
||
self.assertTrue(
|
||
claim_due_automation(self.conn, auto["id"], now=now + 20))
|
||
after = get_automation(self.conn, auto["id"])
|
||
self.assertFalse(after["due"])
|
||
self.assertIsNone(after["next_run_at"])
|
||
self.assertEqual(
|
||
due_automations(self.conn, "p", now=now + 90000), [])
|
||
|
||
def test_update_first_run_at_and_run_once(self):
|
||
now = 1700000000.0
|
||
auto = create_automation(
|
||
self.conn, "p", "upd", "看 z", "u", now=now)
|
||
ok, updated = update_automation(
|
||
self.conn, auto["id"],
|
||
{"first_run_at": now + 7200, "run_once": True}, now=now)
|
||
self.assertTrue(ok)
|
||
self.assertEqual(updated["first_run_at"], now + 7200)
|
||
self.assertTrue(updated["run_once"])
|
||
self.assertFalse(updated["due"])
|
||
|
||
|
||
class WorkerDeliverTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||
init_db(self.db_path)
|
||
self._insert("PM", status="running", created_by="tester")
|
||
self._member("tester", "maintainer")
|
||
|
||
def tearDown(self):
|
||
self.tmp.cleanup()
|
||
|
||
def _insert(self, task_id, status="queued", created_by="tester",
|
||
project_id="p1"):
|
||
now = time.time()
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||
" idempotency_key, depends_on, priority, created_by,"
|
||
" created_at, updated_at, last_heartbeat)"
|
||
" VALUES(?, ?, ?, ?, NULL, '[]', 0, ?, ?, ?, NULL)",
|
||
(task_id, project_id, "do work", status, created_by,
|
||
now, now))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def _member(self, user_id, role):
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,"
|
||
" username TEXT UNIQUE, api_key_hash TEXT,"
|
||
" created_at REAL)")
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS project_members("
|
||
"project_id TEXT, user_id TEXT, role TEXT,"
|
||
" PRIMARY KEY(project_id, user_id))")
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO project_members"
|
||
"(project_id, user_id, role) VALUES('p1', ?, ?)",
|
||
(user_id, role))
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO users(id, username, api_key_hash,"
|
||
" created_at) VALUES(?, ?, '', 0)", (user_id, user_id))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def _worker(self):
|
||
return Worker(self.db_path, "/fake/host.py", env=FAKE_ENV)
|
||
|
||
def _followup_bodies(self):
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT body FROM task_followups"
|
||
" WHERE task_id = 'PM' ORDER BY created_at").fetchall()
|
||
return [row[0] for row in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def _automations(self):
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT name, prompt, interval_seconds, run_once"
|
||
" FROM automations").fetchall()
|
||
except sqlite3.OperationalError:
|
||
# 一个自动化都没建过时表还不存在,不是失败
|
||
return []
|
||
return [tuple(row) for row in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def test_deliver_automation_create_and_bind(self):
|
||
worker = self._worker()
|
||
sender = worker.get_task("PM")
|
||
future = time.time() + 3600
|
||
worker._deliver_marker_tools(sender, {
|
||
"status": "completed",
|
||
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||
"task_cancels": [], "task_retries": [],
|
||
"goal_converts": [],
|
||
"automation_creates": [{
|
||
"name": None, "prompt": "看 yyy",
|
||
"interval_seconds": 86400, "first_run_at": future,
|
||
"once": False, "bind": "heartbeat",
|
||
"repo_url": None}],
|
||
})
|
||
autos = self._automations()
|
||
self.assertEqual(len(autos), 1)
|
||
self.assertEqual(autos[0][1], "看 yyy")
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
binding = conn.execute(
|
||
"SELECT task_id, mode FROM automation_bindings"
|
||
).fetchone()
|
||
bodies = self._followup_bodies()
|
||
finally:
|
||
conn.close()
|
||
self.assertEqual(tuple(binding), ("PM", "heartbeat"))
|
||
self.assertTrue(any("已创建自动化" in b for b in bodies))
|
||
|
||
def test_deliver_automation_past_first_run_rejected(self):
|
||
worker = self._worker()
|
||
sender = worker.get_task("PM")
|
||
worker._deliver_marker_tools(sender, {
|
||
"status": "completed",
|
||
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||
"task_cancels": [], "task_retries": [],
|
||
"goal_converts": [],
|
||
"automation_creates": [{
|
||
"name": "old", "prompt": "看旧",
|
||
"interval_seconds": 86400,
|
||
"first_run_at": time.time() - 3600,
|
||
"once": False, "bind": None, "repo_url": None}],
|
||
})
|
||
self.assertEqual(self._automations(), [])
|
||
bodies = self._followup_bodies()
|
||
self.assertTrue(any("已过去" in b for b in bodies))
|
||
|
||
def test_deliver_goal_convert_queues_after_report(self):
|
||
worker = self._worker()
|
||
sender = worker.get_task("PM")
|
||
worker._deliver_marker_tools(sender, {
|
||
"status": "completed",
|
||
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||
"task_cancels": [], "task_retries": [],
|
||
"goal_converts": [{"goal": "收 xxx"}],
|
||
"automation_creates": [{
|
||
"name": "daily", "prompt": "看 yyy",
|
||
"interval_seconds": 86400, "first_run_at": None,
|
||
"once": False, "bind": None, "repo_url": None}],
|
||
})
|
||
bodies = self._followup_bodies()
|
||
# 报告先、/goal 后:排空先跑报告轮、再转 goal
|
||
self.assertTrue(any("已创建自动化" in b for b in bodies))
|
||
self.assertEqual(bodies[-1], "/goal 收 xxx")
|
||
self.assertEqual(len(self._automations()), 1)
|
||
|
||
def test_deliver_goal_task_ignored(self):
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET goal_mode = 1 WHERE id = 'PM'")
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
worker = self._worker()
|
||
sender = worker.get_task("PM")
|
||
worker._deliver_marker_tools(sender, {
|
||
"status": "completed",
|
||
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||
"task_cancels": [], "task_retries": [],
|
||
"goal_converts": [{"goal": "收 xxx"}],
|
||
"automation_creates": [],
|
||
})
|
||
self.assertEqual(self._followup_bodies(), [])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|