Files
LangBot/tests/unit_tests/pipeline/test_legacy_config_migration.py
T
HyuandClaude Opus 5.5 c0cfb5c18c feat(pipeline-migration): relax legacy config migration and pin latest runner plugins
- Coerce/default/drop invalid or unknown legacy fields with warnings instead of blocking
- Never block on LocalAgent model; prune dangling KB/tool/MCP references
- Mark custom (non-builtin) 4.10 runners as unsupported_runner instead of failing
- Pin runner plugins to current marketplace versions (0.2.x); fall back to latest when a pin is withdrawn
- Raise plugin install/upgrade timeout to 600s for slow dependency installs
- Assistant configure_pipeline writes the 4.11 runner shape

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:09:52 +08:00

1207 lines
45 KiB
Python

"""Behavioral tests for the pure legacy migration planner (relaxed v5 semantics)."""
import copy
import importlib
import importlib.util
import json
from pathlib import Path
import pytest
FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text())
TARGETS = {
'local-agent': ('LocalAgent', '0.2.1', None),
'dify-service-api': ('DifyAgent', '0.2.3', None),
'coze-api': ('CozeAgent', '0.2.0', None),
'dashscope-app-api': ('DashScopeAgent', '0.2.2', None),
'n8n-service-api': ('N8nAgent', '0.2.0', None),
'langflow-api': ('LangflowAgent', '0.2.2', None),
'deerflow-api': ('DeerFlowAgent', '0.2.1', None),
'tbox-app-api': ('TboxAgent', '0.2.1', None),
'weknora-api': ('WeKnoraAgent', '0.2.1', None),
}
def source_for(runner='deerflow-api'):
return {
'uuid': 'synthetic-pipeline',
'bot_uuid': 'synthetic-bot',
'stages': [{'stage': 'synthetic', 'config': {'nested': [False, 0, None]}}],
'output': {'misc': {'remove-think': False}},
'ai': {'runner': {'runner': runner, 'expire-time': 0}, runner: copy.deepcopy(FIXTURES[runner])},
}
def plan(source, preferences=None):
return planner().plan_legacy_pipeline(source, preferences)
def assert_block(result, code, field=None):
assert result['state'] == 'blocked'
assert result['config'] is None
assert result['changed_paths'] == []
expected = {'code': code}
if field is not None:
expected['field'] = field
assert expected in result['blockers']
def assert_repaired(result, code, field=None):
"""Relaxed planner (v5): non-critical problems are repaired and reported as warnings."""
assert result['state'] == 'ready', result['blockers']
assert result['blockers'] == []
assert result['config'] is not None
codes = {(w['code'], w.get('field')) for w in result['warnings']}
if field is None:
assert code in {c for c, _ in codes}
else:
assert (code, field) in codes
public = {key: value for key, value in result.items() if key != 'config'}
assert 'SECRET' not in json.dumps(public)
assert 'secret-' not in json.dumps(public)
@pytest.mark.parametrize('runner', TARGETS)
def test_recognizes_all_nine_exact_official_identities(runner):
result = plan(source_for(runner))
name, version, blocker = TARGETS[runner]
assert result['legacy_runner'] == runner
assert result['target_runner_id'] == f'plugin:langbot-team/{name}/default'
assert result['target_plugin'] == {'author': 'langbot-team', 'name': name, 'version': version}
if blocker:
assert result['state'] == 'blocked'
assert blocker in {item['code'] for item in result['blockers']}
else:
assert result['state'] == 'ready'
def test_supported_deerflow_candidate_preserves_source_and_denies_new_resources():
source = source_for()
source['ai']['local-agent'] = {
'enable-all-tools': True,
'tools': ['private-tool'],
'knowledge-bases': ['private-kb'],
'mcp-resources': [{'uri': 'private'}],
}
preferences = {
'enable_all_plugins': False,
'plugins': [{'author': 'langbot-team', 'name': 'DeerFlowAgent'}],
'enable_all_mcp_servers': False,
'mcp_servers': ['restricted-server'],
'enable_all_skills': False,
'skills': [],
'mcp_resource_agent_read_enabled': True,
'mcp_resources': [{'uri': 'private'}],
}
before = copy.deepcopy((source, preferences))
result = plan(source, preferences)
assert result['state'] == 'ready'
candidate = result['config']
assert candidate is not source
assert candidate['ai']['runner'] == {'id': result['target_runner_id'], 'expire-time': 0}
selected = candidate['ai']['runner_config'][result['target_runner_id']]
assert selected == {
**FIXTURES['deerflow-api'],
'enable-all-tools': False,
'tools': [],
'knowledge-bases': [],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
for key in ('uuid', 'bot_uuid', 'stages', 'output'):
assert candidate[key] == source[key]
assert set(candidate['ai']) == {'runner', 'runner_config'}
assert source['ai']['local-agent'] == before[0]['ai']['local-agent']
assert source['ai']['deerflow-api'] == before[0]['ai']['deerflow-api']
assert (source, preferences) == before
candidate['stages'][0]['config']['nested'].append('mutated')
selected['api-key'] = 'mutated'
assert (source, preferences) == before
assert result['changed_paths'] == [
'ai.runner.runner',
'ai.runner.id',
'ai.runner_config',
'ai.local-agent',
'ai.deerflow-api',
]
assert {'code': 'external.state_validation_required', 'field': 'ai.runner'} in result['warnings']
@pytest.mark.parametrize('runner', TARGETS)
def test_current_identifiers_are_not_migrated_twice(runner):
name = TARGETS[runner][0]
source = source_for(runner)
source['ai']['runner'] = {'id': f'plugin:langbot-team/{name}/default', 'expire-time': 0}
result = plan(source)
assert result['state'] == 'already_current'
assert result['config'] is None
assert result['changed_paths'] == []
@pytest.mark.parametrize('value', [None, [], False, 0, 'secret'])
def test_malformed_roots_are_safe_blockers(value):
assert_block(plan(value), 'invalid_type', 'config')
@pytest.mark.parametrize('field', ['ai', 'runner'])
@pytest.mark.parametrize('value', [None, [], False, 0, 'secret'])
def test_malformed_envelopes_are_safe_blockers(field, value):
source = source_for()
if field == 'ai':
source['ai'] = value
else:
source['ai']['runner'] = value
assert_block(plan(source), 'invalid_type', 'ai' if field == 'ai' else 'ai.runner')
@pytest.mark.parametrize('current', [None, '', False, {}])
def test_mixed_selection_with_unusable_current_id_migrates_legacy(current):
source = source_for()
source['ai']['runner']['id'] = current
result = plan(source)
assert_repaired(result, 'migration.mixed_runner_selection', 'ai.runner')
assert result['config']['ai']['runner']['id'] == 'plugin:langbot-team/DeerFlowAgent/default'
assert 'runner' not in result['config']['ai']['runner']
def test_mixed_selection_prefers_valid_current_id():
source = source_for()
source['ai']['runner']['id'] = 'plugin:langbot-team/DeerFlowAgent/default'
result = plan(source)
assert result['state'] == 'already_current'
assert {'code': 'migration.mixed_runner_selection', 'field': 'ai.runner'} in result['warnings']
@pytest.mark.parametrize('value', [None, False, 0, '', [], 'secret'])
def test_malformed_active_runner_section_is_not_defaulted(value):
source = source_for()
source['ai']['deerflow-api'] = value
assert_block(plan(source), 'invalid_type', 'ai.deerflow-api')
def test_existing_runner_config_is_replaced_with_warning():
source = source_for()
source['ai']['runner_config'] = {'plugin:custom/Runner/default': {'secret': 'SECRET-value'}}
result = plan(source)
assert_repaired(result, 'migration.runner_config_replaced', 'ai.runner_config')
assert list(result['config']['ai']['runner_config']) == [result['target_runner_id']]
@pytest.mark.parametrize('name', ['my-runner', 'custom_runner'])
def test_custom_legacy_runner_is_unsupported_not_blocked(name):
source = source_for()
source['ai']['runner']['runner'] = name
result = plan(source)
assert result['state'] == 'unsupported_runner'
assert result['config'] is None
assert result['blockers'] == []
assert result['changed_paths'] == []
assert {'code': 'legacy.custom_runner_unsupported', 'field': 'ai.runner.runner'} in result['warnings']
def test_custom_runner_diagnostics_never_echo_section_values():
source = source_for()
source['ai']['runner']['runner'] = 'my-runner'
source['ai']['my-runner'] = {'api-key': 'SECRET-KEY'}
result = plan(source)
assert result['state'] == 'unsupported_runner'
assert 'SECRET' not in json.dumps(result)
def test_leftover_custom_section_does_not_block_builtin_migration():
source = source_for()
source['ai']['my-runner'] = {'api-key': 'SECRET-KEY'}
result = plan(source)
assert_repaired(result, 'migration.unknown_section_archived', 'ai')
assert 'my-runner' not in result['config']['ai']
@pytest.mark.parametrize('value', [None, False, -1, 1.5, '0'])
def test_malformed_expiry_is_defaulted(value):
source = source_for()
source['ai']['runner']['expire-time'] = value
result = plan(source)
assert result['state'] == 'ready'
assert result['config']['ai']['runner']['expire-time'] == 0
def test_unknown_active_field_is_dropped_without_leaking():
source = source_for()
source['ai']['deerflow-api']['secret-field-name'] = 'secret-field-value'
result = plan(source)
assert_repaired(result, 'migration.unknown_field_dropped', 'ai.deerflow-api')
assert 'secret-field' not in json.dumps({k: v for k, v in result.items() if k != 'config'})
assert 'secret-field-name' not in json.dumps(result['config']['ai'])
@pytest.mark.parametrize('value', [float('nan'), float('inf'), {1: 'secret'}, ('tuple',), b'secret'])
def test_non_json_values_block_without_serialization_or_coercion(value):
source = source_for()
source['opaque'] = value
assert_block(plan(source), 'invalid_json_value', 'config')
def test_cyclic_input_blocks_without_recursion_error():
source = source_for()
source['opaque'] = source
assert_block(plan(source), 'invalid_json_value', 'config')
@pytest.mark.parametrize('runner', TARGETS)
@pytest.mark.parametrize('value', [None, False, 0, '', [], {}])
def test_unknown_active_keys_are_dropped_even_when_falsey(runner, value):
source = source_for(runner)
source['ai'][runner]['secret-unknown-key'] = value
result = plan(source)
assert_repaired(result, 'migration.unknown_field_dropped', f'ai.{runner}')
assert 'secret-unknown-key' not in selected_config(result)
@pytest.mark.parametrize('runner', TARGETS)
def test_missing_active_section_is_not_seeded(runner):
source = source_for(runner)
del source['ai'][runner]
assert_block(plan(source), 'missing_field', f'ai.{runner}')
@pytest.mark.parametrize('container', ['ai', 'selection'])
def test_unknown_active_envelope_keys_are_not_echoed(container):
source = source_for()
target = source['ai'] if container == 'ai' else source['ai']['runner']
target['secret-unknown-key'] = 'secret-value'
result = plan(source)
code = 'migration.unknown_section_archived' if container == 'ai' else 'migration.unknown_field_dropped'
assert_repaired(result, code, 'ai' if container == 'ai' else 'ai.runner')
assert 'secret-' not in json.dumps(result['config']['ai'])
@pytest.mark.parametrize(
'preferences',
[
False,
[],
'secret',
{'enable_all_plugins': 1},
{'enable_all_mcp_servers': None},
{'enable_all_skills': 'false'},
{'plugins': ['secret']},
{'mcp_servers': [1]},
{'skills': [None]},
{'mcp_resource_agent_read_enabled': 0},
{'mcp_resources': [False]},
],
)
def test_malformed_extension_preferences_are_ignored_without_expanding_access(preferences):
result = plan(source_for(), preferences)
assert result['state'] == 'ready'
assert any(item['code'] == 'invalid_extension_preferences' for item in result['warnings'])
assert result['extensions_preferences'] is None
assert 'secret' not in json.dumps({k: v for k, v in result.items() if k != 'config'})
def test_explicit_plugin_allowlist_gets_only_the_target_runner_added():
preferences = {'enable_all_plugins': False, 'plugins': [{'author': 'other', 'name': 'Plugin'}]}
result = plan(source_for(), preferences)
assert_repaired(result, 'extensions.runner_added', 'extensions_preferences.plugins')
assert result['extensions_preferences'] == {
'enable_all_plugins': False,
'plugins': [{'author': 'other', 'name': 'Plugin'}, {'author': 'langbot-team', 'name': 'DeerFlowAgent'}],
}
assert preferences == {'enable_all_plugins': False, 'plugins': [{'author': 'other', 'name': 'Plugin'}]}
def test_enable_all_plugins_needs_no_extension_patch():
result = plan(source_for(), {'enable_all_plugins': True})
assert result['state'] == 'ready'
assert result['extensions_preferences'] is None
@pytest.mark.parametrize('field', ['api-key', 'auth-header'])
@pytest.mark.parametrize('secret', [' synthetic ', '\tsynthetic', 'synthetic\n'])
def test_deerflow_secret_trim_drift_blocks_without_leaking(field, secret):
source = source_for()
source['ai']['deerflow-api'][field] = secret
result = plan(source)
assert result['state'] == 'ready'
assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == secret
assert secret not in json.dumps({k: v for k, v in result.items() if k != 'config'})
assert source['ai']['deerflow-api'][field] == secret
@pytest.mark.parametrize(
'field,value', [('assistant-id', ''), ('assistant-id', ' padded '), ('model-name', ' padded ')]
)
def test_deerflow_option_trim_drift_is_not_repaired(field, value):
source = source_for()
source['ai']['deerflow-api'][field] = value
result = plan(source)
assert result['state'] == 'ready'
assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == value
@pytest.mark.parametrize('field', ['thinking-enabled', 'plan-mode', 'subagent-enabled'])
@pytest.mark.parametrize(
'value,expected,code',
[
(None, False, 'migration.invalid_value_defaulted'),
([], False, 'migration.invalid_value_defaulted'),
(0, False, 'migration.value_coerced'),
(1, True, 'migration.value_coerced'),
('false', False, 'migration.value_coerced'),
('true', True, 'migration.value_coerced'),
],
)
def test_deerflow_booleans_are_coerced_or_defaulted(field, value, expected, code):
source = source_for()
source['ai']['deerflow-api'][field] = value
result = plan(source)
assert_repaired(result, code, f'ai.deerflow-api.{field}')
assert selected_config(result)[field] is expected
@pytest.mark.parametrize(
'value,expected,code',
[
(None, 300, 'migration.invalid_value_defaulted'),
(False, 300, 'migration.invalid_value_defaulted'),
(1.5, 300, 'migration.invalid_value_defaulted'),
('3', 3, 'migration.value_coerced'),
(0, 300, 'migration.invalid_value_defaulted'),
(-1, 300, 'migration.invalid_value_defaulted'),
],
)
def test_deerflow_timeout_is_coerced_or_defaulted(value, expected, code):
source = source_for()
source['ai']['deerflow-api']['timeout'] = value
result = plan(source)
assert_repaired(result, code, 'ai.deerflow-api.timeout')
assert selected_config(result)['timeout'] == expected
@pytest.mark.parametrize('field', ['max-concurrent-subagents', 'recursion-limit'])
@pytest.mark.parametrize('value', [0, -1, 1])
def test_deerflow_does_not_invent_numeric_clamps(field, value):
source = source_for()
source['ai']['deerflow-api'][field] = value
assert selected_config(plan(source))[field] == value
@pytest.mark.parametrize('value', [None, False, 0, [], ''])
def test_deerflow_requires_url_not_ui_default(value):
source = source_for()
source['ai']['deerflow-api']['api-base'] = value
result = plan(source)
assert result['state'] == 'blocked'
assert result['blockers'][0]['field'] == 'ai.deerflow-api.api-base'
def test_deerflow_native_runtime_defaults_are_materialized_not_ui_defaults():
source = source_for()
source['ai']['deerflow-api'] = {'api-base': ' https://example.invalid/path/?signed=synthetic '}
del source['ai']['runner']['expire-time']
result = plan(source)
assert result['state'] == 'ready'
selected = result['config']['ai']['runner_config'][result['target_runner_id']]
assert selected == {
'api-base': ' https://example.invalid/path/?signed=synthetic ',
'api-key': '',
'auth-header': '',
'assistant-id': 'lead_agent',
'model-name': '',
'thinking-enabled': False,
'plan-mode': False,
'subagent-enabled': False,
'max-concurrent-subagents': 3,
'timeout': 300,
'recursion-limit': 1000,
'enable-all-tools': False,
'tools': [],
'knowledge-bases': [],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
assert result['config']['ai']['runner']['expire-time'] == 0
def test_deerflow_missing_endpoint_blocks_instead_of_using_schema_localhost():
source = source_for()
del source['ai']['deerflow-api']['api-base']
assert_block(plan(source), 'missing_field', 'ai.deerflow-api.api-base')
def test_ready_public_projection_contains_no_secrets_or_credentials_in_urls():
source = source_for()
section = source['ai']['deerflow-api']
section.update(
{
'api-key': 'SECRET-KEY',
'auth-header': 'Bearer SECRET-HEADER',
'api-base': 'https://name:[email protected]/?token=SECRET-TOKEN',
}
)
result = plan(source)
assert result['state'] == 'ready'
selected = result['config']['ai']['runner_config'][result['target_runner_id']]
for key, value in section.items():
assert selected[key] == value
public = {key: value for key, value in result.items() if key != 'config'}
assert 'SECRET' not in json.dumps(public)
@pytest.mark.parametrize(
'value,expected,code',
[
(None, False, 'migration.invalid_value_defaulted'),
(0, False, 'migration.value_coerced'),
('false', False, 'migration.value_coerced'),
('true', True, 'migration.value_coerced'),
],
)
def test_remove_think_flag_is_coerced_or_defaulted(value, expected, code):
source = source_for('tbox-app-api')
source['output']['misc']['remove-think'] = value
result = plan(source)
assert_repaired(result, code, 'output.misc.remove-think')
assert selected_config(result)['remove-think'] is expected
@pytest.mark.parametrize(
'runner,code',
[
('dify-service-api', 'dify.remove_think_schema'),
('coze-api', 'coze.remove_think'),
('dashscope-app-api', 'dashscope.remove_think'),
('tbox-app-api', 'tbox.remove_think'),
],
)
def test_unsupported_thinking_suppression_has_precise_blocker(runner, code):
source = source_for(runner)
source['output']['misc']['remove-think'] = True
result = plan(source)
assert code not in {b['code'] for b in result['blockers']}
if result['state'] == 'ready':
assert result['config']['ai']['runner_config'][result['target_runner_id']]['remove-think'] is True
@pytest.mark.parametrize('rounds', [0, 1, 10, -1])
def test_local_rounds_are_never_translated_into_transcript_item_counts(rounds):
source = source_for('local-agent')
source['ai']['local-agent']['max-round'] = rounds
result = plan(source)
assert result['state'] == 'ready'
selected = result['config']['ai']['runner_config'][result['target_runner_id']]
assert 'max-round' not in selected
assert selected['context-history-fetch-limit'] == 50
assert selected['context-window-tokens'] == 200000
assert selected['tool-execution-mode'] == 'serial'
assert {'code': 'local.context_defaults', 'field': 'ai.local-agent.max-round'} in result['warnings']
@pytest.mark.parametrize(
'field,value,code,expected',
[
(
'model',
{'primary': 'model', 'fallbacks': [], 'reasoning': {'SECRET-model': 'invalid-level'}},
'local.reasoning_dropped',
{'primary': 'model', 'fallbacks': [], 'reasoning': {}},
),
(
'prompt',
[{'role': 'system', 'content': 42}],
'local.prompt_normalized',
[{'role': 'system', 'content': 'You are a helpful assistant.'}],
),
(
'prompt',
[{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}],
'local.prompt_normalized',
[{'role': 'system', 'content': 'text'}],
),
(
'prompt',
[{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}],
'local.prompt_normalized',
[{'role': 'system', 'content': 'You are a helpful assistant.'}],
),
],
)
def test_local_unsupported_behaviors_are_repaired_without_leaking(field, value, code, expected):
source = source_for('local-agent')
source['ai']['local-agent'][field] = value
result = plan(source)
assert_repaired(result, code, f'ai.local-agent.{field}' + ('.reasoning' if field == 'model' else ''))
assert selected_config(result)[field] == expected
assert 'SECRET' not in json.dumps(result['config']['ai'])
@pytest.mark.parametrize(
'field,value,expected',
[
('enable-all-tools', None, True),
('enable-all-tools', 0, False),
('tools', None, []),
('tools', [42], []),
('mcp-resources', None, []),
('mcp-resources', [False], []),
('mcp-resource-agent-read-enabled', 'false', False),
],
)
def test_local_host_policy_malformed_values_are_repaired(field, value, expected):
source = source_for('local-agent')
source['ai']['local-agent'][field] = value
result = plan(source)
assert result['state'] == 'ready'
assert selected_config(result)[field] == expected
codes = {(w['code'], w['field']) for w in result['warnings']}
assert codes & {
('migration.value_coerced', f'ai.local-agent.{field}'),
('migration.invalid_value_defaulted', f'ai.local-agent.{field}'),
}
@pytest.mark.parametrize(
'runner,field',
[
('dify-service-api', 'api-key'),
('langflow-api', 'flow-id'),
('n8n-service-api', 'webhook-url'),
('tbox-app-api', 'app-id'),
],
)
def test_missing_required_credentials_and_endpoints_still_block(runner, field):
source = source_for(runner)
del source['ai'][runner][field]
assert_block(plan(source), 'missing_field', f'ai.{runner}.{field}')
@pytest.mark.parametrize(
'runner,field',
[
('dify-service-api', 'base-prompt'),
('coze-api', 'timeout'),
('coze-api', 'api-base'),
('weknora-api', 'app-type'),
],
)
def test_missing_optional_fields_are_filled_with_410_defaults(runner, field):
source = source_for(runner)
del source['ai'][runner][field]
result = plan(source)
assert_repaired(result, 'migration.missing_default_filled', f'ai.{runner}.{field}')
@pytest.mark.parametrize('value', [None, False, [], {}])
@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api', 'deerflow-api')])
def test_external_api_keys_require_strings_even_if_target_coerces(runner, value):
source = source_for(runner)
source['ai'][runner]['api-key'] = value
assert_block(plan(source), 'invalid_type', f'ai.{runner}.api-key')
@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api')])
def test_numeric_external_api_key_is_stringified(runner):
source = source_for(runner)
source['ai'][runner]['api-key'] = 0
result = plan(source)
assert_repaired(result, 'migration.value_coerced', f'ai.{runner}.api-key')
assert selected_config(result)['api-key'] == '0'
@pytest.mark.parametrize(
'section,expected',
[
({'auto_save_history': False, 'auto-save-history': True}, True),
({'auto_save_history': True, 'auto-save-history': False}, False),
],
)
def test_coze_history_alias_conflict_uses_canonical_field(section, expected):
source = source_for('coze-api')
source['ai']['coze-api'].pop('auto_save_history')
source['ai']['coze-api'].update(section)
result = plan(source)
assert_repaired(result, 'migration.alias_conflict_resolved', 'ai.coze-api.auto_save_history')
assert selected_config(result)['auto-save-history'] is expected
@pytest.mark.parametrize('value', [False, True])
def test_coze_equal_aliases_are_unambiguous_but_statefulness_still_blocks(value):
source = source_for('coze-api')
source['ai']['coze-api'].update({'auto_save_history': value, 'auto-save-history': value})
result = plan(source)
assert selected_config(result)['auto-save-history'] is value
assert 'coze.history_alias' not in {b['code'] for b in result['blockers']}
def test_coze_custom_endpoint_not_replaced_with_schema_region():
source = source_for('coze-api')
source['ai']['coze-api']['api-base'] = 'https://secret-user:[email protected]'
result = plan(source)
assert_block(result, 'coze.custom_endpoint', 'ai.coze-api.api-base')
assert 'secret-' not in json.dumps(result)
def test_dashscope_seed_alias_conflict_is_resolved_with_warning():
source = source_for('dashscope-app-api')
section = source['ai']['dashscope-app-api']
section.pop('references_quote')
section['references_quote'] = 'one'
section['references-quote'] = 'two'
assert_repaired(plan(source), 'migration.alias_conflict_resolved', 'ai.dashscope-app-api.references-quote')
@pytest.mark.parametrize('axis', ['input', 'output'])
@pytest.mark.parametrize(
'underscore,hyphen,code',
[
(None, 'text', 'migration.alias_repaired'),
('chat', 'text', 'migration.alias_conflict_resolved'),
('text', 'chat', 'migration.alias_conflict_resolved'),
],
)
def test_langflow_io_aliases_use_canonical_hyphen_field(axis, underscore, hyphen, code):
source = source_for('langflow-api')
section = source['ai']['langflow-api']
section.pop(f'{axis}_type')
if underscore is not None:
section[f'{axis}_type'] = underscore
section[f'{axis}-type'] = hyphen
result = plan(source)
assert result['state'] == 'ready'
assert selected_config(result)[f'{axis}-type'] == hyphen
if underscore is not None:
assert {'code': code, 'field': f'ai.langflow-api.{axis}_type'} in result['warnings']
@pytest.mark.parametrize(
'raw',
[
'{broken-secret',
'[]',
'0',
'false',
'{"key": NaN}',
'{"key": Infinity}',
'{"key": 1e999}',
'{"key":1,"key":2}',
'{"nested":{"key":1,"key":2}}',
[],
False,
0,
],
)
def test_invalid_langflow_tweaks_are_defaulted_without_leaking(raw):
source = source_for('langflow-api')
source['ai']['langflow-api']['tweaks'] = raw
result = plan(source)
assert_repaired(result, 'langflow.tweaks_invalid_defaulted', 'ai.langflow-api.tweaks')
assert selected_config(result)['tweaks'] == {}
assert 'broken-secret' not in json.dumps({k: v for k, v in result.items() if k != 'config'})
@pytest.mark.parametrize('raw', [None, '', '{}', '{"nested":{"token":"SECRET","values":[null,false,0]}}'])
def test_langflow_native_empty_and_valid_tweaks_not_misreported(raw):
source = source_for('langflow-api')
source['ai']['langflow-api']['tweaks'] = raw
original = copy.deepcopy(source)
result = plan(source)
assert result['state'] == 'ready'
assert 'langflow.invalid_tweaks' not in {item['code'] for item in result['blockers']}
assert 'SECRET' not in json.dumps({k: v for k, v in result.items() if k != 'config'})
assert source == original
def test_n8n_ignore_cannot_be_encoded_as_ignored_plugin_field():
source = source_for('n8n-service-api')
source['ai']['n8n-service-api']['response-handling'] = 'ignore'
result = plan(source)
assert 'n8n.ignore_missing_0_1_6' not in {b['code'] for b in result['blockers']}
@pytest.mark.parametrize(
'mode,missing',
[
('basic', 'basic-username'),
('basic', 'basic-password'),
('jwt', 'jwt-secret'),
('header', 'header-name'),
('header', 'header-value'),
],
)
def test_n8n_missing_active_auth_material_has_precise_blocker(mode, missing):
source = source_for('n8n-service-api')
source['ai']['n8n-service-api'].update(
{
'auth-type': mode,
'basic-username': 'synthetic-user',
'basic-password': 'SECRET-pass',
'jwt-secret': 'SECRET-jwt',
'header-name': 'X-Synthetic',
'header-value': 'SECRET-header',
}
)
source['ai']['n8n-service-api'].pop(missing)
result = plan(source)
assert_block(result, 'missing_field', f'ai.n8n-service-api.{missing}')
assert 'SECRET' not in json.dumps(result)
@pytest.mark.parametrize('mode', ['none', 'basic', 'jwt', 'header'])
def test_n8n_secrets_remain_raw_and_not_public_even_for_inactive_modes(mode):
source = source_for('n8n-service-api')
section = source['ai']['n8n-service-api']
section.update(
{
'auth-type': mode,
'basic-username': 'user',
'basic-password': ' SECRET-pass ',
'jwt-secret': '\tSECRET-jwt',
'header-name': 'Authorization',
'header-value': 'Bearer SECRET-header ',
}
)
original = copy.deepcopy(source)
result = plan(source)
selected = selected_config(result)
for field in ('basic-password', 'jwt-secret', 'header-value'):
assert selected[field] == section[field]
assert 'SECRET' not in json.dumps({k: v for k, v in result.items() if k != 'config'})
assert source == original
@pytest.mark.parametrize(
'runner,field,value,expected',
[
('dify-service-api', 'app-type', 'unknown', 'chat'),
('dashscope-app-api', 'app-type', 'chat', 'agent'),
('weknora-api', 'app-type', 'workflow', 'chat'),
('n8n-service-api', 'auth-type', 'SECRET-unknown', 'none'),
],
)
def test_invalid_enum_values_are_defaulted_and_not_echoed(runner, field, value, expected):
source = source_for(runner)
source['ai'][runner][field] = value
result = plan(source)
assert_repaired(result, 'migration.invalid_value_defaulted', f'ai.{runner}.{field}')
assert selected_config(result)[field] == expected
assert 'SECRET' not in json.dumps(result)
@pytest.mark.parametrize('timeout', [None, 0, 30, 301])
def test_dify_saved_timeout_is_not_blindly_activated(timeout):
source = source_for('dify-service-api')
source['ai']['dify-service-api']['timeout'] = timeout
result = plan(source)
assert 'dify.timeout_semantics' not in {b['code'] for b in result['blockers']}
assert {'code': 'dify.timeout_default', 'field': 'ai.dify-service-api.timeout'} in result['warnings']
@pytest.mark.parametrize('current', [None, False, [], 'plugin:bad', ' plugin:a/b/c', 'plugin:a/b/c/extra'])
def test_malformed_current_id_is_not_already_current(current):
source = {'ai': {'runner': {'id': current}}}
assert_block(plan(source), 'invalid_runner_id', 'ai.runner.id')
@pytest.mark.parametrize('blank', ['', ' '])
def test_blank_current_id_without_legacy_section_is_not_legacy(blank):
"""A saved pipeline that simply has no runner selected is not legacy.
It has no legacy runner section to convert and no target to synthesize, so it
must report not_legacy instead of blocking the whole batch with a
malformed-id diagnostic.
"""
source = {'ai': {'runner': {'id': blank, 'expire-time': 0}, 'runner_config': {}}}
result = plan(source)
assert result['state'] == 'not_legacy'
assert result['config'] is None
assert result['blockers'] == []
assert result['changed_paths'] == []
assert result['target_plugin'] is None
def test_blank_current_id_with_legacy_section_stays_blocked():
"""A blank id cannot silently coexist with a legacy section.
An unselected plugin runner plus a legacy section is an ambiguous state that
the operator must resolve, so it keeps the malformed-id blocker.
"""
source = source_for()
source['ai']['runner'] = {'id': '', 'expire-time': 0}
assert_block(plan(source), 'invalid_runner_id', 'ai.runner.id')
@pytest.mark.parametrize('runner', TARGETS)
def test_deterministic_results_and_input_nonmutation_for_all_nine(runner):
source = source_for(runner)
original = copy.deepcopy(source)
first = plan(source)
second = plan(source)
assert first == second
assert source == original
assert all(set(item) <= {'code', 'field'} for item in first['blockers'] + first['warnings'])
def test_ready_candidate_is_idempotently_recognized_current():
first = plan(source_for())
second = plan(first['config'])
assert second['state'] == 'already_current'
assert second['config'] is None
def planner():
name = 'langbot.pkg.pipeline.legacy_config_migration'
assert importlib.util.find_spec(name) is not None, 'Pure migration planner is not implemented'
return importlib.import_module(name)
def selected_config(result):
assert result['state'] == 'ready', result['blockers']
return result['config']['ai']['runner_config'][result['target_runner_id']]
@pytest.mark.parametrize(
'prompt',
[
[],
[{'role': 'system', 'content': ''}],
[{'role': 'tool', 'content': None, 'tool_call_id': 'call'}],
[
{
'role': 'user',
'name': 'name',
'content': [
{'type': 'text', 'text': ''},
{'type': 'image_url', 'image_url': {'url': 'https://image.invalid'}},
],
'provider_specific_fields': {'opaque': [False, None]},
}
],
[
{
'role': 'assistant',
'tool_calls': [
{
'id': 'call',
'type': 'function',
'function': {'name': 'tool', 'arguments': '{}'},
'provider_specific_fields': {'signature': 'opaque'},
}
],
}
],
],
)
def test_sdk_valid_prompts_are_preserved(prompt):
from langbot_plugin.api.entities.builtin.provider.message import Message
for message in prompt:
Message.model_validate(message, strict=True)
source = source_for('local-agent')
source['ai']['local-agent']['prompt'] = prompt
assert selected_config(plan(source))['prompt'] == prompt
@pytest.mark.parametrize(
'level', ['provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
)
def test_reasoning_and_local_authorizations_survive(level):
source = source_for('local-agent')
section = source['ai']['local-agent']
section.update(
{
'model': {'primary': 'm', 'fallbacks': ['f'], 'reasoning': {'m': level}},
'knowledge-base': 'kb',
'enable-all-tools': False,
'tools': ['tool'],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
)
preferences = {'mcp_resources': [{'uri': 'not-granted'}], 'mcp_resource_agent_read_enabled': True}
selected = selected_config(plan(source, preferences))
assert selected['model'] == section['model']
assert selected['knowledge-bases'] == ['kb']
assert selected['tools'] == ['tool']
assert selected['enable-all-tools'] is False
assert selected['mcp-resources'] == []
assert selected['mcp-resource-agent-read-enabled'] is False
assert selected['tool-execution-mode'] == 'serial'
assert 'knowledge-base' not in selected
assert 'assistant-id' not in selected
def test_missing_local_host_policy_uses_native_defaults_and_preferences():
source = source_for('local-agent')
section = source['ai']['local-agent']
del section['enable-all-tools']
del section['tools']
section['model'] = 'm'
selected = selected_config(
plan(source, {'mcp_resources': [{'uri': 'granted'}], 'mcp_resource_agent_read_enabled': False})
)
assert selected['enable-all-tools'] is True
assert selected['tools'] == []
assert selected['mcp-resources'] == [{'uri': 'granted'}]
assert selected['mcp-resource-agent-read-enabled'] is False
assert selected['model'] == {'primary': 'm', 'fallbacks': [], 'reasoning': {}}
@pytest.mark.parametrize('raw', [None, '', ' ', 'null', {}, '{"nested":[null,false,0]}'])
def test_langflow_convenience_inputs_are_explicitly_normalized(raw):
source = source_for('langflow-api')
source['ai']['langflow-api']['tweaks'] = raw
selected = selected_config(plan(source))
assert selected['tweaks'] == ({'nested': [None, False, 0]} if isinstance(raw, str) and raw.startswith('{') else {})
assert selected['input-type'] == 'chat'
assert 'input_type' not in selected
assert selected['langbot-assets-enabled'] is False
@pytest.mark.parametrize('value', ['', ' ', 'references'])
def test_dashscope_unambiguous_seed_alias_is_repaired(value):
source = source_for('dashscope-app-api')
section = source['ai']['dashscope-app-api']
del section['references_quote']
section['references-quote'] = value
result = plan(source)
selected = selected_config(result)
assert selected['references_quote'] == value
assert 'references-quote' not in selected
assert selected['timeout'] == 120
assert {'code': 'migration.alias_repaired', 'field': 'ai.dashscope-app-api.references-quote'} in result['warnings']
@pytest.mark.parametrize('mode', ['agent', 'chat'])
@pytest.mark.parametrize('value', ['ABSENT', None, '', ' ', 'saved-agent'])
def test_weknora_agent_absence_is_defaulted(mode, value):
source = source_for('weknora-api')
section = source['ai']['weknora-api']
section['app-type'] = mode
section['knowledge-base-ids'] = None
if value != 'ABSENT':
section['agent-id'] = value
selected = selected_config(plan(source))
default = 'builtin-quick-answer' if mode == 'chat' else 'builtin-smart-reasoning'
assert selected['agent-id'] == (default if value in ('ABSENT', None) else value)
assert selected['knowledge-base-ids'] == []
assert selected['knowledge-bases'] == []
@pytest.mark.parametrize('runner', ['dashscope-app-api', 'langflow-api', 'deerflow-api', 'weknora-api'])
def test_every_ready_external_denies_dormant_host_resources(runner):
source = source_for(runner)
source['ai']['local-agent'] = {'tools': ['forbidden'], 'knowledge-bases': ['forbidden'], 'enable-all-tools': True}
selected = selected_config(plan(source, {'enable_all_mcp_servers': True, 'mcp_resources': [{'uri': 'forbidden'}]}))
assert selected['enable-all-tools'] is False
assert selected['tools'] == selected['knowledge-bases'] == selected['mcp-resources'] == []
assert selected['mcp-resource-agent-read-enabled'] is False
if runner != 'deerflow-api':
assert 'assistant-id' not in selected
@pytest.mark.parametrize(
'runner,identity',
[
('dify-service-api', 'legacy-session'),
('coze-api', 'legacy-session'),
('n8n-service-api', 'legacy-session'),
('tbox-app-api', 'legacy-bot'),
],
)
def test_verified_plugin_identity_modes_are_explicit(runner, identity):
result = plan(source_for(runner))
assert selected_config(result)['user-id-source'] == identity
assert {'code': 'migration.identity_preserved', 'field': f'ai.{runner}'} in result['warnings']
@pytest.mark.parametrize(
'values,expected',
[
({}, True),
({'auto_save_history': None}, True),
({'auto-save-history': False}, False),
({'auto_save_history': False}, False),
({'auto_save_history': None, 'auto-save-history': False}, False),
],
)
def test_coze_history_aliases_and_null_defaults(values, expected):
source = source_for('coze-api')
section = source['ai']['coze-api']
section.pop('auto_save_history')
section.update(values)
section['api-base'] = 'https://custom.invalid/prefix'
selected = selected_config(plan(source))
assert selected['auto-save-history'] is expected
assert 'auto_save_history' not in selected
assert selected['api-base'] == section['api-base']
@pytest.mark.parametrize(
'values,code,field',
[
(
{'basic-username': '姓名', 'basic-password': 'p', 'auth-type': 'basic'},
'n8n.basic_encoding',
'basic-username',
),
(
{'basic-username': 'a:b', 'basic-password': 'p', 'auth-type': 'basic'},
'n8n.basic_encoding',
'basic-username',
),
({'header-name': '', 'header-value': '', 'auth-type': 'header'}, 'n8n.header_name', 'header-name'),
],
)
def test_n8n_invalid_auth_is_not_reencoded_or_silently_dropped(values, code, field):
source = source_for('n8n-service-api')
source['ai']['n8n-service-api'].update(values)
assert_block(plan(source), code, f'ai.n8n-service-api.{field}')
def test_n8n_ignore_and_native_latin1_are_selected():
source = source_for('n8n-service-api')
source['ai']['n8n-service-api'].update(
{'auth-type': 'basic', 'basic-username': 'café', 'basic-password': 'p', 'response-handling': 'ignore'}
)
selected = selected_config(plan(source))
assert selected['response-handling'] == 'ignore'
assert selected['basic-encoding'] == 'latin1'
def test_weknora_blank_remote_kb_is_dropped():
source = source_for('weknora-api')
source['ai']['weknora-api']['knowledge-base-ids'] = [' ']
result = plan(source)
assert_repaired(result, 'migration.value_coerced', 'ai.weknora-api.knowledge-base-ids')
assert selected_config(result)['knowledge-base-ids'] == []
@pytest.mark.parametrize('runner', ['coze-api', 'n8n-service-api', 'weknora-api'])
@pytest.mark.parametrize('value', [0, -1])
def test_nonpositive_external_timeout_is_defaulted(runner, value):
source = source_for(runner)
source['ai'][runner]['timeout'] = value
result = plan(source)
assert_repaired(result, 'migration.invalid_value_defaulted', f'ai.{runner}.timeout')
assert selected_config(result)['timeout'] == 120
@pytest.mark.parametrize(
'runner,field',
[
('dify-service-api', 'api-key'),
('dify-service-api', 'base-url'),
('coze-api', 'bot-id'),
('coze-api', 'api-key'),
('dashscope-app-api', 'api-key'),
('dashscope-app-api', 'app-id'),
('n8n-service-api', 'webhook-url'),
('langflow-api', 'base-url'),
('langflow-api', 'api-key'),
('langflow-api', 'flow-id'),
('tbox-app-api', 'api-key'),
('tbox-app-api', 'app-id'),
('weknora-api', 'api-key'),
('weknora-api', 'base-url'),
],
)
def test_required_target_strings_cannot_be_empty(runner, field):
source = source_for(runner)
source['ai'][runner][field] = ''
assert_block(plan(source), 'invalid_value', f'ai.{runner}.{field}')
@pytest.mark.parametrize('runner', TARGETS)
def test_ready_all9_nested_candidates_are_detached_and_no_io(runner, monkeypatch):
module = planner()
source = source_for(runner)
before = copy.deepcopy(source)
def forbidden(*args, **kwargs):
raise AssertionError('planner performed IO')
monkeypatch.setattr('builtins.open', forbidden)
result = module.plan_legacy_pipeline(source)
selected = selected_config(result)
selected['nested-test'] = ['changed']
result['config']['stages'][0]['config']['nested'].append('changed')
result['config']['ai']['runner']['expire-time'] = 999
assert source == before
assert set(result) == {
'state',
'legacy_runner',
'target_runner_id',
'target_plugin',
'config',
'extensions_preferences',
'changed_paths',
'blockers',
'warnings',
}
def test_empty_config_is_not_legacy():
result = planner().plan_legacy_pipeline({})
assert result == {
'state': 'not_legacy',
'legacy_runner': None,
'target_runner_id': None,
'target_plugin': None,
'config': None,
'extensions_preferences': None,
'changed_paths': [],
'blockers': [],
'warnings': [],
}
def test_minimal_local_agent_is_completed_with_defaults():
source = {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': 0},
'local-agent': {'model': 'model-id', 'max-round': 10},
}
}
original = copy.deepcopy(source)
result = planner().plan_legacy_pipeline(source)
assert planner().PLANNER_VERSION == '5'
assert result['target_runner_id'] == 'plugin:langbot-team/LocalAgent/default'
assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.2.1'}
selected = selected_config(result)
assert selected['model'] == {'primary': 'model-id', 'fallbacks': [], 'reasoning': {}}
assert 'max-round' not in selected
assert selected['prompt'] == [{'role': 'system', 'content': 'You are a helpful assistant.'}]
assert {'code': 'migration.new_defaults', 'field': 'ai.local-agent'} in result['warnings']
assert source == original
def test_local_agent_without_primary_model_still_blocks():
source = source_for('local-agent')
del source['ai']['local-agent']['model']
result = plan(source)
assert result['state'] == 'blocked'
assert result['config'] is None
@pytest.mark.parametrize('template', ['{global}', '{launcher_type}_{launcher_id}', '{sender_id}', '{project}'])
def test_local_box_reuse_templates_are_preserved_for_plugin(template):
source = source_for('local-agent')
source['ai']['local-agent']['box-session-id-template'] = template
result = plan(source)
assert result['state'] != 'blocked', result
assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template
@pytest.mark.parametrize(
'template', ['', ' ', '{}', '{0}', '{sender_id', '{sender_id!r}', '{query_id:04}', '{actor.id}', '{actor[id]}']
)
def test_invalid_box_templates_are_defaulted(template):
source = source_for('local-agent')
source['ai']['local-agent']['box-session-id-template'] = template
result = plan(source)
assert_repaired(result, 'local.box_template_defaulted', 'ai.local-agent.box-session-id-template')
assert selected_config(result)['box-session-id-template'] == '{launcher_type}_{launcher_id}'