fix(dingtalk): show submitted HITL choices in cards

This commit is contained in:
fdc310
2026-10-29 20:23:06 +08:00
parent d611f687eb
commit 21e2187c6c
4 changed files with 232 additions and 2 deletions
@@ -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}')
@@ -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]:
@@ -86,6 +86,7 @@ spec:
- get_file_url
- call_platform_api
- interaction.request
- interaction.acknowledge
platform_specific_apis:
- action: check_access_token
@@ -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': {