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` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); 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` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — 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 测试(153 项:43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(156 项:43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 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 (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; 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
11 changes: 11 additions & 0 deletions emrg/gui/renderer/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@
.session-view.active {
display: flex;
}
/* P3 finalize:会话连接断开标记(P4 每会话容器可见;激活会话另走全局横幅) */
.session-view.disconnected {
opacity: 0.55;
}
.session-view.disconnected::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(45deg, transparent 0 8px, rgba(128, 128, 128, 0.08) 8px 16px);
}
#chat-view > *:not(.session-view),
.session-view > * {
width: 100%;
Expand Down
32 changes: 26 additions & 6 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const App = (() => {
function sidState(sid) {
const key = sid || state.sessionId || "default";
if (!state.sessionsBySid.has(key)) {
state.sessionsBySid.set(key, { busy: false, ownStreamRequestId: null, mode: "auto", autoScroll: true });
state.sessionsBySid.set(key, { busy: false, ownStreamRequestId: null, mode: "auto", autoScroll: true, disconnected: false });
}
return state.sessionsBySid.get(key);
}
Expand Down Expand Up @@ -603,6 +603,10 @@ const App = (() => {
if (!opts.silent) {
Chat.addSystemMessage(_t("app.switched"), sid);
}
// P3 finalize:切入断线会话 → 提示自动重连中(状态保留,不打断输入——G89)
if (sidState(sid).disconnected) {
Chat.addSystemMessage(_t("app.sessionDisconnected"), sid);
}
updateEmptyState();
Sidebar.highlight(sid);
setComposerDisabled(false); // 防御性:独立调用 switchSession 也确保输入框可用
Expand Down Expand Up @@ -1074,15 +1078,26 @@ const App = (() => {
Sidebar.render(data.sessions || []);
break;
case "disconnected":
updateConnectionDot("red");
showBanner(EMRG_Copy.COPY.disconnected);
// G89:断连时恢复输入条(不能依赖 30s 超时兜底)——仅当激活会话断连
// P3 slice 1:断连释放该事件所属会话的锁
// P3 finalize:断连按 sid 隔离——后台会话断连不影响全局 UI(无横幅/红点),
// 仅激活会话(或无 sid 的单会话过渡期)断连显示全局横幅 + 红点。
{
const sst = sidState(sid);
sst.busy = false;
sst.ownStreamRequestId = null;
if (!sid || sid === state.sessionId) setComposerDisabled(false);
sst.disconnected = true; // P3 finalize:该会话条目标断线(P4 恢复 UI 用)
const isActive = !sid || sid === state.sessionId;
if (isActive) {
updateConnectionDot("red");
showBanner(EMRG_Copy.COPY.disconnected);
// G89:断连时恢复输入条(不能依赖 30s 超时兜底)
setComposerDisabled(false);
}
// 容器标断线(仅当该 sid 有**独立注册**容器——chatContainer 对未注册
// sid 回退激活容器,打错标;P4 openSessions 后每会话都有注册容器)
if (Chat.hasContainer(sid)) {
const cv = Chat.chatContainer(sid);
if (cv && cv.classList) cv.classList.add("disconnected");
}
}
// P3:广播分组缓存清理按会话隔离(DOM 保留;仅清该会话 Map 引用;无 sid → 默认桶)
Chat.groupNodesFor(sid).clear();
Expand Down Expand Up @@ -1133,6 +1148,11 @@ const App = (() => {
if (data.connected) {
updateConnectionDot("green");
hideBanner();
// P3 finalize:重连成功 → 清全部会话断线标记 + 容器 .disconnected 类
for (const entry of state.sessionsBySid.values()) entry.disconnected = false;
for (const child of $("chat-view").children) {
if (child.classList && child.classList.contains("disconnected")) child.classList.remove("disconnected");
}
if (data.server_id) state.serverId = data.server_id;
if (data.model) state.model = data.model;
state.evolutionCount = data.evolution_count ?? state.evolutionCount;
Expand Down
5 changes: 5 additions & 0 deletions emrg/gui/renderer/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const Chat = (() => {
containers.delete(sid);
sessionState.delete(sid); // 会话关闭 → 释放其分组/工具行/已 done 状态
}
/** 该 sid 是否有**独立注册**容器(区别于 chatContainer 的回退链——断线标记只打真实容器) */
function hasContainer(sid) {
return containers.has(sid);
}

/** 复制代码按钮(设计 §3.3):事件委托在聊天区,CSP 无内联 handler */
function initCodeCopy() {
Expand Down Expand Up @@ -318,6 +322,7 @@ const Chat = (() => {
clearTyping,
registerContainer,
unregisterContainer,
hasContainer,
chatContainer,
// P3:会话级状态访问器(sid=null → 旧版单会话桶,兼容既有调用方)
groupNodesFor: (sid) => st(sid).groupNodes,
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 @@ -288,6 +288,7 @@ const I18N = (() => {
"app.triggered": "已触发任务 {n}。",
"app.deletedSwitch": "这个对话已被删除,已帮你切到最近的对话。",
"app.switched": "已切换对话。",
"app.sessionDisconnected": "该会话连接已断开,正在自动重连…",
"app.switchFailed": "切换对话失败了:{msg}",
"app.newFailed": "新建对话失败了:{msg}",
"app.deleteFailed": "删除失败了:{msg}",
Expand Down Expand Up @@ -580,6 +581,7 @@ const I18N = (() => {
"app.triggered": "Task {n} triggered.",
"app.deletedSwitch": "This conversation was deleted — switched to the most recent one.",
"app.switched": "Conversation switched.",
"app.sessionDisconnected": "This session's connection is lost — reconnecting automatically…",
"app.switchFailed": "Failed to switch: {msg}",
"app.newFailed": "Failed to create conversation: {msg}",
"app.deleteFailed": "Delete failed: {msg}",
Expand Down
83 changes: 83 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1133,3 +1133,86 @@ test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离
assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-x").size, 0, "active bucket untouched by other sid");
});

// ── P3 finalize(rant 15:07:19):disconnected 按 sid 隔离 + 断线标记 ────

test("P3 fin: 后台会话断连不触发全局横幅;仅激活会话断连显示", async () => {
const { ctx, els } = makeSandbox({});
await tick();
els["conn-banner"].classList.add("hidden"); // index.html 初始 hidden
await vm.runInContext(
'App.state.sessionId = "sess-active";' +
'App.activateSessionView("sess-active");' +
'App.activateSessionView("sess-bg");' +
'App.state.sessionId = "sess-active";',
ctx
);
// 后台会话断连:有独立注册容器 → 标 .disconnected;无全局横幅/红点
await vm.runInContext('App.handleEvent({ type: "disconnected", sid: "sess-bg", data: {} });', ctx);
const vb = ctx.EMRG_Chat.chatContainer("sess-bg");
assert.strictEqual(vb.classList.contains("disconnected"), true, "bg container marked disconnected");
assert.strictEqual(els["conn-banner"].classList.contains("hidden"), true, "no global banner for bg disconnect");
// 激活会话断连:全局横幅显示 + 容器标断线 + 输入条恢复(G89)
await vm.runInContext('App.handleEvent({ type: "disconnected", sid: "sess-active", data: {} });', ctx);
const va = ctx.EMRG_Chat.chatContainer("sess-active");
assert.strictEqual(va.classList.contains("disconnected"), true, "active container marked disconnected");
assert.strictEqual(els["conn-banner"].classList.contains("hidden"), false, "global banner shown for active disconnect");
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-bg").disconnected, true, "bg entry flagged");
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-active").disconnected, true, "active entry flagged");
});

test("P3 fin: status connected 清除全部断线标记 + 容器类", async () => {
const { ctx, els } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-a";' +
'App.activateSessionView("sess-a");' +
'App.activateSessionView("sess-b");' +
'App.handleEvent({ type: "disconnected", sid: "sess-a", data: {} });' +
'App.handleEvent({ type: "disconnected", sid: "sess-b", data: {} });',
ctx
);
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-a").disconnected, true);
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-b").disconnected, true);
await vm.runInContext('App.handleEvent({ type: "status", data: { connected: true } });', ctx);
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-a").disconnected, false, "flag cleared on reconnect");
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-b").disconnected, false, "bg flag cleared on reconnect");
const va = ctx.EMRG_Chat.chatContainer("sess-a");
const vb = ctx.EMRG_Chat.chatContainer("sess-b");
assert.strictEqual(va.classList.contains("disconnected"), false, "container class removed");
assert.strictEqual(vb.classList.contains("disconnected"), false, "bg container class removed");
assert.strictEqual(els["conn-banner"].classList.contains("hidden"), true, "banner hidden after reconnect");
});

test("P3 fin: 未注册容器 sid 断连不打标;切到断线会话提示重连", async () => {
const { ctx, els } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-active";' +
'App.activateSessionView("sess-active");',
ctx
);
const va = ctx.EMRG_Chat.chatContainer("sess-active");
const lenBefore = va.children.length;
// 未注册容器(且非激活)的 sid 断连:不打 .disconnected(防 chatContainer 回退误标激活容器)
await vm.runInContext('App.handleEvent({ type: "disconnected", sid: "sess-ghost", data: {} });', ctx);
assert.strictEqual(va.classList.contains("disconnected"), false, "active container not falsely marked");
assert.strictEqual(va.children.length, lenBefore, "no node added to active container");
// 断线会话切入 → 提示自动重连(switchSession 走 mock IPC)
await vm.runInContext(
'App.state.sessionId = "sess-d";' + // 经 defineProperty setter 触发 sidState get-or-create 建条目
'App.state.busy = false;' +
'App.state.sessions = [{ session_id: "sess-d" }];' +
'App.state.sessionsBySid.get("sess-d").disconnected = true;',
ctx
);
await vm.runInContext('App.switchSession("sess-d", { silent: true });', ctx);
await tick();
const vd = ctx.EMRG_Chat.chatContainer("sess-d");
assert.ok(vd.children.length >= 1, "switch renders reconnect notice into session container");
assert.ok(
(vd.children[vd.children.length - 1].textContent || "").includes("重连") || (vd.children[vd.children.length - 1].textContent || "").includes("reconnect"),
"reconnect notice text present"
);
});


Loading