Repository navigation
Auto mode, and an approval box you can't scroll past #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| // Auto mode's decision rules. These are the only place a tool runs | ||
| // WITHOUT a human looking, so they get pinned down hard: what auto mode | ||
| // waves through, what it refuses to wave through, and the fact that a | ||
| // question is never answered by the machine. | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { autoDecision, looksDestructive } from "./auto-approve.ts"; | ||
|
|
||
| describe("looksDestructive", () => { | ||
| const dangerous = [ | ||
| "rm -rf /Users/milind/project", | ||
| "rm -fr node_modules", | ||
| "sudo rm /etc/hosts", | ||
| "dd if=/dev/zero of=/dev/disk2", | ||
| "mkfs.ext4 /dev/sda1", | ||
| "git push --force origin main", | ||
| "git push --force-with-lease", | ||
| "git reset --hard HEAD~5", | ||
| "DROP TABLE users;", | ||
| "truncate table sessions", | ||
| "sudo shutdown -h now", | ||
| ":(){ :|:& };:", | ||
| "chmod -R 777 /", | ||
| ]; | ||
| for (const command of dangerous) { | ||
| it(`stops: ${command}`, () => expect(looksDestructive(command)).toBe(true)); | ||
| } | ||
|
|
||
| const ordinary = [ | ||
| "rm build/output.js", | ||
| "ls -la src", | ||
| "git push origin feature/rooms", | ||
| "npm install lucide-react", | ||
| "grep -rn TODO src", | ||
| "cat package.json", | ||
| "git commit -m 'fix the reformatting'", | ||
| "SELECT * FROM users LIMIT 10", | ||
| ]; | ||
| for (const command of ordinary) { | ||
| it(`allows: ${command}`, () => expect(looksDestructive(command)).toBe(false)); | ||
| } | ||
| }); | ||
|
|
||
| describe("autoDecision", () => { | ||
| it("asks when the bot is not in auto mode", () => { | ||
| expect(autoDecision({}, "Bash", "ls -la")).toBeNull(); | ||
| }); | ||
|
|
||
| it("approves routine tools in auto mode, and says so", () => { | ||
| const decision = autoDecision({ autoApprove: true }, "Bash", "ls -la"); | ||
| expect(decision).toBe("auto-approved Bash"); | ||
| }); | ||
|
|
||
| it("still stops for a destructive command in auto mode", () => { | ||
| expect(autoDecision({ autoApprove: true }, "Bash", "rm -rf /")).toBeNull(); | ||
| }); | ||
|
|
||
| it("honours always-allow for one tool without turning on auto mode", () => { | ||
| const bot = { alwaysAllow: ["Read"] }; | ||
| expect(autoDecision(bot, "Read", "src/index.ts")).toBe("auto-approved Read (always allowed)"); | ||
| expect(autoDecision(bot, "Bash", "ls")).toBeNull(); | ||
| }); | ||
|
|
||
| it("never lets always-allow override the destructive guard", () => { | ||
| expect(autoDecision({ alwaysAllow: ["Bash"] }, "Bash", "sudo rm -rf /var")).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| // Auto mode: when a bot may answer its own permission requests. | ||
| // | ||
| // Two ways in — the bot is in auto mode, or the user pressed "Always | ||
| // allow" for that one tool — and one way out: anything that reads as | ||
| // destructive stops and asks a human anyway. | ||
| // | ||
| // The guard is deliberately tiny and literal. It is NOT a security | ||
| // boundary (an agent set on damage has a thousand spellings for `rm`); | ||
| // it is a "you probably didn't mean to hand THIS one over unattended" | ||
| // backstop for the obvious catastrophes. Real containment is the | ||
| // sandbox and the bot's own computer, not a regex. | ||
|
|
||
| const DESTRUCTIVE = [ | ||
| /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf]/i, // rm -rf, rm -fr, rm -r -f | ||
| /\bmkfs\b|\bdiskutil\s+erase|\bdd\s+[^|]*\bof=\/dev\//i, | ||
| /\bshutdown\b|\breboot\b|\bhalt\b/i, | ||
| /:\(\)\s*\{.*\}\s*;?\s*:/, // fork bomb | ||
| /\bgit\s+push\s+[^|]*--force(-with-lease)?\b|\bgit\s+reset\s+--hard\b/i, | ||
| /\bDROP\s+(TABLE|DATABASE)\b|\bTRUNCATE\s+TABLE\b/i, | ||
| /\bsudo\s+rm\b|\bchmod\s+-R\s+777\s+\//i, | ||
| ]; | ||
|
|
||
| export function looksDestructive(text: string): boolean { | ||
| return DESTRUCTIVE.some((re) => re.test(text)); | ||
| } | ||
|
|
||
| export interface AutoApprover { | ||
| autoApprove?: boolean; | ||
| alwaysAllow?: string[]; | ||
| } | ||
|
|
||
| /** Why this request may be answered without the human, or null to ask. | ||
| * The returned string becomes the chip in the transcript, so an | ||
| * auto-approved action is never invisible. */ | ||
| export function autoDecision(bot: AutoApprover, tool: string, summary: string): string | null { | ||
| if (looksDestructive(summary) || looksDestructive(tool)) return null; | ||
| if (bot.alwaysAllow?.includes(tool)) return `auto-approved ${tool} (always allowed)`; | ||
| if (bot.autoApprove) return `auto-approved ${tool}`; | ||
| return null; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { homedir } from "node:os"; | |||||||||||||||||||||||||||||||||||||
| import { dirname, extname, join } from "node:path"; | ||||||||||||||||||||||||||||||||||||||
| import { fileURLToPath } from "node:url"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import { autoDecision } from "./auto-approve.ts"; | ||||||||||||||||||||||||||||||||||||||
| import * as box from "./box.ts"; | ||||||||||||||||||||||||||||||||||||||
| import * as composio from "./composio.ts"; | ||||||||||||||||||||||||||||||||||||||
| import { ensureDirs, instanceConfigs, loadConfig, saveConfig, EVENTS_DIR, NATIVE_DIR } from "./config.ts"; | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -136,6 +137,7 @@ const askMessageByRequest = new Map<string, string>(); // requestId -> messageId | |||||||||||||||||||||||||||||||||||||
| // records the active member here before dispatching its turn. | ||||||||||||||||||||||||||||||||||||||
| const groupSpeakers = new Map<string, { botId: string; name: string; color: string }>(); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| bus.subscribe((event: RuntimeEvent) => { | ||||||||||||||||||||||||||||||||||||||
| broadcast({ kind: "runtime", event }); | ||||||||||||||||||||||||||||||||||||||
| const bot = store.botByThread(event.threadId); | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -189,6 +191,26 @@ bus.subscribe((event: RuntimeEvent) => { | |||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
| case "request.opened": { | ||||||||||||||||||||||||||||||||||||||
| const permission = event.requestType === "permission"; | ||||||||||||||||||||||||||||||||||||||
| // Auto mode / always-allow: answer routine tool permissions for the | ||||||||||||||||||||||||||||||||||||||
| // bot so it keeps working. A QUESTION always reaches the human — the | ||||||||||||||||||||||||||||||||||||||
| // whole point of asking is that a person decides — and anything that | ||||||||||||||||||||||||||||||||||||||
| // looks destructive stops even in auto mode. | ||||||||||||||||||||||||||||||||||||||
| const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined); | ||||||||||||||||||||||||||||||||||||||
| const settled = permission && asker && event.requestId | ||||||||||||||||||||||||||||||||||||||
| ? autoDecision(asker, event.tool, event.summary) | ||||||||||||||||||||||||||||||||||||||
| : null; | ||||||||||||||||||||||||||||||||||||||
| if (settled && asker) { | ||||||||||||||||||||||||||||||||||||||
| const instance = registry.get(asker.modelSelection.instanceId); | ||||||||||||||||||||||||||||||||||||||
| void instance?.adapter | ||||||||||||||||||||||||||||||||||||||
| .respondToRequest(event.threadId, event.requestId!, { behavior: "allow" }) | ||||||||||||||||||||||||||||||||||||||
| .catch(() => {}); | ||||||||||||||||||||||||||||||||||||||
| pushMessage({ | ||||||||||||||||||||||||||||||||||||||
| role: "bot", | ||||||||||||||||||||||||||||||||||||||
| kind: "activity", | ||||||||||||||||||||||||||||||||||||||
| tool: { name: `${settled}: ${event.summary.slice(0, 120)}`, ok: true }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| const message = pushMessage({ | ||||||||||||||||||||||||||||||||||||||
| role: "bot", | ||||||||||||||||||||||||||||||||||||||
| kind: "options", | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -197,6 +219,9 @@ bus.subscribe((event: RuntimeEvent) => { | |||||||||||||||||||||||||||||||||||||
| subtitle: event.summary, | ||||||||||||||||||||||||||||||||||||||
| options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [], | ||||||||||||||||||||||||||||||||||||||
| requestId: event.requestId, | ||||||||||||||||||||||||||||||||||||||
| tool: permission ? event.tool : undefined, | ||||||||||||||||||||||||||||||||||||||
| // in auto mode a card can only mean the guard stopped it — say so | ||||||||||||||||||||||||||||||||||||||
| held: permission && asker?.autoApprove ? "This looked destructive, so auto mode stopped to ask." : undefined, | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| if (event.requestId) askMessageByRequest.set(event.requestId, message.id); | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -959,7 +984,7 @@ const server = createServer(async (req, res) => { | |||||||||||||||||||||||||||||||||||||
| if (m && method === "PATCH") { | ||||||||||||||||||||||||||||||||||||||
| const body = await readBody(req); | ||||||||||||||||||||||||||||||||||||||
| const patch: Record<string, unknown> = {}; | ||||||||||||||||||||||||||||||||||||||
| for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden"] as const) { | ||||||||||||||||||||||||||||||||||||||
| for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden", "autoApprove", "alwaysAllow"] as const) { | ||||||||||||||||||||||||||||||||||||||
| if (body[key] !== undefined) patch[key] = body[key]; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+987
to
989
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Validate approval settings before persisting them. This route accepts arbitrary JSON for Require Proposed validation- for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden", "autoApprove", "alwaysAllow"] as const) {
+ for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden"] as const) {
if (body[key] !== undefined) patch[key] = body[key];
}
+ if (body.autoApprove !== undefined) {
+ if (typeof body.autoApprove !== "boolean") {
+ return json(res, 400, { error: "autoApprove must be a boolean" });
+ }
+ patch.autoApprove = body.autoApprove;
+ }
+ if (body.alwaysAllow !== undefined) {
+ if (!Array.isArray(body.alwaysAllow) || body.alwaysAllow.some((tool) => typeof tool !== "string")) {
+ return json(res, 400, { error: "alwaysAllow must be an array of strings" });
+ }
+ patch.alwaysAllow = body.alwaysAllow;
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| const bot = store.patchBot(m[1], patch); | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -1072,6 +1097,24 @@ const server = createServer(async (req, res) => { | |||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| return json(res, 200, { ok: true }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| // Answer by THREAD, so a request raised inside a room can be answered | ||||||||||||||||||||||||||||||||||||||
| // too: a member's turn runs on the room's thread, and the bot that | ||||||||||||||||||||||||||||||||||||||
| // owns the pending request is the one currently speaking there. | ||||||||||||||||||||||||||||||||||||||
| m = path.match(/^\/api\/threads\/([\w-]+)\/respond$/); | ||||||||||||||||||||||||||||||||||||||
| if (m && method === "POST") { | ||||||||||||||||||||||||||||||||||||||
| const threadId = m[1]; | ||||||||||||||||||||||||||||||||||||||
| const body = await readBody(req); | ||||||||||||||||||||||||||||||||||||||
| const group = store.groupByThread(threadId); | ||||||||||||||||||||||||||||||||||||||
| const owner = group ? (group.busyBotId ? store.bot(group.busyBotId) : undefined) : store.botByThread(threadId); | ||||||||||||||||||||||||||||||||||||||
| if (!owner) return json(res, 404, { error: "nothing is waiting on an answer in this conversation" }); | ||||||||||||||||||||||||||||||||||||||
| const instance = registry.get(owner.modelSelection.instanceId); | ||||||||||||||||||||||||||||||||||||||
| if (!instance) return json(res, 409, { error: "provider unavailable" }); | ||||||||||||||||||||||||||||||||||||||
| await instance.adapter.respondToRequest(threadId, String(body.requestId), { | ||||||||||||||||||||||||||||||||||||||
| behavior: body.behavior, | ||||||||||||||||||||||||||||||||||||||
| message: body.message, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| return json(res, 200, { ok: true }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/); | ||||||||||||||||||||||||||||||||||||||
| if (m && method === "POST") { | ||||||||||||||||||||||||||||||||||||||
| const bot = store.bot(m[1]); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| // The approval box: what the bot wants to do, and three ways to answer. | ||
| // | ||
| // Deliberately not the lettered A/B/C list the onboarding card uses — an | ||
| // approval is a decision about one concrete action, so it shows the tool | ||
| // and the actual command/path in monospace, and the choices carry their | ||
| // own behavior instead of being matched by their label text. | ||
| import { Check, ShieldCheck, X } from "lucide-react"; | ||
| import { type Bot, type Message } from "@/state/store"; | ||
| import { cn } from "@/lib/cn"; | ||
|
|
||
| /** The tool's own name is noise to a human: mcp__ogb__computer_batch is | ||
| * "computer batch", Bash is "run a command". */ | ||
| function toolLabel(tool?: string): string { | ||
| if (!tool) return "an action"; | ||
| const bare = tool.replace(/^mcp__[^_]+__/, "").replace(/_/g, " "); | ||
| const nice: Record<string, string> = { | ||
| Bash: "run a command", | ||
| Read: "read a file", | ||
| Write: "write a file", | ||
| Edit: "edit a file", | ||
| WebFetch: "fetch a web page", | ||
| WebSearch: "search the web", | ||
| }; | ||
| return nice[tool] ?? bare; | ||
| } | ||
|
|
||
| export function ApprovalCard({ | ||
| bot, | ||
| message, | ||
| }: { | ||
| /** who is asking, for the "Name wants to …" line */ | ||
| bot?: Bot; | ||
| message: Message; | ||
| }) { | ||
| const card = message.card; | ||
| if (!card) return null; | ||
| const settled = card.answered; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "w-full max-w-[840px] rounded-2xl border bg-card p-4", | ||
| settled ? "border-hairline/30 opacity-70" : "border-accent/40", | ||
| )} | ||
| > | ||
| <div className="flex items-baseline justify-between gap-3"> | ||
| <div className="text-[15px] font-semibold text-ink"> | ||
| {bot ? `${bot.name} wants to ` : "Wants to "} | ||
| {toolLabel(card.tool)} | ||
| </div> | ||
| {card.tool && <span className="shrink-0 font-mono text-[11px] text-ink-secondary">{card.tool}</span>} | ||
| </div> | ||
|
|
||
| {/* what, exactly */} | ||
| <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-inset px-3 py-2 font-mono text-[12.5px] leading-relaxed text-ink"> | ||
| {card.subtitle} | ||
| </pre> | ||
|
|
||
| {card.held && ( | ||
| <div className="mt-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-[12.5px] text-warning"> | ||
| {card.held} | ||
| </div> | ||
| )} | ||
|
|
||
| {/* The decision lives in the composer (one place to answer, and it | ||
| can't be scrolled past); here we only record what happened. */} | ||
| <div className="mt-3 flex items-center gap-1.5 text-[13px] text-ink-secondary"> | ||
| {settled === "allow" ? ( | ||
| <> | ||
| <Check size={14} className="text-success" /> Allowed | ||
| </> | ||
| ) : settled ? ( | ||
| <> | ||
| <X size={14} /> Denied | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <ShieldCheck size={14} className="text-accent" /> Waiting for your answer below | ||
| </> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import { useStore, type Bot, type Group } from "@/state/store"; | |
| import { cn } from "@/lib/cn"; | ||
| import { MausAvatar } from "./Avatar"; | ||
| import { normalizeState } from "@/lib/mascot"; | ||
| import { PendingApprovalActions, PendingApprovalPanel, pendingApprovals } from "./PendingApproval"; | ||
|
|
||
| /** The active @mention query at the caret: the text between an `@` that | ||
| * starts a word and the caret. null = no mention being typed. */ | ||
|
|
@@ -33,6 +34,14 @@ export function Composer({ | |
| // Unified target: a 1:1 bot thread or a room. In a room the @ picker | ||
| // offers the members (Buzz rule: only mentioned bots reply). | ||
| const busy = group ? Boolean(group.busyBotId) : Boolean(bot?.busy); | ||
| // a pending approval blocks the prompt until it is answered | ||
| const threadId = group?.threadId ?? bot?.threadId ?? ""; | ||
| const approvals = pendingApprovals(group ? group.messages : (bot?.messages ?? [])); | ||
| const approval = approvals[0]; | ||
| const approvalBot = group | ||
| ? members?.find((b) => b.id === approval?.message.from?.botId) ?? | ||
| members?.find((b) => b.id === group.busyBotId) | ||
| : bot; | ||
|
Comment on lines
+37
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Map the branch and approval-related symbols before inspecting implementations.
ast-grep outline src/state/store.tsx --items all --type function --match 'visibleMessages|patchCard'
# Confirm whether rewinds, edits, and request resolution remove or settle
# cards that are outside the active branch.
rg -n -C 6 'visibleMessages|activeLeafId|rewound|request\.opened|request\.resolved|editMessage' src/state/store.tsx serverRepository: milind-soni/OpenMausBot Length of output: 49871 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Composer and approval helper ---'
cat -n src/components/Composer.tsx | sed -n '1,90p'
rg -n -C 8 'function pendingApprovals|const pendingApprovals|pendingApprovals\\(' src
printf '%s\n' '--- Card data and message patching ---'
rg -n -C 10 'answered|dismissed|card:|kind: "options"|OptionCardData|messagePatched' src/state/store.tsx server/index.ts server/store.ts
printf '%s\n' '--- Branch operations ---'
cat -n server/store.ts | sed -n '340,410p'
cat -n server/index.ts | sed -n '1040,1090p'
cat -n src/state/store.tsx | sed -n '95,125p;245,270p;330,375p;470,500p'Repository: milind-soni/OpenMausBot Length of output: 4956 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Approval helper ---'
rg -n -F -C 10 'pendingApprovals' src
rg -n -C 10 'answered|dismissed|OptionCardData|messagePatched|kind: "options"' src/state/store.tsx src/components/PendingApproval.tsx server/index.ts server/store.ts
printf '%s\n' '--- Branch operations ---'
cat -n server/store.ts | sed -n '340,410p'
cat -n server/index.ts | sed -n '1040,1090p'
cat -n src/state/store.tsx | sed -n '95,125p;245,270p;330,375p;470,500p'Repository: milind-soni/OpenMausBot Length of output: 43867 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Turn interruption and completion paths ---'
rg -n -C 12 'case "interrupt"|interruptTurn|turn.completed|request\\.opened|runtime.error|bot\\.busy|busy: false|patchBot\\(.*busy' server/index.ts server/store.ts src/state/store.tsx
printf '%s\n' '--- Composer visibility and branch selection ---'
rg -n -C 8 'Composer|visibleMessages|switchBranch|active-branch' src/components src/state/store.tsx
printf '%s\n' '--- Branching test coverage around pending cards ---'
rg -n -i -C 6 'approval|permission|question|interrupt|branch|rewind' server/*test.ts src/**/*.test.* 2>/dev/null || trueRepository: milind-soni/OpenMausBot Length of output: 385 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Turn interruption and completion paths ---'
rg -n -C 12 -e 'case "interrupt"' -e 'interruptTurn' -e 'turn\.completed' -e 'request\.opened' -e 'runtime\.error' -e 'bot\.busy' -e 'busy: false' -e 'patchBot\(.*busy' server/index.ts server/store.ts src/state/store.tsx
printf '%s\n' '--- Composer visibility and branch selection ---'
rg -n -C 8 -e 'Composer' -e 'visibleMessages' -e 'switchBranch' -e 'active-branch' src/components src/state/store.tsx
printf '%s\n' '--- Branching test coverage around pending cards ---'
rg -n -i -C 6 -e 'approval' -e 'permission' -e 'question' -e 'interrupt' -e 'branch' -e 'rewind' server/*test.ts 'src/**/*.test.*' 2>/dev/null || trueRepository: milind-soni/OpenMausBot Length of output: 50379 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
composer = Path("src/components/Composer.tsx").read_text()
helper = Path("src/components/PendingApproval.tsx").read_text()
store = Path("src/state/store.tsx").read_text()
assert "pendingApprovals(group ? group.messages : (bot?.messages ?? []))" in composer
assert ".filter((m) => m.kind === \"options\"" in helper
assert "activeLeafId: action.activeLeafId" in store
assert "activeLeafId: cur" in store
# Minimal branch model matching visibleMessages: root -> active leaf.
root = {"id": "root", "parentId": None}
old_leaf = {
"id": "old-card",
"parentId": "root",
"kind": "options",
"card": {"requestId": "req-old", "tool": "shell"},
}
new_leaf = {"id": "new-branch", "parentId": "root", "kind": "text"}
messages = [root, old_leaf, new_leaf]
def visible(messages, leaf_id):
by_id = {m["id"]: m for m in messages}
path = []
cur = by_id.get(leaf_id)
while cur:
path.append(cur)
cur = by_id.get(cur["parentId"]) if cur.get("parentId") else None
return list(reversed(path))
def pending(messages):
return [
m for m in messages
if m.get("kind") == "options"
and m.get("card", {}).get("requestId")
and m.get("card", {}).get("tool")
and not m.get("card", {}).get("answered")
and not m.get("card", {}).get("dismissed")
]
active = visible(messages, "new-branch")
assert not any(m["id"] == "old-card" for m in active)
assert any(m["id"] == "old-card" for m in pending(messages))
print("unanswered off-branch card: visibleMessages=excluded, Composer pendingApprovals input=included")
PYRepository: milind-soni/OpenMausBot Length of output: 256 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
composer = Path("src/components/Composer.tsx").read_text()
helper = Path("src/components/PendingApproval.tsx").read_text()
store = Path("src/state/store.tsx").read_text()
assert "pendingApprovals(group ? group.messages : (bot?.messages ?? []))" in composer
assert ".filter((m) => m.kind === \"options\"" in helper
assert "activeLeafId: action.activeLeafId" in store
assert "activeLeafId: cur" in store
root = {"id": "root", "parentId": None}
old_leaf = {
"id": "old-card",
"parentId": "root",
"kind": "options",
"card": {"requestId": "req-old", "tool": "shell"},
}
new_leaf = {"id": "new-branch", "parentId": "root", "kind": "text"}
messages = [root, old_leaf, new_leaf]
def visible(messages, leaf_id):
by_id = {m["id"]: m for m in messages}
path = []
cur = by_id.get(leaf_id)
while cur:
path.append(cur)
cur = by_id.get(cur["parentId"]) if cur.get("parentId") else None
return list(reversed(path))
def pending(messages):
return [
m for m in messages
if m.get("kind") == "options"
and m.get("card", {}).get("requestId")
and m.get("card", {}).get("tool")
and not m.get("card", {}).get("answered")
and not m.get("card", {}).get("dismissed")
]
active = visible(messages, "new-branch")
assert not any(m["id"] == "old-card" for m in active)
assert any(m["id"] == "old-card" for m in pending(messages))
print("unanswered off-branch card: visibleMessages=excluded, Composer pendingApprovals input=included")
PYRepository: milind-soni/OpenMausBot Length of output: 256 Use the active branch when finding pending approvals.
🤖 Prompt for AI Agents |
||
| const busyName = group | ||
| ? (members?.find((b) => b.id === group.busyBotId)?.name ?? "A bot") | ||
| : (bot?.name ?? "The bot"); | ||
|
|
@@ -204,6 +213,22 @@ export function Composer({ | |
| ))} | ||
| </div> | ||
| )} | ||
| {/* An approval takes over the composer: you answer it before you | ||
| can type again, so a waiting bot is impossible to miss. */} | ||
| {approval && ( | ||
| <div className="mb-2 overflow-hidden rounded-2xl border border-accent/40 bg-card"> | ||
| <PendingApprovalPanel pending={approval} count={approvals.length} index={0} /> | ||
| <PendingApprovalActions | ||
| pending={approval} | ||
| threadId={threadId} | ||
| bot={approvalBot} | ||
| onCancelTurn={() => { | ||
| if (group) dispatch({ type: "interruptGroup", groupId: group.id }); | ||
| else if (bot) dispatch({ type: "interrupt", botId: bot.id }); | ||
| }} | ||
| /> | ||
| </div> | ||
| )} | ||
| <div className="flex items-end gap-2 rounded-3xl border border-hairline/40 bg-raised/60 py-2 pl-3 pr-2"> | ||
| <textarea | ||
| ref={inputRef} | ||
|
|
@@ -248,8 +273,11 @@ export function Composer({ | |
| } | ||
| if (e.key === "Escape" && recording) setRecording(false); | ||
| }} | ||
| disabled={Boolean(approval)} | ||
| placeholder={ | ||
| recording | ||
| approval | ||
| ? "Answer the approval above to continue" | ||
| : recording | ||
| ? "Listening…" | ||
| : busy | ||
| ? `${busyName} is working — Enter queues your message` | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Do not record an approval before the provider accepts it.
Line 204 can have no provider instance. Line 206 discards provider response failures. Lines 207-212 then record an approved activity and skip the approval card.
If
respondToRequestdoes not complete, keep the request available for human approval. Record the auto-approved activity only after the provider accepts theallowresponse.🤖 Prompt for AI Agents