Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
420 lines
16 KiB
JavaScript
420 lines
16 KiB
JavaScript
/**
|
||
* ADR-0042 — FSM unit tests (Task 1).
|
||
*
|
||
* Pure-module tests: no DB, no /proc, no spawn. Boot guard via
|
||
* backend/test/setup.js still runs because `import` triggers the chain.
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
|
||
import {
|
||
STATES,
|
||
SIDE_EFFECTS,
|
||
INITIAL,
|
||
DEFAULT_COMPLETION_TOOLS,
|
||
DEFAULT_TERMINAL_STATES,
|
||
transition,
|
||
} from '../state-machine.js';
|
||
|
||
const tu = (name, id = `toolu_${name}`, input = null) => ({
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'tool_use', id, name, input },
|
||
});
|
||
|
||
const trAlias = (id) => ({ type: 'tool_result', tool_use_id: id });
|
||
|
||
const trUserMsg = (id) => ({
|
||
type: 'user',
|
||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'ok' }] },
|
||
});
|
||
|
||
const txtBlock = () => ({
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'text', text: '' },
|
||
});
|
||
|
||
const has = (arr, x) => arr.includes(x);
|
||
|
||
describe('ADR-0042 FSM — basics', () => {
|
||
it('exports the 5 documented states + SIDE_EFFECTS', () => {
|
||
expect(STATES).toEqual({
|
||
IDLE: 'idle',
|
||
THINKING: 'thinking',
|
||
TOOL_ACTIVE: 'tool_active',
|
||
CLOSING: 'closing',
|
||
STUCK_CHECK: 'stuck_check',
|
||
});
|
||
expect(SIDE_EFFECTS.BUMP_EVENT).toBe('bump_event');
|
||
expect(SIDE_EFFECTS.TOOL_STARTED).toBe('tool_started');
|
||
expect(SIDE_EFFECTS.TOOL_FINISHED).toBe('tool_finished');
|
||
expect(SIDE_EFFECTS.BUMP_COMPLETION_INTENT).toBe('bump_completion_intent');
|
||
expect(SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT).toBe('bump_meaningful_count');
|
||
expect(INITIAL).toEqual({ state: STATES.IDLE, currentTool: null });
|
||
});
|
||
|
||
it('default completion tools include canonical ADR-0042 §config list', () => {
|
||
expect(DEFAULT_COMPLETION_TOOLS).toEqual(
|
||
expect.arrayContaining(['send_chat_message', 'send_widget_message', 'send_ticket_message']),
|
||
);
|
||
});
|
||
|
||
it('default terminal states match §config (Done/Closed/Resolved)', () => {
|
||
expect(DEFAULT_TERMINAL_STATES).toEqual(
|
||
expect.arrayContaining(['Done', 'Closed', 'Resolved']),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('ADR-0042 FSM — table-driven (5 source states × event types)', () => {
|
||
// Case ledger (table-driven): each row is { name, prev, evt, expectedState, expectedToolName?, expectedSideEffects[]? }.
|
||
const stateActiveBash = { state: STATES.TOOL_ACTIVE, currentTool: { name: 'Bash', tool_use_id: 'toolu_Bash', attempt_idx: 0 } };
|
||
const stateClosingChat = { state: STATES.CLOSING, currentTool: { name: 'send_chat_message', tool_use_id: 'toolu_send_chat_message', attempt_idx: 0 } };
|
||
|
||
const cases = [
|
||
// ── idle source ─────────────────────────────────────────────────────
|
||
{
|
||
name: 'idle + message_start → thinking',
|
||
prev: { state: STATES.IDLE, currentTool: null },
|
||
evt: { type: 'message_start', message: {} },
|
||
expectedState: STATES.THINKING,
|
||
expectedSideEffects: [SIDE_EFFECTS.BUMP_EVENT, SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT],
|
||
},
|
||
{
|
||
name: 'idle + content_block_start.text → thinking',
|
||
prev: { state: STATES.IDLE, currentTool: null },
|
||
evt: txtBlock(),
|
||
expectedState: STATES.THINKING,
|
||
expectedSideEffects: [SIDE_EFFECTS.BUMP_EVENT],
|
||
},
|
||
{
|
||
name: 'idle + tool_use Bash → tool_active (currentTool captured)',
|
||
prev: { state: STATES.IDLE, currentTool: null },
|
||
evt: tu('Bash'),
|
||
expectedState: STATES.TOOL_ACTIVE,
|
||
expectedToolName: 'Bash',
|
||
expectedSideEffects: [
|
||
SIDE_EFFECTS.BUMP_EVENT,
|
||
SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT,
|
||
SIDE_EFFECTS.TOOL_STARTED,
|
||
],
|
||
},
|
||
{
|
||
name: 'idle + tool_use send_chat_message → closing (completion intent)',
|
||
prev: { state: STATES.IDLE, currentTool: null },
|
||
evt: tu('send_chat_message'),
|
||
expectedState: STATES.CLOSING,
|
||
expectedToolName: 'send_chat_message',
|
||
expectedSideEffects: [
|
||
SIDE_EFFECTS.BUMP_EVENT,
|
||
SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT,
|
||
SIDE_EFFECTS.TOOL_STARTED,
|
||
SIDE_EFFECTS.BUMP_COMPLETION_INTENT,
|
||
],
|
||
},
|
||
{
|
||
name: 'idle + message_stop → idle (no-op state)',
|
||
prev: { state: STATES.IDLE, currentTool: null },
|
||
evt: { type: 'message_stop' },
|
||
expectedState: STATES.IDLE,
|
||
},
|
||
{
|
||
name: 'idle + orphan tool_result → idle (state unchanged, no tool to clear)',
|
||
prev: { state: STATES.IDLE, currentTool: null },
|
||
evt: trAlias('toolu_xyz'),
|
||
expectedState: STATES.IDLE,
|
||
},
|
||
|
||
// ── thinking source ─────────────────────────────────────────────────
|
||
{
|
||
name: 'thinking + tool_use Bash → tool_active',
|
||
prev: { state: STATES.THINKING, currentTool: null },
|
||
evt: tu('Bash'),
|
||
expectedState: STATES.TOOL_ACTIVE,
|
||
expectedToolName: 'Bash',
|
||
},
|
||
{
|
||
name: 'thinking + tool_use send_chat_message → closing',
|
||
prev: { state: STATES.THINKING, currentTool: null },
|
||
evt: tu('send_chat_message'),
|
||
expectedState: STATES.CLOSING,
|
||
},
|
||
{
|
||
name: 'thinking + message_stop → idle',
|
||
prev: { state: STATES.THINKING, currentTool: null },
|
||
evt: { type: 'message_stop' },
|
||
expectedState: STATES.IDLE,
|
||
},
|
||
{
|
||
name: 'thinking + message_start → thinking (no double-enter)',
|
||
prev: { state: STATES.THINKING, currentTool: null },
|
||
evt: { type: 'message_start' },
|
||
expectedState: STATES.THINKING,
|
||
},
|
||
{
|
||
name: 'thinking + content_block_delta → thinking (just bump_event)',
|
||
prev: { state: STATES.THINKING, currentTool: null },
|
||
evt: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hello' } },
|
||
expectedState: STATES.THINKING,
|
||
expectedSideEffects: [SIDE_EFFECTS.BUMP_EVENT],
|
||
},
|
||
|
||
// ── tool_active source ──────────────────────────────────────────────
|
||
{
|
||
name: 'tool_active + matching tool_result → idle (tool cleared)',
|
||
prev: stateActiveBash,
|
||
evt: trAlias('toolu_Bash'),
|
||
expectedState: STATES.IDLE,
|
||
expectedToolName: null,
|
||
expectedSideEffects: [
|
||
SIDE_EFFECTS.BUMP_EVENT,
|
||
SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT,
|
||
SIDE_EFFECTS.TOOL_FINISHED,
|
||
],
|
||
},
|
||
{
|
||
name: 'tool_active + non-matching tool_result → tool_active unchanged',
|
||
prev: stateActiveBash,
|
||
evt: trAlias('toolu_other'),
|
||
expectedState: STATES.TOOL_ACTIVE,
|
||
expectedToolName: 'Bash',
|
||
},
|
||
{
|
||
name: 'tool_active + tool_use send_chat_message → closing (completion intent overrides)',
|
||
prev: stateActiveBash,
|
||
evt: tu('send_chat_message'),
|
||
expectedState: STATES.CLOSING,
|
||
expectedToolName: 'send_chat_message',
|
||
},
|
||
{
|
||
name: 'tool_active + new non-completion tool_use → tool_active replaced (nested call)',
|
||
prev: stateActiveBash,
|
||
evt: tu('Read', 'toolu_Read'),
|
||
expectedState: STATES.TOOL_ACTIVE,
|
||
expectedToolName: 'Read',
|
||
},
|
||
{
|
||
name: 'tool_active + message_stop → tool_active (waiting for tool_result)',
|
||
prev: stateActiveBash,
|
||
evt: { type: 'message_stop' },
|
||
expectedState: STATES.TOOL_ACTIVE,
|
||
expectedToolName: 'Bash',
|
||
},
|
||
|
||
// ── closing source ──────────────────────────────────────────────────
|
||
{
|
||
name: 'closing + matching tool_result → idle',
|
||
prev: stateClosingChat,
|
||
evt: trAlias('toolu_send_chat_message'),
|
||
expectedState: STATES.IDLE,
|
||
expectedToolName: null,
|
||
},
|
||
{
|
||
name: 'closing + tool_use Read (non-completion) → closing (sticky, currentTool preserved)',
|
||
prev: stateClosingChat,
|
||
evt: tu('Read', 'toolu_Read'),
|
||
expectedState: STATES.CLOSING,
|
||
expectedToolName: 'send_chat_message',
|
||
},
|
||
{
|
||
name: 'closing + message_stop → idle',
|
||
prev: stateClosingChat,
|
||
evt: { type: 'message_stop' },
|
||
expectedState: STATES.IDLE,
|
||
},
|
||
|
||
// ── stuck_check source (hold) ───────────────────────────────────────
|
||
{
|
||
name: 'stuck_check + any event → stuck_check (frozen)',
|
||
prev: { state: STATES.STUCK_CHECK, currentTool: stateActiveBash.currentTool },
|
||
evt: tu('Bash'),
|
||
expectedState: STATES.STUCK_CHECK,
|
||
expectedToolName: 'Bash',
|
||
expectedSideEffects: [SIDE_EFFECTS.BUMP_EVENT],
|
||
},
|
||
{
|
||
name: 'stuck_check + message_stop → stuck_check (still frozen)',
|
||
prev: { state: STATES.STUCK_CHECK, currentTool: null },
|
||
evt: { type: 'message_stop' },
|
||
expectedState: STATES.STUCK_CHECK,
|
||
},
|
||
];
|
||
|
||
for (const c of cases) {
|
||
it(c.name, () => {
|
||
const out = transition(c.prev, c.evt, {});
|
||
expect(out.state, `state for: ${c.name}`).toBe(c.expectedState);
|
||
if (Object.prototype.hasOwnProperty.call(c, 'expectedToolName')) {
|
||
if (c.expectedToolName === null) {
|
||
expect(out.currentTool).toBeNull();
|
||
} else {
|
||
expect(out.currentTool, `tool for: ${c.name}`).toMatchObject({ name: c.expectedToolName });
|
||
}
|
||
}
|
||
if (c.expectedSideEffects) {
|
||
for (const sfx of c.expectedSideEffects) {
|
||
expect(has(out.sideEffects, sfx), `side effect ${sfx} for: ${c.name}`).toBe(true);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('ADR-0042 FSM — completion-intent variants', () => {
|
||
it('idle + tool_use update_ticket_status (status=Done) → closing (terminal-state path)', () => {
|
||
const evt = tu('update_ticket_status', 'toolu_uts', { status: 'Done' });
|
||
const out = transition({ state: STATES.IDLE, currentTool: null }, evt, {});
|
||
expect(out.state).toBe(STATES.CLOSING);
|
||
expect(has(out.sideEffects, SIDE_EFFECTS.BUMP_COMPLETION_INTENT)).toBe(true);
|
||
});
|
||
|
||
it('idle + tool_use update_ticket_status (status=Working) → tool_active (non-terminal)', () => {
|
||
const evt = tu('update_ticket_status', 'toolu_uts', { status: 'Working' });
|
||
const out = transition({ state: STATES.IDLE, currentTool: null }, evt, {});
|
||
expect(out.state).toBe(STATES.TOOL_ACTIVE);
|
||
expect(has(out.sideEffects, SIDE_EFFECTS.BUMP_COMPLETION_INTENT)).toBe(false);
|
||
});
|
||
|
||
it('opts.completionTools override is honored (custom set instead of defaults)', () => {
|
||
const evt = tu('send_chat_message');
|
||
// Override removes send_chat_message from completion list.
|
||
const out = transition({ state: STATES.IDLE, currentTool: null }, evt, {
|
||
completionTools: new Set(['custom_close']),
|
||
});
|
||
expect(out.state).toBe(STATES.TOOL_ACTIVE);
|
||
});
|
||
|
||
it('opts.terminalStates override is honored', () => {
|
||
const evt = tu('update_ticket_status', 'toolu_uts', { status: 'Shipped' });
|
||
const out = transition({ state: STATES.IDLE, currentTool: null }, evt, {
|
||
terminalStates: new Set(['Shipped']),
|
||
});
|
||
expect(out.state).toBe(STATES.CLOSING);
|
||
});
|
||
|
||
it('completion-intent side effect fires only on the entry edge into closing', () => {
|
||
// First transition: idle → closing (entry)
|
||
const t1 = transition({ state: STATES.IDLE, currentTool: null }, tu('send_chat_message'), {});
|
||
expect(has(t1.sideEffects, SIDE_EFFECTS.BUMP_COMPLETION_INTENT)).toBe(true);
|
||
// Second transition: closing → closing (already inside)
|
||
const t2 = transition(t1, tu('send_chat_message', 'toolu_2'), {});
|
||
expect(has(t2.sideEffects, SIDE_EFFECTS.BUMP_COMPLETION_INTENT)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('ADR-0042 FSM — tool_result variants', () => {
|
||
it('Anthropic-style user-message-wrapped tool_result is accepted', () => {
|
||
const prev = { state: STATES.TOOL_ACTIVE, currentTool: { name: 'Bash', tool_use_id: 'toolu_Bash', attempt_idx: 0 } };
|
||
const out = transition(prev, trUserMsg('toolu_Bash'), {});
|
||
expect(out.state).toBe(STATES.IDLE);
|
||
expect(out.currentTool).toBeNull();
|
||
expect(has(out.sideEffects, SIDE_EFFECTS.TOOL_FINISHED)).toBe(true);
|
||
});
|
||
|
||
it('tool_result without tool_use_id matches the active tool (lenient)', () => {
|
||
const prev = { state: STATES.TOOL_ACTIVE, currentTool: { name: 'Bash', tool_use_id: 'toolu_Bash', attempt_idx: 0 } };
|
||
const out = transition(prev, { type: 'tool_result' }, {});
|
||
expect(out.state).toBe(STATES.IDLE);
|
||
});
|
||
});
|
||
|
||
describe('ADR-0042 FSM — attempt_idx tracking', () => {
|
||
it('repeat tool_use of same name increments attempt_idx', () => {
|
||
const prev = { state: STATES.TOOL_ACTIVE, currentTool: { name: 'Bash', tool_use_id: 'toolu_1', attempt_idx: 0 } };
|
||
const out = transition(prev, tu('Bash', 'toolu_2'), {});
|
||
expect(out.currentTool.attempt_idx).toBe(1);
|
||
});
|
||
|
||
it('different tool_use name resets attempt_idx to 0', () => {
|
||
const prev = { state: STATES.TOOL_ACTIVE, currentTool: { name: 'Bash', tool_use_id: 'toolu_1', attempt_idx: 3 } };
|
||
const out = transition(prev, tu('Read', 'toolu_2'), {});
|
||
expect(out.currentTool.attempt_idx).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('ADR-0042 FSM — malformed events do not throw', () => {
|
||
const prev = { state: STATES.THINKING, currentTool: null };
|
||
|
||
it.each([
|
||
['null evt', null],
|
||
['undefined evt', undefined],
|
||
['number evt', 42],
|
||
['string evt', 'message_start'],
|
||
['empty object', {}],
|
||
['object missing type', { foo: 'bar' }],
|
||
['type non-string', { type: 99 }],
|
||
['content_block_start with garbage block', { type: 'content_block_start', content_block: 'oops' }],
|
||
['tool_use without name', { type: 'tool_use', id: 'x' }],
|
||
['user message with non-array content', { type: 'user', message: { content: 'not an array' } }],
|
||
])('%s → state unchanged, no throw', (_label, evt) => {
|
||
let out;
|
||
expect(() => { out = transition(prev, evt, {}); }).not.toThrow();
|
||
expect(out.state).toBe(STATES.THINKING);
|
||
expect(out.currentTool).toBeNull();
|
||
});
|
||
|
||
it('null prev → treated as idle, no throw', () => {
|
||
const out = transition(null, { type: 'message_start' }, {});
|
||
expect(out.state).toBe(STATES.THINKING);
|
||
});
|
||
|
||
it('prev with non-object currentTool → treated as null', () => {
|
||
const out = transition({ state: STATES.TOOL_ACTIVE, currentTool: 'oops' }, trAlias('x'), {});
|
||
// No tool to match → orphan, state unchanged.
|
||
expect(out.state).toBe(STATES.TOOL_ACTIVE);
|
||
});
|
||
});
|
||
|
||
describe('ADR-0042 FSM — end-to-end sequence', () => {
|
||
it('idle → message_start → tool_use Bash → tool_result → message_stop → idle', () => {
|
||
let s = INITIAL;
|
||
|
||
s = transition(s, { type: 'message_start' }, {});
|
||
expect(s.state).toBe(STATES.THINKING);
|
||
|
||
s = transition(s, txtBlock(), {});
|
||
expect(s.state).toBe(STATES.THINKING);
|
||
|
||
s = transition(s, tu('Bash'), {});
|
||
expect(s.state).toBe(STATES.TOOL_ACTIVE);
|
||
expect(s.currentTool.name).toBe('Bash');
|
||
|
||
s = transition(s, trAlias('toolu_Bash'), {});
|
||
expect(s.state).toBe(STATES.IDLE);
|
||
expect(s.currentTool).toBeNull();
|
||
|
||
s = transition(s, { type: 'message_stop' }, {});
|
||
expect(s.state).toBe(STATES.IDLE);
|
||
});
|
||
|
||
it('full closing handoff: tool work → send_chat_message → tool_result → idle', () => {
|
||
let s = INITIAL;
|
||
|
||
s = transition(s, { type: 'message_start' }, {});
|
||
s = transition(s, tu('Read', 'toolu_Read'), {});
|
||
s = transition(s, trAlias('toolu_Read'), {});
|
||
expect(s.state).toBe(STATES.IDLE);
|
||
|
||
s = transition(s, tu('send_chat_message', 'toolu_close'), {});
|
||
expect(s.state).toBe(STATES.CLOSING);
|
||
|
||
s = transition(s, trAlias('toolu_close'), {});
|
||
expect(s.state).toBe(STATES.IDLE);
|
||
});
|
||
});
|
||
|
||
describe('ADR-0042 FSM — stuck_check semantics', () => {
|
||
it('stuck_check holds across structural events (only dispatcher exits it)', () => {
|
||
let s = { state: STATES.STUCK_CHECK, currentTool: { name: 'WebFetch', tool_use_id: 'toolu_w', attempt_idx: 0 } };
|
||
|
||
s = transition(s, tu('Bash'), {});
|
||
expect(s.state).toBe(STATES.STUCK_CHECK);
|
||
|
||
s = transition(s, trAlias('toolu_w'), {});
|
||
expect(s.state).toBe(STATES.STUCK_CHECK);
|
||
|
||
s = transition(s, { type: 'message_stop' }, {});
|
||
expect(s.state).toBe(STATES.STUCK_CHECK);
|
||
});
|
||
});
|