diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py index 8f99cce84..a3df33af0 100644 --- a/src/langbot/pkg/pipeline/legacy_config_migration.py +++ b/src/langbot/pkg/pipeline/legacy_config_migration.py @@ -634,6 +634,12 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: return _block(result, 'mixed_runner_selection', 'ai.runner') if 'id' in selection: current = selection['id'] + # A blank runner id paired with no legacy runner section means the saved + # pipeline simply has no runner selected. There is nothing to migrate and + # nothing to convert, so report not_legacy instead of failing the whole + # batch with a malformed-id blocker. + if type(current) is str and not current.strip() and not any(legacy in ai for legacy in _TARGETS): + return result if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current): return _block(result, 'invalid_runner_id', 'ai.runner.id') result['state'] = 'already_current' diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py index 7355400f1..d054b58ee 100644 --- a/tests/unit_tests/pipeline/test_legacy_config_migration.py +++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py @@ -641,12 +641,40 @@ def test_dify_saved_timeout_is_not_blindly_activated(timeout): 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']) +@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)