diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 3c3476159..085fdc57a 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -160,6 +160,29 @@ def _lark_should_update_stream_element( return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) +def _lark_final_layout_texts( + *, + resume_from: bool, + text_message: str, + pre_pause_cached: str | None, + resume_cached: str, +) -> tuple[str, str]: + """Return (main_text, resume_placeholder_text) for the final card update. + + Non-resume round: the full reply belongs in the main streaming element + only — also rendering the resume placeholder duplicates the reply, since + both hold the same accumulated text. Resume round (Dify HITL): keep the + pre-pause text in the main element and the resumed text in the + placeholder, as they are distinct segments. + """ + if resume_from: + # An empty pre-pause cache is valid (Dify paused before emitting any + # text); only a missing entry (None) falls back to the full text. + main_text = text_message if pre_pause_cached is None else pre_pause_cached + return main_text, resume_cached + return text_message, '' + + def _lark_display_input_value(field: dict, value: typing.Any) -> str: field_type = _dify_field_type(field) if field_type == 'file': @@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) else: - # Normal finish: keep pre-pause + resume content visible, - # remove buttons/notice, drop the resume placeholder. + # Normal finish: remove buttons/notice and finalize the card. + main_text, resume_text = _lark_final_layout_texts( + resume_from=resume_from, + text_message=text_message, + pre_pause_cached=self.card_pre_pause_text.get(card_id), + resume_cached=resume_cached, + ) await self._update_card_layout( card_id=card_id, message_source=message_source, - text_message=pre_pause, + text_message=main_text, sequence=final_seq, form_data=None, notice_text=selected_notice if resume_from else '', - resume_placeholder_text=resume_cached, + resume_placeholder_text=resume_text, ) self._drop_card_state(card_id) self.card_id_dict.pop(message_id, None) diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py index a8e1fe3d9..a89709b75 100644 --- a/tests/unit_tests/platform/test_lark_adapter.py +++ b/tests/unit_tests/platform/test_lark_adapter.py @@ -1,7 +1,7 @@ """Tests for Lark adapter helper behavior.""" import threading -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import ( _lark_completed_input_lines, _lark_current_input_defs, _lark_extract_action_form_inputs, + _lark_final_layout_texts, _lark_should_update_stream_element, _lark_visible_form_content, ) @@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object(): ) assert lines == ['✅ xiala:B'] + + +def test_lark_final_layout_texts_normal_round_drops_resume_placeholder(): + """Non-resume final chunk: the reply must land in the main element only. + + Regression: rendering the resume placeholder too duplicated the reply, + because the accumulated streaming text equals the final text on a normal + round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card). + """ + main_text, resume_text = _lark_final_layout_texts( + resume_from=False, + text_message='It is Sep 1, 2026, 15:09:15.', + pre_pause_cached=None, + resume_cached='It is Sep 1, 2026, 15:09:15.', + ) + + assert main_text == 'It is Sep 1, 2026, 15:09:15.' + assert resume_text == '' + + +def test_lark_final_layout_texts_resume_round_keeps_both_segments(): + """Dify HITL resume final chunk: pre-pause text and resumed text differ, + both segments stay visible.""" + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='resumed answer', + pre_pause_cached='partial answer before pause', + resume_cached='resumed answer', + ) + + assert main_text == 'partial answer before pause' + assert resume_text == 'resumed answer' + + +def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back(): + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='answer', + pre_pause_cached=None, + resume_cached='answer', + ) + + assert main_text == 'answer' + assert resume_text == 'answer' + + +def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty(): + """Dify paused before emitting any text: the pre-pause cache is a valid + empty string and must NOT be treated as a cache miss. + + Regression: `pre_pause_cached or text_message` fell back to the full + text, so the final card rendered ('resumed answer', 'resumed answer') + and duplicated the reply. + """ + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='resumed answer', + pre_pause_cached='', + resume_cached='resumed answer', + ) + + assert main_text == '' + assert resume_text == 'resumed answer' + + +def _build_resume_final_chunk_adapter(message_text: str): + """Build a LarkAdapter whose card state mimics a Dify HITL round that + paused before emitting any text, then resumed and completed.""" + adapter = LarkAdapter.model_construct( + api_client=MagicMock(), + message_converter=MagicMock(yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))), + ) + adapter.config = {'app_type': 'self'} + LarkAdapter.get_app_access_token = lambda self: None + LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None + adapter.card_id_dict = {'msg-1': 'card-1'} + adapter.card_streaming_text = {'card-1': message_text} + adapter.card_pre_pause_text = {'card-1': ''} + adapter.card_resume_transitioned = {'card-1'} + adapter.card_sequence_dict = {} + adapter.card_last_accessed = {} + adapter.card_cleanup_at = 0.0 + adapter.card_id_to_source_ids = {} + adapter.reply_message_card_ids = {} + adapter.card_form_content = {} + adapter.card_form_input_defs = {} + adapter.card_form_inputs = {} + adapter._update_card_layout = AsyncMock() + return adapter + + +@pytest.mark.asyncio +async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty(): + """End-to-end regression via reply_message_chunk: Dify paused before any + text, so the pre-pause cache is ''. The final card update must render the + resumed answer only once (empty main text + resume placeholder), not + twice as ('resumed answer', 'resumed answer').""" + adapter = _build_resume_final_chunk_adapter('resumed answer') + + bot_message = MagicMock( + resp_message_id='msg-1', + msg_sequence=1, + spec=['resp_message_id', 'msg_sequence', '_resume_from_form'], + ) + bot_message._resume_from_form = True + message_source = MagicMock(source_platform_object=None) + + await adapter.reply_message_chunk( + message_source, + bot_message, + MagicMock(), + is_final=True, + ) + + adapter._update_card_layout.assert_awaited_once() + layout_kwargs = adapter._update_card_layout.await_args.kwargs + assert layout_kwargs['text_message'] == '' + assert layout_kwargs['resume_placeholder_text'] == 'resumed answer'