482 lines
28 KiB
JavaScript
482 lines
28 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
|
|
const foundation = require('../../dockerfiles/hermes-webui-hux/foundation.js');
|
|
const contract = require('../../dockerfiles/hermes-webui-hux/runtime/wave_a_contract.js');
|
|
const runtimeApi = require('../../dockerfiles/hermes-webui-hux/runtime/wave_a_activity_memory.js');
|
|
|
|
const IDENTITY = Object.freeze({tenantSlot: 'slot-3', userRef: 'usr_0123456789abcdef',
|
|
surface: 'chat', trust: 'router'});
|
|
const CONVERSATION = 'conv_0001abcd';
|
|
|
|
function capability(overrides) {
|
|
return {schema: 'hux.capabilities.v1', contract_version: '1.1.0',
|
|
identity: {tenant_slot: IDENTITY.tenantSlot, subject: IDENTITY.userRef,
|
|
surface: IDENTITY.surface, trust: IDENTITY.trust},
|
|
cards: [
|
|
{card: 'HUX-11', flag: 'hux.foundation', enabled: true, routes: ['/hux/v1/capabilities']},
|
|
{card: 'HUX-01', flag: 'hux.activity_timeline', enabled: true,
|
|
routes: ['/hux/v1/conversations/{id}/events']},
|
|
{card: 'HUX-10', flag: 'hux.privacy', enabled: true, routes: ['/hux/v1/privacy/policy']},
|
|
{card: 'HUX-02', flag: 'hux.memory_control', enabled: true, routes: ['/hux/v1/memory']},
|
|
], server: {}, ...overrides};
|
|
}
|
|
|
|
function event(overrides) {
|
|
return {schema: 'hux.event.v1', id: 'evt_0001aaaa', seq: 1, ts: '2026-08-24T10:00:00Z',
|
|
conversation_id: CONVERSATION, kind: 'tool.result',
|
|
summary: 'Checked token=private Bearer abc.DEF', sensitivity: 'personal',
|
|
redaction: {level: 'none'}, turn: 1,
|
|
evidence: [{kind: 'tool_result', id: 'call-1', uri: 'https://private.example'}],
|
|
identity: capability().identity, detail: {raw: 'never render'}, ...overrides};
|
|
}
|
|
|
|
function memory(overrides) {
|
|
return {schema: 'hux.memory.v1', id: 'mem_0001aaaa', owner: IDENTITY.userRef,
|
|
scope: {level: 'conversation', scope_id: CONVERSATION}, kind: 'preference',
|
|
content: 'Prefers concise answers token=private', status: 'active', approval_mode: 'automatic',
|
|
sensitivity: 'personal', ttl: {policy: 'decay', decay_days: 180},
|
|
created_at: '2026-08-24T10:00:00Z', updated_at: '2026-08-24T10:00:01Z',
|
|
reason: 'The user asked password=hidden', retrievable: true, revision: 2,
|
|
identity: capability().identity, ...overrides};
|
|
}
|
|
|
|
function response(status, body, etag) {
|
|
return {status, ok: status >= 200 && status < 300, json: async () => body,
|
|
headers: {get: (name) => name === 'ETag' ? etag || null : null}};
|
|
}
|
|
|
|
test('canonical capability adapter bridges the backend cards to the foundation client shape', () => {
|
|
const normalized = contract.normalizeCapabilities(capability(), IDENTITY);
|
|
assert.deepEqual(normalized.flags,
|
|
['hux.foundation', 'hux.activity_timeline', 'hux.privacy', 'hux.memory_control']);
|
|
assert(Object.isFrozen(normalized));
|
|
assert.equal(normalized.contractVersion, '1.1.0');
|
|
const disabled = contract.normalizeCapabilities(capability({cards: [
|
|
{card: 'HUX-11', flag: 'hux.foundation', enabled: false, routes: []},
|
|
{card: 'HUX-01', flag: 'hux.activity_timeline', enabled: true, routes: []},
|
|
]}), IDENTITY);
|
|
assert.deepEqual(disabled.flags, []);
|
|
const missingDependency = contract.normalizeCapabilities(capability({cards: [
|
|
capability().cards[0],
|
|
{card: 'HUX-04', flag: 'hux.artifacts', enabled: true, routes: ['/hux/v1/artifacts']},
|
|
{card: 'HUX-08', flag: 'hux.research', enabled: true, routes: ['/hux/v1/sources']},
|
|
]}), IDENTITY);
|
|
assert.deepEqual(missingDependency.flags, ['hux.foundation']);
|
|
const invalid = [null, [], capability({schema: 'old'}), capability({contract_version: '2.0.0'}),
|
|
capability({identity: {...capability().identity, tenant_slot: 'slot-4'}}),
|
|
capability({cards: 'bad'}), capability({cards: Array(foundation.FLAGS.length * 2 + 1).fill({})}),
|
|
capability({cards: [null]}), capability({cards: [
|
|
{card: 'HUX-01', flag: 'wrong', enabled: true, routes: []}]}),
|
|
capability({cards: [{card: 'HUX-99', flag: 'hux.foundation', enabled: 'yes', routes: []}]}),
|
|
capability({cards: [{card: 'HUX-99', flag: 'hux.foundation', enabled: true, routes: 'bad'}]}),
|
|
capability({cards: [{card: 'HUX-99', flag: 'hux.foundation', enabled: true, routes: ['remote']}]}),
|
|
capability({cards: [capability().cards[0], capability().cards[0]]}),
|
|
];
|
|
invalid.forEach((value) => assert.throws(() => contract.normalizeCapabilities(value, IDENTITY), /capabilit/i));
|
|
});
|
|
|
|
test('identity, endpoint, idempotency, and display helpers fail closed', () => {
|
|
assert.deepEqual(contract.normalizeExpectedIdentity(IDENTITY), IDENTITY);
|
|
for (const bad of [null, {...IDENTITY, tenantSlot: 'tenant'}, {...IDENTITY, userRef: 'brad@example.com'},
|
|
{...IDENTITY, surface: 'browser'}, {...IDENTITY, trust: 'human'}]) {
|
|
assert.throws(() => contract.normalizeExpectedIdentity(bad), /identity/);
|
|
}
|
|
assert.equal(contract.sameCanonicalIdentity(capability().identity, IDENTITY), true);
|
|
assert.equal(contract.sameCanonicalIdentity(null, IDENTITY), false);
|
|
assert.equal(contract.sameCanonicalIdentity({...capability().identity, subject: 'usr_ffffffffffffffff'}, IDENTITY), false);
|
|
assert.equal(contract.safeEndpoint('/hux/v1', '/memory'), '/hux/v1/memory');
|
|
for (const path of ['memory', '//remote', '/../tenant', '/%2e%2e/x', '/x\\y']) {
|
|
assert.throws(() => contract.safeEndpoint('/hux/v1', path), /scoped/);
|
|
}
|
|
assert.equal(contract.safeIdempotencyKey('webui:key:0001'), 'webui:key:0001');
|
|
assert.throws(() => contract.safeIdempotencyKey('short'), /Idempotency/);
|
|
assert.throws(() => contract.safeIdempotencyKey('bad key value'), /Idempotency/);
|
|
assert.equal(contract.safeText(null, 'fallback', 20), 'fallback');
|
|
assert.equal(contract.safeText(' \n ', 'fallback', 20), 'fallback');
|
|
assert.equal(contract.safeText('password=x Bearer abc.DEF', 'fallback', 100),
|
|
'password=[redacted] Bearer [redacted]');
|
|
assert.equal(contract.safeText('-----BEGIN PRIVATE KEY----- x -----END PRIVATE KEY-----', 'x', 100), '[redacted]');
|
|
assert.equal(contract.safeText('abcdef', 'x', 4), 'abc…');
|
|
});
|
|
|
|
test('canonical client negotiates safely, stays same-origin, and sends exact concurrency headers', async () => {
|
|
const calls = [];
|
|
let next = response(200, capability());
|
|
const client = contract.createCanonicalClient({expectedIdentity: IDENTITY, baseUrl: '/hux/v1/',
|
|
fetcher: async (...args) => { calls.push(args); return next; }});
|
|
const phases = [];
|
|
const unsubscribe = client.subscribe((state) => phases.push(state.phase));
|
|
await assert.rejects(() => client.request('/memory'), /not ready/);
|
|
await client.negotiate();
|
|
assert.equal(client.getState().phase, 'ready');
|
|
assert.equal(client.enabled('hux.memory_control'), true);
|
|
assert.equal(client.enabled('hux.bogus'), false);
|
|
assert.equal(client.endpoint('/memory'), '/hux/v1/memory');
|
|
next = response(200, {ok: true}, '3');
|
|
const result = await client.request('/memory/mem_0001aaaa/edit', {method: 'POST',
|
|
headers: {'If-Match': '2', 'Idempotency-Key': 'memory:key:0001'}, body: {content: 'new'}});
|
|
assert.deepEqual(result, {body: {ok: true}, etag: '3'});
|
|
const init = calls.at(-1)[1];
|
|
assert.equal(init.credentials, 'same-origin');
|
|
assert.equal(init.cache, 'no-store');
|
|
assert.equal(init.headers['If-Match'], '2');
|
|
assert.equal(init.headers['Idempotency-Key'], 'memory:key:0001');
|
|
assert.equal(init.headers['Content-Type'], 'application/json');
|
|
assert.equal(init.body, '{"content":"new"}');
|
|
next = response(409, {secret: 'must not escape'});
|
|
await assert.rejects(() => client.request('/memory'), (error) => error.status === 409 &&
|
|
!error.message.includes('secret'));
|
|
next = response(500, {secret: 'private'});
|
|
await assert.rejects(() => client.request('/memory'), /failed \(500\)/);
|
|
unsubscribe();
|
|
assert.deepEqual(phases, ['disabled', 'loading', 'ready']);
|
|
assert.throws(() => client.subscribe('bad'), /Listener/);
|
|
});
|
|
|
|
test('canonical client handles disabled, invalid, failed and superseded negotiations', async () => {
|
|
assert.throws(() => contract.createCanonicalClient({expectedIdentity: IDENTITY, fetcher: null}), /fetch/);
|
|
assert.throws(() => contract.createCanonicalClient({expectedIdentity: IDENTITY,
|
|
baseUrl: 'https://remote.example', fetcher: async () => response(200, {})}), /same-origin/);
|
|
let next = response(404, {});
|
|
const client = contract.createCanonicalClient({expectedIdentity: IDENTITY, fetcher: async () => next});
|
|
await client.negotiate();
|
|
assert.equal(client.getState().phase, 'disabled');
|
|
next = response(500, {raw: 'private'});
|
|
await client.negotiate();
|
|
assert.equal(client.getState().phase, 'error');
|
|
assert.equal(client.getState().error.includes('private'), false);
|
|
next = response(200, capability({identity: {...capability().identity, subject: 'usr_ffffffffffffffff'}}));
|
|
await client.negotiate();
|
|
assert.equal(client.getState().error, 'Hermes workspace identity could not be verified.');
|
|
next = response(200, capability({cards: [{card: 'HUX-11', flag: 'hux.foundation', enabled: false, routes: []}]}));
|
|
await client.negotiate();
|
|
assert.equal(client.getState().phase, 'disabled');
|
|
|
|
const resolvers = [];
|
|
const late = contract.createCanonicalClient({expectedIdentity: IDENTITY,
|
|
fetcher: () => new Promise((resolve) => resolvers.push(resolve))});
|
|
const first = late.negotiate(); const second = late.negotiate();
|
|
resolvers[1](response(200, capability())); await second;
|
|
resolvers[0](response(404, {})); await first;
|
|
assert.equal(late.getState().phase, 'ready');
|
|
});
|
|
|
|
test('event pages expose only redacted summaries and bounded reference identifiers', () => {
|
|
const page = contract.normalizeEventPage({items: [event()], next: null}, IDENTITY, CONVERSATION);
|
|
assert.equal(page.items[0].summary, 'Checked token=[redacted] Bearer [redacted]');
|
|
assert.deepEqual(page.items[0].evidence, [{kind: 'tool_result', id: 'call-1'}]);
|
|
assert.equal(JSON.stringify(page).includes('never render'), false);
|
|
const hidden = contract.normalizeEventPage({items: [event({redaction: {level: 'full'}})], next: 1},
|
|
IDENTITY, CONVERSATION);
|
|
assert.equal(hidden.items[0].summary, 'Details hidden by privacy controls.');
|
|
const empty = contract.normalizeEventPage({items: [event({summary: ''})], next: null}, IDENTITY, CONVERSATION);
|
|
assert.equal(empty.items[0].summary, 'Activity details unavailable.');
|
|
const noEvidence = contract.normalizeEventPage({items: [event({evidence: [{kind: 'raw', id: 'x'}, null]})],
|
|
next: null}, IDENTITY, CONVERSATION);
|
|
assert.deepEqual(noEvidence.items[0].evidence, []);
|
|
const invalidPages = [null, {items: null, next: null}, {items: Array(201).fill(event()), next: null},
|
|
{items: [], next: '1'}, {items: [event({seq: 2}), event({id: 'evt_0002aaaa', seq: 1})], next: null}];
|
|
invalidPages.forEach((raw) => assert.throws(() => contract.normalizeEventPage(raw, IDENTITY, CONVERSATION), /Activity/));
|
|
const invalidEvents = [null, event({identity: {...capability().identity, tenant_slot: 'slot-4'}}),
|
|
event({schema: 'old'}), event({id: 'bad'}), event({seq: -1}), event({ts: 'today'}),
|
|
event({conversation_id: 'conv_other1234'}), event({kind: 'tool.raw'}),
|
|
event({sensitivity: 'secret'}), event({redaction: null}), event({redaction: {level: 'maybe'}})];
|
|
invalidEvents.forEach((item) => assert.throws(() =>
|
|
contract.normalizeEventPage({items: [item], next: null}, IDENTITY, CONVERSATION), /boundary|Activity/));
|
|
});
|
|
|
|
test('memory pages enforce ownership, conversation scope, redaction, retention and revisions', () => {
|
|
const active = contract.normalizeMemoryPage({items: [memory()], next: null}, IDENTITY, CONVERSATION).items[0];
|
|
assert.equal(active.content, 'Prefers concise answers token=[redacted]');
|
|
assert.equal(active.reason, 'The user asked password=[redacted]');
|
|
assert.equal(active.retention, 'decays after 180 days');
|
|
assert.equal(contract.normalizeMemoryPage({items: [memory({ttl: {policy: 'never'}})], next: null},
|
|
IDENTITY, CONVERSATION).items[0].retention, 'does not expire');
|
|
assert.equal(contract.normalizeMemoryPage({items: [memory({ttl: {policy: 'expires_at',
|
|
expires_at: '2026-09-24T10:00:00Z'}})], next: null}, IDENTITY, CONVERSATION).items[0].retention,
|
|
'expires 2026-09-24T10:00:00Z');
|
|
assert.equal(contract.normalizeMemoryPage({items: [memory({status: 'forgotten', content: '',
|
|
retrievable: false})], next: null}, IDENTITY, CONVERSATION).items[0].content,
|
|
'Content removed by privacy controls.');
|
|
assert.equal(contract.normalizeMemoryPage({items: [memory({sensitivity: 'restricted', content: ''})], next: null},
|
|
IDENTITY, CONVERSATION).items[0].content, 'Content removed by privacy controls.');
|
|
assert.equal(contract.normalizeMemoryPage({items: [memory({scope: {level: 'global'}})], next: null},
|
|
IDENTITY, CONVERSATION).items[0].scope.scopeId, null);
|
|
assert.equal(contract.normalizeMemoryPage({items: [memory({scope: {level: 'project', scope_id: 'prj_0001aaaa'}})],
|
|
next: null}, IDENTITY, CONVERSATION).items[0].scope.level, 'project');
|
|
|
|
const invalidPages = [null, {items: null, next: null}, {items: Array(501).fill(memory()), next: null},
|
|
{items: [], next: 1}, {items: [memory(), memory()], next: null}];
|
|
invalidPages.forEach((raw) => assert.throws(() => contract.normalizeMemoryPage(raw, IDENTITY, CONVERSATION), /Memory/));
|
|
const invalid = [null, memory({identity: {...capability().identity, subject: 'usr_ffffffffffffffff'}}),
|
|
memory({schema: 'old'}), memory({id: 'bad'}), memory({owner: 'usr_ffffffffffffffff'}),
|
|
memory({revision: 0}), memory({kind: 'secret'}), memory({status: 'deleted'}),
|
|
memory({approval_mode: 'always'}), memory({sensitivity: 'secret'}), memory({retrievable: 'yes'}),
|
|
memory({created_at: 'today'}), memory({updated_at: 'today'}),
|
|
memory({updated_at: '2026-08-23T10:00:00Z'}), memory({content: 4}),
|
|
memory({content: 'x'.repeat(2001)}), memory({reason: ''}), memory({reason: 'x'.repeat(281)}),
|
|
memory({scope: null}), memory({scope: {level: 'unknown'}}),
|
|
memory({scope: {level: 'project', scope_id: 'bad'}}),
|
|
memory({scope: {level: 'conversation', scope_id: 'conv_other1234'}}),
|
|
memory({status: 'forgotten', content: 'must be gone'}), memory({ttl: null}),
|
|
memory({ttl: {policy: 'unknown'}}), memory({ttl: {policy: 'expires_at', expires_at: 'today'}}),
|
|
memory({ttl: {policy: 'decay', decay_days: 0}}), memory({ttl: {policy: 'decay', decay_days: 3651}})];
|
|
invalid.forEach((item) => assert.throws(() =>
|
|
contract.normalizeMemoryPage({items: [item], next: null}, IDENTITY, CONVERSATION), /boundary|Memory|Terminal/));
|
|
});
|
|
|
|
class FakeNode {
|
|
constructor(tag) {
|
|
this.tagName = tag.toUpperCase(); this.attributes = {}; this.children = []; this.dataset = {};
|
|
this.hidden = false; this.parentNode = null; this.listeners = {}; this.textContent = ''; this.value = '';
|
|
}
|
|
setAttribute(name, value) {
|
|
this.attributes[name] = String(value);
|
|
if (name === 'value') this.value = String(value);
|
|
if (name.startsWith('data-')) this.dataset[name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase())] = String(value);
|
|
}
|
|
getAttribute(name) { return this.attributes[name]; }
|
|
appendChild(node) { node.parentNode = this; this.children.push(node); return node; }
|
|
removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null; }
|
|
replaceChildren(...nodes) {
|
|
this.children.forEach((node) => { node.parentNode = null; }); this.children = [];
|
|
nodes.forEach((node) => this.appendChild(node));
|
|
}
|
|
addEventListener(name, listener) { this.listeners[name] = listener; }
|
|
trigger(name, extra) { return this.listeners[name] && this.listeners[name]({target: this,
|
|
preventDefault() {}, ...(extra || {})}); }
|
|
click() { return this.trigger('click'); }
|
|
focus() { this.focused = true; }
|
|
}
|
|
|
|
function descendants(node) {
|
|
return node.children.flatMap((child) => [child, ...descendants(child)]);
|
|
}
|
|
|
|
function byText(node, text) {
|
|
return descendants(node).find((item) => item.textContent === text);
|
|
}
|
|
|
|
async function flush() {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
test('vanilla runtime mounts through the shell and renders safe Activity and Memory panels', async () => {
|
|
const calls = [];
|
|
const fetcher = async (url, init) => {
|
|
calls.push([url, init]);
|
|
if (url.endsWith('/capabilities')) return response(200, capability());
|
|
if (url.includes('/events?')) return response(200, {items: [event()], next: null});
|
|
if (url.endsWith('/memory')) return response(200, {items: [memory()], next: null});
|
|
if (url.includes('/memory/')) return response(200, memory({revision: 3}), '3');
|
|
throw new Error(`unexpected ${url}`);
|
|
};
|
|
const doc = {createElement: (tag) => new FakeNode(tag)};
|
|
const host = new FakeNode('main');
|
|
const runtime = runtimeApi.createWaveARuntime({document: doc, fetcher, expectedIdentity: IDENTITY,
|
|
conversationId: CONVERSATION, idempotencyKey: () => 'webui:memory:0001'});
|
|
await runtime.mount(host); await flush();
|
|
const root = host.children[0];
|
|
assert.equal(root.getAttribute('data-hux-version'), 'hux.v1');
|
|
assert(byText(root, 'What is happening'));
|
|
assert.equal(descendants(root).some((node) => /private|never render/.test(node.textContent) &&
|
|
!node.textContent.includes('[redacted]')), false);
|
|
assert(byText(root, 'tool_result: call-1'));
|
|
const tabs = descendants(root).filter((node) => node.getAttribute('role') === 'tab');
|
|
assert.deepEqual(tabs.map((tab) => tab.textContent), ['Activity', 'Memory']);
|
|
await tabs[1].click();
|
|
assert(byText(root, 'Memory control'));
|
|
assert(byText(root, 'Prefers concise answers token=[redacted]'));
|
|
assert(byText(root, 'The user asked password=[redacted]'));
|
|
|
|
await byText(root, 'Stop using in replies').click(); await flush();
|
|
const actionCall = calls.find(([url]) => url.endsWith('/memory/mem_0001aaaa/remove_retrieval'));
|
|
assert.equal(actionCall[1].headers['If-Match'], '2');
|
|
assert.equal(actionCall[1].method, 'POST');
|
|
runtime.destroy();
|
|
assert.equal(host.children.length, 0);
|
|
});
|
|
|
|
test('memory renderer sends idempotent proposals and If-Match edits without persistence', async () => {
|
|
const calls = [];
|
|
const client = {identity: IDENTITY, request: async (path, init) => {
|
|
calls.push([path, init]);
|
|
if (path === '/memory' && (!init || init.method !== 'POST')) return {body: {items: [memory()], next: null}};
|
|
return {body: memory({revision: 3})};
|
|
}};
|
|
const doc = {createElement: (tag) => new FakeNode(tag)};
|
|
const panel = new FakeNode('section');
|
|
runtimeApi.memoryExtension({document: doc, conversationId: CONVERSATION,
|
|
idempotencyKey: () => 'webui:memory:0002'}).render({client, container: panel});
|
|
await flush();
|
|
const proposal = descendants(panel).find((node) => node.tagName === 'FORM' &&
|
|
node.getAttribute('class') === 'hux-wave-a__propose');
|
|
const textareas = descendants(proposal).filter((node) => node.tagName === 'TEXTAREA');
|
|
const input = descendants(proposal).find((node) => node.tagName === 'INPUT');
|
|
const select = descendants(proposal).find((node) => node.tagName === 'SELECT');
|
|
textareas[0].value = 'Remember token=private'; input.value = 'Because password=private'; select.value = 'instruction';
|
|
await proposal.trigger('submit'); await flush();
|
|
const proposed = calls.find(([path, init]) => path === '/memory' && init && init.method === 'POST');
|
|
assert.equal(proposed[1].headers['Idempotency-Key'], 'webui:memory:0002');
|
|
assert.deepEqual(proposed[1].body, {kind: 'instruction', content: 'Remember token=[redacted]',
|
|
reason: 'Because password=[redacted]', approval_mode: 'ask', conversation_id: CONVERSATION,
|
|
scope: {level: 'conversation', scope_id: CONVERSATION}});
|
|
|
|
const edit = descendants(panel).find((node) => node.tagName === 'FORM' &&
|
|
node.getAttribute('class') === 'hux-wave-a__edit');
|
|
descendants(edit).find((node) => node.tagName === 'TEXTAREA').value = 'Corrected secret=value';
|
|
edit.trigger('submit'); await flush();
|
|
const edited = calls.find(([path]) => path.endsWith('/edit'));
|
|
assert.equal(edited[1].headers['If-Match'], '2');
|
|
assert.deepEqual(edited[1].body, {content: 'Corrected secret=[redacted]'});
|
|
});
|
|
|
|
test('renderer failures stay generic, conflict is actionable, and helper branches are bounded', async () => {
|
|
assert.equal(runtimeApi.friendlyKind('run.completed'), 'Run completed');
|
|
assert.equal(runtimeApi.friendlyKind('new.kind'), 'new kind');
|
|
assert.deepEqual(runtimeApi.memoryActions({status: 'proposed'}), [['approve', 'Approve'], ['reject', 'Reject']]);
|
|
assert.deepEqual(runtimeApi.memoryActions({status: 'active', retrievable: false})[0], ['restore_retrieval', 'Use in replies']);
|
|
assert.deepEqual(runtimeApi.memoryActions({status: 'expired'}), [['forget', 'Forget']]);
|
|
assert.deepEqual(runtimeApi.memoryActions({status: 'forgotten'}), []);
|
|
assert.throws(() => runtimeApi.activityExtension({document: {}, conversationId: 'bad'}), /conversation/);
|
|
assert.throws(() => runtimeApi.memoryExtension({document: {}, conversationId: CONVERSATION}), /idempotency/);
|
|
assert.throws(() => runtimeApi.createWaveARuntime({expectedIdentity: IDENTITY,
|
|
fetcher: async () => response(404, {}), conversationId: CONVERSATION}), /document/);
|
|
|
|
const doc = {createElement: (tag) => new FakeNode(tag)};
|
|
const activityPanel = new FakeNode('section');
|
|
runtimeApi.activityExtension({document: doc, conversationId: CONVERSATION}).render({container: activityPanel,
|
|
client: {identity: IDENTITY, request: async () => { throw new Error('secret raw failure'); }}});
|
|
await flush();
|
|
assert(byText(activityPanel, 'Activity could not be verified. No event details were shown.'));
|
|
|
|
const panel = new FakeNode('section');
|
|
let status = 409;
|
|
const client = {identity: IDENTITY, request: async (path) => {
|
|
if (path === '/memory') return {body: {items: [memory({status: 'proposed', content: 'pending',
|
|
retrievable: false})], next: null}};
|
|
const error = new Error('raw secret'); error.status = status; throw error;
|
|
}};
|
|
runtimeApi.memoryExtension({document: doc, conversationId: CONVERSATION,
|
|
idempotencyKey: () => 'webui:memory:0003'}).render({container: panel, client});
|
|
await flush(); await byText(panel, 'Approve').click(); await flush();
|
|
assert(byText(panel, 'This memory changed. Refresh before trying again.'));
|
|
|
|
status = 500;
|
|
runtimeApi.memoryExtension({document: doc, conversationId: CONVERSATION,
|
|
idempotencyKey: () => 'bad'}).render({container: panel, client});
|
|
await flush();
|
|
const proposal = descendants(panel).find((node) => node.getAttribute('class') === 'hux-wave-a__propose');
|
|
descendants(proposal).find((node) => node.tagName === 'TEXTAREA').value = 'safe';
|
|
descendants(proposal).find((node) => node.tagName === 'INPUT').value = 'reason';
|
|
await proposal.trigger('submit'); await flush();
|
|
assert(byText(panel, 'The memory proposal could not be completed.'));
|
|
});
|
|
|
|
test('mount rejects duplicate starts and default idempotency keys satisfy the backend contract', async () => {
|
|
const doc = {createElement: (tag) => new FakeNode(tag)};
|
|
const host = new FakeNode('main');
|
|
let generatedKey = null;
|
|
const runtime = runtimeApi.createWaveARuntime({document: doc, expectedIdentity: IDENTITY,
|
|
conversationId: CONVERSATION, fetcher: async (url, init) => {
|
|
if (url.endsWith('/capabilities')) return response(200, capability());
|
|
if (url.includes('/events?')) return response(200, {items: [], next: null});
|
|
if (url.endsWith('/memory') && (!init || init.method !== 'POST')) {
|
|
return response(200, {items: [], next: null});
|
|
}
|
|
generatedKey = init.headers['Idempotency-Key'];
|
|
return response(201, memory({status: 'proposed', content: 'pending', retrievable: false}));
|
|
}});
|
|
await runtime.mount(host); await flush();
|
|
const root = host.children[0];
|
|
descendants(root).find((node) => node.getAttribute('role') === 'tab' && node.textContent === 'Memory').click();
|
|
const proposal = descendants(root).find((node) => node.getAttribute('class') === 'hux-wave-a__propose');
|
|
descendants(proposal).find((node) => node.tagName === 'TEXTAREA').value = 'remember this';
|
|
descendants(proposal).find((node) => node.tagName === 'INPUT').value = 'requested by user';
|
|
await proposal.trigger('submit'); await flush();
|
|
assert.match(generatedKey, /^webui:memory:[a-z0-9]+:[a-z0-9]+$/);
|
|
await assert.rejects(() => runtime.mount(host), /already mounted/);
|
|
runtime.destroy();
|
|
runtime.destroy();
|
|
});
|
|
|
|
test('activity renderer handles empty, plural-evidence, refresh, and stale loads', async () => {
|
|
const doc = {createElement: (tag) => new FakeNode(tag)};
|
|
const panel = new FakeNode('section');
|
|
const queue = [];
|
|
const client = {identity: IDENTITY, request: () => new Promise((resolve, reject) => queue.push({resolve, reject}))};
|
|
runtimeApi.activityExtension({document: doc, conversationId: CONVERSATION}).render({client, container: panel});
|
|
queue[0].resolve({body: {items: [], next: null}}); await flush();
|
|
assert(byText(panel, 'No activity has been recorded yet.'));
|
|
const refresh = byText(panel, 'Refresh activity');
|
|
refresh.click();
|
|
refresh.click();
|
|
byText(panel, 'Loading activity…');
|
|
// Resolve an older request after a newer refresh. Its private response must be discarded.
|
|
queue[2].resolve({body: {items: [event({evidence: [
|
|
{kind: 'source', id: 'src_0001aaaa'}, {kind: 'passage', id: 'psg_0001aaaa'},
|
|
]}), event({id: 'evt_0002aaaa', seq: 2, evidence: []})], next: null}});
|
|
await flush();
|
|
assert(byText(panel, '2 evidence references'));
|
|
queue[1].resolve({body: {items: [event({summary: 'stale secret'})], next: null}});
|
|
await flush();
|
|
assert.equal(descendants(panel).some((node) => node.textContent === 'stale secret'), false);
|
|
});
|
|
|
|
test('memory renderer covers every status, empty states, refresh, action failure, and empty edits', async () => {
|
|
const doc = {createElement: (tag) => new FakeNode(tag)};
|
|
const panel = new FakeNode('section');
|
|
const items = [
|
|
memory({id: 'mem_proposed1', status: 'proposed', content: 'pending', retrievable: false}),
|
|
memory({id: 'mem_hidden001', retrievable: false}),
|
|
memory({id: 'mem_expired01', status: 'expired', content: '', retrievable: false}),
|
|
memory({id: 'mem_rejected1', status: 'rejected', content: '', retrievable: false}),
|
|
memory({id: 'mem_nostore01', status: 'no_store', content: '', retrievable: false,
|
|
scope: {level: 'global'}}),
|
|
];
|
|
let empty = false;
|
|
const calls = [];
|
|
const client = {identity: IDENTITY, request: async (path, init) => {
|
|
calls.push([path, init]);
|
|
if (path === '/memory') return {body: {items: empty ? [] : items, next: null}};
|
|
const error = new Error('private body'); error.status = 500; throw error;
|
|
}};
|
|
runtimeApi.memoryExtension({document: doc, conversationId: CONVERSATION,
|
|
idempotencyKey: () => 'webui:memory:0004'}).render({client, container: panel});
|
|
await flush();
|
|
assert(byText(panel, 'Reject'));
|
|
assert(byText(panel, 'Use in replies'));
|
|
assert.equal(descendants(panel).filter((node) => node.textContent === 'Forget').length, 2);
|
|
await byText(panel, 'Use in replies').click(); await flush();
|
|
assert(byText(panel, 'The memory change could not be completed.'));
|
|
|
|
runtimeApi.memoryExtension({document: doc, conversationId: CONVERSATION,
|
|
idempotencyKey: () => 'webui:memory:0004'}).render({client, container: panel});
|
|
await flush();
|
|
const edit = descendants(panel).find((node) => node.getAttribute('class') === 'hux-wave-a__edit');
|
|
edit.trigger('submit'); await flush();
|
|
assert.equal(calls.filter(([path]) => path.endsWith('/edit')).length, 0);
|
|
empty = true;
|
|
await byText(panel, 'Refresh memory').click(); await flush();
|
|
assert(byText(panel, 'No memory entries are available.'));
|
|
});
|
|
|
|
test('memory load and malformed proposal failures never reveal raw responses', async () => {
|
|
const doc = {createElement: (tag) => new FakeNode(tag)};
|
|
const panel = new FakeNode('section');
|
|
let calls = 0;
|
|
const client = {identity: IDENTITY, request: async () => { calls += 1; throw new Error('token=private'); }};
|
|
runtimeApi.memoryExtension({document: doc, conversationId: CONVERSATION,
|
|
idempotencyKey: () => 'webui:memory:0005'}).render({client, container: panel});
|
|
await flush();
|
|
assert(byText(panel, 'Memory could not be verified. No saved details were shown.'));
|
|
assert.equal(descendants(panel).some((node) => node.textContent.includes('token=private')), false);
|
|
assert.equal(calls, 1);
|
|
assert.throws(() => runtimeApi.memoryExtension({document: doc, conversationId: 'bad',
|
|
idempotencyKey: () => 'webui:memory:0005'}), /conversation/);
|
|
});
|