365 lines
15 KiB
JavaScript
365 lines
15 KiB
JavaScript
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
process.env.XDG_STATE_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-test-'));
|
|
process.env.XDG_DATA_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-data-'));
|
|
process.env.XDG_CONFIG_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-config-'));
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
|
import { QvacScheduler } from '../daemon/qvac-scheduler.js';
|
|
import { JarvisDaemon } from '../daemon/index.js';
|
|
import { HarnessBridge, harnessRoots } from '../daemon/harness-bridge.js';
|
|
import { spokenReply } from '../skills/voice-prompt.js';
|
|
import { voiceSettings } from '../daemon/voice-settings.js';
|
|
|
|
test('voice state machine handles wake, reply, cancel, and idle sleep', () => {
|
|
let now = 0;
|
|
const voice = new VoiceStateMachine({ now: () => now, idleMs: 100 });
|
|
voice.wake(); voice.utterance(); voice.speak(); voice.finishSpeaking();
|
|
assert.equal(voice.state, 'LISTENING');
|
|
assert.equal(voice.command('hands off'), 'cancel');
|
|
assert.equal(voice.state, 'ARMED');
|
|
voice.wake(); now = 101; voice.expireIdle();
|
|
assert.equal(voice.state, 'SLEEPING');
|
|
});
|
|
|
|
test('typed questions wake an armed voice session before thinking', () => {
|
|
const voice = new VoiceStateMachine();
|
|
voice.typedUtterance();
|
|
assert.equal(voice.state, 'THINKING');
|
|
});
|
|
|
|
test('typed questions barge in from SPEAKING', () => {
|
|
const voice = new VoiceStateMachine();
|
|
voice.wake(); voice.utterance(); voice.speak();
|
|
voice.typedUtterance();
|
|
assert.equal(voice.state, 'THINKING');
|
|
});
|
|
|
|
test('spoken replies use harness text and strip HUD sidecars', () => {
|
|
assert.equal(spokenReply({ ok: true, text: 'Hello there.', reason: 'stop' }), 'Hello there.');
|
|
assert.equal(spokenReply({ text: 'Spoken. <jarvis_hud>{"title":"x","chips":[]}</jarvis_hud>' }), 'Spoken.');
|
|
assert.equal(
|
|
spokenReply({
|
|
text: 'I will look that up. <tool_call><function=web_search><parameter=query>weather</parameter></function></tool_call>',
|
|
}),
|
|
'I will look that up.',
|
|
);
|
|
assert.equal(spokenReply({}), '');
|
|
assert.equal(spokenReply('[object Object]'), '');
|
|
assert.equal(spokenReply({ text: '{"ok":true,"action":"type","name":"Discord"}' }), '');
|
|
assert.equal(spokenReply({ text: 'Done. {"ok":true,"action":"click","ref":"r1"}' }), 'Done.');
|
|
assert.equal(
|
|
spokenReply({ text: '## Status\n- CPU is **fine**.\nUse `htop` if you want more.' }),
|
|
'Status\n- CPU is fine.\nUse htop if you want more.',
|
|
);
|
|
});
|
|
|
|
test('voice settings default TTS on and honor an explicit disable', () => {
|
|
assert.equal(voiceSettings({}).ttsEnabled, true);
|
|
assert.equal(voiceSettings({ ttsEnabled: true }).ttsEnabled, true);
|
|
assert.equal(voiceSettings({ ttsEnabled: false }).ttsEnabled, false);
|
|
assert.equal(voiceSettings({ tts_enabled: false }).ttsEnabled, false);
|
|
});
|
|
|
|
test('thought chunks forward SDK text onto Thinking', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
const thoughts = [];
|
|
daemon.on('Thinking', (text) => thoughts.push(text));
|
|
try {
|
|
daemon.harness.emit('agent_thought_chunk', { type: 'agent_thought_chunk', text: 'Considering the lookup.' });
|
|
daemon.harness.emit('agent_thought_chunk', 'plain thought');
|
|
daemon.harness.emit('agent_thought_chunk', { delta: ' via delta' });
|
|
daemon.harness.emit('agent_thought_chunk', { text: '' });
|
|
assert.deepEqual(thoughts, ['Considering the lookup.', 'plain thought', ' via delta']);
|
|
} finally {
|
|
await daemon.close();
|
|
}
|
|
});
|
|
|
|
test('ask extracts harness reply text instead of stringifying the object', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
daemon.harness = { ask: async () => ({ ok: true, text: 'Hello there.', reason: 'stop' }), cancel() {}, close: async () => {} };
|
|
daemon.voiceLoop = null;
|
|
const replies = [];
|
|
daemon.on('Reply', (text) => replies.push(text));
|
|
try {
|
|
const result = await daemon.ask('Hi');
|
|
assert.equal(result, 'Hello there.');
|
|
assert.deepEqual(replies, ['Hello there.']);
|
|
assert.equal(daemon.lastReply, 'Hello there.');
|
|
assert.equal(daemon.state, 'LISTENING');
|
|
} finally {
|
|
await daemon.close();
|
|
}
|
|
});
|
|
|
|
test('harness bridge caps voice shell chaining', () => {
|
|
const bridge = new HarnessBridge({ fsAccess: 'workspace' });
|
|
assert.equal(bridge.options.origin, 'jarvis-qvac');
|
|
assert.equal(bridge.options.voice, true);
|
|
assert.equal(bridge.options.maxShellCalls, 1);
|
|
assert.equal(bridge.options.maxTurns, 24);
|
|
assert.equal(bridge.options.maxToolRounds, 16);
|
|
assert.deepEqual(bridge.options.roots, []);
|
|
assert.deepEqual(harnessRoots('filesystem'), ['/']);
|
|
assert.match(bridge.options.cwd, /jarvis\/workspace$/);
|
|
assert.match(bridge.options.system, /You are Jarvis/);
|
|
assert.match(bridge.options.system, /SOUL\.md/);
|
|
assert.match(bridge.options.system, /PERSONA\.md/);
|
|
assert.match(bridge.options.system, /It is /);
|
|
assert.doesNotMatch(bridge.options.system, /User personality notes/);
|
|
assert.deepEqual(bridge.options.builtinTools, [
|
|
'read_file',
|
|
'write_file',
|
|
'search_replace',
|
|
'list_dir',
|
|
'grep',
|
|
'run_terminal_cmd',
|
|
'todo_write',
|
|
'task',
|
|
'update_goal',
|
|
'memory_search',
|
|
'memory_get',
|
|
'memory_write',
|
|
]);
|
|
assert.equal(bridge.options.webFetch, false);
|
|
assert.equal(bridge.options.builtinTools.includes('web_search'), false);
|
|
assert.equal(bridge.options.builtinTools.includes('web_fetch'), false);
|
|
assert.ok(bridge.options.tools.some((tool) => tool.name === 'browser'));
|
|
assert.equal(bridge.options.tools.find((tool) => tool.name === 'browser').permission, 'read');
|
|
assert.ok(bridge.options.tools.some((tool) => tool.name === 'webcam'));
|
|
assert.equal(bridge.options.tools.find((tool) => tool.name === 'webcam').permission, 'read');
|
|
assert.ok(bridge.options.tools.length <= 32, 'custom tools exceed harness cap: ' + bridge.options.tools.length);
|
|
});
|
|
|
|
test('ask rebuilds the system prompt instead of keeping a stale clock', async () => {
|
|
const bridge = new HarnessBridge({ fsAccess: 'workspace' });
|
|
bridge.options.system = 'stale';
|
|
bridge.session = {
|
|
async prompt() { return { ok: true, text: 'Hello there.' }; },
|
|
on() { return () => {}; },
|
|
};
|
|
try {
|
|
await bridge.ask('hi');
|
|
assert.match(bridge.options.system, /You are Jarvis/);
|
|
assert.match(bridge.options.system, /It is /);
|
|
assert.doesNotMatch(bridge.options.system, /^stale$/);
|
|
} finally {
|
|
await bridge.close();
|
|
}
|
|
});
|
|
|
|
test('harness bridge recovers streamed text when final envelope is empty after a tool call', async () => {
|
|
const session = new (await import('node:events')).EventEmitter();
|
|
session.prompt = async () => {
|
|
session.emit('agent_message_chunk', { text: 'I will check that. ' });
|
|
session.emit('tool_call', { call: { name: 'runtime_status' } });
|
|
session.emit('agent_message_chunk', { text: 'Your computer is ready.' });
|
|
return { ok: true, text: '', reason: 'stop' };
|
|
};
|
|
const bridge = Object.create(HarnessBridge.prototype);
|
|
bridge.session = session;
|
|
const reply = await bridge.ask('How is my computer?');
|
|
assert.equal(reply.text, 'Your computer is ready.');
|
|
});
|
|
|
|
test('ask with no spoken text reports failure and returns to listening', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
daemon.harness = { ask: async () => ({ ok: true, text: '', reason: 'stop' }), cancel() {}, close: async () => {} };
|
|
daemon.voiceLoop = null;
|
|
const replies = [];
|
|
daemon.on('Reply', (text) => replies.push(text));
|
|
try {
|
|
await assert.rejects(daemon.ask('status'), /no spoken answer/);
|
|
assert.deepEqual(replies, []);
|
|
assert.equal(daemon.state, 'LISTENING');
|
|
} finally {
|
|
await daemon.close();
|
|
}
|
|
});
|
|
|
|
test('confirmPermission forwards Allow/Deny/Always to the harness session', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
const calls = [];
|
|
daemon.harness = { session: { permit(...args) { calls.push(args); } }, cancel() {}, close: async () => {} };
|
|
try {
|
|
daemon.confirmPermission('job-1', 'call-9', 'allow');
|
|
daemon.confirmPermission('job-1', 'call-9', 'always');
|
|
assert.deepEqual(calls, [['job-1', 'call-9', 'allow'], ['job-1', 'call-9', 'always']]);
|
|
} finally {
|
|
await daemon.close();
|
|
}
|
|
});
|
|
|
|
test('harness bridge resetContext disposes the current conversation', async () => {
|
|
let cancelled = false; let disposed = false;
|
|
const bridge = Object.create(HarnessBridge.prototype);
|
|
bridge.session = {
|
|
cancel() { cancelled = true; },
|
|
async dispose() { disposed = true; },
|
|
};
|
|
await bridge.resetContext();
|
|
assert.equal(cancelled, true);
|
|
assert.equal(disposed, true);
|
|
assert.equal(bridge.session, null);
|
|
});
|
|
|
|
test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
|
|
const scheduler = new QvacScheduler();
|
|
const order = [];
|
|
const first = scheduler.run(async () => { order.push('first'); await new Promise((r) => setTimeout(r, 5)); }, { lane: 'background' });
|
|
const media = scheduler.run(async () => order.push('media'), { lane: 'background' });
|
|
const voice = scheduler.run(async () => order.push('voice'), { lane: 'voice' });
|
|
await Promise.all([first, media, voice]);
|
|
assert.deepEqual(order, ['first', 'voice', 'media']);
|
|
assert.deepEqual(scheduler.status(), { running: 0, queued: [] });
|
|
});
|
|
|
|
test('idle sleep unloads GPU models instead of only suspending', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
const calls = [];
|
|
daemon.settings = { ...daemon.settings, freeVramOnIdle: true };
|
|
daemon.parkModels = async () => { calls.push('park'); };
|
|
daemon.voiceLoop = { interrupt() {} };
|
|
daemon.harness = { cancel() {}, resetContext: async () => {}, close: async () => {} };
|
|
try {
|
|
await daemon.sleep();
|
|
assert.equal(daemon.state, 'SLEEPING');
|
|
assert.deepEqual(calls, ['park']);
|
|
} finally { await daemon.close(); }
|
|
});
|
|
|
|
test('shutdown still closes the harness when recovery or voice cleanup fails', async () => {
|
|
const daemon = new JarvisDaemon(); let closed = false;
|
|
daemon.recovery.save = () => { throw new Error('disk full'); };
|
|
daemon.harness = { cancel() {}, close: async () => { closed = true; } };
|
|
daemon.voiceLoop = { stop: async () => { throw new Error('voice cleanup'); } };
|
|
await assert.rejects(daemon.close(), /voice cleanup/);
|
|
assert.equal(closed, true);
|
|
});
|
|
|
|
test('Grant desktop revokes any previous portal session before asking GNOME again', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
const order = [];
|
|
daemon.input = {
|
|
revoke: () => order.push('revoke'),
|
|
grant: (opts) => { order.push(['grant', opts]); return Promise.resolve({ backend: 'portal-ei' }); },
|
|
};
|
|
daemon.computer = {
|
|
revoke: () => order.push('computer-revoke'),
|
|
grant: (opts) => { order.push(['computer-grant', opts]); return { session_id: 's' }; },
|
|
setBackend: (backend) => order.push(['backend', backend]),
|
|
status: () => ({ active: true }),
|
|
expiresAt: Date.now() + 180000,
|
|
};
|
|
try {
|
|
daemon.computerGrant(false);
|
|
await Promise.resolve();
|
|
assert.equal(order[0], 'revoke');
|
|
assert.equal(order[1], 'computer-revoke');
|
|
assert.deepEqual(order[2], ['computer-grant', { persist: false }]);
|
|
assert.deepEqual(order[3], ['grant', { persist: false, mode: daemon.settings.computerMode }]);
|
|
assert.deepEqual(order[4], ['backend', 'portal-ei']);
|
|
} finally { await daemon.close(); }
|
|
});
|
|
|
|
test('camera Allow now starts a grant even when Camera access is off', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
const order = [];
|
|
try {
|
|
assert.equal(daemon.settings.webcamEnabled, false);
|
|
daemon.webcam = {
|
|
access: (opts) => { order.push(['access', opts]); return Promise.resolve({ ok: true, via: 'grant' }); },
|
|
};
|
|
daemon.webcamGrant();
|
|
await Promise.resolve();
|
|
assert.equal(daemon.camera.status().active, true);
|
|
assert.equal(daemon.camera.status().enabled, true);
|
|
assert.equal(daemon.camera.status().backend, 'grant');
|
|
assert.deepEqual(order[0], ['access', { device: '' }]);
|
|
const status = JSON.parse(daemon.runtimeStatus());
|
|
assert.equal(status.camera.active, true);
|
|
daemon.webcamRevoke();
|
|
assert.equal(daemon.camera.status().active, false);
|
|
assert.equal(daemon.camera.status().enabled, false);
|
|
} finally { await daemon.close(); }
|
|
});
|
|
|
|
test('a failed camera permission request revokes the grant instead of showing active access', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
try {
|
|
daemon.webcam = { access: async () => { throw new Error('Camera portal Access response 2'); } };
|
|
daemon.webcamGrant();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
assert.equal(daemon.camera.status().active, false);
|
|
assert.equal(daemon.camera.status().backend, 'none');
|
|
} finally { await daemon.close(); }
|
|
});
|
|
|
|
test('cancel suppresses a late reply and revokes portal input', async () => {
|
|
const daemon = new JarvisDaemon(); let resolve; let revoked = false;
|
|
daemon.input = { revoke: () => { revoked = true; } };
|
|
daemon.harness = { ask: () => new Promise(r => { resolve = r; }), cancel() {}, close: async () => {} };
|
|
const replies = []; daemon.on('Reply', text => replies.push(text));
|
|
try {
|
|
const ask = daemon.ask('hello'); await Promise.resolve(); daemon.cancel();
|
|
resolve({ text: 'late answer' }); assert.equal(await ask, '');
|
|
assert.deepEqual(replies, []); assert.equal(daemon.state, 'ARMED'); assert.equal(revoked, true);
|
|
} finally { await daemon.close(); }
|
|
});
|
|
|
|
test('mute ignores arm and does not return to listening after speech', async () => {
|
|
const daemon = new JarvisDaemon();
|
|
const muted = [];
|
|
daemon.voiceLoop = {
|
|
setMuted(value) { muted.push(Boolean(value)); },
|
|
setPushToTalk(value) { this.ptt = value; },
|
|
interrupt() {},
|
|
status: { capture: false, wake: false, muted: false },
|
|
metrics: { snapshot() { return {}; } },
|
|
};
|
|
try {
|
|
daemon.setState('LISTENING');
|
|
daemon.setMuted(true);
|
|
assert.equal(daemon.muted, true);
|
|
assert.equal(daemon.listenEnabled, false);
|
|
assert.equal(daemon.state, 'ARMED');
|
|
assert.deepEqual(muted, [true]);
|
|
await daemon.arm();
|
|
assert.equal(daemon.state, 'ARMED');
|
|
daemon.setListening(true);
|
|
assert.equal(daemon.state, 'ARMED');
|
|
daemon.setState('SPEAKING');
|
|
daemon._finishSpeech();
|
|
assert.equal(daemon.state, 'ARMED');
|
|
const status = JSON.parse(daemon.runtimeStatus());
|
|
assert.equal(status.muted, true);
|
|
assert.equal(status.voice.muted, true);
|
|
daemon.setMuted(false);
|
|
daemon.setListening(false);
|
|
daemon.setState('SPEAKING');
|
|
daemon._finishSpeech();
|
|
assert.equal(daemon.state, 'ARMED');
|
|
} finally { await daemon.close(); }
|
|
});
|
|
|
|
for (const empty of [false, true]) {
|
|
test(`failed inference keeps conversation listening and reports the failure (empty=${empty})`, async () => {
|
|
const daemon = new JarvisDaemon();
|
|
daemon.settings = { ...daemon.settings, listeningMode: 'conversation' };
|
|
daemon.listenEnabled = true;
|
|
daemon.harness.ask = async () => { if (empty) return { text: '' }; throw new Error('inference unavailable'); };
|
|
const errors = [];
|
|
daemon.on('Error', (code, message) => errors.push([code, message]));
|
|
try {
|
|
await assert.rejects(daemon.ask('Read the page.'), empty ? /no spoken answer/ : /inference unavailable/);
|
|
assert.equal(daemon.state, 'LISTENING');
|
|
assert.equal(daemon.voice.state, 'LISTENING');
|
|
assert.ok(errors.some(([code]) => code === 'QVAC'));
|
|
} finally { await daemon.close(); }
|
|
});
|
|
}
|