From 0d468273d0c19ccd0dc4d76d34319af44ce0120c Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 18:55:36 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20multi-session=20acceptance=20comp?= =?UTF-8?q?letion=20=E2=80=94=20project=20activity=20ordering=20+=20model?= =?UTF-8?q?=5Fset=20idempotency=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 6 ++-- README.cn.md | 4 +-- README.md | 4 +-- emrg/gui/test/renderer.smoke.test.js | 21 +++++++++++ emrg/server/daemon.py | 26 ++++++++++++-- emrg/server/evolution_prompt.md | 1 + tests/test_daemon.py | 53 ++++++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 9 deletions(-) diff --git a/Agent.md b/Agent.md index 20815f9..fe54cf5 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - 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` (175: 43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (176: 43 daemon_client + 19 conn-manager + 22 app-commands + 57 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -93,8 +93,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` (680) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (175: 43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +Python: `uv run pytest tests/ -v` (681) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (176: 43 daemon_client + 19 conn-manager + 22 app-commands + 57 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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/README.cn.md b/README.cn.md index 1579341..b6aedbd 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 680 项) +uv run pytest tests/ -v # 跑测试(当前 681 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(175 项:43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(176 项:43 daemon_client + 19 conn-manager + 22 app-commands + 57 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index b1c4b60..a8575c9 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 680 items) +uv run pytest tests/ -v # run tests (currently 681 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (175: 43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (176: 43 daemon_client + 19 conn-manager + 22 app-commands + 57 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 390dc0d..6a0b22c 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1476,3 +1476,24 @@ test("P6: switchSession 超限(too many open sessions)→ 本地化提示( ); assert.ok(!last.includes("close some first"), "raw english error must not leak: " + last); }); + +// ── P6 验收(rant 15:07:19):model_set 多连接重复收无副作用(幂等) ── + +test("P6: model_set 多连接重复广播幂等 — 状态一致、无副作用、重复收同值不崩", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + // 初始模型 m1 + await vm.runInContext('App.state.model = "m1"; App.updateModelSwitcher();', ctx); + // 连接 A 广播 model_set m2 + await vm.runInContext('App.handleEvent({ type: "command_result", sid: "conn-a", data: { type: "model_set", model: "m2" } });', ctx); + assert.strictEqual(ctx.App.state.model, "m2", "model updated after first broadcast"); + assert.strictEqual(els["model-switcher-label"].textContent, "m2", "switcher label updated"); + const msgsAfterA = els["chat-view"].children.length; + // 连接 B(另一会话连接)重复收同一 model_set → 无副作用(无新系统消息/无崩) + await vm.runInContext('App.handleEvent({ type: "command_result", sid: "conn-b", data: { type: "model_set", model: "m2" } });', ctx); + assert.strictEqual(ctx.App.state.model, "m2", "idempotent: same value, no change"); + assert.strictEqual(els["chat-view"].children.length, msgsAfterA, "no extra system messages from duplicate broadcast"); + // 再次重复(三连接场景)仍稳定 + await vm.runInContext('App.handleEvent({ type: "command_result", sid: "conn-c", data: { type: "model_set", model: "m2" } });', ctx); + assert.strictEqual(ctx.App.state.model, "m2", "still idempotent after third broadcast"); +}); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 4f7a378..97a07ab 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2454,6 +2454,11 @@ async def _handle_list_projects( ) -> None: """Read projects.yml and return all project entries. + Ordered by each project's latest session activity (created_at desc, + parallel scan) — P6 of the GUI multi-session rant (2026-08-10T15:07:19) + so the open/new-session dialogs show the most recently active projects + first. + No evolution-workspace filter (rant 2026-08-07T10:48:00): projects.yml only contains explicitly registered entries, and on packaged installs the emrg project's only path IS ~/.emrg/evolution/emrg — filtering it @@ -2466,11 +2471,28 @@ async def _handle_list_projects( if self._projects_log.exists(): data = yaml.safe_load(self._projects_log.read_text(encoding="utf-8")) if isinstance(data, list): + entries = [p for p in data if isinstance(p, dict)] + # P6 验收(rant 2026-08-10T15:07:19):项目按"该项目最新会话活跃"倒序 + # ——并行扫描各项目 sessions 目录取最大 created_at(GUI 单连接无法并发 + # list_sessions:_pending 按 respType 键控会互相覆盖,故 daemon 侧聚合)。 + async def _latest_session_at(entry: dict) -> str: + p = entry.get("path", "") + if not p: + return "" + try: + sessions = Session.list_sessions(Path(p)) + return sessions[0].get("created_at", "") if sessions else "" + except (OSError, ValueError, json.JSONDecodeError): + return "" + + ats = await asyncio.gather(*(_latest_session_at(e) for e in entries)) + ordered = sorted(zip(entries, ats), key=lambda t: t[1], reverse=True) projects = [ {"name": p.get("name", ""), "repo": _detect_git_remote(p.get("path", "")), - "path": p.get("path", "")} - for p in data if isinstance(p, dict) + "path": p.get("path", ""), + "latest_session_at": at} + for p, at in ordered ] except (yaml.YAMLError, OSError): logger.exception("Failed to read projects.yml") diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 1e4d81a..a50a874 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -393,6 +393,7 @@ When reading rants, follow these rules: > - GUI open-session dialog project→session (#641 GUI 多会话 rant 2026-08-10T15:07:19 P5 slice 1:打开会话弹窗两步——`showOpenSessionDialog`(listProjects → 项目行含路径 hint + 底部"+ 新建项目…")+ `showProjectSessions`(listProjectSessions(cwd=projectPath) → created_at 倒序 → 点击 switchSession 复用连接);main.js `emrg:listProjectSessions`/`emrg:registerProject` IPC(G121 目录可写校验;list_sessions(cwd) 轻量命令 → daemon 隐式 `_touch_project` 注册,零 daemon 改动,**不调 init_auto_evolve** 防意外建演化任务);preload 暴露两 API;app.js `/open` 指令(commands 15→16)+ bindUi 初始化;i18n zh/en 10 条 + cmd.open.hint;+2 测试 renderer.smoke 50→52(项目→会话下钻 / 无项目提示);GUI 167→169 三文档同步;680 pytest 全绿;P5 剩余=slice 2 新建会话 + 删除项目) ✅ > - GUI new-session dialog + delete-project protected guard (#642 GUI 多会话 rant 2026-08-10T15:07:19 P5 slice 2:**新建会话弹窗** `showNewSessionDialog`(listProjects 活跃序 → 点选即 `App.newSession({projectPath})`;底部"+ 新建项目…" → pickProjectDir → registerProject → 同路径新建)+ index.html `#new-session-dialog` + 打开弹窗顶部"+ 新建会话…"入口;**删除项目**:打开弹窗项目行右侧删除按钮 → 受保护守卫(内置 project `emrg` / 内置 task `emrg-task` 提示"系统项目不可删除",不调 API;`.emrg` 非内置可删)→ 确认弹窗(数据保留可恢复)→ main.js `emrg:removeProject` IPC(关闭该项目已打开会话连接 + 移出簿记 + 写盘 + 激活被关 → renderer 切相邻/新建 + 广播 open_sessions)→ daemon `remove_project`(P1 已备);**slice-1 补洞**:switchSession/sendMessage 带 per-session projectPath(跨项目 resume 用项目 cwd 非全局 projectDir);newSession 接受 projectPath(首条消息前即记簿记);i18n zh/en 11 条;+3 测试 renderer.smoke 52→55(新建会话选项目 / 新建项目→注册→新建 / 删除项目受保护+普通确认);GUI 169→172 三文档同步;680 pytest 全绿;P5 完成,P6 收尾待续) ✅ > - GUI multi-session P6 finalize (#643 GUI 多会话 rant 2026-08-10T15:07:19 收尾边界:①**关闭在忙连接先 cancel 再 close**——ConnManager.close() 检测 `conn.ownStream && conn.ws` 先发 cancel(fire-and-forget 吞断连异常,不阻塞同步 close 语义),防流式半途断线留脏状态;②**上限 20 超限提示不自动关**——switchSession 显式打开新会话时 `openSessions.size >= DEFAULT_CAP && !openSessions.has(sid)` → 抛 "too many open sessions (20) — close some first"(已打开 sid 复用不拦;sendMessage/newSession 创建路径不拦);renderer 识别该错误 → 本地化 `app.tooManyOpenSessions` zh/en(不再漏英文原始错误);③**projectPath 校验**——switchSession/newSession IPC 收 projectPath 时校验 string 非空;+3 测试(conn-manager 17→19:忙 close 发 cancel / 空闲 close 不 cancel;renderer.smoke 55→56:超限本地化提示);GUI 172→175 三文档同步;680 pytest 全绿;P1-P6 全部完成,验收清单剩余宿主实测项) ✅ +> - GUI multi-session acceptance completion (#644 GUI 多会话 rant 2026-08-10T15:07:19 验收补完两件:①**项目按最新会话活跃倒序**——daemon `_handle_list_projects` 并行扫描各项目 `/.emrg/sessions/*/meta.json` 取最大 created_at(asyncio.gather),projects 响应带 `latest_session_at` 并倒序(此前 projects.yml 按 path 排序、list_projects 按文件序返回,打开/新建会话弹窗"按最近活跃"的验收项实际未满足);GUI 单连接无法并发 list_sessions(DaemonClient `_pending` 按 respType 键控互相覆盖)→ daemon 侧聚合的正确架构;/rant 项目列表同步受益(最近活跃置顶);②**model_set 多连接重复广播幂等测试**——renderer.smoke 新增两/三连接同值广播:state.model 不变、无新系统消息、model-switcher-label 一致(验收"model_set 多连接重复收无副作用");+2 测试(pytest 680→681、renderer.smoke 55→56)GUI 172→173、三文档同步;681 pytest 全绿;P1-P6 验收项仅剩宿主实测类) ✅ #### 2.2 Latest GitHub code changes diff --git a/tests/test_daemon.py b/tests/test_daemon.py index f8f0b9b..592b99f 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1275,3 +1275,56 @@ def test_remove_project_corrupt_yaml_reports_error(tmp_path): replies = _decode_frames(writer) assert replies[0]["removed"] is False assert replies[0]["error"] + + +# ── list_projects ordering by latest session activity (P6, rant 15:07:19) ── + + +def test_list_projects_ordered_by_latest_session_activity(tmp_path, monkeypatch): + """Projects come back sorted by their newest session's created_at (desc). + + P6 acceptance ("选项目按该项目最新会话活跃倒序"): the daemon aggregates + each project's sessions dir (parallel scan) because the GUI cannot issue + concurrent list_sessions on one connection (pending map keys by respType). + """ + import asyncio + import json + + from emrg.server import daemon as dmod + + server = _make_server() + p_old = tmp_path / "old" + p_new = tmp_path / "new" + p_none = tmp_path / "nosess" + for p in (p_old, p_new, p_none): + p.mkdir() + # 老项目:最近会话 created_at 较早 + (p_old / ".emrg" / "sessions" / "s-old").mkdir(parents=True) + (p_old / ".emrg" / "sessions" / "s-old" / "meta.json").write_text( + json.dumps({"session_id": "s-old", "created_at": "2026-08-01T00:00:00"}), encoding="utf-8" + ) + # 新项目:最近会话 created_at 较晚 → 应排第一 + (p_new / ".emrg" / "sessions" / "s-new").mkdir(parents=True) + (p_new / ".emrg" / "sessions" / "s-new" / "meta.json").write_text( + json.dumps({"session_id": "s-new", "created_at": "2026-08-10T12:00:00"}), encoding="utf-8" + ) + # 无会话项目 → 排最后(无 latest_session_at) + projects_file = tmp_path / "projects.yml" + projects_file.write_text( + f"- name: old\n path: {p_old}\n" + f"- name: new\n path: {p_new}\n" + f"- name: nosess\n path: {p_none}\n", + encoding="utf-8", + ) + monkeypatch.setattr(server, "_projects_log", projects_file) + + writer = _FakeWriter() + asyncio.run(server._handle_list_projects(writer)) + + reply = json.loads(writer._frames[0]) + names = [p["name"] for p in reply["projects"]] + assert names == ["new", "old", "nosess"], f"ordered by latest session activity: {names}" + new_proj = next(p for p in reply["projects"] if p["name"] == "new") + assert new_proj["latest_session_at"] == "2026-08-10T12:00:00" + none_proj = next(p for p in reply["projects"] if p["name"] == "nosess") + assert none_proj["latest_session_at"] == ""