From 4eecd70951c9d8d209334400bf70fa9b66807034 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 20 Aug 2026 19:47:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20remove=20Ask/Auto=20mode=20?= =?UTF-8?q?=E2=80=94=20session=20sandbox=20three-tier=20switcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rant 2026-08-20T18:18: the orthogonal Ask/Auto dimension is dropped; every interactive message allows tools, and file permissions are controlled by a per-session sandbox tier (read-only / workspace-write / danger-full-access, default workspace-write). - daemon.py: allow_tools always True (mode=ask empty-tool-set branch removed); ask_injected stays for signature compat, always False - GUI: mode-switcher (Ask/Auto) → sandbox-switcher three-tier control; state.mode → state.sandbox; sendMessage/queuedSends carry sandbox - main.js + daemon_client.js: sandbox passthrough on every task message - i18n: composer.sandboxTitle/ReadOnly/WorkspaceWrite/FullAccess added, composer.modeTitle + app.askModeNotice removed - tests: app-commands setMode → setSandbox; smoke queuedSends sandbox; daemon_client payload asserts sandbox default; ws_e2e ask-empty-tools → sandbox-queued-keeps-tools --- emrg/gui/daemon_client.js | 7 ++-- emrg/gui/main.js | 6 ++-- emrg/gui/renderer/index.html | 7 ++-- emrg/gui/renderer/js/app.js | 50 +++++++++++++--------------- emrg/gui/renderer/js/i18n.js | 12 ++++--- emrg/gui/test/app-commands.test.js | 30 ++++++++--------- emrg/gui/test/daemon_client.test.js | 1 + emrg/gui/test/renderer.smoke.test.js | 10 +++--- emrg/server/daemon.py | 15 ++++++--- tests/test_ws_e2e.py | 22 +++++++----- 10 files changed, 87 insertions(+), 73 deletions(-) diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 2afe778..e258e91 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -482,10 +482,11 @@ class DaemonClient { // ── 消息发送 ──────────────────────────────────────────── - sendTask({ sessionId, cwd, prompt, images = null, requestId = null, mode = "auto" }) { + sendTask({ sessionId, cwd, prompt, images = null, requestId = null, sandbox = "workspace-write" }) { // G32:request_id 必须作为 id 字段发出(daemon 只回显不自生成) // G143:外部预生成 requestId 优先(renderer send 前标记自有流,消除 IPC 往返竞态窗口) - // WorkBuddy P2:mode="ask" → daemon 不启用工具(纯对话) + // Rant 2026-08-20T18:18:sandbox 档位(read-only / workspace-write / danger-full-access), + // 随每条 task 消息发送——daemon 用它控制该次工具执行的写权限(默认可写工作区)。 // rant 21:20:38:非 stream 路径已删除——所有 task 恒走 tool_loop(流式) const rid = requestId || crypto.randomUUID(); const payload = { @@ -496,8 +497,8 @@ class DaemonClient { prompt, timestamp: new Date().toISOString(), images, + sandbox, }; - if (mode && mode !== "auto") payload.mode = mode; this._setCurrentStream(rid); // G65:自有流锁——本连接发出流式 task 即标记,done/error/cancelled/断连释放 // (多会话各自独立;main.js emrg:sendMessage 的 G65 切会话检查读本字段) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index cd5c824..fb998f5 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -287,7 +287,7 @@ vision = false }; }); - ipcMain.handle("emrg:sendMessage", async (_e, { sessionId, text, requestId, mode }) => { + ipcMain.handle("emrg:sendMessage", async (_e, { sessionId, text, requestId, sandbox }) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); if (!validateText(text)) throw new Error("invalid text"); if (requestId !== undefined && (typeof requestId !== "string" || requestId.length < 8 || requestId.length > 64)) { @@ -304,9 +304,9 @@ vision = false let rid; try { // G143:renderer 预生成 requestId(send 前标记自有流,消除 IPC 往返竞态窗口) - // WorkBuddy P2:mode="ask" → 纯对话(daemon 不启用工具) + // Rant 2026-08-20T18:18:sandbox 档位(read-only / workspace-write / danger-full-access) // G65:conn.sendTask 内部标记 ownStream(每连接独立锁) - rid = conn.sendTask({ sessionId, cwd: sessionCwd, prompt: text, requestId, mode }); + rid = conn.sendTask({ sessionId, cwd: sessionCwd, prompt: text, requestId, sandbox }); } catch (e) { conn._releaseOwnStream(); // sendTask 抛异常(ws.send 失败)→ 释放锁,防 G65 锁泄漏 throw e; diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index d58f10f..b2a0540 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -284,9 +284,10 @@

设置

加载中… -
- - +
+ + +
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index dd67111..45a3e13 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -21,7 +21,7 @@ const App = (() => { 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 }] + queuedSends: new Map(), // sid → [{ requestId, text, sandbox }] // P4 slice 2(rant 15:07:19):跨项目打开的会话(侧边栏数据源,main 广播) openSessions: [], // [{ sid, projectName, projectPath, lastActive }],lastActive 倒序 apiKeyConfigured: false, @@ -35,8 +35,8 @@ const App = (() => { autoScroll: true, // GUI / 指令补全菜单(rant 19:44 P1):items=[{cmd,hint,phase}] index=当前高亮 cmdMenu: { items: [], index: -1 }, - // WorkBuddy P2(rant 21:35):工作模式 ask(纯对话)/ auto(默认,自动执行工具) - mode: "auto", + // Rant 2026-08-20T18:18:会话级沙箱档位(read-only / workspace-write / danger-full-access),默认可写工作区 + sandbox: "workspace-write", // B3(rant 21:59:11):每会话输入框草稿(浏览器 tab 式状态保留)——切换会话保存旧 // sid 草稿、恢复新 sid 草稿;发送成功即清除该 sid 草稿 drafts: new Map(), // sid → 草稿文本 @@ -48,12 +48,12 @@ const App = (() => { 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, disconnected: false }); + state.sessionsBySid.set(key, { busy: false, ownStreamRequestId: null, sandbox: "workspace-write", autoScroll: true, disconnected: false }); } return state.sessionsBySid.get(key); } - // 激活会话条目 = state.busy/ownStreamRequestId/mode 的事实源(P3 过渡期兼容层) + // 激活会话条目 = state.busy/ownStreamRequestId/sandbox 的事实源(P3 过渡期兼容层) Object.defineProperty(state, "busy", { get() { return sidState(state.sessionId).busy; }, set(v) { sidState(state.sessionId).busy = v; }, @@ -62,9 +62,9 @@ const App = (() => { 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; }, + Object.defineProperty(state, "sandbox", { + get() { return sidState(state.sessionId).sandbox; }, + set(v) { sidState(state.sessionId).sandbox = v; }, }); // ── 启动 ───────────────────────────────── @@ -158,10 +158,10 @@ const App = (() => { 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 }); + state.queuedSends.get(sid).push({ requestId, text, sandbox: state.sandbox }); } try { - const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId, mode: state.mode }); + const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId, sandbox: state.sandbox }); state.ownStreamRequestId = res.requestId || requestId; // G124:以 daemon 回显为准 } catch (e) { state.busy = false; @@ -1289,28 +1289,24 @@ const App = (() => { } } - // ── 工作模式切换器(Ask/Auto,WorkBuddy P2) ─────── + // ── 沙箱档位切换器(read-only / workspace-write / danger-full-access,rant 2026-08-20T18:18) ── function initModeSwitcher() { - const sw = $("mode-switcher"); + const sw = $("sandbox-switcher"); if (!sw) return; sw.addEventListener("click", (e) => { const btn = e.target.closest ? e.target.closest(".mode-btn") : null; - if (!btn || !btn.dataset.mode) return; - setMode(btn.dataset.mode); + if (!btn || !btn.dataset.sandbox) return; + setSandbox(btn.dataset.sandbox); }); } - function setMode(mode) { - if (mode !== "ask" && mode !== "auto") return; - state.mode = mode; - const sw = $("mode-switcher"); + function setSandbox(sandbox) { + if (!["read-only", "workspace-write", "danger-full-access"].includes(sandbox)) return; + state.sandbox = sandbox; + const sw = $("sandbox-switcher"); if (!sw) return; for (const btn of sw.querySelectorAll(".mode-btn")) { - btn.classList.toggle("active", btn.dataset.mode === mode); - } - // Ask 模式提示(仅当切到 ask 时轻提示一次,不打断) - if (mode === "ask") { - Chat.addSystemMessage(_t("app.askModeNotice")); + btn.classList.toggle("active", btn.dataset.sandbox === sandbox); } } @@ -1455,7 +1451,7 @@ const App = (() => { 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 }); + const res = await window.emrg.sendMessage({ sessionId: sid, text: item.text, requestId: item.requestId, sandbox: item.sandbox }); sst.ownStreamRequestId = res.requestId || item.requestId; } catch (e) { sst.busy = false; @@ -1463,7 +1459,7 @@ const App = (() => { if (!sid || sid === state.sessionId) setComposerDisabled(false); } if (wasBusy || i > 0) { - remaining.push({ requestId: item.requestId, text: item.text, mode: item.mode }); + remaining.push({ requestId: item.requestId, text: item.text, sandbox: item.sandbox }); } } if (remaining.length) state.queuedSends.set(sid, remaining); @@ -1792,7 +1788,7 @@ const App = (() => { initGithubBanner(); // Windows GCM rant Stage 2:演化需 GitHub 但未认证时的连接横幅 initUpgradeBanner(); // rant 18:30:57:升级完成横幅(重启生效按钮) initModelSwitcher(); - initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式 + initModeSwitcher(); // rant 18:18:沙箱档位切换器(替代 Ask/Auto) ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏) FileTree.setSession(state.sessionId, projectPathFor(state.sessionId)); // P3.1:文件树初始根 initEvolutionToast(); // WorkBuddy P3:进化 toast 按钮绑定 @@ -1826,7 +1822,7 @@ const App = (() => { maybeShowEvolutionToast, // WorkBuddy P3:进化 toast 检测 showVersionInfo, // WorkBuddy P3:/version 内容(toast "去看看" 共用) refreshLocale, // rant 21:19:locale 切换后动态文案重刷 - setMode, // WorkBuddy P2:Ask/Auto 模式(导出供测试与外部调用) + setSandbox, // rant 18:18:沙箱档位切换(导出供测试与外部调用) loadEvolutionSummary, // WorkBuddy P3:最近改进摘要 }; })(); diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 9e01ef7..6ce90c7 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -54,7 +54,10 @@ const I18N = (() => { // 输入区 "composer.modelLoading": "加载中…", "composer.switchModel": "切换模型", - "composer.modeTitle": "工作模式:Ask 只对话 / Auto 自动执行", + "composer.sandboxTitle": "沙箱档位:read-only 只读 / workspace-write 可写工作区 / danger-full-access 全放行", + "composer.sandboxReadOnly": "read-only(只读)", + "composer.sandboxWorkspaceWrite": "workspace-write(可写工作区,默认)", + "composer.sandboxFullAccess": "danger-full-access(全放行)", "composer.placeholder": "发消息给 EMRG… (Enter 发送 / Shift+Enter 换行)", "composer.send": "发送", "composer.stop": "停止回复", @@ -410,7 +413,6 @@ const I18N = (() => { "app.goSettings": "去设置添加", "app.modelSwitchFailed": "切换模型失败了:{msg}", "app.modelListFailed": "读取模型列表失败了:{msg}", - "app.askModeNotice": "Ask 模式:我只对话,不执行工具。输入内容问我就好。", "app.upgradeBannerMsg": "EMRG 已升级到 {version},重启后生效", "app.upgradeRestartFailed": "重启失败:{msg}", "app.upgradeRestartBtn": "重启生效", @@ -462,7 +464,10 @@ const I18N = (() => { // Composer "composer.modelLoading": "Loading…", "composer.switchModel": "Switch model", - "composer.modeTitle": "Work mode: Ask (chat only) / Auto (run tools)", + "composer.sandboxTitle": "Sandbox tier: read-only / workspace-write / danger-full-access", + "composer.sandboxReadOnly": "read-only", + "composer.sandboxWorkspaceWrite": "workspace-write (default)", + "composer.sandboxFullAccess": "danger-full-access", "composer.placeholder": "Message EMRG… (Enter to send / Shift+Enter for new line)", "composer.send": "Send", "composer.stop": "Stop reply", @@ -818,7 +823,6 @@ const I18N = (() => { "app.goSettings": "Add in Settings", "app.modelSwitchFailed": "Failed to switch model: {msg}", "app.modelListFailed": "Failed to load models: {msg}", - "app.askModeNotice": "Ask mode: I only chat — no tools. Just ask.", "app.upgradeBannerMsg": "EMRG upgraded to {version} — restart to apply", "app.upgradeRestartFailed": "Restart failed: {msg}", "app.upgradeRestartBtn": "Restart to apply", diff --git a/emrg/gui/test/app-commands.test.js b/emrg/gui/test/app-commands.test.js index cc839f4..54ec6bf 100644 --- a/emrg/gui/test/app-commands.test.js +++ b/emrg/gui/test/app-commands.test.js @@ -255,37 +255,37 @@ test("P4:/trigger 直接触发(不打开对话框)", async () => { assert.ok(!(els["tasks-dialog"] && els["tasks-dialog"].__open), "direct trigger should not open dialog"); }); -// ── WorkBuddy P2(rant 21:35):Ask/Auto 模式 ──────────────── -test("P2:setMode('ask') 更新 state.mode 并提示(Ask 只对话不执行工具)", async () => { +// ── Rant 2026-08-20T18:18:Ask/Auto 删除 → 沙箱三档切换 ──────────────── +test("setSandbox 更新 state.sandbox(三档白名单)", async () => { const { ctx } = makeSandbox({}); await tick(); - await vm.runInContext("App.setMode('ask')", ctx); - const mode = await vm.runInContext("App.state.mode", ctx); - assert.strictEqual(mode, "ask", "state.mode 应为 ask"); - await vm.runInContext("App.setMode('auto')", ctx); - const mode2 = await vm.runInContext("App.state.mode", ctx); - assert.strictEqual(mode2, "auto", "state.mode 应切回 auto"); + await vm.runInContext("App.setSandbox('read-only')", ctx); + const sb = await vm.runInContext("App.state.sandbox", ctx); + assert.strictEqual(sb, "read-only", "state.sandbox 应为 read-only"); + await vm.runInContext("App.setSandbox('danger-full-access')", ctx); + const sb2 = await vm.runInContext("App.state.sandbox", ctx); + assert.strictEqual(sb2, "danger-full-access", "state.sandbox 应切到 danger-full-access"); }); -test("P2:setMode 非法值不生效(白名单 ask/auto)", async () => { +test("setSandbox 非法值不生效(白名单 read-only/workspace-write/danger-full-access)", async () => { const { ctx } = makeSandbox({}); await tick(); - await vm.runInContext("App.setMode('bogus')", ctx); - const mode = await vm.runInContext("App.state.mode", ctx); - assert.strictEqual(mode, "auto", "非法 mode 应保持默认 auto"); + await vm.runInContext("App.setSandbox('bogus')", ctx); + const sb = await vm.runInContext("App.state.sandbox", ctx); + assert.strictEqual(sb, "workspace-write", "非法档位应保持默认 workspace-write"); }); -test("P2:sendMessage 透传 state.mode(ask → sendMessage 带 mode)", async () => { +test("sendMessage 透传 state.sandbox(read-only → sendMessage 带 sandbox)", async () => { let sent = null; const { ctx, els } = makeSandbox({ sendMessage: async (p) => { sent = p; return {}; }, }); await tick(); - vm.runInContext('App.state.sessionId = "s1"; App.state.mode = "ask";', ctx); + vm.runInContext('App.state.sessionId = "s1"; App.state.sandbox = "read-only";', ctx); els["input"].value = "帮我写一段代码"; await vm.runInContext("App.sendMessage()", ctx); assert.ok(sent, "sendMessage 应被调用"); - assert.strictEqual(sent.mode, "ask", "ask 模式应透传 mode 参数"); + assert.strictEqual(sent.sandbox, "read-only", "read-only 档位应透传 sandbox 参数"); assert.strictEqual(sent.text, "帮我写一段代码", "文本应正常透传"); }); diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 33acb1d..d94b89a 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -551,6 +551,7 @@ test("sendTask payload(G32,无 stream 字段——rant 21:20:38)", async ( assert.strictEqual(frame.cwd, "/proj"); assert.strictEqual(frame.prompt, "hello"); assert.strictEqual(frame.images, null); + assert.strictEqual(frame.sandbox, "workspace-write", "rant 18:18:默认沙箱档位应随每条消息发送"); assert.ok(frame.timestamp); }); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 2cc8d12..fec1ccb 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1745,7 +1745,7 @@ test("P2 queue: steer_committed removes that request from queue", async () => { 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.state.queuedSends.set("sess-1", [{ requestId: "req-a", text: "hi", sandbox: "workspace-write" }, { requestId: "req-b", text: "yo", sandbox: "workspace-write" }]);' + 'App.handleEvent({ type: "steer_committed", sid: "sess-1", data: { request_id: "req-a" } });', ctx ); @@ -1763,7 +1763,7 @@ test("P2 queue: queued_requeue re-sends with same requestId + re-tracks (review 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.state.queuedSends.set("sess-1", [{ requestId: "req-queue", text: "hi", sandbox: "workspace-write" }]);' + 'App.handleEvent({ type: "queued_requeue", sid: "sess-1", data: { request_ids: ["req-queue"] } });', ctx ); @@ -1790,8 +1790,8 @@ test("P2 queue: requeue with 2 msgs (idle turn end) re-tracks 2nd+ (review ❌ r '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" }]);' + + ' { requestId: "req-m1", text: "m1", sandbox: "workspace-write" },' + + ' { requestId: "req-m2", text: "m2", sandbox: "workspace-write" }]);' + 'App.handleEvent({ type: "queued_requeue", sid: "sess-1", data: { request_ids: ["req-m1", "req-m2"] } });', ctx ); @@ -1808,7 +1808,7 @@ test("P2 queue: queued_cancelled clears queue + note", async () => { await tick(); await vm.runInContext( 'App.state.sessionId = "sess-1";' + - 'App.state.queuedSends.set("sess-1", [{ requestId: "req-a", text: "hi", mode: "auto" }]);' + + 'App.state.queuedSends.set("sess-1", [{ requestId: "req-a", text: "hi", sandbox: "workspace-write" }]);' + 'App.handleEvent({ type: "queued_cancelled", sid: "sess-1", data: {} });', ctx ); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index b185506..294c531 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -793,9 +793,12 @@ async def _handle_client(self, ws) -> None: except Exception as e: await self._send(ws, {"error": f"invalid task: {e}"}) continue - # WorkBuddy P2 (rant 21:35): Ask mode — pure chat, no tools. - # mode="ask" → LLM gets an empty tool set so it can only reply. - allow_tools = data.get("mode", "auto") != "ask" + # Rant 2026-08-20T18:18: Ask/Auto modes removed — every + # interactive message allows tools; the sandbox tier + # (req.sandbox, per-message) controls file permissions. + # The pending queue keeps the (req, allow_tools) shape for + # compatibility but allow_tools is always True now. + allow_tools = True if self._session_busy.get(session_id): # Queue the task; injected at the next round boundary. self._session_pending.setdefault(session_id, []).append((req, allow_tools)) @@ -2143,13 +2146,15 @@ async def _inject_pending_messages( Returns ``(injected_count, ask_injected)`` — ``ask_injected`` is True when any queued message was Ask mode (mode=ask); the caller must use - an empty tool set for the round that processes it. + an empty tool set for the round that processes it. (Rant + 2026-08-20T18:18: Ask mode removed — always False, kept for signature + compatibility.) """ sid = session.session_id pending = self._session_pending.pop(sid, []) if not pending: return 0, False - ask_injected = any(not allow for _, allow in pending) + ask_injected = any(not allow for _, allow in pending) # always False post-rant 18:18 for preq, _ in pending: pcontent = self._build_user_content( preq.prompt, preq.images, self.llm.config.vision diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index db5bbc5..ea13321 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -1032,12 +1032,15 @@ class TestWSQueueInjection: """ @staticmethod - def _task(sid, tid, prompt, cwd, mode="auto"): - return { + def _task(sid, tid, prompt, cwd, sandbox=None): + t = { "type": "task", "id": tid, "session_id": sid, "cwd": str(cwd), "prompt": prompt, "stream": True, - "mode": mode, "timestamp": "2026-08-10T21:55:37", + "timestamp": "2026-08-10T21:55:37", } + if sandbox: + t["sandbox"] = sandbox + return t def test_pending_injected_at_round_boundary_after_tools(self): """B's message queued while A's tool executes is injected at the @@ -1129,9 +1132,11 @@ async def chat_stream(messages, tools=None): await cleanup() asyncio.run(_test()) - def test_pending_ask_injects_empty_tools(self): - """A queued Ask-mode message is injected with an empty tool set - (mode=ask → tools=[]), so the injected round can only reply.""" + def test_pending_sandbox_queued_keeps_tools(self): + """Rant 2026-08-20T18:18: a queued message with a sandbox tier is + injected with tools enabled (no more Ask/empty-tool mode) — the + sandbox tier only constrains the bash tool's file permissions, + never the tool set itself.""" async def _test(): with tempfile.TemporaryDirectory() as tmp: cwd = Path(tmp) @@ -1175,13 +1180,14 @@ async def chat_stream(messages, tools=None): self._task("s_ask", "t-ask-a", "turn", cwd), ensure_ascii=False)) await asyncio.sleep(0.15) await ws_b.send(json.dumps( - self._task("s_ask", "t-ask-b", "ask-me", cwd, mode="ask"), + self._task("s_ask", "t-ask-b", "ask-me", cwd, sandbox="read-only"), ensure_ascii=False)) fq = await _recv_until( ws_b, lambda f: f.get("type") == "task_queued", what="task_queued") while seen["tools"] == "unset": await asyncio.wait_for(ws_a.recv(), timeout=10) - assert seen["tools"] == [], f"ask round must have empty tools, got {seen['tools']}" + assert seen["tools"] is not None, "tools must be enabled for the queued round" + assert len(seen["tools"]) > 0, f"tools must be non-empty (no Ask mode), got {seen['tools']}" finally: await ws_a.close() await ws_b.close() From eeec01a9ed27453e8999ed5ebb81689f36aeab14 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 20 Aug 2026 19:50:54 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20sync=20Agent.md=20Python=20test=20c?= =?UTF-8?q?ount=20983=E2=86=92984=20(#895=20added=20one=20test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index 60c634f..bff7a45 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (983) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (984) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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 路径不受影响)