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 路径不受影响)
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()