diff --git a/Agent.md b/Agent.md index 2a19dba..4c1d7a0 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` (851) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (858) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 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 8267770..4ab92bf 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1066,6 +1066,52 @@ async def _github_connect_web_cancel(self) -> None: except (OSError, ValueError): pass + async def _task_vibe_check(self, task_name: str, prompt: str, completion_summary: str) -> dict: + """One-shot structured LLM ask (Ask mode, no tools, no history). + + Asks whether a just-finished scheduled task produced meaningful value. + The agent must answer in strict JSON: + ``{"meaningful": bool, "recommend_slowdown": bool, "reason": str}``. + + Raises on any failure (caller sends ``ok: false``); the scheduler + conservatively leaves its empty-cycle counter unchanged then. + """ + system = ( + "你是 EMRG 定时任务调度助手。刚完成一次定时任务「" + (task_name or "") + "」," + "任务要求与最终回复摘要如下。\n" + "请用 JSON 严格回答(不要任何其他文字),格式:\n" + '{"meaningful": true|false, "recommend_slowdown": true|false, ' + '"reason": "一句话原因"}\n' + "- meaningful:这轮是否对项目产生了有意义的价值(产出/提交/分析/决策/" + "维护动作都算;纯空转/无可做=NTE 算 false)\n" + "- recommend_slowdown:若本任务长期无有意义产出,是否建议降频省 token" + "(true=建议降低检查频率)\n" + "- reason:简短中文原因" + ) + user = ( + "任务名称:" + (task_name or "") + "\n" + "任务要求:" + (prompt or "")[:2000] + "\n" + "任务最终回复摘要:" + (completion_summary or "")[:3000] + ) + msg = await self.llm.chat( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + tools=[], + ) + content = (msg.get("content") or "").strip() + # Tolerate markdown fences if the model wraps the JSON in ```json ... ``` + content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content).strip() + data = json.loads(content) + if not isinstance(data, dict): + raise ValueError("vibe check response is not a JSON object") + return { + "meaningful": bool(data.get("meaningful")), + "recommend_slowdown": bool(data.get("recommend_slowdown")), + "reason": str(data.get("reason", ""))[:200], + } + def _build_system_prompt(self, session: Session | None = None) -> str: """Build the system prompt via Jinja2 template. @@ -1872,6 +1918,32 @@ async def _process_message( logger.info("session rewound: %s at index %d (removed %d records)", session_id, record_index, len(records) - len(truncated)) + elif msg_type == "task_vibe_check": + # Rant 2026-08-17T11:39:19: scheduler asks the agent, after a + # scheduled task completes, whether the round produced meaningful + # value — replacing the git-HEAD empty-cycle heuristic (HEAD + # measures commits, not value: analysis/memory work without a + # commit was miscounted as empty, and a no-op round over someone + # else's push counted as work). One-shot Ask-mode LLM call (no + # tools, no session history) with a strict JSON contract. + task_name = msg.get("task_name", "") + prompt = msg.get("prompt", "") + summary = msg.get("completion_summary", "") + try: + result = await self._task_vibe_check(task_name, prompt, summary) + await self._send(ws, { + "type": "vibe_check_result", + "ok": True, + "result": result, + }) + except Exception as e: # noqa: BLE001 — best-effort, never fatal + logger.warning("task_vibe_check failed: %s", e) + await self._send(ws, { + "type": "vibe_check_result", + "ok": False, + "error": str(e)[:200], + }) + elif msg_type == "shutdown": logger.info("shutdown requested by client") await self._send(ws, {"type": "shutdown_ack"}) diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 965fc70..d3071eb 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -221,16 +221,22 @@ def __init__( self.evolutions: list[EvolutionLog] = [] # ── Saturation — slow down, never stop (rant 2026-08-09T09:35:55) ── - # Track consecutive cycles where git HEAD didn't advance (NTE). - # After _IDLE_HALT_THRESHOLD empty cycles, switch to low-frequency + # Track consecutive empty cycles (rant 2026-08-17T11:39:19: the agent + # itself answers whether a round was meaningful — git HEAD compares + # commits, not value, so it was removed as the empty-cycle oracle). + # After the threshold of empty cycles, switch to low-frequency # heartbeat full cycles instead of the old complete halt: # - Scheduled runs continue at heartbeat interval (never skipped) # - heartbeat = max(interval, min(interval*8, 8h)) — 60s task → 8min # - Manual trigger (/trigger) or upstream git HEAD advance restores # the normal frequency immediately (counter reset to 0) + # - The agent's recommend_slowdown votes (3) tighten the threshold + # from 30 to 10 (host 2026-08-17T11:39:19) # # Counter is persisted to disk to survive daemon restarts. self._IDLE_HALT_THRESHOLD = 30 + self._SLOWDOWN_VOTES_TO_TIGHTEN = 3 + self._TIGHTENED_THRESHOLD = 10 # G129: 连续连接失败告警阈值——达到后升级为 ERROR(防静默吞掉, # rant 2026-08-09T08:03:46:GUI 测试覆盖真实 emrgd.port 致 10h 连不上)。 self._CONNECT_FAIL_ALERT = 3 @@ -238,7 +244,7 @@ def __init__( self._saturation_dir = config_dir() / "saturation" self._saturation_dir.mkdir(parents=True, exist_ok=True) self._saturation_file = self._saturation_dir / f"{self.name}.json" - self._empty_cycles = self._load_saturation_state() + self._empty_cycles, self._slowdown_hits = self._load_saturation_state() # Resolve project path from config (new schema) or fall back to # config.path for backward-compat with old tasks.yml entries. @@ -596,32 +602,47 @@ def _ensure_origin_reachable(self) -> None: self.name, (result.stderr.strip() or "")[:80], ssh_url, ) - def _load_saturation_state(self) -> int: - """Restore _empty_cycles counter from disk (survives daemon restarts).""" + def _load_saturation_state(self) -> tuple[int, int]: + """Restore (empty_cycles, slowdown_hits) from disk (daemon restarts).""" try: if self._saturation_file.exists(): data = json.loads(self._saturation_file.read_text(encoding="utf-8")) - count = data.get("empty_cycles", 0) - if count > 0: + count = int(data.get("empty_cycles", 0) or 0) + slowdown = int(data.get("slowdown_hits", 0) or 0) + if count > 0 or slowdown > 0: logger.debug( - "TaskHandler[%s]: restored saturation state (%d empty cycles)", - self.name, count, + "TaskHandler[%s]: restored saturation state (%d empty cycles, %d slowdown votes)", + self.name, count, slowdown, ) - return count + return count, slowdown except Exception: pass - return 0 + return 0, 0 def _save_saturation_state(self) -> None: - """Persist _empty_cycles counter to disk.""" + """Persist (empty_cycles, slowdown_hits) to disk.""" try: self._saturation_file.write_text( - json.dumps({"empty_cycles": self._empty_cycles}, ensure_ascii=False), + json.dumps( + {"empty_cycles": self._empty_cycles, + "slowdown_hits": self._slowdown_hits}, + ensure_ascii=False, + ), encoding="utf-8", ) except Exception: pass + def _saturation_threshold(self) -> int: + """Empty-cycle threshold before dropping to heartbeat cadence. + + The agent's recommend_slowdown votes (3) tighten the threshold from + 30 to 10 — the agent itself keeps reporting the task has no value + (rant 2026-08-17T11:39:19).""" + if self._slowdown_hits >= self._SLOWDOWN_VOTES_TO_TIGHTEN: + return self._TIGHTENED_THRESHOLD + return self._IDLE_HALT_THRESHOLD + async def run(self) -> None: """Run evolution cycles at configured interval. @@ -666,13 +687,14 @@ async def run(self) -> None: # Manual triggers always reset the saturation counter; otherwise # saturated ticks keep running full cycles at heartbeat cadence. if manual_trigger: - if self._empty_cycles >= self._IDLE_HALT_THRESHOLD: + if self._empty_cycles >= self._saturation_threshold(): logger.info( "TaskHandler[%s]: resumed via manual trigger " "(was in saturation at %d empty cycles)", self.name, self._empty_cycles, ) self._empty_cycles = 0 + self._slowdown_hits = 0 self._save_saturation_state() logger.debug("TaskHandler[%s] tick", self.name) @@ -808,7 +830,7 @@ def _saturation_heartbeat_active(self) -> bool: auto-resumes (counter reset, normal frequency), so a saturated handler does not miss new work forever. """ - if self._empty_cycles < self._IDLE_HALT_THRESHOLD: + if self._empty_cycles < self._saturation_threshold(): return False if self._remote_advanced(): logger.info( @@ -825,6 +847,48 @@ def _saturation_heartbeat_active(self) -> bool: ) return True + async def _request_vibe_check(self, ws, prompt: str, completion_summary: str) -> dict | None: + """Ask the daemon for a structured vibe check on the SAME connection. + + Sends ``task_vibe_check`` and waits for ``vibe_check_result`` (~20s). + Fully defensive — any failure/timeout returns None; the caller + conservatively leaves the empty-cycle counter unchanged. + """ + try: + await ws.send(json.dumps({ + "type": "task_vibe_check", + "session_id": self._session_id, + "task_name": self.name, + "prompt": (prompt or "")[:2000], + "completion_summary": (completion_summary or "")[:3000], + }, ensure_ascii=False)) + deadline = time.monotonic() + 20.0 + while time.monotonic() < deadline: + remaining = max(0.5, deadline - time.monotonic()) + try: + frame = json.loads(await asyncio.wait_for(ws.recv(), timeout=remaining)) + except asyncio.TimeoutError: + break + except ConnectionClosed: + break + if frame.get("type") != "vibe_check_result": + continue + if not frame.get("ok"): + logger.warning( + "TaskHandler[%s]: vibe check error: %s", + self.name, (frame.get("error") or "")[:120], + ) + return None + result = frame.get("result") or {} + return { + "meaningful": result.get("meaningful"), + "recommend_slowdown": result.get("recommend_slowdown"), + "reason": result.get("reason", ""), + } + except Exception: + logger.debug("TaskHandler[%s]: vibe check failed", self.name, exc_info=True) + return None + async def _run_evolution_cycle(self) -> None: # Self-heal the evolution workspace first (rant 20:42 方案 C): @@ -844,9 +908,6 @@ async def _run_evolution_cycle(self) -> None: ) start_time = cycle_time - # Track git HEAD to detect empty (NTE) cycles - git_head_before = self._get_git_head() - try: ws = await connect_to_server() logger.info("TaskHandler[%s]: connected", self.name) @@ -889,6 +950,7 @@ async def _run_evolution_cycle(self) -> None: tool_count = 0 error = None truncated = False + completion_content = "" try: await ws.send(task_msg) @@ -908,6 +970,7 @@ async def _run_evolution_cycle(self) -> None: # wrongly advancing the idle-halt backoff (mem repo lesson: # truncation must be flagged, not silently treated as done). content = resp.get("content") or "" + completion_content = content truncated = "exceeded" in content.lower() if truncated: logger.warning( @@ -932,6 +995,17 @@ async def _run_evolution_cycle(self) -> None: self.name, error, ) break + + # Empty-cycle detection (rant 2026-08-17T11:39:19): after a clean + # completion, ask the agent via task_vibe_check whether the round + # was meaningful. Done on the SAME ws connection (daemon replies + # with vibe_check_result). Any failure → None → counter untouched. + vibe_result = None + if not error and not truncated: + vibe_result = await self._request_vibe_check( + ws, prompt=prompt, + completion_summary=completion_content[:3000], + ) except Exception as e: logger.exception("TaskHandler[%s] error", self.name) error = str(e) @@ -941,28 +1015,56 @@ async def _run_evolution_cycle(self) -> None: except Exception: pass - # Detect empty cycles: git HEAD unchanged → no work was done. - # A truncated cycle is NOT empty — the agent wanted to work but hit - # the tool-round cap; counting it would wrongly back off the handler. + # Empty-cycle accounting (rant 2026-08-17T11:39:19): the AGENT decides + # whether the round was meaningful (task_vibe_check structured answer), + # not git HEAD — HEAD compares commits, so an agent that did analysis / + # memory work without a commit was miscounted as empty, and a no-op + # round over someone else's push counted as work. + # - meaningful: false → empty cycle (advance the backoff) + # - meaningful: true → reset the empty streak (+ slowdown votes) + # - recommend_slowdown: true → +1 slowdown vote (3 votes tighten + # the saturation threshold from 30 to 10) + # - vibe check unavailable (ok=false / timeout / parse error) → + # conservative: count unchanged (neither advance nor reset) + # A truncated cycle is NOT empty — the agent wanted to work but hit the + # tool-round cap; counting it would wrongly back off the handler. # An aborted cycle (server error like "session busy", or an exception) # is NOT empty either — the agent was blocked before reaching an NTE # conclusion; counting it would also advance the idle-halt backoff. - git_head_after = self._get_git_head() - if ( - not error - and not truncated - and git_head_before and git_head_after - and git_head_before == git_head_after - ): - self._empty_cycles += 1 - self._save_saturation_state() - logger.debug( - "TaskHandler[%s]: empty cycle #%d (HEAD=%s)", - self.name, self._empty_cycles, git_head_after[:8], + if not error and not truncated and vibe_result is not None: + meaningful = vibe_result.get("meaningful") + recommend = bool(vibe_result.get("recommend_slowdown")) + if meaningful is False: + self._empty_cycles += 1 + if recommend: + self._slowdown_hits += 1 + self._save_saturation_state() + logger.info( + "TaskHandler[%s]: empty cycle #%d (agent: %s%s)", + self.name, self._empty_cycles, + (vibe_result.get("reason") or "")[:100], + f"; slowdown votes {self._slowdown_hits}/{self._SLOWDOWN_VOTES_TO_TIGHTEN}" + if recommend else "", + ) + elif meaningful is True: + if self._empty_cycles > 0 or self._slowdown_hits > 0: + logger.info( + "TaskHandler[%s]: agent reported meaningful work, " + "resetting empty streak (%d) + slowdown votes (%d)", + self.name, self._empty_cycles, self._slowdown_hits, + ) + self._empty_cycles = 0 + self._slowdown_hits = 0 + self._save_saturation_state() + elif not error and not truncated: + # vibe check failed/timeout — conservative: don't count, don't reset + logger.info( + "TaskHandler[%s]: vibe check unavailable — empty streak unchanged", + self.name, ) else: - if self._empty_cycles > 0: - reason = "truncated cycle" if truncated else "git HEAD changed" + if self._empty_cycles > 0 or self._slowdown_hits > 0: + reason = "truncated cycle" if truncated else "aborted cycle" if error: reason = f"aborted cycle ({error[:80]})" logger.info( @@ -970,6 +1072,7 @@ async def _run_evolution_cycle(self) -> None: self.name, reason, ) self._empty_cycles = 0 + self._slowdown_hits = 0 self._save_saturation_state() # Aborted cycles are not evolutions: no log file, no count. Writing diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index baf1c6e..709fb8c 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1507,15 +1507,22 @@ def test_evolution_cycle_truncated_not_empty_not_complete(tmp_path): assert not any(i.endswith("-complete") for i in impact), impact -def test_evolution_cycle_complete_unchanged_head_still_empty(tmp_path): - """Normal completion with unchanged HEAD keeps the existing empty-cycle semantics.""" +def test_evolution_cycle_complete_agent_says_not_meaningful_is_empty(tmp_path): + """Clean completion + agent vibe check meaningful=false → empty cycle. + + Rant 2026-08-17T11:39:19: the AGENT (task_vibe_check structured answer) + decides emptiness, not git HEAD. + """ handler, captured = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Done", "done": True, "delta": False, "session_id": "s"}, + {"type": "vibe_check_result", "ok": True, + "result": {"meaningful": False, "recommend_slowdown": False, + "reason": "nothing to evolve"}}, ]) asyncio.run(handler._run_evolution_cycle()) assert handler._empty_cycles == 1, \ - "unchanged-HEAD complete cycle is still counted as empty (existing behavior)" + "agent-reported meaningless complete cycle is counted as empty" impact = captured["log"].impact assert any(i.endswith("-complete") for i in impact), impact assert any(i.startswith("cycle-") for i in impact), \ @@ -1523,6 +1530,77 @@ def test_evolution_cycle_complete_unchanged_head_still_empty(tmp_path): assert "truncated=max-tool-rounds" not in impact, impact +def test_evolution_cycle_complete_agent_says_meaningful_resets_streak(tmp_path): + """Agent reports meaningful work → empty streak + slowdown votes reset. + + A round that produced value (analysis/memory/decision without a commit) + must NOT count as empty — the git-HEAD heuristic's core false positive. + """ + handler, captured = _make_cycle_handler(tmp_path, frames=[ + {"request_id": "r1", "content": "Analyzed the issue and wrote memory", + "done": True, "delta": False, "session_id": "s"}, + {"type": "vibe_check_result", "ok": True, + "result": {"meaningful": True, "recommend_slowdown": False, + "reason": "completed analysis"}}, + ]) + handler._empty_cycles = 5 + handler._slowdown_hits = 2 + asyncio.run(handler._run_evolution_cycle()) + assert handler._empty_cycles == 0, "meaningful work resets the empty streak" + assert handler._slowdown_hits == 0, "meaningful work resets slowdown votes" + assert "log" in captured + + +def test_evolution_cycle_vibe_unavailable_streak_unchanged(tmp_path): + """Vibe check unavailable (timeout/failure) → counter neither advances nor resets. + + Conservative: a failed question must not cause a wrong slowdown NOR a + wrong reset (rant 2026-08-17T11:39:19).""" + handler, captured = _make_cycle_handler(tmp_path, frames=[ + {"request_id": "r1", "content": "Done", "done": True, + "delta": False, "session_id": "s"}, + # no vibe_check_result frame → helper times out / connection closed + ]) + handler._empty_cycles = 3 + handler._slowdown_hits = 1 + asyncio.run(handler._run_evolution_cycle()) + assert handler._empty_cycles == 3, "vibe check failure must not advance the counter" + assert handler._slowdown_hits == 1, "vibe check failure must not reset votes" + assert "log" in captured, "main task still completed normally" + + +def test_evolution_cycle_agent_recommend_slowdown_accumulates(tmp_path): + """recommend_slowdown votes accumulate; 3 votes tighten the threshold. + + The saturation threshold drops from 30 to 10 when the agent keeps saying + the task has no value (rant 2026-08-17T11:39:19).""" + for i in range(3): + handler, _ = _make_cycle_handler(tmp_path, frames=[ + {"request_id": "r1", "content": "Done", "done": True, + "delta": False, "session_id": "s"}, + {"type": "vibe_check_result", "ok": True, + "result": {"meaningful": False, "recommend_slowdown": True, + "reason": "long-term no value"}}, + ]) + asyncio.run(handler._run_evolution_cycle()) + assert handler._slowdown_hits == i + 1, handler._slowdown_hits + assert handler._empty_cycles == i + 1, handler._empty_cycles + # 3 votes → tightened threshold (30 → 10) + assert handler._saturation_threshold() == 10, "3 slowdown votes must tighten the threshold" + assert handler._saturation_threshold() < handler._IDLE_HALT_THRESHOLD + + +def test_saturation_threshold_defaults_to_idle_halt(tmp_path): + """Below 3 slowdown votes the threshold stays at _IDLE_HALT_THRESHOLD (30).""" + handler = _make_handler(tmp_path, project="", path=str(tmp_path)) + assert handler._slowdown_hits == 0 + assert handler._saturation_threshold() == handler._IDLE_HALT_THRESHOLD + handler._slowdown_hits = 2 + assert handler._saturation_threshold() == handler._IDLE_HALT_THRESHOLD + handler._slowdown_hits = 3 + assert handler._saturation_threshold() == 10 + + def test_evolution_cycle_aborted_error_not_counted(tmp_path): """Server error frame (e.g. 'session busy') → no evolution log, no count.""" handler, captured = _make_cycle_handler(tmp_path, frames=[ @@ -1769,6 +1847,9 @@ def test_saturated_tick_still_runs_full_cycle(tmp_path): handler, captured = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Done", "done": True, "delta": False, "session_id": "s"}, + {"type": "vibe_check_result", "ok": True, + "result": {"meaningful": False, "recommend_slowdown": False, + "reason": "nothing to evolve"}}, ]) handler._empty_cycles = 30 # saturated fake = FakeGitRun(remote_head="abc123") # unchanged → stay saturated diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 3e7f857..332a820 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -167,6 +167,109 @@ async def _test(): asyncio.run(_test()) +class TestWSVibeCheck: + """task_vibe_check — one-shot structured LLM ask (rant 2026-08-17T11:39:19). + + The scheduler replaces its git-HEAD empty-cycle heuristic with an agent + answer: the daemon runs a single Ask-mode LLM call (no tools, no history) + and returns a strict-JSON {meaningful, recommend_slowdown, reason} result. + """ + + def test_vibe_check_returns_structured_result(self): + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + server, _, cleanup = await _boot_server(Path(tmp)) + try: + async def fake_chat(messages, tools=None): + # echo back a strict-JSON answer; fenced JSON tolerated + return {"content": '```json\n{"meaningful": false, "recommend_slowdown": true, "reason": "长期无产出"}\n```'} + server.llm.chat = fake_chat + + ws = await connect_to_server() + try: + await ws.send(json.dumps({ + "type": "task_vibe_check", + "session_id": "s-vibe", + "task_name": "emrg-task", + "prompt": "run the evolution cycle", + "completion_summary": "nothing to evolve", + }, ensure_ascii=False)) + frame = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(frame) + assert data.get("type") == "vibe_check_result" + assert data.get("ok") is True + result = data.get("result", {}) + assert result.get("meaningful") is False + assert result.get("recommend_slowdown") is True + assert result.get("reason") == "长期无产出" + # the ask must carry the fixed system prompt + no tools + sent = server.llm.chat + assert sent is fake_chat + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_vibe_check_bad_llm_answer_returns_ok_false(self): + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + server, _, cleanup = await _boot_server(Path(tmp)) + try: + async def fake_chat(messages, tools=None): + return {"content": "not json at all"} + server.llm.chat = fake_chat + + ws = await connect_to_server() + try: + await ws.send(json.dumps({ + "type": "task_vibe_check", + "session_id": "s-vibe", + "task_name": "t", + "prompt": "p", + "completion_summary": "c", + })) + frame = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(frame) + assert data.get("type") == "vibe_check_result" + assert data.get("ok") is False + assert "error" in data + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_vibe_check_llm_raises_returns_ok_false(self): + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + server, _, cleanup = await _boot_server(Path(tmp)) + try: + async def fake_chat(messages, tools=None): + raise RuntimeError("llm down") + server.llm.chat = fake_chat + + ws = await connect_to_server() + try: + await ws.send(json.dumps({ + "type": "task_vibe_check", + "session_id": "s-vibe", + "task_name": "t", + "prompt": "p", + "completion_summary": "c", + })) + frame = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(frame) + assert data.get("type") == "vibe_check_result" + assert data.get("ok") is False + assert "llm down" in data.get("error", "") + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + class TestWSProtocol: def test_bad_json_message_gets_error_frame(self): async def _test():