diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py
index a286efc4..23a81192 100644
--- a/agent/memory/conversation_store.py
+++ b/agent/memory/conversation_store.py
@@ -509,6 +509,65 @@ class ConversationStore:
finally:
conn.close()
+ def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]:
+ """Return the seq numbers of the latest visible user message and the
+ latest assistant message in a session.
+
+ A "visible" user message is one whose content is real user text
+ (not just a tool_result block), so tool-execution turns do not
+ shadow the actual user query.
+
+ Returns:
+ Dict with keys ``user_seq`` and ``bot_seq``; either may be None
+ when no matching message exists.
+ """
+ result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None}
+ with self._lock:
+ conn = self._connect()
+ try:
+ # Latest assistant message (cheap: single row by seq DESC).
+ row = conn.execute(
+ "SELECT seq FROM messages "
+ "WHERE session_id = ? AND role = 'assistant' "
+ "ORDER BY seq DESC LIMIT 1",
+ (session_id,),
+ ).fetchone()
+ if row:
+ result["bot_seq"] = int(row[0])
+
+ # Latest visible user message: scan recent user rows and
+ # skip pure tool_result entries.
+ rows = conn.execute(
+ "SELECT seq, content FROM messages "
+ "WHERE session_id = ? AND role = 'user' "
+ "ORDER BY seq DESC LIMIT 20",
+ (session_id,),
+ ).fetchall()
+ for seq, content_raw in rows:
+ try:
+ content = json.loads(content_raw)
+ except Exception:
+ result["user_seq"] = int(seq)
+ break
+ if isinstance(content, list):
+ has_text = any(
+ isinstance(b, dict) and b.get("type") == "text"
+ for b in content
+ )
+ has_tool_result = any(
+ isinstance(b, dict) and b.get("type") == "tool_result"
+ for b in content
+ )
+ if has_text and not has_tool_result:
+ result["user_seq"] = int(seq)
+ break
+ else:
+ result["user_seq"] = int(seq)
+ break
+ finally:
+ conn.close()
+ return result
+
def clear_session(self, session_id: str) -> None:
"""Delete all messages and the session record for a given session_id."""
with self._lock:
diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py
index 22be33c0..e8424eb5 100644
--- a/bridge/agent_bridge.py
+++ b/bridge/agent_bridge.py
@@ -383,7 +383,49 @@ class AgentBridge:
"""Initialize agent for a specific session"""
agent = self.initializer.initialize_agent(session_id=session_id)
self.agents[session_id] = agent
-
+
+ def sync_session_messages_from_store(self, session_id: str) -> int:
+ """Reload an agent's in-memory ``messages`` list from the persistent
+ conversation store.
+
+ Used after an external mutation (e.g. user edits / deletes a message
+ via the web console) so the agent's next turn sees the same history
+ as the database. The operation is a no-op when the agent has not been
+ instantiated yet for the session.
+
+ Returns:
+ Number of messages now held in the agent's memory. Returns -1 if
+ the agent does not exist or has no compatible ``messages`` attr.
+ """
+ if not session_id or session_id not in self.agents:
+ return -1
+ agent = self.agents[session_id]
+ if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
+ return -1
+ try:
+ from agent.memory import get_conversation_store
+ store = get_conversation_store()
+ # No turn cap here: we want a faithful mirror of what the store
+ # has for this session after deletion.
+ remaining = store.load_messages(session_id, max_turns=10**6)
+ except Exception as e:
+ logger.warning(
+ f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
+ )
+ return -1
+ with agent.messages_lock:
+ agent.messages.clear()
+ for msg in remaining:
+ agent.messages.append({
+ "role": msg["role"],
+ "content": msg["content"],
+ })
+ count = len(agent.messages)
+ logger.info(
+ f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
+ )
+ return count
+
def agent_reply(self, query: str, context: Context = None,
on_event=None, clear_history: bool = False) -> Reply:
"""
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index 188093b3..e260bbc9 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -1476,6 +1476,11 @@
/* =====================================================================
Drag and Drop Overlay
===================================================================== */
+/* Anchor the absolutely-positioned overlay to the chat view. */
+#view-chat {
+ position: relative;
+}
+
.drag-overlay {
position: absolute;
top: 0;
@@ -1554,8 +1559,7 @@
.user-message-group:hover .edit-msg-btn,
.user-message-group:hover .delete-msg-btn,
-.flex.gap-3:hover .regenerate-msg-btn,
-.flex.gap-3:hover .delete-msg-btn {
+.bot-message-group:hover .regenerate-msg-btn {
opacity: 1;
}
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 70ab97c7..31c8d271 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -1180,68 +1180,40 @@ messagesDiv.addEventListener('click', (e) => {
return;
}
- // Delete message
+ // Delete message (user bubble only; bot bubbles intentionally lack a
+ // delete button — removing only the bot reply would leave an orphan
+ // user message that breaks LLM context alternation).
const deleteBtn = e.target.closest('.delete-msg-btn');
if (deleteBtn) {
e.preventDefault();
const userMsgEl = deleteBtn.closest('.user-message-group');
- const botMsgEl = deleteBtn.closest('.flex.gap-3:not(.user-message-group)');
-
- if (userMsgEl) {
- showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
- let nextSibling = userMsgEl.nextElementSibling;
- let botReplyEl = null;
- while (nextSibling) {
- if (nextSibling.classList && nextSibling.classList.contains('flex') && nextSibling.classList.contains('gap-3') && !nextSibling.classList.contains('user-message-group')) {
- botReplyEl = nextSibling;
- break;
- }
- nextSibling = nextSibling.nextElementSibling;
+ if (!userMsgEl) return;
+
+ showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
+ // Find the next bot reply for this turn (skip non-message nodes).
+ let botReplyEl = null;
+ let sibling = userMsgEl.nextElementSibling;
+ while (sibling) {
+ if (sibling.classList && sibling.classList.contains('bot-message-group')) {
+ botReplyEl = sibling;
+ break;
}
- userMsgEl.remove();
- if (botReplyEl) botReplyEl.remove();
-
- const userSeq = userMsgEl.dataset.seq;
- if (userSeq) {
- fetch('/api/messages/delete', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
- }).then(r => r.json()).then(data => {
- if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
- }).catch(err => console.error('Failed to delete:', err));
- }
- });
- } else if (botMsgEl) {
- showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
- // Find the preceding user message to get its seq
- let prevUserEl = botMsgEl.previousElementSibling;
- while (prevUserEl && !prevUserEl.classList.contains('user-message-group')) {
- prevUserEl = prevUserEl.previousElementSibling;
- }
-
- // Delete from database (keep user message, only delete bot reply)
- if (prevUserEl) {
- const userSeq = prevUserEl.dataset.seq;
- if (userSeq) {
- fetch('/api/messages/delete', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- session_id: sessionId,
- user_seq: parseInt(userSeq),
- delete_user: false
- })
- }).then(r => r.json()).then(data => {
- if (data.status === 'success') console.log(`Deleted ${data.deleted} bot reply messages`);
- }).catch(err => console.error('Failed to delete bot reply:', err));
- }
- }
-
- // Remove from DOM
- botMsgEl.remove();
- });
- }
+ sibling = sibling.nextElementSibling;
+ }
+ userMsgEl.remove();
+ if (botReplyEl) botReplyEl.remove();
+
+ const userSeq = userMsgEl.dataset.seq;
+ if (userSeq) {
+ fetch('/api/messages/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
+ }).then(r => r.json()).then(data => {
+ if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
+ }).catch(err => console.error('Failed to delete:', err));
+ }
+ });
return;
}
@@ -2028,17 +2000,25 @@ async function editUserMessage(msgEl) {
}
}
- // Find all subsequent messages (this message and everything after it)
+ // Remove this message bubble and every later bubble that belongs to
+ // this or a subsequent turn. We mirror the backend cascade contract:
+ // anything with a data-seq >= current seq, plus any live SSE bubble
+ // that is still being streamed (no seq yet) after this point.
+ const currentSeqNum = userSeq ? parseInt(userSeq) : null;
const messagesToRemove = [];
let current = msgEl;
while (current) {
- if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('flex'))) {
- messagesToRemove.push(current);
+ if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('bot-message-group'))) {
+ const seqAttr = current.dataset.seq;
+ if (seqAttr === undefined || seqAttr === '') {
+ // Live message without a persisted seq yet — treat as later.
+ messagesToRemove.push(current);
+ } else if (currentSeqNum === null || parseInt(seqAttr) >= currentSeqNum) {
+ messagesToRemove.push(current);
+ }
}
current = current.nextElementSibling;
}
-
- // Remove all messages from this one onwards
messagesToRemove.forEach(el => {
if (el && el.parentNode) el.parentNode.removeChild(el);
});
@@ -2266,8 +2246,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
if (botEl) return;
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
botEl = document.createElement('div');
- botEl.className = 'flex gap-3 px-4 sm:px-6 py-3';
+ botEl.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group';
botEl.dataset.requestId = requestId;
+ // Regenerate button starts hidden; it's revealed in the "done"
+ // event handler once seq metadata arrives from the backend.
botEl.innerHTML = `