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` (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`
- 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`
- **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` (572) — import check: `uv run python -c "from emrg.client.app import run_client"`
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`
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`
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 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 (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)
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)
```

CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`).
Expand Down
84 changes: 53 additions & 31 deletions emrg/gui/renderer/js/chat.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,51 +103,65 @@ const Chat = (() => {
for (const chunk of chunks) {
const rid = chunk.request_id;
if (!rid || doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta,不建孤儿节点
let node = groupNodes.get(rid);
if (!node) {
let group = groupNodes.get(rid);
if (!group) {
const isOwn = App.state.ownStreamRequestId === rid;
node = createAssistantNode(isOwn);
groupNodes.set(rid, node);
const node = createAssistantNode(isOwn);
group = { node, nodes: [node], hasText: false, sealed: false };
groupNodes.set(rid, group);
} else if (group.sealed) {
// rant 21:57:10:上一文本段被工具行"封存"→ 新文本段开新节点(旧节点保留在 DOM 原位)
const isOwn = App.state.ownStreamRequestId === rid;
const node = createAssistantNode(isOwn);
group.node = node;
group.nodes.push(node);
group.sealed = false;
}
const body = node.querySelector(".msg-body") || node;
const content = chunk.content || "";
if (content) group.hasText = true;
const body = group.node.querySelector(".msg-body") || group.node;
// 流式中只动 textContent(不解析 Markdown)——性能约束
body.textContent += chunk.content || "";
body.textContent += content;
scrollToBottom();
}
}

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

/** done:整体 Markdown 渲染(requestIdleCallback 调度,G127) */
/** done:整体 Markdown 渲染(requestIdleCallback 调度,G127)——该 rid 的全部文本段逐个渲染 */
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;
body.classList.remove("typing");
const text = body.textContent;
const render = () => {
window.emrgMarkdown.renderMarkdown(text).then((html) => {
body.innerHTML = html;
scrollToBottom();
});
};
if (window.requestIdleCallback) {
window.requestIdleCallback(render, { timeout: 2000 });
} else {
render();
const group = groupNodes.get(rid);
if (group) {
for (const node of group.nodes) {
const body = node.querySelector(".msg-body") || node;
body.classList.remove("typing");
const text = body.textContent;
const render = () => {
window.emrgMarkdown.renderMarkdown(text).then((html) => {
body.innerHTML = html;
scrollToBottom();
});
};
if (window.requestIdleCallback) {
window.requestIdleCallback(render, { timeout: 2000 });
} else {
render();
}
}
groupNodes.delete(rid);
}
groupNodes.delete(rid);
}
if (data.timeout) {
addSystemMessage(EMRG_Copy._t("chat.timeoutWarn"));
Expand All@@ -162,10 +176,18 @@ const Chat = (() => {
/** 工具友好状态行(进行中 → 完成/失败,默认折叠,点开展示原始输出) */
function handleToolStart(data) {
const rid = data.request_id;
if (rid && !groupNodes.has(rid)) {
// G104:tool_start 也建组(LLM 先出 tool_calls 后出文本)
const isOwn = App.state.ownStreamRequestId === rid;
groupNodes.set(rid, createAssistantNode(isOwn));
if (rid) {
let group = groupNodes.get(rid);
if (!group) {
// G104:tool_start 也建组(LLM 先出 tool_calls 后出文本)
const isOwn = App.state.ownStreamRequestId === rid;
const node = createAssistantNode(isOwn);
group = { node, nodes: [node], hasText: false, sealed: false };
groupNodes.set(rid, group);
} else if (group.hasText) {
// rant 21:57:10:已有文本段之后来了工具 → 封存当前段,后续 delta 新建段(保持 TUI 交错顺序)
group.sealed = true;
}
}
const phrases = EMRG_Copy.toolPhrases(data.tool_name);
const row = el("div", { class: "tool-row running" });
Expand Down
50 changes: 50 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -211,6 +211,56 @@ test("流式 delta 追加 + 工具行 running→done 状态流转", async () =>
assert.ok(r.toolRowClass.includes("done"), `工具行应 done,实际 ${r.toolRowClass}`);
});

test("rant 21:57:10:交替文本/工具按顺序交错展示(每段文本独立成块)", async () => {
const { ctx } = makeSandbox();
await tick();
const r = vm.runInContext(`(function() {
App.state.sessionId = "s1";
App.state.ownStreamRequestId = "rid-1";
// LLM 常见输出序列:文本段1 → 工具1 → 文本段2 → 工具2 → 文本段3
EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "文本段1" }]);
EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t1", tool_name: "read" });
EMRG_Chat.handleToolEnd({ tool_call_id: "t1", tool_name: "read", content: "out1", elapsed: 0.1 });
EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "文本段2" }]);
EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t2", tool_name: "bash" });
EMRG_Chat.handleToolEnd({ tool_call_id: "t2", tool_name: "bash", content: "out2", elapsed: 0.2 });
EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "文本段3" }]);
const children = $("chat-view").children;
const kinds = [];
const texts = [];
const isToolRow = (c) =>
c.children.length > 0 && (c.children[0].className || "").includes("tool-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);
}
}
const beforeDone = children.length;
// done 后该 rid 的所有文本段都应渲染(typing 移除)
EMRG_Chat.handleDone({ request_id: "rid-1" });
let typingAfter = 0;
for (let i = 0; i < $("chat-view").children.length; i++) {
const c = $("chat-view").children[i];
if (c.className.includes("typing")) typingAfter++;
}
return {
count: children.length,
kinds: kinds.join(","),
texts: texts.join("|"),
beforeDone,
typingAfter,
};
})()`, ctx);
assert.strictEqual(r.beforeDone, 5, "3 文本段 + 2 工具行 = 5 节点");
assert.strictEqual(r.kinds, "text,tool,text,tool,text", "应按 文本→工具→文本→工具→文本 交错(TUI 一致)");
assert.strictEqual(r.texts, "文本段1|文本段2|文本段3", "每段文本独立成块,不拼接在顶部(沙箱 textContent 不含 ✦ 标记 span)");
assert.strictEqual(r.typingAfter, 0, "done 后所有文本段 typing 光标应移除");
});

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