Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
9 changes: 7 additions & 2 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 等敏感字段,
Expand DownExpand Up@@ -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)",
Expand Down
18 changes: 18 additions & 0 deletions emrg/session.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
83 changes: 82 additions & 1 deletion tests/test_session.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -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()."""

Expand Down
68 changes: 68 additions & 0 deletions tests/test_ws_e2e.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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():
Expand Down
Loading