From b908cb33b85c8f631ce1ae3cbe4b391e9f20799f Mon Sep 17 00:00:00 2001 From: kh0pper Date: Sun, 13 Sep 2026 14:37:23 -0500 Subject: [PATCH] feat(perch-hub): envelope + per-session tool narrowing pane on the Session tab (PR-D, audit item 15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the envelope & narrowing pane from the board card drawer so hub-only sessions get per-session narrowing too. Bot Builder stays the only writer of the envelope; the pane can only REMOVE tools, per session, effective from the next message (a wake rebuilds the world). - routes/perch.js: GET /bots/:id/envelope?threadId= attaches the session's saved narrowing (tri-state: absent = not reported, null = reported-empty, JSON text = the narrowed set) so the pane renders in one call; the bare per-bot call the board drawer makes is unchanged. - perch-hub client/html/css: collapsible pane on the Session tab reusing the drawer's botboard.bd* i18n and save semantics (widening_rejected reverts the checkbox and says why); collapse+clear on session switch; identity guard on the envelope continuation (guards 17 -> 18). - tests: 3 hub-client tests + 4 route tests for the threadId contract. - docs: bot-builder.md step 4 (EN+ES) — the hub now HAS the pane; both surfaces read and write the same per-session narrowing. Full suite 4835/0. --- docs/es/guide/bot-builder.md | 4 +- docs/guide/bot-builder.md | 4 +- servers/gateway/dashboard/perch-hub/client.js | 124 ++++++++++++++++++ servers/gateway/dashboard/perch-hub/css.js | 19 +++ servers/gateway/dashboard/perch-hub/html.js | 9 ++ servers/gateway/routes/perch.js | 16 ++- tests/perch-hub-client.test.js | 102 +++++++++++++- tests/perch-routes.test.js | 49 +++++++ 8 files changed, 319 insertions(+), 8 deletions(-) diff --git a/docs/es/guide/bot-builder.md b/docs/es/guide/bot-builder.md index 6783d80b..3eed0942 100644 --- a/docs/es/guide/bot-builder.md +++ b/docs/es/guide/bot-builder.md @@ -89,11 +89,11 @@ Por defecto solo una sesión por agente corre a la vez, y cada sesión compite p ### 4. Acota las herramientas de un agente para una sola sesión -El acotado de herramientas por sesión vive en el **tablero**: abre la tarjeta en la que trabaja una sesión (o inicia la sesión desde una tarjeta) y abre **Envoltura y herramientas** en el panel lateral de la tarjeta. Verás el envelope completo del agente: todas las herramientas que tiene permitidas, cada una con una casilla, además de su modelo y sus skills. (El hub no tiene panel de envelope — las concesiones son solo del Bot Builder, y los recortes van por el panel de la tarjeta.) +El acotado de herramientas por sesión vive en el hub: abre una sesión, ve a su pestaña **Sesión** y despliega **`Envoltura y herramientas`**. Verás el envelope completo del agente: todas las herramientas que tiene permitidas, cada una con una casilla, además de su modelo y sus skills. El mismo panel sigue en el tablero — abre el panel lateral de la tarjeta en la que trabaja una sesión — y ambos leen y escriben el mismo acotado por sesión. Las concesiones son solo del Bot Builder; el panel solo puede quitar. Desmarca una herramienta y queda apagada **solo para esa sesión**, a partir del siguiente mensaje. La definición del agente no se toca, y todas las demás sesiones conservan el conjunto completo. Esto es para el momento en que quieres que un agente responda sin tocar tus archivos, sin editar nada, sin salir a la red: en esta sesión, ahora mismo. -Las herramientas que aparecen con un candado son las que el agente no tiene permitidas en absoluto. Ahí no se pueden activar; enlazan al Bot Builder, que es el único lugar que otorga una herramienta. La sesión solo puede quitar, nunca dar. +Las herramientas que aparecen con un candado son las que el agente no tiene permitidas en absoluto. No se pueden activar desde ninguno de los dos paneles; solo el Bot Builder otorga una herramienta. La sesión solo puede quitar, nunca dar. ### 5. Responder una pregunta que te hace el agente diff --git a/docs/guide/bot-builder.md b/docs/guide/bot-builder.md index 43f24417..5e4715a4 100644 --- a/docs/guide/bot-builder.md +++ b/docs/guide/bot-builder.md @@ -89,11 +89,11 @@ Only one session per agent runs at a time by default, and every session competes ### 4. Narrow an agent's tools for one session -Per-session tool narrowing lives on the **board**: open the card a session is working (or start the session from a card) and open **Envelope & tools** in the card's drawer. You see the agent's full envelope: every tool it is allowed to use, each with a checkbox, plus its model and skills. (The hub itself has no envelope pane — grants are Bot Builder's alone, and removals ride the card drawer.) +Per-session tool narrowing lives in the hub: open a session, go to its **Session** tab, and toggle **`Envelope & tools`**. You see the agent's full envelope: every tool it is allowed to use, each with a checkbox, plus its model and skills. The same pane is still on the board — open the drawer of the card a session is working — and both read and write the same per-session narrowing. Grants themselves are Bot Builder's alone; the pane can only take away. Uncheck a tool and it is switched off **for that session only**, from the next message onward. The agent's definition is untouched, and every other session keeps the full set. This is for the moment when you want an agent to answer without touching your files, without editing anything, without reaching out over the network — for this one session, right now. -Tools shown with a padlock are ones the agent is not allowed at all. They are not togglable here; they link to Bot Builder, which is the only place that grants a tool. The session can only ever take away. +Tools shown with a padlock are ones the agent is not allowed at all. They are not togglable from either pane; only Bot Builder grants a tool. The session can only ever take away. ### 5. Answering a question the agent asks you diff --git a/servers/gateway/dashboard/perch-hub/client.js b/servers/gateway/dashboard/perch-hub/client.js index 64dc2798..25230e87 100644 --- a/servers/gateway/dashboard/perch-hub/client.js +++ b/servers/gateway/dashboard/perch-hub/client.js @@ -1614,6 +1614,11 @@ export function perchHubJs(lang = "en") { var planCb=el('perch-plan-mode'); if(planCb) planCb.checked=false; var cwdEl=el('perch-session-cwd'); if(cwdEl) cwdEl.textContent=''; /* nor its directory */ cwdPath=''; closeFileViewer(); /* PR-B: nor its cwd browse cursor or open text viewer */ + /* PR-D: nor its narrowing pane — collapse + clear so the next session's + envelope is fetched fresh, never shown from the previous bot. */ + narrowOpen=false; + var nb=el('perch-narrow-body'); if(nb){ clearEl(nb); nb.hidden=true; } + var nt=el('perch-narrow-toggle'); if(nt) nt.setAttribute('aria-expanded','false'); setTurnInFlight(false); /* the PREVIOUS session's Steer/Stop state must not bleed in */ turnRendered=false; /* nor its "this turn already rendered" bookkeeping */ renderedTurn=null; /* nor the turn id that bookkeeping now keys on */ @@ -2176,6 +2181,125 @@ export function perchHubJs(lang = "en") { } var fvClose=el('perch-fv-close'); if(fvClose) fvClose.onclick=closeFileViewer; + /* ---- PR-D (audit item 15): envelope + per-session tool narrowing pane ---- + Ported from the board card drawer (drawer.js:265-380) so hub-only sessions + get per-session narrowing too. Bot Builder stays the ONLY WRITER of the + envelope; this pane can only REMOVE tools for the session (the POST + .../narrow route rejects widening), effective from the next message — a + wake rebuilds the world. Reuses the drawer's botboard.bd* i18n strings + (shared table, already EN+ES) and its tri-state narrowing semantics: a Set + is a real narrowing, null is "reported, nothing narrowed", undefined is + "not reported" — the middle must never collapse into the last. Envelope + + the session's saved narrowing come in ONE call via ?threadId=. Checkboxes + are tracked in a closure array (not querySelectorAll) so the pane is + testable in the vm harness. The load continuation carries the file-wide + mySid identity guard; saveNarrowing writes only its own (possibly detached + after a session switch) message element, so it needs none. */ + var narrowOpen=false; + function savedNarrowingFromEnvelope(env){ + if(!env||!Object.prototype.hasOwnProperty.call(env,'savedNarrowing')) return undefined; + var list=env.savedNarrowing; + if(list==null) return null; + if(typeof list==='string'){ try{ list=JSON.parse(list); }catch(e){ return undefined; } } + if(!Array.isArray(list)) return undefined; + var s={}; list.forEach(function(id){ s[String(id)]=true; }); + return s; + } + function toggleNarrowPane(){ + var body=el('perch-narrow-body'), tog=el('perch-narrow-toggle'); + if(!body) return; + if(narrowOpen){ + narrowOpen=false; body.hidden=true; + if(tog) tog.setAttribute('aria-expanded','false'); + return; + } + narrowOpen=true; body.hidden=false; + if(tog) tog.setAttribute('aria-expanded','true'); + loadNarrowPane(); + } + function narrowPaneErr(){ + var body=el('perch-narrow-body'); if(!body) return; + clearEl(body); + var e=document.createElement('div'); e.className='narrow-msg err'; + e.textContent='${tJs("botboard.loadFailed", lang)}'; + body.appendChild(e); + } + function loadNarrowPane(){ + var body=el('perch-narrow-body'); + if(!body||!current.sid||!current.botId) return; + var mySid=current.sid; + clearEl(body); + var loading=document.createElement('div'); loading.className='narrow-msg'; loading.textContent='\\u2026'; + body.appendChild(loading); + perchApi('GET','/bots/'+encodeURIComponent(current.botId)+'/envelope?threadId='+encodeURIComponent(mySid)).then(function(r){ + if(current.sid!==mySid) return; /* identity guard, as everywhere */ + if(!r.ok||!r.j){ narrowPaneErr(); return; } + renderNarrowPane(r.j); + }); + } + function renderNarrowPane(envelope){ + var body=el('perch-narrow-body'); if(!body) return; + clearEl(body); + var allowed=envelope.tools||[]; + var denied=envelope.denied||[]; + var saved=savedNarrowingFromEnvelope(envelope); + var disabledSet=(saved&&typeof saved==='object')?saved:{}; + var head=document.createElement('div'); head.className='narrow-head'; + var skillsTxt=(envelope.skills||[]).length?(' \\u00b7 ${tJs("botboard.bdEnvelopeSkillsPrefix", lang)}'+envelope.skills.join(', ')):''; + head.textContent='${tJs("botboard.bdEnvelopeModelPrefix", lang)}'+(envelope.model||'${tJs("botboard.bdEnvelopeModelUnset", lang)}')+skillsTxt; + body.appendChild(head); + var toolsWrap=document.createElement('div'); toolsWrap.className='narrow-tools'; + var toolBoxes=[]; + if(!allowed.length&&!denied.length){ + var none=document.createElement('div'); none.className='narrow-locked'; none.textContent='${tJs("botboard.bdToolsNone", lang)}'; + toolsWrap.appendChild(none); + } + allowed.forEach(function(tool){ + var label=document.createElement('label'); label.className='narrow-tool'; + var cb=document.createElement('input'); cb.type='checkbox'; + cb.checked=!disabledSet[String(tool.id)]; + cb.setAttribute('data-narrow-tool',tool.id); + label.appendChild(cb); + label.appendChild(document.createTextNode(' '+(tool.label||tool.id))); + toolsWrap.appendChild(label); + toolBoxes.push(cb); + }); + denied.forEach(function(tool){ + var locked=document.createElement('div'); locked.className='narrow-locked'; + locked.textContent='\\uD83D\\uDD12 '+(tool.label||tool.id); + toolsWrap.appendChild(locked); + }); + body.appendChild(toolsWrap); + var note=document.createElement('div'); note.className='narrow-note'; + note.textContent=(saved&&typeof saved==='object')?'${tJs("botboard.bdNarrowNoteSaved", lang)}' + : saved===null?'${tJs("botboard.bdNarrowNoteEmpty", lang)}' + : '${tJs("botboard.bdNarrowNoteUnknown", lang)}'; + body.appendChild(note); + var narrowMsg=document.createElement('div'); narrowMsg.className='narrow-msg'; + body.appendChild(narrowMsg); + toolsWrap.addEventListener('change',function(ev){ + if(ev.target&&ev.target.hasAttribute&&ev.target.hasAttribute('data-narrow-tool')) saveNarrowing(toolBoxes,narrowMsg,ev.target); + }); + } + function saveNarrowing(toolBoxes,narrowMsg,changedInput){ + if(!current.botId||!current.sid) return; + var botId=current.botId, sid=current.sid; + var disabled=toolBoxes.filter(function(b){ return !b.checked; }).map(function(b){ return b.getAttribute('data-narrow-tool'); }); + narrowMsg.className='narrow-msg'; narrowMsg.textContent='\\u2026'; + perchApi('POST','/bots/'+encodeURIComponent(botId)+'/sessions/'+encodeURIComponent(sid)+'/narrow',{disabled_tools:disabled}).then(function(r){ + if(r.ok){ + narrowMsg.textContent=disabled.length + ? ('${tJs("botboard.bdNarrowedToPrefix", lang)}'+(toolBoxes.length-disabled.length)+'${tJs("botboard.bdNarrowedToMid", lang)}'+toolBoxes.length+'${tJs("botboard.bdNarrowedToSuffix", lang)}') + : '${tJs("botboard.bdFullEnvelopeRestored", lang)}'; + } else { + changedInput.checked=!changedInput.checked; + narrowMsg.className='narrow-msg err'; + narrowMsg.textContent=(r.j&&r.j.error==='widening_rejected')?'${tJs("botboard.bdNarrowRejected", lang)}':'${tJs("botboard.bdNarrowFailed", lang)}'; + } + }); + } + var narrowToggle=el('perch-narrow-toggle'); if(narrowToggle) narrowToggle.onclick=toggleNarrowPane; + /* ---- Wave 2: Session-tab facts + plan progress ------------------------- All readings ride the state/plan_state frames the engine already emits (contextUsage = pi's own numbers captured at turn end; uptime/RSS are diff --git a/servers/gateway/dashboard/perch-hub/css.js b/servers/gateway/dashboard/perch-hub/css.js index e848a8ba..adcaa3a9 100644 --- a/servers/gateway/dashboard/perch-hub/css.js +++ b/servers/gateway/dashboard/perch-hub/css.js @@ -318,6 +318,25 @@ min-height:44px;padding:6px 4px;font-size:11px;border-color:transparent;backgrou the two controls keep their own 44px two-id rules further up. */ #perch-hub-root .session-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:6px 0 12px} #perch-hub-root .session-row .state{flex:1;min-width:0} +/* PR-D (audit item 15): the envelope + per-session tool narrowing pane. A + quiet full-width toggle above a collapsible body of tool checkboxes (an + allowed tool, ticked = kept) and locked rows (denied by the envelope). All + selectors scoped; the checkbox override beats the generic full-width input + rule so ticks look like ticks. */ +#perch-hub-root .narrow-pane{margin:6px 0 12px} +#perch-hub-root .narrow-toggle{width:100%;text-align:left;color:var(--dim);font-size:12.5px; +text-transform:uppercase;letter-spacing:.06em;background:none;border:0;border-top:1px solid var(--line); +border-radius:0;padding:12px 0;min-height:44px} +#perch-hub-root .narrow-body[hidden]{display:none} +#perch-hub-root .narrow-head{color:var(--dim);font-size:12.5px;margin:4px 0 8px;word-break:break-word; +font-family:"JetBrains Mono",ui-monospace,monospace} +#perch-hub-root .narrow-tools{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:4px} +#perch-hub-root .narrow-tool{display:flex;align-items:center;gap:7px;font-size:13.5px;padding:6px 4px;min-height:36px} +#perch-hub-root .narrow-tool input{width:auto;padding:0;min-height:0;flex-shrink:0} +#perch-hub-root .narrow-locked{color:var(--dim);font-size:13px;padding:6px 4px} +#perch-hub-root .narrow-note{color:var(--dim);font-size:12px;margin-top:8px;line-height:1.45} +#perch-hub-root .narrow-msg{font-size:12.5px;margin-top:6px} +#perch-hub-root .narrow-msg.err{color:var(--attn)} /* Files tab rows: the whole row is the download link (thumb target), name breaks long, meta never does. */ #perch-hub-root .files-bar{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:6px 0} diff --git a/servers/gateway/dashboard/perch-hub/html.js b/servers/gateway/dashboard/perch-hub/html.js index c844fe86..085329b6 100644 --- a/servers/gateway/dashboard/perch-hub/html.js +++ b/servers/gateway/dashboard/perch-hub/html.js @@ -200,6 +200,15 @@ ${engineBanner(engine, lang)}
+ +
+ + +
diff --git a/servers/gateway/routes/perch.js b/servers/gateway/routes/perch.js index 4ebe103b..f36500ee 100644 --- a/servers/gateway/routes/perch.js +++ b/servers/gateway/routes/perch.js @@ -689,13 +689,27 @@ export default function perchApiRouter(dashboardAuth, { interactiveEngine = getI }); // ---- GET /bots/:id/envelope — what Bot Builder grants ---- + // PR-D (audit item 15): with an optional ?threadId=, the response also + // carries that session's SAVED narrowing (tri-state: field absent = not + // reported, null = reported-and-nothing-narrowed, JSON array = the narrowed + // set) so the hub's ported pane renders correct checkbox state in ONE call. + // The board drawer keeps calling the bare per-bot envelope (no threadId) and + // reads narrowing from its own row snapshot — unchanged. router.get(P + "/bots/:id/envelope", async (req, res) => { const botId = String(req.params.id); + const threadId = req.query.threadId == null ? null : String(req.query.threadId); const db = createDbClient(); try { const row = await loadBotRow(db, botId); if (!row) return jsonError(res, 404, "unknown_bot"); - res.json(await buildEnvelope(db, parseDef(row))); + const envelope = await buildEnvelope(db, parseDef(row)); + if (threadId) { + const sess = await latestSession(db, botId, threadId); + if (sess && Object.prototype.hasOwnProperty.call(sess, "narrowed_tools")) { + envelope.savedNarrowing = sess.narrowed_tools == null ? null : sess.narrowed_tools; + } + } + res.json(envelope); } catch (err) { jsonError(res, 500, String((err && err.message) || err)); } finally { diff --git a/tests/perch-hub-client.test.js b/tests/perch-hub-client.test.js index 29593b07..b00cee48 100644 --- a/tests/perch-hub-client.test.js +++ b/tests/perch-hub-client.test.js @@ -203,9 +203,10 @@ test("async continuations are guarded — a fast back button must not cross sess // that shipped with only 6 asserted — is invisible until an operator hits // the exact race the dropped guard covered. 15 as of Wave 3: the slash // menu's commands fetch. 16+17 as of PR-B (item 18): the Files tab's cwd - // browser list fetch and its text-viewer read fetch. + // browser list fetch and its text-viewer read fetch. 18 as of PR-D (item 15): + // the Session tab's narrowing-pane envelope fetch. const guards = (js.match(/current\.sid\s*!==/g) || []).length; - assert.equal(guards, 17, "expected exactly 17 identity guards, found " + guards); + assert.equal(guards, 18, "expected exactly 18 identity guards, found " + guards); }); test("the emitted script never assigns to an innerHTML-class sink", async () => { @@ -506,6 +507,7 @@ function makeFakeElement(tag) { }, setAttribute(name, val) { attrs[name] = String(val); }, getAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; }, + hasAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name); }, click() { if (this.onclick) this.onclick(); }, }); // defineProperty, NOT a `get firstChild()` in the object literal above: @@ -603,7 +605,9 @@ async function mountHub({ fetchImpl, confirmImpl, promptImpl, initialHash = "", "perch-plan-card", "perch-plan-head", "perch-plan-steps", "perch-cmdmenu", // PR-B (item 18): the Files tab's cwd browser + in-app text viewer. "perch-cwd-crumbs", "perch-cwd-list", "perch-file-viewer", "perch-fv-name", - "perch-fv-close", "perch-fv-body"]; + "perch-fv-close", "perch-fv-body", + // PR-D (item 15): the Session tab's envelope + narrowing pane. + "perch-narrow-toggle", "perch-narrow-body"]; const els = {}; for (const id of IDS) els[id] = makeFakeElement(id === "perch-plan-mode" ? "input" : "div"); @@ -626,6 +630,7 @@ async function mountHub({ fetchImpl, confirmImpl, promptImpl, initialHash = "", body: bodyEl, getElementById(id) { return els[id] || dynamic[id] || null; }, createElement(tag) { return makeFakeElement(tag); }, + createTextNode(text) { const n = makeFakeElement("#text"); n.textContent = String(text == null ? "" : text); return n; }, }); const winTarget = makeEventTarget(); @@ -1206,6 +1211,97 @@ test("PR-C: no archived sessions means no Archived affordance (today's list, unt "a roost with no archived array renders no Archived toggle"); }); +// --------------------------------------------------------------------------- +// PR-D (audit item 15): the Session tab's envelope + narrowing pane +// --------------------------------------------------------------------------- + +const NARROW_ENVELOPE = { + model: "crow-local/qwen", skills: ["alpha"], + tools: [{ id: "read", label: "Read" }, { id: "bash", label: "Bash" }], + denied: [{ id: "write", label: "Write" }], +}; + +test("PR-D: the narrowing pane loads the envelope (with threadId), renders tools, and narrows on uncheck", async () => { + const hub = await mountHub({ + fetchImpl: roostFetch(ROOST_ONE_LIVE, { + "/envelope": () => makeResponse(200, { ...NARROW_ENVELOPE, savedNarrowing: null }), + "/narrow": () => makeResponse(200, { ok: true }), + }), + }); + await openChatSession(hub); + await tick2(); + hub.els["perch-narrow-toggle"].onclick(); // open the pane + await tick2(); + const body = hub.els["perch-narrow-body"]; + assert.equal(body.hidden, false, "the pane opens"); + + // the envelope GET is per-bot AND carries the session threadId (one call) + const envCall = hub.fetchCalls.filter((c) => c.path.indexOf("/envelope") >= 0).pop(); + assert.ok(envCall.path.indexOf("/bots/r4/envelope") >= 0, "per-bot envelope: " + envCall.path); + assert.ok(envCall.path.indexOf("threadId=perchlive-aaaaaaaa") >= 0, "carries the session: " + envCall.path); + + // head shows model + skills + assert.match(body.children[0].textContent, /crow-local\/qwen/); + assert.match(body.children[0].textContent, /alpha/); + // two allowed tools (checked: savedNarrowing null = nothing narrowed), one denied (locked) + const toolsWrap = body.children[1]; + const labels = toolsWrap.children.filter((c) => c.tagName === "LABEL"); + assert.equal(labels.length, 2); + const readCb = labels[0].children[0], bashCb = labels[1].children[0]; + assert.equal(readCb.checked, true); + assert.equal(bashCb.checked, true, "savedNarrowing null → nothing pre-disabled"); + assert.equal(toolsWrap.children.filter((c) => String(c.className).indexOf("narrow-locked") >= 0).length, 1, + "the denied tool renders locked"); + + // uncheck bash → change → POST /narrow with disabled_tools:['bash'] + bashCb.checked = false; + toolsWrap._dispatch("change", { target: bashCb }); + await tick2(); + const post = hub.fetchCalls.filter((c) => c.path.endsWith("/narrow")).pop(); + assert.ok(post, "a narrow POST went out"); + assert.equal(post.path, "/bots/r4/sessions/perchlive-aaaaaaaa/narrow"); + assert.deepEqual(JSON.parse(post.opts.body), { disabled_tools: ["bash"] }); +}); + +test("PR-D: a saved narrowing pre-unchecks exactly those tools (tri-state, not 'full envelope')", async () => { + const hub = await mountHub({ + fetchImpl: roostFetch(ROOST_ONE_LIVE, { + "/envelope": () => makeResponse(200, { ...NARROW_ENVELOPE, savedNarrowing: '["bash"]' }), + "/narrow": () => makeResponse(200, { ok: true }), + }), + }); + await openChatSession(hub); + await tick2(); + hub.els["perch-narrow-toggle"].onclick(); + await tick2(); + const toolsWrap = hub.els["perch-narrow-body"].children[1]; + const labels = toolsWrap.children.filter((c) => c.tagName === "LABEL"); + assert.equal(labels[0].children[0].checked, true, "read stays checked"); + assert.equal(labels[1].children[0].checked, false, "bash was narrowed → pre-unchecked"); +}); + +test("PR-D: a widening_rejected narrow reverts the checkbox and says why", async () => { + const hub = await mountHub({ + fetchImpl: roostFetch(ROOST_ONE_LIVE, { + "/envelope": () => makeResponse(200, { ...NARROW_ENVELOPE, savedNarrowing: null }), + "/narrow": () => makeResponse(400, { error: "widening_rejected" }), + }), + }); + await openChatSession(hub); + await tick2(); + hub.els["perch-narrow-toggle"].onclick(); + await tick2(); + const body = hub.els["perch-narrow-body"]; + const toolsWrap = body.children[1]; + const readCb = toolsWrap.children.filter((c) => c.tagName === "LABEL")[0].children[0]; + readCb.checked = false; + toolsWrap._dispatch("change", { target: readCb }); + await tick2(); + assert.equal(readCb.checked, true, "a rejected narrow reverts the checkbox"); + const msg = body.children[body.children.length - 1]; + assert.match(msg.textContent, /only remove tools|never add/i, "and explains the rejection"); +}); + test("C2: closing the session you are IN returns to the list — no chat view on a dead stream", async () => { const hub = await mountHub({ fetchImpl: roostFetch(ROOST_ALL_BUSY), confirmImpl: () => true, diff --git a/tests/perch-routes.test.js b/tests/perch-routes.test.js index 6b22b8ba..e30b2a2a 100644 --- a/tests/perch-routes.test.js +++ b/tests/perch-routes.test.js @@ -354,6 +354,55 @@ test("two consecutive narrows on one thread leave exactly ONE row, updated in pl assert.equal(rows[0].narrowed_tools, '["read","bash"]'); }); +// --- PR-D (audit item 15): GET /bots/:id/envelope?threadId= carries the ---- +// --- session's SAVED narrowing, so the hub's pane renders in ONE call. ---- +// The pane must distinguish three states without a second round-trip: +// narrowed to a set (checkboxes pre-unchecked), reported-and-empty (all +// checked), and not-reported (absent — a never-seen thread reads as the +// latter, never as a fabricated null). The bare per-bot call the board +// drawer makes must stay byte-for-byte what it was: no savedNarrowing key. + +test("PR-D: GET envelope?threadId= attaches the session's saved narrowing in one call", async () => { + await postJson("/bots/chatty/sessions/envd-a/narrow", { disabled_tools: ["bash"] }); + const { status, body } = await getJson("/bots/chatty/envelope?threadId=envd-a"); + assert.equal(status, 200); + // Emitted as the stored JSON text — the client parses it (same convention + // as the /sessions list); the envelope's own shape is untouched alongside. + assert.equal(body.savedNarrowing, '["bash"]'); + assert.ok(Array.isArray(body.tools) && body.tools.some((t) => t.id === "bash"), + "the per-bot grant still comes through with the session state attached"); +}); + +test("PR-D: GET envelope without threadId reports no savedNarrowing key at all (the board drawer's call is unchanged)", async () => { + // envd-a HAS a narrowing by now — the point is the field rides the query, + // not the bot. A bare per-bot envelope must not grow the key, or the + // drawer's untouched path silently changes contract. + const { status, body } = await getJson("/bots/chatty/envelope"); + assert.equal(status, 200); + assert.equal("savedNarrowing" in body, false, "not reported ≠ null ≠ a set"); +}); + +test("PR-D: GET envelope?threadId= for a thread that does not exist omits savedNarrowing (absent, never null)", async () => { + const { body } = await getJson("/bots/chatty/envelope?threadId=envd-no-such-thread"); + assert.equal("savedNarrowing" in body, false, + "an unknown thread must read as 'not reported', not as 'narrowed to nothing'"); +}); + +test("PR-D: GET envelope?threadId= reports a null row as null (the tri-state's middle)", async () => { + // A perch session row that exists but never narrowed: the thread IS known, + // nothing is taken away. This is the state a bare POST /narrow with an + // empty list cannot produce (it stores "[]"), so seed it directly. + const c = raw(); + c.prepare( + "INSERT INTO bot_sessions (bot_id,gateway_type,gateway_thread_id,kind,status,narrowed_tools) " + + "VALUES ('chatty','perch','envd-null','perch','waiting-user',NULL)" + ).run(); + c.close(); + const { body } = await getJson("/bots/chatty/envelope?threadId=envd-null"); + assert.equal("savedNarrowing" in body, true, "a known row must REPORT, even when the report is empty"); + assert.equal(body.savedNarrowing, null); +}); + test("narrowed_tools is a real column, declared BOTH ways (the #250 convention)", () => { const c = raw(); const cols = c.prepare("PRAGMA table_info(bot_sessions)").all().map((r) => r.name);