From 4795c8d4620ed3ea93627114cf93812cbdc9619c Mon Sep 17 00:00:00 2001 From: red person Date: Wed, 28 Oct 2026 14:27:17 +0300 Subject: [PATCH] fix(group): preserve peer identity when reloading history --- static/js/group.js | 6 +- static/js/sessions.js | 15 ++++- static/sw.js | 2 +- tests/test_group_peer_history_identity.py | 67 +++++++++++++++++++++++ 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 tests/test_group_peer_history_identity.py diff --git a/static/js/group.js b/static/js/group.js index 39057450f..0f2351dfd 100644 --- a/static/js/group.js +++ b/static/js/group.js @@ -801,7 +801,8 @@ async function _sendRoundRobin(msg, box) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', - content: `[${m._groupName || m.display}]: ${response}` + content: `[${m._groupName || m.display}]: ${response}`, + metadata: { group_peer: true, group_model: m._groupName || m.display, model: m.mid } }]}), }); } catch (e) { console.warn('[group] sync failed:', e); } @@ -828,7 +829,8 @@ async function _syncAllResponses(holders) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', - content: `[${model._groupName || model.display}]: ${response}` + content: `[${model._groupName || model.display}]: ${response}`, + metadata: { group_peer: true, group_model: model._groupName || model.display, model: model.mid } }]}), }); } catch (e) { /* silent */ } diff --git a/static/js/sessions.js b/static/js/sessions.js index 0a910ef00..d596e965a 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -216,7 +216,18 @@ function _renderHistoryMessage(msg, modelName) { } else { displayContent = ''; } - if (msg.role === 'user') { + let displayRole = msg.role; + // Peer turns remain user-role context for the model, but are agent replies + // in history. Only explicit identity plus its exact prefix can opt in. + if (displayRole === 'user' && meta?.group_peer === true + && typeof meta.group_model === 'string' && meta.group_model) { + const prefix = `[${meta.group_model}]: `; + if (displayContent.startsWith(prefix)) { + displayRole = 'assistant'; + displayContent = displayContent.slice(prefix.length); + } + } + if (displayRole === 'user') { displayContent = _stripUserVisionBlocks(displayContent); const trimmed = displayContent.trim(); if ( @@ -233,7 +244,7 @@ function _renderHistoryMessage(msg, modelName) { displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`; } } - return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta); + return _addHistoryMessageWithFullRenderer(displayRole, displayContent, modelName, meta); } function _clearHistoryPager() { diff --git a/static/sw.js b/static/sw.js index 44cf2e2a1..3bec8d0c8 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v659-notes-reference'; +const CACHE_NAME = 'odysseus-v660-group-peer-history'; // KaTeX resolves these from its own stylesheet, so caching the CSS without them // gives offline math fallback glyphs instead of proper typesetting. diff --git a/tests/test_group_peer_history_identity.py b/tests/test_group_peer_history_identity.py new file mode 100644 index 000000000..a42bcac13 --- /dev/null +++ b/tests/test_group_peer_history_identity.py @@ -0,0 +1,67 @@ +"""Peer injections retain their context role but hydrate as the named agent.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from tests.helpers.js_modules import js_function_source + +ROOT = Path(__file__).resolve().parents[1] + + +def _node(script, *args): + result = subprocess.run(['node', '--input-type=module', '-e', script, *map(json.dumps, args)], + cwd=ROOT, capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@pytest.mark.skipif(not shutil.which('node'), reason='node not installed') +@pytest.mark.parametrize('mode', ['parallel', 'round-robin']) +def test_peer_sync_persists_explicit_identity(mode): + source = (ROOT / 'static/js/group.js').read_text() + functions = '\n'.join(js_function_source(name, source) for name in ['_syncAllResponses', '_sendRoundRobin']) + data = _node(r''' +const source=JSON.parse(process.argv[1]),mode=JSON.parse(process.argv[2]); +const _models=[{mid:'model-a',display:'Model A',_groupName:'Persona A'}, {mid:'model-b',display:'Model B'}]; +const _participantSessions=['a','b'],API_BASE='http://app',sent=[]; +let _abortControllers=[]; +const uiModule={scrollHistory(){}}; +function _createGroupBubble(model){return {dataset:{raw:'Reply from '+model.mid}};} +async function _streamToHolder(){} function _saveState(){} +async function fetch(url,options){sent.push({url,...JSON.parse(options.body)});return {ok:true};} +eval(source+'\nglobalThis.runSync=async()=> mode==="parallel" ? _syncAllResponses(_models.map(_createGroupBubble)) : _sendRoundRobin("Hi",null);'); +await globalThis.runSync();console.log(JSON.stringify(sent)); +''', functions, mode) + assert len(data) == 2 + for item in data: + message = item['messages'][0] + assert message['role'] == 'user' # Model context must retain peer attribution. + peer = 'model-b' if item['url'].endswith('/a/inject_messages') else 'model-a' + name = 'Model B' if peer == 'model-b' else 'Persona A' + assert message['content'] == f'[{name}]: Reply from {peer}' + assert message['metadata'] == {'group_peer': True, 'group_model': name, 'model': peer} + + +@pytest.mark.skipif(not shutil.which('node'), reason='node not installed') +@pytest.mark.parametrize('role,content,meta,expected_role,expected_content', [ + ('user', '[Persona A]: Reply', {'group_peer': True, 'group_model': 'Persona A'}, 'assistant', 'Reply'), + ('user', '[Persona A]: Human text', {}, 'user', '[Persona A]: Human text'), + ('user', 'Human text', {}, 'user', 'Human text'), + ('assistant', 'Parent reply', {'group_model': 'Persona A'}, 'assistant', 'Parent reply'), + ('user', 'No matching prefix', {'group_peer': True, 'group_model': 'Persona A'}, 'user', 'No matching prefix'), + ('user', '[3]: Human text', {'group_peer': True, 'group_model': 3}, 'user', '[3]: Human text'), + ('user', '[Persona A]: Human text', {'group_peer': False, 'group_model': 'Persona A'}, 'user', '[Persona A]: Human text'), +]) +def test_history_uses_explicit_peer_identity_only(role, content, meta, expected_role, expected_content): + source = js_function_source('_renderHistoryMessage', (ROOT / 'static/js/sessions.js').read_text()) + result = _node(r''' +const source=JSON.parse(process.argv[1]),message=JSON.parse(process.argv[2]); +function _displayHistoryContent(text){return text;}function _stripUserVisionBlocks(text){return text;} +function _addHistoryMessageWithFullRenderer(role,content,model,metadata){return {role,content,metadata};} +const run=eval('('+source+')');console.log(JSON.stringify(run(message,'session-model'))); +''', source, {'role': role, 'content': content, 'metadata': meta}) + assert result['role'] == expected_role + assert result['content'] == expected_content + assert result['metadata'] == {**meta, '_fromHistory': True}