MiniCPM5-2B-WebGPU-Pi / test /controller.test.js
Xenova's picture
Xenova HF Staff
Upload 65 files
ea7122c verified
Raw History Blame Contribute Delete
19.5 kB
import test from 'node:test';
import assert from 'node:assert/strict';
import { WorkspaceController } from '../src/controller.js';
import { ContextOverflowError, DeviceLostError } from '../runtime/minicpm5.js';
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
function answer(content = 'Done.', { calls, reasoning, finish = calls ? 'tool_calls' : 'stop' } = {}) {
return {
finish_reason: finish,
message: {
role: 'assistant', content,
...(reasoning ? { reasoning_content: reasoning } : {}),
...(calls ? { tool_calls: calls } : {}),
},
usage: { prompt_tokens: 80, cached_tokens: 40, completion_tokens: 12, reasoning_tokens: 3 },
timings: { ttft_ms: 15, decode_ms: 50, tokens_per_second: 220 },
};
}
function waitForAbort(signal) {
if (signal.aborted) return Promise.resolve();
return new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }));
}
function modelWithSteps(steps) {
const loss = deferred();
const disposed = deferred();
const requests = [];
const model = {
lost: loss.promise,
lose: loss.resolve,
disposed: disposed.promise,
disposeCount: 0,
sessionCount: 0,
sessionDisposeCount: 0,
contextLength: 4096,
requests,
deviceInfo: () => ({ vendor: 'test', architecture: 'mock-gpu' }),
dispose() { this.disposeCount += 1; disposed.resolve(); },
create_session() {
this.sessionCount += 1;
return {
dispose() { model.sessionDisposeCount += 1; },
generate(history, options) {
const step = steps[requests.length];
assert.ok(step, 'unexpected extra generation');
requests.push({ history, options });
const completion = deferred();
completion.promise.catch(() => {});
return {
result: completion.promise,
async *[Symbol.asyncIterator]() {
try {
for (const event of step.events ?? []) yield event;
if (step.waitForAbort) await waitForAbort(options.signal);
if (step.error) throw step.error;
const result = step.result ?? step;
completion.resolve(result);
} catch (error) {
completion.reject(error);
throw error;
}
},
};
},
};
},
};
return model;
}
function harness({ models = [], saved, checkAvailability, fromPretrained, tools = [], save } = {}) {
const events = [];
const snapshots = [];
const waiters = [];
const calls = { support: 0, load: 0 };
const files = {};
const workspace = {
tools,
snapshot: async () => ({ ...files }),
serialize: async () => Object.entries(files).map(([path, content]) => ({ path, content })),
write: async (path, content) => { files[path] = content; },
read: async path => files[path],
bash: { exec: async command => ({ stdout: command, stderr: '', exitCode: 0 }) },
};
const store = {
async save(snapshot) {
snapshots.push(structuredClone(snapshot));
if (save) await save(snapshot);
},
};
const Model = {
async checkAvailability(...args) {
calls.support += 1;
return checkAvailability ? checkAvailability(...args) : { ok: true };
},
async from_pretrained(...args) {
const index = calls.load++;
if (fromPretrained) return fromPretrained(index, ...args);
assert.ok(models[index], 'unexpected extra model load');
return models[index];
},
};
const controller = new WorkspaceController({ Model, workspace, store, saved, emit(event) {
// Match postMessage/IndexedDB boundaries; source objects continue changing.
events.push(structuredClone(event));
for (const waiter of [...waiters]) {
if (waiter.test(event)) {
waiters.splice(waiters.indexOf(waiter), 1);
waiter.resolve(event);
}
}
} });
const until = predicate => {
const existing = events.find(predicate);
if (existing) return Promise.resolve(existing);
return new Promise(resolve => waiters.push({ test: predicate, resolve }));
};
return { controller, events, snapshots, calls, files, until };
}
const agentEvents = events => events.filter(event => event.type === 'agent_event').map(event => event.event);
test('native agent replies and tools render in the existing terminal protocol and persist separately', async () => {
const call = { id: 'write-1', type: 'function', function: {
name: 'write', arguments: { path: '/workspace/result.txt', content: '42' },
} };
const first = answer('Writing the result.', { calls: [call], reasoning: 'I should save it.' });
const last = answer('Saved result.txt.');
const model = modelWithSteps([
{ result: first, events: [
{ type: 'prefill', done: 32, total: 64 },
{ type: 'reasoning', text: 'I should save it.' },
{ type: 'text', text: 'Writing the result.' },
] },
{ result: last, events: [{ type: 'text', text: 'Saved result.txt.' }] },
]);
const h = harness({ models: [model] });
h.controller.workspace.tools.push({
name: 'write', parameters: { type: 'object', properties: {
path: { type: 'string' }, content: { type: 'string' },
} },
async execute({ path, content }) {
await h.controller.workspace.write(path, content);
return { content: 'File saved.', details: { path } };
},
});
await h.controller.dispatch('load');
const ui = await h.controller.dispatch('prompt', { text: 'Save 42.' });
const native = h.snapshots.at(-1).messages;
assert.equal(h.files['/workspace/result.txt'], '42');
assert.deepEqual(native.map(message => message.role), ['user', 'assistant', 'tool', 'assistant']);
assert.deepEqual(native[1], first.message, 'canonical reasoning and tool calls must remain restorable');
assert.deepEqual(native[2], { role: 'tool', tool_call_id: 'write-1', name: 'write', content: 'File saved.' });
assert.deepEqual(ui.messages.map(message => message.role), ['user', 'assistant', 'toolResult', 'assistant']);
assert.deepEqual(ui.messages[1].content.map(block => block.type), ['thinking', 'text', 'toolCall']);
assert.equal(ui.messages[1].stopReason, 'toolUse');
assert.equal(ui.messages[2].content[0].text, 'File saved.');
assert.equal(ui.messages[3].content[0].text, 'Saved result.txt.');
assert.equal(ui.messages[3].usage.cacheRead, 40);
assert.equal(ui.messages[3].usage.output, 12);
assert.ok(native.every(message => !Object.hasOwn(message, 'timestamp')));
assert.ok(!native.some(message => message.role === 'system'), 'instructions are supplied fresh on restore');
assert.deepEqual(model.requests[1].history.slice(-2).map(message => message.role), ['assistant', 'tool']);
const start = agentEvents(h.events).find(event => event.type === 'tool_execution_start');
assert.equal(start.toolName, 'write');
assert.deepEqual(start.args, call.function.arguments);
const end = agentEvents(h.events).find(event => event.type === 'tool_execution_end');
assert.equal(end.result.content[0].text, 'File saved.');
assert.equal(end.isError, false);
assert.ok(h.events.some(event => event.type === 'inference_activity' && event.done === 32 && event.total === 64));
const metrics = h.events.filter(event => event.type === 'generation');
assert.equal(metrics.at(-1).tokensPerSecond, 220, 'show runtime token timing, not character throughput');
assert.equal(metrics.at(-1).outputTokens, 12);
assert.equal(h.events.find(event => event.type === 'loaded').contextLength, 4096);
assert.equal(agentEvents(h.events).at(-1).type, 'agent_end');
assert.equal(h.events.at(-1).type, 'idle');
});
test('restoring a saved chat sends native history to the model and display history to the UI', async () => {
const native = [
{ role: 'user', content: 'Earlier question' },
{ role: 'assistant', content: 'Earlier answer', reasoning_content: 'Earlier reasoning' },
];
const display = [
{ role: 'user', content: 'Earlier question', timestamp: 1 },
{ role: 'assistant', content: [{ type: 'text', text: 'Earlier answer' }], timestamp: 2, stopReason: 'stop' },
];
const model = modelWithSteps([answer('Follow-up answer')]);
const h = harness({ models: [model], saved: { messages: native, displayMessages: display } });
assert.deepEqual((await h.controller.uiSnapshot()).messages, display);
await h.controller.dispatch('load');
await h.controller.dispatch('prompt', { text: 'Follow-up' });
assert.deepEqual(model.requests[0].history.slice(1, 3), native);
assert.equal(model.requests[0].history.at(-1).content, 'Follow-up');
assert.equal(h.snapshots.at(-1).messages[1].reasoning_content, 'Earlier reasoning');
const exported = await h.controller.dispatch('snapshot');
assert.equal(exported.messages[1].content, 'Earlier answer');
assert.equal(exported.displayMessages[1].content[0].text, 'Earlier answer');
});
test('new chat clears model/display history and reuses the loaded model and session', async () => {
const model = modelWithSteps([answer('First answer'), answer('Fresh answer')]);
const h = harness({ models: [model] });
await h.controller.dispatch('load');
await h.controller.dispatch('prompt', { text: 'First question' });
const cleared = await h.controller.dispatch('new_chat');
assert.deepEqual(cleared.messages, []);
assert.deepEqual(h.snapshots.at(-1).messages, []);
await h.controller.dispatch('prompt', { text: 'Fresh question' });
assert.equal(h.calls.load, 1);
assert.equal(model.sessionCount, 1);
assert.deepEqual(model.requests[1].history.map(message => message.role), ['system', 'user']);
assert.equal(model.requests[1].history.at(-1).content, 'Fresh question');
});
test('stop settles generation, preserves its visible partial, and excludes it from future model history', async () => {
const partial = answer('Interrupted text', { finish: 'cancelled' });
const model = modelWithSteps([
{ result: partial, events: [{ type: 'text', text: 'Interrupted text' }], waitForAbort: true },
answer('Next answer'),
]);
const h = harness({ models: [model] });
await h.controller.dispatch('load');
const pending = h.controller.dispatch('prompt', { text: 'Interrupted question' });
await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
assert.equal(await h.controller.dispatch('stop'), true);
const ui = await pending;
assert.equal(ui.messages.at(-1).content[0].text, 'Interrupted text');
assert.equal(ui.messages.at(-1).stopReason, 'aborted');
assert.deepEqual(h.snapshots.at(-1).messages, []);
assert.ok(h.events.some(event => event.type === 'notice' && /omitted from the model history/.test(event.text)));
await h.controller.dispatch('prompt', { text: 'Next question' });
assert.deepEqual(model.requests[1].history.map(message => message.role), ['system', 'user']);
assert.equal(model.requests[1].history.at(-1).content, 'Next question');
assert.equal(h.controller.busy, false);
});
test('busy operations reject extra prompts, edits, resets, and export while stop remains available', async () => {
const model = modelWithSteps([{ result: answer('', { finish: 'cancelled' }), waitForAbort: true }]);
const h = harness({ models: [model] });
await h.controller.dispatch('load');
const pending = h.controller.dispatch('prompt', { text: 'Wait' });
await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
for (const action of ['prompt', 'write', 'new_chat', 'snapshot', 'load']) {
await assert.rejects(h.controller.dispatch(action, { text: 'Other', path: '/x', content: 'bad' }), /current operation/);
}
assert.deepEqual(h.files, {});
await h.controller.dispatch('stop');
await pending;
assert.equal(h.controller.busy, false);
assert.equal(h.controller.operation, null);
});
test('a failed model download can be retried and never leaves the controller busy', async () => {
const model = modelWithSteps([answer('Ready')]);
const h = harness({ fromPretrained: async index => {
if (index === 0) throw new Error('Download interrupted');
return model;
} });
await assert.rejects(h.controller.dispatch('load'), /Download interrupted/);
assert.equal(h.controller.busy, false);
assert.equal(h.controller.model, null);
assert.equal(h.controller.agent, null);
await h.controller.dispatch('load');
await h.controller.dispatch('prompt', { text: 'Hello' });
assert.equal(h.calls.load, 2);
assert.equal(h.events.filter(event => event.type === 'loaded').length, 1);
});
test('unsupported GPUs and cache-only loading fail before any weight download', async () => {
const unsupported = harness({ checkAvailability: async () => ({ ok: false, reason: 'Insufficient GPU limits' }) });
await assert.rejects(unsupported.controller.dispatch('load'), /Insufficient GPU limits/);
assert.equal(unsupported.calls.load, 0);
assert.equal(unsupported.controller.busy, false);
const cacheOnly = harness();
await assert.rejects(cacheOnly.controller.dispatch('load', { cachedOnly: true }), error => error.code === 'MODEL_CACHE_MISS');
assert.equal(cacheOnly.calls.support, 0);
assert.equal(cacheOnly.calls.load, 0);
assert.equal(await cacheOnly.controller.dispatch('cache_status'), false);
});
test('GPU loss releases the old session/model and allows a reload with the saved conversation', async () => {
const oldModel = modelWithSteps([answer('Before loss')]);
const newModel = modelWithSteps([answer('After reload')]);
const h = harness({ models: [oldModel, newModel] });
await h.controller.dispatch('load');
await h.controller.dispatch('prompt', { text: 'Remember this' });
oldModel.lose(new DeviceLostError('unknown', 'Adapter removed'));
await h.until(event => event.type === 'fatal' && /GPU device lost/.test(event.error));
await oldModel.disposed;
assert.equal(oldModel.disposeCount, 1);
assert.equal(oldModel.sessionDisposeCount, 1);
assert.equal(h.controller.model, null);
await h.controller.dispatch('load');
await h.controller.dispatch('prompt', { text: 'Continue' });
assert.equal(newModel.requests[0].history[1].content, 'Remember this');
assert.equal(newModel.requests[0].history[2].content, 'Before loss');
assert.equal(h.controller.deviceLoss, null);
assert.equal(h.calls.load, 2);
});
test('GPU loss during a reply ends the operation and retains the last completed conversation', async () => {
const deviceError = new DeviceLostError('unknown', 'Adapter disconnected');
const oldModel = modelWithSteps([
answer('Completed answer'),
{ error: deviceError, events: [{ type: 'text', text: 'Interrupted' }], waitForAbort: true },
]);
const newModel = modelWithSteps([answer('Recovered answer')]);
const h = harness({ models: [oldModel, newModel] });
await h.controller.dispatch('load');
await h.controller.dispatch('prompt', { text: 'Completed question' });
h.events.length = 0;
const pending = h.controller.dispatch('prompt', { text: 'Interrupted question' });
const rejected = assert.rejects(pending, error => error === deviceError);
await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
oldModel.lose(deviceError);
await rejected;
await oldModel.disposed;
assert.equal(h.controller.busy, false);
assert.equal(h.snapshots.at(-1).displayMessages.at(-1).stopReason, 'aborted');
assert.deepEqual(h.snapshots.at(-1).messages.map(message => message.content), ['Completed question', 'Completed answer']);
assert.equal(agentEvents(h.events).at(-1).type, 'agent_end');
await h.controller.dispatch('load');
await h.controller.dispatch('prompt', { text: 'Continue after loss' });
assert.deepEqual(newModel.requests[0].history.slice(1).map(message => message.content), [
'Completed question', 'Completed answer', 'Continue after loss',
]);
});
test('context overflow and generation failures end the visible reply without corrupting canonical history', async () => {
const overflow = new ContextOverflowError(9000, 4096);
const model = modelWithSteps([
{ error: overflow },
{ error: new Error('Shader execution failed'), events: [{ type: 'text', text: 'Partial' }] },
answer('Recovered'),
]);
const h = harness({ models: [model] });
await h.controller.dispatch('load');
await assert.rejects(h.controller.dispatch('prompt', { text: 'Too long' }), error => error === overflow);
assert.ok(h.events.some(event => event.type === 'notice' && /9000 tokens.*4096/.test(event.text)));
assert.equal(h.snapshots.at(-1).displayMessages.at(-1).stopReason, 'error');
assert.deepEqual(h.snapshots.at(-1).messages, []);
await assert.rejects(h.controller.dispatch('prompt', { text: 'Fails' }), /Shader execution failed/);
const visible = h.snapshots.at(-1).displayMessages.at(-1);
assert.equal(visible.stopReason, 'error');
assert.equal(visible.content[0].text, 'Partial');
assert.equal(visible.errorMessage, 'Shader execution failed');
assert.deepEqual(h.snapshots.at(-1).messages, []);
await h.controller.dispatch('prompt', { text: 'Try again' });
assert.deepEqual(model.requests[2].history.map(message => message.role), ['system', 'user']);
assert.equal(h.snapshots.at(-1).messages.at(-1).content, 'Recovered');
assert.equal(h.controller.busy, false);
});
test('load progress distinguishes byte download counts from tensors and compilation', async () => {
const model = modelWithSteps([]);
const h = harness({ fromPretrained: async (_index, _id, options) => {
options.onProgress({ status: 'init', message: 'Requesting GPU' });
options.onProgress({ status: 'weights', kind: 'bytes', loaded: 512, total: 1024, fraction: 0.5 });
options.onProgress({ status: 'weights', kind: 'tensors', loaded: 10, total: 20, message: 'Uploading' });
options.onProgress({ status: 'weights', kind: 'tensors', message: 'Compiling kernels' });
options.onProgress({ status: 'ready', fraction: 1, message: 'Ready' });
return model;
} });
await h.controller.dispatch('load');
const progress = h.events.filter(event => event.type === 'load_progress');
assert.deepEqual(progress.filter(event => event.phase === 'download'), [
{ type: 'load_progress', phase: 'download', loaded: 512, total: 1024 },
]);
assert.equal(progress.find(event => event.message === 'Uploading').phase, 'compile');
assert.equal(progress.find(event => event.message === 'Compiling kernels').phase, 'compile');
assert.equal(h.events.filter(event => event.type === 'loaded').length, 1);
assert.ok(h.events.findIndex(event => event.type === 'loaded') > h.events.findIndex(event => event.message === 'Compiling kernels'));
});
test('a persistence failure keeps the answer usable and reports that changes remain in memory', async () => {
const model = modelWithSteps([answer('Still available')]);
const h = harness({ models: [model], save: async () => { throw new Error('Storage quota exceeded'); } });
await h.controller.dispatch('load');
const ui = await h.controller.dispatch('prompt', { text: 'Hello' });
assert.equal(ui.messages.at(-1).content[0].text, 'Still available');
assert.ok(h.events.some(event => event.type === 'persistence_error' && /in memory.*Storage quota/.test(event.error)));
assert.equal(h.controller.history.at(-1).content, 'Still available');
assert.equal(h.controller.busy, false);
});