Download test/controller.test.js from webml-community/MiniCPM5-2B-WebGPU-Pi: direct link, hf CLI and curl.
- Browser
- Download file 19.5 kB
-
https://huggingface.co/spaces/webml-community/MiniCPM5-2B-WebGPU-Pi/resolve/main/test/controller.test.js
- Command line
-
hf download hf://spaces/webml-community/MiniCPM5-2B-WebGPU-Pi/test/controller.test.js
-
curl -L -o controller.test.js https://huggingface.co/spaces/webml-community/MiniCPM5-2B-WebGPU-Pi/resolve/main/test/controller.test.js
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); | |
| }); | |