From 3c8cfdc5896ce43d3300d22bf3527c4dab6dc6ca Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 19:48:56 +0000 Subject: [PATCH] fix(#4811): regenerate row_id/seq from final order index so anonymous tool rows don't collide (gate finding) Codex gate found a SILENT drop: reconnect scene rows are built with orderIndex=0, so two tools WITHOUT a tool id at the same message index both got row_id '...:tool::0'; _completeSettledAnchorSceneForTurn dedupes by row_id, silently dropping the second. Now the emit loop rewrites order_index AND regenerates the index-derived row_id + seq + identity.seq from the final per-bucket position (tid-based tool row_ids, already unique, are left untouched). Regression test added + proven non-vacuous. Co-authored-by: Rod Boev --- static/messages.js | 18 +++++++++-- tests/test_issue4811_reconnect_chronology.py | 32 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 037a5eae2..11667d9f3 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2602,10 +2602,24 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(aTime!==bTime) return aTime-bTime; return a._encounter-b._encounter; }); - // Emit with sequential order_index values, strip temp props + // Emit with sequential order_index values, strip temp props. + // Rows were built with orderIndex=0, so their row_id/seq still encode 0. + // Rewrite order_index AND regenerate the index-derived identity fields + // (row_id/seq) from the final per-bucket position, so two anonymous rows + // (no tool id) at the same message index don't collide on the same row_id + // and get silently deduped by _completeSettledAnchorSceneForTurn(). for(const row of pool){ const {_phase,_encounter,...clean}=row; - clean.order_index=byIdx.has(idx)?byIdx.get(idx).length:0; + const oi=byIdx.has(idx)?byIdx.get(idx).length:0; + clean.order_index=oi; + clean.seq=oi; + if(clean.identity&&typeof clean.identity==='object') clean.identity={...clean.identity,seq:oi}; + // Tool rows with a tool id carry a tid-based row_id (already unique) — + // only regenerate the default index-based row_id form. + const indexRowId=`settled:${activeSid||'session'}:${streamId||'stream'}:${clean.role}:${idx}:0`; + if(clean.row_id===indexRowId){ + clean.row_id=`settled:${activeSid||'session'}:${streamId||'stream'}:${clean.role}:${idx}:${oi}`; + } add(idx,clean); } } diff --git a/tests/test_issue4811_reconnect_chronology.py b/tests/test_issue4811_reconnect_chronology.py index 7515f693e..362040a1d 100644 --- a/tests/test_issue4811_reconnect_chronology.py +++ b/tests/test_issue4811_reconnect_chronology.py @@ -299,3 +299,35 @@ def test_order_index_sequential_within_bucket(driver_path): rows = out["1"] indices = [r["order_index"] for r in rows] assert indices == list(range(len(rows))), f"order_index not sequential: {indices}" + + +def test_anonymous_tool_rows_get_distinct_row_ids(driver_path): + """Two tools WITHOUT an id at the same message index must not collide on the + same row_id (which would let _completeSettledAnchorSceneForTurn dedupe one + away). row_id/seq are regenerated from the final per-bucket order index. + + Regression guard for the gate-found SILENT drop: rows are built with + orderIndex=0, so the index-derived row_id/seq must be rewritten on emit.""" + messages = [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": "", + # two anonymous tools (no 'id') at the same index, distinct timestamps + "tool_calls": [ + {"name": "terminal", "done": True, "started_at": 100}, + {"name": "read_file", "done": True, "started_at": 200}, + ], + }, + {"role": "assistant", "content": "final"}, + ] + payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}} + out = _run(driver_path, payload) + rows = out["1"] + tool_rows = [r for r in rows if r.get("role") == "tool"] + assert len(tool_rows) == 2, f"both anonymous tool rows must survive, got {len(tool_rows)}" + row_ids = [r["row_id"] for r in tool_rows] + assert len(set(row_ids)) == 2, f"anonymous tool rows collided on row_id: {row_ids}" + seqs = [r.get("seq") for r in tool_rows] + assert len(set(seqs)) == 2, f"anonymous tool rows collided on seq: {seqs}" +