All checks were successful
Quality gates / quality (pull_request) Successful in 1m58s
- server.mjs tracks in-flight chats: browser disconnects abort the queued turn (releasing its slot), and SIGTERM/SIGINT cancels every in-flight turn so no Hermes subprocess is orphaned. - vision-service forwards a per-request AbortSignal combined with the provider deadline via AbortSignal.any. - acceptance suites cover overload fallback, urgent bypass under saturation, disconnect cleanup, recovery, and shutdown orphan checks. - zero-call invariant tests prove urgent text never reaches Hermes or the vision provider under load.
109 lines
4.8 KiB
JavaScript
109 lines
4.8 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.js';
|
|
import { analyzePhoto } from '../src/vision-service.js';
|
|
import { OverloadError, createInferenceQueue } from '../src/inference-queue.js';
|
|
|
|
const origin = 'http://127.0.0.1:4173';
|
|
const accessCode = 'test-agent-access-code-2026';
|
|
const configured = () => resolveHermesAgentConfig({
|
|
TIMMY_AGENT_ENABLED: 'true',
|
|
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
|
|
TIMMY_PUBLIC_ORIGIN: origin,
|
|
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
|
|
});
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
test('urgent messages intercept before Hermes even when the queue is saturated', async () => {
|
|
let clock = 0;
|
|
let hermesCalls = 0;
|
|
const gate = deferred();
|
|
const service = createHermesAgentService({
|
|
config: { ...configured(), maxQueueDepth: 0 },
|
|
randomToken: () => 'urgent-cookie',
|
|
now: () => clock,
|
|
runTurn: async () => {
|
|
hermesCalls += 1;
|
|
return gate.promise;
|
|
},
|
|
});
|
|
await service.unlock({ origin, accessCode });
|
|
|
|
// Saturate the single slot so any queued path would be rejected as overloaded.
|
|
const active = service.chat({ origin, cookieToken: 'urgent-cookie', payload: { message: 'normal question', ledger: [] } });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(hermesCalls, 1);
|
|
|
|
const urgent = await service.chat({ origin, cookieToken: 'urgent-cookie', payload: { message: 'I have rectal bleeding', ledger: [] } });
|
|
|
|
assert.equal(hermesCalls, 1, 'Hermes was called exactly once — only for the non-urgent request');
|
|
assert.equal(urgent.safetyOverride, true);
|
|
assert.match(urgent.reply, /medical help/i);
|
|
gate.resolve({ reply: 'late', sessionId: 'urgent_session_01' });
|
|
assert.match((await active).reply, /late/);
|
|
});
|
|
|
|
test('vision analysis is bounded by the shared queue and overload returns the manual-fallback copy', async () => {
|
|
let clock = 0;
|
|
let providerCalls = 0;
|
|
const gates = [];
|
|
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 60_000, now: () => clock });
|
|
|
|
const visionFetch = async (url, options) => {
|
|
providerCalls += 1;
|
|
const gate = deferred();
|
|
gates.push(gate);
|
|
options.signal?.addEventListener('abort', () => gate.reject(new Error('aborted')));
|
|
return new Promise((resolve, reject) => {
|
|
gate.promise.then(() => resolve({ ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify({ isStool: true, bristolType: 4, color: 'brown', confidence: 0.8, imageQuality: 'good', observations: 'ok' }) } }] }) }), reject);
|
|
});
|
|
};
|
|
|
|
const payload = { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true };
|
|
const config = { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'test-key-not-real', model: 'm' };
|
|
|
|
const first = queue.run(signal => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal }));
|
|
while (providerCalls < 1) await Promise.resolve();
|
|
const second = queue.run(signal => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal }));
|
|
await Promise.resolve();
|
|
|
|
await assert.rejects(() => queue.run((signal) => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal })), error => {
|
|
assert.ok(error instanceof OverloadError);
|
|
assert.match(error.message, /continue manually/i);
|
|
assert.doesNotMatch(error.message, /api|key|token|127\.0\.0\.1|fetch/i);
|
|
return true;
|
|
}, 'third request beyond depth is overloaded with sanitized copy');
|
|
|
|
assert.equal(providerCalls, 1, 'overloaded request never reached the provider');
|
|
gates[0].resolve('go');
|
|
const result = await Promise.race([first, new Promise(resolve => setTimeout(() => resolve('pending'), 100))]);
|
|
assert.notEqual(result, 'pending');
|
|
});
|
|
|
|
test('vision requests carry cancellation so disconnected clients release provider work', async () => {
|
|
let capturedSignal;
|
|
const controller = new AbortController();
|
|
const gate = deferred();
|
|
const fetchImpl = async (url, options) => {
|
|
capturedSignal = options.signal;
|
|
options.signal?.addEventListener('abort', () => gate.reject(new Error('aborted')));
|
|
return gate.promise;
|
|
};
|
|
const payload = { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true };
|
|
const config = { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'test-key-not-real', model: 'm' };
|
|
|
|
const attempt = analyzePhoto({ payload, fetchImpl, config, signal: controller.signal });
|
|
while (!capturedSignal) await Promise.resolve();
|
|
|
|
controller.abort(new Error('client disconnected'));
|
|
await assert.rejects(() => attempt, /continue manually|unavailable/i);
|
|
assert.equal(capturedSignal.aborted, true, 'the provider fetch observed the cancellation');
|
|
});
|