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
4 changes: 2 additions & 2 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` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 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` (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`
- **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 Down Expand Up @@ -94,7 +94,7 @@ 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` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 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`
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`
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
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI
cd emrg/gui
npm ci # 安装依赖(生产模式可 --omit=dev)
npm start # 启动 GUI(自动拉起 daemon)
npm test # 运行 Node 测试(172 项:43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration)
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)
```

CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 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 (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)
```

CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`).
Expand Down
6 changes: 6 additions & 0 deletions emrg/gui/conn-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,16 @@ class ConnManager {
// close(sid):conn.close(断开 ws)→ 移除。返回是否有关闭对象。
// 标记 _intentionalClose:主动关闭(切走/删除)不触发 renderer 断线横幅
// (桥检查该标记;真断连/daemon 重启的 disconnected 照常转发)。
// P6(rant 15:07:19 边界):关闭在忙连接先 cancel 再 close——流式进行中
// (ownStream)先发 cancel 让 daemon 停流,再断 ws,防半途断线留脏状态
// (fire-and-forget:断连/ws 已 null 时忽略,不阻塞同步 close 语义)。
close(sid) {
const entry = this._conns.get(sid);
if (!entry) return false;
this._cancelSingleRetry(sid); // 主动关闭 → 取消该会话的独立退避
if (entry.conn.ownStream && entry.conn.ws) {
try { entry.conn.sendCommand("cancel"); } catch { /* 断连时忽略 */ }
}
entry.conn._intentionalClose = true;
entry.conn.close();
this._conns.delete(sid);
Expand Down
14 changes: 13 additions & 1 deletion emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { spawn } = require("child_process");
const { parse: parseToml, stringify: stringifyToml } = require("smol-toml");
const { generateSessionId, SESSION_ID_RE } = require("./daemon_client");
const { ConnManager } = require("./conn-manager");
const { guiStatePath, sanitizeOpenSessions, saveGuiState } = require("./gui-state");
const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state");
const APP_VERSION = require("./package.json").version;

// ── 单实例锁(G85/G120:第二个实例退出并 focus 已有窗口)──
Expand Down Expand Up @@ -299,6 +299,14 @@ vision = false

ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
// P6(rant 15:07:19 边界):projectPath 校验(跨项目打开时传项目路径)
if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) {
throw new Error("invalid project path");
}
// P6(rant 15:07:19 上限 20):显式打开新会话超限 → 提示不自动关(已打开 sid 复用不拦)
if (openSessions.size >= DEFAULT_CAP && !openSessions.has(sessionId)) {
throw new Error(`too many open sessions (${DEFAULT_CAP}) — close some first`);
}
// G65:自有流运行中禁止切会话(每连接独立锁,查当前激活连接)
if (connManager?.get(currentSessionId)?.ownStream) throw new Error("stream in progress — cannot switch");
const prevSid = currentSessionId;
Expand Down Expand Up @@ -359,6 +367,10 @@ vision = false
}));

ipcMain.handle("emrg:newSession", async (_e, { projectPath } = {}) => {
// P6(rant 15:07:19 边界):projectPath 校验(新建会话指定项目时)
if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) {
throw new Error("invalid project path");
}
// G14/G81:本地生成 session_id(无 new_session 消息)
const sid = generateSessionId();
// 同步 main 侧会话状态:重连后 resume 正确会话(G41)+ 窗口标题(G109)
Expand Down
3 changes: 2 additions & 1 deletion emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,8 @@ const App = (() => {
Sidebar.highlight(sid);
setComposerDisabled(false); // 防御性:独立调用 switchSession 也确保输入框可用
} catch (e) {
Chat.addSystemMessage(_t("app.switchFailed", { msg: e.message }));
// P6(rant 15:07:19 上限 20):超限提示本地化(main 抛 too many open sessions)
Chat.addSystemMessage(/too many open sessions/i.test(e.message || "") ? _t("app.tooManyOpenSessions") : _t("app.switchFailed", { msg: e.message }));
}
}

Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ const I18N = (() => {
"app.switched": "已切换对话。",
"app.sessionDisconnected": "该会话连接已断开,正在自动重连…",
"app.switchFailed": "切换对话失败了:{msg}",
"app.tooManyOpenSessions": "打开的会话已达上限(20),请先关闭一些再打开。",
"app.newFailed": "新建对话失败了:{msg}",
"app.deleteFailed": "删除失败了:{msg}",
"app.rename": "✏️ 重命名",
Expand Down Expand Up @@ -632,6 +633,7 @@ const I18N = (() => {
"app.switched": "Conversation switched.",
"app.sessionDisconnected": "This session's connection is lost — reconnecting automatically…",
"app.switchFailed": "Failed to switch: {msg}",
"app.tooManyOpenSessions": "Too many open sessions (20) — close some first.",
"app.newFailed": "Failed to create conversation: {msg}",
"app.deleteFailed": "Delete failed: {msg}",
"app.rename": "✏️ Rename",
Expand Down
23 changes: 23 additions & 0 deletions emrg/gui/test/conn-manager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,29 @@ test("P2 close: 主动关闭标记 _intentionalClose 且不触发重启恢复",
assert.strictEqual(recoverCalls, 0, "intentional close must not trigger restart recovery");
});

test("P6 close: 在忙连接(ownStream)先 cancel 再 close", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
// 停用自动恢复(本测试专注 close 行为;单连接全关会触发重启判定)
manager.recoverAll = async () => {};
const busy = await driveOpen(manager, "sess-busy", "/proj/a");
busy.conn.ownStream = true;
busy.sessionWs.sent.length = 0; // 清空历史帧,聚焦 close 行为
assert.strictEqual(manager.close("sess-busy"), true);
const lastFrame = JSON.parse(busy.sessionWs.sent.at(-1));
assert.strictEqual(lastFrame.type, "cancel", "busy close must send cancel before disconnect");
assert.strictEqual(busy.conn.connected, false, "close must disconnect the ws");
});

test("P6 close: 空闲连接 close 不 cancel", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
manager.recoverAll = async () => {};
const idle = await driveOpen(manager, "sess-idle", "/proj/a");
idle.sessionWs.sent.length = 0;
assert.strictEqual(manager.close("sess-idle"), true);
assert.strictEqual(idle.sessionWs.sent.length, 0, "idle close must not send cancel");
assert.strictEqual(idle.conn.connected, false, "close must disconnect the ws");
});

test("P2 open: 断连残留连接 → 关闭重开(不返回 stale conn)", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
const s1 = await driveOpen(manager, "sess-1", "/proj/a");
Expand Down
19 changes: 19 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1457,3 +1457,22 @@ test("P5 slice 2: 删除项目 — 受保护 emrg 提示不可删;普通项目
assert.strictEqual(removeCalls[0] && removeCalls[0].name, "mem", "project name passed");
assert.strictEqual(removeCalls[0] && removeCalls[0].path, "/p/mem", "project path passed");
});

// ── P6(rant 15:07:19):上限 20 超限提示本地化 + 边界 ──

test("P6: switchSession 超限(too many open sessions)→ 本地化提示(非英文原始错误)", async () => {
const { ctx, els } = makeSandbox({
switchSession: async () => { throw new Error("too many open sessions (20) — close some first"); },
});
await tick();
await vm.runInContext('App.switchSession("s-over", { projectPath: "/p/x" })', ctx);
await tick();
// Chat.addSystemMessage 渲染进 chat-view 容器(系统消息节点 textContent)
const texts = els["chat-view"].children.map((c) => c.textContent || "");
const last = texts[texts.length - 1] || "";
assert.ok(
last.includes("上限") || last.includes("Too many open sessions"),
"localized over-limit message shown, got: " + last
);
assert.ok(!last.includes("close some first"), "raw english error must not leak: " + last);
});
1 change: 1 addition & 0 deletions emrg/server/evolution_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ When reading rants, follow these rules:
> - GUI open-sessions sidebar + gui_state restore (#639 GUI 多会话 rant 2026-08-10T15:07:19 P4 slice 2 读路径/侧边栏:main.js `restoreOpenSessions`(init 读 gui_state.json → 重开有效条目 cap 20 + resume 重订阅 → 失效跳过 + 重写盘 → activeSid 恢复/回退最近有效 → 窗口标题同步)+ `broadcastOpenSessions`(打开/激活/关闭/删除/新建每次变更推 `open_sessions` 事件给 renderer)+ init 返回 `open_sessions` + `active_sid`(renderer 直接采用恢复激活会话,免 switchSession IPC 往返);sidebar.js `renderOpenSessions`(侧边栏顶部跨项目打开会话区:项目名/会话标题、lastActive 倒序、激活高亮、点击切换、右键关闭保留数据/重命名/删除)+ highlight 扩展覆盖;app.js `state.openSessions` + `open_sessions` 事件 + `closeOpenSession`(断开+释放容器+保留磁盘数据;关激活会话 → 切最近打开会话否则新建)+ showOpenSessionsMenu;index.html `#open-sessions` + i18n zh/en + CSS;+4 测试 renderer.smoke 46→50 + mock 升级(querySelectorAll DFS 类选择器 + classList.toggle 忠实 force);GUI 163→167 三文档同步;680 pytest 全绿;P4 完成,P5-P6 待续) ✅
> - 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 全部完成,验收清单剩余宿主实测项) ✅

#### 2.2 Latest GitHub code changes

Expand Down
Loading