import test from 'node:test'; import assert from 'node:assert/strict'; import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { constants as fsConstants } from 'node:fs'; import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig, runHermesCliTurn } 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', }); async function waitUntil(check, timeoutMs = 3_000, stepMs = 20) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await check()) return true; await new Promise(resolve => setTimeout(resolve, stepMs)); } return false; } function isAlive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } } test('aborting an in-flight Hermes turn kills the child promptly and leaves no orphan process', async () => { const dir = await mkdir(join(tmpdir(), `timmy-orphan-${Date.now()}-`), { recursive: true }); const pidFile = join(dir, 'child.pid'); const fixture = join(dir, 'slow-hermes.sh'); // Prints valid Hermes output immediately, then idles long enough that only // an explicit kill can end it. `exec` makes the recorded PID the sleeper. await writeFile(fixture, `#!/usr/bin/env bash\nprintf '%s\\n' $$ > '${pidFile}'\necho "session_id: fixture_kill_001"\necho "thinking"\nexec sleep 60\n`, { mode: fsConstants.S_IRWXU }); const controller = new AbortController(); const turn = runHermesCliTurn({ prompt: 'hello', hermesSessionId: null, config: { ...configured(), command: fixture, workdir: dir }, signal: controller.signal, env: { TMPDIR: dir }, }); const pidAppeared = await waitUntil(async () => { try { Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8')); return true; } catch { return false; } }, 4_000); assert.ok(pidAppeared, 'fixture child started'); const pid = Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8')); assert.ok(isAlive(pid), 'child is running before cancellation'); const startedAt = Date.now(); controller.abort(new Error('client disconnected')); await assert.rejects(() => Promise.race([ turn, new Promise((_, reject) => setTimeout(() => reject(new Error('turn was not cancelled')), 5_000)), ]), error => /disconnect|abort|killed|terminated/i.test(String(error?.message || error?.code || ''))); const cancelMs = Date.now() - startedAt; assert.ok(cancelMs < 4_000, `cancellation returned promptly (took ${cancelMs}ms)`); const reaped = await waitUntil(() => !isAlive(pid), 4_000); assert.ok(reaped, 'child process was actually killed — no orphan subprocess remains'); await rm(dir, { recursive: true, force: true }); }, { timeout: 20_000 }); test('a queued chat past the queue deadline is cleaned up with a sanitized timeout response and zero extra Hermes calls', async () => { let clock = 0; let started = 0; let releaseActive; const service = createHermesAgentService({ config: { ...configured(), timeoutMs: 50 }, randomToken: () => 'deadline-cookie', now: () => clock, runTurn: async () => { started += 1; return new Promise(resolve => { releaseActive = resolve; }); }, }); await service.unlock({ origin, accessCode }); const active = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'wedge', ledger: [] } }); await new Promise(resolve => setImmediate(resolve)); assert.equal(started, 1); const queued = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'waiting', ledger: [] } }); await new Promise(resolve => setImmediate(resolve)); assert.equal(started, 1); clock += 200; // past the 50ms queue deadline // Attach the rejection expectation before the sweep runs so the expired // request never counts as an unhandled rejection. const expiryExpectation = assert.rejects(() => queued, error => { assert.ok(error instanceof AgentGatewayError); assert.equal(error.status, 503); assert.match(error.message, /timed out|busy/i); assert.doesNotMatch(error.message, /spawn|hermes|session|token|workdir|auth/i); return true; }); service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'sweep trigger', ledger: [] } }).catch(() => {}); await expiryExpectation; assert.equal(started, 1, 'expired request never reached Hermes'); releaseActive({ reply: 'late but valid', sessionId: 'deadline_session_01' }); assert.match((await active).reply, /late but valid/); });