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` (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 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` (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 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` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 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 测试(146 项:43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(150 项:43 daemon_client + 17 conn-manager + 22 app-commands + 40 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 (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 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 (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 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
75 changes: 58 additions & 17 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ const App = (() => {
const state = {
sessionId: null,
sessions: [],
busy: false,
ownStreamRequestId: null,
// P3 slice 1(rant 15:07:19):会话级状态表——每会话独立 busy/ownStreamRequestId/
// mode/autoScroll。busy/ownStreamRequestId/mode 为**激活会话条目**的实时视图
// (defineProperty getter/setter → sidState(sessionId)),既有调用点零改动;
// 事件按 sid 路由时操作对应条目(后台会话的 done 不误清激活会话的 busy)。
sessionsBySid: new Map(), // sid → { busy, ownStreamRequestId, mode, autoScroll }
apiKeyConfigured: false,
configExists: false,
projectDir: "",
Expand All @@ -32,6 +35,29 @@ const App = (() => {
mode: "auto",
};

// P3 slice 1:会话条目访问器(get-or-create)。无 sid/未激活 → 归入当前激活会话。
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 });
}
return state.sessionsBySid.get(key);
}

// 激活会话条目 = state.busy/ownStreamRequestId/mode 的事实源(P3 过渡期兼容层)
Object.defineProperty(state, "busy", {
get() { return sidState(state.sessionId).busy; },
set(v) { sidState(state.sessionId).busy = v; },
});
Object.defineProperty(state, "ownStreamRequestId", {
get() { return sidState(state.sessionId).ownStreamRequestId; },
set(v) { sidState(state.sessionId).ownStreamRequestId = v; },
});
Object.defineProperty(state, "mode", {
get() { return sidState(state.sessionId).mode; },
set(v) { sidState(state.sessionId).mode = v; },
});

// ── 启动 ─────────────────────────────────
async function boot() {
try {
Expand Down Expand Up @@ -535,7 +561,8 @@ const App = (() => {
try {
const res = await window.emrg.switchSession({ sessionId: sid });
state.sessionId = sid;
state.ownStreamRequestId = null; // G110:切会话清自有流标记
// P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId
// (G110 旧语义=清全局自有流标记;per-sid 后由各会话条目自己管理,P4 切回继续生成)
Chat.clear();
updateEmptyState();
if (res.error === "session_not_found") {
Expand Down Expand Up @@ -970,10 +997,15 @@ const App = (() => {
break;
case "done":
Chat.handleDone(data, sid);
if (data.request_id && (state.ownStreamRequestId === data.request_id || data.timeout)) {
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
// P3 slice 1:done 释放**该事件所属会话**的 busy 锁(后台会话的广播 done
// 不误清激活会话);仅当该会话是激活会话时同步输入条 UI。
{
const sst = sidState(sid);
if (data.request_id && (sst.ownStreamRequestId === data.request_id || data.timeout)) {
sst.busy = false;
sst.ownStreamRequestId = null;
if (!sid || sid === state.sessionId) setComposerDisabled(false);
}
}
break;
case "tool_started":
Expand All @@ -985,9 +1017,13 @@ const App = (() => {
break;
case "cancelled":
Chat.clearTyping(sid); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清)
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
// P3 slice 1:cancelled 释放该事件所属会话的锁(后台会话取消不误清激活会话)
{
const sst = sidState(sid);
sst.busy = false;
sst.ownStreamRequestId = null;
if (!sid || sid === state.sessionId) setComposerDisabled(false);
}
break;
case "error":
handleError(data, sid);
Expand All @@ -1010,10 +1046,14 @@ const App = (() => {
case "disconnected":
updateConnectionDot("red");
showBanner(EMRG_Copy.COPY.disconnected);
// G89:断连时恢复输入条(不能依赖 30s 超时兜底)
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
// G89:断连时恢复输入条(不能依赖 30s 超时兜底)——仅当激活会话断连
// P3 slice 1:断连释放该事件所属会话的锁
{
const sst = sidState(sid);
sst.busy = false;
sst.ownStreamRequestId = null;
if (!sid || sid === state.sessionId) setComposerDisabled(false);
}
// P3:广播分组缓存清理按会话隔离(DOM 保留;仅清该会话 Map 引用;无 sid → 默认桶)
Chat.groupNodesFor(sid).clear();
// 进行中的工具行 → 结果未知(工具副作用不可重放)
Expand Down Expand Up @@ -1047,11 +1087,12 @@ const App = (() => {
}

function handleError(data, sid) {
const sst = sidState(sid);
if (data.error && String(data.error).includes("session busy")) {
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy, sid);
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
sst.busy = false;
sst.ownStreamRequestId = null;
if (!sid || sid === state.sessionId) setComposerDisabled(false);
} else {
Chat.clearTyping(sid); // rant 14:11:流式错误时移除在途节点 typing 光标
Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") }), sid);
Expand Down
76 changes: 76 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -984,3 +984,79 @@ test("P3: registerContainer 后该会话渲染进独立容器;无 sid 回退
assert.strictEqual(extra.children.length, 1, "unregistered container no longer receives");
assert.strictEqual(els["chat-view"].children.length, 2, "falls back to default container");
});

// ── P3 slice 1(rant 15:07:19):renderer sessionsBySid 会话级状态表 ─────

test("P3 s1: state.busy/ownStreamRequestId 路由到激活会话条目(get-or-create)", async () => {
const { ctx } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.state.busy = true;' +
'App.state.ownStreamRequestId = "req-1";',
ctx
);
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").busy, true, "busy stored in sess-1 entry");
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").ownStreamRequestId, "req-1");
assert.strictEqual(ctx.App.state.busy, true, "state.busy reads active entry");
});

test("P3 s1: 切换会话后 state.busy 指向新会话条目(各会话独立)", async () => {
const { ctx } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-a";' +
'App.state.busy = true;' +
'App.state.sessionId = "sess-b";' +
'App.state.busy = false;',
ctx
);
const sa = ctx.App.state.sessionsBySid.get("sess-a");
const sb = ctx.App.state.sessionsBySid.get("sess-b");
assert.strictEqual(sa.busy, true, "sess-a stays busy after switching away");
assert.strictEqual(sb.busy, false, "sess-b independent");
});

test("P3 s1: done 带 sid → 只释放该会话条目;后台会话 done 不误清激活会话", async () => {
const { ctx, els } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-active";' +
'App.state.busy = true;' +
'App.state.ownStreamRequestId = "req-active";' +
// 模拟后台会话的流:事件带 sid=sess-bg(非激活)→ 释放 bg 条目,不动 active
'window.emrg.onEvent.calls = window.emrg.onEvent.calls || [];',
ctx
);
// 直接驱动 handleEvent:后台会话 done
await vm.runInContext(
'App.handleEvent({ type: "done", sid: "sess-bg", data: { request_id: "req-bg", done: true } });',
ctx
);
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-bg").busy, false, "bg entry released");
// 激活会话未被误清(bg done 不匹配 active 的 rid——除非我们预设了 bg 的 rid)
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-active").busy, true, "active busy untouched by bg done");
// 激活会话自己的 done(带激活 sid)→ 释放
await vm.runInContext(
'App.handleEvent({ type: "done", sid: "sess-active", data: { request_id: "req-active", done: true } });',
ctx
);
assert.strictEqual(ctx.App.state.busy, false, "active released by own done");
assert.strictEqual(els["input"].disabled, false, "composer re-enabled for active");
});

test("P3 s1: cancelled 带 sid → 只清该会话条目;无 sid → 清激活会话", async () => {
const { ctx } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.state.busy = true;' +
'App.handleEvent({ type: "cancelled", sid: "sess-2", data: {} });',
ctx
);
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-2").busy, false, "sess-2 cancelled released");
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").busy, true, "sess-1 (active) untouched");
// 无 sid cancelled → 清激活会话
await vm.runInContext('App.handleEvent({ type: "cancelled", data: {} });', ctx);
assert.strictEqual(ctx.App.state.busy, false, "no-sid cancelled clears active");
});
Loading