fix(group): route raw child chats as whispers

This commit is contained in:
Matyas Fenyves 2026-06-13 08:11:18 +02:00
parent 4a42524ca9
commit a4c73e3bfd
12 changed files with 665 additions and 34 deletions

View file

@ -28,6 +28,7 @@ from src.auth_helpers import effective_user, get_current_user
from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode
from core.database import GroupChatState
from core.database import Session as DBSession, ChatMessage as DBChatMessage
from core.database import Document as DBDocument, ModelEndpoint
from core.log_safety import redact_url
@ -56,6 +57,115 @@ logger = logging.getLogger(__name__)
_active_streams: Dict[str, dict] = {}
def _group_value(value: Any, max_len: int = 1024) -> str:
if value is None:
return ""
if not isinstance(value, str):
value = str(value)
return value.strip()[:max_len]
def _group_participant_label(model: dict | None, idx: int) -> str:
if not isinstance(model, dict):
return f"Participant {idx + 1}"
character = model.get("character")
if not isinstance(character, dict):
character = {}
for value in (
model.get("_groupName"),
character.get("characterName"),
model.get("display"),
model.get("mid"),
):
text = _group_value(value)
if text:
return text
return f"Participant {idx + 1}"
def _group_child_whisper_context(session_id: str, owner: str | None) -> dict | None:
"""Return parent metadata when a direct chat targets a group child session."""
if not session_id:
return None
target_id = str(session_id)
db = SessionLocal()
try:
q = db.query(GroupChatState)
if owner is not None:
q = q.filter(GroupChatState.owner == owner)
for row in q.all():
state = row.state if isinstance(row.state, dict) else {}
participant_ids = state.get("participantSessions")
if not isinstance(participant_ids, list):
continue
models = state.get("models")
if not isinstance(models, list):
models = []
for idx, participant_id in enumerate(participant_ids):
if not participant_id or str(participant_id) != target_id:
continue
model = models[idx] if idx < len(models) and isinstance(models[idx], dict) else {}
return {
"parent_session_id": row.parent_session_id,
"participant_session_id": target_id,
"participant_index": idx,
"participant_name": _group_participant_label(model, idx),
"participant_model": _group_value(model.get("mid") or model.get("display")),
}
finally:
db.close()
return None
def _group_parent_add_message(session_manager, ctx: dict | None, role: str, content: Any, metadata: dict | None) -> None:
if not ctx or not ctx.get("parent_session_id"):
return
try:
parent = session_manager.get_session(ctx["parent_session_id"])
except KeyError:
return
parent.add_message(ChatMessage(role, content, metadata=metadata))
session_manager.save_sessions()
def _mirror_group_child_user_message(session_manager, ctx: dict | None, content: Any) -> None:
if not ctx:
return
_group_parent_add_message(
session_manager,
ctx,
"user",
content,
{
"group_whisper": True,
"whisper_to": ctx["participant_name"],
"whisper_to_session": ctx["participant_session_id"],
"whisper_to_model": ctx.get("participant_model", ""),
},
)
def _mirror_group_child_assistant_message(
session_manager,
ctx: dict | None,
full_response: str,
model: str,
metrics: dict | None,
) -> None:
if not ctx or not full_response:
return
metadata = dict(metrics) if metrics else {}
metadata.update({
"group_model": ctx["participant_name"],
"model": model,
"group_whisper": True,
"whisper_from": ctx["participant_name"],
"whisper_from_session": ctx["participant_session_id"],
})
content, metadata = clean_thinking_for_save(full_response, metadata)
_group_parent_add_message(session_manager, ctx, "assistant", content, metadata)
def _stream_set(session_id: str, **fields) -> None:
"""Update fields on the active-stream entry for `session_id`, or
no-op if the entry has already been popped. Using .get() avoids a
@ -731,7 +841,7 @@ def setup_chat_routes(
use_rag = form_data.get("use_rag")
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
incognito = str(form_data.get("incognito", "")).lower() == "true"
group_internal = str(form_data.get("group_internal", "")).lower() == "true"
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
# Workspace: confine the agent's file/shell tools to this folder.
@ -1149,6 +1259,17 @@ def setup_chat_routes(
# Enforce per-user privileges
_privs = {}
_user = ctx.user
group_child_whisper = None if group_internal else _group_child_whisper_context(session, _user)
if group_child_whisper and not incognito and not compare_mode:
try:
mirrored_user_content = message
if sess.history:
last_msg = sess.history[-1]
if getattr(last_msg, "role", None) == "user":
mirrored_user_content = getattr(last_msg, "content", message)
_mirror_group_child_user_message(session_manager, group_child_whisper, mirrored_user_content)
except Exception:
logger.exception("Failed to mirror group child user message for session %s", session)
if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager:
_privs = request.app.state.auth_manager.get_privileges(_user)
if _privs:
@ -1633,6 +1754,17 @@ def setup_chat_routes(
)
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
if group_child_whisper and not incognito and not compare_mode:
try:
_mirror_group_child_assistant_message(
session_manager,
group_child_whisper,
full_response,
last_metrics.get("model") if last_metrics else sess.model,
last_metrics,
)
except Exception:
logger.exception("Failed to mirror group child assistant message for session %s", session)
run_post_response_tasks(
sess, session_manager, session, message, full_response,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
@ -1794,6 +1926,17 @@ def setup_chat_routes(
)
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
if group_child_whisper and not incognito and not compare_mode:
try:
_mirror_group_child_assistant_message(
session_manager,
group_child_whisper,
full_response,
last_metrics.get("model") if last_metrics else sess.model,
last_metrics,
)
except Exception:
logger.exception("Failed to mirror group child assistant message for session %s", session)
run_post_response_tasks(
sess, session_manager, session, message, _response_to_save,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,

View file

@ -338,6 +338,9 @@ def _group_participants_for_user(db, user) -> dict[str, list[dict]]:
"index": idx,
"name": _group_participant_label(model, idx),
"model": _group_state_str(model.get("display") or model.get("mid"), 1024),
"model_id": _group_state_str(model.get("mid"), 1024),
"endpoint_url": _group_state_str(model.get("url"), 2048),
"endpoint_id": _group_state_str(model.get("endpointId"), 1024),
})
if participants:
participants_by_parent[str(parent_id)] = participants

View file

@ -900,7 +900,8 @@ function initializeEventListeners() {
if (chk) chk.checked = active;
// Hide/show model picker
const _mpw = el('model-picker-wrap');
if (_mpw) _mpw.style.display = active ? 'none' : '';
const whisperActive = !!(sessionModule && sessionModule.isCurrentGroupChild && sessionModule.isCurrentGroupChild());
if (_mpw) _mpw.style.display = (active || whisperActive) ? 'none' : '';
// Mutual exclusion: group disables research + web search
if (active) {
_syncResearchIndicator(false);
@ -966,6 +967,33 @@ function initializeEventListeners() {
if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
}
/** Sync raw participant whisper indicator. */
function _syncWhisperIndicator(active, target = null) {
const btn = el('whisper-toggle-btn');
const label = el('whisper-toggle-label');
const name = target && target.name ? String(target.name) : '';
if (btn) {
btn.style.display = active ? '' : 'none';
btn.classList.toggle('active', active);
btn.title = active
? `Whisper to ${name || 'group participant'} - click to open group`
: 'Whisper mode active';
if (target && target.parent_session_id) {
btn.dataset.parentSessionId = String(target.parent_session_id);
} else {
delete btn.dataset.parentSessionId;
}
}
if (label) {
label.textContent = 'Whisper';
}
const _mpw = el('model-picker-wrap');
if (_mpw) {
const groupActive = !!(groupModule && groupModule.isActive && groupModule.isActive());
_mpw.style.display = (active || groupActive) ? 'none' : '';
}
}
// ── Close compare if active (used by all tool/sidebar activations) ──
// Returns true if compare was active (page will reload), caller should return early
function _closeCompareIfActive() {
@ -2029,6 +2057,7 @@ function initializeEventListeners() {
// run locally — finds it instead of silently no-op'ing (the "group indicator
// sometimes doesn't appear" bug).
window._syncGroupIndicator = _syncGroupIndicator;
window._syncWhisperIndicator = _syncWhisperIndicator;
// Init RAG state on load
{
const st = loadToggleState();
@ -2560,6 +2589,19 @@ function initializeEventListeners() {
}
// ── Incognito mode toggle (on welcome screen) ──
const whisperToggleBtn = el('whisper-toggle-btn');
if (whisperToggleBtn) {
whisperToggleBtn.addEventListener('click', () => {
const child = sessionModule && sessionModule.getCurrentGroupChildInfo
? sessionModule.getCurrentGroupChildInfo()
: null;
const parentId = (child && child.parent_session_id) || whisperToggleBtn.dataset.parentSessionId;
if (parentId && sessionModule && sessionModule.selectSession) {
sessionModule.selectSession(parentId, { keepSidebar: true });
}
});
}
const incognitoBtn = el('incognito-btn');
const INCOGNITO_EYE_OPEN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const INCOGNITO_EYE_CLOSED = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
@ -3891,7 +3933,8 @@ function startOdysseusApp() {
if (!msg) { console.log('[group] Empty message, skipping'); return; }
console.log('[group] Sending:', msg);
chatRenderer.hideWelcomeScreen();
chatRenderer.addMessage('user', msg);
const userMetadata = groupModule.getWhisperUserMetadata ? groupModule.getWhisperUserMetadata() : null;
chatRenderer.addMessage('user', msg, null, userMetadata);
msgInput.value = '';
groupModule.sendMessage(msg);
return;
@ -4021,6 +4064,16 @@ function startOdysseusApp() {
return true;
}
function _currentWhisperChildInfo() {
try {
return sessionModule && sessionModule.getCurrentGroupChildInfo
? sessionModule.getCurrentGroupChildInfo()
: null;
} catch (_) {
return null;
}
}
function _updateSendBtnIcon() {
if (!sendBtn) return;
if (sendBtn.dataset.mode === 'streaming') {
@ -4043,9 +4096,13 @@ function startOdysseusApp() {
} else if (!hasText && !hasFiles && !_isSttEnabled()) {
clearTimeout(sendBtn._collapseTimer);
// Group chat: always show send button, never newchat mode
if (groupModule && groupModule.isActive()) {
const whisperChild = _currentWhisperChildInfo();
if ((groupModule && groupModule.isActive()) || whisperChild) {
const whisperTarget = groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null;
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Send to group';
sendBtn.title = whisperChild
? `Send whisper to ${whisperChild.name || 'participant'}`
: (whisperTarget ? `Whisper to ${whisperTarget.name}` : 'Send to group');
newMode = 'idle';
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded');
} else {
@ -4083,15 +4140,25 @@ function startOdysseusApp() {
const delay = wasExpanded ? 300 : 0;
setTimeout(() => {
if (sendBtn.dataset.mode !== 'send') return;
const groupActive = groupModule && groupModule.isActive && groupModule.isActive();
const whisperTarget = groupActive && groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null;
const whisperChild = _currentWhisperChildInfo();
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Send message';
sendBtn.title = whisperChild
? `Send whisper to ${whisperChild.name || 'participant'}`
: (whisperTarget ? `Whisper to ${whisperTarget.name}` : (groupActive ? 'Send to group' : 'Send message'));
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'anim-spin-swap');
sendBtn.classList.add('anim-spin');
sendBtn.addEventListener('animationend', () => sendBtn.classList.remove('anim-spin'), { once: true });
}, delay);
} else {
const groupActive = groupModule && groupModule.isActive && groupModule.isActive();
const whisperTarget = groupActive && groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null;
const whisperChild = _currentWhisperChildInfo();
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Send message';
sendBtn.title = whisperChild
? `Send whisper to ${whisperChild.name || 'participant'}`
: (whisperTarget ? `Whisper to ${whisperTarget.name}` : (groupActive ? 'Send to group' : 'Send message'));
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land');
}
}

View file

@ -1170,6 +1170,10 @@
<span style="font-size:11px;margin-left:2px;">Group</span>
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
<button type="button" class="input-icon-btn tool-indicator" title="Whisper mode active" id="whisper-toggle-btn" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/><path d="M12 8v5"/><path d="M9 10h6"/></svg>
<span id="whisper-toggle-label" style="font-size:11px;margin-left:2px;">Whisper</span>
</button>
<input type="checkbox" id="group-toggle" style="display:none;">
<!-- Character indicator (hidden until active) -->
<button type="button" class="input-icon-btn tool-indicator" title="Persona active — click to deactivate" id="character-indicator-btn" style="display:none;">

View file

@ -1234,8 +1234,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
})();
// Materialize pending session (deferred from model click) on first message
if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
// Materialize pending session (deferred from model click) on first message.
// Raw group participant chats already have a fixed session/model; stale
// pending chat state must not create a fresh standalone chat here.
if (sessionModule.isCurrentGroupChild && sessionModule.isCurrentGroupChild()) {
if (sessionModule.clearPendingChat) sessionModule.clearPendingChat();
} else if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
_sendPerf.mark('pending_session_begin');
const ok = await sessionModule.materializePendingSession();
_sendPerf.mark('pending_session_done');
@ -1763,6 +1767,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
holder._researchQuery = msg; // Store query for notification text
const modelName = _bestKnownStreamModel(selectedRouteForSend) || null;
const groupChildInfo = sessionModule.getCurrentGroupChildInfo ? sessionModule.getCurrentGroupChildInfo() : null;
const groupChildAlias = groupChildInfo && groupChildInfo.name ? groupChildInfo.name : '';
let loadingText = 'Initializing...';
@ -1778,13 +1784,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
loadingText = 'Processing request...';
}
var roleLabel = _modelRouteLabel(modelName, modelName);
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
var roleLabel = groupChildAlias || _modelRouteLabel(modelName, modelName);
var _charNameInit = groupChildAlias || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
if (_charNameInit) roleLabel = _charNameInit;
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
holder._requestedModel = modelName;
holder._actualModel = modelName;
holder._characterName = _charNameInit || '';
_applyModelColor(holder.querySelector('.role'), modelName);
holder.style.position = 'relative';
@ -2778,7 +2785,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
if (json.suffix) holder._roleSuffix = json.suffix;
// Prepend character name if sent by server or set locally
var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
var _charName = json.character_name || holder._characterName || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
if (_charName) holder._characterName = _charName;
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
@ -3470,12 +3477,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model;
const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel;
// Prepend character name if set
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
var _charNameFinal = holder._characterName || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
const roleEl = holder.querySelector('.role');
if (roleEl) {
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
suffix: holder._roleSuffix,
characterName: _charNameFinal || holder._characterName,
characterName: _charNameFinal,
});
}
holder.dataset.raw = accumulated;

View file

@ -2333,6 +2333,7 @@ export function addMessage(role, content, modelName, metadata) {
const pair = replyModelPair(modelName, metadata);
const contModel = pair.actualModel || pair.requestedModel;
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
if (metadata?.character_name) roleEl.textContent = metadata.character_name;
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
roleEl.title = pair.requestedModel + ' -> ' + contModel;
}
@ -2482,6 +2483,9 @@ export function addMessage(role, content, modelName, metadata) {
// --- Standard single-bubble message ---
const wrap = document.createElement('div');
wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
if (metadata?.group_whisper) {
wrap.classList.add('msg-whisper');
}
const r = document.createElement('div');
r.className = 'role';
@ -2498,6 +2502,15 @@ export function addMessage(role, content, modelName, metadata) {
} else if (metadata?.character_name && role !== 'user' && !isSlash && !isCompacted) {
_roleText = metadata.character_name;
}
if (metadata?.group_whisper) {
if (role === 'user' && metadata.whisper_to) {
_roleText = 'Whisper to ' + metadata.whisper_to;
} else if (role !== 'user' && metadata.whisper_from) {
_roleText = 'Whisper from ' + metadata.whisper_from;
} else if (role !== 'user' && metadata.group_model) {
_roleText = 'Whisper from ' + metadata.group_model;
}
}
r.textContent = _roleText;
if (role !== 'user') {
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {

View file

@ -19,6 +19,7 @@ let _abortControllers = [];
let _mode = 'round-robin'; // 'parallel' or 'round-robin'
let _roundRobinIdx = 0;
let _parentSessionId = null;
let _whisperTargetSessionId = null;
const GROUP_STATE_KEY = 'odysseus-group-state';
export function init(apiBase) {
@ -379,6 +380,74 @@ export function setActive(v) { _active = v; }
export function getMode() { return _mode; }
export function setMode(m) { _mode = m; }
function _participantDisplayName(modelIdx) {
const model = _models[modelIdx] || {};
return model._groupName ||
(model.character ? model.character.characterName : '') ||
model.display ||
(model.mid ? chatRenderer.shortModel(model.mid) : '') ||
`Participant ${modelIdx + 1}`;
}
function _participantTargetForSession(sessionId) {
if (!sessionId) return null;
const targetId = String(sessionId);
const idx = _participantSessions.findIndex(sid => sid && String(sid) === targetId);
if (idx < 0 || !_models[idx]) return null;
const model = _models[idx];
return {
index: idx,
sessionId: String(_participantSessions[idx]),
name: _participantDisplayName(idx),
model: model.display || model.mid || '',
};
}
function _syncWhisperTargetUi() {
const target = getWhisperTarget();
document.querySelectorAll('.group-participant-row').forEach(row => {
const active = !!target && row.dataset.groupParticipantId === target.sessionId;
row.classList.toggle('whisper-active', active);
row.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
}
function _whisperUserMetadata(target) {
if (!target) return null;
const model = _models[target.index] || {};
return {
group_whisper: true,
whisper_to: target.name,
whisper_to_session: target.sessionId,
whisper_to_model: model.mid || '',
};
}
function _whisperPrompt(msg, target) {
return `This is a private direct message from the user to you. Other group participants cannot see it.\n\n${msg}`;
}
export function getWhisperTarget() {
return _participantTargetForSession(_whisperTargetSessionId);
}
export function getWhisperUserMetadata() {
return _whisperUserMetadata(getWhisperTarget());
}
export function setWhisperTarget(sessionId) {
const target = _participantTargetForSession(sessionId);
_whisperTargetSessionId = target ? target.sessionId : null;
_syncWhisperTargetUi();
return target;
}
export function clearWhisperTarget() {
_whisperTargetSessionId = null;
_syncWhisperTargetUi();
}
// ── Model Picker ─────────────────────────────────────
export async function showModelPicker() {
@ -701,6 +770,8 @@ export function stopGroup() {
_active = false;
_models = [];
_participantSessions = [];
_whisperTargetSessionId = null;
_syncWhisperTargetUi();
localStorage.removeItem(GROUP_STATE_KEY);
}
@ -712,15 +783,26 @@ export async function sendMessage(msg) {
const box = document.getElementById('chat-history');
if (!box) return;
const whisperTarget = getWhisperTarget();
// Save user message to parent session for persistence
if (_parentSessionId) {
fetch(`${API_BASE}/api/session/${_parentSessionId}/inject_messages`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: msg }] }),
body: JSON.stringify({ messages: [{
role: 'user',
content: msg,
metadata: whisperTarget ? _whisperUserMetadata(whisperTarget) : null,
}] }),
}).catch(() => {});
}
if (whisperTarget) {
await _sendWhisper(msg, box, whisperTarget);
return;
}
if (_mode === 'parallel') {
await _sendParallel(msg, box);
} else {
@ -728,15 +810,16 @@ export async function sendMessage(msg) {
}
}
function _createGroupBubble(model, box) {
function _createGroupBubble(model, box, options = {}) {
const wrap = document.createElement('div');
wrap.className = 'msg msg-ai msg-group';
wrap.className = 'msg msg-ai msg-group' + (options.whisper ? ' msg-whisper' : '');
wrap.style.position = 'relative';
// Role label — use character name if assigned, otherwise model name
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
const displayLabel = options.whisper ? `Whisper from ${roleLabel}` : roleLabel;
wrap.innerHTML = `<div class="role">${uiModule.esc(displayLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
// Spinner — identical to chat.js line 3062
@ -750,6 +833,20 @@ function _createGroupBubble(model, box) {
return wrap;
}
async function _sendWhisper(msg, box, target) {
const model = _models[target.index];
const holder = _createGroupBubble(model, box, { whisper: true });
uiModule.scrollHistory();
const ac = new AbortController();
_abortControllers = [ac];
await _streamToHolder(target.index, target.sessionId, msg, holder, ac, { whisper: true });
_abortControllers = [];
_saveState();
_saveStateToServer().catch(() => {});
}
async function _sendParallel(msg, box) {
const holders = _models.map(m => _createGroupBubble(m, box));
uiModule.scrollHistory();
@ -838,15 +935,17 @@ async function _syncAllResponses(holders) {
}
}
async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl, options = {}) {
if (!sessionId) {
holderEl.querySelector('.body').innerHTML = '<i style="opacity:0.5;">[Session creation failed]</i>';
return;
}
const fd = new FormData();
fd.append('message', msg);
const target = options.whisper ? _participantTargetForSession(sessionId) : null;
fd.append('message', target ? _whisperPrompt(msg, target) : msg);
fd.append('session', sessionId);
fd.append('group_internal', 'true');
let accumulated = '';
let _buffer = '';
@ -964,12 +1063,18 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
// Save response to parent session for persistence
if (accumulated && _parentSessionId) {
const gName = _models[modelIdx]._groupName || _models[modelIdx].display;
const metadata = { group_model: gName, model: _models[modelIdx].mid };
if (options.whisper) {
metadata.group_whisper = true;
metadata.whisper_from = gName;
metadata.whisper_from_session = String(sessionId);
}
fetch(`${API_BASE}/api/session/${_parentSessionId}/inject_messages`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{
role: 'assistant', content: accumulated,
metadata: { group_model: gName, model: _models[modelIdx].mid }
metadata
}]}),
}).catch(() => {});
}
@ -999,6 +1104,10 @@ function _applyState(s, sessionId) {
while (_participantSessions.length < _models.length) _participantSessions.push(null);
_parentSessionId = s.parentSessionId;
_roundRobinIdx = s.roundRobinIdx || 0;
if (_whisperTargetSessionId && !_participantTargetForSession(_whisperTargetSessionId)) {
_whisperTargetSessionId = null;
}
setTimeout(_syncWhisperTargetUi, 0);
return true;
}
@ -1056,6 +1165,7 @@ const groupModule = {
init, isActive, setActive, getMode, setMode, showModelPicker,
startGroup, stopGroup, sendMessage, restoreState,
getModels, getModelCount,
setWhisperTarget, clearWhisperTarget, getWhisperTarget, getWhisperUserMetadata,
};
export default groupModule;

View file

@ -187,6 +187,7 @@ async function _ensureDefaultPendingChat() {
* @param {function} deps.getSessions - returns sessions array
* @param {function} deps.getPendingChat - returns _pendingChat object
* @param {function} deps.setPendingChat - sets _pendingChat object
* @param {function} deps.isCurrentGroupChild - returns true for raw group participant chats
* @param {function} deps.createDirectChat - creates a new direct chat session
*/
export function initModelPicker(deps) {
@ -856,9 +857,9 @@ export function updateModelPicker() {
if (!_deps) return;
const label = document.getElementById('model-picker-label');
if (!label) return;
// Hide model picker when group chat is active
// Hide model picker when group chat or a raw group participant whisper is active
const wrap = document.getElementById('model-picker-wrap');
if (window.groupModule && window.groupModule.isActive()) {
if ((window.groupModule && window.groupModule.isActive()) || (_deps.isCurrentGroupChild && _deps.isCurrentGroupChild())) {
if (wrap) { wrap.style.display = 'none'; }
return;
}

View file

@ -13,6 +13,8 @@ const API_BASE = window.location.origin;
let sessions = [];
let currentSessionId = null;
let _currentSessionDetails = null;
const _groupChildSessions = new Map();
let _sessionNavToken = 0;
let _skipAutoSelect = false;
let _suppressNextSessionLoading = false;
@ -339,6 +341,49 @@ function _normalizeSessionsList(fetched) {
return unique;
}
function _indexGroupChildSessions(list) {
_groupChildSessions.clear();
for (const session of list || []) {
const participants = _groupParticipantsForSession(session);
participants.forEach(participant => {
const id = String(participant.id || '');
if (!id) return;
_groupChildSessions.set(id, {
id,
name: participant.name || participant.model || `Participant ${(participant.index || 0) + 1}`,
model: participant.model_id || participant.model || '',
endpoint_url: participant.endpoint_url || session.endpoint_url || '',
endpoint_id: participant.endpoint_id || '',
parent_session_id: session.id,
parent_name: session.name || '',
is_group_participant_child: true,
});
});
}
}
function _sessionDetailsForId(id) {
const sid = String(id || '');
return sessions.find(x => String(x.id) === sid) ||
(_currentSessionDetails && String(_currentSessionDetails.id) === sid ? _currentSessionDetails : null) ||
_groupChildInfoForId(sid) ||
null;
}
function _groupChildInfoForId(id) {
const sid = String(id || '');
const indexed = _groupChildSessions.get(sid);
if (indexed) return indexed;
if (
_currentSessionDetails &&
String(_currentSessionDetails.id) === sid &&
_currentSessionDetails.is_group_participant_child
) {
return _currentSessionDetails;
}
return null;
}
function _groupParticipantsForSession(session) {
if (!session || !Array.isArray(session.group_participants)) return [];
return session.group_participants.filter(participant => participant && participant.id);
@ -375,8 +420,10 @@ function _createGroupParticipantsList(session, participants) {
const row = document.createElement('button');
row.type = 'button';
row.className = 'group-participant-row';
row.title = 'Open the parent group chat';
row.setAttribute('aria-label', `Open group chat for ${participant.name || participant.model || 'participant'}`);
row.dataset.groupParticipantId = String(participant.id);
row.title = `Open raw chat for ${participant.name || participant.model || 'participant'}`;
row.setAttribute('aria-label', `Open raw chat for ${participant.name || participant.model || 'participant'}`);
row.setAttribute('aria-pressed', 'false');
const dot = document.createElement('span');
dot.className = 'group-participant-dot';
@ -395,9 +442,12 @@ function _createGroupParticipantsList(session, participants) {
row.appendChild(model);
}
row.addEventListener('click', (e) => {
row.addEventListener('click', async (e) => {
e.stopPropagation();
if (currentSessionId !== session.id) selectSession(session.id);
if (window.groupModule && window.groupModule.clearWhisperTarget) {
window.groupModule.clearWhisperTarget();
}
await selectSession(participant.id, { keepSidebar: true });
});
list.appendChild(row);
});
@ -1791,6 +1841,7 @@ export async function loadSessions() {
fetched = await res.json();
}
sessions = _normalizeSessionsList(fetched);
_indexGroupChildSessions(sessions);
renderSessionList();
const sessionsSection = uiModule.el('sessions-section');
@ -1837,6 +1888,8 @@ export async function loadSessions() {
targetId = null;
} else if (hashId && activeSessions.some(s => s.id === hashId)) {
targetId = hashId;
} else if (hashId && _groupChildSessions.has(String(hashId))) {
targetId = hashId;
} else if (currentSessionId && activeSessions.some(s => s.id === currentSessionId)) {
targetId = currentSessionId;
} else if (currentSessionId) {
@ -1844,6 +1897,8 @@ export async function loadSessions() {
targetId = currentSessionId;
} else if (!_freshRootLoad && savedId && activeSessions.some(s => s.id === savedId)) {
targetId = savedId;
} else if (!_freshRootLoad && savedId && _groupChildSessions.has(String(savedId))) {
targetId = savedId;
} else if (!_freshRootLoad && !_skipAutoSelect && _realSessions.length > 0) {
// Most-recent NON-transient session — skip Assistant / Tasks so the
// auto-firing assistant doesn't become the apparent default chat.
@ -1943,6 +1998,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
currentSessionId = id;
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
_currentSessionDetails = _sessionDetailsForId(id);
// Identify Assistant / task-output sessions so we don't "trap" the user
// there on return. Skipped from both `lastSessionId` persistence and the
// URL hash — the user complained that coming back to Odysseus kept
@ -1959,6 +2015,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
if (presetsModule && presetsModule.onSessionSwitch) presetsModule.onSessionSwitch(id);
} catch (e) {}
const meta = sessions.find(s => s.id === id);
const groupChildInfo = _groupChildInfoForId(id);
if (groupChildInfo) {
_pendingChat = null;
}
if (window._syncWhisperIndicator) {
window._syncWhisperIndicator(!!groupChildInfo, groupChildInfo || null);
}
// Detach any in-flight stream to background instead of aborting
try {
@ -2016,10 +2079,21 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
const activeEl = document.querySelector(`.list-item[data-session-id="${id}"]`);
if (activeEl) activeEl.classList.add('active-session');
document.querySelectorAll('.group-participant-row.child-active').forEach(el => {
el.classList.remove('child-active');
el.setAttribute('aria-pressed', 'false');
});
document.querySelectorAll('.group-participant-row').forEach(row => {
const active = String(row.dataset.groupParticipantId || '') === String(id);
if (active) {
row.classList.add('child-active');
row.setAttribute('aria-pressed', 'true');
}
});
const currentMetaEl = uiModule.el('current-meta');
if (currentMetaEl) {
currentMetaEl.textContent = meta ? meta.name : 'Odysseus Chat';
currentMetaEl.textContent = meta ? meta.name : (groupChildInfo ? groupChildInfo.name : 'Odysseus Chat');
}
// Update model picker visibility
updateModelPicker();
@ -2067,6 +2141,19 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
total: data.total,
has_more_before: !!data.has_more_before,
};
_currentSessionDetails = meta || {
id,
name: data.name || groupChildInfo?.name || 'Odysseus Chat',
model: data.model || groupChildInfo?.model || '',
endpoint_url: data.endpoint_url || groupChildInfo?.endpoint_url || '',
endpoint_id: groupChildInfo?.endpoint_id || '',
parent_session_id: groupChildInfo?.parent_session_id || null,
group_parent_session_id: groupChildInfo?.parent_session_id || null,
is_group_participant_child: !!groupChildInfo,
};
if (!meta && currentMetaEl) {
currentMetaEl.textContent = _currentSessionDetails.name || 'Odysseus Chat';
}
// The model returned by /api/history is the authoritative one the
// backend will use for this session. Write it back into the cached
// session meta and refresh the picker so the displayed model can
@ -2125,8 +2212,19 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
'OpenClaw');
} else if (msgHistory.length) {
for (const msg of msgHistory) {
let renderMsg = msg;
if (groupChildInfo && msg.role === 'assistant') {
renderMsg = {
...msg,
metadata: {
...(msg.metadata || {}),
character_name: (msg.metadata && msg.metadata.character_name) || groupChildInfo.name,
model: (msg.metadata && msg.metadata.model) || groupChildInfo.model || '',
},
};
}
try {
_renderHistoryMessage(msg, modelName);
_renderHistoryMessage(renderMsg, modelName);
} catch (e) {
console.warn('Failed to render history message:', e, msg);
}
@ -2174,14 +2272,32 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
}
if (restoredGroupState) {
if (window._syncWhisperIndicator) window._syncWhisperIndicator(false);
if (window._syncGroupIndicator) window._syncGroupIndicator(true);
if (window.groupModule && window.groupModule.clearWhisperTarget) {
window.groupModule.clearWhisperTarget();
}
// Hide model picker for group sessions
const _mpw = document.getElementById('model-picker-wrap');
if (_mpw) _mpw.style.display = 'none';
} else if (groupChildInfo) {
if (window.groupModule && window.groupModule.isActive()) {
// Child raw chats send through the normal session stream so the
// backend can mirror them to the parent as whispers.
window.groupModule.stopGroup();
}
if (window._syncGroupIndicator) window._syncGroupIndicator(false);
if (window._syncWhisperIndicator) window._syncWhisperIndicator(true, groupChildInfo);
const _mpw = document.getElementById('model-picker-wrap');
if (_mpw) _mpw.style.display = 'none';
} else if (window.groupModule && window.groupModule.isActive()) {
// Switching away from group session — deactivate
window.groupModule.stopGroup();
if (window._syncGroupIndicator) window._syncGroupIndicator(false);
if (window._syncWhisperIndicator) window._syncWhisperIndicator(false);
updateModelPicker();
} else if (window._syncWhisperIndicator) {
window._syncWhisperIndicator(false);
}
// Stop pulsing notification — user is now viewing this session
@ -2307,6 +2423,7 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
}
// Don't hit the API — just store the model info and prepare the UI
if (window._syncWhisperIndicator) window._syncWhisperIndicator(false);
_pendingChat = { url, modelId, endpointId, source: incomingSource };
_pendingMaterializePromise = null;
_skipAutoSelect = true;
@ -2459,21 +2576,33 @@ export function preMaterializePendingSession() {
export function hasPendingChat() { return !!_pendingChat; }
export function getPendingChat() { return _pendingChat; }
export function clearPendingChat() { _pendingChat = null; }
// Getters for external access
export function getCurrentSessionId() {
return currentSessionId;
}
export function getCurrentGroupChildInfo() {
return _groupChildInfoForId(currentSessionId);
}
export function isCurrentGroupChild() {
return !!getCurrentGroupChildInfo();
}
export function isCurrentSessionIncognito() {
return !!(currentSessionId && _isIncognitoSession(currentSessionId));
}
export function getSessions() {
if (_currentSessionDetails && !sessions.some(x => String(x.id) === String(_currentSessionDetails.id))) {
return sessions.concat([_currentSessionDetails]);
}
return sessions;
}
export function getCurrentModel() {
const sess = sessions.find(x => x.id === currentSessionId);
const sess = _sessionDetailsForId(currentSessionId);
if (sess && sess.model) return sess.model;
if (_pendingChat && _pendingChat.modelId) return _pendingChat.modelId;
return null;
@ -2482,7 +2611,7 @@ export function getCurrentModel() {
/** Endpoint URL serving the current (or pending) session's model. Used to
* decide whether a model is local (free) vs a billable cloud provider. */
export function getCurrentEndpointUrl() {
const sess = sessions.find(x => x.id === currentSessionId);
const sess = _sessionDetailsForId(currentSessionId);
if (sess && sess.endpoint_url) return sess.endpoint_url;
if (_pendingChat && _pendingChat.url) return _pendingChat.url;
return null;
@ -2494,6 +2623,7 @@ export function setCurrentSessionId(id) {
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
if (!id) {
_suppressNextSessionLoading = true;
_currentSessionDetails = null;
Storage.remove('lastSessionId');
history.replaceState(null, '', window.location.pathname);
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
@ -2879,9 +3009,10 @@ export function clearStreamComplete(sessionId) {
function _initAllDropdowns() {
initModelPicker({
getCurrentSessionId: () => currentSessionId,
getSessions: () => sessions,
getSessions,
getPendingChat: () => _pendingChat,
setPendingChat: (v) => { _pendingChat = v; },
isCurrentGroupChild,
createDirectChat,
});
_initDropdownDismiss();
@ -3747,7 +3878,10 @@ const sessionModule = {
preMaterializePendingSession,
hasPendingChat,
getPendingChat,
clearPendingChat,
getCurrentSessionId,
getCurrentGroupChildInfo,
isCurrentGroupChild,
getSessions,
getCurrentModel,
getCurrentEndpointUrl,

View file

@ -8428,6 +8428,11 @@ a.chat-link:hover {
background: color-mix(in srgb, var(--fg) 7%, transparent) !important;
opacity: 0.92;
}
.group-participant-row.child-active,
.group-participant-row.whisper-active {
background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent) !important;
opacity: 1;
}
.group-participant-dot {
width: 6px;
height: 6px;
@ -8435,6 +8440,11 @@ a.chat-link:hover {
border: 1px solid color-mix(in srgb, var(--fg) 28%, transparent);
flex-shrink: 0;
}
.group-participant-row.child-active .group-participant-dot,
.group-participant-row.whisper-active .group-participant-dot {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
}
.group-participant-name {
flex: 1;
min-width: 0;
@ -30299,11 +30309,19 @@ button .spinner-whirlpool {
.msg-group .role {
font-weight: 600;
}
.msg-whisper {
border-color: color-mix(in srgb, #8b5cf6 32%, var(--bubble-border, var(--border))) !important;
box-shadow: inset 3px 0 0 color-mix(in srgb, #8b5cf6 58%, transparent);
}
#group-toggle-btn.active,
.overflow-menu-item#overflow-group-btn.active {
color: var(--red);
background: color-mix(in srgb, var(--red) 12%, transparent);
}
#whisper-toggle-btn.active {
color: #8b5cf6;
background: color-mix(in srgb, #8b5cf6 14%, transparent);
}
/* Group model picker — match app theme */
#group-model-picker .modal-content {
background: var(--bg);

View file

@ -196,13 +196,53 @@ def test_list_sessions_hides_group_participants(monkeypatch):
assert not set(child_ids) & returned_ids
assert parent["is_group_parent"] is True
assert parent["group_participants"] == [
{"id": child_ids[0], "index": 0, "name": "Athena", "model": "Llama 3"},
{"id": child_ids[1], "index": 1, "name": "Mistral", "model": "Mistral"},
{
"id": child_ids[0],
"index": 0,
"name": "Athena",
"model": "Llama 3",
"model_id": "llama3",
"endpoint_url": "http://localhost:11434",
"endpoint_id": "local",
},
{
"id": child_ids[1],
"index": 1,
"name": "Mistral",
"model": "Mistral",
"model_id": "mistral",
"endpoint_url": "http://localhost:11434",
"endpoint_id": None,
},
]
assert normal["is_group_parent"] is False
assert normal["group_participants"] == []
def test_group_child_whisper_context_resolves_parent_for_owner(monkeypatch):
_reset_db()
parent_id = str(uuid.uuid4())
child_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
_add_session(parent_id, name="[GRP] Athena, Mistral")
for session_id in child_ids:
_add_session(session_id, name="[GRP] participant")
_add_group_state(parent_id, child_ids)
import routes.chat_routes as cr
monkeypatch.setattr(cr, "SessionLocal", _TS)
ctx = cr._group_child_whisper_context(child_ids[0], "alice")
assert ctx == {
"parent_session_id": parent_id,
"participant_session_id": child_ids[0],
"participant_index": 0,
"participant_name": "Athena",
"participant_model": "llama3",
}
assert cr._group_child_whisper_context(child_ids[0], "bob") is None
def test_group_parent_folder_move_cascades_and_child_move_is_blocked(monkeypatch):
_reset_db()
parent_id = str(uuid.uuid4())

View file

@ -9,6 +9,34 @@ SESSIONS_SOURCE = (
Path(__file__).resolve().parent.parent / "static" / "js" / "sessions.js"
).read_text(encoding="utf-8")
APP_SOURCE = (
Path(__file__).resolve().parent.parent / "static" / "app.js"
).read_text(encoding="utf-8")
RENDERER_SOURCE = (
Path(__file__).resolve().parent.parent / "static" / "js" / "chatRenderer.js"
).read_text(encoding="utf-8")
CHAT_ROUTES_SOURCE = (
Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
).read_text(encoding="utf-8")
MODEL_PICKER_SOURCE = (
Path(__file__).resolve().parent.parent / "static" / "js" / "modelPicker.js"
).read_text(encoding="utf-8")
CHAT_SOURCE = (
Path(__file__).resolve().parent.parent / "static" / "js" / "chat.js"
).read_text(encoding="utf-8")
INDEX_SOURCE = (
Path(__file__).resolve().parent.parent / "static" / "index.html"
).read_text(encoding="utf-8")
STYLE_SOURCE = (
Path(__file__).resolve().parent.parent / "static" / "style.css"
).read_text(encoding="utf-8")
def test_group_session_sidebar_cache_uses_safe_json_loader():
assert "import Storage from './storage.js';" in SOURCE
@ -30,3 +58,66 @@ def test_group_participants_render_as_non_session_sidebar_rows():
assert "group-participant-row" in SESSIONS_SOURCE
assert "row.setAttribute('aria-label'" in SESSIONS_SOURCE
assert "row.setAttribute('data-session-id'" not in SESSIONS_SOURCE
def test_group_participant_rows_open_raw_child_sessions():
assert "row.dataset.groupParticipantId" in SESSIONS_SOURCE
assert "Open raw chat for" in SESSIONS_SOURCE
assert "await selectSession(participant.id, { keepSidebar: true })" in SESSIONS_SOURCE
assert "window.groupModule.setWhisperTarget(participant.id)" not in SESSIONS_SOURCE
assert "window.groupModule.clearWhisperTarget()" in SESSIONS_SOURCE
assert "_groupChildSessions" in SESSIONS_SOURCE
assert "_currentSessionDetails" in SESSIONS_SOURCE
assert "_groupChildSessions.has(String(hashId))" in SESSIONS_SOURCE
def test_group_child_raw_chat_keeps_whisper_context_without_model_picker():
assert 'id="whisper-toggle-btn"' in INDEX_SOURCE
assert "#whisper-toggle-btn.active" in STYLE_SOURCE
assert "window._syncWhisperIndicator = _syncWhisperIndicator" in APP_SOURCE
assert "sessionModule.getCurrentGroupChildInfo" in APP_SOURCE
assert "Send whisper to" in APP_SOURCE
assert "export function getCurrentGroupChildInfo()" in SESSIONS_SOURCE
assert "export function isCurrentGroupChild()" in SESSIONS_SOURCE
assert "export function clearPendingChat()" in SESSIONS_SOURCE
assert "if (groupChildInfo) {\n _pendingChat = null;" in SESSIONS_SOURCE
assert "window._syncWhisperIndicator(true, groupChildInfo)" in SESSIONS_SOURCE
assert "isCurrentGroupChild," in SESSIONS_SOURCE
assert "deps.isCurrentGroupChild" in MODEL_PICKER_SOURCE
assert "sessionModule.clearPendingChat" in CHAT_SOURCE
def test_group_child_raw_chat_uses_participant_alias_for_assistant_labels():
assert "childMeta.character_name = childMeta.character_name || groupChildInfo.name" in SESSIONS_SOURCE
assert "const groupChildAlias = groupChildInfo && groupChildInfo.name" in CHAT_SOURCE
assert "holder._characterName = _charNameInit || ''" in CHAT_SOURCE
assert "json.character_name || holder._characterName" in CHAT_SOURCE
assert "if (metadata?.character_name) roleEl.textContent = metadata.character_name" in RENDERER_SOURCE
def test_group_internal_sends_do_not_trigger_child_mirroring():
assert "let _whisperTargetSessionId = null;" in SOURCE
assert "export function setWhisperTarget(sessionId)" in SOURCE
assert "export function getWhisperUserMetadata()" in SOURCE
assert "async function _sendWhisper(msg, box, target)" in SOURCE
assert "_streamToHolder(target.index, target.sessionId, msg, holder, ac, { whisper: true })" in SOURCE
whisper_block = SOURCE.split("async function _sendWhisper", 1)[1].split("async function _sendParallel", 1)[0]
assert "_syncAllResponses" not in whisper_block
assert "Private whisper from the user" not in SOURCE
assert "This is a private direct message from the user to you" in SOURCE
assert "fd.append('group_internal', 'true')" in SOURCE
assert 'group_internal = str(form_data.get("group_internal", "")' in CHAT_ROUTES_SOURCE
assert "_group_child_whisper_context(session, _user)" in CHAT_ROUTES_SOURCE
assert "_mirror_group_child_user_message" in CHAT_ROUTES_SOURCE
assert "_mirror_group_child_assistant_message" in CHAT_ROUTES_SOURCE
assert "group_whisper: true" in SOURCE
assert "whisper_to" in SOURCE
assert "whisper_from" in SOURCE
def test_group_submit_and_history_render_whisper_metadata():
assert "groupModule.getWhisperUserMetadata" in APP_SOURCE
assert "chatRenderer.addMessage('user', msg, null, userMetadata)" in APP_SOURCE
assert "metadata?.group_whisper" in RENDERER_SOURCE
assert "'Whisper to ' + metadata.whisper_to" in RENDERER_SOURCE
assert "'Whisper from ' + metadata.whisper_from" in RENDERER_SOURCE