- TaskRecord 去 session_data dict,直存 principal/task_params/directives + 可变 goal_progress 字段(__slots__) - create_chat_task 签名改为收 RuntimeContext 必填,TaskRecord.__init__ 同步精简(身份/消息/模式等全部从 ctx 派生) - _run_chat_task 身份还原/门闸认领/事件注入/terminal 属性设置 全部改为三层属性访问;goal_progress 回写改独立可变字段 - RuntimeService.create_task 原样透传 ctx;to_session_data() 删除 - task_public_payload / api.py 单任务载荷改三层读取 - 契约/注释/i18n 文案同步去 session_data 化;测试重写为三层断言 验证:75 测试全量回归,失败恰为 4 项存量(host_workspace_manager / skills_manager / token_usage_extractor / conversation_workspace_storage)
198 lines
7.8 KiB
Python
198 lines
7.8 KiB
Python
"""阶段二公共任务入口(RuntimeService)的显式上下文验收测试。
|
||
|
||
契约用例(docs/runtime_contract.md §7):
|
||
- T01:显式上下文受理(无 HTTP 请求、无 test_request_context)
|
||
- T02:同对话并发 chat 互斥 / notice 豁免
|
||
- T04:取消(受理层语义;执行线程以 no-op 替身阻断,不触达模型调用)
|
||
- 上下文固化:受理后三层结构(principal/task_params/directives)原样落到任务记录
|
||
|
||
本测试全程不创建 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_context_layers_carry_identity_and_preferences(self):
|
||
ctx = _make_ctx()
|
||
# 身份与资源范围(principal 层)
|
||
p = ctx.principal
|
||
self.assertEqual(p.username, "tester")
|
||
self.assertEqual(p.workspace_id, "default")
|
||
self.assertEqual(p.role, "user")
|
||
self.assertFalse(p.is_api_user)
|
||
self.assertTrue(p.host_mode)
|
||
self.assertEqual(p.host_workspace_id, "default")
|
||
# 偏好快照层(非本次覆盖)
|
||
self.assertEqual(p.preferred_model_key, "kimi-test")
|
||
self.assertEqual(p.preferred_run_mode, "fast")
|
||
self.assertFalse(p.preferred_thinking_mode)
|
||
# 内部指令(directives 层)
|
||
self.assertEqual(ctx.directives.main_task_gate_token, "tok123")
|
||
# 默认不回放事件
|
||
self.assertFalse(ctx.directives.auto_user_message_event)
|
||
|
||
def test_context_directives_fields(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": {}}],
|
||
),
|
||
)
|
||
d = ctx.directives
|
||
self.assertTrue(d.auto_user_message_event)
|
||
self.assertEqual(d.auto_user_message_payload, {"visibility": "chat"})
|
||
self.assertEqual(len(d.preceding_user_notices), 1)
|
||
# 超时透传机制(默认 None,显式设置后按层携带)
|
||
self.assertEqual(ctx.params.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.principal.username, "tester")
|
||
self.assertEqual(rec.directives.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_context(self):
|
||
# 直调底层入口且不带显式上下文 → 明确拒绝(不再静默读 Flask session)
|
||
with self.assertRaises(ValueError):
|
||
task_manager.create_chat_task(None)
|
||
|
||
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()
|