fix(spawner): §24 v4 compact检测 - trajectory prompt.submitted 替换 gateway rotation
This commit is contained in:
+141
-60
@@ -1,11 +1,10 @@
|
||||
"""单元测试:§24 v3 rotation-only compact 检测
|
||||
"""单元测试:§24 v4 trajectory prompt.submitted compact 检测
|
||||
|
||||
测试 _get_recent_gateway_logs 和 _check_compact_in_progress_gateway。
|
||||
用 tmp_path 构造 mock gateway 日志文件。
|
||||
测试 _check_compact_in_progress_trajectory 方法。
|
||||
用 tmp_path 构造 mock trajectory jsonl 文件。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,77 +15,159 @@ from src.daemon.spawner import AgentSpawner
|
||||
|
||||
# ── helpers ──
|
||||
|
||||
_SESSION_KEY = "agent:pangtong-fujunshi:main"
|
||||
_TODAY_STR = datetime.now().strftime("%Y-%m-%d")
|
||||
_SESSION_ID = "sess-abc123"
|
||||
|
||||
|
||||
def _make_rotation_event(session_key: str, ts: datetime) -> dict:
|
||||
"""构造一条 rotation 日志事件"""
|
||||
return {
|
||||
"time": ts.isoformat(),
|
||||
"message": f"[compaction] rotated active transcript after compaction (sessionKey={session_key})",
|
||||
}
|
||||
def _make_trajectory_event(event_type: str, ts: str = None, **kwargs) -> dict:
|
||||
"""构造 trajectory jsonl 事件"""
|
||||
obj = {"type": event_type}
|
||||
if ts:
|
||||
obj["ts"] = ts
|
||||
obj.update(kwargs)
|
||||
return obj
|
||||
|
||||
|
||||
def _make_other_event(session_key: str, ts: datetime, msg: str = "something else") -> dict:
|
||||
"""构造一条普通日志事件"""
|
||||
return {
|
||||
"time": ts.isoformat(),
|
||||
"message": f"{msg} (sessionKey={session_key})",
|
||||
}
|
||||
def _write_trajectory(tmp_path: Path, session_id: str, turns: list[list[dict]]):
|
||||
"""写入 trajectory jsonl,按 turns 分组。
|
||||
|
||||
每个 turn 是一个 list of events。
|
||||
自动在每组前加 session.started(如果该 turn 没有的话)。
|
||||
"""
|
||||
traj_file = tmp_path / f"{session_id}.trajectory.jsonl"
|
||||
with open(traj_file, "w") as f:
|
||||
for turn_events in turns:
|
||||
# 如果 turn 第一个事件不是 session.started,自动加一个
|
||||
if not turn_events or turn_events[0].get("type") != "session.started":
|
||||
started = _make_trajectory_event(
|
||||
"session.started",
|
||||
ts=turn_events[0].get("ts") if turn_events else None,
|
||||
)
|
||||
f.write(json.dumps(started, ensure_ascii=False) + "\n")
|
||||
for evt in turn_events:
|
||||
f.write(json.dumps(evt, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _write_log(tmp_path: Path, date_str: str, lines: list[dict]):
|
||||
"""写 mock 日志文件"""
|
||||
log_file = tmp_path / f"openclaw-{date_str}.log"
|
||||
with open(log_file, "w") as f:
|
||||
for obj in lines:
|
||||
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
def _utc_now_str() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 字符串(带 Z 后缀)"""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + \
|
||||
f"{datetime.now(timezone.utc).microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_log_dir(tmp_path, monkeypatch):
|
||||
"""每个测试自动设置 OPENCLAW_LOG_DIR 到 tmp_path"""
|
||||
monkeypatch.setenv("OPENCLAW_LOG_DIR", str(tmp_path))
|
||||
def _utc_past_str(minutes_ago: int) -> str:
|
||||
"""返回过去 N 分钟的 UTC ISO 字符串"""
|
||||
ts = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
||||
return ts.strftime("%Y-%m-%dT%H:%M:%S.") + \
|
||||
f"{ts.microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
# ── 测试用例 ──
|
||||
|
||||
|
||||
class TestCheckCompactInProgress:
|
||||
"""§24 v3: _check_compact_in_progress_gateway 单元测试"""
|
||||
class TestCheckCompactInProgressTrajectory:
|
||||
"""§24 v4: _check_compact_in_progress_trajectory 单元测试"""
|
||||
|
||||
def test_rotation_within_window_returns_true(self, tmp_path):
|
||||
"""TC1: rotation 事件在窗口内 → True"""
|
||||
now = datetime.now(timezone.utc)
|
||||
recent = now - timedelta(seconds=30)
|
||||
_write_log(tmp_path, _TODAY_STR, [_make_rotation_event(_SESSION_KEY, recent)])
|
||||
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is True
|
||||
def test_tc1_normal_turn_with_submitted_returns_false(self, tmp_path):
|
||||
"""TC1: 正常 turn(有 prompt.submitted)→ False"""
|
||||
now = _utc_now_str()
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("prompt.submitted", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_rotation_outside_window_returns_false(self, tmp_path):
|
||||
"""TC2: rotation 事件超出窗口 → False"""
|
||||
now = datetime.now(timezone.utc)
|
||||
old = now - timedelta(seconds=200)
|
||||
_write_log(tmp_path, _TODAY_STR, [_make_rotation_event(_SESSION_KEY, old)])
|
||||
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
|
||||
def test_tc2_compact_turn_returns_true(self, tmp_path):
|
||||
"""TC2: compact turn(无 prompt.submitted,有 context.compiled + model.completed)→ True"""
|
||||
now = _utc_now_str()
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is True
|
||||
|
||||
def test_no_rotation_event_returns_false(self, tmp_path):
|
||||
"""TC3: 无 rotation 事件 → False"""
|
||||
now = datetime.now(timezone.utc)
|
||||
_write_log(tmp_path, _TODAY_STR, [
|
||||
_make_other_event(_SESSION_KEY, now, "model.completed"),
|
||||
])
|
||||
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
|
||||
def test_tc3_skipped_prompt_returns_false(self, tmp_path):
|
||||
"""TC3: 空白 prompt(有 prompt.skipped)→ False"""
|
||||
now = _utc_now_str()
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("prompt.skipped", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_log_file_not_exists_returns_false(self, tmp_path):
|
||||
"""TC4: 日志文件不存在 → False"""
|
||||
# tmp_path 为空目录,无日志文件
|
||||
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
|
||||
def test_tc4_timeout_fallback_returns_false(self, tmp_path):
|
||||
"""TC4: 超过 30min 兜底 → False"""
|
||||
old = _utc_past_str(35)
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=old),
|
||||
_make_trajectory_event("context.compiled", ts=old),
|
||||
_make_trajectory_event("model.completed", ts=old),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_session_key_mismatch_returns_false(self, tmp_path):
|
||||
"""TC5: sessionKey 不匹配 → False"""
|
||||
now = datetime.now(timezone.utc)
|
||||
recent = now - timedelta(seconds=10)
|
||||
other_key = "agent:simayi-challenger:main"
|
||||
_write_log(tmp_path, _TODAY_STR, [_make_rotation_event(other_key, recent)])
|
||||
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
|
||||
def test_tc5_trajectory_not_exists_returns_false(self, tmp_path):
|
||||
"""TC5: trajectory 不存在 → False"""
|
||||
session_file = str(tmp_path / "nonexistent-session")
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc6_empty_trajectory_returns_false(self, tmp_path):
|
||||
"""TC6: 空 trajectory → False"""
|
||||
traj_file = tmp_path / f"{_SESSION_ID}.trajectory.jsonl"
|
||||
traj_file.write_text("")
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc7_multi_turn_last_normal_returns_false(self, tmp_path):
|
||||
"""TC7: 多 turn 尾部只看最后一个(最后一个正常但之前有 compact)→ False"""
|
||||
old = _utc_past_str(10)
|
||||
now = _utc_now_str()
|
||||
turn1 = [
|
||||
_make_trajectory_event("session.started", ts=old),
|
||||
_make_trajectory_event("context.compiled", ts=old),
|
||||
_make_trajectory_event("model.completed", ts=old), # compact turn, no prompt
|
||||
]
|
||||
turn2 = [
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("prompt.submitted", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now), # normal turn
|
||||
]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, [turn1, turn2])
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc8_multi_turn_last_abnormal_returns_true(self, tmp_path):
|
||||
"""TC8: 多 turn 尾部最后一个非正常 → True"""
|
||||
old = _utc_past_str(5)
|
||||
now = _utc_now_str()
|
||||
turn1 = [
|
||||
_make_trajectory_event("session.started", ts=old),
|
||||
_make_trajectory_event("prompt.submitted", ts=old),
|
||||
_make_trajectory_event("model.completed", ts=old), # normal turn
|
||||
]
|
||||
turn2 = [
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now), # compact turn, no prompt
|
||||
]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, [turn1, turn2])
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is True
|
||||
|
||||
def test_tc9_null_session_file_returns_false(self):
|
||||
"""TC9: session_file 为空字符串 → False"""
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory("") is False
|
||||
|
||||
def test_tc10_none_session_file_returns_false(self):
|
||||
"""TC10: session_file 为 None → False"""
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(None) is False
|
||||
|
||||
Reference in New Issue
Block a user