// THIS MODULE IS TRANSPORT-AGNOSTIC AND IS THE ONLY IMPLEMENTATION OF THE TURN LOOP. // Both avatar.js (inline gr.HTML) and avatar-iframe.js (postMessage) import it. // Do NOT add turn behaviour to either transport file - add it here, or the iframe // fallback silently loses the feature. tests/test_transport_seam.py enforces this. // // The renderer is reachable only through the six-method stagePort. The host bridge is // reachable only through getServer(), which is a GETTER rather than a value so this // module never holds a host object and can be constructed before the bridge exists. /** * A deferred method. Calling one now fails loudly and names the plan that fills it in, * which is the difference between a placeholder and an accidental no-op. The parity * guard is therefore meaningful from this wave rather than only after the last one. */ function notWiredYet(name, plan) { return () => { throw new Error(`Avatar.${name}() is not wired yet - plan ${plan} implements it`); }; } /** * @param {object} opts * @param {object} opts.stagePort the renderer boundary * @param {Function} [opts.emit] the facade's event bus * @param {Function} [opts.getServer] returns the host bridge, or null when there is none */ export function createTurnLoop({ stagePort, emit = () => {}, getServer = () => null } = {}) { if (!stagePort) throw new Error('createTurnLoop: a stagePort is required'); // The turn loop's own slice of __debug. The facade merges this object; it does not // own it, and this module does not own the facade's. const state = { thinking: false, listening: false, lastTurnId: null, replayCount: 0, }; return { state, getServer, setThinking(value) { const on = !!value; state.thinking = on; stagePort.setThinking(on); return on; }, setListening(value) { const on = !!value; state.listening = on; stagePort.setListening(on); return on; }, /** * Re-play the cached directive. There is deliberately no network access of any * kind in here: plan 01-09's test_replay asserts zero requests with a browser * request listener, and a cache miss must fail rather than quietly re-download. */ async replay() { state.replayCount += 1; try { return await stagePort.replayCached(); } catch (err) { emit('error', { message: String(err?.message ?? err), where: 'replay' }); throw err; } }, startListening: notWiredYet('startListening', '01-07'), stopListening: notWiredYet('stopListening', '01-07'), dispatchTurn: notWiredYet('dispatchTurn', '01-08'), requestSlower: notWiredYet('requestSlower', '01-08'), }; }