u
This commit is contained in:
207
test/turn-flow.test.js
Normal file
207
test/turn-flow.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Connection-state flow through the real store and the real REPL provider
|
||||
* callbacks, driven by a fake provider. No live model call, no mounted UI: the
|
||||
* orbiter is bypassed and its callbacks are replayed by hand, which is exactly
|
||||
* what the provider contract guarantees.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement as h } from 'react';
|
||||
import { renderToString } from 'ink';
|
||||
|
||||
import { createTurnHandlers, dispatchTurnEvent } from '../src/repl.js';
|
||||
import { TURN_EVENTS, TURN_STATES } from '../src/turn-state.js';
|
||||
import { createStore } from '../src/ui/store.js';
|
||||
import { App } from '../src/ui/app.js';
|
||||
|
||||
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
|
||||
/**
|
||||
* A fake provider turn. `script` is a list of provider callbacks to invoke in
|
||||
* order, the way LLMOrbiter would during `orbiter.run(...)`. It records the
|
||||
* state after every callback, so the observed label sequence is an assertion
|
||||
* about the real wiring rather than about the reducer alone.
|
||||
*/
|
||||
function playTurn(script) {
|
||||
const store = createStore();
|
||||
const uiApi = {
|
||||
store,
|
||||
setTurnState: (state) => store.setTurnState(state),
|
||||
setStatusDetail: (text) => store.setStatusDetail(text),
|
||||
setReasoning: (text) => store.setReasoningDraft(text),
|
||||
clearReasoning: () => store.setReasoningDraft(''),
|
||||
finalizeReasoning: () => store.finalizeReasoning(),
|
||||
log: (text) => store.log(text),
|
||||
};
|
||||
const turnReasoning = { text: '' };
|
||||
const handlers = createTurnHandlers(turnReasoning, uiApi);
|
||||
|
||||
const observed = [];
|
||||
const record = () => observed.push(store.getState().status);
|
||||
|
||||
store.setBusy(true);
|
||||
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
|
||||
record();
|
||||
for (const step of script) {
|
||||
handlers[step.cb](step.arg);
|
||||
record();
|
||||
}
|
||||
dispatchTurnEvent(TURN_EVENTS.TURN_END, uiApi);
|
||||
store.setBusy(false);
|
||||
record();
|
||||
return { store, observed };
|
||||
}
|
||||
|
||||
test('a plain answer reads as connecting -> streaming', () => {
|
||||
const { observed } = playTurn([
|
||||
{ cb: 'onContent' },
|
||||
{ cb: 'onContent' },
|
||||
]);
|
||||
assert.deepEqual(observed, [
|
||||
TURN_STATES.CONNECTING,
|
||||
TURN_STATES.STREAMING,
|
||||
TURN_STATES.STREAMING,
|
||||
TURN_STATES.IDLE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('reasoning then an answer reads as connecting -> thinking -> streaming', () => {
|
||||
const { observed } = playTurn([
|
||||
{ cb: 'onReasoning', arg: 'weighing options' },
|
||||
{ cb: 'onReasoning', arg: 'weighing options carefully' },
|
||||
{ cb: 'onContent' },
|
||||
]);
|
||||
assert.deepEqual(observed, [
|
||||
TURN_STATES.CONNECTING,
|
||||
TURN_STATES.THINKING,
|
||||
TURN_STATES.THINKING,
|
||||
TURN_STATES.STREAMING,
|
||||
TURN_STATES.IDLE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('reasoning, a tool, then an answer reads as connecting -> thinking -> working -> streaming', () => {
|
||||
const { observed } = playTurn([
|
||||
{ cb: 'onReasoning', arg: 'need to list the directory' },
|
||||
{ cb: 'onToolCall', arg: { name: 'read_file', args: { path: 'README.md' } } },
|
||||
{ cb: 'onContent' },
|
||||
]);
|
||||
assert.deepEqual(observed, [
|
||||
TURN_STATES.CONNECTING,
|
||||
TURN_STATES.THINKING,
|
||||
TURN_STATES.WORKING,
|
||||
TURN_STATES.STREAMING,
|
||||
TURN_STATES.IDLE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('a turn that produces no deltas stays in connecting until it ends', () => {
|
||||
const { observed } = playTurn([]);
|
||||
assert.deepEqual(observed, [TURN_STATES.CONNECTING, TURN_STATES.IDLE]);
|
||||
});
|
||||
|
||||
test('each new turn starts at connecting with no bleed from the previous turn', () => {
|
||||
const store = createStore();
|
||||
const uiApi = { store, setTurnState: (s) => store.setTurnState(s) };
|
||||
|
||||
store.setBusy(true);
|
||||
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
|
||||
dispatchTurnEvent(TURN_EVENTS.CONTENT, uiApi);
|
||||
dispatchTurnEvent(TURN_EVENTS.TURN_END, uiApi);
|
||||
store.setBusy(false);
|
||||
assert.equal(store.getState().status, TURN_STATES.IDLE);
|
||||
|
||||
store.setBusy(true);
|
||||
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
|
||||
assert.equal(
|
||||
store.getState().status,
|
||||
TURN_STATES.CONNECTING,
|
||||
'the next turn begins at connecting, not the previous terminal state',
|
||||
);
|
||||
});
|
||||
|
||||
test('cancel and error both fall back to idle so the prompt returns', () => {
|
||||
for (const event of [TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
|
||||
const store = createStore();
|
||||
const uiApi = { store, setTurnState: (s) => store.setTurnState(s) };
|
||||
store.setBusy(true);
|
||||
dispatchTurnEvent(TURN_EVENTS.REASONING, uiApi);
|
||||
dispatchTurnEvent(event, uiApi);
|
||||
assert.equal(store.getState().status, TURN_STATES.IDLE, `${event} resolves to idle`);
|
||||
store.setBusy(false);
|
||||
assert.equal(store.getState().status, TURN_STATES.IDLE);
|
||||
}
|
||||
});
|
||||
|
||||
test('clearing busy returns the state to idle even without a turn_end event', () => {
|
||||
const store = createStore();
|
||||
store.setBusy(true);
|
||||
store.setTurnState(TURN_STATES.STREAMING);
|
||||
store.setBusy(false);
|
||||
assert.equal(store.getState().status, TURN_STATES.IDLE, 'no path strands the bar busy');
|
||||
assert.equal(store.getState().statusDetail, '', 'the stale detail is dropped too');
|
||||
});
|
||||
|
||||
test('the store ignores values outside the closed state enum', () => {
|
||||
const store = createStore();
|
||||
store.setBusy(true);
|
||||
store.setTurnState(TURN_STATES.THINKING);
|
||||
store.setTurnState('thinking…');
|
||||
assert.equal(store.getState().status, TURN_STATES.THINKING, 'free text cannot replace a state');
|
||||
});
|
||||
|
||||
test('provider status text is a detail, never a replacement for the state', () => {
|
||||
const store = createStore();
|
||||
const uiApi = {
|
||||
store,
|
||||
setTurnState: (state) => store.setTurnState(state),
|
||||
setStatusDetail: (text) => store.setStatusDetail(text),
|
||||
setReasoning: () => {},
|
||||
clearReasoning: () => {},
|
||||
finalizeReasoning: () => {},
|
||||
log: () => {},
|
||||
};
|
||||
const handlers = createTurnHandlers({ text: '' }, uiApi);
|
||||
|
||||
store.setBusy(true);
|
||||
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
|
||||
handlers.onStatus('connected');
|
||||
assert.equal(
|
||||
store.getState().status,
|
||||
TURN_STATES.CONNECTING,
|
||||
'status text does not move the state out of connecting',
|
||||
);
|
||||
assert.equal(store.getState().statusDetail, 'connected', 'it is kept as a detail');
|
||||
|
||||
// A later delta still wins, even with provider text present.
|
||||
handlers.onContent();
|
||||
assert.equal(store.getState().status, TURN_STATES.STREAMING, 'the delta drives the state');
|
||||
assert.equal(store.getState().statusDetail, 'connected', 'the detail survives as decoration');
|
||||
});
|
||||
|
||||
test('the command bar renders the label for every non-idle state', () => {
|
||||
const expectations = [
|
||||
[TURN_STATES.CONNECTING, 'connecting…'],
|
||||
[TURN_STATES.THINKING, 'thinking…'],
|
||||
[TURN_STATES.WORKING, 'working…'],
|
||||
[TURN_STATES.STREAMING, 'streaming…'],
|
||||
];
|
||||
for (const [state, label] of expectations) {
|
||||
const store = createStore();
|
||||
store.setBusy(true);
|
||||
store.setTurnState(state);
|
||||
const out = strip(renderToString(h(App, { store, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
|
||||
assert.ok(out.includes(label), `${state} renders as ${label}`);
|
||||
assert.ok(!out.includes('› (idle)'), `${state} is not shown as idle`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the idle placeholder shows only when no turn is running', () => {
|
||||
const store = createStore();
|
||||
store.setBusy(false);
|
||||
const out = strip(renderToString(h(App, { store, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
|
||||
assert.ok(out.includes('› (idle)'), 'idle placeholder renders when not busy');
|
||||
});
|
||||
147
test/turn-state.test.js
Normal file
147
test/turn-state.test.js
Normal file
@@ -0,0 +1,147 @@
|
||||
'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');
|
||||
});
|
||||
Reference in New Issue
Block a user