Chat turns now flow through the bounded inference queue: FIFO with configurable depth (TIMMY_AGENT_MAX_QUEUE_DEPTH) and concurrency (TIMMY_AGENT_MAX_CONCURRENT_TURNS), sanitized overload/timeout fallbacks, browser-disconnect cancellation via AbortSignal, and child process SIGTERM/SIGKILL teardown so no Hermes subprocess survives a cancelled turn.
93 lines
3.6 KiB
JavaScript
93 lines
3.6 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.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 };
|
|
}
|
|
|
|
async function unlock(service, token) {
|
|
await service.unlock({ origin, accessCode });
|
|
return token;
|
|
}
|
|
|
|
test('chat requests beyond the queue depth get a stable sanitized overload fallback', async () => {
|
|
let clock = 0;
|
|
const gate = deferred();
|
|
let started = 0;
|
|
const service = createHermesAgentService({
|
|
config: { ...configured(), maxQueueDepth: 1 },
|
|
randomToken: () => 'queue-cookie',
|
|
now: () => clock,
|
|
runTurn: async () => {
|
|
started += 1;
|
|
return gate.promise;
|
|
},
|
|
});
|
|
await unlock(service);
|
|
|
|
const first = service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'first', ledger: [] } });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(started, 1);
|
|
|
|
const second = service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'second', ledger: [] } });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(started, 1, 'second request waits in the bounded queue');
|
|
|
|
await assert.rejects(() => service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'third', ledger: [] } }), error => {
|
|
assert.equal(error.status, 503);
|
|
assert.match(error.message, /busy|try again/i);
|
|
assert.doesNotMatch(error.message, /spawn|hermes|session|token|workdir/i);
|
|
return true;
|
|
}, 'request beyond max queue depth fails fast with sanitized copy');
|
|
|
|
gate.resolve({ reply: 'eventually', sessionId: 'private-session' });
|
|
assert.match((await first).reply, /eventually/);
|
|
assert.match((await second).reply, /eventually/);
|
|
});
|
|
|
|
test('a chat request abandoned by its browser is cancelled without running Hermes and frees its slot', async () => {
|
|
let clock = 0;
|
|
const gate = deferred();
|
|
const started = [];
|
|
const service = createHermesAgentService({
|
|
config: { ...configured(), maxQueueDepth: 2 },
|
|
randomToken: () => 'disconnect-cookie',
|
|
now: () => clock,
|
|
runTurn: async ({ signal }) => {
|
|
started.push('run');
|
|
return new Promise((resolve, reject) => {
|
|
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
|
gate.promise.then(resolve, reject);
|
|
});
|
|
},
|
|
});
|
|
await unlock(service, 'disconnect-cookie');
|
|
|
|
const active = service.chat({ origin, cookieToken: 'disconnect-cookie', payload: { message: 'holding slot', ledger: [] } });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
const abandonSignal = new AbortController();
|
|
const abandoned = service.chat({ origin, cookieToken: 'disconnect-cookie', payload: { message: 'queued', ledger: [] }, signal: abandonSignal.signal });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
abandonSignal.abort(new Error('client disconnected'));
|
|
await assert.rejects(() => abandoned, error => error.status === 503 && /closed|disconnect/i.test(error.message));
|
|
|
|
gate.resolve({ reply: 'late answer', sessionId: 'private-session' });
|
|
assert.match((await active).reply, /late answer/);
|
|
assert.deepEqual(started, ['run'], 'the cancelled request never invoked a Hermes turn');
|
|
});
|