Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners

- Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines.
- Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines.
- Add tests for WeComBot template card functionalities, including event extraction and payload building for human input.
- Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction.
This commit is contained in:
fdc310
2026-07-10 18:25:24 +08:00
parent 776d48380e
commit 98a633c712
15 changed files with 3741 additions and 352 deletions
@@ -0,0 +1,104 @@
"""Tests for DingTalk adapter helper behavior."""
from langbot.pkg.platform.sources.dingtalk import (
_dingtalk_clean_form_content,
_dingtalk_completed_input_lines,
_dingtalk_extract_component_inputs,
_dingtalk_form_component_params,
_dingtalk_pending_input_defs,
)
def test_dingtalk_select_component_params_expose_options():
params = _dingtalk_form_component_params(
{
'_current_input_field': 'choice',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
}
)
assert params['select_visible'] == 'true'
assert params['select_placeholder'] == 'choice'
assert params['select_options'] == ['A', 'B']
assert [option['value'] for option in params['index_o']] == ['A', 'B']
assert [option['value'] for option in params['test_index']] == ['A', 'B']
assert params['index_o'][0]['text']['zh_CN'] == 'A'
assert params['index_o'][0]['text']['en_US'] == 'A'
assert params['select_index'] == -1
def test_dingtalk_extract_select_from_builtin_result_dict():
inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}})
assert inputs == {'select': 'B'}
def test_dingtalk_extract_select_from_template_param_string():
inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'})
assert inputs == {'select': '{"index": 1, "value": "B"}'}
def test_dingtalk_extract_input_and_select_together():
inputs = _dingtalk_extract_component_inputs(
{
'inputResult': {'value': 'looks good'},
'__built_in_selectResult__': {'index': 0, 'value': 'A'},
}
)
assert inputs == {'input': 'looks good', 'select': 'A'}
def test_dingtalk_pending_input_defs_includes_file_fields():
pending = _dingtalk_pending_input_defs(
{
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {'comment': 'ready'},
}
)
assert [field['output_variable_name'] for field in pending] == ['files']
def test_dingtalk_completed_input_lines_include_text_and_select_values():
lines = _dingtalk_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {'comment': 'looks good', 'choice': 'B'},
}
)
assert lines == ['✅ 已填写 comment**looks good**', '✅ 已选择 choice**B**']
def test_dingtalk_clean_form_content_uses_all_input_defs():
content = _dingtalk_clean_form_content(
{
'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
'input_defs': [],
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
}
)
assert content == 'Hello'
@@ -0,0 +1,42 @@
"""Tests for DingTalk API payload helpers."""
import json
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
params = _stringify_card_param_map(
{
'content': 'Pick one',
'btns': json.dumps([{'text': 'OK'}], ensure_ascii=False),
'select_options': ['A', 'B'],
'index_o': [
{
'value': 'A',
'text': {'zh_CN': 'A', 'en_US': 'A'},
}
],
'test_index': [
{
'value': 'A',
'text': {'zh_CN': 'A', 'en_US': 'A'},
}
],
'select_index': -1,
}
)
assert params['content'] == 'Pick one'
assert params['btns'] == '[{"text": "OK"}]'
assert params['select_options'] == '["A", "B"]'
assert json.loads(params['index_o'])[0]['value'] == 'A'
assert json.loads(params['test_index'])[0]['value'] == 'A'
assert params['select_index'] == '-1'
def test_dingtalk_card_param_map_stringifies_unregistered_structures():
params = _stringify_card_param_map({'other': ['A'], 'empty': None})
assert params['other'] == '["A"]'
assert params['empty'] == ''
@@ -0,0 +1,111 @@
"""Tests for Lark adapter helper behavior."""
from langbot.pkg.platform.sources.lark import (
_lark_clean_form_content,
_lark_completed_input_lines,
_lark_extract_action_form_inputs,
)
def test_lark_completed_input_lines_include_text_select_and_files():
lines = _lark_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {
'us_input': '你好',
'xiala': 'or',
'files': [{'upload_file_id': 'file-1'}, {'upload_file_id': 'file-2'}],
},
}
)
assert lines == [
'✅ 已填写 us_input**你好**',
'✅ 已选择 xiala**or**',
'✅ 已上传 files**2 file(s)**',
]
def test_lark_clean_form_content_removes_all_input_placeholders():
content = _lark_clean_form_content(
'人工介入\n\n{{#$output.us_input#}}\n\n{{#$output.xiala#}}\n',
[
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
],
)
assert content == '人工介入'
def test_lark_extract_action_form_inputs_from_json_form_value():
class Action:
form_value = '{"Input_1_us_input_abcd12": "hello", "Select_2_xiala_abcd12": "B"}'
input_value = None
option = None
name = None
inputs = _lark_extract_action_form_inputs(
Action(),
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
'Select_2_xiala_abcd12': 'xiala',
}
},
)
assert inputs == {'us_input': 'hello', 'xiala': 'B'}
def test_lark_extract_action_form_inputs_from_webhook_dict_action():
inputs = _lark_extract_action_form_inputs(
{
'form_value': {
'Input_1_us_input_abcd12': 'hello',
'Select_2_xiala_abcd12': {'value': 'B', 'text': {'content': 'Option B'}},
}
},
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
'Select_2_xiala_abcd12': 'xiala',
}
},
)
assert inputs == {'us_input': 'hello', 'xiala': {'value': 'B', 'text': {'content': 'Option B'}}}
def test_lark_extract_action_form_inputs_maps_dotted_component_names():
inputs = _lark_extract_action_form_inputs(
{
'form_value': {
'Form_1_token_abcd12.Input_1_us_input_abcd12': 'hello',
}
},
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
}
},
)
assert inputs == {'us_input': 'hello'}
def test_lark_completed_input_lines_display_select_value_from_object():
lines = _lark_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'xiala', 'type': 'select'},
],
'inputs': {'xiala': {'value': 'B', 'text': {'content': 'Option B'}}},
}
)
assert lines == ['✅ 已选择 xiala**B**']
@@ -1,6 +1,8 @@
import sys
import types
import pytest
logger_module = types.ModuleType('langbot.pkg.platform.logger')
logger_module.EventLogger = object
@@ -8,9 +10,17 @@ sys.modules.setdefault('langbot.pkg.platform.logger', logger_module)
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
build_button_interaction_payload,
build_human_input_template_card_payload,
build_button_interaction_update_card,
build_multiple_interaction_update_card,
extract_template_card_event_payload,
extract_template_card_selections,
extract_wecom_event_type,
extract_template_card_action,
build_human_input_text_prompt,
parse_select_button_action,
)
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
def test_extract_template_card_action_supports_nested_button_key():
@@ -27,6 +37,31 @@ def test_extract_template_card_action_supports_nested_button_key():
assert card_type == 'button_interaction'
def test_extract_wecom_event_type_supports_top_level_template_card_event():
payload = {
'eventtype': 'template_card_event',
'template_card_event': {
'TaskId': 'task-1',
'CardType': 'multiple_interaction',
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
},
}
assert extract_wecom_event_type(payload) == 'template_card_event'
assert extract_template_card_event_payload(payload)['TaskId'] == 'task-1'
def test_extract_wecom_event_type_infers_template_card_event_from_top_level_card_fields():
payload = {
'TaskId': 'task-1',
'CardType': 'button_interaction',
'EventKey': 'approve',
}
assert extract_wecom_event_type(payload) == 'template_card_event'
assert extract_template_card_event_payload(payload)['EventKey'] == 'approve'
def test_build_button_interaction_update_card_marks_clicked_button():
card = build_button_interaction_update_card(
{
@@ -70,3 +105,281 @@ def test_build_button_interaction_payload_uses_preselected_button_styles_before_
{'text': 'Approve', 'style': 2, 'key': 'approve'},
{'text': 'Reject', 'style': 2, 'key': 'reject'},
]
def test_build_payload_uses_multiple_interaction_for_pending_select_field():
payload = build_human_input_template_card_payload(
{
'node_title': 'Manual Review',
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
'actions': [{'id': 'yes', 'title': 'Yes'}],
},
task_id='task-1',
source={'desc': 'LangBot'},
)
card = payload['template_card']
assert card['card_type'] == 'multiple_interaction'
assert card['source'] == {'desc': 'LangBot'}
assert card['select_list'] == [
{
'question_key': 'choice',
'title': 'choice',
'selected_id': 'opt_1',
'option_list': [
{'id': 'opt_1', 'text': 'A'},
{'id': 'opt_2', 'text': 'B'},
],
}
]
assert card['submit_button'] == {'text': 'Submit', 'key': 'submit_human_input'}
def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws():
form_data = {
'node_title': 'Manual Review',
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
'actions': [{'id': 'yes', 'title': 'Yes'}],
}
payload = build_human_input_template_card_payload(
form_data,
task_id='task-1',
select_as_buttons=True,
)
card = payload['template_card']
assert card['card_type'] == 'button_interaction'
assert card['button_list'][0]['text'] == 'A'
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
def test_text_input_card_shows_direct_reply_prompt():
payload = build_human_input_template_card_payload(
{
'node_title': '人工介入',
'form_content': '请输入你的问题\n\n{{#$output.us_input#}}',
'input_defs': [
{
'output_variable_name': 'us_input',
'type': 'paragraph',
'label': '请输入你的问题',
}
],
'inputs': {},
'actions': [{'id': 'yes', 'title': 'yes'}],
'_current_input_field': 'us_input',
},
task_id='task-1',
)
card = payload['template_card']
assert card['main_title']['desc'] == '请直接回复:请输入你的问题'
assert '请直接回复:请输入你的问题' in card['sub_title_text']
assert card['button_list'] == []
def test_build_human_input_text_prompt_for_current_text_field():
prompt = build_human_input_text_prompt(
{
'node_title': '人工介入',
'input_defs': [
{
'output_variable_name': 'us_input',
'type': 'paragraph',
'label': '请输入你的问题',
}
],
'_current_input_field': 'us_input',
}
)
assert prompt == '人工介入\n\n请直接回复:请输入你的问题'
@pytest.mark.asyncio
async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
client = WecomBotWsClient('bot-id', 'secret', object())
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {'user_id': 'user-1'}
sent = []
async def fake_reply_text(req_id, content):
sent.append((req_id, content))
return {}
client.reply_text = fake_reply_text
ok, stream_id, task_id = await client.push_form_pause(
'msg-1',
{
'node_title': '人工介入',
'input_defs': [
{
'output_variable_name': 'us_input',
'type': 'paragraph',
'label': '请输入你的问题',
}
],
'_current_input_field': 'us_input',
},
)
assert ok is True
assert stream_id == 'stream-1'
assert task_id is None
assert sent == [('req-1', '人工介入\n\n请直接回复:请输入你的问题')]
assert client._pending_forms_by_task == {}
assert 'msg-1' not in client._stream_ids
@pytest.mark.asyncio
async def test_ws_stream_sends_delta_chunks_to_wecom():
client = WecomBotWsClient('bot-id', 'secret', object())
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {}
sent = []
async def fake_reply_stream(req_id, stream_id, content, finish=False, feedback_id=''):
sent.append((req_id, stream_id, content, finish))
return {}
client.reply_stream = fake_reply_stream
assert await client.push_stream_chunk('msg-1', '', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
assert sent == [
('req-1', 'stream-1', '', False),
('req-1', 'stream-1', '', False),
('req-1', 'stream-1', '', True),
]
def test_human_input_payload_keeps_action_select_stage_as_buttons():
payload = build_human_input_template_card_payload(
{
'node_title': 'Manual Review',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {'choice': 'B'},
'actions': [
{'id': 'approve', 'title': 'Approve'},
{'id': 'reject', 'title': 'Reject'},
],
'_action_select_only': True,
},
task_id='task-1',
)
card = payload['template_card']
assert card['card_type'] == 'button_interaction'
assert card['button_list'] == [
{'text': 'Approve', 'style': 2, 'key': 'approve'},
{'text': 'Reject', 'style': 2, 'key': 'reject'},
]
def test_extract_template_card_selections_maps_selected_id_to_option_text():
selections = extract_template_card_selections(
{
'SelectedItems': [
{'QuestionKey': 'choice', 'SelectedId': 'opt_2'},
],
},
{
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
)
assert selections == {'choice': 'B'}
def test_extract_template_card_selections_reads_nested_response_data_json():
selections = extract_template_card_selections(
{
'CardType': 'multiple_interaction',
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
},
{
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
)
assert selections == {'choice': 'B'}
def test_extract_template_card_selections_reads_response_data_direct_mapping():
selections = extract_template_card_selections(
{
'CardType': 'multiple_interaction',
'EventKey': 'submit_human_input',
'ResponseData': '{"choice":"opt_2"}',
},
{
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
)
assert selections == {'choice': 'B'}
def test_build_multiple_interaction_update_card_disables_submitted_select():
card = build_multiple_interaction_update_card(
{
'node_title': 'Manual Review',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
task_id='task-1',
selections={'choice': 'B'},
)
assert card['card_type'] == 'multiple_interaction'
assert card['main_title']['desc'] == 'Submitted'
assert card['select_list'][0]['disable'] is True
assert card['select_list'][0]['selected_id'] == 'opt_2'
@@ -6,6 +6,9 @@ Tests the helper methods that don't require real Dify API calls.
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class TestDifyExtractTextOutput:
@@ -26,7 +29,7 @@ class TestDifyExtractTextOutput:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
@@ -111,7 +114,7 @@ class TestDifyRunnerConfigValidation:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
with pytest.raises(DifyAPIError, match='不支持'):
@@ -134,7 +137,7 @@ class TestDifyRunnerConfigValidation:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
@@ -160,10 +163,509 @@ class TestDifyRunnerInit:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
assert runner.pipeline_config == pipeline_config
assert runner.ap == mock_app
assert runner.ap == mock_app
class TestDifyHumanInputForms:
"""Tests for Dify human-input form helpers."""
def _create_runner(self):
from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner
mock_app = MagicMock()
mock_app.logger = MagicMock()
pipeline_config = {
'ai': {
'dify-service-api': {
'app-type': 'workflow',
'api-key': 'test-key',
'base-url': 'https://api.dify.ai',
'base-prompt': '',
}
},
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
runner.dify_client = MagicMock()
runner.dify_client.upload_file = AsyncMock(return_value={'id': 'upload-1'})
return runner
def test_format_human_input_text_includes_field_help(self):
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
text = _format_human_input_text(
'Manual Review',
'Please fill fields.',
[{'id': 'yes', 'title': 'Yes'}],
[
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
{'output_variable_name': 'attachment', 'type': 'file-list', 'number_limits': 2},
],
)
assert 'comment: <value>' in text
assert 'choice (select): 1. A, 2. B' in text
assert 'attachment (file-list' in text
assert 'action: <number or title>' in text
def test_form_snapshot_for_platform_omits_action_text_and_placeholders(self):
from langbot.pkg.provider.runners.difysvapi import _extract_form_snapshot
snapshot, _, _, display_form_content = _extract_form_snapshot(
'run-1',
{
'form_token': 'token-1',
'node_title': 'Manual Review',
'form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
'inputs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'actions': [{'id': 'yes', 'title': 'Yes'}],
},
'person_user-1',
)
assert '{{#$output.comment#}}' not in display_form_content
assert 'Actions:' not in display_form_content
assert 'comment (paragraph)' in display_form_content
assert snapshot['form_content'] == display_form_content
@pytest.mark.asyncio
async def test_match_pending_form_collects_select_and_text_inputs(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(
query,
session_key,
'action: yes\ncomment: looks good\nchoice: 2',
)
assert action['action_id'] == 'yes'
assert action['inputs'] == {'comment': 'looks good', 'choice': 'B'}
@pytest.mark.asyncio
async def test_collect_form_inputs_uploads_files(self):
runner = self._create_runner()
image = platform_message.Image(base64='data:image/png;base64,aGVsbG8=')
query = MagicMock()
query.message_chain = platform_message.MessageChain([image])
inputs = await runner._collect_form_inputs_from_query(
query,
{
'input_defs': [{'output_variable_name': 'photo', 'type': 'file'}],
'inputs': {},
'user': 'person_user-1',
},
'',
)
assert inputs['photo'] == {
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': 'upload-1',
}
@pytest.mark.asyncio
async def test_partial_input_with_multiple_actions_waits_for_missing_fields(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(query, session_key, 'comment: ready')
assert action['_partial'] is True
assert action['inputs'] == {'comment': 'ready'}
assert action['_form_data']['_current_input_field'] == 'choice'
assert 'choice (select)' in action['notice']
assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'}
@pytest.mark.asyncio
async def test_complete_partial_input_with_multiple_actions_renders_action_form(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'raw_form_content': 'Please review\n\n{{#$output.comment#}}\n',
'form_content': 'Please review\n\nFields:\n - comment (paragraph): reply "comment: <value>"',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(query, session_key, 'comment: ready')
assert action['_partial'] is True
assert action['inputs'] == {'comment': 'ready'}
assert 'action: <number or title>' in action['notice']
assert action['_form_data']['_action_select_only'] is True
assert action['_form_data']['input_defs'] == []
assert action['_form_data']['actions'] == [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}]
assert '{{#$output.comment#}}' not in action['_form_data']['form_content']
assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'}
@pytest.mark.asyncio
async def test_sequential_field_collection_advances_one_field_at_a_time(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'current_input_field': 'comment',
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
first = await runner._match_pending_form_action(query, session_key, 'looks good')
assert first['_partial'] is True
assert first['inputs'] == {'comment': 'looks good'}
assert first['_form_data']['_current_input_field'] == 'choice'
assert first['_form_data']['input_defs'][0]['output_variable_name'] == 'comment'
assert 'choice (select)' in first['_form_data']['form_content']
second = await runner._match_pending_form_action(query, session_key, '2')
assert second['_partial'] is True
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
assert second['_form_data']['_action_select_only'] is True
@pytest.mark.asyncio
async def test_digit_reply_fills_missing_select_before_matching_action_number(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [
{'id': 'yes', 'title': 'yes'},
{'id': 'no', 'title': 'no'},
{'id': 'or', 'title': 'or'},
{'id': 'but', 'title': 'but'},
],
'input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{
'output_variable_name': 'xiala',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['1', '2']},
},
],
'inputs': {'us_input': 'hello'},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(query, session_key, '2')
assert action['_partial'] is True
assert action['inputs'] == {'us_input': 'hello', 'xiala': '2'}
assert action['_form_data']['_action_select_only'] is True
@pytest.mark.asyncio
async def test_workflow_pause_without_text_yields_form_chunk(self):
from langbot.pkg.provider.runners import difysvapi
async def workflow_run(**kwargs):
del kwargs
yield {
'event': 'workflow_started',
'data': {'workflow_run_id': 'run-1'},
}
yield {
'event': 'workflow_paused',
'data': {
'workflow_run_id': 'run-1',
'reasons': [
{
'TYPE': 'human_input_required',
'form_token': 'token-1',
'node_title': 'Manual Review',
'form_content': 'Please review\n\n{{#$output.comment#}}\n',
'inputs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'actions': [{'id': 'yes', 'title': 'Yes'}],
}
],
},
}
runner = self._create_runner()
runner.dify_client.workflow_run = workflow_run
query = MagicMock()
query.session.launcher_type.value = 'person'
query.session.launcher_id = 'user-1'
query.session.using_conversation.uuid = 'conversation-1'
query.variables = {
'session_id': 'session-1',
'conversation_id': 'conversation-1',
'msg_create_time': '0',
}
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
difysvapi._PENDING_FORMS.clear()
chunks = [chunk async for chunk in runner._workflow_messages_chunk(query)]
assert chunks[-1].is_final is True
assert chunks[-1].content == difysvapi._STREAM_FORM_PLACEHOLDER
assert chunks[-1]._form_data['form_token'] == 'token-1'
assert chunks[-1]._form_data['_current_input_field'] == 'comment'
@pytest.mark.asyncio
async def test_action_after_partial_input_reuses_saved_inputs(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
await runner._match_pending_form_action(query, session_key, 'comment: ready')
action = await runner._match_pending_form_action(query, session_key, 'action: yes')
assert action.get('_partial') is not True
assert action['action_id'] == 'yes'
assert action['inputs'] == {'comment': 'ready'}
def test_form_action_merges_card_inputs_with_saved_inputs(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'yes', 'title': 'Yes'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'photo', 'type': 'file'},
],
'inputs': {'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'}},
'user': session_key,
},
)
action = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'action_id': 'yes',
'inputs': {'comment': 'ready'},
},
)
assert action['inputs'] == {
'comment': 'ready',
'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'},
}
def test_form_action_with_missing_required_file_fields_stays_partial(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'yes', 'title': 'Yes'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'file', 'type': 'file'},
{'output_variable_name': 'files', 'type': 'file-list', 'number_limits': 5},
],
'inputs': {},
'user': session_key,
},
)
action = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'action_id': 'yes',
'inputs': {'comment': 'ready'},
},
)
assert action['_partial'] is True
assert action['inputs'] == {'comment': 'ready'}
assert 'file, files' in action['notice']
assert action['_form_data']['_current_input_field'] == 'file'
assert action['_form_data']['input_defs'][1]['output_variable_name'] == 'file'
def test_card_component_input_progress_maps_to_current_field(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'current_input_field': 'comment',
'user': session_key,
},
)
first = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'inputs': {'input': 'looks good'},
'_current_input_field': 'comment',
'_input_progress': True,
},
)
assert first['_partial'] is True
assert first['inputs'] == {'comment': 'looks good'}
assert first['_form_data']['_current_input_field'] == 'choice'
second = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'inputs': {'select': '{"index": 1, "value": "B"}'},
'_current_input_field': 'choice',
'_input_progress': True,
},
)
assert second['_partial'] is True
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
assert second['_form_data']['_action_select_only'] is True