agent-Specialization/test/test_runtime_service.py
JOJO d9bd599c1c feat(runtime): Gateway 独立启动与公共协议落地(改造第 0-3 步)
- 闭环修复:审批 task_id 显式化(N2)、通知回退路径门闸 finally 释放(N1)、
  personalization 原子写(S10,公共 atomic_write_json)
- server.tasks 拆解:__init__ 轻量化、Blueprint/路由移至 blueprint.py/web.py,
  app_legacy 装配点惰性挂载;models.py 循环依赖死导入移除
- 执行链 Web 解耦:emit_event/run_background 安全包装(socketio 未绑定静默),
  清除 4 处裸 socketio.emit(含 task_stopped 先写事件流再推送的顺序修复)
- RuntimeService 补建对话下沉:chat 任务无 conversation_id 时服务层装配,
  客户端无需复制 Web 两步流程
- 事件协议:get_task_events 返回 meta.window_start 缺口检测水位
- 验收:test_runtime_standalone_lifecycle 子进程隔离(config import 固化问题),
  独立启动 + 协议全链路 + 审批语义全绿;全量 74 测试失败恰为 4 项存量
2026-09-07 23:00:15 +08:00

200 lines
7.8 KiB
Python
Raw 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.

"""阶段二公共任务入口RuntimeService的显式上下文验收测试。
契约用例docs/runtime_contract.md §7
- T01显式上下文受理无 HTTP 请求、无 test_request_context
- T02同对话并发 chat 互斥 / notice 豁免
- T04取消受理层语义执行线程以 no-op 替身阻断,不触达模型调用)
- 快照完整性to_session_data 携带资源装配所需的全部身份与偏好字段
本测试全程不创建 Flask 应用/请求上下文——这本身就是
「公共入口不依赖隐式 Web 环境」的直接证明。
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server.runtime import ( # noqa: E402
InternalDirectives,
RuntimeContext,
TaskParams,
TrustedPrincipal,
principal_from_session_snapshot,
runtime_service,
)
from server.tasks import task_manager # noqa: E402
def _make_ctx(conversation_id="conv_test_rt", task_type="chat", username="tester"):
return RuntimeContext(
principal=TrustedPrincipal(
username=username,
workspace_id="default",
role="user",
is_api_user=False,
host_mode=True,
host_workspace_id="default",
preferred_model_key="kimi-test",
preferred_run_mode="fast",
preferred_thinking_mode=False,
),
params=TaskParams(
message="hello",
conversation_id=conversation_id,
run_mode="fast",
task_type=task_type,
),
directives=InternalDirectives(main_task_gate_token="tok123"),
)
class RuntimeContextModelTest(unittest.TestCase):
def test_validate_rejects_empty_username(self):
ctx = _make_ctx()
object.__setattr__(ctx.principal, "username", "") # frozen dataclass 测试绕过
with self.assertRaises(ValueError):
ctx.validate()
def test_to_session_data_carries_identity_and_preferences(self):
ctx = _make_ctx()
snap = ctx.to_session_data()
# 身份与资源范围
self.assertEqual(snap["username"], "tester")
self.assertEqual(snap["workspace_id"], "default")
self.assertEqual(snap["role"], "user")
self.assertFalse(snap["is_api_user"])
self.assertTrue(snap["host_mode"])
self.assertEqual(snap["host_workspace_id"], "default")
# 偏好快照层(非本次覆盖)
self.assertEqual(snap["model_key"], "kimi-test")
self.assertEqual(snap["run_mode"], "fast")
self.assertFalse(snap["thinking_mode"])
# 内部指令
self.assertEqual(snap["main_task_gate_token"], "tok123")
# 默认不出现事件回放键
self.assertNotIn("auto_user_message_event", snap)
def test_to_session_data_directives(self):
ctx = RuntimeContext(
principal=_make_ctx().principal,
params=TaskParams(message="m", conversation_id="c1", approval_timeout_seconds=120),
directives=InternalDirectives(
auto_user_message_event=True,
auto_user_message_payload={"visibility": "chat"},
preceding_user_notices=[{"message": "n1", "payload": {}}],
),
)
snap = ctx.to_session_data()
self.assertTrue(snap["auto_user_message_event"])
self.assertEqual(snap["auto_user_message_payload"], {"visibility": "chat"})
self.assertEqual(len(snap["preceding_user_notices"]), 1)
# 超时透传机制(默认 None 时不出现,语义不变)
self.assertEqual(snap["approval_timeout_seconds"], 120)
def test_principal_from_session_snapshot(self):
snap = {
"username": "u1",
"role": "api",
"is_api_user": True,
"host_mode": False,
"workspace_id": "ws1",
"model_key": "m1",
}
p = principal_from_session_snapshot(snap, "ws1")
self.assertEqual(p.username, "u1")
self.assertEqual(p.role, "api")
self.assertTrue(p.is_api_user)
self.assertFalse(p.host_mode)
self.assertIsNone(p.host_workspace_id)
self.assertEqual(p.preferred_model_key, "m1")
class RuntimeServiceAdmissionTest(unittest.TestCase):
"""受理层行为:用 no-op 替身阻断执行线程,不触达模型与工作区装配。"""
def setUp(self):
self._orig_run = task_manager._run_chat_task
task_manager._run_chat_task = lambda *a, **k: None # 线程即刻结束
self._created = []
def tearDown(self):
task_manager._run_chat_task = self._orig_run
for task_id in self._created:
with task_manager._lock:
task_manager._tasks.pop(task_id, None)
def _create(self, **kw):
rec = runtime_service.create_task(_make_ctx(**kw))
self._created.append(rec.task_id)
return rec
def test_t01_create_task_without_http_context(self):
rec = self._create()
self.assertTrue(rec.task_id)
self.assertEqual(rec.username, "tester")
self.assertEqual(rec.conversation_id, "conv_test_rt")
# 快照经显式上下文进入,未触碰 Flask session
self.assertEqual(rec.session_data["username"], "tester")
self.assertEqual(rec.session_data["main_task_gate_token"], "tok123")
def test_t02_same_conversation_chat_mutex_and_notice_exempt(self):
# 第一个任务保持 running线程 no-op 但 status 已被置 running
rec1 = self._create()
self.assertEqual(rec1.status, "running")
# 同对话第二个 chat 被拒
with self.assertRaises(RuntimeError):
self._create()
# notice 类型豁免互斥
rec2 = self._create(task_type="notice")
self.assertEqual(rec2.task_type, "notice")
# 不同对话不受影响
rec3 = self._create(conversation_id="conv_other")
self.assertTrue(rec3.task_id)
def test_create_task_validates_context(self):
bad = _make_ctx()
object.__setattr__(bad.principal, "workspace_id", "")
with self.assertRaises(ValueError):
runtime_service.create_task(bad)
def test_create_chat_task_requires_explicit_session_data(self):
# 直调底层入口且不带快照 → 明确拒绝(不再静默读 Flask session
with self.assertRaises(ValueError):
task_manager.create_chat_task(
"tester", "default", "msg", [], "conv_x",
)
def test_t04_cancel_task(self):
rec = self._create()
ok = runtime_service.cancel_task("tester", rec.task_id)
self.assertTrue(ok)
# 他人不可取消
rec2 = self._create(conversation_id="conv_cancel2")
self.assertFalse(runtime_service.cancel_task("someone_else", rec2.task_id))
def test_get_task_events_offset_protocol(self):
rec = self._create()
task_manager._append_event(rec, "system_message", {"text": "a"})
task_manager._append_event(rec, "text_chunk", {"text": "b"})
events, next_offset, err, meta = runtime_service.get_task_events("tester", rec.task_id, 0)
self.assertIsNone(err)
self.assertEqual([e["idx"] for e in events], [0, 1])
self.assertEqual(next_offset, 2)
# 缺口检测水位:未裁剪时为 0
self.assertEqual(meta, {"window_start": 0})
# offset 续读
events2, next2, _, meta2 = runtime_service.get_task_events("tester", rec.task_id, 1)
self.assertEqual([e["idx"] for e in events2], [1])
self.assertEqual(next2, 2)
self.assertEqual(meta2["window_start"], 0)
# 无权/不存在
events3, _, err3, meta3 = runtime_service.get_task_events("someone_else", rec.task_id, 0)
self.assertIsNone(events3)
self.assertIsNone(meta3)
self.assertTrue(err3)
if __name__ == "__main__":
unittest.main()