878 lines
30 KiB
JavaScript
878 lines
30 KiB
JavaScript
/**
|
|
* Bare-only host checks (path jail, pack register, agent loop).
|
|
* Run: node ./node_modules/bare/bin/bare ./test-bare.js
|
|
*/
|
|
require('bare-process/global');
|
|
const path = require('bare-path');
|
|
const fs = require('bare-fs');
|
|
const os = require('bare-os');
|
|
|
|
function ok(cond, msg) {
|
|
if (!cond) throw new Error(msg || 'assertion failed');
|
|
}
|
|
|
|
function throws(fn, re) {
|
|
let hit = false;
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
hit = true;
|
|
if (re && !re.test(String(err.message || err))) {
|
|
throw new Error('threw unexpected: ' + err.message);
|
|
}
|
|
}
|
|
if (!hit) throw new Error('expected throw');
|
|
}
|
|
|
|
function tmpDir(prefix) {
|
|
const base = (os.tmpdir && os.tmpdir()) || '/tmp';
|
|
const dir = path.join(base, prefix + Date.now() + '_' + Math.random().toString(36).slice(2, 8));
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function testPackRegister() {
|
|
const registry = require('./capabilities/registry.js');
|
|
const qvac = require('./capabilities/qvac.js');
|
|
const agent = require('./capabilities/agent.js');
|
|
const q = qvac.createQvacPack();
|
|
const a = agent.createAgentPack();
|
|
ok(q.id === 'qvac', 'qvac id');
|
|
ok(a.id === 'agent', 'agent id');
|
|
ok(typeof q.commands.load === 'function', 'qvac.load');
|
|
ok(typeof q.commands.audioGen === 'function', 'qvac.audioGen');
|
|
ok(typeof q.commands.pushDesktopFrame === 'function', 'qvac.pushDesktopFrame');
|
|
ok(typeof a.commands.prompt === 'function', 'agent.prompt');
|
|
registry.registerPack(q);
|
|
registry.registerPack(a);
|
|
const discord = require('./capabilities/discord.js');
|
|
const d = discord.createDiscordPack();
|
|
ok(d.id === 'discord', 'discord id');
|
|
ok(typeof d.commands.surface === 'function', 'discord.surface');
|
|
ok(typeof d.commands.construct === 'function', 'discord.construct');
|
|
registry.registerPack(d);
|
|
ok(registry.hasPack('qvac'), 'has qvac');
|
|
ok(registry.hasPack('agent'), 'has agent');
|
|
ok(registry.hasPack('discord'), 'has discord');
|
|
const engine = require('./qvac/engine.js');
|
|
ok(engine.publicStatus().enabled === false, 'qvac off by default');
|
|
ok(engine.publicStatus().available === false, 'unavailable when disabled');
|
|
}
|
|
|
|
function testAudiogenHelpers() {
|
|
const ag = require('./qvac/audiogen.js');
|
|
const wav = require('./qvac/wav.js');
|
|
ok(ag.isAudiogenLoad({ modelType: 'audiogen' }), 'audiogen load');
|
|
ok(ag.isAudiogenLoad({ model: 'acestep-turbo-q4' }), 'acestep id');
|
|
ok(ag.engineOf({ model: 'minimax-music3' }) === 'minimax', 'minimax engine');
|
|
const stripped = ag.stripMiniMaxParams({ caption: 'x', bpm: 120, lyrics: '[Instrumental]' });
|
|
ok(stripped.rejected.indexOf('bpm') >= 0, 'bpm rejected');
|
|
ok(stripped.params.lyrics === '[Instrumental]', 'lyrics kept');
|
|
let threw = false;
|
|
try {
|
|
ag.genParams({ caption: 'x', bpm: 98 }, 'minimax');
|
|
} catch (err) {
|
|
threw = /ACE-Step/.test(err.message);
|
|
}
|
|
ok(threw, 'minimax gen rejects ACE fields');
|
|
const pcm = new Uint8Array(16);
|
|
const blob = wav.createWav(pcm, 48000, 2, 16);
|
|
ok(blob.byteLength === 60, 'wav size');
|
|
ok(String.fromCharCode(blob[0], blob[1], blob[2], blob[3]) === 'RIFF', 'riff');
|
|
const sdk = ag.pickKeys(
|
|
{ engine: 'acestep', useGPU: true, ditModelSrc: 'DIT', caption: 'x', lmTemperature: 0.9 },
|
|
ag.SDK_LOAD_KEYS
|
|
);
|
|
ok(sdk.engine === undefined, 'pickKeys drops engine');
|
|
ok(sdk.useGPU === true, 'pickKeys keeps useGPU');
|
|
ok(sdk.lmTemperature === undefined, 'pickKeys drops gen extras from load keys');
|
|
return ag
|
|
.buildLoadOpts({ model: 'acestep-turbo-q4', modelType: 'audiogen', engine: 'acestep', useGPU: true }, async (n) => n)
|
|
.then((built) => {
|
|
ok(built.engine === 'acestep', 'host engine acestep');
|
|
ok(built.loadOpts.modelConfig.engine === undefined, 'SDK modelConfig has no engine');
|
|
ok(built.loadOpts.modelConfig.useGPU === true, 'useGPU forwarded');
|
|
ok(built.loadOpts.modelConfig.ditModelSrc === ag.DIT['turbo-q4'], 'dit from catalog variant');
|
|
ok(built.loadOpts.modelConfig.textEncModelSrc, 'textEnc set');
|
|
ok(built.loadOpts.modelType === 'audiogen', 'modelType audiogen');
|
|
return ag.buildLoadOpts({ model: 'minimax-music3', engine: 'minimax' }, async (n) => n).then(
|
|
() => {
|
|
throw new Error('minimax load should throw');
|
|
},
|
|
(err) => {
|
|
ok(/ACE-Step/.test(err.message), 'minimax load rejected');
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function testPathJail() {
|
|
const tmp = tmpDir('bs-jail-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const paths = require('./capabilities/paths.js');
|
|
const jail = path.join(tmp, 'workspace');
|
|
fs.mkdirSync(jail, { recursive: true });
|
|
fs.writeFileSync(path.join(jail, 'ok.txt'), 'in');
|
|
const inside = paths.resolveUnderRoot(jail, 'ok.txt');
|
|
ok(inside.indexOf(jail) === 0, 'inside jail');
|
|
throws(() => paths.resolveUnderRoot(jail, '../etc/passwd'), /escapes/);
|
|
throws(() => paths.resolveUnderRoot(jail, '/etc/passwd'), /escapes/);
|
|
ok(paths.isPathInside(jail, path.join(jail, 'a', 'b')), 'nested inside');
|
|
ok(!paths.isPathInside(jail, path.join(tmp, 'outside')), 'outside rejected');
|
|
const sandbox = require('./agent/sandbox.js');
|
|
sandbox.setGrants({ roots: [jail] });
|
|
throws(() => sandbox.resolvePath('http://127.0.0.1:4173', '/etc/passwd', jail), /escapes/);
|
|
}
|
|
|
|
async function testAgentLoopFakeComplete() {
|
|
const tmp = tmpDir('bs-agent-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const origComplete = engine.complete;
|
|
const origLoad = engine.load;
|
|
const origGet = engine.getLoaded;
|
|
engine.getLoaded = () => ({
|
|
modelId: 'fake',
|
|
friendlyId: 'qwen3.5-4b',
|
|
constant: 'QWEN3_5_4B_MULTIMODAL_Q4_K_M',
|
|
ctxSize: 8192,
|
|
});
|
|
engine.load = async () => engine.getLoaded();
|
|
let n = 0;
|
|
engine.complete = async (opts, onEvent) => {
|
|
n += 1;
|
|
if (n === 1) {
|
|
const call = { id: 'c1', name: 'list_dir', arguments: { path: '.' } };
|
|
if (onEvent) onEvent({ type: 'toolCall', call });
|
|
return { text: '', toolCalls: [call] };
|
|
}
|
|
if (onEvent) onEvent({ type: 'contentDelta', delta: 'listed' });
|
|
return { text: 'listed', toolCalls: [] };
|
|
};
|
|
const cwd = path.join(tmp, 'agent', 'ws');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, 'hello.txt'), 'hi');
|
|
const sandbox = require('./agent/sandbox.js');
|
|
sandbox.setGrants({ roots: [cwd] });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'test',
|
|
});
|
|
const chunks = [];
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'list files',
|
|
jobId: 'job_test',
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit(type, p) {
|
|
chunks.push({ type, kind: p && p.kind, evType: p && p.type });
|
|
},
|
|
});
|
|
engine.complete = origComplete;
|
|
engine.load = origLoad;
|
|
engine.getLoaded = origGet;
|
|
ok(result.ok, 'loop ok');
|
|
ok(n >= 2, 'two completions');
|
|
ok(
|
|
chunks.some((c) => c.evType === 'tool_call' || c.evType === 'tool_result' || c.kind === 'session_update'),
|
|
'streamed updates'
|
|
);
|
|
}
|
|
|
|
testPackRegister();
|
|
testPathJail();
|
|
testAudiogenHelpers()
|
|
.then(() => testAgentLoopFakeComplete())
|
|
.then(() => testCustomToolLoop())
|
|
.then(() => testHostWorkspaceOffLoop())
|
|
.then(() => testPlanWriteGateLoop())
|
|
.then(() => testAskUserLoop())
|
|
.then(() => testGoalPrematureLoop())
|
|
.then(() => testStationarityLoop())
|
|
.then(() => testSessionDestroyLoop())
|
|
.then(() => testParallelWritesLoop())
|
|
.then(() => testSamePathWriteOrderLoop())
|
|
.then(() => testGrepGlobLoop())
|
|
.then(() => testBareVersionsCoerce())
|
|
.then(() => testQvacOffByDefault())
|
|
.then(() => testDiscordPack())
|
|
.then(() => {
|
|
console.log('ok — bare host checks passed');
|
|
})
|
|
.catch((err) => {
|
|
console.error(err && err.stack ? err.stack : err);
|
|
process.exitCode = 1;
|
|
});
|
|
|
|
async function testBareVersionsCoerce() {
|
|
const origDesc = Object.getOwnPropertyDescriptor(Bare, 'versions');
|
|
const orig = Bare.versions && Bare.versions.bare;
|
|
const patch = require('./qvac/semver-patch.js');
|
|
try {
|
|
Bare.versions.bare = '^1.30.3';
|
|
const engine = require('./qvac/engine.js');
|
|
engine.normalizeBareVersions();
|
|
ok(/^\d+\.\d+\.\d+/.test(String(Bare.versions.bare)), 'coerced caret range to concrete version');
|
|
ok(Bare.versions.bare !== '^1.30.3', 'no longer a range');
|
|
|
|
const { satisfies } = require('bare-semver');
|
|
ok(satisfies('^1.30.3', '^1.30.3') === true, 'satisfies coerces caret runtime version');
|
|
|
|
Object.defineProperty(Bare, 'versions', {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() {
|
|
return { bare: '^1.30.3' };
|
|
},
|
|
});
|
|
patch.normalizeBareVersions();
|
|
ok(Bare.versions.bare !== '^1.30.3', 'replaced getter Bare.versions with concrete version');
|
|
} finally {
|
|
if (origDesc) Object.defineProperty(Bare, 'versions', origDesc);
|
|
else if (orig) Bare.versions.bare = orig;
|
|
}
|
|
}
|
|
|
|
async function testQvacOffByDefault() {
|
|
const qvac = require('./capabilities/qvac.js');
|
|
const agent = require('./capabilities/agent.js');
|
|
const q = qvac.createQvacPack();
|
|
const a = agent.createAgentPack();
|
|
let qReply = null;
|
|
await q.commands.load({ payload: {}, reply(r) { qReply = r; }, emit() {} });
|
|
ok(qReply && qReply.ok === false && /disabled/i.test(qReply.error), 'qvac.load refused when disabled');
|
|
let frameReply = null;
|
|
await q.commands.pushDesktopFrame({
|
|
payload: { dataUrl: 'data:image/png;base64,AA==' },
|
|
reply(r) { frameReply = r; },
|
|
emit() {},
|
|
});
|
|
ok(frameReply && frameReply.ok === false && /disabled/i.test(frameReply.error), 'qvac.pushDesktopFrame refused when disabled');
|
|
let aReply = null;
|
|
await a.commands.create({
|
|
payload: { _origin: 'http://127.0.0.1:4173' },
|
|
reply(r) { aReply = r; },
|
|
emit() {},
|
|
});
|
|
ok(aReply && aReply.ok === false && /disabled/i.test(aReply.error), 'agent.create refused when disabled');
|
|
let st = null;
|
|
await q.commands.status({ payload: {}, reply(r) { st = r; }, emit() {} });
|
|
ok(st && st.enabled === false, 'status reports disabled');
|
|
ok(st && st.available === false, 'status available false');
|
|
}
|
|
|
|
async function testDiscordPack() {
|
|
require('./discord/ws-bootstrap.cjs');
|
|
ok(typeof AbortController === 'function', 'AbortController after discord bootstrap');
|
|
ok(typeof fetch === 'function' || typeof globalThis.fetch === 'function', 'fetch after discord bootstrap');
|
|
|
|
const discord = require('./capabilities/discord.js');
|
|
const pack = discord.createDiscordPack();
|
|
let denied = null;
|
|
await pack.commands.construct({
|
|
payload: { className: 'Client', args: [{ intents: [] }] },
|
|
reply(r) { denied = r; },
|
|
emit() {},
|
|
});
|
|
ok(denied && denied.ok === false && /disabled/i.test(denied.error), 'construct refused when disabled');
|
|
|
|
let en = null;
|
|
await pack.commands.setEnabled({
|
|
payload: { enabled: true },
|
|
reply(r) { en = r; },
|
|
emit() {},
|
|
});
|
|
ok(en && en.ok && en.enabled === true, 'setEnabled on');
|
|
|
|
let surface = null;
|
|
await pack.commands.surface({
|
|
payload: {},
|
|
reply(r) { surface = r; },
|
|
emit() {},
|
|
});
|
|
ok(surface && surface.ok, 'surface ok: ' + (surface && surface.error));
|
|
ok(surface.surface && surface.surface.GatewayIntentBits, 'GatewayIntentBits present');
|
|
ok(surface.surface._classes && surface.surface._classes.indexOf('Client') >= 0, 'Client listed');
|
|
|
|
const guilds = surface.surface.GatewayIntentBits.Guilds;
|
|
let constructed = null;
|
|
await pack.commands.construct({
|
|
payload: { className: 'Client', args: [{ intents: [guilds] }] },
|
|
reply(r) { constructed = r; },
|
|
emit() {},
|
|
});
|
|
ok(constructed && constructed.ok && constructed.handle, 'construct Client');
|
|
|
|
let destroyed = null;
|
|
await pack.commands.destroy({
|
|
payload: { handle: constructed.handle },
|
|
reply(r) { destroyed = r; },
|
|
emit() {},
|
|
});
|
|
ok(destroyed && destroyed.ok, 'destroy Client');
|
|
|
|
async function dj(cmd, payload) {
|
|
let out = null;
|
|
await pack.commands[cmd]({
|
|
payload: payload || {},
|
|
reply(r) { out = r; },
|
|
emit() {},
|
|
});
|
|
return out;
|
|
}
|
|
|
|
const first = await dj('construct', {
|
|
className: 'Client',
|
|
args: [{ intents: [guilds] }],
|
|
_tabId: 11,
|
|
_pageEpoch: 'doc-a',
|
|
});
|
|
ok(first && first.ok && first.handle, 'construct tab-scoped Client');
|
|
const replace = await dj('construct', {
|
|
className: 'Client',
|
|
args: [{ intents: [guilds] }],
|
|
_tabId: 11,
|
|
_pageEpoch: 'doc-b',
|
|
});
|
|
ok(replace && replace.ok && replace.handle, 'reload construct replaces same tab');
|
|
ok(replace.handle !== first.handle, 'replacement is a new handle');
|
|
let st = await dj('status');
|
|
ok(st && st.clients === 1, 'same-tab replace keeps one Client, got ' + (st && st.clients));
|
|
|
|
const other = await dj('construct', {
|
|
className: 'Client',
|
|
args: [{ intents: [guilds] }],
|
|
_tabId: 12,
|
|
_pageEpoch: 'other',
|
|
});
|
|
ok(other && other.ok, 'second tab Client');
|
|
st = await dj('status');
|
|
ok(st && st.clients === 2, 'two tabs, two Clients');
|
|
|
|
const lateHide = await dj('dropTab', { tabId: 11, pageEpoch: 'doc-a' });
|
|
ok(lateHide && lateHide.ok && lateHide.dropped === 0, 'old document pagehide does not kill the new Client');
|
|
st = await dj('status');
|
|
ok(st && st.clients === 2, 'clients unchanged after stale epoch dropTab');
|
|
|
|
const closed = await dj('dropTab', { tabId: 12 });
|
|
ok(closed && closed.ok && closed.dropped === 1, 'tab close drops that tab only');
|
|
st = await dj('status');
|
|
ok(st && st.clients === 1, 'other tab still live');
|
|
|
|
const listed = await dj('listClients');
|
|
ok(listed && listed.ok && listed.clients.length === 1, 'listClients remaining');
|
|
ok(String(listed.clients[0].tabId) === '11', 'remaining client is tab 11');
|
|
|
|
await dj('dropTab', { tabId: 11 });
|
|
st = await dj('status');
|
|
ok(st && st.clients === 0, 'all tab-scoped Clients dropped');
|
|
|
|
const orphans = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
const o = await dj('construct', { className: 'Client', args: [{ intents: [guilds] }] });
|
|
ok(o && o.ok, 'orphan Client ' + i);
|
|
orphans.push(o.handle);
|
|
}
|
|
st = await dj('status');
|
|
ok(st && st.clients === 4, 'four orphans fill the cap');
|
|
const reclaim = await dj('construct', {
|
|
className: 'Client',
|
|
args: [{ intents: [guilds] }],
|
|
_tabId: 99,
|
|
_pageEpoch: 'reclaim',
|
|
});
|
|
ok(reclaim && reclaim.ok, 'construct at max reclaims orphans');
|
|
st = await dj('status');
|
|
ok(st && st.clients === 1, 'orphans dropped, one tab-scoped Client remains, got ' + (st && st.clients));
|
|
|
|
await pack.commands.setEnabled({
|
|
payload: { enabled: false },
|
|
reply() {},
|
|
emit() {},
|
|
});
|
|
}
|
|
|
|
async function testCustomToolLoop() {
|
|
const tmp = tmpDir('bs-custom-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const customTools = require('./agent/custom-tools.js');
|
|
const origComplete = engine.complete;
|
|
const origLoad = engine.load;
|
|
const origGet = engine.getLoaded;
|
|
engine.getLoaded = () => ({
|
|
modelId: 'fake',
|
|
friendlyId: 'qwen3.5-4b',
|
|
constant: 'QWEN3_5_4B_MULTIMODAL_Q4_K_M',
|
|
ctxSize: 8192,
|
|
});
|
|
engine.load = async () => engine.getLoaded();
|
|
let n = 0;
|
|
engine.complete = async (opts, onEvent) => {
|
|
n += 1;
|
|
if (n === 1) {
|
|
const call = { id: 'c1', name: 'get_cart', arguments: {} };
|
|
if (onEvent) onEvent({ type: 'toolCall', call });
|
|
return { text: '', toolCalls: [call] };
|
|
}
|
|
if (onEvent) onEvent({ type: 'contentDelta', delta: 'cart ok' });
|
|
return { text: 'cart ok', toolCalls: [] };
|
|
};
|
|
const cwd = path.join(tmp, 'ws');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'custom',
|
|
});
|
|
customTools.register(session.id, {
|
|
name: 'get_cart',
|
|
description: 'Return cart',
|
|
parameters: { type: 'object', properties: {} },
|
|
});
|
|
const jobId = 'job_custom';
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'what is in the cart',
|
|
jobId,
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit(_type, p) {
|
|
if (p && p.type === 'tool_request') {
|
|
loop.resolveCustomResult(jobId, p.toolCallId, { result: { items: 2 } });
|
|
}
|
|
},
|
|
});
|
|
engine.complete = origComplete;
|
|
engine.load = origLoad;
|
|
engine.getLoaded = origGet;
|
|
ok(result.ok, 'custom loop ok');
|
|
ok(n >= 2, 'custom tool then final');
|
|
ok(String(result.text || '').indexOf('cart') !== -1 || result.ok, 'got reply');
|
|
}
|
|
|
|
async function testHostWorkspaceOffLoop() {
|
|
const tmp = tmpDir('bs-hostws-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const customTools = require('./agent/custom-tools.js');
|
|
const origComplete = engine.complete;
|
|
const origLoad = engine.load;
|
|
const origGet = engine.getLoaded;
|
|
engine.getLoaded = () => ({
|
|
modelId: 'fake',
|
|
friendlyId: 'qwen3.5-4b',
|
|
constant: 'QWEN3_5_4B_MULTIMODAL_Q4_K_M',
|
|
ctxSize: 8192,
|
|
});
|
|
engine.load = async () => engine.getLoaded();
|
|
let n = 0;
|
|
let firstNames = [];
|
|
engine.complete = async (opts) => {
|
|
n += 1;
|
|
if (n === 1) firstNames = (opts.tools || []).map((t) => t.name);
|
|
if (n === 1) {
|
|
const call = { id: 'c1', name: 'read_file', arguments: { path: '/etc/passwd' } };
|
|
return { text: '', toolCalls: [call] };
|
|
}
|
|
return { text: 'from container', toolCalls: [] };
|
|
};
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd: 'dlinux-container',
|
|
workspace: 'dlinux-container',
|
|
hostWorkspace: false,
|
|
model: 'qwen3.5-4b',
|
|
title: 'page',
|
|
});
|
|
customTools.setSession(session.id, { hostWorkspace: false });
|
|
customTools.register(session.id, {
|
|
name: 'read_file',
|
|
description: 'Container read',
|
|
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
});
|
|
const jobId = 'job_hostws';
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'read passwd',
|
|
jobId,
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit(_type, p) {
|
|
if (p && p.type === 'tool_request') {
|
|
loop.resolveCustomResult(jobId, p.toolCallId, { result: { from: 'container' } });
|
|
}
|
|
},
|
|
});
|
|
engine.complete = origComplete;
|
|
engine.load = origLoad;
|
|
engine.getLoaded = origGet;
|
|
ok(result.ok, 'page-workspace loop ok');
|
|
ok(firstNames.indexOf('read_file') !== -1, 'page read_file offered');
|
|
ok(firstNames.indexOf('run_terminal_cmd') === -1, 'host shell not offered');
|
|
ok(firstNames.indexOf('grep') === -1, 'host grep not offered');
|
|
ok(n >= 2, 'custom read_file then final');
|
|
}
|
|
|
|
function mockEngine(engine) {
|
|
const origComplete = engine.complete;
|
|
const origLoad = engine.load;
|
|
const origGet = engine.getLoaded;
|
|
engine.getLoaded = () => ({
|
|
modelId: 'fake',
|
|
friendlyId: 'qwen3.5-4b',
|
|
constant: 'QWEN3_5_4B_MULTIMODAL_Q4_K_M',
|
|
ctxSize: 8192,
|
|
});
|
|
engine.load = async () => engine.getLoaded();
|
|
return () => {
|
|
engine.complete = origComplete;
|
|
engine.load = origLoad;
|
|
engine.getLoaded = origGet;
|
|
};
|
|
}
|
|
|
|
async function testPlanWriteGateLoop() {
|
|
const tmp = tmpDir('bs-plan-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const restore = mockEngine(engine);
|
|
let n = 0;
|
|
const results = [];
|
|
engine.complete = async () => {
|
|
n += 1;
|
|
if (n === 1) {
|
|
return {
|
|
text: '',
|
|
toolCalls: [
|
|
{ id: 'e', name: 'enter_plan_mode', arguments: {} },
|
|
{ id: 'w', name: 'write_file', arguments: { path: 'hello.js', contents: 'nope' } },
|
|
],
|
|
};
|
|
}
|
|
return { text: 'planned', toolCalls: [] };
|
|
};
|
|
const cwd = path.join(tmp, 'ws');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'plan',
|
|
});
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'plan then write',
|
|
jobId: 'job_plan',
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit(_t, p) {
|
|
if (p && p.type === 'tool_result') results.push(String(p.result || ''));
|
|
},
|
|
});
|
|
restore();
|
|
ok(result.ok, 'plan gate loop ok');
|
|
ok(results.some((r) => /Rejected|plan mode/i.test(r)), 'write to hello.js rejected');
|
|
ok(!fs.existsSync(path.join(cwd, 'hello.js')), 'hello.js not written');
|
|
}
|
|
|
|
async function testAskUserLoop() {
|
|
const tmp = tmpDir('bs-ask-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const restore = mockEngine(engine);
|
|
let n = 0;
|
|
let choiceSeen = false;
|
|
engine.complete = async (opts) => {
|
|
n += 1;
|
|
if (n === 1) {
|
|
return {
|
|
text: '',
|
|
toolCalls: [{ id: 'a1', name: 'ask_user_question', arguments: { question: 'Pick', options: ['A', 'B'] } }],
|
|
};
|
|
}
|
|
const last = (opts.history || []).filter((m) => m.role === 'tool').pop();
|
|
choiceSeen = !!(last && /A/.test(String(last.content)));
|
|
return { text: 'chose A', toolCalls: [] };
|
|
};
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd: path.join(tmp, 'ws'),
|
|
model: 'qwen3.5-4b',
|
|
title: 'ask',
|
|
});
|
|
fs.mkdirSync(session.cwd, { recursive: true });
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'ask me',
|
|
jobId: 'job_ask',
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit(_t, p) {
|
|
if (p && p.type === 'ask_user') loop.resolveAsk('job_ask', p.toolCallId, 'A');
|
|
},
|
|
});
|
|
restore();
|
|
ok(result.ok, 'ask loop ok');
|
|
ok(n >= 2, 'waited then continued');
|
|
ok(choiceSeen, 'choice reached history');
|
|
}
|
|
|
|
async function testGoalPrematureLoop() {
|
|
const tmp = tmpDir('bs-goal-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const restore = mockEngine(engine);
|
|
let n = 0;
|
|
let premature = false;
|
|
engine.complete = async (opts) => {
|
|
n += 1;
|
|
if (!opts.tools || !opts.tools.length) {
|
|
return { text: '{"achieved": true, "gaps": []}', toolCalls: [] };
|
|
}
|
|
if (n === 1) {
|
|
return { text: '', toolCalls: [{ id: 'g1', name: 'update_goal', arguments: { completed: true } }] };
|
|
}
|
|
const last = (opts.history || []).filter((m) => m.role === 'tool').pop();
|
|
premature = !!(last && /todos|not complete/i.test(String(last.content)));
|
|
return { text: 'still working', toolCalls: [] };
|
|
};
|
|
const cwd = path.join(tmp, 'ws');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'goal',
|
|
});
|
|
session.plan = [{ id: '1', content: 'write file', status: 'pending' }];
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'finish the goal',
|
|
jobId: 'job_goal',
|
|
payload: {
|
|
_origin: 'http://127.0.0.1:4173',
|
|
permissionMode: 'always-approve',
|
|
goal: 'Ship hello.js',
|
|
verify: false,
|
|
},
|
|
emit() {},
|
|
});
|
|
restore();
|
|
ok(result.ok, 'goal loop ok');
|
|
ok(premature, 'update_goal rejected with open todos');
|
|
ok(n >= 2, 'continued after premature stop');
|
|
}
|
|
|
|
async function testStationarityLoop() {
|
|
const tmp = tmpDir('bs-stuck-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const restore = mockEngine(engine);
|
|
engine.complete = async () => {
|
|
const calls = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
calls.push({ id: 'c' + i, name: 'list_dir', arguments: { path: '.' } });
|
|
}
|
|
return { text: '', toolCalls: calls };
|
|
};
|
|
const cwd = path.join(tmp, 'ws');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'stuck',
|
|
});
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'list forever',
|
|
jobId: 'job_stuck',
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit() {},
|
|
});
|
|
restore();
|
|
ok(result.reason === 'stuck', 'stuck end reason');
|
|
}
|
|
|
|
async function testSessionDestroyLoop() {
|
|
const tmp = tmpDir('bs-sess-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const sessions = require('./agent/sessions.js');
|
|
const loop = require('./agent/loop.js');
|
|
const customTools = require('./agent/custom-tools.js');
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd: path.join(tmp, 'ws'),
|
|
model: 'qwen3.5-4b',
|
|
title: 'gone',
|
|
});
|
|
customTools.setSession(session.id, { hostWorkspace: true });
|
|
customTools.register(session.id, {
|
|
name: 'get_cart',
|
|
description: 'cart',
|
|
parameters: { type: 'object', properties: {} },
|
|
});
|
|
ok(sessions.exists(session.id), 'session on disk');
|
|
loop.markLive(session.id);
|
|
ok(loop.isLive(session.id), 'live while open');
|
|
loop.finishLive(session.id);
|
|
ok(!loop.isLive(session.id), 'cleared after turn');
|
|
loop.forget(session.id);
|
|
customTools.clear(session.id);
|
|
ok(sessions.remove(session.id), 'removed');
|
|
ok(!sessions.exists(session.id), 'gone from disk');
|
|
ok(customTools.list(session.id).length === 0, 'tools cleared');
|
|
}
|
|
|
|
async function testParallelWritesLoop() {
|
|
const tmp = tmpDir('bs-par-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const restore = mockEngine(engine);
|
|
let n = 0;
|
|
engine.complete = async () => {
|
|
n += 1;
|
|
if (n === 1) {
|
|
return {
|
|
text: '',
|
|
toolCalls: [
|
|
{ id: 'w1', name: 'write_file', arguments: { path: 'a.txt', contents: 'AAA' } },
|
|
{ id: 'w2', name: 'write_file', arguments: { path: 'b.txt', contents: 'BBB' } },
|
|
],
|
|
};
|
|
}
|
|
return { text: 'wrote both', toolCalls: [] };
|
|
};
|
|
const cwd = path.join(tmp, 'ws');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const sandbox = require('./agent/sandbox.js');
|
|
sandbox.setGrants({ roots: [cwd] });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'par',
|
|
});
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'write two files',
|
|
jobId: 'job_par',
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit() {},
|
|
});
|
|
restore();
|
|
ok(result.ok, 'parallel writes ok');
|
|
ok(fs.readFileSync(path.join(cwd, 'a.txt'), 'utf8') === 'AAA', 'a.txt written');
|
|
ok(fs.readFileSync(path.join(cwd, 'b.txt'), 'utf8') === 'BBB', 'b.txt written');
|
|
}
|
|
|
|
async function testSamePathWriteOrderLoop() {
|
|
const tmp = tmpDir('bs-ord-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const restore = mockEngine(engine);
|
|
let n = 0;
|
|
engine.complete = async () => {
|
|
n += 1;
|
|
if (n === 1) {
|
|
return {
|
|
text: '',
|
|
toolCalls: [
|
|
{ id: 'w1', name: 'write_file', arguments: { path: 'same.txt', contents: 'AAA' } },
|
|
{
|
|
id: 's1',
|
|
name: 'search_replace',
|
|
arguments: { path: 'same.txt', old_string: 'AAA', new_string: 'BBB' },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return { text: 'ordered', toolCalls: [] };
|
|
};
|
|
const cwd = path.join(tmp, 'ws');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const sandbox = require('./agent/sandbox.js');
|
|
sandbox.setGrants({ roots: [cwd] });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'ord',
|
|
});
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'write then replace',
|
|
jobId: 'job_ord',
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit() {},
|
|
});
|
|
restore();
|
|
ok(result.ok, 'same-path writes ok');
|
|
ok(fs.readFileSync(path.join(cwd, 'same.txt'), 'utf8') === 'BBB', 'search_replace ran after write_file');
|
|
}
|
|
|
|
async function testGrepGlobLoop() {
|
|
const tmp = tmpDir('bs-grep-');
|
|
process.env.BRIDGE_SWARM_STORAGE = tmp;
|
|
const engine = require('./qvac/engine.js');
|
|
const loop = require('./agent/loop.js');
|
|
const sessions = require('./agent/sessions.js');
|
|
const restore = mockEngine(engine);
|
|
let n = 0;
|
|
let grepOut = '';
|
|
engine.complete = async (opts) => {
|
|
n += 1;
|
|
if (n === 1) {
|
|
return {
|
|
text: '',
|
|
toolCalls: [{ id: 'g1', name: 'grep', arguments: { pattern: 'needle', glob: '*.js', output_mode: 'files_with_matches' } }],
|
|
};
|
|
}
|
|
const last = (opts.history || []).filter((m) => m.role === 'tool').pop();
|
|
grepOut = String(last && last.content || '');
|
|
return { text: 'grepped', toolCalls: [] };
|
|
};
|
|
const cwd = path.join(tmp, 'ws');
|
|
fs.mkdirSync(path.join(cwd, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, 'src', 'hit.js'), 'needle here\n');
|
|
fs.writeFileSync(path.join(cwd, 'miss.md'), 'needle here\n');
|
|
const sandbox = require('./agent/sandbox.js');
|
|
sandbox.setGrants({ roots: [cwd] });
|
|
const session = sessions.create({
|
|
origin: 'http://127.0.0.1:4173',
|
|
cwd,
|
|
model: 'qwen3.5-4b',
|
|
title: 'grep',
|
|
});
|
|
const result = await loop.runTurn({
|
|
session,
|
|
userText: 'grep js',
|
|
jobId: 'job_grep',
|
|
payload: { _origin: 'http://127.0.0.1:4173', permissionMode: 'always-approve' },
|
|
emit() {},
|
|
});
|
|
restore();
|
|
ok(result.ok, 'grep glob ok');
|
|
ok(/hit\.js/.test(grepOut), 'js hit listed');
|
|
ok(!/miss\.md/.test(grepOut), 'md excluded by glob');
|
|
}
|
|
|