feat(LongTermMemory): review consolidation proposals in Memory Console

This commit is contained in:
huanghuoguoguo
2026-11-02 20:52:56 +08:00
parent 8a3657430c
commit f207965e68
6 changed files with 440 additions and 2 deletions
@@ -0,0 +1,160 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from components.pages.memory_console.memory_console import MemoryConsolePage
from langbot_plugin.api.definition.components.page import PageRequest
@pytest.fixture
def page():
component = MemoryConsolePage()
config = {
"isolation": "session", "consolidation_enabled": True,
"consolidation_model_uuid": "trusted-summary-model", "embedding_model_uuid": "trusted-embedding-model",
"consolidation_min_age_days": 10, "consolidation_max_candidates": 8,
}
source = "<script>alert('untrusted source')</script>\n" + "Complete source fact. " * 500
store = SimpleNamespace(
configs={"kb": config},
get_kb_configs=AsyncMock(return_value={"kb": config}),
preview_consolidation=AsyncMock(return_value={
"proposal_id": "proposal-1", "summary_episode": "An inferred summary <b>not HTML</b>",
"source_records": [{"id": "source-1", "content": source}],
"risk_notes": ["Review the inference"],
}),
apply_consolidation=AsyncMock(return_value={"summary_episode": {"id": "summary-1"}, "archived_episode_ids": []}),
append_audit_entry=AsyncMock(),
)
component.plugin = SimpleNamespace(memory_store=store)
return component
def body(**changes):
return {"collection_id": "kb", "scope_key": "bot:group_room", "user_key": "bot:group_room", **changes}
async def request(page, endpoint, payload):
return await page.handle_api(PageRequest(endpoint=endpoint, method="POST", body=payload))
@pytest.mark.asyncio
async def test_preview_uses_trusted_kb_model_and_preserves_complete_sources(page):
response = await request(page, "/consolidation/preview", body())
assert response.error is None
assert response.data["source_records"][0]["content"].endswith("Complete source fact. " * 500)
assert response.data["summary_episode"] == "An inferred summary <b>not HTML</b>"
page._store.preview_consolidation.assert_awaited_once_with(
collection_id="kb", user_key="bot:group_room", min_age_days=10, max_candidates=8,
summary_model_uuid="trusted-summary-model",
)
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_preview_is_available_when_run_is_disabled(page):
page._store.configs["kb"]["consolidation_enabled"] = False
response = await request(page, "/consolidation/preview", body())
assert response.error is None
page._store.preview_consolidation.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_requires_reviewed_proposal_and_trusted_embedding_model(page):
response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
assert response.error is None
page._store.apply_consolidation.assert_awaited_once_with(
collection_id="kb", embedding_model_uuid="trusted-embedding-model",
user_key="bot:group_room", proposal_id="proposal-1",
)
audit = page._store.append_audit_entry.call_args.kwargs
assert audit["scope_key"] == "bot:group_room"
assert audit["user_key"] == "bot:group_room"
assert audit["metadata"] == {"collection_id": "kb", "proposal_id": "proposal-1"}
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"summary_model_uuid": "browser-model"}, {"embedding_model_uuid": "browser-model"}, {"consolidation_enabled": True}, {"min_age_days": 0}])
@pytest.mark.parametrize("endpoint", ["preview", "run"])
async def test_browser_cannot_override_models_or_policy(page, extra, endpoint):
payload = body(**extra)
if endpoint == "run":
payload["proposal_id"] = "proposal-1"
response = await request(page, "/consolidation/" + endpoint, payload)
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("changes", [{"proposal_id": ""}, {"proposal_id": None}, {"proposal_id": 123}, {"proposal_id": "proposal-1", "user_key": "another-space"}, {"proposal_id": "proposal-1", "collection_id": "unknown-kb"}])
async def test_invalid_run_never_reaches_store(page, changes):
response = await request(page, "/consolidation/run", body(**changes))
assert response.error is not None
page._store.apply_consolidation.assert_not_awaited()
page._store.append_audit_entry.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("disabled", [False, "false", None])
async def test_run_is_fail_closed_when_not_explicitly_enabled(page, disabled):
page._store.configs["kb"]["consolidation_enabled"] = disabled
response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
assert response.error is not None
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_requires_configured_embedding_model(page):
page._store.configs["kb"].pop("embedding_model_uuid")
response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
assert response.error is not None
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_preview_scope_is_checked_against_kb_isolation(page):
page._store.configs["kb"]["isolation"] = "bot"
response = await request(page, "/consolidation/preview", body(user_key="bot:bot"))
assert response.error is None
assert page._store.preview_consolidation.call_args.kwargs["user_key"] == "bot:bot"
rejected = await request(page, "/consolidation/preview", body())
assert rejected.error is not None
assert page._store.preview_consolidation.await_count == 1
@pytest.mark.asyncio
async def test_store_rejects_cross_scope_or_stale_proposal_without_success_audit(page):
page._store.apply_consolidation.side_effect = ValueError("proposal scope changed; preview again")
response = await request(page, "/consolidation/run", body(proposal_id="proposal-from-another-space"))
assert response.error is not None
assert "preview again" in response.error
page._store.apply_consolidation.assert_awaited_once()
page._store.append_audit_entry.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [[], "body", 123])
async def test_consolidation_body_must_be_an_object(page, payload):
response = await request(page, "/consolidation/preview", payload)
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("changes", [{"collection_id": ""}, {"scope_key": ""}, {"user_key": ""}, {"collection_id": 123}, {"user_key": []}])
async def test_preview_requires_string_kb_scope_and_user_identity(page, changes):
response = await request(page, "/consolidation/preview", body(**changes))
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_unknown_kb_isolation_fails_closed(page):
page._store.configs["kb"]["isolation"] = "unknown"
response = await request(page, "/consolidation/preview", body(user_key="bot:bot"))
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
@@ -0,0 +1,84 @@
"""Exercise the Console review flow without adding a browser dependency."""
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import pytest
NODE_CHECK = r"""
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const html = fs.readFileSync(process.argv[1], 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
const nodes = new Map();
function element(id = '') {
return {value: '', checked: false, disabled: false, children: [], _text: '',
set textContent(text) { this._text = String(text); this.children = []; },
get textContent() { return this._text; },
set innerHTML(text) { throw new Error('Review data must never be inserted as HTML: ' + text); },
replaceChildren() { this.children = []; this._text = ''; },
appendChild(child) { this.children.push(child); },
};
}
function get(id) { if (!nodes.has(id)) nodes.set(id, element(id)); return nodes.get(id); }
const calls = [];
let handler;
const context = {
document: {getElementById: get, createElement: () => element()},
langbot: {onReady() {}, api: async (endpoint, body) => { calls.push({endpoint, body}); return handler(endpoint, body); }},
};
vm.createContext(context);
vm.runInContext(script, context);
get('scopeKey').value = 'bot:group_room';
get('userKey').value = 'bot:group_room';
get('collectionId').value = 'kb';
const untrusted = '<img src=x onerror=alert(1)>\nComplete source text';
const proposal = {proposal_id: 'p-1', summary_episode: untrusted, source_records: [{id: 'source', content: untrusted}], risk_notes: ['Verify sources']};
(async () => {
handler = () => proposal;
await context.previewConsolidation();
assert.equal(get('reviewConsolidation').disabled, false);
assert.equal(get('applyConsolidationBtn').disabled, true);
assert.equal(get('consolidationOutput').children.filter(child => child.textContent === untrusted).length, 2);
await context.applyConsolidation();
assert.equal(calls.length, 1, 'Unreviewed proposal cannot run');
get('reviewConsolidation').checked = true;
handler = () => ({summary_episode: {id: 'summary'}});
await context.applyConsolidation();
assert.equal(calls.length, 2);
assert.equal(calls[1].endpoint, '/consolidation/run');
assert.equal(calls[1].body.proposal_id, 'p-1');
assert.equal(calls[1].body.user_key, 'bot:group_room');
assert.equal(calls[1].body.model_uuid, undefined);
assert.equal(get('reviewConsolidation').disabled, true);
let resolve;
handler = () => new Promise(done => { resolve = done; });
const pending = context.previewConsolidation();
get('collectionId').value = 'another-kb';
context.clearConsolidation();
resolve(proposal);
await pending;
assert.equal(get('reviewConsolidation').disabled, true, 'Old KB response cannot restore proposal');
assert.equal(get('consolidationOutput').children.length, 0);
handler = () => ({summary_episode: 'No proposal ID'});
await context.previewConsolidation();
get('reviewConsolidation').checked = true;
const before = calls.length;
await context.applyConsolidation();
assert.equal(calls.length, before, 'Missing proposal ID cannot run');
})().catch(error => { console.error(error); process.exitCode = 1; });
"""
def test_review_flow_handles_untrusted_text_explicit_approval_and_stale_responses():
node = shutil.which("node")
if node is None:
pytest.skip("Node is needed for the Console DOM behavior check")
root = Path(__file__).parents[2]
html = root / "components/pages/memory_console/index.html"
result = subprocess.run([node, "-e", NODE_CHECK, str(html)], capture_output=True, text=True, timeout=15)
assert result.returncode == 0, result.stdout + result.stderr