hermes(hux): inline approval prompts and ask-not-deny defaults
Re-enforcement prerequisites, code-side complete: the autonomy runtime now docks pending-approval cards above the composer (newest first, cap three, aria-live, allow-once / always / deny wired to the existing decide route with idempotency; polling gated to active turns and fail-tolerant), so parked tool calls are never silent. The default capability matrix no longer denies by default: network and web_search ask below autonomous (visible prompt) and nothing resolves to deny except explicit grants or private mode; SO-39 stays intact - deploy and external side effects always ask and external never auto-allows. rules.py at 100% line+branch; 744 hux-lane tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
d1225bcab4
commit
3bca7b7465
@ -46,9 +46,9 @@ CAPABILITIES = (
|
||||
"read_files", "write_files", "shell", "network", "web_search", "send_message",
|
||||
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
|
||||
)
|
||||
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
|
||||
_READ_ONLY = frozenset({"read_files", "spend_tokens"})
|
||||
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
|
||||
_EXTERNAL = frozenset({"network", "deploy"})
|
||||
_NETWORK = frozenset({"network", "web_search"})
|
||||
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
|
||||
|
||||
MODE_CATALOG: dict[str, dict[str, Any]] = {
|
||||
@ -142,7 +142,13 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
|
||||
|
||||
|
||||
def default_capability_matrix() -> dict[str, dict[str, str]]:
|
||||
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
|
||||
"""Autonomy level -> capability -> allow|ask. Deploy and external side effects always ask (SO-39).
|
||||
|
||||
Network access (``network``/``web_search``) asks below ``autonomous`` instead of
|
||||
denying: for a chat assistant whose core value includes browsing, the right
|
||||
default is a visible approval prompt, never a silent block. No default is
|
||||
``deny``; denials only come from explicit grants or private mode.
|
||||
"""
|
||||
matrix: dict[str, dict[str, str]] = {}
|
||||
for level in ("ask_first", "safe", "autonomous"):
|
||||
row: dict[str, str] = {}
|
||||
@ -151,10 +157,9 @@ def default_capability_matrix() -> dict[str, dict[str, str]]:
|
||||
row[capability] = "ask"
|
||||
elif capability in _READ_ONLY:
|
||||
row[capability] = "allow"
|
||||
elif capability in _MUTATING:
|
||||
else:
|
||||
# _NETWORK and _MUTATING park with a visible prompt below autonomous.
|
||||
row[capability] = "ask" if level != "autonomous" else "allow"
|
||||
elif capability in _EXTERNAL:
|
||||
row[capability] = {"ask_first": "ask", "safe": "deny", "autonomous": "allow"}[level]
|
||||
matrix[level] = row
|
||||
return matrix
|
||||
|
||||
|
||||
@ -117,6 +117,48 @@
|
||||
border-color: color-mix(in srgb, #22d3ee 30%, currentColor);
|
||||
}
|
||||
|
||||
/* Inline approval prompts: a fixed dock near the composer so parked tool
|
||||
calls surface in the chat flow instead of hiding in the drawer. */
|
||||
.hux-runtime-inline-dock {
|
||||
bottom: 6.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
left: 0;
|
||||
margin: 0 auto;
|
||||
max-height: 50vh;
|
||||
max-width: 44rem;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.75rem;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.hux-runtime-inline-dock[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hux-runtime-inline-title {
|
||||
align-self: flex-start;
|
||||
background: Canvas;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 999px;
|
||||
color: CanvasText;
|
||||
font-size: 0.625rem;
|
||||
margin: 0;
|
||||
padding: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
.hux-runtime-approval.hux-runtime-inline-card {
|
||||
background: Canvas;
|
||||
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.25);
|
||||
color: CanvasText;
|
||||
margin-top: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@container (max-width: 34rem) {
|
||||
.hux-runtime-summary {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@ -101,7 +101,8 @@
|
||||
!identity || !actor || !exact(actor, ['type', 'id'], ['display']) || actor.type !== 'user' || actor.id !== identity.userRef) return null;
|
||||
} else if (value.decision !== undefined) return null;
|
||||
return {id: value.id, runId: value.run_id, capability: value.capability, summary: request.summary,
|
||||
risk: request.risk, external: request.external, status: value.status, expiresAt: value.expires_at};
|
||||
risk: request.risk, external: request.external, status: value.status,
|
||||
requestedAt: value.requested_at, expiresAt: value.expires_at};
|
||||
}
|
||||
|
||||
function normalizeApprovalPage(raw, conversationId) {
|
||||
@ -203,7 +204,10 @@
|
||||
let client = null; let autonomyRoot = null; let privacyRoot = null; let policy = null; let approvals = [];
|
||||
let receipt = null; let privacyPolicy = null; let audits = []; let activeTopic = null; let privacyNotice = null;
|
||||
let noticeShown = false; let noticePending = false; let privacyMessage = ''; let stopMessage = '';
|
||||
let inlineDock = null; let pollTimer = null;
|
||||
const issuedApprovals = new Set();
|
||||
const pollMs = Number.isSafeInteger(settings.approvalPollMs) && settings.approvalPollMs >= 1 ? settings.approvalPollMs : 4000;
|
||||
function timers() { return settings.timers || globalThis; }
|
||||
|
||||
function assertClient(candidate, flag) {
|
||||
if (!candidate || candidate.apiVersion !== 'hux.v1' || !candidate.identity ||
|
||||
@ -252,6 +256,64 @@
|
||||
issuedApprovals.delete(approvalId); approvals = approvals.filter((item) => item.id !== approvalId); renderAutonomy(); return resolved;
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const response = await request('/approvals?status=pending', {method: 'GET'});
|
||||
const page = normalizeApprovalPage(await response.json(), settings.conversationId);
|
||||
if (!page) return null;
|
||||
approvals = page; page.forEach((item) => issuedApprovals.add(item.id));
|
||||
renderAutonomy(); return page;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function startApprovalPolling() {
|
||||
if (pollTimer !== null) return;
|
||||
pollTimer = timers().setInterval(() => {
|
||||
if (typeof settings.canStopModelResponse === 'function' && !settings.canStopModelResponse()) return;
|
||||
void refreshApprovals();
|
||||
}, pollMs);
|
||||
if (pollTimer && typeof pollTimer.unref === 'function') pollTimer.unref();
|
||||
}
|
||||
|
||||
function stopApprovalPolling() {
|
||||
if (pollTimer === null) return;
|
||||
timers().clearInterval(pollTimer); pollTimer = null;
|
||||
}
|
||||
|
||||
function mountInlineDock() {
|
||||
const host = settings.inlineHost || doc.body || null;
|
||||
if (inlineDock || !host) return;
|
||||
inlineDock = el(doc, 'section', {class: 'hux-runtime-inline-dock', role: 'region',
|
||||
'aria-label': 'Approvals needed', 'aria-live': 'polite'});
|
||||
inlineDock.hidden = true; host.appendChild(inlineDock);
|
||||
}
|
||||
|
||||
function approvalCard(item, choices, extraClass) {
|
||||
const card = el(doc, 'article', {class: `hux-runtime-approval${extraClass} risk-${item.risk}`});
|
||||
card.appendChild(el(doc, 'strong', {}, item.summary));
|
||||
card.appendChild(el(doc, 'p', {}, `${item.capability.replaceAll('_', ' ')} · ${item.risk} risk${item.external ? ' · external' : ''}`));
|
||||
const actions = el(doc, 'div', {class: 'hux-runtime-actions'});
|
||||
choices.forEach(([choice, label]) => {
|
||||
const button = el(doc, 'button', {type: 'button'}, label);
|
||||
button.addEventListener('click', () => { void decideApproval(item.id, choice).catch(() => card.appendChild(safeError(doc))); });
|
||||
actions.appendChild(button);
|
||||
});
|
||||
card.appendChild(actions); return card;
|
||||
}
|
||||
|
||||
function renderInline() {
|
||||
if (!inlineDock) return;
|
||||
inlineDock.replaceChildren();
|
||||
const pending = (approvals || []).slice().sort((a, b) => b.requestedAt.localeCompare(a.requestedAt));
|
||||
inlineDock.hidden = pending.length === 0;
|
||||
if (!pending.length) return;
|
||||
inlineDock.appendChild(el(doc, 'h2', {class: 'hux-runtime-inline-title'}, 'Approval needed'));
|
||||
pending.slice(0, 3).forEach((item) => inlineDock.appendChild(approvalCard(item,
|
||||
[['once', 'Allow once'], ['always', 'Always allow'], ['deny', 'Deny']], ' hux-runtime-inline-card')));
|
||||
if (pending.length > 3) inlineDock.appendChild(status(doc, `${pending.length - 3} more waiting in the workspace drawer.`));
|
||||
}
|
||||
|
||||
async function stopRun() {
|
||||
if (typeof settings.stopModelResponse !== 'function') throw new TypeError('No owned model response is bound to this view');
|
||||
const model = await settings.stopModelResponse();
|
||||
@ -277,6 +339,7 @@
|
||||
}
|
||||
|
||||
function renderAutonomy() {
|
||||
renderInline();
|
||||
if (!autonomyRoot) return; autonomyRoot.replaceChildren();
|
||||
if (!policy || !approvals) { autonomyRoot.appendChild(status(doc, 'Loading autonomy controls…')); return; }
|
||||
const title = el(doc, 'h2', {class: 'hux-runtime-title'}, 'Autonomy and approvals'); autonomyRoot.appendChild(title);
|
||||
@ -290,17 +353,8 @@
|
||||
picker.appendChild(save); autonomyRoot.appendChild(picker);
|
||||
const queue = el(doc, 'section', {'aria-labelledby': 'hux-runtime-approvals'});
|
||||
queue.appendChild(el(doc, 'h3', {id: 'hux-runtime-approvals'}, `Pending approvals (${approvals.length})`));
|
||||
approvals.forEach((item) => {
|
||||
const card = el(doc, 'article', {class: `hux-runtime-approval risk-${item.risk}`});
|
||||
card.appendChild(el(doc, 'strong', {}, item.summary));
|
||||
card.appendChild(el(doc, 'p', {}, `${item.capability.replaceAll('_', ' ')} · ${item.risk} risk${item.external ? ' · external' : ''}`));
|
||||
const actions = el(doc, 'div', {class: 'hux-runtime-actions'});
|
||||
[['once', 'Allow once'], ['session', 'Allow this session'], ['always', 'Always allow'], ['deny', 'Deny']].forEach(([choice, label]) => {
|
||||
const button = el(doc, 'button', {type: 'button'}, label);
|
||||
button.addEventListener('click', () => { void decideApproval(item.id, choice).catch(() => card.appendChild(safeError(doc))); }); actions.appendChild(button);
|
||||
});
|
||||
card.appendChild(actions); queue.appendChild(card);
|
||||
});
|
||||
approvals.forEach((item) => queue.appendChild(approvalCard(item,
|
||||
[['once', 'Allow once'], ['session', 'Allow this session'], ['always', 'Always allow'], ['deny', 'Deny']], '')));
|
||||
autonomyRoot.appendChild(queue);
|
||||
const canStop = typeof settings.canStopModelResponse === 'function' && settings.canStopModelResponse();
|
||||
if (canStop) { const stop = el(doc, 'button', {type: 'button', class: 'hux-runtime-stop'}, 'Stop model response');
|
||||
@ -372,9 +426,11 @@
|
||||
}
|
||||
|
||||
const autonomyExtension = {id: 'autonomy-controls', flag: 'hux.autonomy', label: 'Autonomy', order: 30, render(context) {
|
||||
client = assertClient(context.client, 'hux.autonomy'); autonomyRoot = context.container; renderAutonomy();
|
||||
client = assertClient(context.client, 'hux.autonomy'); autonomyRoot = context.container;
|
||||
mountInlineDock(); renderAutonomy();
|
||||
const target = autonomyRoot;
|
||||
void loadAutonomy().catch(() => { if (autonomyRoot === target) target.replaceChildren(safeError(doc)); });
|
||||
startApprovalPolling();
|
||||
}};
|
||||
const privacyExtension = {id: 'privacy-controls', flag: 'hux.privacy', label: 'Privacy', order: 40, render(context) {
|
||||
client = assertClient(context.client, 'hux.privacy'); privacyRoot = context.container; renderPrivacy();
|
||||
@ -385,13 +441,16 @@
|
||||
const removeAutonomy = shell.register(autonomyExtension); const removePrivacy = shell.register(privacyExtension);
|
||||
return () => { removePrivacy(); removeAutonomy(); }; }
|
||||
function destroy() {
|
||||
stopApprovalPolling();
|
||||
if (inlineDock && inlineDock.parentNode) inlineDock.parentNode.removeChild(inlineDock);
|
||||
inlineDock = null;
|
||||
client = null; autonomyRoot = null; privacyRoot = null; policy = null; approvals = [];
|
||||
receipt = null; privacyPolicy = null; audits = []; activeTopic = null; privacyNotice = null;
|
||||
stopMessage = ''; issuedApprovals.clear();
|
||||
}
|
||||
return Object.freeze({extensions: Object.freeze([autonomyExtension, privacyExtension]), register, destroy,
|
||||
showPrivacyNotice, clearPrivacyNotice() { activeTopic = null; privacyNotice = null; if (privacyRoot) renderPrivacy(); },
|
||||
savePolicy, decideApproval, stopRun, privacyChoice});
|
||||
savePolicy, decideApproval, refreshApprovals, stopRun, privacyChoice});
|
||||
}
|
||||
|
||||
function createAutonomyPrivacyRuntime(options) {
|
||||
|
||||
@ -160,16 +160,24 @@ def test_capability_matrix_shape():
|
||||
assert row["external_side_effect"] == "ask", level
|
||||
assert row["read_files"] == "allow"
|
||||
assert matrix["ask_first"]["shell"] == "ask"
|
||||
assert matrix["safe"]["network"] == "deny"
|
||||
assert matrix["safe"]["network"] == "ask", "browsing under safe parks a visible prompt, never a silent deny"
|
||||
assert matrix["safe"]["web_search"] == "ask"
|
||||
assert matrix["ask_first"]["network"] == "ask" and matrix["ask_first"]["web_search"] == "ask"
|
||||
assert matrix["autonomous"]["shell"] == "allow"
|
||||
assert matrix["autonomous"]["network"] == "allow"
|
||||
assert matrix["autonomous"]["web_search"] == "allow"
|
||||
assert "deny" not in {value for row in matrix.values() for value in row.values()}, "no default silently denies"
|
||||
assert set(policy.CAPABILITIES) == set(SCHEMAS["permission.schema.json"]["$defs"]["capability"]["enum"])
|
||||
|
||||
|
||||
def test_effective_decision_honours_grants_expiry_and_deny():
|
||||
base = copy.deepcopy(EXAMPLES["policy"])
|
||||
assert policy.effective_decision(base, "network", NOW) == "ask"
|
||||
assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "deny"
|
||||
base["grants"][0]["decision"] = "allow"
|
||||
assert policy.effective_decision(base, "network", NOW) == "allow"
|
||||
assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "ask", (
|
||||
"an expired allow grant falls back to the safe matrix, which asks for network"
|
||||
)
|
||||
base["grants"] = [{"capability": "shell", "decision": "allow"}, {"capability": "shell", "decision": "deny"}]
|
||||
assert policy.effective_decision(base, "shell", NOW) == "deny"
|
||||
base["grants"] = [{"capability": "deploy", "decision": "allow"}]
|
||||
|
||||
@ -98,8 +98,10 @@ def test_safe_policy_auto_allows_reads_and_queues_mutations(router, frozen, even
|
||||
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body
|
||||
assert body["expires_at"] == policy.iso(T0 + timedelta(hours=24))
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network"), WORKER)
|
||||
assert status == 201 and valid(body)["status"] == "denied" and body["decision"]["choice"] == "deny"
|
||||
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.resolved"]
|
||||
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body, "safe parks network visibly"
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("web_search"), WORKER)
|
||||
assert status == 201 and valid(body)["status"] == "pending", "safe parks web_search visibly"
|
||||
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.requested", "approval.requested"]
|
||||
assert events[0]["evidence"] == [{"kind": "approval", "id": events[0]["evidence"][0]["id"]}]
|
||||
|
||||
|
||||
|
||||
@ -107,7 +107,29 @@ def test_grants_get_server_set_expiry_and_actor(router, frozen):
|
||||
assert policy.resolve_request(body, "shell", False) == "ask", "expired grant falls back to the matrix"
|
||||
assert policy.resolve_request(body, "network", False) == "allow"
|
||||
frozen["now"] = T0 + timedelta(days=31)
|
||||
assert policy.resolve_request(body, "network", False) == "deny", "safe denies network once the grant lapses"
|
||||
assert policy.resolve_request(body, "network", False) == "ask", "safe asks for network once the grant lapses"
|
||||
|
||||
|
||||
def test_safe_and_ask_first_park_network_with_a_visible_prompt(router):
|
||||
"""The chat surface browses: network and web_search resolve to ask, never a silent deny."""
|
||||
matrix = rules.default_capability_matrix()
|
||||
for level in ("ask_first", "safe"):
|
||||
assert matrix[level]["network"] == "ask" and matrix[level]["web_search"] == "ask", level
|
||||
assert matrix["autonomous"]["network"] == "allow" and matrix["autonomous"]["web_search"] == "allow"
|
||||
assert "deny" not in {value for row in matrix.values() for value in row.values()}
|
||||
status, body, _ = call(router, "GET", "/hux/v1/policy")
|
||||
assert status == 200 and body["autonomy"] == "safe"
|
||||
for capability in ("network", "web_search"):
|
||||
assert policy.resolve_request(body, capability, external=False) == "ask"
|
||||
assert policy.resolve_request(body, capability, external=True) == "ask"
|
||||
# SO-39 stays intact: external effects never auto-allow, even under autonomous.
|
||||
status, autonomous, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"})
|
||||
assert status == 200
|
||||
assert policy.resolve_request(autonomous, "network", external=False) == "allow"
|
||||
assert policy.resolve_request(autonomous, "network", external=True) == "ask"
|
||||
assert policy.resolve_request(autonomous, "web_search", external=True) == "ask"
|
||||
for always_ask in ("deploy", "external_side_effect"):
|
||||
assert policy.resolve_request(autonomous, always_ask, external=False) == "ask"
|
||||
|
||||
|
||||
def test_put_honours_if_match_and_audits_unconditional_writes(router, tmp_path):
|
||||
|
||||
@ -39,6 +39,8 @@ def test_runtime_pair_is_scoped_dependency_free_and_inert():
|
||||
assert 'credentials: \'same-origin\'' in js and "cache: 'no-store'" in js
|
||||
assert "'If-Match'" in js and "'Idempotency-Key'" in js
|
||||
assert "Stop receipt" in js and "GENERIC_NOTICE" in js
|
||||
assert "hux-runtime-inline-dock" in js and "hux-runtime-inline-dock" in css
|
||||
assert "'aria-live': 'polite'" in js and "Approvals needed" in js
|
||||
assert ".hux-runtime" in css and "body" not in css and ":root" not in css
|
||||
assert "focus-visible" in css and "prefers-reduced-motion" in css
|
||||
assert len(js.splitlines()) < 500 and len(css.splitlines()) < 500
|
||||
@ -68,5 +70,6 @@ def test_runtime_uses_canonical_backend_routes_and_explicit_safety_language():
|
||||
assert route in js
|
||||
assert "Pending approvals" in js
|
||||
assert "Allow once" in js and "Always allow" in js and "Deny" in js
|
||||
assert "Approval needed" in js and "more waiting in the workspace drawer" in js
|
||||
assert "prior sensitive details are not repeated" in js
|
||||
assert "process_registry_empty: false" in js
|
||||
|
||||
@ -497,3 +497,94 @@ test('event handlers contain failed mutations without claiming success', async (
|
||||
container: plainRoot}); await ticks();
|
||||
assert.equal(allText(plainRoot).includes('external'), false);
|
||||
});
|
||||
|
||||
function inlineItems(count) {
|
||||
return Array.from({length: count}, (_, index) => approval({id: `apr_inline000${index + 1}`,
|
||||
requested_at: `2026-08-24T10:0${index + 1}:00Z`,
|
||||
request: {...approval().request, summary: `req ${index + 1}`}}));
|
||||
}
|
||||
|
||||
test('inline approval prompts stack newest first near the chat flow and decide in place', async () => {
|
||||
const inlineHost = new FakeNode('main');
|
||||
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage(inlineItems(4))),
|
||||
response(200, approval({id: 'apr_inline0004', requested_at: '2026-08-24T10:04:00Z',
|
||||
request: {...approval().request, summary: 'req 4'}, status: 'approved',
|
||||
decision: {choice: 'always', by: {type: 'user', id: IDENTITY.userRef}, at: STAMP}})),
|
||||
response(200, policy(), {ETag: '1'}), response(200, approvalPage([]))]);
|
||||
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher, inlineHost});
|
||||
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
||||
const dock = inlineHost.children[0];
|
||||
assert.equal(dock.attributes.role, 'region');
|
||||
assert.equal(dock.attributes['aria-live'], 'polite');
|
||||
assert.equal(dock.attributes['aria-label'], 'Approvals needed');
|
||||
assert.equal(dock.hidden, false);
|
||||
const cards = dock.children.filter((node) => node.tagName === 'ARTICLE');
|
||||
assert.equal(cards.length, 3, 'at most three prompts are visible');
|
||||
assert.deepEqual(cards.map((card) => card.children[0].textContent), ['req 4', 'req 3', 'req 2']);
|
||||
assert.match(allText(dock), /1 more waiting in the workspace drawer/);
|
||||
assert(findText(cards[0], 'Allow once') && findText(cards[0], 'Always allow') && findText(cards[0], 'Deny'));
|
||||
assert.equal(findText(cards[0], 'Allow this session'), undefined, 'inline offers exactly three choices');
|
||||
findText(cards[0], 'Always allow').click(); await ticks();
|
||||
assert.match(h.calls[2].init.headers['Idempotency-Key'], /^hux:approval-always:/);
|
||||
assert.deepEqual(JSON.parse(h.calls[2].init.body), {choice: 'always'});
|
||||
const after = inlineHost.children[0].children.filter((node) => node.tagName === 'ARTICLE');
|
||||
assert.deepEqual(after.map((card) => card.children[0].textContent), ['req 3', 'req 2', 'req 1']);
|
||||
assert.equal(allText(inlineHost).includes('more waiting'), false);
|
||||
assert.match(allText(root), /Pending approvals \(3\)/, 'drawer stays in sync');
|
||||
runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
||||
assert.equal(inlineHost.children.length, 1, 'a re-render never mounts a second dock');
|
||||
runtime.destroy(); runtime.destroy();
|
||||
assert.equal(inlineHost.children.length, 0, 'destroy removes the inline dock');
|
||||
});
|
||||
|
||||
test('parked approvals poll only while a turn is active and stop with the runtime', async () => {
|
||||
let active = true; let tick = null; let cleared = 0;
|
||||
const timersApi = {setInterval(callback, ms) { tick = callback; assert.equal(ms, 4000); return 0; },
|
||||
clearInterval(handle) { assert.equal(handle, 0); cleared += 1; }};
|
||||
const inlineHost = new FakeNode('main');
|
||||
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage([])),
|
||||
response(200, approvalPage([approval()])), response(500, {}),
|
||||
response(200, {items: 'nope', next: null})]);
|
||||
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher,
|
||||
inlineHost, timers: timersApi, canStopModelResponse: () => active});
|
||||
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
|
||||
assert.equal(inlineHost.children[0].hidden, true, 'no pending approvals keeps the dock hidden');
|
||||
active = false; tick(); await ticks();
|
||||
assert.equal(h.calls.length, 2, 'no polling without an active turn');
|
||||
active = true; tick(); await ticks();
|
||||
assert.equal(h.calls.length, 3);
|
||||
assert.equal(inlineHost.children[0].hidden, false, 'a parked approval surfaces within one poll');
|
||||
assert.match(allText(inlineHost), /Send the finished report/);
|
||||
tick(); await ticks();
|
||||
tick(); await ticks();
|
||||
assert.match(allText(inlineHost), /Send the finished report/, 'poll failures keep the last prompt');
|
||||
runtime.destroy(); runtime.destroy();
|
||||
assert.equal(cleared, 1);
|
||||
assert.equal(await runtime.refreshApprovals(), null, 'a destroyed runtime never fetches');
|
||||
});
|
||||
|
||||
test('inline dock defaults to the document element root and tolerates a missing approvals page', async () => {
|
||||
const bodyDoc = {createElement: (tag) => new FakeNode(tag), body: new FakeNode('div')};
|
||||
const fallback = api.createRuntime({document: bodyDoc, conversationId: CONVERSATION,
|
||||
approvalPollMs: 3600000, fetcher: async () => response(500, {})});
|
||||
fallback.extensions[0].render({client: harness([]).client, container: new FakeNode('section')}); await ticks();
|
||||
assert.equal(bodyDoc.body.children[0].attributes.class, 'hux-runtime-inline-dock');
|
||||
assert.equal(bodyDoc.body.children[0].hidden, true);
|
||||
fallback.destroy();
|
||||
assert.equal(bodyDoc.body.children.length, 0);
|
||||
|
||||
let tick = null;
|
||||
const timersApi = {setInterval(callback) { tick = callback; return 9; }, clearInterval() {}};
|
||||
const inlineHost = new FakeNode('main');
|
||||
const bad = harness([response(200, policy(), {ETag: '1'}), response(200, {items: 'nope', next: null}),
|
||||
response(200, policy({autonomy: 'ask_first', revision: 2}), {ETag: '2'})]);
|
||||
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: bad.fetcher,
|
||||
inlineHost, timers: timersApi});
|
||||
const root = new FakeNode('section'); runtime.extensions[0].render({client: bad.client, container: root}); await ticks();
|
||||
assert.match(allText(root), /temporarily unavailable/);
|
||||
await runtime.savePolicy('ask_first');
|
||||
assert.equal(inlineHost.children[0].hidden, true, 'no approvals page renders no inline prompt');
|
||||
tick(); await ticks();
|
||||
assert.equal(bad.responses.length, 0);
|
||||
runtime.destroy();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user