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 numberDiff line numberDiff 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` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands); 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 DownExpand Up@@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (508) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — 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 numberDiff line numberDiff 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 测试(58 项:22 daemon_client + 7 integration + 25 renderer smoke + 4 app-commands;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(91 项:22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 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 numberDiff line numberDiff 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 (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands; 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
26 changes: 18 additions & 8 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -556,21 +556,31 @@ vision = false
// G122:message_delta 16ms 批量推送
let deltaBuf = [];
let deltaTimer = null;
// rant 14:11:冲刷 delta 缓冲——终态事件(done/error/cancelled)直通不走缓冲,
// 若残留 delta 在 16ms 定时器之后才 flush,会晚于终态到达渲染层 →
// handleDelta 找不到 group 节点 → 建孤儿节点(误标"来自其他客户端")+ 光标永不消失。
const flushDeltaBuf = () => {
if (deltaTimer) {
clearTimeout(deltaTimer);
deltaTimer = null;
}
if (deltaBuf.length && win && !win.isDestroyed()) {
const chunks = deltaBuf;
deltaBuf = [];
win.webContents.send("emrg:event", { type: "message_delta", data: { chunks } });
}
};
client.onEvent((type, data) => {
if (type === "message_delta") {
deltaBuf.push(data);
if (!deltaTimer) {
deltaTimer = setTimeout(() => {
const chunks = deltaBuf;
deltaBuf = [];
deltaTimer = null;
if (win && !win.isDestroyed()) {
win.webContents.send("emrg:event", { type: "message_delta", data: { chunks } });
}
}, 16);
deltaTimer = setTimeout(flushDeltaBuf, 16);
}
return;
}
if (type === "done" || type === "error" || type === "cancelled") {
flushDeltaBuf(); // 终态前先清空缓冲:delta 保证不晚于终态(webContents.send 保序)
}
if (type === "done") {
// 仅自有流的 done 释放 G65 锁(广播 done 不影响);timeout 兜底同样只清自有
if (data.request_id === ownStreamRequestId || (data.timeout && ownStream)) {
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -935,6 +935,7 @@ const App = (() => {
ResultPanel.addToolResult(data);
break;
case "cancelled":
Chat.clearTyping(); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清)
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
Expand DownExpand Up@@ -1003,6 +1004,7 @@ const App = (() => {
state.ownStreamRequestId = null;
setComposerDisabled(false);
} else {
Chat.clearTyping(); // rant 14:11:流式错误时移除在途节点 typing 光标
Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") }));
}
}
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/js/chat.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ const Chat = (() => {
const groupNodes = new Map();
// tool_call_id → 工具行 DOM 节点
const toolRows = new Map();
// rant 14:11:已 done 的 request_id(UUID 不复用)——残留 delta 直接丢弃,防孤儿节点
const doneRids = new Set();

/** 复制代码按钮(设计 §3.3):事件委托在聊天区,CSP 无内联 handler */
function initCodeCopy() {
Expand DownExpand Up@@ -53,6 +55,8 @@ const Chat = (() => {
function append(node) {
$("chat-view").appendChild(node);
scrollToBottom();
// rant 14:11:任何消息增删都重新评估欢迎屏显隐(此前只在切会话时评估 → 首条消息后欢迎屏不隐藏)
App.updateEmptyState?.();
}

function scrollToBottom() {
Expand All@@ -64,6 +68,8 @@ const Chat = (() => {
$("chat-view").innerHTML = "";
groupNodes.clear();
toolRows.clear();
doneRids.clear();
App.updateEmptyState?.(); // rant 14:11:清空(切会话/新会话)也同步欢迎屏显隐
}

/** 用户消息:右对齐柔和气泡 */
Expand DownExpand Up@@ -96,7 +102,7 @@ const Chat = (() => {
function handleDelta(chunks) {
for (const chunk of chunks) {
const rid = chunk.request_id;
if (!rid) continue;
if (!rid || doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta,不建孤儿节点
let node = groupNodes.get(rid);
if (!node) {
const isOwn = App.state.ownStreamRequestId === rid;
Expand All@@ -110,9 +116,21 @@ const Chat = (() => {
}
}

/** 取消/错误收尾:移除所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */
function clearTyping() {
for (const node of groupNodes.values()) {
const body = node.querySelector(".msg-body") || node;
body.classList.remove("typing");
}
}

/** done:整体 Markdown 渲染(requestIdleCallback 调度,G127) */
function handleDone(data) {
const rid = data.request_id;
if (rid) {
doneRids.add(rid);
if (doneRids.size > 500) doneRids.clear(); // UUID 不复用,超限即清防长期运行增长
}
const node = groupNodes.get(rid);
if (node) {
const body = node.querySelector(".msg-body") || node;
Expand DownExpand Up@@ -216,6 +234,7 @@ const Chat = (() => {
handleDone,
handleToolStart,
handleToolEnd,
clearTyping,
get groupNodes() {
return groupNodes;
},
Expand Down
52 changes: 52 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,58 @@ test("流式 delta 追加 + 工具行 running→done 状态流转", async () =>
assert.ok(r.toolRowClass.includes("done"), `工具行应 done,实际 ${r.toolRowClass}`);
});

test("rant 14:11:首条消息后欢迎屏立即隐藏(append 同步 updateEmptyState)", async () => {
const { ctx } = makeSandbox();
await tick();
const r = vm.runInContext(`(function() {
App.state.sessionId = "s1";
EMRG_Chat.addUserMessage("hello");
return {
emptyHidden: $("empty-state").classList.contains("hidden"),
msgCount: $("chat-view").children.length,
};
})()`, ctx);
assert.strictEqual(r.msgCount, 1, "消息应已追加");
assert.strictEqual(r.emptyHidden, true, "有消息时欢迎屏应隐藏(欢迎屏不得叠在消息区上方)");
});

test("rant 14:11:done 后残留 delta 被丢弃,不建孤儿节点", async () => {
const { ctx } = makeSandbox();
await tick();
const r = vm.runInContext(`(function() {
App.state.sessionId = "s1";
App.state.ownStreamRequestId = "rid-1";
EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "你" }]);
EMRG_Chat.handleDone({ request_id: "rid-1" });
const afterDone = $("chat-view").children.length;
// 模拟 16ms 批量定时器在 done 之后才 flush 的残留 delta(G122 竞态)
EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "残留" }]);
const afterStale = $("chat-view").children.length;
return { afterDone, afterStale };
})()`, ctx);
assert.strictEqual(r.afterDone, 1, "done 前 1 个节点");
assert.strictEqual(r.afterStale, 1, "残留 delta 应被丢弃,不得产生孤儿节点(否则误标来自其他客户端 + 光标残留)");
});

test("rant 14:11:cancelled 事件清除在途节点 typing 光标", async () => {
const { ctx } = makeSandbox();
await tick();
const r = vm.runInContext(`(function() {
App.state.sessionId = "s1";
App.state.ownStreamRequestId = "rid-1";
EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "部分" }]);
const node = $("chat-view").children[0];
const body = node.querySelector(".msg-body") || node;
body.classList.add("typing");
const typingBefore = body.classList.contains("typing");
App.handleEvent({ type: "cancelled" });
const typingAfter = body.classList.contains("typing");
return { typingBefore, typingAfter };
})()`, ctx);
assert.strictEqual(r.typingBefore, true, "取消前应处于 typing 态");
assert.strictEqual(r.typingAfter, false, "取消后 typing 光标(▍)应移除");
});

test("多模型管理:modelDetails 加载渲染 + saveSettings 传 models 数组", async () => {
let saved = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading