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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 路径不受影响)
Expand Down
7 changes: 4 additions & 3 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 切会话检查读本字段)
Expand Down
6 changes: 3 additions & 3 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,10 @@ <h2 class="workspace-view-title" data-i18n="settings.title">设置</h2>
<span id="model-switcher-label" data-i18n="composer.modelLoading">加载中…</span>
<span style="font-size:10px;">⌄</span>
</div>
<div class="mode-switcher" id="mode-switcher" title="工作模式:Ask 只对话 / Auto 自动执行" data-i18n-title="composer.modeTitle">
<button type="button" class="mode-btn" data-mode="ask">Ask</button>
<button type="button" class="mode-btn active" data-mode="auto">Auto</button>
<div class="mode-switcher" id="sandbox-switcher" title="沙箱档位:read-only 只读 / workspace-write 可写工作区 / danger-full-access 全放行" data-i18n-title="composer.sandboxTitle">
<button type="button" class="mode-btn" data-sandbox="read-only" title="read-only(只读)" data-i18n-title="composer.sandboxReadOnly">只读</button>
<button type="button" class="mode-btn active" data-sandbox="workspace-write" title="workspace-write(可写工作区,默认)" data-i18n-title="composer.sandboxWorkspaceWrite">可写</button>
<button type="button" class="mode-btn" data-sandbox="danger-full-access" title="danger-full-access(全放行)" data-i18n-title="composer.sandboxFullAccess">全放行</button>
</div>
<div class="composer-card">
<textarea id="input" rows="1" placeholder="发消息给 EMRG… (Enter 发送 / Shift+Enter 换行)" data-i18n-placeholder="composer.placeholder" disabled></textarea>
Expand Down
50 changes: 23 additions & 27 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 → 草稿文本
Expand All @@ -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; },
Expand All @@ -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; },
});

// ── 启动 ─────────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -1455,15 +1451,15 @@ 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;
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 });
remaining.push({ requestId: item.requestId, text: item.text, sandbox: item.sandbox });
}
}
if (remaining.length) state.queuedSends.set(sid, remaining);
Expand Down Expand Up @@ -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 按钮绑定
Expand Down Expand Up @@ -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:最近改进摘要
};
})();
Expand Down
12 changes: 8 additions & 4 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": "停止回复",
Expand Down Expand Up @@ -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": "重启生效",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
30 changes: 15 additions & 15 deletions emrg/gui/test/app-commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,37 +255,37 @@ test("P4:/trigger <name> 直接触发(不打开对话框)", 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, "帮我写一段代码", "文本应正常透传");
});

Expand Down
1 change: 1 addition & 0 deletions emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading
Loading