148 lines
5.6 KiB
JavaScript
148 lines
5.6 KiB
JavaScript
'use strict';
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
TURN_STATES,
|
|
TURN_EVENTS,
|
|
isTurnState,
|
|
nextState,
|
|
statusLabel,
|
|
} from '../src/turn-state.js';
|
|
|
|
// Every state the machine can be in when a delta arrives.
|
|
const BUSY_STATES = [
|
|
TURN_STATES.CONNECTING,
|
|
TURN_STATES.THINKING,
|
|
TURN_STATES.WORKING,
|
|
TURN_STATES.STREAMING,
|
|
];
|
|
const DELTA_EVENTS = [TURN_EVENTS.REASONING, TURN_EVENTS.TOOL_CALL, TURN_EVENTS.CONTENT];
|
|
|
|
test('the state set is closed and enumerated', () => {
|
|
assert.deepEqual(Object.values(TURN_STATES), [
|
|
'idle', 'connecting', 'thinking', 'working', 'streaming',
|
|
]);
|
|
for (const state of Object.values(TURN_STATES)) {
|
|
assert.equal(isTurnState(state), true, `${state} is a known state`);
|
|
}
|
|
for (const value of ['', 'thinking…', 'IDLE', null, undefined, 3, {}]) {
|
|
assert.equal(isTurnState(value), false, `${String(value)} is not a state`);
|
|
}
|
|
});
|
|
|
|
test('turn_start always yields connecting, from any state', () => {
|
|
for (const current of Object.values(TURN_STATES)) {
|
|
assert.equal(
|
|
nextState(current, TURN_EVENTS.TURN_START),
|
|
TURN_STATES.CONNECTING,
|
|
`turn_start from ${current}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('turn end, cancel, and error always resolve to idle', () => {
|
|
for (const current of Object.values(TURN_STATES)) {
|
|
for (const event of [TURN_EVENTS.TURN_END, TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
|
|
assert.equal(
|
|
nextState(current, event),
|
|
TURN_STATES.IDLE,
|
|
`${event} from ${current}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('each delta kind maps to its state from every state', () => {
|
|
const expected = {
|
|
[TURN_EVENTS.REASONING]: TURN_STATES.THINKING,
|
|
[TURN_EVENTS.TOOL_CALL]: TURN_STATES.WORKING,
|
|
[TURN_EVENTS.CONTENT]: TURN_STATES.STREAMING,
|
|
};
|
|
for (const from of [...BUSY_STATES, TURN_STATES.IDLE]) {
|
|
for (const event of DELTA_EVENTS) {
|
|
assert.equal(
|
|
nextState(from, event),
|
|
expected[event],
|
|
`${event} from ${from}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a later delta moves the state backward, never stalling on the furthest point', () => {
|
|
// Streaming back to thinking, and thinking back to working.
|
|
assert.equal(
|
|
nextState(TURN_STATES.STREAMING, TURN_EVENTS.REASONING),
|
|
TURN_STATES.THINKING,
|
|
'a reasoning delta after content moves back to thinking',
|
|
);
|
|
assert.equal(
|
|
nextState(TURN_STATES.THINKING, TURN_EVENTS.TOOL_CALL),
|
|
TURN_STATES.WORKING,
|
|
'a tool-call delta after reasoning moves back to working',
|
|
);
|
|
assert.equal(
|
|
nextState(TURN_STATES.WORKING, TURN_EVENTS.CONTENT),
|
|
TURN_STATES.STREAMING,
|
|
'content after a tool call moves forward to streaming',
|
|
);
|
|
});
|
|
|
|
test('a repeated delta of the same kind still updates the state', () => {
|
|
for (const event of DELTA_EVENTS) {
|
|
const first = nextState(TURN_STATES.CONNECTING, event);
|
|
const again = nextState(first, event);
|
|
assert.equal(again, first, `${event} is idempotent on its own state`);
|
|
}
|
|
});
|
|
|
|
test('a turn with no reasoning and no tools goes connecting -> streaming', () => {
|
|
let state = nextState(TURN_STATES.IDLE, TURN_EVENTS.TURN_START);
|
|
assert.equal(state, TURN_STATES.CONNECTING);
|
|
for (let i = 0; i < 3; i++) state = nextState(state, TURN_EVENTS.CONTENT);
|
|
assert.equal(state, TURN_STATES.STREAMING);
|
|
state = nextState(state, TURN_EVENTS.TURN_END);
|
|
assert.equal(state, TURN_STATES.IDLE);
|
|
});
|
|
|
|
test('a reasoning-then-tool turn goes connecting -> thinking -> working', () => {
|
|
let state = nextState(TURN_STATES.IDLE, TURN_EVENTS.TURN_START);
|
|
const states = [state];
|
|
for (const event of [TURN_EVENTS.REASONING, TURN_EVENTS.TOOL_CALL, TURN_EVENTS.TURN_END]) {
|
|
state = nextState(state, event);
|
|
states.push(state);
|
|
}
|
|
assert.deepEqual(states, ['connecting', 'thinking', 'working', 'idle']);
|
|
});
|
|
|
|
test('no turn ever begins in a busy state and no delta strands the session', () => {
|
|
for (const terminal of [TURN_EVENTS.TURN_END, TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
|
|
const state = nextState(TURN_STATES.STREAMING, terminal);
|
|
const restarted = nextState(state, TURN_EVENTS.TURN_START);
|
|
assert.equal(restarted, TURN_STATES.CONNECTING, 'a new turn always starts at connecting');
|
|
}
|
|
});
|
|
|
|
test('the reducer is total: an unknown event never throws and never invents a state', () => {
|
|
for (const current of [...Object.values(TURN_STATES), 'bogus', undefined, null]) {
|
|
for (const event of ['nope', undefined, null, 42]) {
|
|
const next = nextState(current, event);
|
|
assert.equal(isTurnState(next), true, `unknown event keeps a real state`);
|
|
}
|
|
}
|
|
// A known current state survives an unknown event; an invalid one resets.
|
|
assert.equal(nextState(TURN_STATES.WORKING, 'nope'), TURN_STATES.WORKING);
|
|
assert.equal(nextState('bogus', 'nope'), TURN_STATES.IDLE);
|
|
});
|
|
|
|
test('statusLabel names each busy state and leaves idle to the placeholder', () => {
|
|
assert.equal(statusLabel(TURN_STATES.CONNECTING), 'connecting…');
|
|
assert.equal(statusLabel(TURN_STATES.THINKING), 'thinking…');
|
|
assert.equal(statusLabel(TURN_STATES.WORKING), 'working…');
|
|
assert.equal(statusLabel(TURN_STATES.STREAMING), 'streaming…');
|
|
assert.equal(statusLabel(TURN_STATES.IDLE), '', 'idle is not rendered by the spinner');
|
|
assert.equal(statusLabel('bogus'), '', 'an unknown value renders no busy label');
|
|
});
|