Skip to content

Commit e04582b

Browse files
committed
fix(embed): harden reset and reconnect recovery
1 parent c5a1dd5 commit e04582b

3 files changed

Lines changed: 88 additions & 4 deletions

File tree

src/langbot/pkg/platform/sources/websocket_adapter.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ def get_websocket_messages(
536536
"""Return history for one pipeline/client conversation."""
537537
conversation_key = self._conversation_key(pipeline_uuid, session_id)
538538
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
539-
return [message.model_dump() for message in session.get_message_list(conversation_key)]
539+
return [message.model_dump() for message in session.message_lists.get(conversation_key, [])]
540540

541541
def reset_session(
542542
self,
@@ -551,3 +551,23 @@ def reset_session(
551551
session.message_lists[conversation_key] = []
552552
if conversation_key in session.stream_message_indexes:
553553
session.stream_message_indexes[conversation_key] = {}
554+
555+
if session_id:
556+
launcher_id = (
557+
f'websocketgroup_{pipeline_uuid}:{session_id}'
558+
if session_type == 'group'
559+
else f'websocket_{pipeline_uuid}:{session_id}'
560+
)
561+
self.ap.sess_mgr.session_list = [
562+
candidate_session
563+
for candidate_session in self.ap.sess_mgr.session_list
564+
if not (
565+
str(
566+
candidate_session.launcher_type.value
567+
if hasattr(candidate_session.launcher_type, 'value')
568+
else candidate_session.launcher_type
569+
)
570+
== session_type
571+
and str(candidate_session.launcher_id) == launcher_id
572+
)
573+
]

src/langbot/templates/embed/widget.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,9 @@
364364
isStreaming: false,
365365
streamingMsgId: null,
366366
historyLoaded: false,
367+
hasConnected: false,
368+
messageVersion: 0,
369+
historyReloadTimer: null,
367370
pendingImage: null,
368371
feedbackState: {},
369372
};
@@ -571,6 +574,8 @@
571574
case "connected":
572575
state.isConnected = true;
573576
state.connectionId = data.connection_id;
577+
if (state.hasConnected) loadHistory(true);
578+
state.hasConnected = true;
574579
updateStatusDot();
575580
updateSendBtn();
576581
break;
@@ -638,6 +643,7 @@
638643

639644
if (existingIdx >= 0) {
640645
state.messages[existingIdx] = msg;
646+
state.messageVersion++;
641647
updateMessageEl(existingIdx, msg);
642648
} else {
643649
addMessage(msg);
@@ -707,9 +713,18 @@
707713
}
708714

709715
// ========== Message History ==========
710-
function loadHistory() {
711-
if (state.historyLoaded) return;
716+
function scheduleHistoryReload() {
717+
if (state.historyReloadTimer) clearTimeout(state.historyReloadTimer);
718+
state.historyReloadTimer = setTimeout(function () {
719+
state.historyReloadTimer = null;
720+
loadHistory(true);
721+
}, 100);
722+
}
723+
724+
function loadHistory(force) {
725+
if (state.historyLoaded && !force) return;
712726
state.historyLoaded = true;
727+
var messageVersion = state.messageVersion;
713728

714729
var url =
715730
CONFIG.baseUrl +
@@ -728,6 +743,16 @@
728743
})
729744
.then(function (json) {
730745
if (json.code === 0 && json.data && json.data.messages) {
746+
if (force && messageVersion !== state.messageVersion) {
747+
scheduleHistoryReload();
748+
return;
749+
}
750+
if (force) {
751+
state.messages = [];
752+
state.isStreaming = false;
753+
state.streamingMsgId = null;
754+
renderMessages();
755+
}
731756
var msgs = json.data.messages;
732757
for (var i = 0; i < msgs.length; i++) {
733758
addMessage(msgs[i], true);
@@ -755,6 +780,7 @@
755780
fetch(url, { method: "POST", headers: headers })
756781
.then(function () {
757782
state.messages = [];
783+
state.messageVersion++;
758784
state.isStreaming = false;
759785
state.streamingMsgId = null;
760786
state.historyLoaded = true;
@@ -768,6 +794,7 @@
768794
// ========== UI Rendering ==========
769795
function addMessage(msg, silent) {
770796
state.messages.push(msg);
797+
if (!silent) state.messageVersion++;
771798
var el = createMessageEl(msg);
772799
if (els.welcome) {
773800
els.welcome.style.display = "none";

tests/unit_tests/platform/test_websocket_session_isolation.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,36 @@ def test_session_ids_must_be_canonical_random_uuids():
170170
assert not is_valid_session_id('00000000-0000-0000-0000-000000000000')
171171

172172

173+
def test_history_read_does_not_allocate_unknown_session():
174+
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
175+
adapter.websocket_person_session = WebSocketSession(id='person')
176+
adapter.websocket_group_session = WebSocketSession(id='group')
177+
178+
assert adapter.get_websocket_messages('pipeline-1', 'person', 'missing-session') == []
179+
assert adapter.websocket_person_session.message_lists == {}
180+
181+
173182
def test_history_and_reset_are_scoped_to_browser_session():
183+
matching_provider_session = Mock(
184+
launcher_type=Mock(value='person'),
185+
launcher_id='websocket_pipeline-1:session-a',
186+
)
187+
matching_group_provider_session = Mock(
188+
launcher_type=Mock(value='group'),
189+
launcher_id='websocketgroup_pipeline-1:session-a',
190+
)
191+
other_session = Mock(
192+
launcher_type=Mock(value='person'),
193+
launcher_id='websocket_pipeline-1:session-b',
194+
)
195+
ap = Mock()
196+
ap.sess_mgr.session_list = [
197+
matching_provider_session,
198+
matching_group_provider_session,
199+
other_session,
200+
]
174201
adapter = WebSocketAdapter.model_construct(
175-
ap=Mock(),
202+
ap=ap,
176203
logger=AsyncMock(),
177204
)
178205
adapter.websocket_person_session = Mock()
@@ -207,6 +234,8 @@ def test_history_and_reset_are_scoped_to_browser_session():
207234
adapter.websocket_person_session.get_message_list.side_effect = histories.__getitem__
208235
adapter.websocket_person_session.message_lists = histories
209236
adapter.websocket_person_session.stream_message_indexes = stream_indexes
237+
adapter.websocket_group_session.message_lists = {}
238+
adapter.websocket_group_session.stream_message_indexes = {}
210239

211240
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-a')[0]['content'] == 'private-a'
212241
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-b')[0]['content'] == 'private-b'
@@ -217,6 +246,11 @@ def test_history_and_reset_are_scoped_to_browser_session():
217246
assert stream_indexes['pipeline-1:session-a'] == {}
218247
assert histories['pipeline-1:session-b'] == session_b
219248
assert stream_indexes['pipeline-1:session-b'] == {'response-b': 0}
249+
assert ap.sess_mgr.session_list == [matching_group_provider_session, other_session]
250+
251+
adapter.reset_session('pipeline-1', 'group', 'session-a')
252+
253+
assert ap.sess_mgr.session_list == [other_session]
220254

221255

222256
def test_widget_sends_stable_session_id_to_all_conversation_endpoints():
@@ -228,3 +262,6 @@ def test_widget_sends_stable_session_id_to_all_conversation_endpoints():
228262
assert 'window.localStorage' not in widget
229263
assert 'session_id=' in widget
230264
assert widget.count('encodeURIComponent(state.sessionId)') >= 3
265+
assert 'loadHistory(true)' in widget
266+
assert 'messageVersion !== state.messageVersion' in widget
267+
assert 'scheduleHistoryReload();' in widget

0 commit comments

Comments
 (0)