From 19c42be745293325e3db04c337076d09041e58cf Mon Sep 17 00:00:00 2001 From: riotcoder Date: Thu, 5 Nov 2026 17:50:33 +0000 Subject: [PATCH] fix: catching up on a room reads to the end of the log, not a page of it My own regression from #54. With the legs finally reading the room, a rehearsal opened with weeks of earlier episodes arriving as though the guest had just said them: 17:49:06 riotcoder (host) (other agent): Man, that's a brutal 1. 33 Bitcoin gone... 17:49:06 riotcoder (host) (other agent): Wait. Your mom is 81 and uses it? /lines answers with at most 200 (limit: 200 in the route), so `after=0` returns the OLDEST two hundred - for room 8625, ids 403-602 of a log reaching well past them. Taking `all.lines.at(-1).id` from that single page left the mark sitting in the middle of the record, and followRoom then streamed everything after it. #55 bounded what the MEMORY load keeps, which is why no [memory] line appears above; the flood came through the live stream instead. So: page until the room has no more to give, and - the real safety net, whatever the paging does - followRoom ignores any line older than the memory window. A line from last week is never what was just said. A line with no timestamp is still kept. 114 pass, 0 fail; tsc clean. Co-Authored-By: Claude Opus 5 (1M context) --- ops/agent-phone.mjs | 25 +++++++++++++++++++++++-- test/agent-phone.test.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/ops/agent-phone.mjs b/ops/agent-phone.mjs index b2ab94a..8a3dba4 100755 --- a/ops/agent-phone.mjs +++ b/ops/agent-phone.mjs @@ -821,8 +821,25 @@ async function main() { const roomHeaders = { authorization: `Bearer ${process.env.GROUPCALL_AGENT_WORKER_KEY}` }; if (join && process.env.GROUPCALL_STATUS_URL) { const r = await fetch(process.env.GROUPCALL_STATUS_URL, { headers: roomHeaders }).then((x) => (x.ok ? x.json() : null)).catch(() => null); - const all = await fetch(`${process.env.GROUPCALL_STATUS_URL}/lines?after=0&wait=0`, { headers: roomHeaders }).then((x) => (x.ok ? x.json() : null)).catch(() => null); - afterId = all?.lines?.at(-1)?.id || 0; + /** + * Read to the END of the room's record, not to the end of its first page. + * + * /lines returns at most 200 at a time, so `after=0` answers with the OLDEST + * two hundred. Taking the last id of that one page left the mark sitting in + * the middle of the log, and the stream then delivered everything after it - + * weeks of earlier episodes, arriving as though the guest had just said + * them. Page until the room has no more to give. + */ + const all = { lines: [] }; + for (let page = 0; page < 50; page++) { + const mark = all.lines.at(-1)?.id ?? 0; + const r2 = await fetch(`${process.env.GROUPCALL_STATUS_URL}/lines?after=${mark}&wait=0`, { headers: roomHeaders }).then((x) => (x.ok ? x.json() : null)).catch(() => null); + const lines = r2?.lines || []; + if (!lines.length) break; + all.lines.push(...lines); + if (lines.length < 200) break; + } + afterId = all.lines.at(-1)?.id || 0; /** * Recent lines only. The room's record is our memory so that a drop, a crash * or a deploy picks the conversation up instead of starting over - and a @@ -888,6 +905,10 @@ async function main() { for (const l of r.lines || []) { afterId = Math.max(afterId, l.id); if (ended || (l.kind === 'agent' && l.who === agent)) continue; + // Old enough to be another call: never "what was just said". The mark + // can start behind (a short page, a room that was pruned), and a line + // from last week must not arrive as a line from this second. + if (Number(l.at) && Date.now() - Number(l.at) > ROOM_MEMORY_MS) continue; if (l.kind === 'agent') { transcript.push({ who: l.who, text: l.text, at: new Date().toISOString() }); try { diff --git a/test/agent-phone.test.mjs b/test/agent-phone.test.mjs index 4b9b063..899e06b 100644 --- a/test/agent-phone.test.mjs +++ b/test/agent-phone.test.mjs @@ -346,6 +346,36 @@ test("the room's record is memory of this call, not of the last episode", () => assert.ok(worker.ROOM_MEMORY_MS >= 10 * minute); }); +test('catching up on a room reads to the end of the log, not the end of a page', () => { + // /lines answers with at most 200, so after=0 returns the OLDEST two hundred + // (room 8625: ids 403-602 of a log reaching far past it). Taking the last id of + // that single page left the mark mid-log and the stream then delivered + // everything after it - weeks of earlier episodes, arriving as though the guest + // had just said them. + const log = Array.from({ length: 450 }, (_, i) => ({ id: 403 + i, at: now - (450 - i) * 1000 })); + const page = (after) => log.filter((l) => l.id > after).slice(0, 200); + const collected = []; + for (let i = 0; i < 50; i++) { + const lines = page(collected.at(-1)?.id ?? 0); + if (!lines.length) break; + collected.push(...lines); + if (lines.length < 200) break; + } + assert.equal(collected.at(-1).id, log.at(-1).id); + // One page would have stopped here, 250 lines short of the end. + assert.equal(page(0).at(-1).id, 602); +}); + +test('a line older than the memory window is never what was just said', () => { + const fresh = { id: 9, at: now - 60_000 }; + const ancient = { id: 2, at: now - 8 * 24 * 60 * minute }; + const stale = (l) => Boolean(Number(l.at)) && now - Number(l.at) > worker.ROOM_MEMORY_MS; + assert.equal(stale(fresh), false); + assert.equal(stale(ancient), true); + // A line with no timestamp is not thrown away. + assert.equal(stale({ id: 3, at: 0 }), 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: '' });