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
6 changes: 3 additions & 3 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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/`)
Expand Down Expand Up @@ -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 路径不受影响)

Expand Down
4 changes: 3 additions & 1 deletion emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 配对)。
Expand Down
6 changes: 4 additions & 2 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -626,16 +626,18 @@ 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);
return frame;
});

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;
Expand Down
12 changes: 12 additions & 0 deletions emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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})
Expand Down
117 changes: 117 additions & 0 deletions tests/test_ws_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Loading