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 @@ -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)
Expand All @@ -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 路径不受影响)

Expand Down
4 changes: 2 additions & 2 deletions README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,15 +274,15 @@ 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 即失败

# 可选:Electron GUI(非开发者主入口,Phase 3)
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`)。
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,15 +273,15 @@ 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

# Optional: Electron GUI (non-developer entry point, Phase 3)
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`).
Expand Down
21 changes: 21 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
26 changes: 24 additions & 2 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions emrg/server/evolution_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 并行扫描各项目 `<path>/.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

Expand Down
53 changes: 53 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == ""
Loading