'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'); });