WolfDavid's picture
feat(01-03): add the single facade builder and the shared turn loop
7146e75
Raw History Blame
2.75 kB
// 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'),
};
}