From 21e2187c6c82a964e567631981a4c9d4061ee4b0 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Thu, 29 Oct 2026 20:23:06 +0800 Subject: [PATCH] fix(dingtalk): show submitted HITL choices in cards --- .../pkg/platform/adapters/dingtalk/adapter.py | 5 + .../platform/adapters/dingtalk/interaction.py | 122 +++++++++++++++++- .../platform/adapters/dingtalk/manifest.yaml | 1 + .../platform/test_dingtalk_eba_adapter.py | 106 +++++++++++++++ 4 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py index 12ad43e6b..972d8eca1 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -19,6 +19,7 @@ from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEvent from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP from langbot.pkg.platform.adapters.dingtalk.interaction import ( + acknowledge_interaction, interaction_delivery_capabilities, interaction_event_from_native, send_interaction, @@ -165,6 +166,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo ] if hasattr(self.bot, 'create_and_deliver_ai_card'): apis.append('interaction.request') + if hasattr(self.bot, 'update_ai_card'): + apis.append('interaction.acknowledge') return apis def get_interaction_capabilities(self) -> dict[str, typing.Any]: @@ -288,6 +291,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo async def call_platform_api(self, action: str, params: dict = {}) -> dict: if action == 'interaction.request' and action in self.get_supported_apis(): return await send_interaction(self, params) + if action == 'interaction.acknowledge' and action in self.get_supported_apis(): + return await acknowledge_interaction(self, params) handler = PLATFORM_API_MAP.get(action) if handler is None: raise NotSupportedError(f'call_platform_api:{action}') diff --git a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py index a36e89375..f87c01674 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py @@ -25,10 +25,87 @@ def _json_pointer_segment(value: str) -> str: return value.replace('~', '~0').replace('/', '~1') +def _stored_submitted_values(value: typing.Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + return [ + { + 'label': str(item['label'])[:200], + 'value': str(item['value'])[:2000], + **({'description': str(item['description'])[:4000]} if item.get('description') is not None else {}), + } + for item in value[:50] + if isinstance(item, dict) and item.get('label') is not None and item.get('value') is not None + ] + + +def _submission_display_values( + request: dict[str, typing.Any], submission: dict[str, typing.Any] +) -> list[dict[str, str]]: + values = submission.get('values') if isinstance(submission.get('values'), dict) else {} + displayed: list[dict[str, str]] = [] + for field in request.get('fields') or []: + if not isinstance(field, dict): + continue + field_id = str(field.get('id') or '') + if not field_id or field_id not in values: + continue + value = values[field_id] + if field.get('type') == 'select': + value = next( + ( + option.get('label') or option.get('value') + for option in field.get('options') or [] + if isinstance(option, dict) and option.get('value') == value + ), + value, + ) + rendered = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value) + display_value = {'label': str(field.get('label') or field_id), 'value': rendered[:2000]} + description = str(request.get('description') or '').strip() + if description: + display_value['description'] = description[:4000] + displayed.append(display_value) + + action_id = submission.get('action_id') + if action_id: + action_label = next( + ( + str(action.get('label') or action_id) + for action in request.get('actions') or [] + if isinstance(action, dict) and str(action.get('id') or '') == str(action_id) + ), + str(action_id), + ) + action_value = {'label': '', 'value': action_label} + description = str(request.get('description') or '').strip() + if description and not displayed: + action_value['description'] = description[:4000] + displayed.append(action_value) + return displayed + + +def _submitted_components(values: list[dict[str, str]]) -> tuple[list[dict[str, typing.Any]], list[str]]: + components = [] + children = [] + for index, item in enumerate(values): + description = item.get('description') + if description: + description_id = f'submitted-description-{index}' + components.append({'id': description_id, 'component': 'Text', 'text': description}) + children.append(description_id) + component_id = f'submitted-{index}' + label = f'{item["label"]}: ' if item['label'] else '' + components.append({'id': component_id, 'component': 'Text', 'text': f'\u2705 {label}{item["value"]}'}) + children.append(component_id) + return components, children + + def _build_interaction_messages( surface_id: str, request: dict[str, typing.Any], callback_token: str, + submitted_values: list[dict[str, str]] | None = None, ) -> tuple[dict[str, typing.Any], list[dict[str, typing.Any]], dict[str, str]]: raw_fields = request.get('fields') if isinstance(request.get('fields'), list) else [] raw_actions = request.get('actions') if isinstance(request.get('actions'), list) else [] @@ -46,6 +123,9 @@ def _build_interaction_messages( if title: components.append({'id': 'title', 'component': 'Text', 'text': title}) children.append('title') + history_components, history_children = _submitted_components(submitted_values or []) + components.extend(history_components) + children.extend(history_children) if description: components.append({'id': 'description', 'component': 'Markdown', 'content': description}) children.append('description') @@ -188,6 +268,9 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - if target_type not in {'group', 'person'} or not target_id: raise ValueError('interaction.request has an invalid DingTalk target') update_target = params.get('update_target') + submitted_values = _stored_submitted_values( + update_target.get('submitted_values') if isinstance(update_target, dict) else None + ) if isinstance(update_target, dict): if update_target.get('rich') is not True or not update_target.get('message_id'): raise ValueError('DingTalk interaction update target is not an A2UI card') @@ -195,7 +278,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - else: out_track_id = uuid.uuid4().hex surface_id = f'langbot-interaction-{out_track_id}' - data, components, field_types = _build_interaction_messages(surface_id, request, callback_token) + data, components, field_types = _build_interaction_messages(surface_id, request, callback_token, submitted_values) if isinstance(update_target, dict): await adapter.bot.update_ai_card( out_track_id=out_track_id, @@ -222,12 +305,47 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - 'target_id': target_id, 'expires_at': time.monotonic() + 30 * 60, } - return { + result = { 'ok': True, 'message_id': out_track_id, 'rich': True, 'updated': isinstance(update_target, dict), } + if submitted_values: + result['submitted_values'] = submitted_values + return result + + +async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]: + request = params.get('request') + submission = params.get('submission') + target = params.get('update_target') + if not all(isinstance(value, dict) for value in (request, submission, target)): + raise ValueError('interaction.acknowledge requires request, submission, and update_target') + if target.get('rich') is not True or not target.get('message_id'): + raise ValueError('DingTalk interaction acknowledgement requires an A2UI card') + + out_track_id = str(target['message_id']) + submitted_values = [ + *_stored_submitted_values(target.get('submitted_values')), + *_submission_display_values(request, submission), + ][-50:] + components, children = _submitted_components(submitted_values) + title = str(request.get('title') or '').strip() + if title: + components.insert(0, {'id': 'title', 'component': 'Text', 'text': title}) + children.insert(0, 'title') + children.append('langbot-brand') + components.extend(brand_components()) + components.insert(0, {'id': 'root', 'component': 'Column', 'children': children, 'gap': 12}) + await adapter.bot.update_ai_card( + out_track_id=out_track_id, + a2ui_messages=update_surface_messages( + f'langbot-interaction-{out_track_id}', data={'form': {}}, components=components + ), + flow_status='CONFIRMED', + ) + return {**target, 'updated': True, 'submitted_values': submitted_values} def parse_callback_data(value: str) -> dict[str, typing.Any]: diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml index 4efbf2fb7..60b8e211f 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -86,6 +86,7 @@ spec: - get_file_url - call_platform_api - interaction.request + - interaction.acknowledge platform_specific_apis: - action: check_access_token diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py index adcebc88e..cf0eebfc0 100644 --- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -632,6 +632,112 @@ async def test_dingtalk_interaction_update_reuses_existing_a2ui_card(): assert adapter.interaction_callback_contexts['existing-track-id']['callback_token'] == 'new-token' +@pytest.mark.asyncio +async def test_dingtalk_acknowledgement_shows_text_selection_and_clicked_button(): + adapter = make_adapter() + assert 'interaction.acknowledge' in adapter.get_supported_apis() + result = await adapter.call_platform_api( + 'interaction.acknowledge', + { + 'request': { + 'title': 'Review', + 'fields': [ + {'id': 'reason', 'label': 'Reason', 'type': 'text'}, + { + 'id': 'priority', + 'label': 'Priority', + 'type': 'select', + 'options': [{'value': 'urgent', 'label': 'Urgent'}], + }, + ], + 'actions': [{'id': 'approve', 'label': 'Approve'}], + }, + 'submission': {'values': {'reason': 'Looks good', 'priority': 'urgent'}, 'action_id': 'approve'}, + 'update_target': {'message_id': 'card-1', 'rich': True}, + }, + ) + + kwargs = adapter.bot.update_ai_card.await_args.kwargs + assert kwargs['out_track_id'] == 'card-1' + assert kwargs['flow_status'] == 'CONFIRMED' + components = kwargs['a2ui_messages'][1]['updateComponents']['components'] + assert [ + component['text'] + for component in components + if component['component'] == 'Text' and component['id'].startswith('submitted-') + ] == [ + '\u2705 Reason: Looks good', + '\u2705 Priority: Urgent', + '\u2705 Approve', + ] + assert not any(component['component'] in {'Button', 'TextField', 'ChoicePicker'} for component in components) + assert result['submitted_values'] == [ + {'label': 'Reason', 'value': 'Looks good'}, + {'label': 'Priority', 'value': 'Urgent'}, + {'label': '', 'value': 'Approve'}, + ] + + next_result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'next-token', + 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, + 'update_target': result, + 'request': {'title': 'Next step', 'actions': [{'id': 'finish', 'label': 'Finish'}]}, + }, + ) + components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components'] + assert next_result['submitted_values'] == result['submitted_values'] + assert components[0]['children'][:4] == ['title', 'submitted-0', 'submitted-1', 'submitted-2'] + assert any(component['component'] == 'Button' for component in components) + + +@pytest.mark.asyncio +async def test_dingtalk_field_submit_shows_entered_value_without_submit_action(): + adapter = make_adapter() + await adapter.call_platform_api( + 'interaction.acknowledge', + { + 'request': { + 'description': 'Please enter your question', + 'fields': [{'id': 'question', 'label': 'Question', 'type': 'textarea'}], + }, + 'submission': {'values': {'question': 'What happened?'}}, + 'update_target': {'message_id': 'card-2', 'rich': True}, + }, + ) + components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components'] + assert [component['text'] for component in components if component['id'].startswith('submitted-')] == [ + 'Please enter your question', + '\u2705 Question: What happened?', + ] + + +@pytest.mark.asyncio +async def test_dingtalk_button_submit_preserves_its_prompt(): + adapter = make_adapter() + result = await adapter.call_platform_api( + 'interaction.acknowledge', + { + 'request': { + 'title': 'Human input', + 'description': 'Choose how to proceed', + 'actions': [{'id': 'continue', 'label': 'Continue'}], + }, + 'submission': {'action_id': 'continue', 'values': {}}, + 'update_target': {'message_id': 'card-3', 'rich': True}, + }, + ) + components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components'] + assert [component['text'] for component in components if component['id'].startswith('submitted-')] == [ + 'Choose how to proceed', + '\u2705 Continue', + ] + assert result['submitted_values'] == [ + {'label': '', 'value': 'Continue', 'description': 'Choose how to proceed'}, + ] + + def test_dingtalk_stale_callback_does_not_consume_replacement_context(): contexts = { 'card-1': {