223 lines
8.3 KiB
JavaScript
223 lines
8.3 KiB
JavaScript
'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');
|
||
});
|