Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
175 lines
6.4 KiB
JavaScript
175 lines
6.4 KiB
JavaScript
/**
|
|
* ADR-0042 Task 4 — eventTranslator unit tests.
|
|
*
|
|
* Pure module: no DB, no spawn. Boot guard from backend/test/setup.js still
|
|
* runs (vitest setupFiles).
|
|
*
|
|
* Coverage gates per Task 4 brief — ≥10 cases:
|
|
* - output → message_start
|
|
* - output with tool_use marker → tool_use (regex hit)
|
|
* - output with no marker / non-string content → message_start (regex miss)
|
|
* - result → message_stop
|
|
* - error → error (with message preserved)
|
|
* - info → null (skip)
|
|
* - unknown / non-object / null / missing-type → null (defensive)
|
|
* - mcp__ tool name (double-underscore namespace) → tool_use
|
|
* - stream-json passthrough mode (env-flip)
|
|
* - warnLegacyOnce only prints once per process
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
eventTranslator,
|
|
warnLegacyOnce,
|
|
_resetLegacyWarnForTest,
|
|
DEFAULT_BACKSTOP_MS,
|
|
} from '../run-stream-handler.mjs';
|
|
|
|
describe('ADR-0042 eventTranslator — basics', () => {
|
|
it('exports DEFAULT_BACKSTOP_MS = 4 hours', () => {
|
|
expect(DEFAULT_BACKSTOP_MS).toBe(4 * 60 * 60 * 1000);
|
|
});
|
|
|
|
it('output → message_start (no tool marker)', () => {
|
|
const out = eventTranslator({ type: 'output', content: 'hello world', status: 'success', exit: 0 });
|
|
expect(out).toEqual({ type: 'message_start' });
|
|
});
|
|
|
|
it('output with tool_use JSON marker → tool_use event', () => {
|
|
const text = 'thinking...\n{"type":"tool_use","name":"Bash","input":{"cmd":"ls"}}';
|
|
const out = eventTranslator({ type: 'output', content: text });
|
|
expect(out).toMatchObject({ type: 'tool_use', name: 'Bash' });
|
|
});
|
|
|
|
it('output with mcp__ tool name preserves double-underscore namespace', () => {
|
|
const text = '{"type":"tool_use","name":"mcp__godcrm__update_table_row","input":{}}';
|
|
const out = eventTranslator({ type: 'output', content: text });
|
|
expect(out).toMatchObject({ type: 'tool_use', name: 'mcp__godcrm__update_table_row' });
|
|
});
|
|
|
|
it('output with non-string content → plain message_start', () => {
|
|
const out = eventTranslator({ type: 'output', content: { not: 'a string' } });
|
|
expect(out).toEqual({ type: 'message_start' });
|
|
});
|
|
|
|
it('output empty content → plain message_start', () => {
|
|
const out = eventTranslator({ type: 'output', content: '' });
|
|
expect(out).toEqual({ type: 'message_start' });
|
|
});
|
|
|
|
it('result → message_stop', () => {
|
|
const out = eventTranslator({ type: 'result', status: 'success', ticket_id: 1, exit: 0 });
|
|
expect(out).toEqual({ type: 'message_stop' });
|
|
});
|
|
|
|
it('error → error event with message preserved', () => {
|
|
const out = eventTranslator({ type: 'error', message: 'workspace_missing' });
|
|
expect(out).toEqual({ type: 'error', message: 'workspace_missing' });
|
|
});
|
|
|
|
it('error with non-string message → empty string', () => {
|
|
const out = eventTranslator({ type: 'error', message: { code: 7 } });
|
|
expect(out).toEqual({ type: 'error', message: '' });
|
|
});
|
|
});
|
|
|
|
describe('ADR-0042 eventTranslator — null returns', () => {
|
|
it('info → null (skipped)', () => {
|
|
expect(eventTranslator({ type: 'info', message: 'runner_starting' })).toBeNull();
|
|
});
|
|
|
|
it('unknown event type → null', () => {
|
|
expect(eventTranslator({ type: 'wibble' })).toBeNull();
|
|
});
|
|
|
|
it('null input → null', () => {
|
|
expect(eventTranslator(null)).toBeNull();
|
|
});
|
|
|
|
it('undefined input → null', () => {
|
|
expect(eventTranslator(undefined)).toBeNull();
|
|
});
|
|
|
|
it('non-object input → null', () => {
|
|
expect(eventTranslator('result')).toBeNull();
|
|
expect(eventTranslator(42)).toBeNull();
|
|
});
|
|
|
|
it('object missing `type` field → null', () => {
|
|
expect(eventTranslator({ content: 'orphan' })).toBeNull();
|
|
});
|
|
|
|
it('object with non-string `type` → null', () => {
|
|
expect(eventTranslator({ type: 7 })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ADR-0042 eventTranslator — tool_use regex edge cases', () => {
|
|
it('does NOT match a malformed name (numeric only)', () => {
|
|
const text = '{"type":"tool_use","name":"42","input":{}}';
|
|
const out = eventTranslator({ type: 'output', content: text });
|
|
// 42 fails the [A-Za-z_][\w]* anchor → falls back to message_start
|
|
expect(out).toEqual({ type: 'message_start' });
|
|
});
|
|
|
|
it('matches the first tool_use when content has multiple', () => {
|
|
const text =
|
|
'{"type":"tool_use","name":"Read","input":{}} ' +
|
|
'and later {"type":"tool_use","name":"Edit","input":{}}';
|
|
const out = eventTranslator({ type: 'output', content: text });
|
|
expect(out).toMatchObject({ type: 'tool_use', name: 'Read' });
|
|
});
|
|
|
|
it('content with only the word "tool_use" but no JSON → message_start', () => {
|
|
const out = eventTranslator({
|
|
type: 'output',
|
|
content: 'I considered using a tool_use but decided against it.',
|
|
});
|
|
// No name match possible → plain message_start.
|
|
expect(out).toEqual({ type: 'message_start' });
|
|
});
|
|
});
|
|
|
|
describe('ADR-0042 eventTranslator — stream-json passthrough', () => {
|
|
// The translator reads STREAM_FORMAT at module load. We can't easily flip
|
|
// it mid-test without re-importing the module — vi.resetModules + dynamic
|
|
// import is the standard pattern.
|
|
it('passes Anthropic stream-json events through unchanged when AGENT_STREAM_FORMAT=stream-json', async () => {
|
|
vi.resetModules();
|
|
const prev = process.env.AGENT_STREAM_FORMAT;
|
|
process.env.AGENT_STREAM_FORMAT = 'stream-json';
|
|
try {
|
|
const mod = await import('../run-stream-handler.mjs');
|
|
const evt = { type: 'message_start', message: { id: 'msg_1' } };
|
|
expect(mod.eventTranslator(evt)).toBe(evt);
|
|
// info also passes through (not translated).
|
|
const info = { type: 'info', message: 'x' };
|
|
expect(mod.eventTranslator(info)).toBe(info);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.AGENT_STREAM_FORMAT;
|
|
else process.env.AGENT_STREAM_FORMAT = prev;
|
|
vi.resetModules();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('ADR-0042 warnLegacyOnce', () => {
|
|
let warnSpy;
|
|
beforeEach(() => {
|
|
_resetLegacyWarnForTest();
|
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
afterEach(() => {
|
|
warnSpy.mockRestore();
|
|
_resetLegacyWarnForTest();
|
|
});
|
|
|
|
it('prints exactly once on repeated calls in legacy mode', () => {
|
|
warnLegacyOnce();
|
|
warnLegacyOnce();
|
|
warnLegacyOnce();
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(warnSpy.mock.calls[0][0]).toContain('AGENT_STREAM_FORMAT=legacy');
|
|
});
|
|
});
|