Files
fidoca/test/turn-flow.test.js
2026-09-27 23:45:07 +02:00

223 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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, stampUserInput } 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 user message is prefixed with a zero-padded 24-hour local time', () => {
const at = (h, m) => new Date(2026, 8, 17, h, m);
assert.equal(stampUserInput('add a test', at(14, 32)), '[14:32] add a test');
assert.equal(stampUserInput('list dirs', at(9, 5)), '[09:05] list dirs');
assert.equal(stampUserInput('midnight', at(0, 0)), '[00:00] midnight');
assert.equal(stampUserInput('last minute', at(23, 59)), '[23:59] last minute');
});
test('the timestamp prefix never touches the text it wraps', () => {
const at = new Date(2026, 8, 17, 8, 7);
const text = ' run websearch with start_date \n and a second line ';
assert.equal(stampUserInput(text, at), `[08:07] ${text}`);
assert.equal(stampUserInput('', at), '[08:07] ');
});
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');
});