This commit is contained in:
seb
2026-09-26 19:19:48 +02:00
parent 5b1d896157
commit 0149846a15
10 changed files with 822 additions and 29 deletions

207
test/turn-flow.test.js Normal file
View 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
View 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');
});