From 18ae0c79da873b8b4a90b17c8ed6e2fa8307a6af Mon Sep 17 00:00:00 2001 From: riotcoder Date: Thu, 5 Nov 2026 17:39:06 +0000 Subject: [PATCH] fix: hosts coordinate on the greeting turn too, not only after it Episode 9 ended twenty seconds after Anthony picked up. He said "Hello? This is Anthony." and got two introductions on top of each other: 17:35:22.882 cha0spunk: Hello, I'm here, nice to have you on the show, Anthony. 17:35:24.665 riotcoder: Hey Anthony, glad you're here... (played until :43) Not a race: deterministic. The co-host block - do not answer what your co-host was asked, lead first and co-host waits a beat, reserve the floor - was gated on `!firstTurn`, and firstTurn is `shouldIntroduce()`, true whenever the agent has not spoken for three minutes. Both hosts dial in and then wait in an empty room while the guest is rung, which is always longer than three minutes. So on the guest's very first words both hosts counted as introducing themselves and skipped the stagger, the reservation and the one-voice rule entirely - in the ten seconds that decide whether he stays on the line. Episode 8's duplicate bare-metal question was the same hole. Coordination now runs on every interview turn. shouldIntroduce() goes back to being only what it says: whether the PROMPT should ask for a hello. The same staleness also switched off the guards that stop two hosts answering one line (`introduced && ...`), so any three-minute lull re-enabled double answers - and a long answer from the guest is exactly what the deep format asks for. Those now test whether the agent has spoken on this call at all. 110 pass, 0 fail; tsc clean. Co-Authored-By: Claude Opus 5 (1M context) --- ops/agent-phone.mjs | 22 ++++++++++++++++++++-- test/agent-phone.test.mjs | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ops/agent-phone.mjs b/ops/agent-phone.mjs index 9f68737..2d8191b 100755 --- a/ops/agent-phone.mjs +++ b/ops/agent-phone.mjs @@ -1136,7 +1136,19 @@ async function main() { const last = [...transcript].reverse().find((t) => !t.history && t.who !== agent); return Boolean(last && last.who === partnerAgent); })(); - if (join && a.interview && !firstTurn) { + /** + * Co-host coordination, on EVERY turn including the first one. + * + * This used to be gated on `!firstTurn`, and `firstTurn` is true + * whenever the agent has not spoken for three minutes. The hosts dial in + * and then wait in an empty room while the guest is rung, which is nearly + * always longer than that - so on the guest's very first words both hosts + * counted as introducing themselves, skipped the stagger, the reservation + * and the do-not-answer-your-co-host rule, and talked over each other in + * the first ten seconds of the show. Episode 9 ended twenty seconds after + * Anthony said hello and got two overlapping introductions. + */ + if (join && a.interview) { // Never answer a question the co-host put to the guest. if (partnerSpokeLast && !mine) return; if (!mine) { @@ -1213,7 +1225,13 @@ async function main() { const lastHeard = recentSpeech(); const mine = addressed(agent, lastHeard); const otherNamed = Object.keys(NAME_ALIASES).some((o) => o !== agent && addressed(o, lastHeard)) && !mine; - const introduced = !shouldIntroduce(agent, transcript, Date.now()); + /** + * Whether this agent has spoken on this call at all - NOT whether it + * spoke recently. These guards stop two hosts answering the same line, + * and tying them to introduction staleness turned them off after any + * three-minute lull: exactly the long guest answer the format asks for. + */ + const introduced = transcript.some((t) => t.who === agent && !t.history); if (introduced && kind !== 'hangup' && otherNamed) return; // The other agent already answered what we are answering: leave it, unless it was ours by name. const lineAt = Date.parse([...transcript].reverse().find((t) => t.who === 'them')?.at || '') || 0; diff --git a/test/agent-phone.test.mjs b/test/agent-phone.test.mjs index 3facc87..9fd8e58 100644 --- a/test/agent-phone.test.mjs +++ b/test/agent-phone.test.mjs @@ -297,6 +297,25 @@ test('a host saying its own name is not the guest, however the transcriber spell assert.equal(worker.echoesFloor('We run our own security and nothing else', 'rootkey handles the scanning for us'), false); }); +test('a host that has gone quiet is still introduced, so it keeps coordinating', () => { + // shouldIntroduce() is about the PROMPT: say hello if you have not spoken for + // three minutes. It used to gate co-host coordination too, and the hosts wait + // in an empty room while the guest is rung - always longer than three minutes. + // So on the guest's first words both hosts counted as introducing themselves, + // skipped the stagger and the reservation, and talked over each other. Episode + // 9 ended twenty seconds after Anthony said hello. + const transcript = [ + { who: 'riotcoder', text: 'Welcome to Off The Record.', at: new Date(now - 4 * minute).toISOString() }, + { who: 'them', text: 'Hello? This is Anthony.', at: new Date(now).toISOString() }, + ]; + // Four minutes quiet: the prompt should still ask for a hello... + assert.equal(worker.shouldIntroduce('riotcoder', transcript, now), true); + // ...but having spoken on this call is a different fact, and the one the + // coordination guards now use. + assert.equal(transcript.some((l) => l.who === 'riotcoder' && !l.history), true); + assert.equal(transcript.some((l) => l.who === 'cha0spunk' && !l.history), false); +}); + test('a model that says PASS is saying nothing, not reading the word out', () => { assert.deepEqual(worker.parseTurn('SAY: PASS'), { kind: 'pass', text: '' }); assert.deepEqual(worker.parseTurn('PASS'), { kind: 'pass', text: '' });