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` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (96: 22 daemon_client + 22 app-commands + 27 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 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` (634) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 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` (96: 22 daemon_client + 22 app-commands + 27 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.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 (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (96: 22 daemon_client + 22 app-commands + 27 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
4 changes: 4 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
/* rant 21:08:工具完成后 spinner 不再转圈(JS 亦移除元素,CSS 兜底防闪烁) */
.tool-row:not(.running) .tool-spinner {
display: none;
}
.tool-row.running {
color: var(--amber);
}
Expand Down
13 changes: 12 additions & 1 deletion emrg/gui/renderer/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,14 @@ const Chat = (() => {
for (const node of group.nodes) {
const body = node.querySelector(".msg-body") || node;
body.classList.remove("typing");
const text = body.textContent;
// rant 21:10:✦ 标记是元素而非文本——流式时 body.textContent 含 "✦ " 前缀,
// 直接整体 render 会让 "✦ # Title" 等块语法(标题/列表/代码围栏)解析失败
// (前缀不在行首 → marked 不识别)。渲染前剥离前缀,渲染后重新插入标记保持视觉一致。
const text = body.textContent.replace(/^✦\s*/, "");
const render = () => {
window.emrgMarkdown.renderMarkdown(text).then((html) => {
body.innerHTML = html;
body.insertBefore(el("span", { class: "msg-assistant-mark" }, "✦ "), body.firstChild);
scrollToBottom();
});
};
Expand Down Expand Up @@ -187,6 +191,10 @@ const Chat = (() => {
} else if (group.hasText) {
// rant 21:57:10:已有文本段之后来了工具 → 封存当前段,后续 delta 新建段(保持 TUI 交错顺序)
group.sealed = true;
// rant 21:09:已结束的文本段不再闪烁——封存时移除其 typing 光标,
// 让 ▍ 只保留在最新一段文本后面("前面的文本都结束了还显示闪烁光标")。
const prevBody = group.node.querySelector(".msg-body") || group.node;
prevBody.classList.remove("typing");
}
}
const phrases = EMRG_Copy.toolPhrases(data.tool_name);
Expand All @@ -210,6 +218,9 @@ const Chat = (() => {
function handleToolEnd(data) {
const row = toolRows.get(data.tool_call_id);
if (!row) return;
// rant 21:08:工具执行完成后 spinner 必须停止——移除转圈元素(CSS 亦有
// .tool-row:not(.running) 隐藏兜底),只保留 ✓ 完成标记,防止"对号前面一直转圈"。
row.querySelector(".tool-spinner")?.remove();
const ok = !data.error;
row.classList.remove("running");
row.classList.add(ok ? "done" : "failed");
Expand Down
107 changes: 97 additions & 10 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ function makeEl(id) {
attributes: {},
_cls: new Set(),
classList: {
add(c) { node._cls.add(c); node._update(); },
remove(c) { node._cls.delete(c); node._update(); },
toggle(c) { node._cls.has(c) ? node._cls.delete(c) : node._cls.add(c); node._update(); },
contains(c) { return node._cls.has(c); },
// className 为唯一事实源:el() 直接赋值 className 后 classList 操作不得清空既有类
_set() { node._cls = new Set((node.className || "").split(/\s+/).filter(Boolean)); },
add(c) { node.classList._set(); node._cls.add(c); node._update(); },
remove(c) { node.classList._set(); node._cls.delete(c); node._update(); },
toggle(c) { node.classList._set(); node._cls.has(c) ? node._cls.delete(c) : node._cls.add(c); node._update(); },
contains(c) { return (node.className || "").split(/\s+/).includes(c); },
},
_update() { node.className = [...node._cls].join(" "); },
className: "",
Expand All @@ -43,17 +45,34 @@ function makeEl(id) {
scrollHeight: 0,
clientHeight: 100,
open: false,
appendChild(c) { this.children.push(c); return c; },
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
addEventListener() {},
querySelector() { return null; },
querySelector(sel) {
// 最小类选择器搜索(chat.js 用 ".msg-body"/".tool-spinner"):DFS 子节点
if (!sel || !sel.startsWith(".")) return null;
const cls = sel.slice(1);
const stack = [...(this.children || [])];
while (stack.length) {
const n = stack.shift();
if ((n.className || "").split(/\s+/).includes(cls)) return n;
stack.push(...(n.children || []));
}
return null;
},
querySelectorAll() { return []; },
closest() { return null; },
showModal() { this.open = true; },
close() { this.open = false; },
setAttribute(k, v) { this.attributes[k] = v; if (k === "value") this.value = v; },
removeAttribute(k) { delete this.attributes[k]; },
insertBefore(c) { this.children.unshift(c); return c; },
remove() {},
insertBefore(c) { c.parentNode = this; this.children.unshift(c); return c; },
remove() {
// 真实脱离父节点(chat.js handleToolEnd 移除 .tool-spinner)
if (this.parentNode) {
const i = this.parentNode.children.indexOf(this);
if (i >= 0) this.parentNode.children.splice(i, 1);
}
},
focus() {},
select() {},
};
Expand Down Expand Up @@ -229,14 +248,14 @@ test("rant 21:57:10:交替文本/工具按顺序交错展示(每段文本独
const kinds = [];
const texts = [];
const isToolRow = (c) =>
c.children.length > 0 && (c.children[0].className || "").includes("tool-spinner");
(c.className || "").includes("tool-row"); // 用行类而非 spinner 子元素(rant 21:08 后 spinner 完成即移除)
for (let i = 0; i < children.length; i++) {
const c = children[i];
if (isToolRow(c)) {
kinds.push("tool");
} else {
kinds.push("text");
texts.push(c.textContent);
texts.push((c.querySelector(".msg-body") || c).textContent); // 文本在 body 子节点
}
}
const beforeDone = children.length;
Expand All @@ -261,6 +280,74 @@ test("rant 21:57:10:交替文本/工具按顺序交错展示(每段文本独
assert.strictEqual(r.typingAfter, 0, "done 后所有文本段 typing 光标应移除");
});

test("rant 21:08:工具完成后 spinner 停止(元素移除,不再转圈)", async () => {
const { ctx } = makeSandbox();
await tick();
const r = vm.runInContext(`(function() {
App.state.sessionId = "s1";
App.state.ownStreamRequestId = "rid-1";
EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t1", tool_name: "read" });
const row = $("chat-view").children[1]; // children[0] 是助手文本节点,[1] 是工具行
const spinnerBefore = (row.querySelector(".tool-spinner") !== null);
EMRG_Chat.handleToolEnd({ tool_call_id: "t1", tool_name: "read", content: "ok", elapsed: 0.3 });
const spinnerAfter = (row.querySelector(".tool-spinner") !== null);
return { spinnerBefore, spinnerAfter, cls: row.className };
})()`, ctx);
assert.strictEqual(r.spinnerBefore, true, "工具运行中应有 spinner");
assert.strictEqual(r.spinnerAfter, false, "工具完成后 spinner 应移除(不得一直转圈)");
assert.ok(r.cls.includes("done"), `工具行应 done,实际 ${r.cls}`);
});

test("rant 21:09:文本段被工具封存后 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 firstNode = $("chat-view").children[0];
const firstBody = firstNode.querySelector(".msg-body") || firstNode;
const typingBefore = firstBody.classList.contains("typing");
EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t1", tool_name: "read" });
const typingAfter = firstBody.classList.contains("typing");
return { typingBefore, typingAfter };
})()`, ctx);
assert.strictEqual(r.typingBefore, true, "封存前第一段应有 typing 光标(流式中)");
assert.strictEqual(r.typingAfter, false, "封存后第一段 typing 光标应移除(光标只留在最新文本段)");
});

test("rant 21:10:done 渲染剥离 ✦ 前缀(标题/列表/代码围栏不被前缀破坏)", async () => {
const { ctx } = makeSandbox();
await tick();
const p = vm.runInContext(`(async function() {
App.state.sessionId = "s1";
App.state.ownStreamRequestId = "rid-1";
EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "# 标题\\n- 列表项\\n\\n\\u0060\\u0060\\u0060python\\nprint(1)\\n\\u0060\\u0060\\u0060" }]);
const node = $("chat-view").children[0];
const body = node.querySelector(".msg-body") || node;
// 模拟真实 DOM:body 内先有 ✦ 标记 span(textContent 含前缀)
body.textContent = "✦ " + body.textContent;
let captured = null;
window.marked = {
use: () => {},
parse: async (t) => { captured = t; return "<h1>标题</h1><ul><li>列表项</li></ul>"; },
};
EMRG_Chat.handleDone({ request_id: "rid-1" });
await new Promise((res) => setTimeout(res, 10)); // 等 renderMarkdown microtask
return {
renderedHasHeader: body.innerHTML.includes("h1"),
renderedHasMark: (body.children[0]?.className || "").includes("msg-assistant-mark"),
capturedStartsClean: String(captured).startsWith("# 标题"),
capturedHasPrefix: String(captured).includes("✦"),
};
})()`, ctx);
const r = await p;
assert.strictEqual(r.capturedStartsClean, true, "传入 marked 的文本应以 # 开头(✦ 前缀已剥离)");
assert.strictEqual(r.capturedHasPrefix, false, "传入 marked 的文本不得含 ✦ 前缀");
assert.ok(r.renderedHasHeader, "剥离前缀后 # 标题渲染为 h1(块语法不被破坏)");
assert.strictEqual(r.renderedHasMark, true, "渲染后 ✦ 标记应重新插入(元素而非文本)");
});

test("rant 14:11:首条消息后欢迎屏立即隐藏(append 同步 updateEmptyState)", async () => {
const { ctx } = makeSandbox();
await tick();
Expand Down
Loading