scripts / verify-tool-host.cjs
scripts / verify-tool-host.cjs
// A temporary development generator and local SSE server exercise the real LM
// Studio tool loop. Dummy tool implementations record arguments; no website is fetched.
const assert = require('node:assert/strict');
const http = require('node:http');
const { readFileSync, mkdirSync, writeFileSync } = require('node:fs');
const { spawn } = require('node:child_process');
const { homedir } = require('node:os');
const { join } = require('node:path');
const { LMStudioClient, tool } = require('@lmstudio/sdk');
const { z } = require('zod');
const { generate } = require('../dist/generator');
const { configSchematics, globalConfigSchematics } = require('../dist/config');
(async () => {
const calls = [], requests = [];
const xml = readFileSync(join(__dirname, '../test/fixtures/reported-web-tools.txt'), 'utf8');
let serverError;
const uiMode = process.argv.includes('--ui');
let uiResolve;
const uiCompleted = new Promise(resolve => { uiResolve = resolve; });
const server = http.createServer(async (req, res) => {
try {
let content = '';
for await (const data of req) content += data;
const body = JSON.parse(content); requests.push(body);
if (req.url.endsWith('/record/visit') || req.url.endsWith('/record/search')) {
requests.pop();
calls.push({ name: req.url.endsWith('/visit') ? 'visit' : 'search', args: body });
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}'); return;
}
assert.ok(body.tools.some(tool => tool.function.name === 'Visit_Website_Advanced'), 'Missing visit alias: ' + body.tools.map(tool => tool.function.name).join(', '));
assert.ok(body.tools.some(tool => tool.function.name === 'Web_Search_Advanced'), 'Missing search alias: ' + body.tools.map(tool => tool.function.name).join(', '));
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
if (requests.length > 2) throw new Error('Unexpected extra tool loop');
if (requests.length === 1) {
for (let i = 0; i < xml.length; i += 7) res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: xml.slice(i, i + 7) } }] })}\n\n`);
} else {
assert.equal(body.messages.filter(message => message.role === 'tool').length, 2);
const history = body.messages.flatMap(message => message.tool_calls ?? []);
assert.deepEqual(history.map(call => call.function.name), ['Visit_Website_Advanced', 'Web_Search_Advanced']);
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'Both test tools completed.' } }] })}\n\n`);
}
res.end(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`);
if (uiMode && requests.length === 2) uiResolve();
} catch (error) { serverError = error; console.error(error.message); res.destroy(error); uiResolve(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`;
const client = new LMStudioClient();
let child;
try {
// Development registration is reserved for the official CLI. Use its normal
// dev workflow rather than requesting privileged SDK registration directly.
const directory = join(__dirname, '../.host-test-plugin');
mkdirSync(join(directory, 'src'), { recursive: true });
writeFileSync(join(directory, 'manifest.json'), JSON.stringify({ type: 'plugin', runner: 'node', owner: 'local', name: 'strata-tool-host-test', revision: 1 }));
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'strata-tool-host-test', version: '0.0.0', main: 'src/index.ts', dependencies: { '@lmstudio/sdk': '1.5.0', zod: '3.24.1', jsonschema: '1.5.0' } }));
writeFileSync(join(directory, 'src/index.ts'), `import { tool } from '@lmstudio/sdk';
import { z } from 'zod';
import { generate } from '../../src/generator';
import { configSchematics, globalConfigSchematics } from '../../src/config';
export async function main(context: any) {
context.withConfigSchematics(configSchematics).withGlobalConfigSchematics(globalConfigSchematics);
context.withToolsProvider(async () => [
tool({name:'Visit Website Advanced',parameters:{url:z.string().url(),contentLimit:z.number().int().max(10000).optional(),maxLinks:z.number().int().max(200).optional(),maxImages:z.number().int().max(200).optional()},implementation:async(args:any)=>(await fetch(${JSON.stringify(baseUrl + '/record/visit')},{method:'POST',body:JSON.stringify(args)})).json()}),
tool({name:'Web Search Advanced',parameters:{query:z.string(),pageSize:z.number().int().max(10).optional()},implementation:async(args:any)=>(await fetch(${JSON.stringify(baseUrl + '/record/search')},{method:'POST',body:JSON.stringify(args)})).json()})
]);
context.withGenerator((ctl: any, history: any) => generate(new Proxy(ctl, { get(target, key) {
if (key === 'getGlobalPluginConfig') return () => ({ get: (field: string) => field === 'baseUrl' ? ${JSON.stringify(baseUrl)} : '' });
const value = Reflect.get(target, key); return typeof value === 'function' ? value.bind(target) : value;
} }), history));
console.info('STRATA_HOST_TEST_READY');
}`);
child = spawn(join(homedir(), '.lmstudio/bin/lms.exe'), ['dev', '--no-notify'], { cwd: directory, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Dev plugin startup timed out')), 25000);
const inspect = data => {
const text = data.toString();
if (text.includes('STRATA_HOST_TEST_READY')) { clearTimeout(timeout); resolve(); }
if (/error|failed/i.test(text)) process.stderr.write(text);
};
child.stdout.on('data', inspect); child.stderr.on('data', inspect);
child.on('error', reject);
child.on('exit', code => { clearTimeout(timeout); reject(new Error('Dev CLI exited: ' + code)); });
});
const tools = [
tool({ name: 'Visit Website Advanced', parameters: { url: z.string().url(), contentLimit: z.number().int().min(0).max(10000).optional(), maxLinks: z.number().int().min(0).max(200).optional(), maxImages: z.number().int().min(0).max(200).optional() }, implementation: async args => { calls.push({ name: 'visit', args }); return { ok: true }; } }),
tool({ name: 'Web Search Advanced', parameters: { query: z.string(), pageSize: z.number().int().min(1).max(10).optional() }, implementation: async args => { calls.push({ name: 'search', args }); return { ok: true }; } }),
];
if (uiMode) {
console.log('READY: select local/strata-tool-host-test in a fresh LM Studio chat and send test.');
let timeout;
try { await Promise.race([uiCompleted,new Promise((_,reject)=>{timeout=setTimeout(()=>reject(new Error('UI test timed out')),600000);})]); }
finally { clearTimeout(timeout); }
} else await client.plugins.createGeneratorHandle('local/strata-tool-host-test').act('Run both test tools.', tools, { signal: AbortSignal.timeout(30000) });
if (serverError) throw serverError;
assert.deepEqual(calls, [
{ name: 'visit', args: { url: 'https://hoju.jp/rika-nagao', contentLimit: 8000, maxLinks: 60, maxImages: 30 } },
{ name: 'search', args: { query: 'hoju.jp 長尾里香 風船 人形', pageSize: 10 } },
]);
assert.equal(requests.length, 2);
console.log('PASS: real LM Studio executed both dummy tools, returned both results, and completed the next generation. No website requests were made.');
} finally {
if (child) child.kill();
await new Promise(resolve => server.close(resolve));
}
})().then(() => process.exit(0), error => { console.error(error.stack || error); process.exit(1); });
// A temporary development generator and local SSE server exercise the real LM
// Studio tool loop. Dummy tool implementations record arguments; no website is fetched.
const assert = require('node:assert/strict');
const http = require('node:http');
const { readFileSync, mkdirSync, writeFileSync } = require('node:fs');
const { spawn } = require('node:child_process');
const { homedir } = require('node:os');
const { join } = require('node:path');
const { LMStudioClient, tool } = require('@lmstudio/sdk');
const { z } = require('zod');
const { generate } = require('../dist/generator');
const { configSchematics, globalConfigSchematics } = require('../dist/config');
(async () => {
const calls = [], requests = [];
const xml = readFileSync(join(__dirname, '../test/fixtures/reported-web-tools.txt'), 'utf8');
let serverError;
const uiMode = process.argv.includes('--ui');
let uiResolve;
const uiCompleted = new Promise(resolve => { uiResolve = resolve; });
const server = http.createServer(async (req, res) => {
try {
let content = '';
for await (const data of req) content += data;
const body = JSON.parse(content); requests.push(body);
if (req.url.endsWith('/record/visit') || req.url.endsWith('/record/search')) {
requests.pop();
calls.push({ name: req.url.endsWith('/visit') ? 'visit' : 'search', args: body });
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}'); return;
}
assert.ok(body.tools.some(tool => tool.function.name === 'Visit_Website_Advanced'), 'Missing visit alias: ' + body.tools.map(tool => tool.function.name).join(', '));
assert.ok(body.tools.some(tool => tool.function.name === 'Web_Search_Advanced'), 'Missing search alias: ' + body.tools.map(tool => tool.function.name).join(', '));
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
if (requests.length > 2) throw new Error('Unexpected extra tool loop');
if (requests.length === 1) {
for (let i = 0; i < xml.length; i += 7) res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: xml.slice(i, i + 7) } }] })}\n\n`);
} else {
assert.equal(body.messages.filter(message => message.role === 'tool').length, 2);
const history = body.messages.flatMap(message => message.tool_calls ?? []);
assert.deepEqual(history.map(call => call.function.name), ['Visit_Website_Advanced', 'Web_Search_Advanced']);
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'Both test tools completed.' } }] })}\n\n`);
}
res.end(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`);
if (uiMode && requests.length === 2) uiResolve();
} catch (error) { serverError = error; console.error(error.message); res.destroy(error); uiResolve(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`;
const client = new LMStudioClient();
let child;
try {
// Development registration is reserved for the official CLI. Use its normal
// dev workflow rather than requesting privileged SDK registration directly.
const directory = join(__dirname, '../.host-test-plugin');
mkdirSync(join(directory, 'src'), { recursive: true });
writeFileSync(join(directory, 'manifest.json'), JSON.stringify({ type: 'plugin', runner: 'node', owner: 'local', name: 'strata-tool-host-test', revision: 1 }));
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'strata-tool-host-test', version: '0.0.0', main: 'src/index.ts', dependencies: { '@lmstudio/sdk': '1.5.0', zod: '3.24.1', jsonschema: '1.5.0' } }));
writeFileSync(join(directory, 'src/index.ts'), `import { tool } from '@lmstudio/sdk';
import { z } from 'zod';
import { generate } from '../../src/generator';
import { configSchematics, globalConfigSchematics } from '../../src/config';
export async function main(context: any) {
context.withConfigSchematics(configSchematics).withGlobalConfigSchematics(globalConfigSchematics);
context.withToolsProvider(async () => [
tool({name:'Visit Website Advanced',parameters:{url:z.string().url(),contentLimit:z.number().int().max(10000).optional(),maxLinks:z.number().int().max(200).optional(),maxImages:z.number().int().max(200).optional()},implementation:async(args:any)=>(await fetch(${JSON.stringify(baseUrl + '/record/visit')},{method:'POST',body:JSON.stringify(args)})).json()}),
tool({name:'Web Search Advanced',parameters:{query:z.string(),pageSize:z.number().int().max(10).optional()},implementation:async(args:any)=>(await fetch(${JSON.stringify(baseUrl + '/record/search')},{method:'POST',body:JSON.stringify(args)})).json()})
]);
context.withGenerator((ctl: any, history: any) => generate(new Proxy(ctl, { get(target, key) {
if (key === 'getGlobalPluginConfig') return () => ({ get: (field: string) => field === 'baseUrl' ? ${JSON.stringify(baseUrl)} : '' });
const value = Reflect.get(target, key); return typeof value === 'function' ? value.bind(target) : value;
} }), history));
console.info('STRATA_HOST_TEST_READY');
}`);
child = spawn(join(homedir(), '.lmstudio/bin/lms.exe'), ['dev', '--no-notify'], { cwd: directory, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Dev plugin startup timed out')), 25000);
const inspect = data => {
const text = data.toString();
if (text.includes('STRATA_HOST_TEST_READY')) { clearTimeout(timeout); resolve(); }
if (/error|failed/i.test(text)) process.stderr.write(text);
};
child.stdout.on('data', inspect); child.stderr.on('data', inspect);
child.on('error', reject);
child.on('exit', code => { clearTimeout(timeout); reject(new Error('Dev CLI exited: ' + code)); });
});
const tools = [
tool({ name: 'Visit Website Advanced', parameters: { url: z.string().url(), contentLimit: z.number().int().min(0).max(10000).optional(), maxLinks: z.number().int().min(0).max(200).optional(), maxImages: z.number().int().min(0).max(200).optional() }, implementation: async args => { calls.push({ name: 'visit', args }); return { ok: true }; } }),
tool({ name: 'Web Search Advanced', parameters: { query: z.string(), pageSize: z.number().int().min(1).max(10).optional() }, implementation: async args => { calls.push({ name: 'search', args }); return { ok: true }; } }),
];
if (uiMode) {
console.log('READY: select local/strata-tool-host-test in a fresh LM Studio chat and send test.');
let timeout;
try { await Promise.race([uiCompleted,new Promise((_,reject)=>{timeout=setTimeout(()=>reject(new Error('UI test timed out')),600000);})]); }
finally { clearTimeout(timeout); }
} else await client.plugins.createGeneratorHandle('local/strata-tool-host-test').act('Run both test tools.', tools, { signal: AbortSignal.timeout(30000) });
if (serverError) throw serverError;
assert.deepEqual(calls, [
{ name: 'visit', args: { url: 'https://hoju.jp/rika-nagao', contentLimit: 8000, maxLinks: 60, maxImages: 30 } },
{ name: 'search', args: { query: 'hoju.jp 長尾里香 風船 人形', pageSize: 10 } },
]);
assert.equal(requests.length, 2);
console.log('PASS: real LM Studio executed both dummy tools, returned both results, and completed the next generation. No website requests were made.');
} finally {
if (child) child.kill();
await new Promise(resolve => server.close(resolve));
}
})().then(() => process.exit(0), error => { console.error(error.stack || error); process.exit(1); });