diff --git a/Agent.md b/Agent.md index 22bd822b..5338ea89 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (977) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (983) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 26c702f5..0f721131 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -50,7 +50,7 @@ ServerPong, TaskRequest, ) -from emrg.session import Session +from emrg.session import Session, last_n_messages # ── 日志脱敏(rant 2026-08-06T10:21:26)──────────────────────────── # tool call 参数可能含 api_key/token/authorization/password 等敏感字段, @@ -1259,7 +1259,12 @@ async def _task_vibe_check(self, task_name: str, session_id: str, cwd: str, session = Session.load(session_id, Path(cwd)) history = session.get_messages_for_llm() if history: - messages.extend(history[-100:]) + # Rant 2026-08-19T19:25:56 (root cause): slicing the + # validated list can orphan a leading role:"tool" message + # whose matching assistant(tool_calls) lies before the + # window → LLM 400 "tool must follow tool_calls". Use + # last_n_messages to drop window-boundary orphans. + messages.extend(last_n_messages(history, 100)) except Exception: logger.warning( "task_vibe_check: session history load failed (%s/%s)", diff --git a/emrg/session.py b/emrg/session.py index b55f07dc..0510b25c 100644 --- a/emrg/session.py +++ b/emrg/session.py @@ -569,6 +569,24 @@ def list_sessions(cwd: Path) -> list[dict]: return results +def last_n_messages(messages: list[dict], n: int) -> list[dict]: + """Take the last ``n`` messages of a validated LLM message list. + + The list must already be OpenAI-valid (e.g. produced by + ``_validate_tool_messages``). Slicing a valid list can still orphan a + leading ``role: "tool"`` message: its matching assistant message with + ``tool_calls`` lies just before the window boundary, so the API rejects + it with "Messages with role 'tool' must be a response to a preceding + message with 'tool_calls'" (observed on task_vibe_check, 2026-08-19). + Leading orphaned tool messages are dropped; the remaining window keeps + its original order. + """ + window = messages[-n:] + while window and window[0].get("role") == "tool": + window.pop(0) + return window + + def _validate_tool_messages(messages: list[dict]) -> list[dict]: """Post-process messages to ensure OpenAI API validity. diff --git a/tests/test_session.py b/tests/test_session.py index ada0ed79..35cabcaf 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -6,7 +6,12 @@ import pytest -from emrg.session import Session, _validate_tool_messages, generate_session_id +from emrg.session import ( + Session, + _validate_tool_messages, + generate_session_id, + last_n_messages, +) class TestValidateToolMessages: @@ -167,6 +172,82 @@ def test_mixed_block_one_valid_one_orphaned(self): # ── Session core operations ──────────────────────────────────── +class TestLastNMessages: + """Tests for last_n_messages — window slicing that drops boundary + orphans (rant 2026-08-19T19:25:56: task_vibe_check LLM 400).""" + + def test_plain_window_unchanged(self): + """Normal window with no leading tool message passes through.""" + msgs = [ + {"role": "user", "content": "a"}, + {"role": "assistant", "content": "b"}, + {"role": "user", "content": "c"}, + ] + assert last_n_messages(msgs, 2) == [ + {"role": "assistant", "content": "b"}, + {"role": "user", "content": "c"}, + ] + + def test_leading_tool_orphan_dropped(self): + """A window starting with role:'tool' (its assistant lies before the + boundary) has the orphan dropped so the API accepts the request.""" + msgs = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "x", "type": "function", + "function": {"name": "bash", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "x", "content": "out"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "final"}, + ] + window = last_n_messages(msgs, 3) + assert window == [ + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "final"}, + ] + + def test_consecutive_leading_tool_orphans_all_dropped(self): + """Multiple consecutive leading tool orphans are all dropped.""" + msgs = [ + {"role": "assistant", "content": None, + "tool_calls": [{"id": "x", "type": "function", + "function": {"name": "bash", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "x", "content": "1"}, + {"role": "tool", "tool_call_id": "x", "content": "2"}, + {"role": "user", "content": "after"}, + ] + window = last_n_messages(msgs, 3) + assert [m["role"] for m in window] == ["user"] + + def test_all_tool_window_empties(self): + """Window of only tool orphans returns empty list (caller guards).""" + msgs = [ + {"role": "tool", "tool_call_id": "x", "content": "1"}, + {"role": "tool", "tool_call_id": "y", "content": "2"}, + ] + assert last_n_messages(msgs, 2) == [] + + def test_reproduced_real_session_case(self): + """Regression: a 172-message valid history whose [-100:] slice starts + with role:'tool' (real aitokenpool session, 2026-08-19) must no longer + produce a leading tool message.""" + hist = [] + for i in range(172): + if i % 2 == 0: + hist.append({"role": "user", "content": f"u{i}"}) + else: + hist.append({"role": "assistant", "content": None, + "tool_calls": [{"id": f"t{i}", "type": "function", + "function": {"name": "bash", "arguments": "{}"}}]}) + hist.append({"role": "tool", "tool_call_id": f"t{i}", "content": "r"}) + # force the 100-window to start with a tool message: insert a trailing + # tool cluster right before position len-100 like the real history + hist = hist[:72] + [{"role": "tool", "tool_call_id": "orphan", "content": "o"}] + hist[72:] + window = last_n_messages(hist, 100) + assert len(window) <= 100 + assert window[0]["role"] != "tool" + + class TestSessionCreate: """Tests for Session.create() and Session.create_with_id().""" diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 71f89be1..d140c4c0 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -297,6 +297,74 @@ async def fake_chat(messages, tools=None): await cleanup() asyncio.run(_test()) + def test_vibe_check_long_session_window_leading_tool_stripped(self): + """Rant 2026-08-19T19:25:56 (root cause): slicing a validated session + history to the last 100 messages can orphan a leading role:'tool' + message whose matching assistant(tool_calls) lies before the window → + the LLM rejects the request with 400 "tool must follow tool_calls" + (observed on every task_vibe_check for long sessions). The daemon + must strip window-boundary orphans before calling the LLM.""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + sess_dir = tmp / ".emrg" / "sessions" / "emrg-evolution-emrg-task" + sess_dir.mkdir(parents=True, exist_ok=True) + lines = [] + # 60 user → assistant(tool_calls) → tool_result triples: + # 180 llm messages → [-100:] starts at an index ≡ 2 (mod 3) = tool. + for i in range(60): + lines.append(json.dumps({ + "type": "message", "role": "user", "content": f"cycle {i}", + }, ensure_ascii=False)) + lines.append(json.dumps({ + "type": "message", "role": "assistant", "content": None, + "tool_calls": [{ + "id": f"x{i}", "type": "function", + "function": {"name": "bash", "arguments": "{}"}, + }], + }, ensure_ascii=False)) + lines.append(json.dumps({ + "type": "tool_result", "tool_call_id": f"x{i}", + "content": "ok", + }, ensure_ascii=False)) + (sess_dir / "history.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") + + server, _, cleanup = await _boot_server(tmp) + try: + seen = {} + + async def fake_chat(messages, tools=None): + seen["messages"] = messages + return {"content": '{"meaningful": false, "recommend_slowdown": false, "reason": "nt", "done": ""}'} + server.llm.chat = fake_chat + + ws = await connect_to_server() + try: + await ws.send(json.dumps({ + "type": "task_vibe_check", + "session_id": "emrg-evolution-emrg-task", + "cwd": str(tmp), + "task_name": "emrg-task", + "prompt": "run cycle", + "completion_summary": "aux", + }, ensure_ascii=False)) + frame = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(frame) + assert data.get("ok") is True, data + msgs = seen.get("messages", []) + assert msgs[0]["role"] == "system" + assert msgs[-1]["role"] == "user" + # no leading tool orphan after system; history portion + # must start with a non-tool role + first_hist = next(m for m in msgs[1:] if m["role"] != "system") + assert first_hist["role"] != "tool", [m["role"] for m in msgs[:6]] + assert len(msgs) <= 2 + 100, len(msgs) + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + def test_vibe_check_missing_done_field_is_compatible(self): """Old models / old parsing omit 'done' → empty string, no crash.""" async def _test():