mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-02 23:57:14 +00:00
fix(lark): stop duplicating final reply text in streaming card (#2490)
* fix(lark): stop duplicating final reply text in streaming card * fix(lark): stop duplicating final reply text in streaming card
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user