Files
codex-app-server/cloud/tests/test_session_goal_automation.py
host e81ba8d2bf feat: 会话内 LLM 自判 goal / 自动化标记工具,不靠关键词
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 项全过。
2026-10-30 07:15:05 +08:00

335 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""会话内 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()