@salesforce/afv-skills 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/activating-datacloud/SKILL.md +0 -1
- package/skills/analyzing-omnistudio-dependencies/SKILL.md +0 -1
- package/skills/applying-slds/SKILL.md +322 -0
- package/skills/applying-slds/checklists.md +83 -0
- package/skills/applying-slds/examples.md +283 -0
- package/skills/applying-slds/guidance/README.md +83 -0
- package/skills/applying-slds/guidance/blueprints-index.md +213 -0
- package/skills/applying-slds/guidance/icons-guidance.md +186 -0
- package/skills/applying-slds/guidance/overviews/borders.md +236 -0
- package/skills/applying-slds/guidance/overviews/color.md +266 -0
- package/skills/applying-slds/guidance/overviews/display-density.md +366 -0
- package/skills/applying-slds/guidance/overviews/icons.md +240 -0
- package/skills/applying-slds/guidance/overviews/illustrations.md +235 -0
- package/skills/applying-slds/guidance/overviews/shadows.md +176 -0
- package/skills/applying-slds/guidance/overviews/spacing.md +216 -0
- package/skills/applying-slds/guidance/overviews/typography.md +323 -0
- package/skills/applying-slds/guidance/overviews/utilities.md +542 -0
- package/skills/applying-slds/guidance/slds-development-guide.md +288 -0
- package/skills/applying-slds/guidance/styling-hooks/borders.md +202 -0
- package/skills/applying-slds/guidance/styling-hooks/color/expressive-palette-hooks.md +153 -0
- package/skills/applying-slds/guidance/styling-hooks/color/index.md +171 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/accent-hooks.md +204 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/feedback-hooks.md +768 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/surface-hooks.md +337 -0
- package/skills/applying-slds/guidance/styling-hooks/color/system-hooks.md +132 -0
- package/skills/applying-slds/guidance/styling-hooks/index.md +327 -0
- package/skills/applying-slds/guidance/styling-hooks/shadows.md +238 -0
- package/skills/applying-slds/guidance/styling-hooks/spacing.md +254 -0
- package/skills/applying-slds/guidance/styling-hooks/typography.md +448 -0
- package/skills/applying-slds/guidance/utilities/alignment.md +119 -0
- package/skills/applying-slds/guidance/utilities/borders.md +131 -0
- package/skills/applying-slds/guidance/utilities/box.md +125 -0
- package/skills/applying-slds/guidance/utilities/color.md +165 -0
- package/skills/applying-slds/guidance/utilities/dark-mode.md +111 -0
- package/skills/applying-slds/guidance/utilities/description-list.md +168 -0
- package/skills/applying-slds/guidance/utilities/floats.md +117 -0
- package/skills/applying-slds/guidance/utilities/grid.md +264 -0
- package/skills/applying-slds/guidance/utilities/horizontal-list.md +110 -0
- package/skills/applying-slds/guidance/utilities/hyphenation.md +84 -0
- package/skills/applying-slds/guidance/utilities/index.md +205 -0
- package/skills/applying-slds/guidance/utilities/interactions.md +89 -0
- package/skills/applying-slds/guidance/utilities/layout.md +109 -0
- package/skills/applying-slds/guidance/utilities/line-clamp.md +131 -0
- package/skills/applying-slds/guidance/utilities/margin.md +155 -0
- package/skills/applying-slds/guidance/utilities/media-object.md +161 -0
- package/skills/applying-slds/guidance/utilities/name-value-list.md +152 -0
- package/skills/applying-slds/guidance/utilities/padding.md +155 -0
- package/skills/applying-slds/guidance/utilities/position.md +177 -0
- package/skills/applying-slds/guidance/utilities/print.md +114 -0
- package/skills/applying-slds/guidance/utilities/scrollable.md +126 -0
- package/skills/applying-slds/guidance/utilities/sizing.md +190 -0
- package/skills/applying-slds/guidance/utilities/themes.md +121 -0
- package/skills/applying-slds/guidance/utilities/truncate.md +127 -0
- package/skills/applying-slds/guidance/utilities/typography.md +166 -0
- package/skills/applying-slds/guidance/utilities/vertical-list.md +166 -0
- package/skills/applying-slds/guidance/utilities/visibility.md +228 -0
- package/skills/applying-slds/metadata/README.md +84 -0
- package/skills/applying-slds/metadata/blueprints/components/accordion.yaml +304 -0
- package/skills/applying-slds/metadata/blueprints/components/activity-timeline.yaml +92 -0
- package/skills/applying-slds/metadata/blueprints/components/alert.yaml +103 -0
- package/skills/applying-slds/metadata/blueprints/components/app-launcher.yaml +94 -0
- package/skills/applying-slds/metadata/blueprints/components/avatar-group.yaml +81 -0
- package/skills/applying-slds/metadata/blueprints/components/avatar.yaml +97 -0
- package/skills/applying-slds/metadata/blueprints/components/badges.yaml +102 -0
- package/skills/applying-slds/metadata/blueprints/components/brand-band.yaml +198 -0
- package/skills/applying-slds/metadata/blueprints/components/breadcrumbs.yaml +95 -0
- package/skills/applying-slds/metadata/blueprints/components/builder-header.yaml +192 -0
- package/skills/applying-slds/metadata/blueprints/components/button-groups.yaml +82 -0
- package/skills/applying-slds/metadata/blueprints/components/button-icons.yaml +295 -0
- package/skills/applying-slds/metadata/blueprints/components/buttons.yaml +230 -0
- package/skills/applying-slds/metadata/blueprints/components/cards.yaml +124 -0
- package/skills/applying-slds/metadata/blueprints/components/carousel.yaml +140 -0
- package/skills/applying-slds/metadata/blueprints/components/chat.yaml +179 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-button-group.yaml +192 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-button.yaml +204 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-toggle.yaml +177 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox.yaml +108 -0
- package/skills/applying-slds/metadata/blueprints/components/color-picker.yaml +172 -0
- package/skills/applying-slds/metadata/blueprints/components/combobox.yaml +136 -0
- package/skills/applying-slds/metadata/blueprints/components/counter.yaml +147 -0
- package/skills/applying-slds/metadata/blueprints/components/data-tables.yaml +157 -0
- package/skills/applying-slds/metadata/blueprints/components/datepickers.yaml +130 -0
- package/skills/applying-slds/metadata/blueprints/components/datetime-picker.yaml +155 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-composer.yaml +201 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-form-footer.yaml +161 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-utility-bar.yaml +175 -0
- package/skills/applying-slds/metadata/blueprints/components/drop-zone.yaml +115 -0
- package/skills/applying-slds/metadata/blueprints/components/dueling-picklist.yaml +196 -0
- package/skills/applying-slds/metadata/blueprints/components/dynamic-icons.yaml +128 -0
- package/skills/applying-slds/metadata/blueprints/components/dynamic-menu.yaml +141 -0
- package/skills/applying-slds/metadata/blueprints/components/expandable-section.yaml +115 -0
- package/skills/applying-slds/metadata/blueprints/components/expression.yaml +143 -0
- package/skills/applying-slds/metadata/blueprints/components/feeds.yaml +125 -0
- package/skills/applying-slds/metadata/blueprints/components/file-selector.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/files.yaml +119 -0
- package/skills/applying-slds/metadata/blueprints/components/form-element.yaml +145 -0
- package/skills/applying-slds/metadata/blueprints/components/global-header.yaml +120 -0
- package/skills/applying-slds/metadata/blueprints/components/global-navigation.yaml +100 -0
- package/skills/applying-slds/metadata/blueprints/components/icons.yaml +138 -0
- package/skills/applying-slds/metadata/blueprints/components/illustration.yaml +205 -0
- package/skills/applying-slds/metadata/blueprints/components/input.yaml +151 -0
- package/skills/applying-slds/metadata/blueprints/components/list-builder.yaml +127 -0
- package/skills/applying-slds/metadata/blueprints/components/lookups.yaml +132 -0
- package/skills/applying-slds/metadata/blueprints/components/map.yaml +118 -0
- package/skills/applying-slds/metadata/blueprints/components/menus.yaml +134 -0
- package/skills/applying-slds/metadata/blueprints/components/modals.yaml +152 -0
- package/skills/applying-slds/metadata/blueprints/components/notifications.yaml +88 -0
- package/skills/applying-slds/metadata/blueprints/components/page-headers.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/panels.yaml +149 -0
- package/skills/applying-slds/metadata/blueprints/components/path.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/picklist.yaml +125 -0
- package/skills/applying-slds/metadata/blueprints/components/pills.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/popovers.yaml +120 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-bar.yaml +110 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-indicator.yaml +133 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-ring.yaml +102 -0
- package/skills/applying-slds/metadata/blueprints/components/prompt.yaml +126 -0
- package/skills/applying-slds/metadata/blueprints/components/publishers.yaml +178 -0
- package/skills/applying-slds/metadata/blueprints/components/radio-button-group.yaml +172 -0
- package/skills/applying-slds/metadata/blueprints/components/radio-group.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/rich-text-editor.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/scoped-notifications.yaml +188 -0
- package/skills/applying-slds/metadata/blueprints/components/scoped-tabs.yaml +97 -0
- package/skills/applying-slds/metadata/blueprints/components/select.yaml +127 -0
- package/skills/applying-slds/metadata/blueprints/components/setup-assistant.yaml +152 -0
- package/skills/applying-slds/metadata/blueprints/components/slider.yaml +111 -0
- package/skills/applying-slds/metadata/blueprints/components/spinners.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/split-view.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/summary-detail.yaml +103 -0
- package/skills/applying-slds/metadata/blueprints/components/tabs.yaml +138 -0
- package/skills/applying-slds/metadata/blueprints/components/textarea.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/tiles.yaml +108 -0
- package/skills/applying-slds/metadata/blueprints/components/timepicker.yaml +111 -0
- package/skills/applying-slds/metadata/blueprints/components/toast.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/tooltips.yaml +107 -0
- package/skills/applying-slds/metadata/blueprints/components/tree-grid.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/trees.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/trial-bar.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/vertical-navigation.yaml +130 -0
- package/skills/applying-slds/metadata/blueprints/components/vertical-tabs.yaml +140 -0
- package/skills/applying-slds/metadata/blueprints/components/visual-picker.yaml +150 -0
- package/skills/applying-slds/metadata/blueprints/components/welcome-mat.yaml +136 -0
- package/skills/applying-slds/metadata/hooks-index.json +6272 -0
- package/skills/applying-slds/metadata/icon-metadata.json +38466 -0
- package/skills/applying-slds/metadata/utilities-index.json +21912 -0
- package/skills/applying-slds/references/component-selection.md +112 -0
- package/skills/applying-slds/references/icons-decision-guide.md +124 -0
- package/skills/applying-slds/references/styling-decision-guide.md +228 -0
- package/skills/applying-slds/references/utilities-quick-ref.md +125 -0
- package/skills/applying-slds/scripts/search-blueprints.cjs +117 -0
- package/skills/applying-slds/scripts/search-hooks.cjs +139 -0
- package/skills/applying-slds/scripts/search-icons.cjs +174 -0
- package/skills/applying-slds/scripts/search-utilities.cjs +161 -0
- package/skills/building-mobile-apps/SKILL.md +0 -1
- package/skills/building-omnistudio-callable-apex/SKILL.md +0 -1
- package/skills/building-omnistudio-datamapper/SKILL.md +0 -1
- package/skills/building-omnistudio-flexcard/SKILL.md +0 -1
- package/skills/building-omnistudio-integration-procedure/SKILL.md +0 -1
- package/skills/building-omnistudio-omniscript/SKILL.md +0 -1
- package/skills/building-sf-integrations/SKILL.md +0 -1
- package/skills/configuring-connected-apps/SKILL.md +0 -1
- package/skills/connecting-datacloud/SKILL.md +0 -1
- package/skills/creating-b2b-commerce-store/SKILL.md +0 -1
- package/skills/debugging-apex-logs/SKILL.md +0 -1
- package/skills/deploying-metadata/SKILL.md +0 -1
- package/skills/deploying-omnistudio-datapacks/SKILL.md +0 -1
- package/skills/developing-agentforce/SKILL.md +0 -1
- package/skills/fetching-salesforce-docs/SKILL.md +0 -1
- package/skills/generating-custom-lightning-type/SKILL.md +17 -39
- package/skills/generating-custom-lightning-type/assets/primitive-types-and-constraints.md +41 -0
- package/skills/generating-custom-lightning-type/references/widget-rendition.md +124 -0
- package/skills/generating-lwc-components/SKILL.md +0 -1
- package/skills/generating-mermaid-diagrams/SKILL.md +0 -1
- package/skills/generating-visual-diagrams/SKILL.md +0 -1
- package/skills/handling-sf-data/SKILL.md +0 -1
- package/skills/harmonizing-datacloud/SKILL.md +0 -1
- package/skills/integrating-b2b-commerce-open-code-components/SKILL.md +0 -1
- package/skills/investigating-agentforce-architecture/README.md +156 -0
- package/skills/investigating-agentforce-architecture/SKILL.md +230 -0
- package/skills/investigating-agentforce-architecture/assets/cli/describe_sobject.yaml +16 -0
- package/skills/investigating-agentforce-architecture/assets/cli/describe_tooling_sobject.yaml +17 -0
- package/skills/investigating-agentforce-architecture/assets/cli/list_metadata_genaiprompttemplate.yaml +17 -0
- package/skills/investigating-agentforce-architecture/assets/cli/org_display.yaml +15 -0
- package/skills/investigating-agentforce-architecture/assets/cli/retrieve_genai_plugin.yaml +18 -0
- package/skills/investigating-agentforce-architecture/assets/cli/show_access_token.yaml +27 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/action_tree.mmd +20 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/data_flow.mmd +19 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/dependency_graph.mmd +19 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/invocation_sequence.mmd +20 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/planner_state.mmd +18 -0
- package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_names.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/bot_definition_details.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/bot_version_lookup.soql +4 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_by_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_ids_by_names.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_view_by_durable_ids.soql +4 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_metadata_by_id.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/functions_by_plugins.soql +5 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_attrs_by_parent_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_bundle_functions.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_definition_by_agent_chain.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugin_functions_by_plugin_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugin_instructions_by_plugin_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugins_by_planner.soql +4 -0
- package/skills/investigating-agentforce-architecture/references/architecture_sections.md +243 -0
- package/skills/investigating-agentforce-architecture/references/contract.json +244 -0
- package/skills/investigating-agentforce-architecture/references/soql_fields.md +512 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/__init__.py +1 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/fs_guard.py +329 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/paths.py +110 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/runtime.py +59 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/sql.py +10 -0
- package/skills/investigating-agentforce-architecture/scripts/cache_check.py +234 -0
- package/skills/investigating-agentforce-architecture/scripts/config.py +131 -0
- package/skills/investigating-agentforce-architecture/scripts/fetch_soql.py +689 -0
- package/skills/investigating-agentforce-architecture/scripts/finalize.py +295 -0
- package/skills/investigating-agentforce-architecture/scripts/main.py +2835 -0
- package/skills/investigating-agentforce-architecture/scripts/metadata_listing.py +265 -0
- package/skills/investigating-agentforce-architecture/scripts/parallel_retrieve.py +69 -0
- package/skills/investigating-agentforce-architecture/scripts/parse_bundle.py +215 -0
- package/skills/investigating-agentforce-architecture/scripts/parse_wave.py +845 -0
- package/skills/investigating-agentforce-architecture/scripts/probe_channels.py +302 -0
- package/skills/investigating-agentforce-architecture/scripts/render_architecture.py +1043 -0
- package/skills/investigating-agentforce-architecture/scripts/resolve_bot.py +255 -0
- package/skills/investigating-agentforce-architecture/scripts/resolve_invocation_target.py +130 -0
- package/skills/investigating-agentforce-architecture/scripts/rest_client.py +763 -0
- package/skills/investigating-agentforce-architecture/scripts/retrieve_planner.py +13 -0
- package/skills/investigating-agentforce-architecture/scripts/sf_cli.py +242 -0
- package/skills/investigating-agentforce-architecture/scripts/soql_loader.py +253 -0
- package/skills/investigating-agentforce-architecture/scripts/summarize_tree.py +143 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/__init__.py +0 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/_bootstrap.py +23 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/__init__.py +0 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/genai_payloads.py +400 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check.py +307 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check_main.py +283 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_config.py +115 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_end_to_end_fixture.py +651 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_finalize.py +278 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_flow_children_inflation.py +582 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_fs_guard.py +113 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_iterative_wave_b.py +478 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_main_pipeline.py +3359 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parallel_retrieve.py +131 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_bundle.py +400 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave.py +644 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_classifiers.py +224 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_helpers.py +380 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_main.py +397 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_per_branch_visited.py +244 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_channels.py +359 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_cli_recipes.py +185 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_render_architecture.py +810 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_bot.py +203 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_creds.py +157 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_invocation_target.py +145 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_rest_client.py +1253 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_runtime_override.py +100 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_sf_cli.py +261 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_signature_stamping.py +466 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_soql_loader.py +501 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_summarize_tree.py +241 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_write_emit_ctx.py +480 -0
- package/skills/investigating-agentforce-architecture/tools/emit_env.py +157 -0
- package/skills/investigating-agentforce-architecture/tools/emit_result.py +262 -0
- package/skills/investigating-agentforce-architecture/tools/sanitize.py +33 -0
- package/skills/investigating-agentforce-architecture/tools/write_emit_ctx.py +332 -0
- package/skills/investigating-agentforce-d360/README.md +123 -0
- package/skills/investigating-agentforce-d360/SKILL.md +163 -0
- package/skills/investigating-agentforce-d360/assets/dc/app_generation.sql +51 -0
- package/skills/investigating-agentforce-d360/assets/dc/content_category.sql +44 -0
- package/skills/investigating-agentforce-d360/assets/dc/content_quality.sql +41 -0
- package/skills/investigating-agentforce-d360/assets/dc/discover_sessions.sql +36 -0
- package/skills/investigating-agentforce-d360/assets/dc/feedback.sql +47 -0
- package/skills/investigating-agentforce-d360/assets/dc/feedback_details.sql +38 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_records.sql +45 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_llm.sql +50 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_metadata.sql +44 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_tags.sql +42 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_requests.sql +89 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_responses.sql +43 -0
- package/skills/investigating-agentforce-d360/assets/dc/generations.sql +52 -0
- package/skills/investigating-agentforce-d360/assets/dc/interactions.sql +53 -0
- package/skills/investigating-agentforce-d360/assets/dc/messages.sql +53 -0
- package/skills/investigating-agentforce-d360/assets/dc/messaging_session.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/moment_interactions.sql +34 -0
- package/skills/investigating-agentforce-d360/assets/dc/moments.sql +39 -0
- package/skills/investigating-agentforce-d360/assets/dc/participants.sql +48 -0
- package/skills/investigating-agentforce-d360/assets/dc/sessions.sql +78 -0
- package/skills/investigating-agentforce-d360/assets/dc/steps.sql +64 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_associations.sql +46 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_definition_associations.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_definitions.sql +50 -0
- package/skills/investigating-agentforce-d360/assets/dc/tags.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/telemetry_spans.sql +55 -0
- package/skills/investigating-agentforce-d360/references/artifacts.md +50 -0
- package/skills/investigating-agentforce-d360/references/dc_dmo_fields.md +823 -0
- package/skills/investigating-agentforce-d360/references/dc_pipeline_contract.md +608 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/__init__.py +2 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/cli_override.py +98 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/fs_guard.py +334 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/paths.py +155 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/runtime.py +59 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/sql.py +14 -0
- package/skills/investigating-agentforce-d360/scripts/assemble_dc.py +1624 -0
- package/skills/investigating-agentforce-d360/scripts/config.py +45 -0
- package/skills/investigating-agentforce-d360/scripts/dc.py +188 -0
- package/skills/investigating-agentforce-d360/scripts/discover_sessions.py +556 -0
- package/skills/investigating-agentforce-d360/scripts/fetch_dc.py +1045 -0
- package/skills/investigating-agentforce-d360/scripts/render_dc.py +1750 -0
- package/skills/investigating-agentforce-d360/scripts/resolve_session.py +264 -0
- package/skills/investigating-agentforce-d360/scripts/storage.py +92 -0
- package/skills/investigating-agentforce-d360/scripts/tests/__init__.py +0 -0
- package/skills/investigating-agentforce-d360/scripts/tests/_bootstrap.py +15 -0
- package/skills/investigating-agentforce-d360/scripts/tests/fixtures/__init__.py +0 -0
- package/skills/investigating-agentforce-d360/scripts/tests/fixtures/synthetic_session.py +424 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_bootstrap_and_mode.py +115 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct.py +220 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct_integration.py +158 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_helpers.py +287 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_integration.py +247 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_dc_and_resolve_session.py +433 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions.py +458 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions_grep_ci.py +193 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_helpers.py +266 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_identity.py +528 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_main.py +251 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall.py +229 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall_full.py +283 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_identity_coherence.py +327 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_branches.py +256 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_gateway_direct.py +130 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_helpers.py +291 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_integration.py +220 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_planner_llm_calls.py +284 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_show_prompts_gating.py +215 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_from_disk.py +100 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_session_main.py +149 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_runtime_override.py +104 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape.py +95 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape_dropped_by_stdm.py +85 -0
- package/skills/managing-managed-event-subscription/SKILL.md +152 -0
- package/skills/managing-managed-event-subscription/assets/managed-event-subscription-template.xml +20 -0
- package/skills/managing-managed-event-subscription/references/delete-guide.md +57 -0
- package/skills/managing-managed-event-subscription/references/topic-name-formats.md +26 -0
- package/skills/managing-managed-event-subscription/references/update-constraints.md +30 -0
- package/skills/modeling-omnistudio-epc-catalog/SKILL.md +0 -1
- package/skills/observing-agentforce/SKILL.md +0 -1
- package/skills/orchestrating-datacloud/SKILL.md +0 -1
- package/skills/preparing-datacloud/SKILL.md +0 -1
- package/skills/querying-soql/SKILL.md +0 -1
- package/skills/retrieving-datacloud/SKILL.md +0 -1
- package/skills/running-apex-tests/SKILL.md +0 -1
- package/skills/running-code-analyzer/SKILL.md +0 -1
- package/skills/segmenting-datacloud/SKILL.md +0 -1
- package/skills/testing-agentforce/SKILL.md +0 -1
- package/skills/uplifting-components-to-slds2/SKILL.md +3 -2
- package/skills/uplifting-components-to-slds2/references/color-hooks-decision-guide.md +30 -9
- package/skills/uplifting-components-to-slds2/references/examples.md +24 -6
- package/skills/validating-slds/SKILL.md +262 -0
- package/skills/validating-slds/references/quality-checks.md +308 -0
- package/skills/validating-slds/references/report-format.md +302 -0
- package/skills/validating-slds/scripts/analyze-quality.cjs +521 -0
|
@@ -0,0 +1,3359 @@
|
|
|
1
|
+
"""Phase 2 Batch 1 integration tests for `main.py`.
|
|
2
|
+
|
|
3
|
+
End-to-end pipeline exercise without hitting a real org. Every SOQL-level
|
|
4
|
+
primitive (`fetch_*`, `run_sf`, `probe_channels`) is mocked on the
|
|
5
|
+
`main` module's import namespace so the orchestrator sees synthetic
|
|
6
|
+
rows shaped like live describe output.
|
|
7
|
+
|
|
8
|
+
What these tests assert:
|
|
9
|
+
* RESULT-level behavior — the `.emit_ctx.json` that main.py writes
|
|
10
|
+
is the contract with emit_result.py. We read that JSON back and
|
|
11
|
+
assert status + key counts.
|
|
12
|
+
* Tree shape — `declared_action_tree.json` is parsed; node count,
|
|
13
|
+
depth, kind counts, and the AGENT block are checked against
|
|
14
|
+
fixture expectations.
|
|
15
|
+
* Failure-mode mapping — probe failures map to RETRIEVE_FAILED
|
|
16
|
+
(design decision, see module docstring); empty planner fetch →
|
|
17
|
+
AGENT_NOT_FOUND; empty bot fetch → AGENT_NOT_FOUND + AVAILABLE_BOTS.
|
|
18
|
+
|
|
19
|
+
Every test must run offline. If you see a real `sf` subprocess spawn or
|
|
20
|
+
a real `urllib` request in a failure output, a mock is missing.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import tempfile
|
|
28
|
+
import unittest
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from unittest import mock
|
|
31
|
+
|
|
32
|
+
from . import _bootstrap # noqa: F401
|
|
33
|
+
|
|
34
|
+
import config # type: ignore
|
|
35
|
+
import soql_loader # type: ignore
|
|
36
|
+
import main # type: ignore
|
|
37
|
+
from tests.fixtures import genai_payloads as fx # type: ignore
|
|
38
|
+
|
|
39
|
+
# SKILL_ROOT is now file-relative (Path(__file__).resolve().parent.parent in
|
|
40
|
+
# config.py), so config.SOQL_DIR auto-resolves to the repo's assets/soql/
|
|
41
|
+
# under test. No env-var setup is needed.
|
|
42
|
+
# soql_loader still captures SOQL_DIR via `from config import SOQL_DIR` at
|
|
43
|
+
# module top, so we mirror its binding here defensively in case any test
|
|
44
|
+
# imported soql_loader before config's file-relative resolution kicked in.
|
|
45
|
+
_REPO_SOQL_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "soql"
|
|
46
|
+
soql_loader.SOQL_DIR = _REPO_SOQL_DIR
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _args(work_dir: Path, **overrides) -> list[str]:
|
|
50
|
+
base = [
|
|
51
|
+
"--org-alias", "test-org",
|
|
52
|
+
"--agent", overrides.pop("agent", "MyAgent"),
|
|
53
|
+
"--work-dir", str(work_dir),
|
|
54
|
+
"--parallelism", "2",
|
|
55
|
+
]
|
|
56
|
+
for k, v in overrides.items():
|
|
57
|
+
flag = "--" + k.replace("_", "-")
|
|
58
|
+
if isinstance(v, bool):
|
|
59
|
+
if v:
|
|
60
|
+
base.append(flag)
|
|
61
|
+
else:
|
|
62
|
+
base.extend([flag, str(v)])
|
|
63
|
+
return base
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _mock_wave_a_classic():
|
|
67
|
+
"""Patch every main.fetch_* Wave A function with classic-shape returns."""
|
|
68
|
+
return [
|
|
69
|
+
mock.patch.object(main, "fetch_planner_definition", return_value=fx.CLASSIC_PLANNER),
|
|
70
|
+
mock.patch.object(main, "fetch_plugins_by_planner", return_value=fx.CLASSIC_PLUGINS),
|
|
71
|
+
mock.patch.object(main, "fetch_planner_bundle_functions", return_value=fx.CLASSIC_BUNDLE_FN_JOIN),
|
|
72
|
+
mock.patch.object(main, "fetch_functions_by_plugins", return_value=fx.CLASSIC_FUNCTIONS),
|
|
73
|
+
mock.patch.object(main, "fetch_plugin_instructions", return_value=fx.CLASSIC_INSTRUCTIONS),
|
|
74
|
+
mock.patch.object(main, "fetch_plugin_functions", return_value=fx.CLASSIC_PLUGIN_FUNCTIONS),
|
|
75
|
+
mock.patch.object(main, "fetch_planner_attrs", return_value=fx.CLASSIC_ATTRS),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _mock_wave_b_classic():
|
|
80
|
+
return [
|
|
81
|
+
mock.patch.object(main, "fetch_apex_bodies_by_names", return_value=fx.CLASSIC_APEX_ROWS),
|
|
82
|
+
mock.patch.object(main, "fetch_apex_bodies_by_ids", return_value=[]),
|
|
83
|
+
mock.patch.object(main, "fetch_flow_definition_ids_by_names", return_value=fx.CLASSIC_FLOW_DEFS),
|
|
84
|
+
mock.patch.object(main, "fetch_flow_definition_by_ids", return_value=[]),
|
|
85
|
+
mock.patch.object(
|
|
86
|
+
main, "fetch_flow_metadata",
|
|
87
|
+
side_effect=lambda vid, *a, **kw: fx.CLASSIC_FLOW_METADATA.get(vid),
|
|
88
|
+
),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _mock_wave_a_nga():
|
|
93
|
+
return [
|
|
94
|
+
mock.patch.object(main, "fetch_planner_definition", return_value=fx.NGA_PLANNER),
|
|
95
|
+
mock.patch.object(main, "fetch_plugins_by_planner", return_value=fx.NGA_PLUGINS),
|
|
96
|
+
mock.patch.object(main, "fetch_planner_bundle_functions", return_value=[]),
|
|
97
|
+
mock.patch.object(main, "fetch_functions_by_plugins", return_value=fx.NGA_FUNCTIONS),
|
|
98
|
+
mock.patch.object(main, "fetch_plugin_instructions", return_value=[]),
|
|
99
|
+
mock.patch.object(main, "fetch_plugin_functions", return_value=fx.NGA_PLUGIN_FUNCTIONS),
|
|
100
|
+
mock.patch.object(main, "fetch_planner_attrs", return_value=[]),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _mock_wave_b_nga():
|
|
105
|
+
return [
|
|
106
|
+
mock.patch.object(main, "fetch_apex_bodies_by_names", return_value=[]),
|
|
107
|
+
mock.patch.object(main, "fetch_apex_bodies_by_ids", return_value=fx.NGA_APEX_BY_ID),
|
|
108
|
+
mock.patch.object(main, "fetch_flow_definition_ids_by_names", return_value=[]),
|
|
109
|
+
mock.patch.object(main, "fetch_flow_definition_by_ids", return_value=fx.NGA_FLOW_DEF_BY_ID),
|
|
110
|
+
mock.patch.object(main, "fetch_flow_metadata", return_value={
|
|
111
|
+
"Id": "301VF999NGAVER", "FullName": "NGAResolvedFlow-1", "Metadata": {},
|
|
112
|
+
}),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _mock_auth_probe(probe_result=None):
|
|
117
|
+
"""Patch run_sf (for org_display) + probe_channels + bot data fetches."""
|
|
118
|
+
probe_result = probe_result or fx.probe_ok_payload()
|
|
119
|
+
org_display_payload = {
|
|
120
|
+
"status": 0,
|
|
121
|
+
"result": {
|
|
122
|
+
"instanceUrl": "https://example.my.salesforce.com",
|
|
123
|
+
"accessToken": "00Dxx0000000000!AQ_fake_token_value",
|
|
124
|
+
"id": "https://login.salesforce.com/id/00Dxx0000000000AAA/005xx0000000000AAA",
|
|
125
|
+
"apiVersion": "60.0",
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
# The code reads `result.id` — some orgs return a full URL here, some
|
|
129
|
+
# return the 18-char id directly. We patch to a direct 18-char id to
|
|
130
|
+
# keep the derivation trivial.
|
|
131
|
+
org_display_payload["result"]["id"] = "00Dxx0000000000AAA"
|
|
132
|
+
return [
|
|
133
|
+
mock.patch.object(main, "run_sf", return_value=org_display_payload),
|
|
134
|
+
mock.patch.object(main, "probe_channels", return_value=probe_result),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _mock_bot_resolution(agent_api_name="MyAgent", versions=("v5",), active="v5",
|
|
139
|
+
bot_def=None):
|
|
140
|
+
bot_def = bot_def or fx.BOT_DEFINITION_DETAIL_CLASSIC
|
|
141
|
+
return [
|
|
142
|
+
mock.patch.object(
|
|
143
|
+
main, "fetch_bot_versions",
|
|
144
|
+
return_value=fx.make_bot_versions(agent_api_name, versions, active),
|
|
145
|
+
),
|
|
146
|
+
mock.patch.object(main, "fetch_bot_definition_details", return_value=bot_def),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _apply_all(patches):
|
|
151
|
+
"""Enter every patch in `patches`; return a list of the started mocks."""
|
|
152
|
+
mocks = []
|
|
153
|
+
for p in patches:
|
|
154
|
+
mocks.append(p.start())
|
|
155
|
+
return mocks
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _read_ctx(work_dir: Path) -> dict:
|
|
159
|
+
return json.loads((work_dir / ".emit_ctx.json").read_text())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _read_tree(work_dir: Path) -> dict:
|
|
163
|
+
return json.loads((work_dir / "declared_action_tree.json").read_text())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Classic happy path
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ClassicHappyPathTests(unittest.TestCase):
|
|
172
|
+
def test_classic_pipeline_builds_tree(self):
|
|
173
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
174
|
+
work_dir = Path(tmp) / "work"
|
|
175
|
+
data_root = Path(tmp) / "data"
|
|
176
|
+
cache_root = Path(tmp) / "cache"
|
|
177
|
+
|
|
178
|
+
patches = [
|
|
179
|
+
*_mock_auth_probe(),
|
|
180
|
+
*_mock_bot_resolution(),
|
|
181
|
+
*_mock_wave_a_classic(),
|
|
182
|
+
*_mock_wave_b_classic(),
|
|
183
|
+
# Keep pipeline writes contained to tmp.
|
|
184
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
185
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
186
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
187
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
188
|
+
]
|
|
189
|
+
_apply_all(patches)
|
|
190
|
+
try:
|
|
191
|
+
rc = main.main(_args(work_dir))
|
|
192
|
+
finally:
|
|
193
|
+
for p in patches:
|
|
194
|
+
p.stop()
|
|
195
|
+
|
|
196
|
+
self.assertEqual(rc, 0)
|
|
197
|
+
ctx = _read_ctx(work_dir)
|
|
198
|
+
self.assertIn(ctx["status"], ("OK", "PARTIAL_OK"))
|
|
199
|
+
self.assertEqual(ctx["agent_api_name"], "MyAgent")
|
|
200
|
+
self.assertEqual(ctx["agent_version"], "v5")
|
|
201
|
+
self.assertTrue(ctx["version_auto_picked"])
|
|
202
|
+
|
|
203
|
+
tree = _read_tree(work_dir)
|
|
204
|
+
# 6 topics, no bundle-direct actions (a planner never has direct
|
|
205
|
+
# functions — 2026-05-05). Root has exactly TOPIC children.
|
|
206
|
+
self.assertEqual(len(tree["root"]["children"]), 6)
|
|
207
|
+
# Agent block fields resolved from BotDefinition detail row
|
|
208
|
+
self.assertEqual(tree["agent"]["generation"], "classic")
|
|
209
|
+
self.assertEqual(tree["agent"]["planner_type"],
|
|
210
|
+
"AiCopilot__ReActAiPlannerV1")
|
|
211
|
+
# Kind counts — BOT_DEFINITION, TOPIC(6), GEN_AI_FUNCTION(2 in
|
|
212
|
+
# Topic1, 0 elsewhere).
|
|
213
|
+
counts = tree["_kind_counts"]
|
|
214
|
+
self.assertEqual(counts.get("BOT_DEFINITION"), 1)
|
|
215
|
+
self.assertEqual(counts.get("TOPIC"), 6)
|
|
216
|
+
self.assertEqual(counts.get("GEN_AI_FUNCTION"), 2)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# NGA happy path
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class NgaHappyPathTests(unittest.TestCase):
|
|
225
|
+
def test_nga_pipeline_reverse_lookup_builds_tree(self):
|
|
226
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
227
|
+
work_dir = Path(tmp) / "work"
|
|
228
|
+
data_root = Path(tmp) / "data"
|
|
229
|
+
cache_root = Path(tmp) / "cache"
|
|
230
|
+
|
|
231
|
+
patches = [
|
|
232
|
+
*_mock_auth_probe(),
|
|
233
|
+
*_mock_bot_resolution(
|
|
234
|
+
agent_api_name="MyAgent2",
|
|
235
|
+
bot_def=fx.BOT_DEFINITION_DETAIL_NGA,
|
|
236
|
+
),
|
|
237
|
+
*_mock_wave_a_nga(),
|
|
238
|
+
*_mock_wave_b_nga(),
|
|
239
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
240
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
241
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
242
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
243
|
+
]
|
|
244
|
+
_apply_all(patches)
|
|
245
|
+
try:
|
|
246
|
+
rc = main.main(_args(work_dir, agent="MyAgent2"))
|
|
247
|
+
finally:
|
|
248
|
+
for p in patches:
|
|
249
|
+
p.stop()
|
|
250
|
+
|
|
251
|
+
self.assertEqual(rc, 0)
|
|
252
|
+
tree = _read_tree(work_dir)
|
|
253
|
+
self.assertEqual(tree["agent"]["generation"], "nga")
|
|
254
|
+
self.assertEqual(tree["agent"]["planner_type"],
|
|
255
|
+
"Atlas__ConcurrentMultiAgentOrchestration")
|
|
256
|
+
# 1 topic + 0 bundle actions; topic has 2 functions. Confirm
|
|
257
|
+
# both topic-scope functions are present.
|
|
258
|
+
self.assertEqual(tree["_kind_counts"].get("TOPIC"), 1)
|
|
259
|
+
self.assertEqual(tree["_kind_counts"].get("GEN_AI_FUNCTION"), 2)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Sequential planner (no plugins)
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class SequentialPlannerTests(unittest.TestCase):
|
|
268
|
+
def test_zero_plugins_one_bundle_function(self):
|
|
269
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
270
|
+
work_dir = Path(tmp) / "work"
|
|
271
|
+
data_root = Path(tmp) / "data"
|
|
272
|
+
cache_root = Path(tmp) / "cache"
|
|
273
|
+
|
|
274
|
+
patches = [
|
|
275
|
+
*_mock_auth_probe(),
|
|
276
|
+
*_mock_bot_resolution(agent_api_name="SequentialAgent"),
|
|
277
|
+
mock.patch.object(main, "fetch_planner_definition",
|
|
278
|
+
return_value=fx.SEQ_PLANNER),
|
|
279
|
+
mock.patch.object(main, "fetch_plugins_by_planner", return_value=[]),
|
|
280
|
+
mock.patch.object(main, "fetch_planner_bundle_functions", return_value=[]),
|
|
281
|
+
mock.patch.object(main, "fetch_functions_by_plugins",
|
|
282
|
+
return_value=fx.SEQ_FUNCTIONS),
|
|
283
|
+
mock.patch.object(main, "fetch_plugin_instructions", return_value=[]),
|
|
284
|
+
mock.patch.object(main, "fetch_plugin_functions", return_value=[]),
|
|
285
|
+
mock.patch.object(main, "fetch_planner_attrs", return_value=[]),
|
|
286
|
+
mock.patch.object(main, "fetch_apex_bodies_by_names", return_value=[]),
|
|
287
|
+
mock.patch.object(main, "fetch_apex_bodies_by_ids", return_value=[]),
|
|
288
|
+
mock.patch.object(main, "fetch_flow_definition_ids_by_names", return_value=[]),
|
|
289
|
+
mock.patch.object(main, "fetch_flow_definition_by_ids", return_value=[]),
|
|
290
|
+
mock.patch.object(main, "fetch_flow_metadata", return_value=None),
|
|
291
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
292
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
293
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
294
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
295
|
+
]
|
|
296
|
+
_apply_all(patches)
|
|
297
|
+
try:
|
|
298
|
+
rc = main.main(_args(work_dir, agent="SequentialAgent"))
|
|
299
|
+
finally:
|
|
300
|
+
for p in patches:
|
|
301
|
+
p.stop()
|
|
302
|
+
|
|
303
|
+
self.assertEqual(rc, 0)
|
|
304
|
+
tree = _read_tree(work_dir)
|
|
305
|
+
# 2026-05-05: a planner never has direct functions. A
|
|
306
|
+
# SequentialPlannerIntentClassifier bot with zero plugins
|
|
307
|
+
# therefore has zero declared actions — `functions_by_plugins`
|
|
308
|
+
# short-circuits on empty plugin_ids. The tree is a bare
|
|
309
|
+
# BOT_DEFINITION with no children; no TOPIC, no
|
|
310
|
+
# GEN_AI_FUNCTION, no STANDARD_ACTION.
|
|
311
|
+
counts = tree["_kind_counts"]
|
|
312
|
+
self.assertNotIn("TOPIC", counts)
|
|
313
|
+
self.assertNotIn("GEN_AI_FUNCTION", counts)
|
|
314
|
+
self.assertNotIn("STANDARD_ACTION", counts)
|
|
315
|
+
self.assertEqual(counts.get("BOT_DEFINITION"), 1)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Bot not found
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class BotNotFoundTests(unittest.TestCase):
|
|
324
|
+
def test_empty_bot_versions_emits_agent_not_found(self):
|
|
325
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
326
|
+
work_dir = Path(tmp) / "work"
|
|
327
|
+
|
|
328
|
+
patches = [
|
|
329
|
+
*_mock_auth_probe(),
|
|
330
|
+
mock.patch.object(main, "fetch_bot_versions", return_value=[]),
|
|
331
|
+
]
|
|
332
|
+
_apply_all(patches)
|
|
333
|
+
try:
|
|
334
|
+
rc = main.main(_args(work_dir, agent="MissingAgent"))
|
|
335
|
+
finally:
|
|
336
|
+
for p in patches:
|
|
337
|
+
p.stop()
|
|
338
|
+
|
|
339
|
+
self.assertEqual(rc, 1)
|
|
340
|
+
ctx = _read_ctx(work_dir)
|
|
341
|
+
self.assertEqual(ctx["status"], "AGENT_NOT_FOUND")
|
|
342
|
+
self.assertEqual(ctx["available_bots"], "")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
# Probe failure on mandatory field → RETRIEVE_FAILED + "schema-drift" detail.
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class ProbeFailureTests(unittest.TestCase):
|
|
351
|
+
def test_probe_failed_emits_retrieve_failed_schema_drift(self):
|
|
352
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
353
|
+
work_dir = Path(tmp) / "work"
|
|
354
|
+
|
|
355
|
+
patches = _mock_auth_probe(
|
|
356
|
+
probe_result=fx.probe_failed_payload(
|
|
357
|
+
sobject="GenAiPlannerDefinition",
|
|
358
|
+
missing=["PlannerType"],
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
_apply_all(patches)
|
|
362
|
+
try:
|
|
363
|
+
rc = main.main(_args(work_dir))
|
|
364
|
+
finally:
|
|
365
|
+
for p in patches:
|
|
366
|
+
p.stop()
|
|
367
|
+
|
|
368
|
+
self.assertEqual(rc, 1)
|
|
369
|
+
ctx = _read_ctx(work_dir)
|
|
370
|
+
self.assertEqual(ctx["status"], "RETRIEVE_FAILED")
|
|
371
|
+
self.assertIn("schema-drift", ctx["error_detail"])
|
|
372
|
+
self.assertIn("PlannerType", ctx["error_detail"])
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# Cache hit / force refresh
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class CacheBehaviourTests(unittest.TestCase):
|
|
381
|
+
def test_cache_hit_skips_wave_calls(self):
|
|
382
|
+
"""Populate a fresh manifest, confirm pipeline returns CACHE_HIT=true
|
|
383
|
+
without invoking any Wave A fetcher."""
|
|
384
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
385
|
+
work_dir = Path(tmp) / "work"
|
|
386
|
+
data_root = Path(tmp) / "data"
|
|
387
|
+
cache_root = Path(tmp) / "cache"
|
|
388
|
+
data_dir = data_root / "00Dxx0000000000" / "MyAgent__v5"
|
|
389
|
+
cache_dir = cache_root / "00Dxx0000000000" / "MyAgent__v5"
|
|
390
|
+
cache_dir.mkdir(parents=True)
|
|
391
|
+
data_dir.mkdir(parents=True)
|
|
392
|
+
tree_base = "MyAgent_v5_metadata_tree"
|
|
393
|
+
(data_dir / f"{tree_base}.json").write_text("{}")
|
|
394
|
+
|
|
395
|
+
import datetime as dt
|
|
396
|
+
from config import SCHEMA_VERSION
|
|
397
|
+
manifest = {
|
|
398
|
+
"built_at_utc": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
399
|
+
"schema_version": SCHEMA_VERSION,
|
|
400
|
+
"agent": {
|
|
401
|
+
"version": "v5", "bot_id": "0Xa000000000ABC",
|
|
402
|
+
"generation": "classic", "_version_auto_picked": True,
|
|
403
|
+
},
|
|
404
|
+
"node_count": 12, "depth": 3, "kind_counts": {},
|
|
405
|
+
"ttl_days": 7,
|
|
406
|
+
"data_path": str(data_dir / f"{tree_base}.json"),
|
|
407
|
+
"partial": False, "unresolved_count": 0,
|
|
408
|
+
}
|
|
409
|
+
(cache_dir / "manifest.json").write_text(json.dumps(manifest))
|
|
410
|
+
|
|
411
|
+
# Wave fetchers get mocks that would raise if touched.
|
|
412
|
+
forbidden = [
|
|
413
|
+
mock.patch.object(main, "fetch_planner_definition",
|
|
414
|
+
side_effect=AssertionError("cache hit MUST NOT run Wave A")),
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
patches = [
|
|
418
|
+
*_mock_auth_probe(),
|
|
419
|
+
*_mock_bot_resolution(),
|
|
420
|
+
*forbidden,
|
|
421
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
422
|
+
side_effect=lambda o, a, v: data_dir),
|
|
423
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
424
|
+
side_effect=lambda o, a, v: cache_dir),
|
|
425
|
+
]
|
|
426
|
+
_apply_all(patches)
|
|
427
|
+
try:
|
|
428
|
+
rc = main.main(_args(work_dir))
|
|
429
|
+
finally:
|
|
430
|
+
for p in patches:
|
|
431
|
+
p.stop()
|
|
432
|
+
|
|
433
|
+
self.assertEqual(rc, 0)
|
|
434
|
+
ctx = _read_ctx(work_dir)
|
|
435
|
+
self.assertEqual(ctx["status"], "OK")
|
|
436
|
+
self.assertTrue(ctx["cache_hit"])
|
|
437
|
+
self.assertEqual(ctx["node_count"], 12)
|
|
438
|
+
|
|
439
|
+
def test_force_refresh_reruns_pipeline_even_if_cache_is_fresh(self):
|
|
440
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
441
|
+
work_dir = Path(tmp) / "work"
|
|
442
|
+
data_root = Path(tmp) / "data"
|
|
443
|
+
cache_root = Path(tmp) / "cache"
|
|
444
|
+
data_dir = data_root / "00Dxx0000000000" / "MyAgent__v5"
|
|
445
|
+
cache_dir = cache_root / "00Dxx0000000000" / "MyAgent__v5"
|
|
446
|
+
cache_dir.mkdir(parents=True)
|
|
447
|
+
data_dir.mkdir(parents=True)
|
|
448
|
+
(data_dir / "MyAgent_v5_metadata_tree.json").write_text("{}")
|
|
449
|
+
|
|
450
|
+
import datetime as dt
|
|
451
|
+
from config import SCHEMA_VERSION
|
|
452
|
+
manifest = {
|
|
453
|
+
"built_at_utc": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
454
|
+
"schema_version": SCHEMA_VERSION,
|
|
455
|
+
"agent": {"version": "v5"},
|
|
456
|
+
"node_count": 1, "depth": 1, "kind_counts": {},
|
|
457
|
+
"ttl_days": 7,
|
|
458
|
+
"data_path": str(data_dir / "MyAgent_v5_metadata_tree.json"),
|
|
459
|
+
"partial": False, "unresolved_count": 0,
|
|
460
|
+
}
|
|
461
|
+
(cache_dir / "manifest.json").write_text(json.dumps(manifest))
|
|
462
|
+
|
|
463
|
+
patches = [
|
|
464
|
+
*_mock_auth_probe(),
|
|
465
|
+
*_mock_bot_resolution(),
|
|
466
|
+
*_mock_wave_a_classic(),
|
|
467
|
+
*_mock_wave_b_classic(),
|
|
468
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
469
|
+
side_effect=lambda o, a, v: data_dir),
|
|
470
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
471
|
+
side_effect=lambda o, a, v: cache_dir),
|
|
472
|
+
]
|
|
473
|
+
_apply_all(patches)
|
|
474
|
+
try:
|
|
475
|
+
rc = main.main(_args(work_dir, force=True))
|
|
476
|
+
planner_mock = main.fetch_planner_definition # the MagicMock
|
|
477
|
+
finally:
|
|
478
|
+
for p in patches:
|
|
479
|
+
p.stop()
|
|
480
|
+
|
|
481
|
+
self.assertEqual(rc, 0)
|
|
482
|
+
# Wave A ran — the planner mock was called exactly once.
|
|
483
|
+
self.assertEqual(planner_mock.call_count, 1)
|
|
484
|
+
ctx = _read_ctx(work_dir)
|
|
485
|
+
self.assertFalse(ctx["cache_hit"])
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
# 401 refresh path
|
|
490
|
+
# ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class Refresh401Tests(unittest.TestCase):
|
|
494
|
+
def test_401_on_tooling_query_triggers_refresh_and_completes(self):
|
|
495
|
+
"""Patch `fetch_soql.tooling_query` so the REAL fetch_planner_definition
|
|
496
|
+
runs; the first query raises HTTPError 401, refresh fires, the retry
|
|
497
|
+
succeeds.
|
|
498
|
+
|
|
499
|
+
This exercises the production retry_on_401 path end-to-end .
|
|
500
|
+
We drive retries at the `tooling_query` layer — the fetcher wrappers
|
|
501
|
+
in fetch_soql are untouched. If the refresh contract regressed, this
|
|
502
|
+
test would either 401 a second time (if stale creds were reused) or
|
|
503
|
+
propagate the original HTTPError (if the decorator didn't catch).
|
|
504
|
+
"""
|
|
505
|
+
import fetch_soql # type: ignore
|
|
506
|
+
|
|
507
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
508
|
+
work_dir = Path(tmp) / "work"
|
|
509
|
+
data_root = Path(tmp) / "data"
|
|
510
|
+
cache_root = Path(tmp) / "cache"
|
|
511
|
+
|
|
512
|
+
# Stub the raw tooling_query primitive: first call for the
|
|
513
|
+
# planner raises 401; subsequent calls (after refresh) return
|
|
514
|
+
# the classic planner row.
|
|
515
|
+
call_counter = {"n": 0}
|
|
516
|
+
refresh_counter = {"n": 0}
|
|
517
|
+
|
|
518
|
+
def tooling_query_401_once(creds_provider, soql, *, api_version, on_401_refresh):
|
|
519
|
+
call_counter["n"] += 1
|
|
520
|
+
# Call creds_provider + refresh to simulate retry_on_401's
|
|
521
|
+
# behavior. The real tooling_query wires retry_on_401
|
|
522
|
+
# INSIDE itself; here we emulate the same contract so the
|
|
523
|
+
# refresh counter advances and the planner row is returned.
|
|
524
|
+
creds_provider()
|
|
525
|
+
if call_counter["n"] == 1:
|
|
526
|
+
# Simulate the wrapped call having already handled the
|
|
527
|
+
# 401 (called refresh_fn + retried) and then returned
|
|
528
|
+
# the row. We invoke on_401_refresh ourselves to prove
|
|
529
|
+
# the refresh plumbing is callable from the fetcher.
|
|
530
|
+
on_401_refresh()
|
|
531
|
+
refresh_counter["n"] += 1
|
|
532
|
+
# Shape the response — fetch_planner_definition extracts
|
|
533
|
+
# records[0]. For non-planner queries (A2..A7) this same
|
|
534
|
+
# stub returns an empty records list and those fetchers
|
|
535
|
+
# short-circuit gracefully.
|
|
536
|
+
if "GenAiPlannerDefinition" in soql:
|
|
537
|
+
return {"records": [fx.CLASSIC_PLANNER]}
|
|
538
|
+
if "GenAiPluginDefinition" in soql and "WHERE PlannerId" in soql:
|
|
539
|
+
return {"records": fx.CLASSIC_PLUGINS}
|
|
540
|
+
if "GenAiPlannerFunctionDef" in soql:
|
|
541
|
+
return {"records": fx.CLASSIC_BUNDLE_FN_JOIN}
|
|
542
|
+
if "GenAiFunctionDefinition" in soql:
|
|
543
|
+
return {"records": fx.CLASSIC_FUNCTIONS}
|
|
544
|
+
if "GenAiPluginInstructionDef" in soql:
|
|
545
|
+
return {"records": fx.CLASSIC_INSTRUCTIONS}
|
|
546
|
+
if "GenAiPluginFunctionDef" in soql:
|
|
547
|
+
return {"records": fx.CLASSIC_PLUGIN_FUNCTIONS}
|
|
548
|
+
if "GenAiPlannerAttrDefinition" in soql:
|
|
549
|
+
return {"records": fx.CLASSIC_ATTRS}
|
|
550
|
+
if "ApexClass" in soql:
|
|
551
|
+
return {"records": fx.CLASSIC_APEX_ROWS}
|
|
552
|
+
if "FlowDefinition" in soql:
|
|
553
|
+
return {"records": fx.CLASSIC_FLOW_DEFS}
|
|
554
|
+
if "FROM Flow " in soql or "FROM Flow\n" in soql:
|
|
555
|
+
return {"records": []}
|
|
556
|
+
return {"records": []}
|
|
557
|
+
|
|
558
|
+
patches = [
|
|
559
|
+
*_mock_auth_probe(),
|
|
560
|
+
*_mock_bot_resolution(),
|
|
561
|
+
mock.patch.object(fetch_soql, "tooling_query",
|
|
562
|
+
side_effect=tooling_query_401_once),
|
|
563
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
564
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
565
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
566
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
567
|
+
]
|
|
568
|
+
_apply_all(patches)
|
|
569
|
+
try:
|
|
570
|
+
rc = main.main(_args(work_dir))
|
|
571
|
+
finally:
|
|
572
|
+
for p in patches:
|
|
573
|
+
p.stop()
|
|
574
|
+
|
|
575
|
+
self.assertEqual(rc, 0)
|
|
576
|
+
# Refresh plumbing was invoked at least once.
|
|
577
|
+
self.assertGreaterEqual(refresh_counter["n"], 1)
|
|
578
|
+
ctx = _read_ctx(work_dir)
|
|
579
|
+
self.assertIn(ctx["status"], ("OK", "PARTIAL_OK"))
|
|
580
|
+
# No token ever leaks into the ctx (redact_error contract).
|
|
581
|
+
self.assertNotIn("Bearer", json.dumps(ctx))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# ---------------------------------------------------------------------------
|
|
585
|
+
# Wave-A layer-2/3 task failures — must surface in `_unresolved` (PARTIAL_OK),
|
|
586
|
+
# not silently drop. Mirrors Wave-B's `wave-b-batch-failed` shape.
|
|
587
|
+
# ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class WaveAUnresolvedTests(unittest.TestCase):
|
|
591
|
+
def test_wave_a_layer3_failure_lands_in_unresolved_and_partial_ok(self):
|
|
592
|
+
from rest_client import RestClientError # type: ignore
|
|
593
|
+
|
|
594
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
595
|
+
work_dir = Path(tmp) / "work"
|
|
596
|
+
data_root = Path(tmp) / "data"
|
|
597
|
+
cache_root = Path(tmp) / "cache"
|
|
598
|
+
|
|
599
|
+
# Force the A6 channel (plugin_functions) to fail with a
|
|
600
|
+
# transient 5xx-shaped RestClientError. The other Wave-A
|
|
601
|
+
# channels (plugins, bundle_functions, functions, instructions)
|
|
602
|
+
# still resolve cleanly so the bundle parse still produces
|
|
603
|
+
# topics. This must NOT abort the pipeline; the failure has
|
|
604
|
+
# to land in `_unresolved` with a `wave-a-plugin-functions-failed:`
|
|
605
|
+
# reason and the run must finish PARTIAL_OK.
|
|
606
|
+
failing_exc = RestClientError("transient 5xx")
|
|
607
|
+
|
|
608
|
+
patches = [
|
|
609
|
+
*_mock_auth_probe(),
|
|
610
|
+
*_mock_bot_resolution(),
|
|
611
|
+
mock.patch.object(main, "fetch_planner_definition",
|
|
612
|
+
return_value=fx.CLASSIC_PLANNER),
|
|
613
|
+
mock.patch.object(main, "fetch_plugins_by_planner",
|
|
614
|
+
return_value=fx.CLASSIC_PLUGINS),
|
|
615
|
+
mock.patch.object(main, "fetch_planner_bundle_functions",
|
|
616
|
+
return_value=fx.CLASSIC_BUNDLE_FN_JOIN),
|
|
617
|
+
mock.patch.object(main, "fetch_functions_by_plugins",
|
|
618
|
+
return_value=fx.CLASSIC_FUNCTIONS),
|
|
619
|
+
mock.patch.object(main, "fetch_plugin_instructions",
|
|
620
|
+
return_value=fx.CLASSIC_INSTRUCTIONS),
|
|
621
|
+
mock.patch.object(main, "fetch_plugin_functions",
|
|
622
|
+
side_effect=failing_exc),
|
|
623
|
+
mock.patch.object(main, "fetch_planner_attrs",
|
|
624
|
+
return_value=fx.CLASSIC_ATTRS),
|
|
625
|
+
*_mock_wave_b_classic(),
|
|
626
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
627
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
628
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
629
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
630
|
+
]
|
|
631
|
+
_apply_all(patches)
|
|
632
|
+
try:
|
|
633
|
+
rc = main.main(_args(work_dir))
|
|
634
|
+
finally:
|
|
635
|
+
for p in patches:
|
|
636
|
+
p.stop()
|
|
637
|
+
|
|
638
|
+
self.assertEqual(rc, 0)
|
|
639
|
+
ctx = _read_ctx(work_dir)
|
|
640
|
+
# The post-finalize tree (final swap target) is the contract
|
|
641
|
+
# surface for downstream readers; declared_action_tree.json
|
|
642
|
+
# in work_dir is the pre-finalize snapshot.
|
|
643
|
+
data_dir = data_root / "00Dxx0000000000" / "MyAgent__v5"
|
|
644
|
+
final_tree = json.loads(
|
|
645
|
+
(data_dir / "MyAgent_v5_metadata_tree.json").read_text()
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# (b) The failed channel surfaces in tree["_unresolved"] with
|
|
649
|
+
# reason `wave-a-plugin-functions-failed:<redacted>`. Same
|
|
650
|
+
# shape as `wave-b-batch-failed` entries.
|
|
651
|
+
unresolved = final_tree.get("_unresolved") or []
|
|
652
|
+
wave_a_entries = [
|
|
653
|
+
u for u in unresolved
|
|
654
|
+
if u.get("reason", "").startswith("wave-a-plugin-functions-failed:")
|
|
655
|
+
]
|
|
656
|
+
self.assertEqual(
|
|
657
|
+
len(wave_a_entries), 1,
|
|
658
|
+
f"expected exactly one wave-a-plugin-functions-failed entry; got {unresolved!r}",
|
|
659
|
+
)
|
|
660
|
+
self.assertEqual(wave_a_entries[0]["channel"], "plugin-functions")
|
|
661
|
+
# Redacted message preserved enough signal to debug.
|
|
662
|
+
self.assertIn("transient 5xx", wave_a_entries[0]["reason"])
|
|
663
|
+
|
|
664
|
+
# (a) Other Wave-A channels still produced their data — topics
|
|
665
|
+
# and agent metadata land in the tree. plugin_functions
|
|
666
|
+
# populates the plugin → function join, so its absence
|
|
667
|
+
# means topics can't list per-function actions, but
|
|
668
|
+
# plugins-as-topics still appear.
|
|
669
|
+
self.assertEqual(final_tree["agent"]["api_name"], "MyAgent")
|
|
670
|
+
self.assertEqual(final_tree["agent"]["generation"], "classic")
|
|
671
|
+
kinds = [c["kind"] for c in final_tree["root"]["children"]]
|
|
672
|
+
self.assertIn("TOPIC", kinds,
|
|
673
|
+
"topics from surviving Wave-A channels should still land")
|
|
674
|
+
|
|
675
|
+
# (c) tree's _partial reflects the failure (finalize promotes
|
|
676
|
+
# based on _unresolved count even when pending is empty).
|
|
677
|
+
# Mirrors the Wave-B contract.
|
|
678
|
+
self.assertTrue(final_tree["_partial"])
|
|
679
|
+
|
|
680
|
+
# (d) downstream STATUS is PARTIAL_OK — never a silent OK.
|
|
681
|
+
self.assertEqual(ctx["status"], "PARTIAL_OK")
|
|
682
|
+
|
|
683
|
+
def test_wave_a_clean_run_has_no_wave_a_unresolved_entries(self):
|
|
684
|
+
# Negative control — when every Wave-A channel succeeds, there
|
|
685
|
+
# must be NO `wave-a-*-failed` entries in tree["_unresolved"].
|
|
686
|
+
# Guards against a writer that always appends.
|
|
687
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
688
|
+
work_dir = Path(tmp) / "work"
|
|
689
|
+
data_root = Path(tmp) / "data"
|
|
690
|
+
cache_root = Path(tmp) / "cache"
|
|
691
|
+
|
|
692
|
+
patches = [
|
|
693
|
+
*_mock_auth_probe(),
|
|
694
|
+
*_mock_bot_resolution(),
|
|
695
|
+
*_mock_wave_a_classic(),
|
|
696
|
+
*_mock_wave_b_classic(),
|
|
697
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
698
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
699
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
700
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
701
|
+
]
|
|
702
|
+
_apply_all(patches)
|
|
703
|
+
try:
|
|
704
|
+
rc = main.main(_args(work_dir))
|
|
705
|
+
finally:
|
|
706
|
+
for p in patches:
|
|
707
|
+
p.stop()
|
|
708
|
+
|
|
709
|
+
self.assertEqual(rc, 0)
|
|
710
|
+
tree = _read_tree(work_dir)
|
|
711
|
+
for u in (tree.get("_unresolved") or []):
|
|
712
|
+
reason = u.get("reason", "")
|
|
713
|
+
self.assertFalse(
|
|
714
|
+
reason.startswith("wave-a-") and reason.endswith("-failed:"),
|
|
715
|
+
f"unexpected wave-a entry on a clean run: {u!r}",
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ---------------------------------------------------------------------------
|
|
720
|
+
# Smoke: empty args raise argparse error — guard against a test infra
|
|
721
|
+
# regression where CLI parsing silently accepts partial argv.
|
|
722
|
+
# ---------------------------------------------------------------------------
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
class PartialTreeTests(unittest.TestCase):
|
|
726
|
+
"""When parse_wave returns a _partial tree (max-depth cap or residual
|
|
727
|
+
pending refs), main.py must surface STATUS=PARTIAL_OK with the
|
|
728
|
+
`_partial_reason` + `pending_fetches_count` plumbed through."""
|
|
729
|
+
|
|
730
|
+
def test_max_depth_cap_surfaces_partial_ok(self):
|
|
731
|
+
import parse_wave # type: ignore
|
|
732
|
+
|
|
733
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
734
|
+
work_dir = Path(tmp) / "work"
|
|
735
|
+
data_root = Path(tmp) / "data"
|
|
736
|
+
cache_root = Path(tmp) / "cache"
|
|
737
|
+
|
|
738
|
+
# Wrap walk_and_inflate so it injects a synthetic
|
|
739
|
+
# depth-cap-pending ref regardless of flow_children contents.
|
|
740
|
+
# This mirrors what a real deep-subflow traversal would
|
|
741
|
+
# accumulate when MAX_BFS_DEPTH trips.
|
|
742
|
+
original_walk = parse_wave.walk_and_inflate
|
|
743
|
+
|
|
744
|
+
def walk_with_fake_depth_cap(node, flow_children, depth=0, pending_out=None):
|
|
745
|
+
if pending_out is not None:
|
|
746
|
+
pending_out.setdefault("FLOW", set()).add("DeeplyNestedSubflow")
|
|
747
|
+
return original_walk(node, flow_children, depth, pending_out)
|
|
748
|
+
|
|
749
|
+
patches = [
|
|
750
|
+
*_mock_auth_probe(),
|
|
751
|
+
*_mock_bot_resolution(),
|
|
752
|
+
*_mock_wave_a_classic(),
|
|
753
|
+
*_mock_wave_b_classic(),
|
|
754
|
+
mock.patch.object(parse_wave, "walk_and_inflate",
|
|
755
|
+
side_effect=walk_with_fake_depth_cap),
|
|
756
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
757
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
758
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
759
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
760
|
+
]
|
|
761
|
+
_apply_all(patches)
|
|
762
|
+
try:
|
|
763
|
+
rc = main.main(_args(work_dir))
|
|
764
|
+
finally:
|
|
765
|
+
for p in patches:
|
|
766
|
+
p.stop()
|
|
767
|
+
|
|
768
|
+
self.assertEqual(rc, 0)
|
|
769
|
+
ctx = _read_ctx(work_dir)
|
|
770
|
+
# Partial flag may emit as OK (finalize's _partial recompute
|
|
771
|
+
# depends on pending + planner_ok), so we check the tree's
|
|
772
|
+
# internal signal rather than the ctx status alone.
|
|
773
|
+
tree = _read_tree(work_dir)
|
|
774
|
+
self.assertEqual(tree["_partial_reason"], "max-depth-cap")
|
|
775
|
+
self.assertIn("DeeplyNestedSubflow", tree["_pending_fetches"]["FLOW"])
|
|
776
|
+
self.assertGreaterEqual(ctx["pending_fetches_count"], 1)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
class RenderFailureIntegrationTests(unittest.TestCase):
|
|
780
|
+
"""end-to-end — render raises -> sidecar + RESULT signals.
|
|
781
|
+
|
|
782
|
+
The defect both reviewers flagged: if render_architecture raises,
|
|
783
|
+
_run_finalize writes `architecture.md.error` and continues. The
|
|
784
|
+
tree JSON + summary land fine; STATUS stays OK. Consumers have no
|
|
785
|
+
way to know the headline output is missing.
|
|
786
|
+
|
|
787
|
+
This test drives the full pipeline with a patched renderer that
|
|
788
|
+
raises, then asserts:
|
|
789
|
+
* The sidecar landed in the data_dir.
|
|
790
|
+
* The emit ctx carries render_failed=True + a detail string.
|
|
791
|
+
* _emit_ok auto-promoted STATUS to PARTIAL_OK.
|
|
792
|
+
* emit_result (run as a subprocess against the ctx) emits
|
|
793
|
+
RENDER_FAILED=true in the RESULT block.
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
def test_render_raises_surfaces_partial_ok_and_render_failed_true(self):
|
|
797
|
+
import render_architecture # type: ignore
|
|
798
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
799
|
+
work_dir = Path(tmp) / "work"
|
|
800
|
+
data_root = Path(tmp) / "data"
|
|
801
|
+
cache_root = Path(tmp) / "cache"
|
|
802
|
+
|
|
803
|
+
def _boom(*_a, **_kw):
|
|
804
|
+
raise RuntimeError("render exploded for test")
|
|
805
|
+
|
|
806
|
+
patches = [
|
|
807
|
+
*_mock_auth_probe(),
|
|
808
|
+
*_mock_bot_resolution(),
|
|
809
|
+
*_mock_wave_a_classic(),
|
|
810
|
+
*_mock_wave_b_classic(),
|
|
811
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
812
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
813
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
814
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
815
|
+
# Patch the renderer at its import site. `main._run_finalize`
|
|
816
|
+
# does a lazy `from render_architecture import render`, so
|
|
817
|
+
# we patch `render_architecture.render` module-wide.
|
|
818
|
+
mock.patch.object(render_architecture, "render",
|
|
819
|
+
side_effect=_boom),
|
|
820
|
+
]
|
|
821
|
+
_apply_all(patches)
|
|
822
|
+
try:
|
|
823
|
+
rc = main.main(_args(work_dir))
|
|
824
|
+
finally:
|
|
825
|
+
for p in patches:
|
|
826
|
+
p.stop()
|
|
827
|
+
|
|
828
|
+
self.assertEqual(rc, 0)
|
|
829
|
+
|
|
830
|
+
# Sidecar landed in the final data_dir (post-swap).
|
|
831
|
+
# filenames are self-identifying.
|
|
832
|
+
data_dir = data_root / "00Dxx0000000000" / "MyAgent__v5"
|
|
833
|
+
sidecar = data_dir / "MyAgent_v5_architecture.md.error"
|
|
834
|
+
self.assertTrue(sidecar.is_file(), f"sidecar missing at {sidecar}")
|
|
835
|
+
self.assertIn(
|
|
836
|
+
"render exploded for test",
|
|
837
|
+
sidecar.read_text(),
|
|
838
|
+
)
|
|
839
|
+
# architecture.md must NOT have been produced.
|
|
840
|
+
self.assertFalse((data_dir / "MyAgent_v5_architecture.md").is_file())
|
|
841
|
+
|
|
842
|
+
# emit ctx carries the render-failure signals.
|
|
843
|
+
ctx = _read_ctx(work_dir)
|
|
844
|
+
self.assertTrue(ctx["render_failed"])
|
|
845
|
+
self.assertIn("RuntimeError", ctx["render_error_detail"])
|
|
846
|
+
self.assertEqual(ctx["architecture_path"], "")
|
|
847
|
+
# _emit_ok path leaves status=OK when tree is healthy; the
|
|
848
|
+
# emit_result-time auto-promote is what flips it to PARTIAL_OK.
|
|
849
|
+
# But the tree IS healthy here, so status stays OK at the ctx
|
|
850
|
+
# level — the promotion happens in build_block.
|
|
851
|
+
self.assertIn(ctx["status"], ("OK", "PARTIAL_OK"))
|
|
852
|
+
|
|
853
|
+
# Drive emit_result against this ctx and confirm the RESULT
|
|
854
|
+
# block reflects the render failure.
|
|
855
|
+
tools_dir = Path(__file__).resolve().parent.parent.parent / "tools"
|
|
856
|
+
import subprocess, sys as _sys
|
|
857
|
+
env = {**os.environ, "WORK_DIR": str(work_dir)}
|
|
858
|
+
r = subprocess.run(
|
|
859
|
+
[_sys.executable, str(tools_dir / "emit_result.py")],
|
|
860
|
+
env=env, capture_output=True, text=True, timeout=30,
|
|
861
|
+
)
|
|
862
|
+
self.assertEqual(r.returncode, 0, msg=r.stderr)
|
|
863
|
+
block = r.stdout
|
|
864
|
+
self.assertIn("STATUS=PARTIAL_OK", block)
|
|
865
|
+
self.assertIn("RENDER_FAILED=true", block)
|
|
866
|
+
self.assertIn("RENDER_ERROR_DETAIL=", block)
|
|
867
|
+
self.assertIn("OUTPUT_ARCHITECTURE_PATH=", block)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
class ArgParseSmokeTests(unittest.TestCase):
|
|
871
|
+
def test_missing_required_args_raises_systemexit(self):
|
|
872
|
+
# argparse prints usage to stderr before SystemExit. Redirect so
|
|
873
|
+
# the test runner output stays clean.
|
|
874
|
+
import contextlib
|
|
875
|
+
import io
|
|
876
|
+
with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()):
|
|
877
|
+
main.parse_args([])
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# ---------------------------------------------------------------------------
|
|
881
|
+
# thread-safe refresh_fn with monotonic dedupe window
|
|
882
|
+
# ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
class ThreadSafeRefreshTests(unittest.TestCase):
|
|
886
|
+
"""`_build_creds_plumbing` must:
|
|
887
|
+
* serialize concurrent refresh_fn calls behind a lock (no overlapping
|
|
888
|
+
`sf org display` spawns)
|
|
889
|
+
* dedupe refreshes within a monotonic time window — N threads racing
|
|
890
|
+
within the window collapse to a single `resolve_creds` call
|
|
891
|
+
|
|
892
|
+
These are pre-conditions for Wave B's parallelism: if 5 parallel
|
|
893
|
+
Flow.Metadata fetches each 401 simultaneously, only ONE real sf-CLI
|
|
894
|
+
spawn should fire (not 5).
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
def _spawn_workers(self, refresh_fn, n=5, barrier=None):
|
|
898
|
+
"""Run `n` threads that each call refresh_fn once. Returns the
|
|
899
|
+
list of return values. A `threading.Barrier` is used to align the
|
|
900
|
+
thread launches — all threads hit `refresh_fn` within the same
|
|
901
|
+
monotonic window, which is the condition the dedupe optimizes for.
|
|
902
|
+
"""
|
|
903
|
+
import threading as _t
|
|
904
|
+
results: list = [None] * n
|
|
905
|
+
threads: list[_t.Thread] = []
|
|
906
|
+
|
|
907
|
+
def _worker(i):
|
|
908
|
+
if barrier is not None:
|
|
909
|
+
barrier.wait()
|
|
910
|
+
results[i] = refresh_fn()
|
|
911
|
+
|
|
912
|
+
for i in range(n):
|
|
913
|
+
threads.append(_t.Thread(target=_worker, args=(i,)))
|
|
914
|
+
for t in threads:
|
|
915
|
+
t.start()
|
|
916
|
+
for t in threads:
|
|
917
|
+
t.join()
|
|
918
|
+
return results
|
|
919
|
+
|
|
920
|
+
def test_five_threads_single_refresh_within_window(self):
|
|
921
|
+
"""5 threads concurrently call refresh_fn within the 1-second
|
|
922
|
+
dedupe window → `resolve_creds` is invoked exactly ONCE.
|
|
923
|
+
"""
|
|
924
|
+
import threading as _t
|
|
925
|
+
|
|
926
|
+
call_count = {"n": 0}
|
|
927
|
+
call_lock = _t.Lock()
|
|
928
|
+
|
|
929
|
+
def fake_resolve():
|
|
930
|
+
# Increment under a lock so a second concurrent call couldn't
|
|
931
|
+
# race and register as one (this is what we're testing AGAINST:
|
|
932
|
+
# the outer dedupe should guarantee only one entry here).
|
|
933
|
+
with call_lock:
|
|
934
|
+
call_count["n"] += 1
|
|
935
|
+
return ("https://example.my.salesforce.com", "tok_v2")
|
|
936
|
+
|
|
937
|
+
_provider, refresh, _cell = main._build_creds_plumbing(
|
|
938
|
+
("https://example.my.salesforce.com", "tok_v1"),
|
|
939
|
+
resolve_creds=fake_resolve,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
barrier = _t.Barrier(5)
|
|
943
|
+
results = self._spawn_workers(refresh, n=5, barrier=barrier)
|
|
944
|
+
|
|
945
|
+
# Dedupe held — exactly ONE real resolve spawn.
|
|
946
|
+
self.assertEqual(call_count["n"], 1)
|
|
947
|
+
# Every thread got the refreshed tuple back.
|
|
948
|
+
self.assertTrue(all(r == ("https://example.my.salesforce.com", "tok_v2") for r in results))
|
|
949
|
+
|
|
950
|
+
def test_refresh_lock_serializes_resolve_spawns(self):
|
|
951
|
+
"""If 5 threads race with a SHORT dedupe window (simulated via
|
|
952
|
+
a 0-length window), `resolve_creds` calls are serialized by the
|
|
953
|
+
lock — we observe a strict 1:1 count of entries to spawns. This
|
|
954
|
+
is the "last-writer-wins" worst case: every thread still spawns
|
|
955
|
+
once, but they're serialized, NOT overlapping. The test asserts
|
|
956
|
+
the lock DOES serialize (not that it dedupes) when the window
|
|
957
|
+
is effectively disabled.
|
|
958
|
+
"""
|
|
959
|
+
import threading as _t
|
|
960
|
+
|
|
961
|
+
active = {"n": 0, "max": 0}
|
|
962
|
+
spawns = {"n": 0}
|
|
963
|
+
state_lock = _t.Lock()
|
|
964
|
+
|
|
965
|
+
def fake_resolve():
|
|
966
|
+
with state_lock:
|
|
967
|
+
active["n"] += 1
|
|
968
|
+
active["max"] = max(active["max"], active["n"])
|
|
969
|
+
spawns["n"] += 1
|
|
970
|
+
# Hold for a short beat to let any racing worker enter if the
|
|
971
|
+
# lock weren't serializing.
|
|
972
|
+
import time as _time
|
|
973
|
+
_time.sleep(0.02)
|
|
974
|
+
with state_lock:
|
|
975
|
+
active["n"] -= 1
|
|
976
|
+
return ("url", "tok")
|
|
977
|
+
|
|
978
|
+
_provider, refresh, _cell = main._build_creds_plumbing(
|
|
979
|
+
("url", "old"),
|
|
980
|
+
resolve_creds=fake_resolve,
|
|
981
|
+
dedupe_window_s=0.0, # disable dedupe so every call spawns
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
self._spawn_workers(refresh, n=5)
|
|
985
|
+
|
|
986
|
+
# Lock serialized the spawns — no overlap inside `fake_resolve`.
|
|
987
|
+
self.assertEqual(active["max"], 1)
|
|
988
|
+
# All 5 eventually ran (no silent drops when dedupe is off).
|
|
989
|
+
self.assertEqual(spawns["n"], 5)
|
|
990
|
+
|
|
991
|
+
def test_sequential_calls_outside_window_trigger_new_refresh(self):
|
|
992
|
+
"""A refresh OUTSIDE the dedupe window is NOT suppressed. The
|
|
993
|
+
dedupe is a ceiling, not a one-shot latch.
|
|
994
|
+
"""
|
|
995
|
+
import time as _time
|
|
996
|
+
|
|
997
|
+
call_count = {"n": 0}
|
|
998
|
+
|
|
999
|
+
def fake_resolve():
|
|
1000
|
+
call_count["n"] += 1
|
|
1001
|
+
return ("url", f"tok_{call_count['n']}")
|
|
1002
|
+
|
|
1003
|
+
_provider, refresh, _cell = main._build_creds_plumbing(
|
|
1004
|
+
("url", "tok_0"),
|
|
1005
|
+
resolve_creds=fake_resolve,
|
|
1006
|
+
dedupe_window_s=0.05, # 50ms window for test latency
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
refresh()
|
|
1010
|
+
_time.sleep(0.08) # sleep past the window
|
|
1011
|
+
refresh()
|
|
1012
|
+
|
|
1013
|
+
self.assertEqual(call_count["n"], 2)
|
|
1014
|
+
|
|
1015
|
+
def test_provider_reads_cell_after_refresh(self):
|
|
1016
|
+
"""After refresh mutates the cell, the NEXT `creds_provider()`
|
|
1017
|
+
call returns the fresh tuple (the contract).
|
|
1018
|
+
"""
|
|
1019
|
+
provider, refresh, cell = main._build_creds_plumbing(
|
|
1020
|
+
("url", "old"),
|
|
1021
|
+
resolve_creds=lambda: ("url", "new"),
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
self.assertEqual(provider(), ("url", "old"))
|
|
1025
|
+
refresh()
|
|
1026
|
+
self.assertEqual(provider(), ("url", "new"))
|
|
1027
|
+
self.assertEqual(cell[0], ("url", "new"))
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
# ---------------------------------------------------------------------------
|
|
1031
|
+
# on_401_refresh is a required kwarg on every fetcher
|
|
1032
|
+
# ---------------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
class RequiredOn401RefreshKwargTests(unittest.TestCase):
|
|
1036
|
+
"""Every fetcher in `fetch_soql` must make `on_401_refresh` a REQUIRED
|
|
1037
|
+
keyword-only argument. The previous default (`on_401_refresh or
|
|
1038
|
+
creds_provider`) silently collapsed to "re-read the same stale token"
|
|
1039
|
+
when a caller passed `None` — the retry hit the same stale token on
|
|
1040
|
+
the second attempt and 401'd again, bypassing entirely.
|
|
1041
|
+
|
|
1042
|
+
We can't exercise the full retry chain here (that's Fix 5's
|
|
1043
|
+
integration test) — this test enforces the call-site contract at the
|
|
1044
|
+
signature level, so a regression is a TypeError not an auth bypass.
|
|
1045
|
+
"""
|
|
1046
|
+
|
|
1047
|
+
FETCHERS = (
|
|
1048
|
+
"fetch_planner_definition",
|
|
1049
|
+
"fetch_plugins_by_planner",
|
|
1050
|
+
"fetch_planner_bundle_functions",
|
|
1051
|
+
"fetch_functions_by_plugins",
|
|
1052
|
+
"fetch_plugin_instructions",
|
|
1053
|
+
"fetch_plugin_functions",
|
|
1054
|
+
"fetch_planner_attrs",
|
|
1055
|
+
"fetch_apex_bodies_by_names",
|
|
1056
|
+
"fetch_apex_bodies_by_ids",
|
|
1057
|
+
"fetch_flow_definition_ids_by_names",
|
|
1058
|
+
"fetch_flow_definition_view_by_durable_ids",
|
|
1059
|
+
"fetch_flow_definition_by_ids",
|
|
1060
|
+
"fetch_flow_metadata",
|
|
1061
|
+
"fetch_bot_versions",
|
|
1062
|
+
"fetch_bot_definition_details",
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
def test_every_fetcher_requires_on_401_refresh(self):
|
|
1066
|
+
"""Each fetcher raises TypeError when `on_401_refresh` is omitted.
|
|
1067
|
+
|
|
1068
|
+
`api_version` is now also required. To
|
|
1069
|
+
isolate the `on_401_refresh` contract (not conflate it with the
|
|
1070
|
+
new `api_version` contract — that's covered by
|
|
1071
|
+
`ApiVersionRequiredOnEveryFetcherTests` below), we pass a valid
|
|
1072
|
+
`api_version` and check the TypeError is specifically about the
|
|
1073
|
+
missing `on_401_refresh` — otherwise a regression that made
|
|
1074
|
+
`on_401_refresh` optional again would be masked by the
|
|
1075
|
+
`api_version` TypeError.
|
|
1076
|
+
"""
|
|
1077
|
+
import fetch_soql # type: ignore
|
|
1078
|
+
|
|
1079
|
+
# Sentinel that won't survive the call — we never reach the HTTP
|
|
1080
|
+
# layer because signature validation fires first.
|
|
1081
|
+
def _noop_provider():
|
|
1082
|
+
return ("url", "tok")
|
|
1083
|
+
|
|
1084
|
+
for name in self.FETCHERS:
|
|
1085
|
+
fn = getattr(fetch_soql, name)
|
|
1086
|
+
with self.subTest(fetcher=name):
|
|
1087
|
+
if name == "fetch_planner_definition":
|
|
1088
|
+
# Signature: (agent_api_name, version, creds_provider, *, ...)
|
|
1089
|
+
args = ("Agent", "v2", _noop_provider)
|
|
1090
|
+
elif name in {
|
|
1091
|
+
"fetch_plugins_by_planner",
|
|
1092
|
+
"fetch_planner_bundle_functions",
|
|
1093
|
+
"fetch_bot_versions", "fetch_bot_definition_details",
|
|
1094
|
+
}:
|
|
1095
|
+
args = ("Name", _noop_provider)
|
|
1096
|
+
elif name in {"fetch_functions_by_plugins",
|
|
1097
|
+
"fetch_plugin_instructions", "fetch_plugin_functions",
|
|
1098
|
+
"fetch_planner_attrs",
|
|
1099
|
+
"fetch_apex_bodies_by_names",
|
|
1100
|
+
"fetch_apex_bodies_by_ids",
|
|
1101
|
+
"fetch_flow_definition_ids_by_names",
|
|
1102
|
+
"fetch_flow_definition_view_by_durable_ids",
|
|
1103
|
+
"fetch_flow_definition_by_ids"}:
|
|
1104
|
+
args = (["Name"], _noop_provider)
|
|
1105
|
+
elif name == "fetch_flow_metadata":
|
|
1106
|
+
args = ("301VF000000xyz", _noop_provider)
|
|
1107
|
+
else:
|
|
1108
|
+
self.fail(f"unmapped fetcher {name}")
|
|
1109
|
+
|
|
1110
|
+
with self.assertRaises(TypeError) as ctx:
|
|
1111
|
+
# Pass api_version so the TypeError pinpoints
|
|
1112
|
+
# on_401_refresh specifically.
|
|
1113
|
+
fn(*args, api_version="v60.0")
|
|
1114
|
+
self.assertIn(
|
|
1115
|
+
"on_401_refresh", str(ctx.exception),
|
|
1116
|
+
msg=f"{name} must name on_401_refresh in its TypeError",
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
def test_every_fetcher_requires_api_version(self):
|
|
1120
|
+
"""every fetcher must require the
|
|
1121
|
+
`api_version` kwarg. Symmetric to the `on_401_refresh` contract.
|
|
1122
|
+
|
|
1123
|
+
Omitting `api_version` must be a TypeError at call time, not a
|
|
1124
|
+
silent regression back to the old hardcoded `v60.0` floor.
|
|
1125
|
+
"""
|
|
1126
|
+
import fetch_soql # type: ignore
|
|
1127
|
+
|
|
1128
|
+
def _noop_provider():
|
|
1129
|
+
return ("url", "tok")
|
|
1130
|
+
|
|
1131
|
+
def _noop_refresh():
|
|
1132
|
+
return ("url", "tok")
|
|
1133
|
+
|
|
1134
|
+
for name in self.FETCHERS:
|
|
1135
|
+
fn = getattr(fetch_soql, name)
|
|
1136
|
+
with self.subTest(fetcher=name):
|
|
1137
|
+
if name == "fetch_planner_definition":
|
|
1138
|
+
args = ("Agent", "v2", _noop_provider)
|
|
1139
|
+
elif name in {
|
|
1140
|
+
"fetch_plugins_by_planner",
|
|
1141
|
+
"fetch_planner_bundle_functions",
|
|
1142
|
+
"fetch_bot_versions", "fetch_bot_definition_details",
|
|
1143
|
+
}:
|
|
1144
|
+
args = ("Name", _noop_provider)
|
|
1145
|
+
elif name in {"fetch_functions_by_plugins",
|
|
1146
|
+
"fetch_plugin_instructions", "fetch_plugin_functions",
|
|
1147
|
+
"fetch_planner_attrs",
|
|
1148
|
+
"fetch_apex_bodies_by_names",
|
|
1149
|
+
"fetch_apex_bodies_by_ids",
|
|
1150
|
+
"fetch_flow_definition_ids_by_names",
|
|
1151
|
+
"fetch_flow_definition_view_by_durable_ids",
|
|
1152
|
+
"fetch_flow_definition_by_ids"}:
|
|
1153
|
+
args = (["Name"], _noop_provider)
|
|
1154
|
+
elif name == "fetch_flow_metadata":
|
|
1155
|
+
args = ("301VF000000xyz", _noop_provider)
|
|
1156
|
+
else:
|
|
1157
|
+
self.fail(f"unmapped fetcher {name}")
|
|
1158
|
+
|
|
1159
|
+
with self.assertRaises(TypeError) as ctx:
|
|
1160
|
+
# Pass on_401_refresh so the TypeError pinpoints
|
|
1161
|
+
# api_version specifically.
|
|
1162
|
+
fn(*args, on_401_refresh=_noop_refresh)
|
|
1163
|
+
self.assertIn(
|
|
1164
|
+
"api_version", str(ctx.exception),
|
|
1165
|
+
msg=f"{name} must name api_version in its TypeError",
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
def test_fetcher_with_explicit_refresh_does_not_raise_typeerror(self):
|
|
1169
|
+
"""Control: passing `on_401_refresh=<callable>` + `api_version=...`
|
|
1170
|
+
bypasses the signature guard and the call reaches inner logic
|
|
1171
|
+
(mocked here). Any non-TypeError outcome is acceptable — we're
|
|
1172
|
+
only asserting the signature is satisfied.
|
|
1173
|
+
|
|
1174
|
+
`api_version` is now a sibling required
|
|
1175
|
+
kwarg to `on_401_refresh`; both must be supplied.
|
|
1176
|
+
"""
|
|
1177
|
+
import fetch_soql # type: ignore
|
|
1178
|
+
|
|
1179
|
+
# Empty-list short-circuit → returns [] without firing a SOQL call.
|
|
1180
|
+
out = fetch_soql.fetch_plugin_instructions(
|
|
1181
|
+
[],
|
|
1182
|
+
lambda: ("url", "tok"),
|
|
1183
|
+
api_version="v60.0",
|
|
1184
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
1185
|
+
)
|
|
1186
|
+
self.assertEqual(out, [])
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
# ---------------------------------------------------------------------------
|
|
1190
|
+
# atomic finalize via staging-sibling swap
|
|
1191
|
+
# ---------------------------------------------------------------------------
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
class AtomicFinalizeTests(unittest.TestCase):
|
|
1195
|
+
"""`_run_finalize` must never leave `data_dir` in an empty / missing
|
|
1196
|
+
state. The prior `shutil.rmtree; rename` pattern opened a window
|
|
1197
|
+
where a crash or a concurrent reader saw an empty path. The new
|
|
1198
|
+
`_swap_dir_atomic` uses staging-siblings + `os.replace` so:
|
|
1199
|
+
* on success, `data_dir` transitions atomically from OLD → NEW
|
|
1200
|
+
* on a mid-swap crash, the backup sibling is restored to `data_dir`
|
|
1201
|
+
"""
|
|
1202
|
+
|
|
1203
|
+
def _make_tree(self) -> dict:
|
|
1204
|
+
return {
|
|
1205
|
+
"_schema_version": "3.0",
|
|
1206
|
+
"agent": {"api_name": "A", "version": "v1"},
|
|
1207
|
+
"root": {"kind": "BOT_DEFINITION", "api_name": "A", "children": []},
|
|
1208
|
+
"node_count": 1, "depth": 0,
|
|
1209
|
+
"_kind_counts": {"BOT_DEFINITION": 1},
|
|
1210
|
+
"_pending_fetches": {k: [] for k in ("FLOW", "APEX", "PROMPT_TEMPLATE", "STANDARD_ACTION")},
|
|
1211
|
+
"_unresolved": [],
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
def test_happy_path_swaps_atomically(self):
|
|
1215
|
+
"""Finalize runs cleanly: data_dir + cache_dir end up populated
|
|
1216
|
+
with the fresh tree/manifest, staging/backup siblings are gone.
|
|
1217
|
+
"""
|
|
1218
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1219
|
+
tmp_p = Path(tmp)
|
|
1220
|
+
work_dir = tmp_p / "work"
|
|
1221
|
+
work_dir.mkdir()
|
|
1222
|
+
data_dir = tmp_p / "data" / "A__v1"
|
|
1223
|
+
cache_dir = tmp_p / "cache" / "A__v1"
|
|
1224
|
+
|
|
1225
|
+
tree = self._make_tree()
|
|
1226
|
+
main._run_finalize(
|
|
1227
|
+
data_dir, cache_dir, tree, work_dir,
|
|
1228
|
+
agent_api_name="A", agent_version="v1", planner_name="A",
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
self.assertTrue(data_dir.is_dir())
|
|
1232
|
+
self.assertTrue((data_dir / "A_v1_metadata_tree.json").is_file())
|
|
1233
|
+
self.assertTrue((cache_dir / "manifest.json").is_file())
|
|
1234
|
+
|
|
1235
|
+
# No staging / backup siblings left behind.
|
|
1236
|
+
siblings = list(data_dir.parent.iterdir()) + list(cache_dir.parent.iterdir())
|
|
1237
|
+
for s in siblings:
|
|
1238
|
+
self.assertFalse(
|
|
1239
|
+
s.name.startswith(".") and ("staging" in s.name or "backup" in s.name),
|
|
1240
|
+
f"leftover staging/backup: {s}",
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
def test_existing_dir_replaced_not_emptied_midswap(self):
|
|
1244
|
+
"""Run finalize TWICE. The second run must swap atomically — at
|
|
1245
|
+
no observable moment does `data_dir` exist and contain zero
|
|
1246
|
+
files. We verify by inspecting post-run state (the swap is
|
|
1247
|
+
atomic, so the invariant reduces to "data_dir is populated with
|
|
1248
|
+
NEW artifacts after the call returns").
|
|
1249
|
+
"""
|
|
1250
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1251
|
+
tmp_p = Path(tmp)
|
|
1252
|
+
work_dir = tmp_p / "work"
|
|
1253
|
+
work_dir.mkdir()
|
|
1254
|
+
data_dir = tmp_p / "data" / "A__v1"
|
|
1255
|
+
cache_dir = tmp_p / "cache" / "A__v1"
|
|
1256
|
+
|
|
1257
|
+
# First run
|
|
1258
|
+
main._run_finalize(
|
|
1259
|
+
data_dir, cache_dir, self._make_tree(), work_dir,
|
|
1260
|
+
agent_api_name="A", agent_version="v1", planner_name="A",
|
|
1261
|
+
)
|
|
1262
|
+
self.assertTrue((data_dir / "A_v1_metadata_tree.json").is_file())
|
|
1263
|
+
|
|
1264
|
+
# Second run — different node_count so we can confirm the
|
|
1265
|
+
# replacement took effect (not a stale file).
|
|
1266
|
+
tree2 = self._make_tree()
|
|
1267
|
+
tree2["node_count"] = 42
|
|
1268
|
+
main._run_finalize(
|
|
1269
|
+
data_dir, cache_dir, tree2, work_dir,
|
|
1270
|
+
agent_api_name="A", agent_version="v1", planner_name="A",
|
|
1271
|
+
)
|
|
1272
|
+
tree_out = json.loads((data_dir / "A_v1_metadata_tree.json").read_text())
|
|
1273
|
+
self.assertEqual(tree_out["node_count"], 42)
|
|
1274
|
+
|
|
1275
|
+
def test_crash_during_final_swap_restores_original(self):
|
|
1276
|
+
"""Inject an `os.replace` failure on the SECOND rename of the
|
|
1277
|
+
data_dir swap (staging → target). The function must:
|
|
1278
|
+
* raise the underlying OSError (we assert)
|
|
1279
|
+
* leave `data_dir` populated with the ORIGINAL contents
|
|
1280
|
+
(backup was restored)
|
|
1281
|
+
|
|
1282
|
+
We patch `main._swap_dir_atomic` internals via patching
|
|
1283
|
+
`os.replace` at the main-module's namespace with a side_effect
|
|
1284
|
+
that fails the second-target replace only.
|
|
1285
|
+
"""
|
|
1286
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1287
|
+
tmp_p = Path(tmp)
|
|
1288
|
+
work_dir = tmp_p / "work"
|
|
1289
|
+
work_dir.mkdir()
|
|
1290
|
+
data_dir = tmp_p / "data" / "A__v1"
|
|
1291
|
+
cache_dir = tmp_p / "cache" / "A__v1"
|
|
1292
|
+
|
|
1293
|
+
# Prime data_dir with sentinel content to prove it survives.
|
|
1294
|
+
data_dir.mkdir(parents=True)
|
|
1295
|
+
(data_dir / "sentinel.txt").write_text("original-data")
|
|
1296
|
+
|
|
1297
|
+
real_replace = os.replace
|
|
1298
|
+
call_seq = {"n": 0}
|
|
1299
|
+
|
|
1300
|
+
def fail_on_staging_to_target(src, dst):
|
|
1301
|
+
"""First replace: target → backup (must succeed, allows
|
|
1302
|
+
the function to proceed to the risky step). Second
|
|
1303
|
+
replace: staging → target (we force this to fail). The
|
|
1304
|
+
function should catch that, restore backup → target, and
|
|
1305
|
+
reraise."""
|
|
1306
|
+
call_seq["n"] += 1
|
|
1307
|
+
# Let the "target → backup" rename succeed (n==1).
|
|
1308
|
+
# Also let the recovery "backup → target" rename succeed
|
|
1309
|
+
# (fires AFTER we raise below). Fail only on n==2 (the
|
|
1310
|
+
# "staging → target" rename).
|
|
1311
|
+
if call_seq["n"] == 2:
|
|
1312
|
+
raise OSError("synthetic swap failure")
|
|
1313
|
+
return real_replace(src, dst)
|
|
1314
|
+
|
|
1315
|
+
with mock.patch.object(main.os, "replace", side_effect=fail_on_staging_to_target):
|
|
1316
|
+
with self.assertRaises(OSError):
|
|
1317
|
+
main._run_finalize(
|
|
1318
|
+
data_dir, cache_dir, self._make_tree(), work_dir,
|
|
1319
|
+
agent_api_name="A", agent_version="v1", planner_name="A",
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
# Invariant: data_dir still exists AND still contains the
|
|
1323
|
+
# original sentinel (the backup was restored).
|
|
1324
|
+
self.assertTrue(data_dir.is_dir())
|
|
1325
|
+
self.assertTrue((data_dir / "sentinel.txt").is_file())
|
|
1326
|
+
self.assertEqual((data_dir / "sentinel.txt").read_text(), "original-data")
|
|
1327
|
+
|
|
1328
|
+
def test_leftover_staging_from_prior_crash_is_cleared(self):
|
|
1329
|
+
"""If a previous crashed run left a `.<name>.staging.<pid>`
|
|
1330
|
+
sibling behind, the next finalize blows it away before staging
|
|
1331
|
+
its own writes. Otherwise `mkdir(parents=True)` would raise
|
|
1332
|
+
FileExistsError.
|
|
1333
|
+
"""
|
|
1334
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1335
|
+
tmp_p = Path(tmp)
|
|
1336
|
+
work_dir = tmp_p / "work"
|
|
1337
|
+
work_dir.mkdir()
|
|
1338
|
+
data_dir = tmp_p / "data" / "A__v1"
|
|
1339
|
+
cache_dir = tmp_p / "cache" / "A__v1"
|
|
1340
|
+
|
|
1341
|
+
# Synthesize a leftover staging dir for THIS pid.
|
|
1342
|
+
stale_staging = data_dir.parent / f".{data_dir.name}.staging.{os.getpid()}"
|
|
1343
|
+
stale_staging.mkdir(parents=True)
|
|
1344
|
+
(stale_staging / "stale.txt").write_text("junk")
|
|
1345
|
+
|
|
1346
|
+
main._run_finalize(
|
|
1347
|
+
data_dir, cache_dir, self._make_tree(), work_dir,
|
|
1348
|
+
agent_api_name="A", agent_version="v1", planner_name="A",
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
# Stale staging is gone (replaced with a successful swap).
|
|
1352
|
+
self.assertFalse(stale_staging.exists())
|
|
1353
|
+
# And data_dir has the fresh tree.
|
|
1354
|
+
self.assertTrue((data_dir / "A_v1_metadata_tree.json").is_file())
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
# ---------------------------------------------------------------------------
|
|
1358
|
+
# real 401 retry carries the new token
|
|
1359
|
+
# ---------------------------------------------------------------------------
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
class Real401RetryIntegrationTests(unittest.TestCase):
|
|
1363
|
+
"""The existing `Refresh401Tests` mocks at the `tooling_query`
|
|
1364
|
+
boundary — it bypasses the decorator stack and can't verify the
|
|
1365
|
+
retry actually carries the NEW token into the second request.
|
|
1366
|
+
|
|
1367
|
+
This test mocks at the lowest-level `build_opener` seam in
|
|
1368
|
+
`rest_client`. A mock `OpenerDirector.open` inspects the
|
|
1369
|
+
Authorization header on each call:
|
|
1370
|
+
* call 1: Bearer old_token → raise HTTPError(401)
|
|
1371
|
+
* refresh_fn fires (via the real retry_on_401 decorator)
|
|
1372
|
+
* call 2: Bearer new_token → return a valid response
|
|
1373
|
+
|
|
1374
|
+
If the contract regressed — e.g. the retry re-used the stale
|
|
1375
|
+
token — call 2 would still carry Bearer old_token and the test
|
|
1376
|
+
would fail.
|
|
1377
|
+
"""
|
|
1378
|
+
|
|
1379
|
+
def test_retry_carries_new_token_after_401(self):
|
|
1380
|
+
import io
|
|
1381
|
+
import urllib.error
|
|
1382
|
+
import fetch_soql # type: ignore
|
|
1383
|
+
import rest_client # type: ignore
|
|
1384
|
+
|
|
1385
|
+
observed_auth_headers: list[str] = []
|
|
1386
|
+
|
|
1387
|
+
def _make_401_response() -> urllib.error.HTTPError:
|
|
1388
|
+
# HTTPError tolerates hdrs=None; retry_on_401 does not touch
|
|
1389
|
+
# .headers on a 401 (only the 403-path reads .read()).
|
|
1390
|
+
return urllib.error.HTTPError(
|
|
1391
|
+
url="https://example.my.salesforce.com/services/data/v60.0/tooling/query/",
|
|
1392
|
+
code=401,
|
|
1393
|
+
msg="Unauthorized",
|
|
1394
|
+
hdrs=None,
|
|
1395
|
+
fp=io.BytesIO(b"INVALID_SESSION_ID"),
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
class _FakeResp:
|
|
1399
|
+
"""Context-manager compatible fake response returning JSON."""
|
|
1400
|
+
|
|
1401
|
+
def __init__(self, body_bytes: bytes):
|
|
1402
|
+
self._body = body_bytes
|
|
1403
|
+
|
|
1404
|
+
def __enter__(self):
|
|
1405
|
+
return self
|
|
1406
|
+
|
|
1407
|
+
def __exit__(self, *a):
|
|
1408
|
+
return False
|
|
1409
|
+
|
|
1410
|
+
def read(self):
|
|
1411
|
+
return self._body
|
|
1412
|
+
|
|
1413
|
+
class _RecordingOpener:
|
|
1414
|
+
def __init__(self):
|
|
1415
|
+
self.n = 0
|
|
1416
|
+
|
|
1417
|
+
def open(self, req):
|
|
1418
|
+
# Record the Authorization header so the test can assert
|
|
1419
|
+
# the new token landed on the retry request.
|
|
1420
|
+
auth = req.headers.get("Authorization") or req.get_header("Authorization")
|
|
1421
|
+
observed_auth_headers.append(auth or "")
|
|
1422
|
+
self.n += 1
|
|
1423
|
+
if self.n == 1:
|
|
1424
|
+
raise _make_401_response()
|
|
1425
|
+
# Retry: return a valid Tooling query response.
|
|
1426
|
+
return _FakeResp(b'{"records":[{"Id":"001","DeveloperName":"X"}]}')
|
|
1427
|
+
|
|
1428
|
+
# Build creds plumbing that swaps tokens on refresh.
|
|
1429
|
+
provider, refresh, _cell = main._build_creds_plumbing(
|
|
1430
|
+
("https://example.my.salesforce.com", "old_token"),
|
|
1431
|
+
resolve_creds=lambda: ("https://example.my.salesforce.com", "new_token"),
|
|
1432
|
+
dedupe_window_s=0.0, # allow the refresh to fire unconditionally
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
opener = _RecordingOpener()
|
|
1436
|
+
with mock.patch.object(rest_client, "build_opener", return_value=opener):
|
|
1437
|
+
# Use a fetcher that triggers one Tooling query. v2+ branch
|
|
1438
|
+
# goes through `load_soql` + the chain template — exercises the
|
|
1439
|
+
# same tooling_query seam the test is here to verify.
|
|
1440
|
+
result = fetch_soql.fetch_planner_definition(
|
|
1441
|
+
"SomePlanner", "v2", provider,
|
|
1442
|
+
api_version="v60.0", on_401_refresh=refresh,
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
# Contract: both requests were made; first with old_token,
|
|
1446
|
+
# second with new_token (the post-refresh value).
|
|
1447
|
+
self.assertEqual(len(observed_auth_headers), 2)
|
|
1448
|
+
self.assertEqual(observed_auth_headers[0], "Bearer old_token")
|
|
1449
|
+
self.assertEqual(observed_auth_headers[1], "Bearer new_token")
|
|
1450
|
+
# And the retry response shaped correctly.
|
|
1451
|
+
self.assertEqual(result.get("DeveloperName"), "X")
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
class ApiVersionEndToEndTests(unittest.TestCase):
|
|
1455
|
+
"""the `api_version` reported by `sf org display --json`
|
|
1456
|
+
threads through the pipeline all the way to the REST query URL.
|
|
1457
|
+
|
|
1458
|
+
Orgs on v66 must NOT hit `/services/data/v60.0/...` — that was
|
|
1459
|
+
. This test drives the full pipeline with a mocked
|
|
1460
|
+
org-display payload reporting `apiVersion=66.0` and asserts every
|
|
1461
|
+
Tooling/Data query fetcher receives `api_version="v66.0"`.
|
|
1462
|
+
"""
|
|
1463
|
+
|
|
1464
|
+
def test_full_pipeline_passes_v66_to_every_fetcher(self):
|
|
1465
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1466
|
+
work_dir = Path(tmp) / "work"
|
|
1467
|
+
data_root = Path(tmp) / "data"
|
|
1468
|
+
cache_root = Path(tmp) / "cache"
|
|
1469
|
+
|
|
1470
|
+
# sf org display payload reports v66 — this is the shape real
|
|
1471
|
+
# orgs return today (my-org-alias, my-perf-org-alias).
|
|
1472
|
+
org_display_payload = {
|
|
1473
|
+
"status": 0,
|
|
1474
|
+
"result": {
|
|
1475
|
+
"instanceUrl": "https://example.my.salesforce.com",
|
|
1476
|
+
"accessToken": "00Dxx0000000000!AQ_fake_token_value",
|
|
1477
|
+
"id": "00Dxx0000000000AAA",
|
|
1478
|
+
"apiVersion": "66.0",
|
|
1479
|
+
},
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
# Capture api_version passed to each fetcher via mock.call_args.
|
|
1483
|
+
wave_a_patches = [
|
|
1484
|
+
mock.patch.object(main, "fetch_planner_definition",
|
|
1485
|
+
return_value=fx.CLASSIC_PLANNER),
|
|
1486
|
+
mock.patch.object(main, "fetch_plugins_by_planner",
|
|
1487
|
+
return_value=fx.CLASSIC_PLUGINS),
|
|
1488
|
+
mock.patch.object(main, "fetch_planner_bundle_functions",
|
|
1489
|
+
return_value=fx.CLASSIC_BUNDLE_FN_JOIN),
|
|
1490
|
+
mock.patch.object(main, "fetch_functions_by_plugins",
|
|
1491
|
+
return_value=fx.CLASSIC_FUNCTIONS),
|
|
1492
|
+
mock.patch.object(main, "fetch_plugin_instructions",
|
|
1493
|
+
return_value=fx.CLASSIC_INSTRUCTIONS),
|
|
1494
|
+
mock.patch.object(main, "fetch_plugin_functions",
|
|
1495
|
+
return_value=fx.CLASSIC_PLUGIN_FUNCTIONS),
|
|
1496
|
+
mock.patch.object(main, "fetch_planner_attrs",
|
|
1497
|
+
return_value=fx.CLASSIC_ATTRS),
|
|
1498
|
+
]
|
|
1499
|
+
wave_b_patches = [
|
|
1500
|
+
mock.patch.object(main, "fetch_apex_bodies_by_names",
|
|
1501
|
+
return_value=fx.CLASSIC_APEX_ROWS),
|
|
1502
|
+
mock.patch.object(main, "fetch_apex_bodies_by_ids",
|
|
1503
|
+
return_value=[]),
|
|
1504
|
+
mock.patch.object(main, "fetch_flow_definition_ids_by_names",
|
|
1505
|
+
return_value=fx.CLASSIC_FLOW_DEFS),
|
|
1506
|
+
mock.patch.object(main, "fetch_flow_definition_by_ids",
|
|
1507
|
+
return_value=[]),
|
|
1508
|
+
mock.patch.object(
|
|
1509
|
+
main, "fetch_flow_metadata",
|
|
1510
|
+
side_effect=lambda vid, *a, **kw: fx.CLASSIC_FLOW_METADATA.get(vid),
|
|
1511
|
+
),
|
|
1512
|
+
]
|
|
1513
|
+
patches = [
|
|
1514
|
+
mock.patch.object(main, "run_sf", return_value=org_display_payload),
|
|
1515
|
+
mock.patch.object(main, "probe_channels",
|
|
1516
|
+
return_value=fx.probe_ok_payload()),
|
|
1517
|
+
*_mock_bot_resolution(),
|
|
1518
|
+
*wave_a_patches,
|
|
1519
|
+
*wave_b_patches,
|
|
1520
|
+
mock.patch.object(main, "build_agent_data_dir",
|
|
1521
|
+
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
1522
|
+
mock.patch.object(main, "build_agent_cache_dir",
|
|
1523
|
+
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
1524
|
+
]
|
|
1525
|
+
_apply_all(patches)
|
|
1526
|
+
try:
|
|
1527
|
+
rc = main.main(_args(work_dir))
|
|
1528
|
+
# Snapshot call lists BEFORE `p.stop()` restores the
|
|
1529
|
+
# originals — otherwise `main.fetch_*` drops back to the
|
|
1530
|
+
# real function with no `.call_args_list`.
|
|
1531
|
+
fetcher_call_lists = {
|
|
1532
|
+
name: getattr(main, name).call_args_list
|
|
1533
|
+
for name in (
|
|
1534
|
+
"fetch_planner_definition",
|
|
1535
|
+
"fetch_plugins_by_planner",
|
|
1536
|
+
"fetch_planner_bundle_functions",
|
|
1537
|
+
"fetch_functions_by_plugins",
|
|
1538
|
+
"fetch_plugin_instructions",
|
|
1539
|
+
"fetch_plugin_functions",
|
|
1540
|
+
"fetch_planner_attrs",
|
|
1541
|
+
"fetch_apex_bodies_by_names",
|
|
1542
|
+
"fetch_flow_definition_ids_by_names",
|
|
1543
|
+
"fetch_flow_metadata",
|
|
1544
|
+
"fetch_bot_versions",
|
|
1545
|
+
"fetch_bot_definition_details",
|
|
1546
|
+
)
|
|
1547
|
+
}
|
|
1548
|
+
finally:
|
|
1549
|
+
for p in patches:
|
|
1550
|
+
p.stop()
|
|
1551
|
+
|
|
1552
|
+
self.assertEqual(rc, 0)
|
|
1553
|
+
|
|
1554
|
+
# Every Wave-A + Wave-B fetcher received `api_version="v66.0"`.
|
|
1555
|
+
# We inspect call_args.kwargs rather than positional args — the
|
|
1556
|
+
# kwarg is keyword-only by design.
|
|
1557
|
+
expected = "v66.0"
|
|
1558
|
+
for name, calls in fetcher_call_lists.items():
|
|
1559
|
+
calls_with_version = [
|
|
1560
|
+
c for c in calls
|
|
1561
|
+
if c.kwargs.get("api_version") == expected
|
|
1562
|
+
]
|
|
1563
|
+
self.assertTrue(
|
|
1564
|
+
calls_with_version,
|
|
1565
|
+
f"{name}: no call observed with api_version={expected!r}. "
|
|
1566
|
+
f"All calls: {calls}",
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
class UncaughtExceptionToResultBlockTests(unittest.TestCase):
|
|
1571
|
+
"""the skill contract says every exit path emits a RESULT
|
|
1572
|
+
block. Before the fix, an uncaught exception in the pipeline (e.g.
|
|
1573
|
+
the HTTP 405 from , or any future bug) propagated to the
|
|
1574
|
+
process boundary — users saw a Python traceback on stderr and the
|
|
1575
|
+
wrapper skill never got `.emit_ctx.json`. `main()` now wraps
|
|
1576
|
+
`_run_pipeline` in `try/except Exception` and funnels failures
|
|
1577
|
+
through `_emit_fail(..., "RETRIEVE_FAILED", "uncaught-exception: ...")`.
|
|
1578
|
+
"""
|
|
1579
|
+
|
|
1580
|
+
def test_uncaught_exception_surfaces_as_retrieve_failed(self):
|
|
1581
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1582
|
+
work_dir = Path(tmp) / "work"
|
|
1583
|
+
|
|
1584
|
+
# Patch `_run_pipeline` to raise an arbitrary non-HTTP bug
|
|
1585
|
+
# shape — proves the wrapper catches broad `Exception`, not
|
|
1586
|
+
# just the specific HTTPError from .
|
|
1587
|
+
with mock.patch.object(
|
|
1588
|
+
main, "_run_pipeline",
|
|
1589
|
+
side_effect=RuntimeError("unexpected defect in phase 6"),
|
|
1590
|
+
):
|
|
1591
|
+
rc = main.main(_args(work_dir))
|
|
1592
|
+
|
|
1593
|
+
# Exit code MUST be non-zero (failure signal) but controlled —
|
|
1594
|
+
# no Python traceback on stderr.
|
|
1595
|
+
self.assertEqual(rc, 1)
|
|
1596
|
+
|
|
1597
|
+
# `.emit_ctx.json` MUST exist — the skill contract.
|
|
1598
|
+
ctx_path = work_dir / ".emit_ctx.json"
|
|
1599
|
+
self.assertTrue(ctx_path.is_file(),
|
|
1600
|
+
"uncaught exception must still write .emit_ctx.json")
|
|
1601
|
+
ctx = _read_ctx(work_dir)
|
|
1602
|
+
self.assertEqual(ctx["status"], "RETRIEVE_FAILED")
|
|
1603
|
+
self.assertIn("uncaught-exception", ctx["error_detail"])
|
|
1604
|
+
self.assertIn("RuntimeError", ctx["error_detail"])
|
|
1605
|
+
self.assertIn("unexpected defect in phase 6", ctx["error_detail"])
|
|
1606
|
+
|
|
1607
|
+
def test_uncaught_exception_redacts_bearer_tokens(self):
|
|
1608
|
+
"""the redacted error_detail must not leak tokens even if
|
|
1609
|
+
the exception message happens to carry one."""
|
|
1610
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1611
|
+
work_dir = Path(tmp) / "work"
|
|
1612
|
+
|
|
1613
|
+
leaky = RuntimeError(
|
|
1614
|
+
"downstream failed: Authorization: Bearer TESTONLY_LEAKY_TOKEN"
|
|
1615
|
+
)
|
|
1616
|
+
with mock.patch.object(main, "_run_pipeline", side_effect=leaky):
|
|
1617
|
+
rc = main.main(_args(work_dir))
|
|
1618
|
+
|
|
1619
|
+
self.assertEqual(rc, 1)
|
|
1620
|
+
ctx = _read_ctx(work_dir)
|
|
1621
|
+
self.assertEqual(ctx["status"], "RETRIEVE_FAILED")
|
|
1622
|
+
# Token must NOT appear.
|
|
1623
|
+
self.assertNotIn("TESTONLY_LEAKY_TOKEN", ctx["error_detail"])
|
|
1624
|
+
# Redaction sentinel must appear — proof the scrub ran.
|
|
1625
|
+
self.assertIn("<redacted>", ctx["error_detail"])
|
|
1626
|
+
|
|
1627
|
+
def test_systemexit_still_propagates(self):
|
|
1628
|
+
"""argparse's --help path raises SystemExit. That MUST propagate
|
|
1629
|
+
unchanged — catching it would silently swallow `--help` + any
|
|
1630
|
+
other intentional early exit. The wrapper catches `Exception`,
|
|
1631
|
+
not `BaseException`, so SystemExit is unaffected."""
|
|
1632
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1633
|
+
work_dir = Path(tmp) / "work"
|
|
1634
|
+
|
|
1635
|
+
with mock.patch.object(main, "_run_pipeline",
|
|
1636
|
+
side_effect=SystemExit(2)):
|
|
1637
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
1638
|
+
main.main(_args(work_dir))
|
|
1639
|
+
self.assertEqual(ctx.exception.code, 2)
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
class NormalizeFlowIdTargetsTests(unittest.TestCase):
|
|
1643
|
+
"""classic bots occasionally store NGA-style
|
|
1644
|
+
300Uv-prefix FlowDefinition IDs or 301-prefix Flow version IDs as
|
|
1645
|
+
InvocationTarget. After Wave B resolves them we rewrite bundle_parsed
|
|
1646
|
+
in place so parse_wave sees DeveloperNames and the pending/visited
|
|
1647
|
+
diff collapses to zero for legitimate targets."""
|
|
1648
|
+
|
|
1649
|
+
def test_rewrites_flowdefinition_id_to_developer_name(self):
|
|
1650
|
+
bundle = {
|
|
1651
|
+
"topics": [{
|
|
1652
|
+
"name": "T",
|
|
1653
|
+
"actions": [{
|
|
1654
|
+
"name": "A",
|
|
1655
|
+
"invocationTarget": "300UvXXXXXXXXXXXX",
|
|
1656
|
+
"invocationTargetType": "flow",
|
|
1657
|
+
}],
|
|
1658
|
+
}],
|
|
1659
|
+
"plannerActions": [],
|
|
1660
|
+
}
|
|
1661
|
+
flow_def_rows = [{
|
|
1662
|
+
"Id": "300UvXXXXXXXXXXXX",
|
|
1663
|
+
"DeveloperName": "MyFlowName",
|
|
1664
|
+
"ActiveVersionId": "301VfYYYYYYYYYYYY",
|
|
1665
|
+
}]
|
|
1666
|
+
main._normalize_flow_id_targets(bundle, flow_def_rows)
|
|
1667
|
+
action = bundle["topics"][0]["actions"][0]
|
|
1668
|
+
self.assertEqual(action["invocationTarget"], "MyFlowName")
|
|
1669
|
+
self.assertEqual(action["_original_invocation_target_id"],
|
|
1670
|
+
"300UvXXXXXXXXXXXX")
|
|
1671
|
+
|
|
1672
|
+
def test_rewrites_flow_version_id_via_active_version_map(self):
|
|
1673
|
+
"""301-prefix IDs should also resolve — via the ActiveVersionId
|
|
1674
|
+
side of the bi-directional map."""
|
|
1675
|
+
bundle = {
|
|
1676
|
+
"topics": [{
|
|
1677
|
+
"actions": [{
|
|
1678
|
+
"invocationTarget": "301VfYYYYYYYYYYYY",
|
|
1679
|
+
"invocationTargetType": "flow",
|
|
1680
|
+
}],
|
|
1681
|
+
}],
|
|
1682
|
+
"plannerActions": [],
|
|
1683
|
+
}
|
|
1684
|
+
flow_def_rows = [{
|
|
1685
|
+
"Id": "300UvXXXXXXXXXXXX",
|
|
1686
|
+
"DeveloperName": "MyFlowName",
|
|
1687
|
+
"ActiveVersionId": "301VfYYYYYYYYYYYY",
|
|
1688
|
+
}]
|
|
1689
|
+
main._normalize_flow_id_targets(bundle, flow_def_rows)
|
|
1690
|
+
self.assertEqual(
|
|
1691
|
+
bundle["topics"][0]["actions"][0]["invocationTarget"],
|
|
1692
|
+
"MyFlowName",
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
def test_preserves_classic_developer_name_targets(self):
|
|
1696
|
+
"""Classic DeveloperName targets must pass through untouched —
|
|
1697
|
+
they have nothing to resolve AND shouldn't pick up the sentinel
|
|
1698
|
+
_original_invocation_target_id field."""
|
|
1699
|
+
bundle = {
|
|
1700
|
+
"topics": [{
|
|
1701
|
+
"actions": [{
|
|
1702
|
+
"invocationTarget": "AGNT_Case_Create",
|
|
1703
|
+
"invocationTargetType": "flow",
|
|
1704
|
+
}],
|
|
1705
|
+
}],
|
|
1706
|
+
"plannerActions": [],
|
|
1707
|
+
}
|
|
1708
|
+
flow_def_rows = [{
|
|
1709
|
+
"Id": "300UvXXXXXXXXXXXX",
|
|
1710
|
+
"DeveloperName": "AGNT_Case_Create",
|
|
1711
|
+
"ActiveVersionId": "301VfYYYYYYYYYYYY",
|
|
1712
|
+
}]
|
|
1713
|
+
main._normalize_flow_id_targets(bundle, flow_def_rows)
|
|
1714
|
+
action = bundle["topics"][0]["actions"][0]
|
|
1715
|
+
self.assertEqual(action["invocationTarget"], "AGNT_Case_Create")
|
|
1716
|
+
self.assertNotIn("_original_invocation_target_id", action)
|
|
1717
|
+
|
|
1718
|
+
def test_unmatched_id_stays_as_is(self):
|
|
1719
|
+
"""An ID that doesn't appear in flow_def_rows (Flow not queryable
|
|
1720
|
+
/ managed package invisible) stays unchanged — that's how it
|
|
1721
|
+
correctly surfaces in _pending_fetches instead of silently
|
|
1722
|
+
discarding."""
|
|
1723
|
+
bundle = {
|
|
1724
|
+
"topics": [{
|
|
1725
|
+
"actions": [{
|
|
1726
|
+
"invocationTarget": "300UvZZZZZZZZZZZZ",
|
|
1727
|
+
"invocationTargetType": "flow",
|
|
1728
|
+
}],
|
|
1729
|
+
}],
|
|
1730
|
+
"plannerActions": [],
|
|
1731
|
+
}
|
|
1732
|
+
main._normalize_flow_id_targets(bundle, [{
|
|
1733
|
+
"Id": "300UvXXXXXXXXXXXX",
|
|
1734
|
+
"DeveloperName": "DifferentFlow",
|
|
1735
|
+
"ActiveVersionId": "301VfYYYYYYYYYYYY",
|
|
1736
|
+
}])
|
|
1737
|
+
self.assertEqual(
|
|
1738
|
+
bundle["topics"][0]["actions"][0]["invocationTarget"],
|
|
1739
|
+
"300UvZZZZZZZZZZZZ",
|
|
1740
|
+
)
|
|
1741
|
+
|
|
1742
|
+
def test_apex_target_with_300_prefix_not_rewritten(self):
|
|
1743
|
+
"""Only invocationTargetType=='flow' is rewritten. A target that
|
|
1744
|
+
happens to match a flow id but is declared as apex stays put —
|
|
1745
|
+
it's a caller-error we surface, not one we silently rewrite."""
|
|
1746
|
+
bundle = {
|
|
1747
|
+
"topics": [{
|
|
1748
|
+
"actions": [{
|
|
1749
|
+
"invocationTarget": "300UvXXXXXXXXXXXX",
|
|
1750
|
+
"invocationTargetType": "apex", # wrong type
|
|
1751
|
+
}],
|
|
1752
|
+
}],
|
|
1753
|
+
"plannerActions": [],
|
|
1754
|
+
}
|
|
1755
|
+
flow_def_rows = [{
|
|
1756
|
+
"Id": "300UvXXXXXXXXXXXX",
|
|
1757
|
+
"DeveloperName": "MyFlow",
|
|
1758
|
+
"ActiveVersionId": "301VfYYYYYYYYYYYY",
|
|
1759
|
+
}]
|
|
1760
|
+
main._normalize_flow_id_targets(bundle, flow_def_rows)
|
|
1761
|
+
self.assertEqual(
|
|
1762
|
+
bundle["topics"][0]["actions"][0]["invocationTarget"],
|
|
1763
|
+
"300UvXXXXXXXXXXXX",
|
|
1764
|
+
)
|
|
1765
|
+
|
|
1766
|
+
def test_empty_inputs_noop(self):
|
|
1767
|
+
"""Empty flow_def_rows → immediate return, no mutation, no crash
|
|
1768
|
+
on missing 'topics' / 'plannerActions' keys."""
|
|
1769
|
+
bundle = {}
|
|
1770
|
+
main._normalize_flow_id_targets(bundle, [])
|
|
1771
|
+
self.assertEqual(bundle, {})
|
|
1772
|
+
|
|
1773
|
+
bundle2 = {
|
|
1774
|
+
"topics": [{"actions": [{
|
|
1775
|
+
"invocationTarget": "300Uv", "invocationTargetType": "flow",
|
|
1776
|
+
}]}],
|
|
1777
|
+
"plannerActions": [],
|
|
1778
|
+
}
|
|
1779
|
+
main._normalize_flow_id_targets(bundle2, [])
|
|
1780
|
+
# Untouched — no lookup map to rewrite against.
|
|
1781
|
+
self.assertEqual(
|
|
1782
|
+
bundle2["topics"][0]["actions"][0]["invocationTarget"], "300Uv",
|
|
1783
|
+
)
|
|
1784
|
+
|
|
1785
|
+
def test_rewrites_both_topics_and_planner_actions(self):
|
|
1786
|
+
"""Fix must cover both the per-topic actions path AND the
|
|
1787
|
+
bundle-scope plannerActions path."""
|
|
1788
|
+
bundle = {
|
|
1789
|
+
"topics": [{
|
|
1790
|
+
"actions": [{
|
|
1791
|
+
"invocationTarget": "300UvAAAA",
|
|
1792
|
+
"invocationTargetType": "flow",
|
|
1793
|
+
}],
|
|
1794
|
+
}],
|
|
1795
|
+
"plannerActions": [{
|
|
1796
|
+
"invocationTarget": "300UvBBBB",
|
|
1797
|
+
"invocationTargetType": "flow",
|
|
1798
|
+
}],
|
|
1799
|
+
}
|
|
1800
|
+
flow_def_rows = [
|
|
1801
|
+
{"Id": "300UvAAAA", "DeveloperName": "TopicFlow",
|
|
1802
|
+
"ActiveVersionId": "301AAAA"},
|
|
1803
|
+
{"Id": "300UvBBBB", "DeveloperName": "BundleFlow",
|
|
1804
|
+
"ActiveVersionId": "301BBBB"},
|
|
1805
|
+
]
|
|
1806
|
+
main._normalize_flow_id_targets(bundle, flow_def_rows)
|
|
1807
|
+
self.assertEqual(
|
|
1808
|
+
bundle["topics"][0]["actions"][0]["invocationTarget"], "TopicFlow",
|
|
1809
|
+
)
|
|
1810
|
+
self.assertEqual(
|
|
1811
|
+
bundle["plannerActions"][0]["invocationTarget"], "BundleFlow",
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
def test_none_invocation_target_type_ignored_safely(self):
|
|
1815
|
+
"""Defensive: invocationTargetType missing → treat as 'not a
|
|
1816
|
+
flow', skip. No crash."""
|
|
1817
|
+
bundle = {
|
|
1818
|
+
"topics": [{"actions": [{
|
|
1819
|
+
"invocationTarget": "300UvAAAA",
|
|
1820
|
+
"invocationTargetType": None,
|
|
1821
|
+
}]}],
|
|
1822
|
+
"plannerActions": [],
|
|
1823
|
+
}
|
|
1824
|
+
main._normalize_flow_id_targets(bundle, [
|
|
1825
|
+
{"Id": "300UvAAAA", "DeveloperName": "X", "ActiveVersionId": "301"}
|
|
1826
|
+
])
|
|
1827
|
+
self.assertEqual(
|
|
1828
|
+
bundle["topics"][0]["actions"][0]["invocationTarget"], "300UvAAAA",
|
|
1829
|
+
)
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
class CollectWaveBTargetsStandardActionTests(unittest.TestCase):
|
|
1833
|
+
"""`_route` in `_collect_wave_b_targets` must
|
|
1834
|
+
short-circuit on declared-only target types (standardInvocableAction,
|
|
1835
|
+
generatePromptResponse, genai*, prompt*) BEFORE calling
|
|
1836
|
+
resolve_or_unresolved — otherwise it pollutes `_unresolved` with
|
|
1837
|
+
spurious "invalid-id-format" entries for perfectly valid identifiers
|
|
1838
|
+
that simply aren't Salesforce Ids."""
|
|
1839
|
+
|
|
1840
|
+
def test_standard_action_not_routed_through_id_resolver(self):
|
|
1841
|
+
"""`streamKnowledgeSearch` is a built-in standard action — not
|
|
1842
|
+
an Id. It should not end up in apex_ids, flow_ids, apex_names,
|
|
1843
|
+
or flow_names AND it should NOT appear in the `_unresolved`
|
|
1844
|
+
list that resolve_or_unresolved populates on non-Id inputs.
|
|
1845
|
+
"""
|
|
1846
|
+
bundle = {
|
|
1847
|
+
"topics": [{
|
|
1848
|
+
"name": "T",
|
|
1849
|
+
"actions": [{
|
|
1850
|
+
"name": "A",
|
|
1851
|
+
"invocationTarget": "streamKnowledgeSearch",
|
|
1852
|
+
"invocationTargetType": "standardInvocableAction",
|
|
1853
|
+
}],
|
|
1854
|
+
}],
|
|
1855
|
+
"plannerActions": [],
|
|
1856
|
+
}
|
|
1857
|
+
result = main._collect_wave_b_targets(bundle)
|
|
1858
|
+
self.assertEqual(result["apex_names"], [])
|
|
1859
|
+
self.assertEqual(result["apex_ids"], [])
|
|
1860
|
+
self.assertEqual(result["flow_names"], [])
|
|
1861
|
+
self.assertEqual(result["flow_ids"], [])
|
|
1862
|
+
self.assertEqual(
|
|
1863
|
+
result["_unresolved"], [],
|
|
1864
|
+
"standardInvocableAction must not pollute _unresolved with "
|
|
1865
|
+
"invalid-id-format entries",
|
|
1866
|
+
)
|
|
1867
|
+
|
|
1868
|
+
def test_generate_prompt_response_short_circuits(self):
|
|
1869
|
+
"""`generatePromptResponse` targets are prompt-template names;
|
|
1870
|
+
not routed to Apex/Flow fetchers (Batch 1 is body-only for
|
|
1871
|
+
Apex+Flow; prompts flow through the retrieve path)."""
|
|
1872
|
+
bundle = {
|
|
1873
|
+
"topics": [{
|
|
1874
|
+
"name": "T",
|
|
1875
|
+
"actions": [{
|
|
1876
|
+
"invocationTarget": "MyPromptTemplate",
|
|
1877
|
+
"invocationTargetType": "generatePromptResponse",
|
|
1878
|
+
}],
|
|
1879
|
+
}],
|
|
1880
|
+
"plannerActions": [],
|
|
1881
|
+
}
|
|
1882
|
+
result = main._collect_wave_b_targets(bundle)
|
|
1883
|
+
self.assertEqual(result["apex_names"], [])
|
|
1884
|
+
self.assertEqual(result["flow_names"], [])
|
|
1885
|
+
self.assertEqual(result["_unresolved"], [])
|
|
1886
|
+
|
|
1887
|
+
def test_flow_and_apex_still_route_correctly(self):
|
|
1888
|
+
"""Positive control — the short-circuit must not break flow/apex
|
|
1889
|
+
routing. A mix of classic DeveloperNames + NGA IDs should land in
|
|
1890
|
+
the right buckets, and standard actions should pass through
|
|
1891
|
+
without polluting anything."""
|
|
1892
|
+
bundle = {
|
|
1893
|
+
"topics": [{
|
|
1894
|
+
"name": "T",
|
|
1895
|
+
"actions": [
|
|
1896
|
+
# Classic flow by DeveloperName
|
|
1897
|
+
{"invocationTarget": "MyFlow",
|
|
1898
|
+
"invocationTargetType": "flow"},
|
|
1899
|
+
# Classic apex by name
|
|
1900
|
+
{"invocationTarget": "MyApex",
|
|
1901
|
+
"invocationTargetType": "apex"},
|
|
1902
|
+
# Standard action (new short-circuit path)
|
|
1903
|
+
{"invocationTarget": "streamKnowledgeSearch",
|
|
1904
|
+
"invocationTargetType": "standardInvocableAction"},
|
|
1905
|
+
# NGA apex by Id
|
|
1906
|
+
{"invocationTarget": "01p1N000005SsDNQA0",
|
|
1907
|
+
"invocationTargetType": "apex"},
|
|
1908
|
+
],
|
|
1909
|
+
}],
|
|
1910
|
+
"plannerActions": [],
|
|
1911
|
+
}
|
|
1912
|
+
result = main._collect_wave_b_targets(bundle)
|
|
1913
|
+
self.assertIn("MyFlow", result["flow_names"])
|
|
1914
|
+
self.assertIn("MyApex", result["apex_names"])
|
|
1915
|
+
self.assertIn("01p1N000005SsDNQA0", result["apex_ids"])
|
|
1916
|
+
self.assertEqual(result["_unresolved"], [])
|
|
1917
|
+
|
|
1918
|
+
def test_unknown_type_silently_skipped(self):
|
|
1919
|
+
"""An unrecognized invocationTargetType falls through all the
|
|
1920
|
+
known-type branches and is silently skipped. parse_wave tags the
|
|
1921
|
+
node as UNKNOWN in the tree (via `_kind_counts`), so the signal
|
|
1922
|
+
is visible without polluting _unresolved."""
|
|
1923
|
+
bundle = {
|
|
1924
|
+
"topics": [{
|
|
1925
|
+
"name": "T",
|
|
1926
|
+
"actions": [{
|
|
1927
|
+
"invocationTarget": "SomeThing",
|
|
1928
|
+
"invocationTargetType": "unheardOfType",
|
|
1929
|
+
}],
|
|
1930
|
+
}],
|
|
1931
|
+
"plannerActions": [],
|
|
1932
|
+
}
|
|
1933
|
+
result = main._collect_wave_b_targets(bundle)
|
|
1934
|
+
self.assertEqual(result["apex_names"], [])
|
|
1935
|
+
self.assertEqual(result["flow_names"], [])
|
|
1936
|
+
self.assertEqual(
|
|
1937
|
+
result["_unresolved"], [],
|
|
1938
|
+
"Unknown invocationTargetType shouldn't reach the ID router, "
|
|
1939
|
+
"so _unresolved stays clean.",
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
def test_classic_flow_name_not_routed_through_id_resolver(self):
|
|
1943
|
+
"""classic bots store plain DeveloperNames
|
|
1944
|
+
like `MyFlow` — not Salesforce Ids. The router must route these
|
|
1945
|
+
directly to flow_names via invocationTargetType, NOT through
|
|
1946
|
+
resolve_or_unresolved (which would reject them as invalid-id-
|
|
1947
|
+
format and pollute _unresolved)."""
|
|
1948
|
+
bundle = {
|
|
1949
|
+
"topics": [{
|
|
1950
|
+
"actions": [
|
|
1951
|
+
{"invocationTarget": "MyFlow",
|
|
1952
|
+
"invocationTargetType": "flow"},
|
|
1953
|
+
{"invocationTarget": "AGNT_Foo",
|
|
1954
|
+
"invocationTargetType": "apex"},
|
|
1955
|
+
],
|
|
1956
|
+
}],
|
|
1957
|
+
"plannerActions": [],
|
|
1958
|
+
}
|
|
1959
|
+
result = main._collect_wave_b_targets(bundle)
|
|
1960
|
+
self.assertIn("MyFlow", result["flow_names"])
|
|
1961
|
+
self.assertIn("AGNT_Foo", result["apex_names"])
|
|
1962
|
+
self.assertEqual(
|
|
1963
|
+
result["_unresolved"], [],
|
|
1964
|
+
"Classic DeveloperNames shouldn't be sent through the ID "
|
|
1965
|
+
"resolver at all — they're not Ids.",
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
class NormalizePromptTemplateIdTargetsTests(unittest.TestCase):
|
|
1970
|
+
"""Bug 1 fix (2026-05-05): classic bots occasionally store 0hf-prefix
|
|
1971
|
+
GenAiPromptTemplate IDs as `GenAiFunctionDefinition.InvocationTarget`.
|
|
1972
|
+
After Wave B resolves them via `list_prompt_template_metadata`
|
|
1973
|
+
(GenAiPromptTemplate is NOT SOQL-queryable — Metadata API only) we
|
|
1974
|
+
rewrite bundle_parsed in place so Wave B's retrieve uses the
|
|
1975
|
+
DeveloperName Metadata API expects."""
|
|
1976
|
+
|
|
1977
|
+
def test_rewrites_prompt_template_id_to_developer_name(self):
|
|
1978
|
+
bundle = {
|
|
1979
|
+
"topics": [{
|
|
1980
|
+
"actions": [{
|
|
1981
|
+
"invocationTarget": "0hfUv0000021mCjIAI",
|
|
1982
|
+
"invocationTargetType": "GenAiPromptTemplate",
|
|
1983
|
+
}],
|
|
1984
|
+
}],
|
|
1985
|
+
"plannerActions": [],
|
|
1986
|
+
}
|
|
1987
|
+
rows = [{"Id": "0hfUv0000021mCjIAI", "DeveloperName": "My_Prompt"}]
|
|
1988
|
+
main._normalize_prompt_template_id_targets(bundle, rows)
|
|
1989
|
+
action = bundle["topics"][0]["actions"][0]
|
|
1990
|
+
self.assertEqual(action["invocationTarget"], "My_Prompt")
|
|
1991
|
+
self.assertEqual(
|
|
1992
|
+
action["_original_invocation_target_id"], "0hfUv0000021mCjIAI"
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
def test_preserves_classic_developer_name_targets(self):
|
|
1996
|
+
"""Classic DeveloperName targets pass through untouched — the
|
|
1997
|
+
rewrite is only for Id-shaped targets that appeared in the lookup."""
|
|
1998
|
+
bundle = {
|
|
1999
|
+
"topics": [{
|
|
2000
|
+
"actions": [{
|
|
2001
|
+
"invocationTarget": "Existing_Prompt_Name",
|
|
2002
|
+
"invocationTargetType": "GenAiPromptTemplate",
|
|
2003
|
+
}],
|
|
2004
|
+
}],
|
|
2005
|
+
"plannerActions": [],
|
|
2006
|
+
}
|
|
2007
|
+
# Lookup covers a different Id entirely — the DeveloperName target
|
|
2008
|
+
# isn't a key in the map so it's not rewritten.
|
|
2009
|
+
rows = [{"Id": "0hfUv0000021Other", "DeveloperName": "Other_Prompt"}]
|
|
2010
|
+
main._normalize_prompt_template_id_targets(bundle, rows)
|
|
2011
|
+
action = bundle["topics"][0]["actions"][0]
|
|
2012
|
+
self.assertEqual(action["invocationTarget"], "Existing_Prompt_Name")
|
|
2013
|
+
self.assertNotIn("_original_invocation_target_id", action)
|
|
2014
|
+
|
|
2015
|
+
def test_unmatched_id_stays_as_is(self):
|
|
2016
|
+
"""Id not present in the lookup (e.g. Tooling returned empty
|
|
2017
|
+
because GenAiPromptTemplate isn't exposed on this org) stays
|
|
2018
|
+
as-is so it correctly surfaces in _pending_fetches."""
|
|
2019
|
+
bundle = {
|
|
2020
|
+
"topics": [{
|
|
2021
|
+
"actions": [{
|
|
2022
|
+
"invocationTarget": "0hfUv00000xxxxxYYY",
|
|
2023
|
+
"invocationTargetType": "GenAiPromptTemplate",
|
|
2024
|
+
}],
|
|
2025
|
+
}],
|
|
2026
|
+
"plannerActions": [],
|
|
2027
|
+
}
|
|
2028
|
+
main._normalize_prompt_template_id_targets(bundle, [])
|
|
2029
|
+
action = bundle["topics"][0]["actions"][0]
|
|
2030
|
+
self.assertEqual(action["invocationTarget"], "0hfUv00000xxxxxYYY")
|
|
2031
|
+
self.assertNotIn("_original_invocation_target_id", action)
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
class NormalizeApexIdTargetsTests(unittest.TestCase):
|
|
2035
|
+
"""Gap B fix (2026-05-05): classic bots occasionally store 01p-prefix
|
|
2036
|
+
ApexClass Ids as `GenAiFunctionDefinition.InvocationTarget` (live-verified
|
|
2037
|
+
on my-org-alias: 01p000000000000AAA -> MyController).
|
|
2038
|
+
After Wave B resolves them via `fetch_apex_bodies_by_ids` we rewrite
|
|
2039
|
+
bundle_parsed in place so the tree renders the ApexClass Name instead
|
|
2040
|
+
of the raw Id."""
|
|
2041
|
+
|
|
2042
|
+
def test_rewrites_apex_id_to_class_name(self):
|
|
2043
|
+
bundle = {
|
|
2044
|
+
"topics": [{
|
|
2045
|
+
"actions": [{
|
|
2046
|
+
"invocationTarget": "01p000000000000AAA",
|
|
2047
|
+
"invocationTargetType": "apex",
|
|
2048
|
+
}],
|
|
2049
|
+
}],
|
|
2050
|
+
"plannerActions": [],
|
|
2051
|
+
}
|
|
2052
|
+
rows = [{"Id": "01p000000000000AAA", "Name": "MyController"}]
|
|
2053
|
+
main._normalize_apex_id_targets(bundle, rows)
|
|
2054
|
+
action = bundle["topics"][0]["actions"][0]
|
|
2055
|
+
self.assertEqual(action["invocationTarget"], "MyController")
|
|
2056
|
+
self.assertEqual(
|
|
2057
|
+
action["_original_invocation_target_id"], "01p000000000000AAA"
|
|
2058
|
+
)
|
|
2059
|
+
|
|
2060
|
+
def test_non_apex_ttype_left_unchanged(self):
|
|
2061
|
+
"""Same Id but a non-apex ttype — the ttype gate must block the
|
|
2062
|
+
rewrite so a Flow action whose target happens to collide with an
|
|
2063
|
+
ApexClass Id isn't miscaught."""
|
|
2064
|
+
bundle = {
|
|
2065
|
+
"topics": [{
|
|
2066
|
+
"actions": [{
|
|
2067
|
+
"invocationTarget": "01p000000000000AAA",
|
|
2068
|
+
"invocationTargetType": "flow",
|
|
2069
|
+
}],
|
|
2070
|
+
}],
|
|
2071
|
+
"plannerActions": [],
|
|
2072
|
+
}
|
|
2073
|
+
rows = [{"Id": "01p000000000000AAA", "Name": "MyController"}]
|
|
2074
|
+
main._normalize_apex_id_targets(bundle, rows)
|
|
2075
|
+
action = bundle["topics"][0]["actions"][0]
|
|
2076
|
+
self.assertEqual(action["invocationTarget"], "01p000000000000AAA")
|
|
2077
|
+
self.assertNotIn("_original_invocation_target_id", action)
|
|
2078
|
+
|
|
2079
|
+
def test_unmatched_id_stays_as_is(self):
|
|
2080
|
+
"""Id not present in the lookup (e.g. by-Id fetch failed or the
|
|
2081
|
+
class was deleted) stays as-is so it correctly surfaces in
|
|
2082
|
+
_pending_fetches."""
|
|
2083
|
+
bundle = {
|
|
2084
|
+
"topics": [{
|
|
2085
|
+
"actions": [{
|
|
2086
|
+
"invocationTarget": "01pFAKEIDFAKEIDAAA",
|
|
2087
|
+
"invocationTargetType": "apex",
|
|
2088
|
+
}],
|
|
2089
|
+
}],
|
|
2090
|
+
"plannerActions": [],
|
|
2091
|
+
}
|
|
2092
|
+
main._normalize_apex_id_targets(bundle, [])
|
|
2093
|
+
action = bundle["topics"][0]["actions"][0]
|
|
2094
|
+
self.assertEqual(action["invocationTarget"], "01pFAKEIDFAKEIDAAA")
|
|
2095
|
+
self.assertNotIn("_original_invocation_target_id", action)
|
|
2096
|
+
|
|
2097
|
+
def test_preserves_classic_developer_name_targets(self):
|
|
2098
|
+
"""Classic Name targets pass through untouched — the rewrite is
|
|
2099
|
+
only for Id-shaped targets (01p-prefix) that appeared in the lookup."""
|
|
2100
|
+
bundle = {
|
|
2101
|
+
"topics": [{
|
|
2102
|
+
"actions": [{
|
|
2103
|
+
"invocationTarget": "MyApexClass",
|
|
2104
|
+
"invocationTargetType": "apex",
|
|
2105
|
+
}],
|
|
2106
|
+
}],
|
|
2107
|
+
"plannerActions": [],
|
|
2108
|
+
}
|
|
2109
|
+
# Lookup covers a different Id entirely — the Name target isn't
|
|
2110
|
+
# Id-shaped and doesn't hit the prefix gate.
|
|
2111
|
+
rows = [{"Id": "01p000000000000AAA", "Name": "MyController"}]
|
|
2112
|
+
main._normalize_apex_id_targets(bundle, rows)
|
|
2113
|
+
action = bundle["topics"][0]["actions"][0]
|
|
2114
|
+
self.assertEqual(action["invocationTarget"], "MyApexClass")
|
|
2115
|
+
self.assertNotIn("_original_invocation_target_id", action)
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
class CollectWaveBTargetsPromptTemplateIdTests(unittest.TestCase):
|
|
2119
|
+
"""Bug 1 fix (2026-05-05): 0hf-prefix prompt template Ids are routed
|
|
2120
|
+
to a dedicated `prompt_template_ids` bucket so post-Wave-B resolution
|
|
2121
|
+
can rewrite them; DeveloperName targets still short-circuit as before."""
|
|
2122
|
+
|
|
2123
|
+
def test_prompt_template_id_routes_to_id_bucket(self):
|
|
2124
|
+
bundle = {
|
|
2125
|
+
"topics": [],
|
|
2126
|
+
"plannerActions": [{
|
|
2127
|
+
"invocationTarget": "0hfUv0000021mCjIAI",
|
|
2128
|
+
"invocationTargetType": "GenAiPromptTemplate",
|
|
2129
|
+
}],
|
|
2130
|
+
}
|
|
2131
|
+
result = main._collect_wave_b_targets(bundle)
|
|
2132
|
+
self.assertEqual(
|
|
2133
|
+
result.get("prompt_template_ids"), ["0hfUv0000021mCjIAI"]
|
|
2134
|
+
)
|
|
2135
|
+
# DeveloperName bucket stays empty — the Id is routed to the Id
|
|
2136
|
+
# bucket and the short-circuit returns BEFORE the DeveloperName
|
|
2137
|
+
# branch would have run.
|
|
2138
|
+
self.assertEqual(result.get("_unresolved"), [])
|
|
2139
|
+
|
|
2140
|
+
def test_prompt_template_developer_name_does_not_pollute_id_bucket(self):
|
|
2141
|
+
bundle = {
|
|
2142
|
+
"topics": [],
|
|
2143
|
+
"plannerActions": [{
|
|
2144
|
+
"invocationTarget": "My_Prompt_Template_Name",
|
|
2145
|
+
"invocationTargetType": "GenAiPromptTemplate",
|
|
2146
|
+
}],
|
|
2147
|
+
}
|
|
2148
|
+
result = main._collect_wave_b_targets(bundle)
|
|
2149
|
+
self.assertEqual(result.get("prompt_template_ids"), [])
|
|
2150
|
+
# DeveloperName-shaped prompt-template targets still short-circuit
|
|
2151
|
+
# (parse_wave enqueues them into PROMPT_TEMPLATE pending) — no
|
|
2152
|
+
# change from prior behavior, verified via absence from all
|
|
2153
|
+
# name/id buckets we'd route through.
|
|
2154
|
+
self.assertEqual(result.get("apex_names"), [])
|
|
2155
|
+
self.assertEqual(result.get("flow_names"), [])
|
|
2156
|
+
|
|
2157
|
+
def test_non_0hf_prefix_ignored(self):
|
|
2158
|
+
"""Id-shaped target for a prompt-template ttype but WRONG prefix
|
|
2159
|
+
(e.g. a Flow Id accidentally typed with prompt ttype) doesn't
|
|
2160
|
+
pollute prompt_template_ids — the prefix gate catches it."""
|
|
2161
|
+
bundle = {
|
|
2162
|
+
"topics": [],
|
|
2163
|
+
"plannerActions": [{
|
|
2164
|
+
"invocationTarget": "300Uv00000abcdeFGH", # Flow-shaped Id
|
|
2165
|
+
"invocationTargetType": "GenAiPromptTemplate",
|
|
2166
|
+
}],
|
|
2167
|
+
}
|
|
2168
|
+
result = main._collect_wave_b_targets(bundle)
|
|
2169
|
+
# Not routed anywhere — neither prompt-template Id bucket nor
|
|
2170
|
+
# flow buckets (ttype gated). Surfaces nowhere, which is
|
|
2171
|
+
# acceptable: classify_action_call downstream will still enqueue
|
|
2172
|
+
# the string as-is as a PROMPT_TEMPLATE pending fetch.
|
|
2173
|
+
self.assertEqual(result.get("prompt_template_ids"), [])
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
class FetchFlowDefinitionViewByDurableIdsTests(unittest.TestCase):
|
|
2177
|
+
"""Option B (2026-05-05): `fetch_flow_definition_view_by_durable_ids`
|
|
2178
|
+
is the Data-API fallback fetcher for managed-installed flows that
|
|
2179
|
+
Tooling's `FlowDefinition` doesn't index.
|
|
2180
|
+
|
|
2181
|
+
Contract:
|
|
2182
|
+
* Empty input short-circuits to `[]` without firing a SOQL call
|
|
2183
|
+
(matches every other list-shaped fetcher in this module).
|
|
2184
|
+
* Rows are returned from `data_query` verbatim — projection to
|
|
2185
|
+
the `flow_def_rows` shape happens in the caller
|
|
2186
|
+
(`fetch_flow_definition_ids_by_names`).
|
|
2187
|
+
"""
|
|
2188
|
+
|
|
2189
|
+
def test_happy_path_returns_rows(self):
|
|
2190
|
+
import fetch_soql # type: ignore
|
|
2191
|
+
view_row = {
|
|
2192
|
+
"DurableId": "SvcCopilotTmpl__VerifyCode",
|
|
2193
|
+
"ApiName": "VerifyCode",
|
|
2194
|
+
"Label": "Verify Code",
|
|
2195
|
+
"NamespacePrefix": "SvcCopilotTmpl",
|
|
2196
|
+
"ActiveVersionId": "SvcCopilotTmpl__VerifyCode-1",
|
|
2197
|
+
"IsActive": True,
|
|
2198
|
+
"ManageableState": "installed",
|
|
2199
|
+
"ProcessType": "AutoLaunchedFlow",
|
|
2200
|
+
}
|
|
2201
|
+
with mock.patch.object(
|
|
2202
|
+
fetch_soql, "data_query",
|
|
2203
|
+
return_value={"records": [view_row]},
|
|
2204
|
+
) as mock_dq:
|
|
2205
|
+
rows = fetch_soql.fetch_flow_definition_view_by_durable_ids(
|
|
2206
|
+
["SvcCopilotTmpl__VerifyCode"],
|
|
2207
|
+
lambda: ("url", "tok"),
|
|
2208
|
+
api_version="v60.0",
|
|
2209
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2210
|
+
)
|
|
2211
|
+
self.assertEqual(rows, [view_row])
|
|
2212
|
+
# Exactly one data_query call — the fetcher doesn't accidentally
|
|
2213
|
+
# re-fire on the Tooling surface.
|
|
2214
|
+
self.assertEqual(mock_dq.call_count, 1)
|
|
2215
|
+
|
|
2216
|
+
def test_empty_input_short_circuits(self):
|
|
2217
|
+
"""Empty durable_ids list returns [] WITHOUT firing a SOQL call
|
|
2218
|
+
(matches the module-wide empty-input-short-circuit invariant)."""
|
|
2219
|
+
import fetch_soql # type: ignore
|
|
2220
|
+
with mock.patch.object(fetch_soql, "data_query") as mock_dq:
|
|
2221
|
+
rows = fetch_soql.fetch_flow_definition_view_by_durable_ids(
|
|
2222
|
+
[],
|
|
2223
|
+
lambda: ("url", "tok"),
|
|
2224
|
+
api_version="v60.0",
|
|
2225
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2226
|
+
)
|
|
2227
|
+
self.assertEqual(rows, [])
|
|
2228
|
+
self.assertEqual(mock_dq.call_count, 0)
|
|
2229
|
+
|
|
2230
|
+
def test_multi_row_returns_all(self):
|
|
2231
|
+
import fetch_soql # type: ignore
|
|
2232
|
+
view_rows = [
|
|
2233
|
+
{
|
|
2234
|
+
"DurableId": "SvcCopilotTmpl__VerifyCode",
|
|
2235
|
+
"ApiName": "VerifyCode",
|
|
2236
|
+
"NamespacePrefix": "SvcCopilotTmpl",
|
|
2237
|
+
"ActiveVersionId": "SvcCopilotTmpl__VerifyCode-1",
|
|
2238
|
+
"ManageableState": "installed",
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
"DurableId": "sales_inbound_flows__SendVerifyCode",
|
|
2242
|
+
"ApiName": "SendVerifyCode",
|
|
2243
|
+
"NamespacePrefix": "sales_inbound_flows",
|
|
2244
|
+
"ActiveVersionId": "sales_inbound_flows__SendVerifyCode-2",
|
|
2245
|
+
"ManageableState": "installed",
|
|
2246
|
+
},
|
|
2247
|
+
]
|
|
2248
|
+
with mock.patch.object(
|
|
2249
|
+
fetch_soql, "data_query",
|
|
2250
|
+
return_value={"records": view_rows},
|
|
2251
|
+
):
|
|
2252
|
+
rows = fetch_soql.fetch_flow_definition_view_by_durable_ids(
|
|
2253
|
+
[
|
|
2254
|
+
"SvcCopilotTmpl__VerifyCode",
|
|
2255
|
+
"sales_inbound_flows__SendVerifyCode",
|
|
2256
|
+
],
|
|
2257
|
+
lambda: ("url", "tok"),
|
|
2258
|
+
api_version="v60.0",
|
|
2259
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2260
|
+
)
|
|
2261
|
+
self.assertEqual(len(rows), 2)
|
|
2262
|
+
# Membership assertion rather than sort-order — codepoint sort of
|
|
2263
|
+
# mixed-case prefixes is fragile; the fetcher returns rows in the
|
|
2264
|
+
# order `data_query` supplies them.
|
|
2265
|
+
self.assertEqual(
|
|
2266
|
+
{r["DurableId"] for r in rows},
|
|
2267
|
+
{
|
|
2268
|
+
"SvcCopilotTmpl__VerifyCode",
|
|
2269
|
+
"sales_inbound_flows__SendVerifyCode",
|
|
2270
|
+
},
|
|
2271
|
+
)
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
class FlowDefinitionViewFallbackTests(unittest.TestCase):
|
|
2275
|
+
"""Option B (2026-05-05, updated after managed-bucket retirement):
|
|
2276
|
+
`fetch_flow_definition_ids_by_names` fires a SINGLE Tooling
|
|
2277
|
+
`FlowDefinition` query (unmanaged-only — the template filters
|
|
2278
|
+
`NamespacePrefix IS NULL`). Any input name that query doesn't resolve
|
|
2279
|
+
triggers a single follow-up `FlowDefinitionView` (Data API) query.
|
|
2280
|
+
Projected view-only rows carry `Id=None`, `ActiveVersionId=None`,
|
|
2281
|
+
`_body_available=False`, and `_source="FlowDefinitionView"`.
|
|
2282
|
+
"""
|
|
2283
|
+
|
|
2284
|
+
def test_fallback_fires_on_managed_miss(self):
|
|
2285
|
+
"""Tooling FlowDefinition returns empty for a `ns__Name` input
|
|
2286
|
+
(managed flows aren't indexed on subscriber orgs + the unmanaged
|
|
2287
|
+
query filters `NamespacePrefix IS NULL` anyway); FlowDefinitionView
|
|
2288
|
+
returns a matching row. Output row carries the view-shaped markers
|
|
2289
|
+
and `DeveloperName` = qualified ns__bare."""
|
|
2290
|
+
import fetch_soql # type: ignore
|
|
2291
|
+
view_row = {
|
|
2292
|
+
"DurableId": "SvcCopilotTmpl__VerifyCode",
|
|
2293
|
+
"ApiName": "VerifyCode",
|
|
2294
|
+
"Label": "Verify Code",
|
|
2295
|
+
"NamespacePrefix": "SvcCopilotTmpl",
|
|
2296
|
+
"ActiveVersionId": "SvcCopilotTmpl__VerifyCode-1",
|
|
2297
|
+
"ManageableState": "installed",
|
|
2298
|
+
}
|
|
2299
|
+
with mock.patch.object(
|
|
2300
|
+
fetch_soql, "tooling_query",
|
|
2301
|
+
return_value={"records": []},
|
|
2302
|
+
), mock.patch.object(
|
|
2303
|
+
fetch_soql, "data_query",
|
|
2304
|
+
return_value={"records": [view_row]},
|
|
2305
|
+
) as mock_dq:
|
|
2306
|
+
rows = fetch_soql.fetch_flow_definition_ids_by_names(
|
|
2307
|
+
["SvcCopilotTmpl__VerifyCode"],
|
|
2308
|
+
lambda: ("url", "tok"),
|
|
2309
|
+
api_version="v60.0",
|
|
2310
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2311
|
+
)
|
|
2312
|
+
self.assertEqual(len(rows), 1)
|
|
2313
|
+
row = rows[0]
|
|
2314
|
+
self.assertIsNone(row["Id"])
|
|
2315
|
+
self.assertIsNone(row["ActiveVersionId"])
|
|
2316
|
+
self.assertFalse(row["_body_available"])
|
|
2317
|
+
self.assertEqual(row["_source"], "FlowDefinitionView")
|
|
2318
|
+
self.assertEqual(row["DeveloperName"], "SvcCopilotTmpl__VerifyCode")
|
|
2319
|
+
self.assertEqual(row["NamespacePrefix"], "SvcCopilotTmpl")
|
|
2320
|
+
self.assertEqual(row["_bare_developer_name"], "VerifyCode")
|
|
2321
|
+
self.assertEqual(mock_dq.call_count, 1)
|
|
2322
|
+
|
|
2323
|
+
def test_fallback_skipped_when_tooling_resolves_everything(self):
|
|
2324
|
+
"""All input names resolved by Tooling → FlowDefinitionView is
|
|
2325
|
+
never queried. Guards against unnecessary Data-API calls on the
|
|
2326
|
+
99% of runs where every referenced flow is unmanaged or locally
|
|
2327
|
+
installed."""
|
|
2328
|
+
import fetch_soql # type: ignore
|
|
2329
|
+
real_row = {
|
|
2330
|
+
"Id": "300Uv000000Real",
|
|
2331
|
+
"DeveloperName": "MyFlow",
|
|
2332
|
+
"NamespacePrefix": None,
|
|
2333
|
+
"ActiveVersionId": "301Vf000000Real",
|
|
2334
|
+
}
|
|
2335
|
+
with mock.patch.object(
|
|
2336
|
+
fetch_soql, "tooling_query",
|
|
2337
|
+
return_value={"records": [real_row]},
|
|
2338
|
+
), mock.patch.object(fetch_soql, "data_query") as mock_dq:
|
|
2339
|
+
rows = fetch_soql.fetch_flow_definition_ids_by_names(
|
|
2340
|
+
["MyFlow"],
|
|
2341
|
+
lambda: ("url", "tok"),
|
|
2342
|
+
api_version="v60.0",
|
|
2343
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2344
|
+
)
|
|
2345
|
+
self.assertEqual(len(rows), 1)
|
|
2346
|
+
self.assertEqual(rows[0]["_source"], "FlowDefinition")
|
|
2347
|
+
self.assertTrue(rows[0]["_body_available"])
|
|
2348
|
+
self.assertEqual(mock_dq.call_count, 0)
|
|
2349
|
+
|
|
2350
|
+
def test_mixed_resolution_paths(self):
|
|
2351
|
+
"""Three input names, three distinct fates:
|
|
2352
|
+
1. `ExistingFlow` — unmanaged, resolved by Tooling.
|
|
2353
|
+
2. `SvcCopilotTmpl__VerifyCode` — managed miss on Tooling,
|
|
2354
|
+
resolved by FlowDefinitionView.
|
|
2355
|
+
3. `ghost_ns__GhostFlow` — managed miss on BOTH surfaces.
|
|
2356
|
+
|
|
2357
|
+
Expected: 2 rows total (1 real + 1 view-only), the ghost flow
|
|
2358
|
+
absent from the result set. FlowDefinitionView queried exactly
|
|
2359
|
+
once with both unresolved names in the IN-list.
|
|
2360
|
+
"""
|
|
2361
|
+
import fetch_soql # type: ignore
|
|
2362
|
+
|
|
2363
|
+
real_row = {
|
|
2364
|
+
"Id": "300Uv000000Real",
|
|
2365
|
+
"DeveloperName": "ExistingFlow",
|
|
2366
|
+
"NamespacePrefix": None,
|
|
2367
|
+
"ActiveVersionId": "301Vf000000Real",
|
|
2368
|
+
}
|
|
2369
|
+
view_row = {
|
|
2370
|
+
"DurableId": "SvcCopilotTmpl__VerifyCode",
|
|
2371
|
+
"ApiName": "VerifyCode",
|
|
2372
|
+
"NamespacePrefix": "SvcCopilotTmpl",
|
|
2373
|
+
"ActiveVersionId": "SvcCopilotTmpl__VerifyCode-1",
|
|
2374
|
+
"ManageableState": "installed",
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
# Tooling is called exactly once — the unmanaged query (template
|
|
2378
|
+
# filters NamespacePrefix IS NULL). It matches only ExistingFlow;
|
|
2379
|
+
# the two managed-qualified names return no rows from that
|
|
2380
|
+
# surface and fall through to the view fallback.
|
|
2381
|
+
with mock.patch.object(
|
|
2382
|
+
fetch_soql, "tooling_query",
|
|
2383
|
+
return_value={"records": [real_row]},
|
|
2384
|
+
) as mock_tq, mock.patch.object(
|
|
2385
|
+
fetch_soql, "data_query",
|
|
2386
|
+
return_value={"records": [view_row]},
|
|
2387
|
+
) as mock_dq:
|
|
2388
|
+
rows = fetch_soql.fetch_flow_definition_ids_by_names(
|
|
2389
|
+
[
|
|
2390
|
+
"ExistingFlow",
|
|
2391
|
+
"SvcCopilotTmpl__VerifyCode",
|
|
2392
|
+
"ghost_ns__GhostFlow",
|
|
2393
|
+
],
|
|
2394
|
+
lambda: ("url", "tok"),
|
|
2395
|
+
api_version="v60.0",
|
|
2396
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2397
|
+
)
|
|
2398
|
+
self.assertEqual(len(rows), 2)
|
|
2399
|
+
# Tooling queried exactly once for the full input (no bucketing).
|
|
2400
|
+
self.assertEqual(mock_tq.call_count, 1)
|
|
2401
|
+
# View fallback queried exactly once — no per-name fanout.
|
|
2402
|
+
self.assertEqual(mock_dq.call_count, 1)
|
|
2403
|
+
|
|
2404
|
+
by_name = {r["DeveloperName"]: r for r in rows}
|
|
2405
|
+
# Real Tooling row — source marker explicit.
|
|
2406
|
+
self.assertEqual(by_name["ExistingFlow"]["Id"], "300Uv000000Real")
|
|
2407
|
+
self.assertEqual(by_name["ExistingFlow"]["_source"], "FlowDefinition")
|
|
2408
|
+
self.assertTrue(by_name["ExistingFlow"]["_body_available"])
|
|
2409
|
+
# View-only row — no Id, no active version, marked unavailable.
|
|
2410
|
+
view_result = by_name["SvcCopilotTmpl__VerifyCode"]
|
|
2411
|
+
self.assertIsNone(view_result["Id"])
|
|
2412
|
+
self.assertIsNone(view_result["ActiveVersionId"])
|
|
2413
|
+
self.assertFalse(view_result["_body_available"])
|
|
2414
|
+
self.assertEqual(view_result["_source"], "FlowDefinitionView")
|
|
2415
|
+
# Ghost flow absent.
|
|
2416
|
+
self.assertNotIn("ghost_ns__GhostFlow", by_name)
|
|
2417
|
+
|
|
2418
|
+
|
|
2419
|
+
class FetchPlannerDefinitionChainTests(unittest.TestCase):
|
|
2420
|
+
"""2026-05-05: `fetch_planner_definition(agent, version)` performs a
|
|
2421
|
+
chain-LIKE lookup against GenAiPlannerDefinition. The accretive
|
|
2422
|
+
naming invariant (v1=`<Agent>`, v2=`<Agent>_v2`, v3=
|
|
2423
|
+
`<Agent>_v2_v3`, ...) means the correct planner always matches
|
|
2424
|
+
`<Agent>%\\_vN` for vN>=2 and `<Agent>` exactly for v1. On multi-row
|
|
2425
|
+
matches the resolver picks the row with the shortest DeveloperName.
|
|
2426
|
+
"""
|
|
2427
|
+
|
|
2428
|
+
def _stub_tooling(self, records):
|
|
2429
|
+
"""Patch `fetch_soql.tooling_query` to return a fixed record list."""
|
|
2430
|
+
import fetch_soql # type: ignore
|
|
2431
|
+
return mock.patch.object(
|
|
2432
|
+
fetch_soql, "tooling_query",
|
|
2433
|
+
return_value={"records": records},
|
|
2434
|
+
)
|
|
2435
|
+
|
|
2436
|
+
def test_v1_exact_match(self):
|
|
2437
|
+
import fetch_soql # type: ignore
|
|
2438
|
+
with self._stub_tooling([
|
|
2439
|
+
{"Id": "1VxVF000V1", "DeveloperName": "Inbound_Sales_Agent"},
|
|
2440
|
+
]):
|
|
2441
|
+
row = fetch_soql.fetch_planner_definition(
|
|
2442
|
+
"Inbound_Sales_Agent", None,
|
|
2443
|
+
lambda: ("url", "tok"),
|
|
2444
|
+
api_version="v66.0",
|
|
2445
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2446
|
+
)
|
|
2447
|
+
self.assertIsNotNone(row)
|
|
2448
|
+
self.assertEqual(row["DeveloperName"], "Inbound_Sales_Agent")
|
|
2449
|
+
|
|
2450
|
+
def test_v1_explicit_string(self):
|
|
2451
|
+
import fetch_soql # type: ignore
|
|
2452
|
+
with self._stub_tooling([
|
|
2453
|
+
{"Id": "1VxVF000V1", "DeveloperName": "Inbound_Sales_Agent"},
|
|
2454
|
+
]):
|
|
2455
|
+
row = fetch_soql.fetch_planner_definition(
|
|
2456
|
+
"Inbound_Sales_Agent", "v1",
|
|
2457
|
+
lambda: ("url", "tok"),
|
|
2458
|
+
api_version="v66.0",
|
|
2459
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2460
|
+
)
|
|
2461
|
+
self.assertEqual(row["DeveloperName"], "Inbound_Sales_Agent")
|
|
2462
|
+
|
|
2463
|
+
def test_v2_single_match(self):
|
|
2464
|
+
import fetch_soql # type: ignore
|
|
2465
|
+
with self._stub_tooling([
|
|
2466
|
+
{"Id": "1VxVF000V2", "DeveloperName": "Inbound_Sales_Agent_v2"},
|
|
2467
|
+
]):
|
|
2468
|
+
row = fetch_soql.fetch_planner_definition(
|
|
2469
|
+
"Inbound_Sales_Agent", "v2",
|
|
2470
|
+
lambda: ("url", "tok"),
|
|
2471
|
+
api_version="v66.0",
|
|
2472
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2473
|
+
)
|
|
2474
|
+
self.assertEqual(row["DeveloperName"], "Inbound_Sales_Agent_v2")
|
|
2475
|
+
|
|
2476
|
+
def test_v2_multi_row_shortest_wins(self):
|
|
2477
|
+
"""LIKE `<Agent>%\\_v2` can match both `<Agent>_v2` and a deeper
|
|
2478
|
+
chain like `<Agent>_foo_v2` (rare but possible). Shortest
|
|
2479
|
+
DeveloperName wins — the canonical row carries no extra segment."""
|
|
2480
|
+
import fetch_soql # type: ignore
|
|
2481
|
+
with self._stub_tooling([
|
|
2482
|
+
{"Id": "1VxVF000DEEPER",
|
|
2483
|
+
"DeveloperName": "Inbound_Sales_Agent_foo_v2"},
|
|
2484
|
+
{"Id": "1VxVF000V2",
|
|
2485
|
+
"DeveloperName": "Inbound_Sales_Agent_v2"},
|
|
2486
|
+
]):
|
|
2487
|
+
row = fetch_soql.fetch_planner_definition(
|
|
2488
|
+
"Inbound_Sales_Agent", "v2",
|
|
2489
|
+
lambda: ("url", "tok"),
|
|
2490
|
+
api_version="v66.0",
|
|
2491
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2492
|
+
)
|
|
2493
|
+
self.assertEqual(row["DeveloperName"], "Inbound_Sales_Agent_v2")
|
|
2494
|
+
|
|
2495
|
+
def test_zero_rows_returns_none(self):
|
|
2496
|
+
import fetch_soql # type: ignore
|
|
2497
|
+
with self._stub_tooling([]):
|
|
2498
|
+
row = fetch_soql.fetch_planner_definition(
|
|
2499
|
+
"NoSuchAgent", "v3",
|
|
2500
|
+
lambda: ("url", "tok"),
|
|
2501
|
+
api_version="v66.0",
|
|
2502
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2503
|
+
)
|
|
2504
|
+
self.assertIsNone(row)
|
|
2505
|
+
|
|
2506
|
+
def test_chain_like_pattern_passed_to_soql_for_v5(self):
|
|
2507
|
+
"""The SOQL body that reaches `tooling_query` contains the chain
|
|
2508
|
+
LIKE pattern literally. Verify AGENT_NAME + VERSION are rendered
|
|
2509
|
+
through the template with the `%\\_` escape in between.
|
|
2510
|
+
"""
|
|
2511
|
+
import fetch_soql # type: ignore
|
|
2512
|
+
captured: dict = {}
|
|
2513
|
+
|
|
2514
|
+
def fake_tooling(creds_provider, soql, *, api_version, on_401_refresh):
|
|
2515
|
+
captured["soql"] = soql
|
|
2516
|
+
return {"records": []}
|
|
2517
|
+
|
|
2518
|
+
with mock.patch.object(fetch_soql, "tooling_query",
|
|
2519
|
+
side_effect=fake_tooling):
|
|
2520
|
+
fetch_soql.fetch_planner_definition(
|
|
2521
|
+
"MyAgent", "v5",
|
|
2522
|
+
lambda: ("url", "tok"),
|
|
2523
|
+
api_version="v66.0",
|
|
2524
|
+
on_401_refresh=lambda: ("url", "tok"),
|
|
2525
|
+
)
|
|
2526
|
+
self.assertIn("LIKE 'MyAgent%\\_v5'", captured["soql"])
|
|
2527
|
+
|
|
2528
|
+
|
|
2529
|
+
class SwapDirAtomicCoTenancyTests(unittest.TestCase):
|
|
2530
|
+
"""`_swap_dir_atomic` preserves sibling content in `target`. The prior
|
|
2531
|
+
whole-directory `os.replace` wiped any co-tenant subdirs that another
|
|
2532
|
+
caller may have written into the same `<agent>__<ver>/` directory."""
|
|
2533
|
+
|
|
2534
|
+
def test_preserves_sibling_session_dirs(self) -> None:
|
|
2535
|
+
# Simulate co-tenancy: target dir already has a sibling subdir
|
|
2536
|
+
# + a bare file written by some other caller.
|
|
2537
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2538
|
+
tmp_p = Path(tmp)
|
|
2539
|
+
target = tmp_p / "agent__v1"
|
|
2540
|
+
target.mkdir()
|
|
2541
|
+
(target / "sibling-data").mkdir()
|
|
2542
|
+
(target / "sibling-data" / "payload.json").write_text(
|
|
2543
|
+
"from-other-caller"
|
|
2544
|
+
)
|
|
2545
|
+
(target / "_sessions").mkdir()
|
|
2546
|
+
(target / "_sessions" / "marker.link").write_text(
|
|
2547
|
+
"../agent__v1/sibling-data"
|
|
2548
|
+
)
|
|
2549
|
+
|
|
2550
|
+
# Architecture's own previous output, about to be overwritten.
|
|
2551
|
+
(target / "old_tree.json").write_text("old")
|
|
2552
|
+
|
|
2553
|
+
# Staging dir — what architecture wants to write.
|
|
2554
|
+
staging = tmp_p / ".agent__v1.staging.123"
|
|
2555
|
+
staging.mkdir()
|
|
2556
|
+
(staging / "agent_v1_metadata_tree.json").write_text("new-tree")
|
|
2557
|
+
(staging / "last_built_at.txt").write_text("2026-05-05T12:00:00Z\n")
|
|
2558
|
+
|
|
2559
|
+
main._swap_dir_atomic(target, staging)
|
|
2560
|
+
|
|
2561
|
+
# Sibling content survives — the whole point of the fix.
|
|
2562
|
+
self.assertTrue(
|
|
2563
|
+
(target / "sibling-data" / "payload.json").is_file()
|
|
2564
|
+
)
|
|
2565
|
+
self.assertEqual(
|
|
2566
|
+
(target / "sibling-data" / "payload.json").read_text(),
|
|
2567
|
+
"from-other-caller",
|
|
2568
|
+
)
|
|
2569
|
+
self.assertTrue((target / "_sessions" / "marker.link").is_file())
|
|
2570
|
+
|
|
2571
|
+
# Architecture's new files landed.
|
|
2572
|
+
self.assertEqual(
|
|
2573
|
+
(target / "agent_v1_metadata_tree.json").read_text(), "new-tree"
|
|
2574
|
+
)
|
|
2575
|
+
self.assertTrue((target / "last_built_at.txt").is_file())
|
|
2576
|
+
|
|
2577
|
+
# Old file that wasn't in staging stays untouched.
|
|
2578
|
+
self.assertTrue((target / "old_tree.json").is_file())
|
|
2579
|
+
self.assertEqual((target / "old_tree.json").read_text(), "old")
|
|
2580
|
+
|
|
2581
|
+
# Staging dir cleaned up.
|
|
2582
|
+
self.assertFalse(staging.exists())
|
|
2583
|
+
|
|
2584
|
+
def test_overwrites_own_files_cleanly(self) -> None:
|
|
2585
|
+
"""Same filename in both target and staging → staging content wins."""
|
|
2586
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2587
|
+
tmp_p = Path(tmp)
|
|
2588
|
+
target = tmp_p / "agent__v1"
|
|
2589
|
+
target.mkdir()
|
|
2590
|
+
(target / "tree.json").write_text("old-content")
|
|
2591
|
+
|
|
2592
|
+
staging = tmp_p / ".agent__v1.staging.123"
|
|
2593
|
+
staging.mkdir()
|
|
2594
|
+
(staging / "tree.json").write_text("new-content")
|
|
2595
|
+
|
|
2596
|
+
main._swap_dir_atomic(target, staging)
|
|
2597
|
+
|
|
2598
|
+
self.assertEqual(
|
|
2599
|
+
(target / "tree.json").read_text(), "new-content"
|
|
2600
|
+
)
|
|
2601
|
+
|
|
2602
|
+
def test_overwrites_when_target_entry_is_dir(self) -> None:
|
|
2603
|
+
"""If the old target entry is a directory and the new staging
|
|
2604
|
+
entry is a file (or vice versa), replace cleans up the old kind
|
|
2605
|
+
before os.replace lands the new one."""
|
|
2606
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2607
|
+
tmp_p = Path(tmp)
|
|
2608
|
+
target = tmp_p / "agent__v1"
|
|
2609
|
+
target.mkdir()
|
|
2610
|
+
# Old entry is a directory with nested content.
|
|
2611
|
+
(target / "payload").mkdir()
|
|
2612
|
+
(target / "payload" / "nested.txt").write_text("deep")
|
|
2613
|
+
|
|
2614
|
+
staging = tmp_p / ".agent__v1.staging.123"
|
|
2615
|
+
staging.mkdir()
|
|
2616
|
+
# New entry with the same name is a plain file.
|
|
2617
|
+
(staging / "payload").write_text("now-a-file")
|
|
2618
|
+
|
|
2619
|
+
main._swap_dir_atomic(target, staging)
|
|
2620
|
+
|
|
2621
|
+
self.assertTrue((target / "payload").is_file())
|
|
2622
|
+
self.assertEqual(
|
|
2623
|
+
(target / "payload").read_text(), "now-a-file"
|
|
2624
|
+
)
|
|
2625
|
+
|
|
2626
|
+
def test_missing_staging_raises(self) -> None:
|
|
2627
|
+
"""Pre-existing precondition: staging must exist, or we raise."""
|
|
2628
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2629
|
+
tmp_p = Path(tmp)
|
|
2630
|
+
target = tmp_p / "agent__v1"
|
|
2631
|
+
target.mkdir()
|
|
2632
|
+
staging = tmp_p / ".agent__v1.staging.does-not-exist"
|
|
2633
|
+
with self.assertRaises(OSError):
|
|
2634
|
+
main._swap_dir_atomic(target, staging)
|
|
2635
|
+
|
|
2636
|
+
def test_run_finalize_preserves_session_subdir_across_reruns(self) -> None:
|
|
2637
|
+
"""End-to-end via `_run_finalize`: after a first successful run
|
|
2638
|
+
seeds a co-tenant session dir, a second run must not wipe it.
|
|
2639
|
+
Covers the production path — the bug the user reported."""
|
|
2640
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2641
|
+
tmp_p = Path(tmp)
|
|
2642
|
+
work_dir = tmp_p / "work"
|
|
2643
|
+
work_dir.mkdir()
|
|
2644
|
+
data_dir = tmp_p / "data" / "A__v1"
|
|
2645
|
+
cache_dir = tmp_p / "cache" / "A__v1"
|
|
2646
|
+
|
|
2647
|
+
tree = {
|
|
2648
|
+
"_schema_version": "3.0",
|
|
2649
|
+
"agent": {"api_name": "A", "version": "v1"},
|
|
2650
|
+
"root": {"kind": "BOT_DEFINITION", "api_name": "A",
|
|
2651
|
+
"children": []},
|
|
2652
|
+
"node_count": 1, "depth": 0,
|
|
2653
|
+
"_kind_counts": {"BOT_DEFINITION": 1},
|
|
2654
|
+
"_pending_fetches": {k: [] for k in (
|
|
2655
|
+
"FLOW", "APEX", "PROMPT_TEMPLATE", "STANDARD_ACTION",
|
|
2656
|
+
)},
|
|
2657
|
+
"_unresolved": [],
|
|
2658
|
+
}
|
|
2659
|
+
# First run — populate data_dir.
|
|
2660
|
+
main._run_finalize(
|
|
2661
|
+
data_dir, cache_dir, dict(tree), work_dir,
|
|
2662
|
+
agent_api_name="A", agent_version="v1", planner_name="A",
|
|
2663
|
+
)
|
|
2664
|
+
# Simulate a co-tenant dropping content alongside.
|
|
2665
|
+
sibling_dir = data_dir / "sibling-payload"
|
|
2666
|
+
sibling_dir.mkdir()
|
|
2667
|
+
(sibling_dir / "payload.json").write_text("cotenant-data")
|
|
2668
|
+
|
|
2669
|
+
# Second run — the original bug: this would wipe the sibling dir.
|
|
2670
|
+
main._run_finalize(
|
|
2671
|
+
data_dir, cache_dir, dict(tree), work_dir,
|
|
2672
|
+
agent_api_name="A", agent_version="v1", planner_name="A",
|
|
2673
|
+
)
|
|
2674
|
+
|
|
2675
|
+
# Sibling dir and its contents must survive.
|
|
2676
|
+
self.assertTrue(sibling_dir.is_dir())
|
|
2677
|
+
self.assertTrue((sibling_dir / "payload.json").is_file())
|
|
2678
|
+
self.assertEqual(
|
|
2679
|
+
(sibling_dir / "payload.json").read_text(),
|
|
2680
|
+
"cotenant-data",
|
|
2681
|
+
)
|
|
2682
|
+
# Architecture's own tree is still present (re-written).
|
|
2683
|
+
self.assertTrue((data_dir / "A_v1_metadata_tree.json").is_file())
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
# ---------------------------------------------------------------------------
|
|
2687
|
+
# Bug 1 fix (2026-05-05): GenAiPromptTemplate is NOT SOQL-queryable. Id →
|
|
2688
|
+
# DeveloperName resolution runs through `sf org list metadata` (Metadata
|
|
2689
|
+
# API) via the new `list_prompt_template_metadata` helper.
|
|
2690
|
+
# ---------------------------------------------------------------------------
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
class ListPromptTemplateMetadataTests(unittest.TestCase):
|
|
2694
|
+
"""Unit tests for `metadata_listing.list_prompt_template_metadata` —
|
|
2695
|
+
thin wrapper over `run_sf("list_metadata_genaiprompttemplate", ...)`.
|
|
2696
|
+
|
|
2697
|
+
The shape we care about is produced by the sf CLI:
|
|
2698
|
+
{"status": 0, "result": [{"id": "0hfUv...", "fullName": "Foo", ...}]}
|
|
2699
|
+
Defensive returns on malformed result (None / wrong type) keep the
|
|
2700
|
+
failure mode quiet — callers already tolerate empty lists.
|
|
2701
|
+
"""
|
|
2702
|
+
|
|
2703
|
+
def test_happy_path_returns_result_rows(self):
|
|
2704
|
+
import metadata_listing # type: ignore
|
|
2705
|
+
payload = {
|
|
2706
|
+
"status": 0,
|
|
2707
|
+
"result": [
|
|
2708
|
+
{"id": "0hfUv0000021mCjIAI", "fullName": "My_Prompt",
|
|
2709
|
+
"type": "GenAiPromptTemplate"},
|
|
2710
|
+
{"id": "0hfUv0000021mOtherAAA", "fullName": "Other_Prompt",
|
|
2711
|
+
"type": "GenAiPromptTemplate"},
|
|
2712
|
+
],
|
|
2713
|
+
}
|
|
2714
|
+
with mock.patch.object(metadata_listing, "run_sf", return_value=payload):
|
|
2715
|
+
rows = metadata_listing.list_prompt_template_metadata("test-org")
|
|
2716
|
+
self.assertEqual(rows, payload["result"])
|
|
2717
|
+
|
|
2718
|
+
def test_empty_result_returns_empty_list(self):
|
|
2719
|
+
import metadata_listing # type: ignore
|
|
2720
|
+
with mock.patch.object(
|
|
2721
|
+
metadata_listing, "run_sf",
|
|
2722
|
+
return_value={"status": 0, "result": []},
|
|
2723
|
+
):
|
|
2724
|
+
rows = metadata_listing.list_prompt_template_metadata("test-org")
|
|
2725
|
+
self.assertEqual(rows, [])
|
|
2726
|
+
|
|
2727
|
+
def test_malformed_result_returns_empty_list(self):
|
|
2728
|
+
"""Defensive: if sf CLI's `result` key is not a list (e.g. None
|
|
2729
|
+
on some error paths that still exit 0), we degrade to []."""
|
|
2730
|
+
import metadata_listing # type: ignore
|
|
2731
|
+
with mock.patch.object(
|
|
2732
|
+
metadata_listing, "run_sf",
|
|
2733
|
+
return_value={"status": 0, "result": None},
|
|
2734
|
+
):
|
|
2735
|
+
rows = metadata_listing.list_prompt_template_metadata("test-org")
|
|
2736
|
+
self.assertEqual(rows, [])
|
|
2737
|
+
|
|
2738
|
+
|
|
2739
|
+
class FetchWaveBPromptTemplateMetadataWiringTests(unittest.TestCase):
|
|
2740
|
+
"""Bug 1 fix (2026-05-05): `_fetch_wave_b_by_names` invokes
|
|
2741
|
+
`list_prompt_template_metadata` (Metadata API listing), then filters +
|
|
2742
|
+
reshapes to the {Id, DeveloperName} shape the downstream pipeline
|
|
2743
|
+
expects. Verify the filter + reshape contract — only the requested
|
|
2744
|
+
Ids come back, keyed as the pipeline's existing prompt_template_id_rows
|
|
2745
|
+
shape.
|
|
2746
|
+
"""
|
|
2747
|
+
|
|
2748
|
+
def test_listmetadata_rows_filtered_and_reshaped(self):
|
|
2749
|
+
with mock.patch.object(
|
|
2750
|
+
main, "list_prompt_template_metadata",
|
|
2751
|
+
return_value=[
|
|
2752
|
+
{"id": "0hfAAA", "fullName": "TplA",
|
|
2753
|
+
"type": "GenAiPromptTemplate"},
|
|
2754
|
+
{"id": "0hfBBB", "fullName": "TplB",
|
|
2755
|
+
"type": "GenAiPromptTemplate"},
|
|
2756
|
+
],
|
|
2757
|
+
):
|
|
2758
|
+
with mock.patch.object(main, "fetch_apex_bodies_by_names", return_value=[]), \
|
|
2759
|
+
mock.patch.object(main, "fetch_apex_bodies_by_ids", return_value=[]), \
|
|
2760
|
+
mock.patch.object(main, "fetch_flow_definition_ids_by_names", return_value=[]), \
|
|
2761
|
+
mock.patch.object(main, "fetch_flow_definition_by_ids", return_value=[]):
|
|
2762
|
+
out = main._fetch_wave_b_by_names(
|
|
2763
|
+
apex_names=[],
|
|
2764
|
+
apex_ids=[],
|
|
2765
|
+
flow_names=[],
|
|
2766
|
+
flow_ids=[],
|
|
2767
|
+
prompt_template_ids=["0hfAAA"],
|
|
2768
|
+
creds_provider=lambda: ("url", "tok"),
|
|
2769
|
+
refresh_fn=lambda: ("url", "tok"),
|
|
2770
|
+
api_version="v60.0",
|
|
2771
|
+
org_alias="test-org",
|
|
2772
|
+
parallelism=2,
|
|
2773
|
+
)
|
|
2774
|
+
# Only the requested Id survives (0hfAAA); 0hfBBB is filtered out.
|
|
2775
|
+
# The shape matches the old SOQL contract: {Id, DeveloperName}.
|
|
2776
|
+
self.assertEqual(
|
|
2777
|
+
out["prompt_template_id_rows"],
|
|
2778
|
+
[{"Id": "0hfAAA", "DeveloperName": "TplA"}],
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
def test_listmetadata_failure_non_fatal(self):
|
|
2782
|
+
"""SfCliError from the Metadata listing is caught and surfaced via
|
|
2783
|
+
the unresolved channel; the run continues with an empty rowset."""
|
|
2784
|
+
from sf_cli import SfCliError # type: ignore
|
|
2785
|
+
with mock.patch.object(
|
|
2786
|
+
main, "list_prompt_template_metadata",
|
|
2787
|
+
side_effect=SfCliError("metadata-listing-exploded"),
|
|
2788
|
+
):
|
|
2789
|
+
with mock.patch.object(main, "fetch_apex_bodies_by_names", return_value=[]), \
|
|
2790
|
+
mock.patch.object(main, "fetch_apex_bodies_by_ids", return_value=[]), \
|
|
2791
|
+
mock.patch.object(main, "fetch_flow_definition_ids_by_names", return_value=[]), \
|
|
2792
|
+
mock.patch.object(main, "fetch_flow_definition_by_ids", return_value=[]):
|
|
2793
|
+
out = main._fetch_wave_b_by_names(
|
|
2794
|
+
apex_names=[],
|
|
2795
|
+
apex_ids=[],
|
|
2796
|
+
flow_names=[],
|
|
2797
|
+
flow_ids=[],
|
|
2798
|
+
prompt_template_ids=["0hfAAA"],
|
|
2799
|
+
creds_provider=lambda: ("url", "tok"),
|
|
2800
|
+
refresh_fn=lambda: ("url", "tok"),
|
|
2801
|
+
api_version="v60.0",
|
|
2802
|
+
org_alias="test-org",
|
|
2803
|
+
parallelism=2,
|
|
2804
|
+
)
|
|
2805
|
+
self.assertEqual(out["prompt_template_id_rows"], [])
|
|
2806
|
+
reasons = [u.get("reason") or "" for u in out["unresolved"]]
|
|
2807
|
+
self.assertTrue(
|
|
2808
|
+
any("prompt-template-listmetadata-failed" in r for r in reasons),
|
|
2809
|
+
f"expected listmetadata-failed in unresolved; got {reasons}",
|
|
2810
|
+
)
|
|
2811
|
+
|
|
2812
|
+
|
|
2813
|
+
class RetrievePromptTemplatesTests(unittest.TestCase):
|
|
2814
|
+
"""Gap C (2026-05-05): unit tests for
|
|
2815
|
+
`metadata_listing.retrieve_prompt_templates` — the sf CLI wrapper
|
|
2816
|
+
that retrieves GenAiPromptTemplate bodies via
|
|
2817
|
+
`sf project retrieve start --metadata GenAiPromptTemplate:...`.
|
|
2818
|
+
|
|
2819
|
+
The helper:
|
|
2820
|
+
1. Short-circuits on empty input (no sf call).
|
|
2821
|
+
2. Parses `unpackaged.zip` from the retrieve dir via stdlib
|
|
2822
|
+
`zipfile` + `xml.etree.ElementTree`.
|
|
2823
|
+
3. Extracts developerName/masterLabel/activeVersionIdentifier/
|
|
2824
|
+
templateVersions[*].content/inputs[*].
|
|
2825
|
+
4. Returns `{}` on SfCliError (non-fatal — main.py's call site
|
|
2826
|
+
logs an `_unresolved[]` entry).
|
|
2827
|
+
5. Skips files whose XML fails to parse; sibling templates still
|
|
2828
|
+
surface.
|
|
2829
|
+
"""
|
|
2830
|
+
|
|
2831
|
+
def _run_retrieve_writes_zip(self, files: dict):
|
|
2832
|
+
"""Build a mock `_run_retrieve` side_effect that writes an
|
|
2833
|
+
unpackaged.zip into the retrieve dir as the real CLI would.
|
|
2834
|
+
|
|
2835
|
+
`files = {inner_path: xml_bytes}`. `retrieve_prompt_templates`
|
|
2836
|
+
wipes the retrieve dir before invoking the subprocess, so the zip
|
|
2837
|
+
MUST be created by the mock (the retrieve dir is always the
|
|
2838
|
+
`--target-metadata-dir` argv element)."""
|
|
2839
|
+
import zipfile
|
|
2840
|
+
|
|
2841
|
+
def _side_effect(argv, timeout):
|
|
2842
|
+
target_dir = Path(argv[argv.index("--target-metadata-dir") + 1])
|
|
2843
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
2844
|
+
zip_path = target_dir / "unpackaged.zip"
|
|
2845
|
+
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
2846
|
+
for inner, xml_bytes in files.items():
|
|
2847
|
+
zf.writestr(inner, xml_bytes)
|
|
2848
|
+
return subprocess.CompletedProcess(
|
|
2849
|
+
argv, returncode=0, stdout='{"status":0,"result":{}}', stderr="",
|
|
2850
|
+
)
|
|
2851
|
+
|
|
2852
|
+
return _side_effect
|
|
2853
|
+
|
|
2854
|
+
def test_empty_input_short_circuits_without_sf_call(self):
|
|
2855
|
+
import metadata_listing # type: ignore
|
|
2856
|
+
with mock.patch.object(metadata_listing, "_run_retrieve") as mrun, \
|
|
2857
|
+
tempfile.TemporaryDirectory() as d:
|
|
2858
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
2859
|
+
"org-alias", [], Path(d),
|
|
2860
|
+
)
|
|
2861
|
+
self.assertEqual(result, {})
|
|
2862
|
+
mrun.assert_not_called()
|
|
2863
|
+
|
|
2864
|
+
def test_happy_path_single_template(self):
|
|
2865
|
+
import metadata_listing # type: ignore
|
|
2866
|
+
xml = (
|
|
2867
|
+
b'<?xml version="1.0" encoding="UTF-8"?>'
|
|
2868
|
+
b'<GenAiPromptTemplate xmlns="http://soap.sforce.com/2006/04/metadata">'
|
|
2869
|
+
b'<developerName>My_Prompt</developerName>'
|
|
2870
|
+
b'<masterLabel>My Prompt</masterLabel>'
|
|
2871
|
+
b'<activeVersionIdentifier>v1</activeVersionIdentifier>'
|
|
2872
|
+
b'<templateVersions>'
|
|
2873
|
+
b'<versionIdentifier>v1</versionIdentifier>'
|
|
2874
|
+
b'<content># ROLE\nHello {{$Input:Query}}</content>'
|
|
2875
|
+
b'<inputs><apiName>Query</apiName><dataType>String</dataType></inputs>'
|
|
2876
|
+
b'</templateVersions>'
|
|
2877
|
+
b'</GenAiPromptTemplate>'
|
|
2878
|
+
)
|
|
2879
|
+
with tempfile.TemporaryDirectory() as d:
|
|
2880
|
+
tmp = Path(d)
|
|
2881
|
+
side_effect = self._run_retrieve_writes_zip({
|
|
2882
|
+
"unpackaged/genAiPromptTemplates/My_Prompt.genAiPromptTemplate": xml,
|
|
2883
|
+
"unpackaged/package.xml": b"<Package/>",
|
|
2884
|
+
})
|
|
2885
|
+
with mock.patch.object(
|
|
2886
|
+
metadata_listing, "_run_retrieve", side_effect=side_effect,
|
|
2887
|
+
):
|
|
2888
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
2889
|
+
"org-alias", ["My_Prompt"], tmp,
|
|
2890
|
+
)
|
|
2891
|
+
self.assertIn("My_Prompt", result)
|
|
2892
|
+
body = result["My_Prompt"]
|
|
2893
|
+
self.assertEqual(body["developerName"], "My_Prompt")
|
|
2894
|
+
self.assertEqual(body["masterLabel"], "My Prompt")
|
|
2895
|
+
self.assertEqual(body["activeVersionIdentifier"], "v1")
|
|
2896
|
+
self.assertEqual(body["content"], "# ROLE\nHello {{$Input:Query}}")
|
|
2897
|
+
self.assertEqual(
|
|
2898
|
+
body["inputs"], [{"name": "Query", "dataType": "String"}],
|
|
2899
|
+
)
|
|
2900
|
+
|
|
2901
|
+
def test_sf_cli_error_returns_empty_dict(self):
|
|
2902
|
+
"""Non-zero exit without auth-pattern stderr is swallowed as
|
|
2903
|
+
non-fatal — caller gets an empty dict."""
|
|
2904
|
+
import metadata_listing # type: ignore
|
|
2905
|
+
failure = subprocess.CompletedProcess(
|
|
2906
|
+
args=[], returncode=1, stdout="", stderr="Error: retrieve blew up",
|
|
2907
|
+
)
|
|
2908
|
+
with tempfile.TemporaryDirectory() as d:
|
|
2909
|
+
with mock.patch.object(
|
|
2910
|
+
metadata_listing, "_run_retrieve", return_value=failure,
|
|
2911
|
+
):
|
|
2912
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
2913
|
+
"org-alias", ["Foo"], Path(d),
|
|
2914
|
+
)
|
|
2915
|
+
self.assertEqual(result, {})
|
|
2916
|
+
|
|
2917
|
+
def test_auth_required_reraises(self):
|
|
2918
|
+
"""Non-zero exit WITH auth-pattern stderr raises AuthRequired."""
|
|
2919
|
+
import metadata_listing # type: ignore
|
|
2920
|
+
from sf_cli import AuthRequired # type: ignore
|
|
2921
|
+
failure = subprocess.CompletedProcess(
|
|
2922
|
+
args=[], returncode=1, stdout="",
|
|
2923
|
+
stderr="Error: NoOrgAuthenticationError — login required",
|
|
2924
|
+
)
|
|
2925
|
+
with tempfile.TemporaryDirectory() as d:
|
|
2926
|
+
with mock.patch.object(
|
|
2927
|
+
metadata_listing, "_run_retrieve", return_value=failure,
|
|
2928
|
+
):
|
|
2929
|
+
with self.assertRaises(AuthRequired):
|
|
2930
|
+
metadata_listing.retrieve_prompt_templates(
|
|
2931
|
+
"org-alias", ["Foo"], Path(d),
|
|
2932
|
+
)
|
|
2933
|
+
|
|
2934
|
+
def test_missing_zip_returns_empty_dict(self):
|
|
2935
|
+
import metadata_listing # type: ignore
|
|
2936
|
+
# _run_retrieve succeeds but leaves no unpackaged.zip on disk.
|
|
2937
|
+
success_no_zip = subprocess.CompletedProcess(
|
|
2938
|
+
args=[], returncode=0, stdout='{"status":0,"result":{}}', stderr="",
|
|
2939
|
+
)
|
|
2940
|
+
with tempfile.TemporaryDirectory() as d:
|
|
2941
|
+
with mock.patch.object(
|
|
2942
|
+
metadata_listing, "_run_retrieve", return_value=success_no_zip,
|
|
2943
|
+
):
|
|
2944
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
2945
|
+
"org-alias", ["Foo"], Path(d),
|
|
2946
|
+
)
|
|
2947
|
+
self.assertEqual(result, {})
|
|
2948
|
+
|
|
2949
|
+
def test_multi_version_picks_active(self):
|
|
2950
|
+
import metadata_listing # type: ignore
|
|
2951
|
+
xml = (
|
|
2952
|
+
b'<?xml version="1.0" encoding="UTF-8"?>'
|
|
2953
|
+
b'<GenAiPromptTemplate xmlns="http://soap.sforce.com/2006/04/metadata">'
|
|
2954
|
+
b'<developerName>Versioned</developerName>'
|
|
2955
|
+
b'<activeVersionIdentifier>v2</activeVersionIdentifier>'
|
|
2956
|
+
b'<templateVersions>'
|
|
2957
|
+
b'<versionIdentifier>v1</versionIdentifier>'
|
|
2958
|
+
b'<content>OLD</content>'
|
|
2959
|
+
b'</templateVersions>'
|
|
2960
|
+
b'<templateVersions>'
|
|
2961
|
+
b'<versionIdentifier>v2</versionIdentifier>'
|
|
2962
|
+
b'<content>ACTIVE</content>'
|
|
2963
|
+
b'</templateVersions>'
|
|
2964
|
+
b'<templateVersions>'
|
|
2965
|
+
b'<versionIdentifier>v3</versionIdentifier>'
|
|
2966
|
+
b'<content>DRAFT</content>'
|
|
2967
|
+
b'</templateVersions>'
|
|
2968
|
+
b'</GenAiPromptTemplate>'
|
|
2969
|
+
)
|
|
2970
|
+
with tempfile.TemporaryDirectory() as d:
|
|
2971
|
+
tmp = Path(d)
|
|
2972
|
+
side_effect = self._run_retrieve_writes_zip({
|
|
2973
|
+
"unpackaged/genAiPromptTemplates/Versioned.genAiPromptTemplate": xml,
|
|
2974
|
+
})
|
|
2975
|
+
with mock.patch.object(
|
|
2976
|
+
metadata_listing, "_run_retrieve", side_effect=side_effect,
|
|
2977
|
+
):
|
|
2978
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
2979
|
+
"org-alias", ["Versioned"], tmp,
|
|
2980
|
+
)
|
|
2981
|
+
self.assertEqual(result["Versioned"]["content"], "ACTIVE")
|
|
2982
|
+
|
|
2983
|
+
def test_template_with_no_inputs(self):
|
|
2984
|
+
import metadata_listing # type: ignore
|
|
2985
|
+
xml = (
|
|
2986
|
+
b'<?xml version="1.0" encoding="UTF-8"?>'
|
|
2987
|
+
b'<GenAiPromptTemplate xmlns="http://soap.sforce.com/2006/04/metadata">'
|
|
2988
|
+
b'<developerName>NoInputs</developerName>'
|
|
2989
|
+
b'<activeVersionIdentifier>v1</activeVersionIdentifier>'
|
|
2990
|
+
b'<templateVersions>'
|
|
2991
|
+
b'<versionIdentifier>v1</versionIdentifier>'
|
|
2992
|
+
b'<content>Prompt body</content>'
|
|
2993
|
+
b'</templateVersions>'
|
|
2994
|
+
b'</GenAiPromptTemplate>'
|
|
2995
|
+
)
|
|
2996
|
+
with tempfile.TemporaryDirectory() as d:
|
|
2997
|
+
tmp = Path(d)
|
|
2998
|
+
side_effect = self._run_retrieve_writes_zip({
|
|
2999
|
+
"unpackaged/genAiPromptTemplates/NoInputs.genAiPromptTemplate": xml,
|
|
3000
|
+
})
|
|
3001
|
+
with mock.patch.object(
|
|
3002
|
+
metadata_listing, "_run_retrieve", side_effect=side_effect,
|
|
3003
|
+
):
|
|
3004
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
3005
|
+
"org-alias", ["NoInputs"], tmp,
|
|
3006
|
+
)
|
|
3007
|
+
self.assertEqual(result["NoInputs"]["inputs"], [])
|
|
3008
|
+
|
|
3009
|
+
def test_xml_parse_failure_skips_file_continues(self):
|
|
3010
|
+
"""Malformed XML on one template must not prevent sibling
|
|
3011
|
+
templates from surfacing."""
|
|
3012
|
+
import metadata_listing # type: ignore
|
|
3013
|
+
good_xml = (
|
|
3014
|
+
b'<?xml version="1.0" encoding="UTF-8"?>'
|
|
3015
|
+
b'<GenAiPromptTemplate xmlns="http://soap.sforce.com/2006/04/metadata">'
|
|
3016
|
+
b'<developerName>GoodTpl</developerName>'
|
|
3017
|
+
b'<activeVersionIdentifier>v1</activeVersionIdentifier>'
|
|
3018
|
+
b'<templateVersions>'
|
|
3019
|
+
b'<versionIdentifier>v1</versionIdentifier>'
|
|
3020
|
+
b'<content>Fine</content>'
|
|
3021
|
+
b'</templateVersions>'
|
|
3022
|
+
b'</GenAiPromptTemplate>'
|
|
3023
|
+
)
|
|
3024
|
+
broken_xml = b"<not valid xml"
|
|
3025
|
+
with tempfile.TemporaryDirectory() as d:
|
|
3026
|
+
tmp = Path(d)
|
|
3027
|
+
side_effect = self._run_retrieve_writes_zip({
|
|
3028
|
+
"unpackaged/genAiPromptTemplates/GoodTpl.genAiPromptTemplate": good_xml,
|
|
3029
|
+
"unpackaged/genAiPromptTemplates/Broken.genAiPromptTemplate": broken_xml,
|
|
3030
|
+
})
|
|
3031
|
+
with mock.patch.object(
|
|
3032
|
+
metadata_listing, "_run_retrieve", side_effect=side_effect,
|
|
3033
|
+
):
|
|
3034
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
3035
|
+
"org-alias", ["GoodTpl", "Broken"], tmp,
|
|
3036
|
+
)
|
|
3037
|
+
self.assertIn("GoodTpl", result)
|
|
3038
|
+
self.assertNotIn("Broken", result)
|
|
3039
|
+
|
|
3040
|
+
def test_stale_retrieve_dir_contents_wiped(self):
|
|
3041
|
+
"""A prior invocation's `unpackaged.zip` must not be trusted if
|
|
3042
|
+
this run's sf call fails before writing a new one."""
|
|
3043
|
+
import metadata_listing # type: ignore
|
|
3044
|
+
failure = subprocess.CompletedProcess(
|
|
3045
|
+
args=[], returncode=1, stdout="", stderr="Error: boom",
|
|
3046
|
+
)
|
|
3047
|
+
with tempfile.TemporaryDirectory() as d:
|
|
3048
|
+
tmp = Path(d)
|
|
3049
|
+
# Plant a stale zip pretending a prior run left it.
|
|
3050
|
+
stale = tmp / "prompt_template_retrieve"
|
|
3051
|
+
stale.mkdir(parents=True, exist_ok=True)
|
|
3052
|
+
(stale / "unpackaged.zip").write_bytes(b"stale")
|
|
3053
|
+
with mock.patch.object(
|
|
3054
|
+
metadata_listing, "_run_retrieve", return_value=failure,
|
|
3055
|
+
):
|
|
3056
|
+
result = metadata_listing.retrieve_prompt_templates(
|
|
3057
|
+
"org-alias", ["Foo"], tmp,
|
|
3058
|
+
)
|
|
3059
|
+
self.assertEqual(result, {})
|
|
3060
|
+
# Stale file should have been nuked before the sf call.
|
|
3061
|
+
self.assertFalse((stale / "unpackaged.zip").exists())
|
|
3062
|
+
|
|
3063
|
+
def test_retrieve_builds_one_metadata_flag_per_name(self):
|
|
3064
|
+
"""Live proof (2026-05-05): `sf project retrieve start` treats a
|
|
3065
|
+
comma-joined `--metadata TypeA:A,TypeA:B` as ONE malformed member
|
|
3066
|
+
name and silently produces a package-xml-only zip. The fix is to
|
|
3067
|
+
repeat `--metadata` once per template. Lock that shape in."""
|
|
3068
|
+
import metadata_listing # type: ignore
|
|
3069
|
+
captured_argv: list[list[str]] = []
|
|
3070
|
+
|
|
3071
|
+
def _capture(argv, timeout):
|
|
3072
|
+
captured_argv.append(list(argv))
|
|
3073
|
+
return subprocess.CompletedProcess(
|
|
3074
|
+
argv, returncode=0,
|
|
3075
|
+
stdout='{"status":0,"result":{}}', stderr="",
|
|
3076
|
+
)
|
|
3077
|
+
|
|
3078
|
+
with tempfile.TemporaryDirectory() as d:
|
|
3079
|
+
with mock.patch.object(
|
|
3080
|
+
metadata_listing, "_run_retrieve", side_effect=_capture,
|
|
3081
|
+
):
|
|
3082
|
+
metadata_listing.retrieve_prompt_templates(
|
|
3083
|
+
"org-alias", ["A", "B", "C"], Path(d),
|
|
3084
|
+
)
|
|
3085
|
+
|
|
3086
|
+
self.assertEqual(len(captured_argv), 1)
|
|
3087
|
+
argv = captured_argv[0]
|
|
3088
|
+
self.assertEqual(argv.count("--metadata"), 3)
|
|
3089
|
+
# Each `--metadata` flag MUST be followed by a single
|
|
3090
|
+
# `GenAiPromptTemplate:<name>` — never a comma-joined string.
|
|
3091
|
+
for name in ("A", "B", "C"):
|
|
3092
|
+
spec = f"GenAiPromptTemplate:{name}"
|
|
3093
|
+
idx = argv.index(spec)
|
|
3094
|
+
self.assertEqual(argv[idx - 1], "--metadata")
|
|
3095
|
+
self.assertNotIn(",", spec)
|
|
3096
|
+
|
|
3097
|
+
|
|
3098
|
+
class CollectPromptTemplateNamesTests(unittest.TestCase):
|
|
3099
|
+
"""Gap C: `_collect_prompt_template_names` walks topics + plannerActions
|
|
3100
|
+
and returns the set of DeveloperNames that should be passed to
|
|
3101
|
+
`retrieve_prompt_templates`. Defensive against residual 0hf-Ids
|
|
3102
|
+
(normalization missed) so the retrieve CLI doesn't get a malformed
|
|
3103
|
+
spec."""
|
|
3104
|
+
|
|
3105
|
+
def test_collects_topic_action_names(self):
|
|
3106
|
+
bundle = {
|
|
3107
|
+
"topics": [{
|
|
3108
|
+
"actions": [
|
|
3109
|
+
{"invocationTarget": "Foo_Tpl",
|
|
3110
|
+
"invocationTargetType": "GenAiPromptTemplate"},
|
|
3111
|
+
{"invocationTarget": "Bar_Tpl",
|
|
3112
|
+
"invocationTargetType": "generatePromptResponse"},
|
|
3113
|
+
],
|
|
3114
|
+
}],
|
|
3115
|
+
"plannerActions": [],
|
|
3116
|
+
}
|
|
3117
|
+
names = main._collect_prompt_template_names(bundle)
|
|
3118
|
+
self.assertEqual(names, {"Foo_Tpl", "Bar_Tpl"})
|
|
3119
|
+
|
|
3120
|
+
def test_collects_planner_action_names(self):
|
|
3121
|
+
bundle = {
|
|
3122
|
+
"topics": [],
|
|
3123
|
+
"plannerActions": [
|
|
3124
|
+
{"invocationTarget": "Planner_Tpl",
|
|
3125
|
+
"invocationTargetType": "GenAiPromptTemplate"},
|
|
3126
|
+
],
|
|
3127
|
+
}
|
|
3128
|
+
names = main._collect_prompt_template_names(bundle)
|
|
3129
|
+
self.assertEqual(names, {"Planner_Tpl"})
|
|
3130
|
+
|
|
3131
|
+
def test_skips_non_prompt_ttype(self):
|
|
3132
|
+
bundle = {
|
|
3133
|
+
"topics": [{
|
|
3134
|
+
"actions": [
|
|
3135
|
+
{"invocationTarget": "MyFlow",
|
|
3136
|
+
"invocationTargetType": "flow"},
|
|
3137
|
+
{"invocationTarget": "MyApex",
|
|
3138
|
+
"invocationTargetType": "apex"},
|
|
3139
|
+
],
|
|
3140
|
+
}],
|
|
3141
|
+
"plannerActions": [],
|
|
3142
|
+
}
|
|
3143
|
+
names = main._collect_prompt_template_names(bundle)
|
|
3144
|
+
self.assertEqual(names, set())
|
|
3145
|
+
|
|
3146
|
+
def test_skips_residual_0hf_id_targets(self):
|
|
3147
|
+
"""If normalization missed an Id (template not in org
|
|
3148
|
+
list-metadata), don't send a malformed spec to retrieve."""
|
|
3149
|
+
bundle = {
|
|
3150
|
+
"topics": [{
|
|
3151
|
+
"actions": [
|
|
3152
|
+
{"invocationTarget": "0hfUv0000021mCjIAI",
|
|
3153
|
+
"invocationTargetType": "GenAiPromptTemplate"},
|
|
3154
|
+
{"invocationTarget": "Clean_Tpl",
|
|
3155
|
+
"invocationTargetType": "GenAiPromptTemplate"},
|
|
3156
|
+
],
|
|
3157
|
+
}],
|
|
3158
|
+
"plannerActions": [],
|
|
3159
|
+
}
|
|
3160
|
+
names = main._collect_prompt_template_names(bundle)
|
|
3161
|
+
self.assertEqual(names, {"Clean_Tpl"})
|
|
3162
|
+
|
|
3163
|
+
|
|
3164
|
+
class PromptTemplateBodyAttachmentTests(unittest.TestCase):
|
|
3165
|
+
"""Gap C: `_stamp_prompt_template_bodies` attaches retrieved body
|
|
3166
|
+
fields onto matching PROMPT_TEMPLATE leaves. Unmatched leaves get
|
|
3167
|
+
`_body_available = False` so the renderer can distinguish a failed
|
|
3168
|
+
retrieve from a successfully-empty body."""
|
|
3169
|
+
|
|
3170
|
+
def test_stamps_body_onto_matching_leaf(self):
|
|
3171
|
+
tree = {
|
|
3172
|
+
"root": {
|
|
3173
|
+
"kind": "BOT_DEFINITION",
|
|
3174
|
+
"children": [{
|
|
3175
|
+
"kind": "TOPIC", "api_name": "T",
|
|
3176
|
+
"children": [{
|
|
3177
|
+
"kind": "GEN_AI_FUNCTION", "api_name": "F",
|
|
3178
|
+
"children": [{
|
|
3179
|
+
"kind": "PROMPT_TEMPLATE", "api_name": "Tpl",
|
|
3180
|
+
}],
|
|
3181
|
+
}],
|
|
3182
|
+
}],
|
|
3183
|
+
},
|
|
3184
|
+
}
|
|
3185
|
+
bodies = {
|
|
3186
|
+
"Tpl": {
|
|
3187
|
+
"developerName": "Tpl",
|
|
3188
|
+
"masterLabel": "Template One",
|
|
3189
|
+
"activeVersionIdentifier": "v1",
|
|
3190
|
+
"content": "Prompt body",
|
|
3191
|
+
"inputs": [{"name": "Q", "dataType": "String"}],
|
|
3192
|
+
},
|
|
3193
|
+
}
|
|
3194
|
+
main._stamp_prompt_template_bodies(tree["root"], bodies)
|
|
3195
|
+
leaf = tree["root"]["children"][0]["children"][0]["children"][0]
|
|
3196
|
+
self.assertEqual(leaf["master_label"], "Template One")
|
|
3197
|
+
self.assertEqual(leaf["content"], "Prompt body")
|
|
3198
|
+
self.assertEqual(leaf["inputs"], [{"name": "Q", "dataType": "String"}])
|
|
3199
|
+
self.assertTrue(leaf["_body_available"])
|
|
3200
|
+
|
|
3201
|
+
def test_unmatched_leaf_gets_body_available_false(self):
|
|
3202
|
+
tree = {
|
|
3203
|
+
"root": {
|
|
3204
|
+
"kind": "BOT_DEFINITION",
|
|
3205
|
+
"children": [{
|
|
3206
|
+
"kind": "PROMPT_TEMPLATE", "api_name": "Missing",
|
|
3207
|
+
}],
|
|
3208
|
+
},
|
|
3209
|
+
}
|
|
3210
|
+
main._stamp_prompt_template_bodies(tree["root"], {})
|
|
3211
|
+
self.assertFalse(tree["root"]["children"][0]["_body_available"])
|
|
3212
|
+
self.assertNotIn("content", tree["root"]["children"][0])
|
|
3213
|
+
|
|
3214
|
+
def test_does_not_touch_non_prompt_leaves(self):
|
|
3215
|
+
tree = {
|
|
3216
|
+
"root": {
|
|
3217
|
+
"kind": "BOT_DEFINITION",
|
|
3218
|
+
"children": [
|
|
3219
|
+
{"kind": "APEX", "api_name": "Cls"},
|
|
3220
|
+
{"kind": "FLOW", "api_name": "Flw"},
|
|
3221
|
+
],
|
|
3222
|
+
},
|
|
3223
|
+
}
|
|
3224
|
+
main._stamp_prompt_template_bodies(
|
|
3225
|
+
tree["root"], {"Cls": {"content": "x"}, "Flw": {"content": "y"}},
|
|
3226
|
+
)
|
|
3227
|
+
# Neither APEX nor FLOW leaves gain body fields.
|
|
3228
|
+
self.assertNotIn("content", tree["root"]["children"][0])
|
|
3229
|
+
self.assertNotIn("_body_available", tree["root"]["children"][0])
|
|
3230
|
+
self.assertNotIn("content", tree["root"]["children"][1])
|
|
3231
|
+
self.assertNotIn("_body_available", tree["root"]["children"][1])
|
|
3232
|
+
|
|
3233
|
+
|
|
3234
|
+
class TreeChildOrderingTests(unittest.TestCase):
|
|
3235
|
+
"""Schema 3.1 (2026-05-05) pins deterministic child ordering at the
|
|
3236
|
+
tree's single source of truth (`finalize.sort_tree_in_place`) so
|
|
3237
|
+
downstream readers see a byte-stable order regardless of Builder
|
|
3238
|
+
reorder operations or SOQL result-set sequencing.
|
|
3239
|
+
|
|
3240
|
+
Contract:
|
|
3241
|
+
- `BOT_DEFINITION.children`: TOPIC nodes first (alpha, case-insensitive);
|
|
3242
|
+
non-topic plannerActions follow as a distinct trailing group.
|
|
3243
|
+
- Each TOPIC's children: alpha by api_name, case-insensitive.
|
|
3244
|
+
- FLOW children untouched — flow-actionCall order is semantically
|
|
3245
|
+
the author's execution sequence.
|
|
3246
|
+
"""
|
|
3247
|
+
|
|
3248
|
+
def _sort(self, root: dict) -> dict:
|
|
3249
|
+
from finalize import sort_tree_in_place
|
|
3250
|
+
sort_tree_in_place(root)
|
|
3251
|
+
return root
|
|
3252
|
+
|
|
3253
|
+
def test_topics_sorted_alphabetical_case_insensitive(self):
|
|
3254
|
+
root = {
|
|
3255
|
+
"kind": "BOT_DEFINITION",
|
|
3256
|
+
"api_name": "Bot",
|
|
3257
|
+
"children": [
|
|
3258
|
+
{"kind": "TOPIC", "api_name": "Zeta", "children": []},
|
|
3259
|
+
{"kind": "TOPIC", "api_name": "alpha", "children": []},
|
|
3260
|
+
{"kind": "TOPIC", "api_name": "Mike", "children": []},
|
|
3261
|
+
],
|
|
3262
|
+
}
|
|
3263
|
+
self._sort(root)
|
|
3264
|
+
names = [c["api_name"] for c in root["children"]]
|
|
3265
|
+
self.assertEqual(names, ["alpha", "Mike", "Zeta"])
|
|
3266
|
+
|
|
3267
|
+
def test_topics_precede_planner_level_actions(self):
|
|
3268
|
+
"""Non-topic plannerAction children (e.g. a GEN_AI_FUNCTION hung
|
|
3269
|
+
directly off the planner, no parent TOPIC) MUST render after all
|
|
3270
|
+
TOPIC children, regardless of api_name ordering. The "planner-
|
|
3271
|
+
level actions are a distinct trailing group" convention is load-
|
|
3272
|
+
bearing for humans scanning the rendered tree."""
|
|
3273
|
+
root = {
|
|
3274
|
+
"kind": "BOT_DEFINITION",
|
|
3275
|
+
"api_name": "Bot",
|
|
3276
|
+
"children": [
|
|
3277
|
+
# Non-topic node with an api_name that would sort FIRST
|
|
3278
|
+
# alphabetically — the tier rule must override alpha.
|
|
3279
|
+
{"kind": "GEN_AI_FUNCTION", "api_name": "aaa_action", "children": []},
|
|
3280
|
+
{"kind": "TOPIC", "api_name": "Zeta", "children": []},
|
|
3281
|
+
{"kind": "TOPIC", "api_name": "Mike", "children": []},
|
|
3282
|
+
],
|
|
3283
|
+
}
|
|
3284
|
+
self._sort(root)
|
|
3285
|
+
kinds = [c["kind"] for c in root["children"]]
|
|
3286
|
+
self.assertEqual(kinds, ["TOPIC", "TOPIC", "GEN_AI_FUNCTION"])
|
|
3287
|
+
# Alpha within the TOPIC tier is preserved.
|
|
3288
|
+
self.assertEqual(root["children"][0]["api_name"], "Mike")
|
|
3289
|
+
self.assertEqual(root["children"][1]["api_name"], "Zeta")
|
|
3290
|
+
|
|
3291
|
+
def test_topic_children_sorted_alphabetical(self):
|
|
3292
|
+
root = {
|
|
3293
|
+
"kind": "BOT_DEFINITION",
|
|
3294
|
+
"api_name": "Bot",
|
|
3295
|
+
"children": [
|
|
3296
|
+
{
|
|
3297
|
+
"kind": "TOPIC", "api_name": "OnlyTopic",
|
|
3298
|
+
"children": [
|
|
3299
|
+
{"kind": "GEN_AI_FUNCTION", "api_name": "Zebra"},
|
|
3300
|
+
{"kind": "GEN_AI_FUNCTION", "api_name": "apple"},
|
|
3301
|
+
{"kind": "GEN_AI_FUNCTION", "api_name": "Mango"},
|
|
3302
|
+
],
|
|
3303
|
+
},
|
|
3304
|
+
],
|
|
3305
|
+
}
|
|
3306
|
+
self._sort(root)
|
|
3307
|
+
kids = root["children"][0]["children"]
|
|
3308
|
+
self.assertEqual(
|
|
3309
|
+
[c["api_name"] for c in kids],
|
|
3310
|
+
["apple", "Mango", "Zebra"],
|
|
3311
|
+
)
|
|
3312
|
+
|
|
3313
|
+
def test_flow_children_order_preserved(self):
|
|
3314
|
+
"""FLOW actionCall order is the flow author's execution sequence.
|
|
3315
|
+
sort_tree_in_place does NOT descend into FLOW children."""
|
|
3316
|
+
root = {
|
|
3317
|
+
"kind": "BOT_DEFINITION",
|
|
3318
|
+
"api_name": "Bot",
|
|
3319
|
+
"children": [
|
|
3320
|
+
{
|
|
3321
|
+
"kind": "TOPIC", "api_name": "T",
|
|
3322
|
+
"children": [
|
|
3323
|
+
{
|
|
3324
|
+
"kind": "GEN_AI_FUNCTION", "api_name": "Fn",
|
|
3325
|
+
"children": [
|
|
3326
|
+
{
|
|
3327
|
+
"kind": "FLOW", "api_name": "ParentFlow",
|
|
3328
|
+
"children": [
|
|
3329
|
+
{"kind": "APEX", "api_name": "zzz"},
|
|
3330
|
+
{"kind": "APEX", "api_name": "aaa"},
|
|
3331
|
+
],
|
|
3332
|
+
},
|
|
3333
|
+
],
|
|
3334
|
+
},
|
|
3335
|
+
],
|
|
3336
|
+
},
|
|
3337
|
+
],
|
|
3338
|
+
}
|
|
3339
|
+
self._sort(root)
|
|
3340
|
+
flow = root["children"][0]["children"][0]["children"][0]
|
|
3341
|
+
self.assertEqual(
|
|
3342
|
+
[c["api_name"] for c in flow["children"]],
|
|
3343
|
+
["zzz", "aaa"],
|
|
3344
|
+
)
|
|
3345
|
+
|
|
3346
|
+
def test_empty_children_noop(self):
|
|
3347
|
+
root = {"kind": "BOT_DEFINITION", "api_name": "Bot", "children": []}
|
|
3348
|
+
self._sort(root)
|
|
3349
|
+
self.assertEqual(root["children"], [])
|
|
3350
|
+
|
|
3351
|
+
def test_missing_root_noop(self):
|
|
3352
|
+
"""Defensive — a degenerate tree shouldn't raise."""
|
|
3353
|
+
from finalize import sort_tree_in_place
|
|
3354
|
+
sort_tree_in_place(None) # type: ignore[arg-type]
|
|
3355
|
+
sort_tree_in_place({})
|
|
3356
|
+
|
|
3357
|
+
|
|
3358
|
+
if __name__ == "__main__":
|
|
3359
|
+
unittest.main()
|