mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-03 08:07: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:
@@ -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)
|
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:
|
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||||
field_type = _dify_field_type(field)
|
field_type = _dify_field_type(field)
|
||||||
if field_type == 'file':
|
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_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||||
else:
|
else:
|
||||||
# Normal finish: keep pre-pause + resume content visible,
|
# Normal finish: remove buttons/notice and finalize the card.
|
||||||
# remove buttons/notice, drop the resume placeholder.
|
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(
|
await self._update_card_layout(
|
||||||
card_id=card_id,
|
card_id=card_id,
|
||||||
message_source=message_source,
|
message_source=message_source,
|
||||||
text_message=pre_pause,
|
text_message=main_text,
|
||||||
sequence=final_seq,
|
sequence=final_seq,
|
||||||
form_data=None,
|
form_data=None,
|
||||||
notice_text=selected_notice if resume_from else '',
|
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._drop_card_state(card_id)
|
||||||
self.card_id_dict.pop(message_id, None)
|
self.card_id_dict.pop(message_id, None)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for Lark adapter helper behavior."""
|
"""Tests for Lark adapter helper behavior."""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import (
|
|||||||
_lark_completed_input_lines,
|
_lark_completed_input_lines,
|
||||||
_lark_current_input_defs,
|
_lark_current_input_defs,
|
||||||
_lark_extract_action_form_inputs,
|
_lark_extract_action_form_inputs,
|
||||||
|
_lark_final_layout_texts,
|
||||||
_lark_should_update_stream_element,
|
_lark_should_update_stream_element,
|
||||||
_lark_visible_form_content,
|
_lark_visible_form_content,
|
||||||
)
|
)
|
||||||
@@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert lines == ['✅ xiala:B']
|
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