mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(demo): add a tool approval menu
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -86,11 +86,18 @@ URL printed by the launcher in Chrome, click **Start talking**, and allow the
|
||||
microphone. Model loading and warm-up happen before this URL appears. Stop with
|
||||
Ctrl-C. The launcher supports macOS and Linux.
|
||||
|
||||
Try “What is two plus two?” and then “Run uptime.” A tool request displays its
|
||||
arguments: choose **Allow once** or **Deny**. The demo advertises one short shell
|
||||
Try “What is two plus two?” and then “Run uptime.” By default, a tool request
|
||||
displays its arguments: choose **Allow once** or **Deny**. The demo advertises one short shell
|
||||
tool through a thin adapter to the existing `buzz-dev-mcp`; approval and execution
|
||||
stay in Buzz. Nothing is executed by recognizing speech alone.
|
||||
|
||||
The **Tool approval** menu defaults to **Ask each time**. Choose **Automatically
|
||||
approve** to approve every subsequent shell call, including commands that modify
|
||||
files. You can switch back while connected; any approval dialog already open
|
||||
still needs a decision. The choice stays in this page across reconnects and resets
|
||||
to **Ask each time** on reload. Automatically approved calls are noted in the
|
||||
transcript.
|
||||
|
||||
By default tools run in an empty temporary directory. Add `--cwd /path/to/project`
|
||||
to choose their working directory. The launcher uses neutral instructions and
|
||||
disables local hints/skills; it does not load a personal persona. Each connection
|
||||
@@ -169,3 +176,8 @@ peers, including stale playback, cancellation, thinking acknowledgment, output
|
||||
limits, and reconnect isolation. Native model inference is a separate end-to-end
|
||||
check requiring the GGUF and the fork binary; unit fixtures are not inference
|
||||
benchmarks. The example tests also run in `just ci` and GitHub CI.
|
||||
|
||||
With the repository’s Playwright development dependencies and Chromium installed,
|
||||
run `node examples/realtime-audio/client.browser.mjs` to check the browser approval
|
||||
menu against a controlled ACP subprocess. This covers manual and automatic
|
||||
approval, switching modes, keyboard controls, reload defaults, and narrow screens.
|
||||
|
||||
211
examples/realtime-audio/client.browser.mjs
Normal file
211
examples/realtime-audio/client.browser.mjs
Normal file
@@ -0,0 +1,211 @@
|
||||
// Run with the repository's installed Playwright development dependencies.
|
||||
import { chromium } from "../../desktop/node_modules/@playwright/test/index.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import net from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "frankie-menu-test-"));
|
||||
const requestFile = path.join(directory, "request.json");
|
||||
const responseFile = path.join(directory, "response.json");
|
||||
const agentFile = path.join(directory, "agent.mjs");
|
||||
// A controlled ACP peer lets the real browser client exercise permission RPCs
|
||||
// without model weights, inference timing, or executing actual shell commands.
|
||||
await writeFile(
|
||||
agentFile,
|
||||
`#!/usr/bin/env node
|
||||
import {createInterface} from 'node:readline';
|
||||
import {existsSync,readFileSync,writeFileSync,unlinkSync} from 'node:fs';
|
||||
const send = value => console.log(JSON.stringify({jsonrpc:'2.0',...value}));
|
||||
let prompt;
|
||||
const input = createInterface({input:process.stdin});
|
||||
input.on('line', line => {
|
||||
const e=JSON.parse(line);
|
||||
if (!e.method) {writeFileSync(process.env.TEST_RESPONSE,JSON.stringify(e));return;}
|
||||
if (e.method==='initialize') send({id:e.id,result:{agentCapabilities:{_meta:{buzz:{realtimeAudio:1}}}}});
|
||||
else if(e.method==='session/new') send({id:e.id,result:{sessionId:'session'}});
|
||||
else if(e.method==='session/prompt') {
|
||||
prompt=e.id;
|
||||
send({method:'_buzz/unstable/realtime/update',params:{sessionId:'session',streamId:'stream',update:{type:'ready'}}});
|
||||
} else {
|
||||
send({id:e.id,result:null});
|
||||
if(e.method.endsWith('/close'))send({id:prompt,result:{stopReason:'end_turn'}});
|
||||
}
|
||||
});
|
||||
const timer=setInterval(()=>{
|
||||
if(!existsSync(process.env.TEST_REQUEST))return;
|
||||
const request=JSON.parse(readFileSync(process.env.TEST_REQUEST));unlinkSync(process.env.TEST_REQUEST);
|
||||
send({id:request.id,method:'session/request_permission',params:{toolCall:{title:'dev__shell',rawInput:{command:'echo example'}},options:[{kind:'allow_once',optionId:'allow'},{kind:'reject_once',optionId:'deny'}]}});
|
||||
},25);
|
||||
input.on('close',()=>clearInterval(timer));
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
const socket = net.createServer();
|
||||
socket.listen(0, "127.0.0.1");
|
||||
await once(socket, "listening");
|
||||
const port = socket.address().port;
|
||||
await new Promise((resolve) => socket.close(resolve));
|
||||
const server = spawn(
|
||||
process.execPath,
|
||||
[fileURLToPath(new URL("server.mjs", import.meta.url))],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
BUZZ_AGENT_BIN: agentFile,
|
||||
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ""}`,
|
||||
TEST_REQUEST: requestFile,
|
||||
TEST_RESPONSE: responseFile,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let output = "",
|
||||
errors = "",
|
||||
browser;
|
||||
server.stdout.on("data", (value) => {
|
||||
output += value;
|
||||
});
|
||||
server.stderr.on("data", (value) => {
|
||||
errors += value;
|
||||
});
|
||||
async function until(check) {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!(await check())) {
|
||||
if (Date.now() > deadline) throw Error(`test deadline: ${errors}`);
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await until(() => output.includes("/#"));
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
});
|
||||
page.setDefaultTimeout(10000);
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (e) => pageErrors.push(e.message));
|
||||
await page.addInitScript(() => {
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
const context = new AudioContext({ sampleRate: 24000 });
|
||||
await context.resume();
|
||||
return context.createMediaStreamDestination().stream;
|
||||
};
|
||||
});
|
||||
await page.goto(output.trim());
|
||||
const approval = page.getByLabel("Tool approval");
|
||||
assert.equal(await approval.inputValue(), "ask");
|
||||
await page.locator("#go").click();
|
||||
await page.waitForFunction(() =>
|
||||
window.realtimeEvidence?.some((e) => e.type === "ready"),
|
||||
);
|
||||
const ask = async (id) => {
|
||||
await rm(responseFile, { force: true });
|
||||
await writeFile(requestFile, JSON.stringify({ id }));
|
||||
};
|
||||
const response = async (id) => {
|
||||
let value;
|
||||
await until(async () => {
|
||||
try {
|
||||
value = JSON.parse(await readFile(responseFile));
|
||||
return value.id === id;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return value.result.outcome;
|
||||
};
|
||||
await ask("manual");
|
||||
await page.locator("#permission").waitFor({ state: "visible" });
|
||||
await assert.rejects(readFile(responseFile), { code: "ENOENT" });
|
||||
await page.locator("#deny").press("Enter");
|
||||
assert.deepEqual(await response("manual"), {
|
||||
outcome: "selected",
|
||||
optionId: "deny",
|
||||
});
|
||||
await approval.focus();
|
||||
await approval.press("Tab");
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.activeElement.id),
|
||||
"fullscreen",
|
||||
);
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.activeElement.id),
|
||||
"tool-approval",
|
||||
);
|
||||
await approval.selectOption("auto");
|
||||
assert.equal(await approval.inputValue(), "auto");
|
||||
assert.equal(await page.locator("#approval-note").isVisible(), true);
|
||||
await ask("automatic");
|
||||
assert.deepEqual(await response("automatic"), {
|
||||
outcome: "selected",
|
||||
optionId: "allow",
|
||||
});
|
||||
assert.equal(await page.locator("#permission").isVisible(), false);
|
||||
assert.equal(
|
||||
await approval.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
);
|
||||
assert.match(
|
||||
await page.locator("#log").innerText(),
|
||||
/Automatically approved/,
|
||||
);
|
||||
await approval.selectOption("ask");
|
||||
await ask("manual-again");
|
||||
await page.locator("#permission").waitFor({ state: "visible" });
|
||||
await assert.rejects(readFile(responseFile), { code: "ENOENT" });
|
||||
await approval.selectOption("auto");
|
||||
await assert.rejects(readFile(responseFile), { code: "ENOENT" });
|
||||
await page.locator("#allow").click();
|
||||
assert.deepEqual(await response("manual-again"), {
|
||||
outcome: "selected",
|
||||
optionId: "allow",
|
||||
});
|
||||
await page.locator("#go").click();
|
||||
await page.waitForFunction(() =>
|
||||
window.realtimeEvidence.some((e) => e.type === "client_closed"),
|
||||
);
|
||||
assert.equal(await approval.inputValue(), "auto");
|
||||
for (const width of [1280, 1024, 768, 650, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
for (const id of ["thinking", "tool-approval", "fullscreen", "go"]) {
|
||||
const box = await page.locator(`#${id}`).boundingBox();
|
||||
assert.ok(
|
||||
box &&
|
||||
box.x >= 0 &&
|
||||
box.x + box.width <= width &&
|
||||
box.y + box.height <= 844,
|
||||
`${id} clipped at ${width}`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
`horizontal overflow at ${width}`,
|
||||
);
|
||||
}
|
||||
await page.reload();
|
||||
assert.equal(await approval.inputValue(), "ask");
|
||||
assert.equal(await page.locator("#approval-note").isVisible(), false);
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log(
|
||||
"Approval menu: manual, automatic, switch-back, pending decision, keyboard, reload, and six viewport checks passed.",
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
if (server.exitCode === null && server.signalCode === null) {
|
||||
server.kill("SIGTERM");
|
||||
const timeout = setTimeout(() => server.kill("SIGKILL"), 8000);
|
||||
await once(server, "exit");
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -12,10 +12,26 @@ let voice,
|
||||
measured = false,
|
||||
outputItem;
|
||||
window.realtimeEvidence = [];
|
||||
const approval = $("tool-approval");
|
||||
// Approval is a visible choice for this page, never a saved default.
|
||||
approval.value = "ask";
|
||||
approval.onchange = () => {
|
||||
$("approval-note").hidden = approval.value !== "auto";
|
||||
approval
|
||||
.closest("label")
|
||||
.classList.toggle("automatic", approval.value === "auto");
|
||||
};
|
||||
function permission(tool, decide) {
|
||||
if (tool && approval.value === "auto") {
|
||||
$("permission").hidden = true;
|
||||
ui.note(`Automatically approved ${tool.title || "tool call"}.`);
|
||||
decide("allow_once").catch(error);
|
||||
return;
|
||||
}
|
||||
$("permission").hidden = !tool;
|
||||
if (!tool) {
|
||||
savedFocus?.focus();
|
||||
savedFocus = null;
|
||||
return;
|
||||
}
|
||||
savedFocus = document.activeElement;
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<label class="thinking-control"
|
||||
<label class="setting-control"
|
||||
>Thinking
|
||||
<select
|
||||
id="thinking"
|
||||
@@ -133,7 +133,22 @@
|
||||
<option value="xhigh">Very high</option>
|
||||
<option value="max">Maximum</option>
|
||||
</select></label
|
||||
><button
|
||||
>
|
||||
<label class="setting-control approval-control">
|
||||
Tool approval
|
||||
<select
|
||||
id="tool-approval"
|
||||
aria-describedby="approval-note"
|
||||
autocomplete="off"
|
||||
>
|
||||
<option value="ask" selected>Ask each time</option>
|
||||
<option value="auto">Automatically approve</option>
|
||||
</select>
|
||||
<span id="approval-note" hidden
|
||||
>All shell commands, including file changes.</span
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
id="fullscreen"
|
||||
type="button"
|
||||
aria-label="Toggle fullscreen"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 202 KiB |
@@ -969,20 +969,87 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.thinking-control {
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
color: #b2c7ce;
|
||||
gap: 5px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.thinking-control select {
|
||||
.setting-control select {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
background: #080c10;
|
||||
min-height: 34px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem;
|
||||
max-width: 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.thinking-control select:disabled {
|
||||
.setting-control select:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setting-control select:focus-visible {
|
||||
outline: 2px solid #72ebdf;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.approval-control.automatic {
|
||||
color: #72ebdf;
|
||||
}
|
||||
#approval-note {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 12px);
|
||||
width: max-content;
|
||||
max-width: 270px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334751;
|
||||
border-radius: 6px;
|
||||
background: #101b20;
|
||||
color: #b2c7ce;
|
||||
font-size: 11px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.control-copy {
|
||||
display: none;
|
||||
}
|
||||
.controlbar {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
.controlbar {
|
||||
height: 150px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
.deck {
|
||||
bottom: 150px;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
height: auto;
|
||||
gap: 10px 12px;
|
||||
}
|
||||
.setting-control {
|
||||
min-width: 0;
|
||||
}
|
||||
.setting-control select {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
}
|
||||
#fullscreen {
|
||||
grid-column: 1;
|
||||
}
|
||||
#go {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
padding: 14px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user