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 @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request
- 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` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (218: 43 daemon_client + 19 conn-manager + 22 app-commands + 97 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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 @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (705) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (218: 43 daemon_client + 19 conn-manager + 22 app-commands + 97 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — 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
76 changes: 75 additions & 1 deletion emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const App = (() => {
// (defineProperty getter/setter → sidState(sessionId)),既有调用点零改动;
// 事件按 sid 路由时操作对应条目(后台会话的 done 不误清激活会话的 busy)。
sessionsBySid: new Map(), // sid → { busy, ownStreamRequestId, mode, autoScroll }
// P2 queue-injection(#655):busy 时发送的消息入 daemon 队列(task_queued),
// 此处按会话记录以便 queued_requeue 以原 requestId 重发(不重加用户行)。
queuedSends: new Map(), // sid → [{ requestId, text, mode }]
// P4 slice 2(rant 15:07:19):跨项目打开的会话(侧边栏数据源,main 广播)
openSessions: [], // [{ sid, projectName, projectPath, lastActive }],lastActive 倒序
apiKeyConfigured: false,
Expand Down Expand Up @@ -133,7 +136,7 @@ const App = (() => {
async function sendMessage() {
const input = $("input");
const text = input.value.trim();
if (!text || state.busy) return;
if (!text) return;
// GUI / 指令(rant 19:44 P1):/ 开头 → 路由到指令 handler,不走 sendMessage
const parsed = Commands.parseInput(text);
if (parsed.type !== "message") {
Expand All @@ -147,6 +150,9 @@ const App = (() => {
Chat.addSystemMessage(_t("app.needSession"));
return;
}
// P2 queue-injection(#655):busy 不再拦截——daemon 排队注入(task_queued),
// 回合结束未注入则 queued_requeue 以原 requestId 重发。busy 时记录待重发条目。
const wasBusy = state.busy;
state.busy = true;
setComposerDisabled(true);
Chat.addUserMessage(text);
Expand All @@ -157,6 +163,11 @@ const App = (() => {
// G143:send 前预生成 requestId 并标记自有流——消除 IPC 往返竞态窗口
const requestId = genRequestId();
state.ownStreamRequestId = requestId;
if (wasBusy) {
const sid = state.sessionId;
if (!state.queuedSends.has(sid)) state.queuedSends.set(sid, []);
state.queuedSends.get(sid).push({ requestId, text, mode: state.mode });
}
try {
const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId, mode: state.mode });
state.ownStreamRequestId = res.requestId || requestId; // G124:以 daemon 回显为准
Expand Down Expand Up @@ -1194,6 +1205,68 @@ const App = (() => {
if (!sid || sid === state.sessionId) setComposerDisabled(false);
}
break;
// P2 queue-injection(#655):4 个 daemon→client 广播帧(busy 排队注入协议)
case "task_queued":
Chat.addSystemMessage(_t("app.queued", { pos: data.position || 0 }), sid);
break;
case "steer_committed":
// 已注入当前回合——从待重发记录移除(回合内 deltas 会带上原 turn 的回复)
{
const q = state.queuedSends.get(sid);
if (q && data.request_id) {
const idx = q.findIndex((e) => e.requestId === data.request_id);
if (idx >= 0) q.splice(idx, 1);
if (q.length === 0) state.queuedSends.delete(sid);
}
}
break;
case "queued_requeue":
// 回合正常结束且消息从未注入——daemon 锁已释放,以原 requestId 静默重发
// (不重加用户行;后台会话按 sid 处理,只操作该会话条目)
{
const q = state.queuedSends.get(sid);
if (q && q.length) {
const ids = new Set(data.request_ids || []);
const toResend = q.filter((e) => ids.has(e.requestId));
const remaining = q.filter((e) => !ids.has(e.requestId));
if (toResend.length) {
// P2 审查 ❌ 同 #695:was_busy 在循环前捕获,单客户端时首条重发
// 开启新回合,M2+ 到达时 daemon busy 被再排队但客户端未跟踪 → 下个
// queued_requeue 找不到 → 静默丢失。修复=每条重发若 (wasBusy || i>0)
// 重新跟踪——steer_committed 移除已注入的,下个 queued_requeue 重发
// 其余,收敛。
const wasBusy = sidState(sid).busy;
for (let i = 0; i < toResend.length; i++) {
const item = toResend[i];
const sst = sidState(sid);
sst.busy = true;
sst.ownStreamRequestId = item.requestId;
if (!sid || sid === state.sessionId) setComposerDisabled(true);
try {
const res = await window.emrg.sendMessage({ sessionId: sid, text: item.text, requestId: item.requestId, mode: item.mode });
sst.ownStreamRequestId = res.requestId || item.requestId;
} catch (e) {
sst.busy = false;
sst.ownStreamRequestId = null;
if (!sid || sid === state.sessionId) setComposerDisabled(false);
}
if (wasBusy || i > 0) {
remaining.push({ requestId: item.requestId, text: item.text, mode: item.mode });
}
}
if (remaining.length) state.queuedSends.set(sid, remaining);
else state.queuedSends.delete(sid);
Chat.addSystemMessage(_t("app.queuedResent", { n: toResend.length }), sid);
}
}
}
break;
case "queued_cancelled":
// 回合被取消/异常/断连——daemon 丢弃队列
if (state.queuedSends.delete(sid)) {
Chat.addSystemMessage(_t("app.queuedCancelled"), sid);
}
break;
case "error":
handleError(data, sid);
break;
Expand Down Expand Up @@ -1225,6 +1298,7 @@ const App = (() => {
sst.busy = false;
sst.ownStreamRequestId = null;
sst.disconnected = true; // P3 finalize:该会话条目标断线(P4 恢复 UI 用)
state.queuedSends.delete(sid); // P2 queue-injection:断连 daemon 丢队列
const isActive = !sid || sid === state.sessionId;
if (isActive) {
updateConnectionDot("red");
Expand Down
6 changes: 6 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ const I18N = (() => {
"app.workdirInvalid": "工作目录不可用,请到设置里改一下。",
"app.bootFailed": "启动遇到了问题:{msg}",
"app.needSession": "请先创建一个对话。",
"app.queued": "⏳ 已排队(位置 {pos})— 当前回合结束后处理。",
"app.queuedResent": "→ 已重新发送 {n} 条排队消息。",
"app.queuedCancelled": "⏹ 排队消息已取消。",
"app.recentImprovements": "最近改进",
"app.noImprovements": "还没有改进记录,输入 /rant 驱动第一次进化吧",
"app.cmdUnknown": "指令 {cmd} 暂未开放。",
Expand Down Expand Up @@ -637,6 +640,9 @@ const I18N = (() => {
"app.workdirInvalid": "Working directory unavailable — update it in Settings.",
"app.bootFailed": "Startup failed: {msg}",
"app.needSession": "Start a conversation first.",
"app.queued": "⏳ Queued (position {pos}) — will run after the current turn.",
"app.queuedResent": "→ Re-sent {n} queued message(s).",
"app.queuedCancelled": "⏹ Queued message(s) cancelled.",
"app.recentImprovements": "Recent improvements",
"app.noImprovements": "No improvements recorded yet — type /rant to drive the first evolution",
"app.cmdUnknown": "Command {cmd} is not available yet.",
Expand Down
108 changes: 108 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,114 @@ test("P3 s1: cancelled 带 sid → 只清该会话条目;无 sid → 清激活
assert.strictEqual(ctx.App.state.busy, false, "no-sid cancelled clears active");
});

// ── P2 queue-injection(#655):GUI 客户端侧(busy 排队注入协议)──

test("P2 queue: sendMessage while busy records queued send (no early-return)", async () => {
const { ctx, els } = makeSandbox({});
await tick();
els["input"].value = "queued msg";
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.state.busy = true;' +
'App.sendMessage();',
ctx
);
await tick(); // sendMessage 内部 await window.emrg.sendMessage(mock 立即 resolve)
const q = ctx.App.state.queuedSends.get("sess-1");
assert.ok(q && q.length === 1, "busy send recorded in queuedSends");
assert.strictEqual(q[0].text, "queued msg");
assert.strictEqual(q[0].requestId, "mock-uuid", "pre-generated requestId preserved");
});

test("P2 queue: task_queued shows queued position note", async () => {
const { ctx, els } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.handleEvent({ type: "task_queued", sid: "sess-1", data: { position: 2 } });',
ctx
);
const texts = [...els["chat-view"].children].map((c) => c.textContent).join("|");
assert.ok(texts.includes("位置 2"), "task_queued shows position note");
});

test("P2 queue: steer_committed removes that request from queue", async () => {
const { ctx } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.state.queuedSends.set("sess-1", [{ requestId: "req-a", text: "hi", mode: "auto" }, { requestId: "req-b", text: "yo", mode: "auto" }]);' +
'App.handleEvent({ type: "steer_committed", sid: "sess-1", data: { request_id: "req-a" } });',
ctx
);
const q = ctx.App.state.queuedSends.get("sess-1");
assert.strictEqual(q.length, 1, "steer_committed removes that request");
assert.strictEqual(q[0].requestId, "req-b");
});

test("P2 queue: queued_requeue re-sends with same requestId + re-tracks (review ❌ fix)", async () => {
const sent = [];
const { ctx, els } = makeSandbox({
sendMessage: async (p) => { sent.push(p); return { ok: true, requestId: p.requestId }; },
});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.state.busy = true;' + // wasBusy → re-send is re-tracked
'App.state.queuedSends.set("sess-1", [{ requestId: "req-queue", text: "hi", mode: "auto" }]);' +
'App.handleEvent({ type: "queued_requeue", sid: "sess-1", data: { request_ids: ["req-queue"] } });',
ctx
);
await tick(); // handleEvent 内部 await window.emrg.sendMessage(mock 立即 resolve)
assert.strictEqual(sent.length, 1, "queued_requeue re-sends");
assert.strictEqual(sent[0].sessionId, "sess-1");
assert.strictEqual(sent[0].text, "hi");
assert.strictEqual(sent[0].requestId, "req-queue", "same requestId reused");
// 审查 ❌ 修复:busy 时重发被再排队 → 重新跟踪(steer_committed 才移除)
assert.strictEqual(ctx.App.state.queuedSends.has("sess-1"), true, "re-tracked after requeue (daemon may re-queue)");
assert.strictEqual(ctx.App.state.queuedSends.get("sess-1")[0].requestId, "req-queue", "same requestId tracked");
assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").busy, true, "requeue marks session busy");
const texts = [...els["chat-view"].children].map((c) => c.textContent).join("|");
assert.ok(texts.includes("重新发送 1"), "requeue note shown");
});

test("P2 queue: requeue with 2 msgs (idle turn end) re-tracks 2nd+ (review ❌ regression)", async () => {
const sent = [];
const { ctx } = makeSandbox({
sendMessage: async (p) => { sent.push(p); return { ok: true, requestId: p.requestId }; },
});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.state.busy = false;' + // 单客户端:回合刚结束 → wasBusy=false
'App.state.queuedSends.set("sess-1", [' +
' { requestId: "req-m1", text: "m1", mode: "auto" },' +
' { requestId: "req-m2", text: "m2", mode: "auto" }]);' +
'App.handleEvent({ type: "queued_requeue", sid: "sess-1", data: { request_ids: ["req-m1", "req-m2"] } });',
ctx
);
await tick();
assert.strictEqual(sent.length, 2, "both queued messages re-sent");
// M1 开启新回合(不再跟踪);M2 到达时 daemon busy 被再排队 → i>0 重新跟踪
const q = ctx.App.state.queuedSends.get("sess-1");
assert.ok(q && q.length === 1, "2nd message re-tracked");
assert.strictEqual(q[0].requestId, "req-m2", "M2 tracked for next queued_requeue");
});

test("P2 queue: queued_cancelled clears queue + note", async () => {
const { ctx, els } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-1";' +
'App.state.queuedSends.set("sess-1", [{ requestId: "req-a", text: "hi", mode: "auto" }]);' +
'App.handleEvent({ type: "queued_cancelled", sid: "sess-1", data: {} });',
ctx
);
assert.strictEqual(ctx.App.state.queuedSends.has("sess-1"), false, "queue cleared");
const texts = [...els["chat-view"].children].map((c) => c.textContent).join("|");
assert.ok(texts.includes("排队消息已取消"), "cancelled note shown");
});

// ── P3 slice 2(rant 15:07:19):每会话 .session-view 容器 + display 切换 ──

test("P3 s2: activateSessionView 建独立容器并切换 display(仅激活可见)", async () => {
Expand Down
Loading
Loading