timmy-talking-turd/tests/agent-queue.test.js
Timmy 5dcaaae4d6 feat: queue agent turns with cancellation and orphan-free timeout kills
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.
2026-08-22 22:04:17 +00:00

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');
});