Files
EJClaw/src/agent-runner.test.ts
Eyejoker dd15585941 fix: pass actual chat JID to agent runner instead of group folder
watch_ci tasks in perf channel were silently failing because
NANOCLAW_CHAT_JID was set to the group folder name (e.g. "eyejokerdb-4")
instead of the actual Discord JID. The scheduler rejected the task with
"target group not registered".

Also adds a fallback in ipc.ts to resolve folder names to registered
JIDs when a folder name is passed instead of a JID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:27:00 +09:00

352 lines
8.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
import fs from 'fs';
import { spawn } from 'child_process';
// Sentinel markers must match agent-runner.ts
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
// Mock config
vi.mock('./config.js', () => ({
AGENT_MAX_OUTPUT_SIZE: 10485760,
AGENT_TIMEOUT: 1800000, // 30min
DATA_DIR: '/tmp/nanoclaw-test-data',
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min
TIMEZONE: 'America/Los_Angeles',
}));
// Mock logger
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock fs
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
existsSync: vi.fn((p: string) => {
// Return true for runner dist entry so tests proceed past the build check
if (typeof p === 'string' && p.includes('dist/index.js')) return true;
return false;
}),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn(() => ''),
readdirSync: vi.fn(() => []),
statSync: vi.fn(() => ({ isDirectory: () => false })),
copyFileSync: vi.fn(),
cpSync: vi.fn(),
},
};
});
// Mock env
vi.mock('./env.js', () => ({
readEnvFile: vi.fn(() => ({})),
}));
// Create a controllable fake ChildProcess
function createFakeProcess() {
const proc = new EventEmitter() as EventEmitter & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
pid: number;
};
proc.stdin = new PassThrough();
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
proc.kill = vi.fn();
proc.pid = 12345;
return proc;
}
let fakeProc: ReturnType<typeof createFakeProcess>;
// Mock child_process.spawn
vi.mock('child_process', async () => {
const actual =
await vi.importActual<typeof import('child_process')>('child_process');
return {
...actual,
spawn: vi.fn(() => fakeProc),
exec: vi.fn(
(_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
if (cb) cb(null);
return new EventEmitter();
},
),
};
});
import { runAgentProcess, AgentOutput } from './agent-runner.js';
import type { RegisteredGroup } from './types.js';
const testGroup: RegisteredGroup = {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: new Date().toISOString(),
};
const testInput = {
prompt: 'Hello',
groupFolder: 'test-group',
chatJid: 'test@g.us',
isMain: false,
};
function emitOutputMarker(
proc: ReturnType<typeof createFakeProcess>,
output: AgentOutput,
) {
const json = JSON.stringify(output);
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
}
describe('agent-runner timeout behavior', () => {
beforeEach(() => {
vi.useFakeTimers();
fakeProc = createFakeProcess();
});
afterEach(() => {
vi.useRealTimers();
});
it('timeout after output resolves as success', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
onOutput,
);
// Emit output with a result
emitOutputMarker(fakeProc, {
status: 'success',
result: 'Here is my response',
newSessionId: 'session-123',
});
// Let output processing settle
await vi.advanceTimersByTimeAsync(10);
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
await vi.advanceTimersByTimeAsync(1830000);
// Emit close event (as if agent was stopped by the timeout)
fakeProc.emit('close', 137);
// Let the promise resolve
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(result.newSessionId).toBe('session-123');
expect(onOutput).toHaveBeenCalledWith(
expect.objectContaining({ result: 'Here is my response' }),
);
});
it('timeout with no output resolves as error', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
onOutput,
);
// No output emitted — fire the hard timeout
await vi.advanceTimersByTimeAsync(1830000);
// Emit close event
fakeProc.emit('close', 137);
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('error');
expect(result.error).toContain('timed out');
expect(onOutput).not.toHaveBeenCalled();
});
it('normal exit after output resolves as success', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
onOutput,
);
// Emit output
emitOutputMarker(fakeProc, {
status: 'success',
result: 'Done',
newSessionId: 'session-456',
});
await vi.advanceTimersByTimeAsync(10);
// Normal exit (no timeout)
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(result.newSessionId).toBe('session-456');
});
it('preserves streamed progress phase metadata', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
onOutput,
);
emitOutputMarker(fakeProc, {
status: 'success',
result: '생각 중...',
phase: 'progress',
newSessionId: 'session-progress',
});
emitOutputMarker(fakeProc, {
status: 'success',
result: '최종 답변',
phase: 'final',
newSessionId: 'session-progress',
});
await vi.advanceTimersByTimeAsync(10);
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(onOutput).toHaveBeenCalledWith(
expect.objectContaining({
result: '생각 중...',
phase: 'progress',
}),
);
expect(onOutput).toHaveBeenCalledWith(
expect.objectContaining({
result: '최종 답변',
phase: 'final',
}),
);
});
it('passes the actual chat JID into codex runner MCP env', async () => {
vi.useRealTimers();
fakeProc = createFakeProcess();
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
const str = String(p);
return (
str.includes('dist/index.js') ||
str.includes('dist/ipc-mcp-stdio.js') ||
str.endsWith('/.codex/config.toml')
);
});
const codexGroup: RegisteredGroup = {
...testGroup,
agentType: 'codex',
};
const resultPromise = runAgentProcess(
codexGroup,
testInput,
() => {},
async () => {},
);
fakeProc.emit('close', 0);
const result = await resultPromise;
expect(result.status).toBe('success');
const spawnEnv = vi.mocked(spawn).mock.calls[0]?.[2]?.env as
| Record<string, string>
| undefined;
expect(spawnEnv?.NANOCLAW_CHAT_JID).toBe(testInput.chatJid);
const tomlWrite = vi
.mocked(fs.writeFileSync)
.mock.calls.find((call) =>
String(call[0]).endsWith('/.codex/config.toml'),
);
expect(String(tomlWrite?.[1])).toContain(
`NANOCLAW_CHAT_JID = ${JSON.stringify(testInput.chatJid)}`,
);
});
it('waits for queued streamed output before resolving an error exit', async () => {
let releaseOutputs: (() => void) | undefined;
const outputsFlushed = new Promise<void>((resolve) => {
releaseOutputs = resolve;
});
const onOutput = vi.fn(async () => {
await outputsFlushed;
});
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
onOutput,
);
emitOutputMarker(fakeProc, {
status: 'error',
result: null,
error: 'No conversation found with session ID: stale',
});
emitOutputMarker(fakeProc, {
status: 'error',
result: null,
error: 'Claude Code process exited with code 1',
newSessionId: 'stale-session',
});
await vi.advanceTimersByTimeAsync(10);
fakeProc.emit('close', 1);
let settled = false;
resultPromise.then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(10);
expect(settled).toBe(false);
releaseOutputs?.();
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('error');
expect(onOutput).toHaveBeenCalledTimes(2);
expect(onOutput).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ newSessionId: 'stale-session' }),
);
});
});