diff --git a/Agent.md b/Agent.md index afe1a0c..dcf2b25 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (246: 44 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (247: 45 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -118,8 +118,8 @@ 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` (807) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (246: 44 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +Python: `uv run pytest tests/ -v` (810) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (247: 45 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 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/gui/daemon_client.js b/emrg/gui/daemon_client.js index 78d827f..3ccfc27 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -523,7 +523,9 @@ class DaemonClient { } sendCommand(type, params = {}) { - this.ws.send(JSON.stringify({ type, ...params })); + // Wire message type last: a payload field named "type" (e.g. the task type in + // task CRUD) must never override the wire message type (rant 2026-08-14T21:48:00). + this.ws.send(JSON.stringify({ ...params, type })); } // G93/G103:命令-响应配对(pending FIFO,按响应帧 type 配对)。 diff --git a/emrg/gui/main.js b/emrg/gui/main.js index d64e662..527a6e3 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -626,7 +626,7 @@ vision = false if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); const frame = await requireConn().sendCommandAndWait("task_create", { - name: name.trim(), type: type.trim(), project: project.trim(), + name: name.trim(), task_type: type.trim(), project: project.trim(), interval, enabled, repo, description, }, 8000); if (!frame.ok && frame.error) throw new Error(frame.error); @@ -634,8 +634,10 @@ vision = false }); ipcMain.handle("emrg:taskUpdate", async (_e, payload) => { - const { name, ...fields } = payload || {}; + const { name, type, ...fields } = payload || {}; if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + if (type !== undefined && typeof type !== "string") throw new Error("invalid task type"); + if (type !== undefined && type.trim()) fields.task_type = type.trim(); const frame = await requireConn().sendCommandAndWait("task_update", { name: name.trim(), ...fields }, 8000); if (!frame.ok && frame.error) throw new Error(frame.error); return frame; diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 15dc371..c65b873 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -576,6 +576,18 @@ test("sendCommand payload + cancel 无多余字段(G24)", async () => { assert.deepStrictEqual(f2, { type: "set_model", model: "gpt-4o" }); }); +test("sendCommand 帧形状:payload 带 type 字段不覆盖消息类型(rant 2026-08-14T21:48)", async () => { + // task CRUD payload 含任务类型字段 type(如 "evolution")——消息类型必须保留, + // 否则 daemon 路由失败返回 unknown message type,GUI 保存无响应。 + const client = new DaemonClient({ projectDir: tmpHome }); + await connectClient(client); + client.sendCommand("task_create", { type: "evolution", name: "t1", project: "p1" }); + const frame = JSON.parse(currentMockWs.sent.at(-1)); + assert.strictEqual(frame.type, "task_create", "wire 消息类型必须保留,不被 payload 的 type 覆盖"); + assert.strictEqual(frame.name, "t1"); + assert.strictEqual(frame.type, "task_create"); +}); + test("帧分类(G21+G58):各帧事件分发正确", async () => { const client = new DaemonClient({ projectDir: tmpHome }); await connectClient(client); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 5220de2..8267770 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1267,7 +1267,7 @@ async def _process_message( return ok, res = self._scheduler.task_create( name=msg.get("name", "").strip(), - task_type=msg.get("type", "").strip(), + task_type=msg.get("task_type", "").strip(), project=msg.get("project", "").strip(), interval=msg.get("interval"), enabled=msg.get("enabled", True), @@ -1284,7 +1284,9 @@ async def _process_message( if not self._scheduler: await self._send(ws, {"type": "task_result", "error": "scheduler not running"}) return - fields = {k: msg[k] for k in ("type", "project", "interval", "enabled", "repo", "description") if k in msg} + fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg} + if "task_type" in fields: + fields["type"] = fields.pop("task_type") ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields) if not ok: await self._send(ws, {"type": "task_result", "error": res}) diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 4f357ba..3e7f857 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -1262,3 +1262,120 @@ async def _test(): finally: await cleanup() asyncio.run(_test()) + + +class TestWSTaskWire: + """GUI task CRUD wire frames — task type field contract (rant 2026-08-14T21:48:00). + + The GUI sends the task type under `task_type` (never `type`) so it cannot + collide with the wire message type. The daemon must read `task_type` — + reading `msg["type"]` would swallow the wire message type after the + sendCommand fix (`{ ...params, type }`), e.g. writing "task_update" as the + task type. + """ + + @staticmethod + async def _cmd(ws, payload): + await ws.send(json.dumps(payload)) + return json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + + @staticmethod + def _mock_scheduler(create_side_effect=None, update_side_effect=None): + """Scheduler mock with async methods the daemon awaits (apply_tasks/wait_all).""" + from unittest.mock import AsyncMock, Mock + sched = Mock() + sched.task_create = Mock(side_effect=create_side_effect or (lambda **kw: (True, {"name": "x"}))) + sched.task_update = Mock(side_effect=update_side_effect or (lambda name, **fields: (True, {"name": name}))) + sched.apply_tasks = AsyncMock(return_value="") + sched.wait_all = AsyncMock() + sched.stop_all = Mock() + sched._load_tasks = Mock(return_value=[]) + return sched + + def test_task_create_reads_task_type(self): + """task_create with task_type → scheduler receives task_type, not the wire type.""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + server, _, cleanup = await _boot_server(cwd) + try: + calls = {} + + def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None): + calls.update(name=name, task_type=task_type, project=project) + return True, {"name": name, "type": task_type} + + server._scheduler = self._mock_scheduler(create_side_effect=fake_create) + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "task_create", "name": "daily", + "task_type": "evolution", "project": "emrg", + }) + assert resp["type"] == "task_result" + assert resp.get("ok") is True + assert calls["task_type"] == "evolution", "daemon must read task_type" + assert calls["project"] == "emrg" + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_task_create_missing_task_type_not_read_from_wire_type(self): + """Missing task_type → scheduler receives '' (never the wire type 'task_create').""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + server, _, cleanup = await _boot_server(cwd) + try: + calls = {} + + def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None): + calls.update(name=name, task_type=task_type, project=project) + return True, {"name": name, "type": task_type} + + server._scheduler = self._mock_scheduler(create_side_effect=fake_create) + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "task_create", "name": "daily", "project": "emrg", + }) + assert resp["type"] == "task_result" + assert calls["task_type"] == "", "must not fall back to wire type 'task_create'" + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_task_update_maps_task_type_to_type(self): + """task_update with task_type → scheduler receives fields['type'] (its internal name).""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + server, _, cleanup = await _boot_server(cwd) + try: + calls = {} + + def fake_update(name, **fields): + calls.update(name=name, fields=fields) + return True, {"name": name, "type": fields.get("type", "evolution")} + + server._scheduler = self._mock_scheduler(update_side_effect=fake_update) + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "task_update", "name": "daily", + "task_type": "open-source", "interval": 300, + }) + assert resp["type"] == "task_result" + assert resp.get("ok") is True + assert calls["fields"]["type"] == "open-source", "task_type mapped to scheduler type field" + assert "task_type" not in calls["fields"] + assert calls["fields"]["interval"] == 300 + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test())