agentforce-architecture-analyze — declared architecture snapshot Design-time metadata tree for one Agentforce agent: planner → topics → actions → flows → Apex → prompts → NGA plugins. Reads declared metadata only — , , , , , , . Does not read runtime audit rows. Runtime budget: 30–45s typical, ≤60s hard cap on reference fixtures. Sequential baseline would be 90–220s; parallel Tooling SOQL fan-out delivers a 3–5× speedup. Large bots with many flows scale approximately linearly — each flow metadata retrieve is one round-trip. Runs inline — no subagent. Every phase is deterministic file processi…

\\n'*}\"\nfi\n\n# Usage block if required flags missing. Agent reads stderr,\n# prints verbatim, and stops — does NOT pre-run main.py.\nif [ -z \"$ARG_ORG\" ] || [ -z \"$ARG_AGENT\" ]; then\n cat >&2 \u003c\u003c'USAGE'\n> Which agent should I document, and in which org?\n>\n> I need:\n> - **Agent API name** — the BotDefinition.DeveloperName (e.g. `MyAgent`)\n> - **Org alias** — for `sf` CLI auth (the alias you configured with `sf org login`);\n> only needed when no default target org is set (`sf config set target-org \u003calias>`)\n>\n> Optional flags:\n> - `--version v5` — pin a specific BotVersion (default: Active+highest)\n> - `--force` — bypass cache\n> - `--reprobe` — force channel-probe refresh\n> - `--parallelism N` — ThreadPoolExecutor size (default 5)\n> - `--max-mermaid-nodes N` — cap Mermaid node count (default 80)\nUSAGE\n exit 2\nfi\n\n# Fresh work dir per invocation. Epoch + random suffix avoids collisions\n# between concurrent runs on the same host.\nWORK_DIR=\"/tmp/agentforce-architecture-analyze-$(date +%s)-$RANDOM\"\nmkdir -p \"$WORK_DIR\"\n\n# Input validation at the boundary, BEFORE any python3 call.\n# fs_guard exits 1 and prints an INVALID_INPUT RESULT block on failure;\n# `|| exit 1` is mandatory — bare calls silently continue past failures.\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$ARG_AGENT\" agent_api_name api_name || exit 1\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$ARG_ORG\" org_alias not_empty || exit 1\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$WORK_DIR\" WORK_DIR symlink || exit 1\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$WORK_DIR\" WORK_DIR owned || exit 1\nif [ -n \"$ARG_VERSION\" ]; then\n python3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$ARG_VERSION\" agent_version api_name || exit 1\nfi\n\n# Single python3 call drives all pipeline phases. main.py writes\n# `.emit_ctx.json` into $WORK_DIR — emit_result.py then renders the\n# RESULT block from that ctx. No subprocess-per-phase.\n_main_args=(--org-alias \"$ARG_ORG\" --agent \"$ARG_AGENT\" --work-dir \"$WORK_DIR\")\n[ -n \"$ARG_VERSION\" ] && _main_args+=(--version \"$ARG_VERSION\")\n[ -n \"$ARG_FORCE\" ] && _main_args+=(--force)\n[ -n \"$ARG_REPROBE\" ] && _main_args+=(--reprobe)\n[ -n \"$ARG_PARALLELISM\" ] && _main_args+=(--parallelism \"$ARG_PARALLELISM\")\n[ -n \"$ARG_MAX_MERMAID\" ] && _main_args+=(--max-mermaid-nodes \"$ARG_MAX_MERMAID\")\n\n# main.py returns nonzero on terminal failures; we DON'T short-circuit —\n# emit_result still publishes the failure RESULT block. `set -e` is\n# temporarily relaxed around this single call.\nset +e\npython3 \"$SKILL_ROOT/scripts/main.py\" \"${_main_args[@]}\"\n_rc=$?\nset -e\n\n# Final RESULT block is emit_result.py's stdout — MUST be the last thing\n# stdout sees. emit_result exits 0 on render success; the bash harness\n# propagates main.py's rc for the agent's exit status.\nWORK_DIR=\"$WORK_DIR\" python3 \"$SKILL_ROOT/scripts/emit_result.py\"\nexit \"$_rc\"\n```\n\n## Inputs\n\n| Input | Flag | Required | Default |\n|---|---|---|---|\n| `org_alias` | `--org` | no | sf CLI default target org (`sf config get target-org`) |\n| `agent_api_name` | `--agent` | yes | — |\n| `agent_version_api_name` | `--version` | no | active BotVersion |\n| `force_refresh` | `--force` | no | false (honor cache) |\n| `reprobe` | `--reprobe` | no | false (honor 7-day channel-probe cache) |\n| `parallelism` | `--parallelism` | no | 5 |\n| `max_mermaid_nodes` | `--max-mermaid-nodes` | no | 80 |\n| `data_dir` | `--data-dir` | no | `~/.vibe/data/agentforce-architecture-analyze` |\n| `cache_dir` | `--cache-dir` | no | `~/.vibe/cache/agentforce-architecture-analyze` |\n\n## Outputs\n\nAll artifacts under `~/.vibe/data/agentforce-architecture-analyze/\u003corg_id15>/\u003cagent_api_name>__\u003cagent_version>/` (default; override with `--data-dir \u003cpath>`):\n\n```xml\n\u003cagent>_\u003cver>_metadata_tree.json primary artifact — normalized planner/topic/action/flow/apex/prompt/plugin tree\n\u003cagent>_\u003cver>_architecture.md human-readable section-by-section rendering (H1 + 7 numbered sections, plus a conditional Dependency graph appendix). Mermaid diagrams are embedded inside the relevant sections (Action tree, Data flow, and Dependency graph)\n```\n\n## Pipeline — inline, no subagent\n\n```text\nresolve_bot.py → BotDefinition + BotVersion + planner name lookup\nretrieve_planner.py → Metadata API zip retrieve for GenAiPlannerBundle (+ NGA plugins if present)\nparallel_retrieve.py → 6 parallel Tooling SOQL channels fan out from the planner id\n (resolved by the `planner_definition_by_agent_chain` seed query):\n - plugins_by_planner (GenAiPluginDefinition)\n - planner_bundle_functions (GenAiPlannerFunctionDef join)\n - functions_by_plugins (GenAiFunctionDefinition)\n - planner_attrs_by_parent_ids (GenAiPlannerAttrDefinition)\n - plugin_functions_by_plugin_ids (GenAiPluginFunctionDef join)\n - plugin_instructions_by_plugin_ids (GenAiPluginInstructionDef)\nparse_bundle.py → parse retrieved XML into normalized node shapes\nparse_wave.py → BFS expansion: flow/apex/prompt refs discovered in nodes\n → SOQL for Flow/Apex bodies (batched by id list)\n → Metadata retrieve ONLY for GenAiPromptTemplate (+ NGA external plugins conditionally)\nfinalize.py → merge waves into metadata_tree.json\nrender_architecture.py → \u003cagent>_\u003cver>_architecture.md + Mermaid invocation graph (capped at --max-mermaid-nodes)\n```\n\n**Channel strategy — SOQL-first.**\n- **Tooling SOQL** for every normalized tree node (planner, plugins, functions, plugin-functions, plugin-instructions, planner-functions, planner-attrs) — 6 parallel channels keyed on planner id, plus the `planner_definition_by_agent_chain` seed query that resolves the planner id from the agent chain.\n- **Data API SOQL** for Flow (by id) and Apex (by id or name) bodies — batched.\n- **Metadata retrieve** only for two cases: (a) `GenAiPromptTemplate` (prompt bodies aren't cleanly exposed via Tooling SOQL), and (b) NGA **external plugins** when the planner is Native Generative Agent shape (skipped for classic ReAct).\n\nThis is where the 3–5× speedup comes from. A naive implementation would retrieve everything via Metadata API zips sequentially; parallel Tooling SOQL covers ~80% of the tree in a single fan-out.\n\n## Planner shapes — classic ReAct vs NGA\n\nThe skill normalizes two planner families into a single tree shape:\n\n| Shape | `GenAiPlannerDefinition.PlannerType` | InvocationTarget style | NGA plugins? |\n|---|---|---|---|\n| **Classic ReAct** | `ReactAiPlannerV1` / `SequentialPlannerIntentClassifier` / etc. | DeveloperName strings | no |\n| **NGA** | `ConcurrentMultiAgentOrchestration` / `AnthropicCompatibleV1` / etc. | Sometimes 15/18-char Ids (ID-prefix routed) | yes (external plugins via Metadata retrieve) |\n\nThe ID-prefix router in `resolve_invocation_target.py` distinguishes the two: NGA InvocationTargets that look like ids (`01p…` = ApexClass, `301…` = Flow, etc.) get resolved via id-scoped SOQL; DeveloperName targets go through name-scoped SOQL. Unknown prefixes surface as `_unresolved[]` with `reason=\"unknown-id-prefix:\u003cprefix>\"` — never silently dropped.\n\n## Caching\n\n- **Tree cache**: `metadata_tree.json` is reused unless `--force` is passed. Cache key includes the asset-hash of every `.soql` / `.yaml` / `.mmd` template bundled with the skill — bump a template, the cache busts automatically.\n- **Channel probe cache**: 7-day TTL on the per-org `sf sobject describe` results that validate every field name the SOQL assets reference. A Salesforce quarterly release that renames / removes a field triggers `status: PROBE_FAILED`; `--reprobe` forces a refresh.\n\n## Prerequisites\n\n| Tool | Required |\n|---|---|\n| `sf` CLI (authenticated against the target org) | yes — `sf org login web --alias \u003calias>`, and the CLI must provide `sf org auth show-access-token` (startup preflight enforces this) |\n| Python 3.10+ | yes |\n\n## Reference docs to load when needed\n\nDo NOT load eagerly. Load when the user's question requires it:\n\n- `references/soql_fields.md` — per-sObject field reference for the 13 sObjects this skill touches (2 Data API + 11 Tooling), with `[mandatory]` vs `[optional]` tags. Load when the user asks about a specific field, or when debugging an `INVALID_FIELD` SOQL error.\n- `references/contract.json` — machine-readable schema for `metadata_tree.json`. Load when writing downstream tooling that consumes the tree.\n- `references/architecture_sections.md` — section-by-section structure of the rendered `\u003cagent>_\u003cver>_architecture.md`.\n\n## Invariants worth knowing upfront\n\n- **Pipeline is deterministic.** Same `(org, agent, version)` + static org metadata → byte-identical `\u003cagent>_\u003cver>_metadata_tree.json` and `\u003cagent>_\u003cver>_architecture.md`. Only manifest timestamps drift across re-runs.\n- **Forward-only traversal.** Every discovered ref goes forward from planner → children. No backward lookups.\n- **Partial results are surfaced, not silenced.** Any unresolved reference lands in `_unresolved[]` with `reason=...`. `STATUS=PARTIAL_OK` if any channel failed; `STATUS=OK` only on a clean run.\n- **Cycle detection is per-branch.** Same flow visited along its own ancestor chain emits `_cycle_back_to:\u003cpath>` instead of recursing. A defensive `MAX_BFS_DEPTH=20` guard backs the per-branch ancestor set; real-world agents bottom out well before either limit fires. (Earlier docs claimed a hard cap of 5; that was the historical limit and was abandoned because shared utility flows like `handleFlowFault` tripped it on every nested tree — see `config.MAX_BFS_DEPTH` for the rationale.)\n- **Child ordering is alphabetical by `api_name` (case-insensitive).** Topics come before non-topic plannerActions at the root level. Flow-actionCall order is NOT sorted — that's the flow author's execution sequence.","attachment_filenames":["assets/cli/describe_sobject.yaml","assets/cli/describe_tooling_sobject.yaml","assets/cli/list_metadata_genaiprompttemplate.yaml","assets/cli/org_display.yaml","assets/cli/retrieve_genai_plugin.yaml","assets/cli/show_access_token.yaml","README.md","references/architecture_sections.md","references/contract.json","references/soql_fields.md","scripts/_shared/__init__.py","scripts/_shared/fs_guard.py","scripts/_shared/paths.py","scripts/_shared/runtime.py","scripts/_shared/sql.py","scripts/cache_check.py","scripts/config.py","scripts/emit_env.py","scripts/emit_result.py","scripts/fetch_soql.py","scripts/finalize.py","scripts/main.py","scripts/metadata_listing.py","scripts/parallel_retrieve.py","scripts/parse_bundle.py","scripts/parse_wave.py","scripts/probe_channels.py","scripts/render_architecture.py","scripts/resolve_bot.py","scripts/resolve_invocation_target.py","scripts/rest_client.py","scripts/retrieve_planner.py","scripts/sanitize.py","scripts/sf_cli.py","scripts/soql_loader.py","scripts/summarize_tree.py","scripts/tests/__init__.py","scripts/tests/_bootstrap.py","scripts/tests/fixtures/__init__.py","scripts/tests/test_cache_check_main.py","scripts/tests/test_cache_check.py","scripts/tests/test_config.py","scripts/tests/test_end_to_end_fixture.py","scripts/tests/test_finalize.py","scripts/tests/test_flow_children_inflation.py","scripts/tests/test_fs_guard.py","scripts/tests/test_iterative_wave_b.py","scripts/tests/test_main_pipeline.py","scripts/tests/test_parallel_retrieve.py","scripts/tests/test_parse_bundle.py","scripts/tests/test_parse_wave_classifiers.py","scripts/tests/test_parse_wave_helpers.py","scripts/tests/test_parse_wave_main.py","scripts/tests/test_parse_wave.py","scripts/tests/test_per_branch_visited.py","scripts/tests/test_probe_channels.py","scripts/tests/test_probe_cli_recipes.py","scripts/tests/test_render_architecture.py","scripts/tests/test_resolve_bot.py","scripts/tests/test_resolve_creds.py","scripts/tests/test_resolve_invocation_target.py","scripts/tests/test_rest_client.py","scripts/tests/test_runtime_override.py","scripts/tests/test_sf_cli.py","scripts/tests/test_signature_stamping.py","scripts/tests/test_skill_md_pipeline_block.py","scripts/tests/test_soql_loader.py","scripts/tests/test_summarize_tree.py","scripts/write_emit_ctx.py"],"attachments":[{"filename":"assets/cli/describe_sobject.yaml","content":"name: describe_sobject\nargv:\n - sf\n - sobject\n - describe\n - --sobject\n - \"{{SOBJECT}}\"\n - --target-org\n - \"{{ORG_ALIAS}}\"\n - --json\ntimeout_seconds: 300\nrequired_params: [ORG_ALIAS, SOBJECT]\nsuccess_check: stdout_json_status_zero\nauth_required_stderr_patterns:\n - NoOrgAuthenticationError\n - AuthInfoError\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":317,"content_sha256":"9d55df222e575de7cbaecb3a251dd70c85131c58f2f4e2193f7e3da31cd5a698"},{"filename":"assets/cli/describe_tooling_sobject.yaml","content":"name: describe_tooling_sobject\nargv:\n - sf\n - sobject\n - describe\n - --sobject\n - \"{{SOBJECT}}\"\n - --use-tooling-api\n - --target-org\n - \"{{ORG_ALIAS}}\"\n - --json\ntimeout_seconds: 300\nrequired_params: [ORG_ALIAS, SOBJECT]\nsuccess_check: stdout_json_status_zero\nauth_required_stderr_patterns:\n - NoOrgAuthenticationError\n - AuthInfoError\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":347,"content_sha256":"6e28dc1b254d8fdc226047925c6e783584ccdba20eb8fa08623b0aec733c9a69"},{"filename":"assets/cli/list_metadata_genaiprompttemplate.yaml","content":"name: list_metadata_genaiprompttemplate\nargv:\n - sf\n - org\n - list\n - metadata\n - --metadata-type\n - GenAiPromptTemplate\n - --target-org\n - \"{{ORG_ALIAS}}\"\n - --json\ntimeout_seconds: 300\nrequired_params: [ORG_ALIAS]\nsuccess_check: stdout_json_status_zero\nauth_required_stderr_patterns:\n - NoOrgAuthenticationError\n - AuthInfoError\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":342,"content_sha256":"9215394ac6d63e196fed33e186775d0fe39c4c0bbaa9b1845caa593f08debabe"},{"filename":"assets/cli/org_display.yaml","content":"name: org_display\nargv:\n - sf\n - org\n - display\n - --target-org\n - \"{{ORG_ALIAS}}\"\n - --json\n - --verbose\ntimeout_seconds: 300\nrequired_params: [ORG_ALIAS]\nsuccess_check: stdout_json_status_zero\nauth_required_stderr_patterns:\n - NoOrgAuthenticationError\n - AuthInfoError\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":280,"content_sha256":"8a7d4638fb838632fced7c62730f86ef91155b012e121b507a53cf3ee8fef7b9"},{"filename":"assets/cli/retrieve_genai_plugin.yaml","content":"name: retrieve_genai_plugin\nargv:\n - sf\n - project\n - retrieve\n - start\n - --target-org\n - \"{{ORG_ALIAS}}\"\n - --target-metadata-dir\n - \"{{TARGET_DIR}}\"\n - --json\nextra_argv_anchor: --target-metadata-dir\ntimeout_seconds: 300\nrequired_params: [ORG_ALIAS, TARGET_DIR]\nsuccess_check: stdout_json_status_zero\nauth_required_stderr_patterns:\n - NoOrgAuthenticationError\n - AuthInfoError\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":391,"content_sha256":"c833dcfc6c1e40abe25774b5e379c93ebfc403a1a3b47da96d3cb02cd772cd38"},{"filename":"assets/cli/show_access_token.yaml","content":"name: show_access_token\n# Access-token retrieval per forcedotcom/cli#3560. The `sf org display --json`\n# accessToken field is redacted to a placeholder string; the dedicated\n# `sf org auth show-access-token` command is the replacement.\n#\n# `--no-prompt` skips the interactive confirmation banner so the command\n# behaves identically under `--json`.\n#\n# This command is the only supported runtime token-retrieval path.\nargv:\n - sf\n - org\n - auth\n - show-access-token\n - --target-org\n - \"{{ORG_ALIAS}}\"\n - --json\n - --no-prompt\ntimeout_seconds: 60\nrequired_params: [ORG_ALIAS]\nsuccess_check: stdout_json_status_zero\nauth_required_stderr_patterns:\n - NoOrgAuthenticationError\n - AuthInfoError\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":700,"content_sha256":"cc88fdecfd5b0988955d7e7778a9d913aabb86aa7431d00567d0e378374ca31b"},{"filename":"README.md","content":"# agentforce-architecture-analyze\n\nDeclared architecture snapshot for a single Agentforce agent: planner + topics + actions + flows + Apex + prompts + NGA plugins. Reads design-time metadata only (`BotDefinition` + `GenAi*` Tooling objects + Metadata API retrieve) — no runtime audit data.\n\nInput: an `agent_api_name` (the `BotDefinition.DeveloperName`) and an org alias. Optional `agent_version_api_name` to pin a version; otherwise the active `BotVersion` resolves.\n\nOutput: two files under `~/.vibe/data/agentforce-architecture-analyze/\u003corg_id15>/\u003cagent>__\u003cversion>/` — a normalized `\u003cagent>_\u003cver>_metadata_tree.json` and a human-readable `\u003cagent>_\u003cver>_architecture.md`. Override with `--data-dir \u003cpath>` (other runtimes pass this to land artifacts under their own distribution layout).\n\n---\n\n## Runtime budget\n\n**30–45s typical, ≤60s hard cap** on the reference fixtures.\n\nA naive sequential implementation (Metadata API retrieves only) would take 90–220s. Speedup: **3–5×**.\n\nScaling note: large bots with many flows scale approximately linearly in flow count. Each Flow metadata retrieve is an individual SOQL round-trip; a 20-flow bot takes proportionally longer than a 5-flow bot. The 7 planner-side Tooling SOQL fan-outs are constant-cost (single fan-out regardless of bot size); the flow/apex body fetch wave scales with ref count.\n\n---\n\n## Prerequisites\n\n| Tool | Why |\n|---|---|\n| `sf` CLI (authenticated against the target org) | Must provide `sf org auth show-access-token` (preflighted at startup). Shells `sf org display --target-org \u003calias> --json` for org metadata, `sf org auth show-access-token --target-org \u003calias> --json --no-prompt` for the access token, and `sf sobject describe` for the 7-day channel probe |\n| Python 3.10+ | `pathlib`, dataclasses, `\\|` union types, `concurrent.futures` |\n\n---\n\n## Usage\n\nInvoked conversationally through whatever skill-aware runtime hosts it. Example prompts:\n\n| User says | Skill does |\n|---|---|\n| `document the architecture of MyAgent in my-org-alias` | Resolve active version, fetch tree, render architecture.md + Mermaid |\n| `draw the invocation graph for MySalesAgent v5 in my-org-alias-3` | Same, pinned to v5 |\n| `what tools does MyAgent2 have in my-org-alias-2` | Fetch tree, surface the plugin/function inventory from the rendered architecture.md |\n| `re-fetch the architecture of MyAgent — I think metadata changed` | Pass `--force` to bypass the cache |\n\nSee `SKILL.md` for the full flag table and sample prompts.\n\n---\n\n## Directory layout\n\n```text\nagentforce-architecture-analyze/\n├── SKILL.md Skill contract (inputs, outputs, pipeline, invariants)\n├── README.md This file\n├── assets/\n│ ├── soql/*.soql Tooling + Data SOQL templates\n│ ├── cli/*.yaml sf CLI recipes (subprocess invocation specs)\n│ └── mermaid/*.mmd Mermaid templates for the invocation graph\n├── references/\n│ ├── soql_fields.md Per-sObject field reference (13 sObjects)\n│ ├── architecture_sections.md Section-by-section structure of the rendered architecture.md\n│ └── contract.json metadata_tree.json schema contract\n├── scripts/\n│ ├── _shared/ Path helpers + fs_guard validators + sql escapers\n│ ├── main.py Orchestrator entry point\n│ ├── config.py Shared paths, cache TTLs, validated path builders\n│ ├── soql_loader.py Template loader with fs_guard-validated substitution\n│ ├── sf_cli.py sf CLI subprocess wrapper (yaml.safe_load + stderr redaction)\n│ ├── rest_client.py urllib wrapper (Authorization-stripping redirect handler)\n│ ├── resolve_bot.py BotDefinition + BotVersion + planner name lookup\n│ ├── retrieve_planner.py Metadata retrieve for GenAiPlannerBundle + NGA plugins\n│ ├── parallel_retrieve.py 7-channel parallel Tooling SOQL fan-out\n│ ├── parse_bundle.py XML → normalized node shapes\n│ ├── parse_wave.py BFS expansion of flow/apex/prompt refs\n│ ├── probe_channels.py 7-day-TTL channel describe probe\n│ ├── cache_check.py Asset-hash-aware cache freshness\n│ ├── finalize.py Merge waves → metadata_tree.json\n│ ├── render_architecture.py architecture.md + Mermaid graph\n│ ├── resolve_invocation_target.py ID-prefix router for NGA InvocationTargets\n│ ├── emit_env.py Env-var emit helper (Phase 0.5)\n│ ├── emit_result.py Final RESULT block renderer\n│ ├── sanitize.py Stdin → safe-string filter\n│ ├── write_emit_ctx.py Per-phase ctx writer\n│ └── tests/ Unit + integration tests (unittest)\n```\n\n---\n\n## Architecture\n\n### Channel strategy — SOQL-first\n\n```text\nSeed query: planner_definition_by_agent_chain (chain-LIKE lookup → planner id)\n\n6 parallel Tooling SOQL channels (keyed on the resolved planner id):\n - plugins_by_planner\n - planner_bundle_functions (join)\n - functions_by_plugins\n - planner_attrs_by_parent_ids\n - plugin_functions_by_plugin_ids (join)\n - plugin_instructions_by_plugin_ids\n\n+ Data API SOQL for Flow / Apex bodies (batched by id list)\n+ Metadata retrieve ONLY for:\n - GenAiPromptTemplate (prompt bodies)\n - NGA external plugins (when planner is ConcurrentMultiAgentOrchestration etc.)\n```\n\nMost of the 3–5× speedup over a naive Metadata-API-only implementation comes from collapsing a sequential zip-retrieve chain into a single Tooling SOQL fan-out.\n\n### Planner normalization — classic ReAct vs NGA\n\nOne tree shape, two planner families:\n\n| `PlannerType` examples | Family | InvocationTarget style |\n|---|---|---|\n| `ReactAiPlannerV1`, `SequentialPlannerIntentClassifier` | Classic ReAct | DeveloperName strings |\n| `ConcurrentMultiAgentOrchestration`, `AnthropicCompatibleV1` | NGA | Sometimes 15/18-char Ids (ID-prefix routed) |\n\n`resolve_invocation_target.py` routes NGA InvocationTargets by Salesforce ID prefix (`01p` → ApexClass, `301` → Flow, etc.). Unknown prefixes become `_unresolved[]` entries with `reason=\"unknown-id-prefix:\u003cprefix>\"` — never silently dropped.\n\n### Cache layers\n\n1. **Tree cache** — `metadata_tree.json` is reused unless `--force`. Cache key includes asset-hashes of every SOQL / YAML / Mermaid template shipped with the skill, so changing a template busts the cache automatically.\n2. **Channel probe cache** — 7-day TTL on `sf sobject describe` results for the 13 sObjects the skill touches. `--reprobe` forces a refresh (needed after Salesforce quarterly releases that rename / remove fields). Mandatory-field gate: a probe that sees any mandatory field missing (per `probe_channels.MANDATORY_FIELDS`) flips `status: PROBE_FAILED` so the caller surfaces a clean error.\n\n---\n\n## Key behaviors\n\n### Idempotence\n\nRe-running the same `(org, agent, version)` overwrites prior artifacts in place. Safe to run repeatedly during development.\n\n### Partial-results surfacing\n\nNo silent drops. Any unresolved ref — unknown ID prefix, failed SOQL, missing describe field — lands in `_unresolved[]` with a `reason=...` string. Top-level `STATUS` is `OK` on a clean run, `PARTIAL_OK` when any channel degrades.\n\n### Cycle handling\n\nPer-branch ancestor-path cycle detection is the primary termination primitive: the same flow visited along its own ancestor chain emits `_cycle_back_to:\u003cpath>` instead of recursing. `MAX_BFS_DEPTH=20` is a defensive last-resort guard against pathological graphs that evade per-branch detection; real-world agents bottom out well before that.\n\n---\n\n## Troubleshooting\n\n| Symptom | Fix |\n|---|---|\n| `sf CLI is missing required command 'sf org auth show-access-token'` | Upgrade/reinstall Salesforce CLI, then rerun |\n| `sf org display failed` | Re-authenticate: `sf org login web --alias \u003calias>` |\n| `INVALID_FIELD` from a SOQL asset | Salesforce renamed / removed the field in a quarterly release. Run with `--reprobe` to refresh the 7-day channel cache and pick up the new schema |\n| `STATUS=PROBE_FAILED` on first run | Channel probe saw a mandatory field missing. Check `channels.json` under the probe cache dir for which sObject / field — may require org-side feature enablement |\n| Tree for classic ReAct agent shows `_unresolved` entries for NGA plugins | Expected — the NGA external-plugin retrieve is skipped when the planner shape is classic. Those entries can be ignored |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8753,"content_sha256":"0f287b210a543ea0ccd9fe31fbd36c9e0373116bea0f6753821472415836c996"},{"filename":"references/architecture_sections.md","content":"# architecture.md section reference\n\nPer-section rendering spec for `scripts/render_architecture.py::render`.\nOne `# Architecture` H1, seven numbered `## N. ...` sections, plus one\nconditional \"Dependency graph\" appendix. Source of truth: the\n`metadata_tree.json` schema produced by `scripts/parse_wave.py` at the\nend of phase 9.\n\n**Source of truth: the live tree** — field shapes evolve as parse_wave\nlearns new agent generations. When this doc disagrees with the\n`metadata_tree.json` written by `parse_wave.py` for an actual agent on\nyour machine (under `~/.vibe/data/agentforce-architecture-analyze/\u003corg>/\u003cagent>__\u003cver>/`),\ntrust the live tree.\n\nThe Mermaid templates live at `assets/mermaid/*.mmd` and are the sole\nconsumer of the renderer helpers; each `{{PARAM}}` contract is\ndocumented in the template's own header comment.\n\n---\n\n## Per-diagram node caps\n\nDefault caps (override via `render(..., max_mermaid_nodes={...})`):\n\n| Diagram kind | Default cap | Over-cap behaviour |\n|---|---|---|\n| `flowchart` | 200 | summary placeholder + top-5 fan-out + catalog pointer |\n| `stateDiagram` | 40 | summary placeholder (no fan-out — states are not nodes) |\n| `sequenceDiagram` | 60 | summary placeholder + top-5 fan-out |\n| `graph` | 100 | summary placeholder + top-5 fan-out |\n\nCap is measured in rendered elements (nodes + edges for flowchart/graph,\nmessages for sequenceDiagram, states + transitions for stateDiagram).\nAbove cap, the renderer emits a `> [diagram truncated: ...]` blockquote\nwith the element count, the top-5 nodes by fan-out (when applicable),\nand a pointer to the catalog section that enumerates the same data\nwithout compression.\n\n---\n\n## 1. Header + agent overview\n\n**Purpose**: identify the agent and its planner in a single kv table.\n\n**Input fields** (`tree.agent.*`):\n\n- `api_name` — agent developer name\n- `version` — `v\u003cN>` label\n- `master_label` — human-readable name\n- `description` — prose summary\n- `agent_type` — `EinsteinAgentKind` / `EinsteinCopilotForSalesforce` / …\n- `type` — `ExternalCopilot` / `InternalCopilot` / …\n- `agent_template`\n- `bot_source`\n- `generation` — `classic` / `nga` / `search` / `byop`\n- `planner_name`\n- `planner_type`\n- `bot_id`\n\nPlus `tree._schema_version`.\n\n**Output shape**: `# \u003cH1>` with api_name + version, followed by a 2-col\nmarkdown table.\n\n**Empty/degenerate cases**:\n\n- Missing `api_name` / `version` renders `?` in the H1 (not `None`).\n- Any missing field renders `-` in its row — not empty, because GitHub\n table rendering collapses empty cells and the row loses alignment.\n\n---\n\n## 2. Anatomy summary\n\n**Purpose**: one-paragraph executive summary plus health callouts.\n\n**Input fields**:\n\n- `tree._kind_counts` — preferred source for topic / action / flow /\n apex / prompt counts.\n- `tree.depth`, `tree.node_count` — size metrics.\n- `tree._partial`, `tree._partial_reason` — health flag + prose reason.\n- `tree._pending_fetches` — `{\"FLOW\":[...],\"APEX\":[...],\n \"PROMPT_TEMPLATE\":[...],\"STANDARD_ACTION\":[...]}`. Count determines\n the \"pending fetches: N\" line in the callout.\n- `tree._unresolved` — triggers a second warn callout with the count.\n- `tree.agent.planner_name` — missing -> warn callout.\n\n**Output shape**: `## 2. Anatomy summary` + paragraph + zero-to-three\nblockquote callouts. Callouts render as `> **Health: PARTIAL.**` /\n`> **Health: WARN.**` prefixes with bullet sub-lines.\n\n**Empty/degenerate cases**: if `_kind_counts` is absent, the walker\nsupplies counts from its own pass over the tree. Zero topics is valid\n(SequentialPlannerIntentClassifier).\n\n---\n\n## 3. Action tree\n\n**Purpose**: the full declared action tree as a flowchart with per-topic\nsubgraphs, plus a deterministic ASCII appendix for diff review.\n\n**Input fields**:\n\n- Walker-derived `topics[]` -> one subgraph per topic.\n- Walker-derived `edges[]` -> `parent_api_name --> child_api_name`.\n- `node._cycle_back_to` -> dotted back-edge `A -.->|cycle_back_to: X| B`.\n- Planner-level actions (top-level `GEN_AI_FUNCTION` with no parent\n topic) -> synthetic `_plannerActions` subgraph.\n\n**Output shape**: ` ```mermaid ` block with `flowchart TB` + subgraphs\n+ edges, followed by a `\u003cdetails>` with ASCII-tree appendix. When\nabove cap, the mermaid block is replaced with the truncation\nplaceholder; the ASCII appendix still renders.\n\n**Empty/degenerate cases**:\n\n- No topics, no planner actions -> `%% no topics` + `%% no edges`\n mermaid comments; diagram still parses.\n- Cycle annotations render in both the mermaid (dotted edge) and the\n ASCII appendix (`[cycle]` marker on the node line).\n\n---\n\n## 4. Topic anatomy\n\n**Purpose**: per-topic detail dump. One H3 per topic with a bullet list\nof actions.\n\n**Input fields**:\n\n- Walker-derived `topics[]` with `api_name`, `label`, `actions`.\n- `topic.raw.master_label` (preferred) or api_name for the label.\n\n**Output shape**: `## 4. Topic anatomy` + one `### \\`\u003capi_name>\\``\nblock per topic + kv list (`- Label:`, `- Action count:`, `- Actions:`\nwith sub-bullets).\n\n**Empty/degenerate cases**: 0 topics -> `_No topics defined (planner\nexposes actions directly)._` italic fallback. This is the expected\ncase for `SequentialPlannerIntentClassifier`.\n\n---\n\n## 5. Action catalog\n\n**Purpose**: flat markdown table of every declared action.\n\n**Input fields**:\n\n- Walker-derived `actions[]` with `api_name`, `topic`, `raw.unwraps_to`.\n\n**Output shape**: 3-col table (`Action | Topic | Unwraps to`). Unwraps\ncolumn renders as `KIND \\`api_name\\`` when `unwraps_to` is present, `-`\notherwise. Planner-level actions show `(plannerAction)` in the topic\ncolumn.\n\n**Empty/degenerate cases**: no actions -> `_No actions declared._`\nitalic fallback.\n\n---\n\n## 6. Data flow / context propagation\n\n**Purpose**: show which slot values propagate from user utterance\nthrough the planner into each action.\n\n**Input fields**:\n\n- Walker-derived `topics[]` and `actions[]`.\n- `action.raw.planner_attr.variable_name` + `planner_attr.data_type` —\n labels the edge `A -->|var: Type| B`. When absent, the edge is bare.\n\n**Output shape**: `flowchart LR` via `load_mermaid(\"data_flow\", ...)`.\nSkeleton: `User` -> `Planner` -> each `Topic` -> each `Action` under\nthat topic.\n\n**Empty/degenerate cases**: no planner_attr metadata -> every edge is\nbare. 0 topics -> only `User --> Planner` renders (no action-level\nedges). Above cap -> truncation placeholder.\n\n---\n\n## 7. Flow / Apex / Prompt catalogs\n\n**Purpose**: per-artifact detail section for every backing flow, apex\nclass, and prompt template referenced by any action.\n\n**Input fields**:\n\n- Walker-derived `flows{}`, `apex{}`, `prompts{}`, keyed on api_name.\n- `node.signature` (or `_signature` fallback) — signature block.\n\n**Output shape**: `### Flows` / `### Apex classes` / `### Prompt\ntemplates` H3 buckets, each containing `#### \\`\u003capi_name>\\`` + a fenced\nsignature block or `_Signature not captured._` fallback. Prompt\ntemplates render as a flat bullet list (signature capture is\nper-template prose, not a flow/apex shape).\n\n**Empty/degenerate cases**: no backing artifacts in the tree ->\n`_No backing artifacts in tree._` italic fallback.\n\n---\n\n## 8. Unresolved refs + artifact pointers\n\n**Purpose**: surface every `_unresolved[]` entry and point at the\nsidecar files the reader can open.\n\n**Input fields**:\n\n- `tree._unresolved[]` — list of `{kind, api_name, reason}` records.\n\n**Output shape**: Either a 3-col `Kind | Api name | Reason` table or\nan `_No unresolved references._` italic fallback. Followed by an\n`### Artifact pointers` sub-heading with a bullet list of relative\npaths (tree JSON, manifest, summary).\n\n**Empty/degenerate cases**: always renders — the artifact-pointer list\nis unconditional.\n\n---\n\n## Conditional: Dependency graph\n\n**Purpose**: rendered only when `tree._unresolved[]` is non-empty OR\nthe tree contains cycle annotations. Shows cross-artifact dependencies\nplus the unresolved nodes as dashed-outline markers.\n\n**Input fields**:\n\n- Walker-derived `edges[]`.\n- `tree._unresolved[]` — each becomes a `:::unresolved` styled node.\n- Walker-derived `cycles[]` — each becomes a dotted back-edge.\n\n**Output shape**: `graph LR` via `load_mermaid(\"dependency_graph\",\n...)`. Includes a `classDef unresolved` class marker at the bottom of\nthe template so unresolved nodes render with `stroke-dasharray`.\n\n**Empty/degenerate cases**: not rendered at all when both conditions\nare false. Above the `graph` cap -> truncation placeholder replaces\nthe mermaid block.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8460,"content_sha256":"b6a6fe9ed733b2a417be5b9d8132eeaf9c458c781370840ab652c5dabd1e30a8"},{"filename":"references/contract.json","content":"{\n \"_doc\": \"retired-name references replaced with plugin-scoped layout. Machine-readable I/O contract for agentforce-architecture-analyze. Orchestrator code reads this file (not SKILL.md prose) to discover the input schema, output keys, STATUS enum, and error-contract rules. The architecture skill runs inline (no subagent).\",\n \"_schema_version\": \"3.1\",\n \"_schema_notes\": \"3.1 (2026-05-05) canonicalizes `invocation_type` on STANDARD_ACTION / UNKNOWN nodes in the metadata tree. Prior versions split this across `raw_invocation_type` (bundle-sourced nodes) and `raw_action_type` (flow-actionCall-sourced nodes). Both legacy keys are still tolerated by readers for one release.\",\n \"input\": {\n \"required\": {\n \"org_alias\": {\n \"type\": \"string\",\n \"description\": \"sf CLI alias (e.g. 'my-org-alias'). Auth is always derived via `sf org display --target-org $ORG_ALIAS --json` (instance URL) and `sf org auth show-access-token --target-org $ORG_ALIAS --json --no-prompt` (access token).\"\n },\n \"agent_api_name\": {\n \"type\": \"string\",\n \"regex\": \"^[A-Za-z0-9_]+$\",\n \"description\": \"BotDefinition.DeveloperName (e.g. 'MyAgent'). Must match the regex above; INVALID_INPUT otherwise.\"\n }\n },\n \"optional\": {\n \"agent_version_api_name\": {\n \"type\": \"string\",\n \"description\": \"BotVersion.DeveloperName (e.g. 'v5'). When omitted, the agent auto-picks the Active version with the highest natural-key sort (v10 > v9); VERSION_AUTO_PICKED=true in the RESULT block.\"\n },\n \"org_id_15\": {\n \"type\": \"string\",\n \"regex\": \"^[A-Za-z0-9]{15}$\",\n \"description\": \"15-char org ID. If omitted, derived from `sf org display`.\"\n },\n \"org_id_18\": {\n \"type\": \"string\",\n \"regex\": \"^[A-Za-z0-9]{18}$\",\n \"description\": \"18-char org ID. If omitted, derived from `sf org display`.\"\n },\n \"session_id\": {\n \"type\": \"string\",\n \"description\": \"Only used to name the ephemeral $WORK_DIR. Random UUID if omitted.\"\n },\n \"work_dir\": {\n \"type\": \"absolute_path\",\n \"description\": \"Ephemeral scratch. Default: /tmp/agentforce-architecture-analyze-\u003cepoch>-\u003crand>.\"\n },\n \"cache_root\": {\n \"type\": \"absolute_path\",\n \"description\": \"Internal rebuildable cache root. Default: ~/.vibe/cache/agentforce-architecture-analyze. Override with --cache-dir \u003cpath>.\"\n },\n \"data_root\": {\n \"type\": \"absolute_path\",\n \"description\": \"Durable user-facing output root. Default: ~/.vibe/data/agentforce-architecture-analyze. Override with --data-dir \u003cpath>.\"\n },\n \"force_refresh\": {\n \"type\": \"boolean\",\n \"description\": \"If true, ignore cache and rewrite. Default: false.\"\n }\n },\n \"minimum_viable\": {\n \"org_alias\": \"my-org-alias\",\n \"agent_api_name\": \"MyAgent\"\n },\n \"notes\": [\n \"Standalone and orchestrated modes accept the same input fields; no renames.\",\n \"To bypass cache, delete the cache entry or pass force_refresh: true.\"\n ]\n },\n \"output\": {\n \"format\": \"Two-part return message: (1) one-line prose status, (2) blank line, (3) === RESULT === key-value block. The KV block is always LAST in the output and always present.\",\n \"parse_rule\": \"Find the line '=== RESULT ===', then read subsequent lines as KEY=VALUE pairs until EOF. Values are raw strings; no quoting; one pair per line.\",\n \"parse_examples\": {\n \"bash\": \"awk -F= '/^STATUS=/{print $2; exit}' \u003c\u003c\u003c \\\"$subagent_output\\\"\",\n \"python\": \"import re; dict(line.split('=', 1) for line in output.splitlines() if re.match(r'^[A-Z_]+=', line))\"\n },\n \"keys\": {\n \"STATUS\": {\n \"type\": \"enum\",\n \"presence\": \"always\",\n \"description\": \"Terminal status. See error_contract for per-status required keys.\",\n \"values\": [\n \"OK\",\n \"PARTIAL_OK\",\n \"INVALID_INPUT\",\n \"AUTH_REQUIRED\",\n \"AGENT_NOT_FOUND\",\n \"AGENT_VERSION_NOT_FOUND\",\n \"RETRIEVE_FAILED\",\n \"WRITE_FAILED\"\n ]\n },\n \"ERROR_DETAIL\": {\n \"type\": \"string\",\n \"presence\": \"on_error\",\n \"description\": \"Human-readable explanation on any non-OK STATUS. Sanitized (no backticks/quotes/dollar signs/newlines).\"\n },\n \"AGENT_API_NAME\": {\n \"type\": \"string\",\n \"presence\": \"always\",\n \"description\": \"Echo of input agent_api_name. May be empty on INVALID_INPUT if sanitize rejected before resolution.\"\n },\n \"AGENT_VERSION\": {\n \"type\": \"string\",\n \"presence\": \"on_success_or_after_resolve\",\n \"description\": \"Resolved BotVersion.DeveloperName (e.g. 'v5').\"\n },\n \"VERSION_AUTO_PICKED\": {\n \"type\": \"boolean\",\n \"presence\": \"on_success_or_after_resolve\",\n \"description\": \"True iff agent_version_api_name was omitted and the agent auto-picked.\"\n },\n \"AGENT_GENERATION\": {\n \"type\": \"enum\",\n \"presence\": \"always\",\n \"values\": [\"classic\", \"nga\", \"unknown\"],\n \"description\": \"classic = AiCopilot__ planner, nga = Atlas__ planner, unknown = anything else / not resolved.\"\n },\n \"BOT_ID\": {\n \"type\": \"string\",\n \"presence\": \"on_success_or_after_resolve\",\n \"description\": \"18-char BotDefinition.Id from SOQL.\"\n },\n \"ORG_ID_15\": {\n \"type\": \"string\",\n \"presence\": \"on_success_or_after_auth\",\n \"description\": \"15-char org ID. Cache-key prefix.\"\n },\n \"ORG_ID_18\": {\n \"type\": \"string\",\n \"presence\": \"on_success_or_after_auth\",\n \"description\": \"18-char org ID (from `sf org display`).\"\n },\n \"OUTPUT_JSON_PATH\": {\n \"type\": \"absolute_path\",\n \"presence\": \"on_success\",\n \"description\": \"Durable {agent}_{version}_metadata_tree.json path under the resolved data_root.\"\n },\n \"OUTPUT_SUMMARY_PATH\": {\n \"type\": \"absolute_path\",\n \"presence\": \"always_empty\",\n \"description\": \"Reserved — historically pointed at {agent}_{version}_metadata_tree.summary.md. Dropped in ; field is now always emitted empty for RESULT-block shape stability.\"\n },\n \"CACHE_PATH\": {\n \"type\": \"absolute_path\",\n \"presence\": \"on_success_or_after_cache_check\",\n \"description\": \"Cache dir ($CACHE_ROOT/$ORG_ID_15/$AGENT__$VERSION/). Ends with /.\"\n },\n \"CACHE_HIT\": {\n \"type\": \"boolean\",\n \"presence\": \"on_success_or_after_cache_check\",\n \"description\": \"True iff the cache was served without any Metadata API retrieves.\"\n },\n \"CACHED_AT_UTC\": {\n \"type\": \"iso8601_utc_or_empty\",\n \"presence\": \"on_success_or_after_cache_check\",\n \"description\": \"ISO-8601 UTC built-at time. On cache hit this is the age of the cached snapshot; on cold build this is the just-computed timestamp.\"\n },\n \"NODE_COUNT\": {\n \"type\": \"integer\",\n \"presence\": \"on_success\",\n \"description\": \"Total nodes in the declared_action_tree.\"\n },\n \"DEPTH\": {\n \"type\": \"integer\",\n \"presence\": \"on_success\",\n \"description\": \"Max depth of the tree (BOT_DEFINITION = depth 0).\"\n },\n \"PARTIAL\": {\n \"type\": \"boolean\",\n \"presence\": \"on_success\",\n \"description\": \"True iff the tree is incomplete (planner unresolved OR MAX_WAVE hit). When true, STATUS=PARTIAL_OK.\"\n },\n \"UNRESOLVED_COUNT\": {\n \"type\": \"integer\",\n \"presence\": \"on_success\",\n \"description\": \"Length of tree._unresolved[].\"\n },\n \"AVAILABLE_BOTS\": {\n \"type\": \"csv\",\n \"presence\": \"on_AGENT_NOT_FOUND\",\n \"description\": \"Comma-separated list of all BotDefinition.DeveloperName values in the org.\"\n },\n \"AVAILABLE_VERSIONS\": {\n \"type\": \"csv\",\n \"presence\": \"on_AGENT_VERSION_NOT_FOUND\",\n \"description\": \"Format: 'v5(Active),v4(Inactive),...' — natural-key sorted DESC.\"\n },\n \"RESULT_BLOCK_PATH\": {\n \"type\": \"absolute_path\",\n \"presence\": \"always\",\n \"description\": \"Path to $DATA_DIR/last_result_block.txt — byte-for-byte tee written BEFORE stdout.\"\n },\n \"WALL_TIME_SECONDS\": {\n \"type\": \"float\",\n \"presence\": \"always\",\n \"description\": \"End-to-end wall-clock seconds. Two decimals. Target: 90-220s cold, \u003c2s warm.\"\n }\n }\n },\n \"error_contract\": {\n \"OK\": {\n \"description\": \"Tree built, zero unresolved, planner resolved.\",\n \"required_keys\": [\"STATUS\", \"AGENT_API_NAME\", \"AGENT_VERSION\", \"VERSION_AUTO_PICKED\", \"AGENT_GENERATION\", \"BOT_ID\", \"ORG_ID_15\", \"ORG_ID_18\", \"OUTPUT_JSON_PATH\", \"OUTPUT_SUMMARY_PATH\", \"CACHE_PATH\", \"CACHE_HIT\", \"CACHED_AT_UTC\", \"NODE_COUNT\", \"DEPTH\", \"PARTIAL\", \"UNRESOLVED_COUNT\", \"RESULT_BLOCK_PATH\", \"WALL_TIME_SECONDS\"],\n \"optional_keys\": []\n },\n \"PARTIAL_OK\": {\n \"description\": \"Tree built but planner unresolved (Bot.bot had no planner) OR waves hit MAX_WAVE=5. tree._unresolved[] explains.\",\n \"required_keys\": [\"STATUS\", \"AGENT_API_NAME\", \"AGENT_VERSION\", \"AGENT_GENERATION\", \"BOT_ID\", \"ORG_ID_15\", \"ORG_ID_18\", \"OUTPUT_JSON_PATH\", \"CACHE_PATH\", \"PARTIAL\", \"UNRESOLVED_COUNT\", \"WALL_TIME_SECONDS\", \"RESULT_BLOCK_PATH\"],\n \"optional_keys\": [\"OUTPUT_SUMMARY_PATH\", \"CACHE_HIT\", \"CACHED_AT_UTC\", \"NODE_COUNT\", \"DEPTH\", \"VERSION_AUTO_PICKED\", \"ERROR_DETAIL\"]\n },\n \"INVALID_INPUT\": {\n \"description\": \"agent_api_name failed [A-Za-z0-9_]+ regex, or other input validation (org_alias empty, ORG_ID_15 regex, $WORK_DIR symlink/foreign-owned).\",\n \"required_keys\": [\"STATUS\", \"ERROR_DETAIL\", \"AGENT_API_NAME\"],\n \"optional_keys\": [\"ORG_ID_15\", \"ORG_ID_18\"]\n },\n \"AUTH_REQUIRED\": {\n \"description\": \"`sf org display` returned nothing, or a SOQL/Metadata call failed with an auth error. Caller must run `sf org login web --alias $ORG_ALIAS`.\",\n \"required_keys\": [\"STATUS\", \"ERROR_DETAIL\", \"AGENT_API_NAME\"],\n \"optional_keys\": [\"ORG_ID_15\", \"ORG_ID_18\"]\n },\n \"AGENT_NOT_FOUND\": {\n \"description\": \"BotDefinition.DeveloperName not in the org. AVAILABLE_BOTS carries the CSV of what IS in the org.\",\n \"required_keys\": [\"STATUS\", \"ERROR_DETAIL\", \"AGENT_API_NAME\", \"AVAILABLE_BOTS\"],\n \"optional_keys\": [\"ORG_ID_15\", \"ORG_ID_18\"]\n },\n \"AGENT_VERSION_NOT_FOUND\": {\n \"description\": \"No matching BotVersion under the bot (explicit version doesn't exist, or no Active version found during auto-pick).\",\n \"required_keys\": [\"STATUS\", \"ERROR_DETAIL\", \"AGENT_API_NAME\", \"AVAILABLE_VERSIONS\"],\n \"optional_keys\": [\"BOT_ID\", \"ORG_ID_15\", \"ORG_ID_18\"]\n },\n \"RETRIEVE_FAILED\": {\n \"description\": \"`sf project retrieve start` failed (network, permissions, zip unreadable). ERROR_DETAIL carries the specifics.\",\n \"required_keys\": [\"STATUS\", \"ERROR_DETAIL\", \"AGENT_API_NAME\", \"AGENT_VERSION\", \"BOT_ID\"],\n \"optional_keys\": [\"ORG_ID_15\", \"ORG_ID_18\", \"OUTPUT_JSON_PATH\"]\n },\n \"WRITE_FAILED\": {\n \"description\": \"Filesystem write failed during finalize (DATA_DIR rename, CACHE_DIR rename, or tree rewrite).\",\n \"required_keys\": [\"STATUS\", \"ERROR_DETAIL\", \"AGENT_API_NAME\", \"AGENT_VERSION\"],\n \"optional_keys\": [\"BOT_ID\", \"ORG_ID_15\", \"ORG_ID_18\", \"CACHE_HIT\", \"CACHED_AT_UTC\"]\n }\n },\n \"stability\": {\n \"key_names\": \"Stable. Key-name changes require a NOTICE.md entry and a skill reinstall; consumers read this contract to discover the current key set.\",\n \"error_codes\": \"The STATUS enum is fixed at 8 values. A new status is a breaking change for consumers that switch on STATUS.\",\n \"path_format\": \"All path values are absolute (no tilde expansion required). One KEY=VALUE per line. No trailing whitespace.\",\n \"order\": \"Keys inside the RESULT block appear in a stable order — orchestrators should not depend on order but may use it for human readability.\"\n },\n \"referenced_files\": {\n \"skill_md\": \"\u003cSKILL_ROOT>/SKILL.md\"\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":11960,"content_sha256":"f30a22dc53e68b5d63e2dcacf7b25606b7520a5bdb78dc9b9014f20b11255fb7"},{"filename":"references/soql_fields.md","content":"# sObject field reference — architecture skill\n\nField reference for the 13 sObjects this skill queries across the 15\nSOQL templates under `assets/soql/`. Two are reached via the Data API\n(`BotDefinition`, `BotVersion`); the remaining 11 are Tooling-only.\n\n**Source of truth: the live org** (`sf sobject describe --sobject \u003cName>\n[--use-tooling-api]`). Schemas verified against live Salesforce API\nv66.0 via `sf sobject describe` on `my-org-alias` + `my-org-alias-2`,\n2026-05-02. Official Salesforce Help pages describe logical structures\nthat frequently diverge from the physical fields the REST / Tooling API\nactually expose. When a Help page disagrees with a live describe, trust\nthe live describe — the names in this reference are what you query.\n\nThe source of truth for \"mandatory\" is `scripts/probe_channels.py`'s\n`MANDATORY_FIELDS` map. A probe that sees any `[mandatory]` field\nmissing flips to `status: \"PROBE_FAILED\"` — the skill aborts with a\nclean error rather than producing a subtly-wrong tree. `[optional]`\nfields degrade gracefully — missing ones are recorded but don't block\nthe run.\n\n---\n\n## Casing gotchas\n\n- **Mixed case is mandatory for `GenAi*` sObjects.** API names are\n `GenAiPlannerDefinition`, `GenAiPluginDefinition`, `GenAiFunctionDefinition`,\n `GenAiPluginFunctionDef`, `GenAiPluginInstructionDef`,\n `GenAiPlannerFunctionDef`, `GenAiPlannerAttrDefinition`. Lowercase\n variants (`genai_planner_definition`, `genaiplannerdefinition`) do not\n resolve. The SOQL parser is case-insensitive on keywords but\n sObject + field names must match the describe output for Tooling API\n calls to route correctly.\n- **`GenAiPluginFunctionDef` vs `GenAiPlannerFunctionDef` are different\n tables.** Plugin-scope join (topic → function, via `PluginId`) vs\n planner-bundle-scope join (planner → function, via `PlannerId`). Both\n are 10-field join tables with nearly identical shape; keep them\n straight.\n- **`BotVersion.DeveloperName` is the version id, not the version\n label.** `DeveloperName` on BotVersion is the version API name (e.g.\n `v5`), used as the second path segment under\n `\u003corg_id15>/\u003cagent>__\u003cversion>/`. `MasterLabel` is the\n human-readable label (e.g. \"Version 5 — ported from staging\"). Never\n mix them — the data dir layout and the SKILL.md input contract both\n key on `DeveloperName`.\n- **`complexvalue` fields require single-row retrieval.** `Metadata`\n (on `Flow`, `FlowDefinition`, `GenAiPluginDefinition`,\n `GenAiFunctionDefinition`), `SymbolTable` (on `ApexClass`), and\n `AgentGraph` (on `GenAiPlannerDefinition`, often null in practice) are\n `complexvalue` types. Salesforce enforces\n `MALFORMED_QUERY: When retrieving results with Metadata or FullName\n fields, the query qualifications must specify no more than one row for\n retrieval.` Batch IN-clause SELECTs that return ≥2 rows fail. Use a\n single-row equality (`WHERE Id = '\u003cid>'`) per fetch; parallelize\n across ids via `concurrent.futures`. `ApexClass.Body` +\n `ApexClass.SymbolTable` is the one notable exception: the\n `Name IN (...)` batch works even though `SymbolTable` is complex.\n\n---\n\n## Cross-sObject join map\n\nEvery edge is strictly **forward** from the entry query\n(`GenAiPlannerDefinition WHERE DeveloperName = :planner_name`). No\nbackward lookups.\n\n```text\nBotDefinition (Data API, PK Id, matched by DeveloperName)\n └── BotVersion (Data API, FK BotDefinitionId)\n │ [resolved to planner name via Bot metadata retrieve]\n ▼\nGenAiPlannerDefinition (Tooling, PK Id, matched by DeveloperName)\n │\n ├── GenAiPluginDefinition ← WHERE PlannerId = :plannerId\n │ ├── GenAiPluginInstructionDef ← WHERE GenAiPluginDefinitionId IN (:topic_ids)\n │ ├── GenAiPluginFunctionDef ← WHERE PluginId IN (:topic_ids)\n │ │ └── Function picklist → GenAiFunctionDefinition.Id\n │ └── GenAiFunctionDefinition ← WHERE PluginId IN (:topic_ids)\n │\n ├── GenAiPlannerFunctionDef ← WHERE PlannerId = :plannerId\n │ └── Plugin picklist → GenAiPluginDefinition.Id (or external via retrieve)\n │\n ├── GenAiFunctionDefinition ← WHERE PluginId IN (:topic_ids)\n │ │ (single-query — see functions_by_plugins.soql)\n │ │\n │ └── InvocationTargetType + InvocationTarget route to:\n │ ├── flow → FlowDefinition (batch IN) → Flow.Metadata (parallel single-row)\n │ ├── apex → ApexClass (batch IN, Body + SymbolTable)\n │ ├── standardInvocableAction → (no further fetch — declared only)\n │ └── generatePromptResponse → GenAiPromptTemplate (Metadata API retrieve)\n │\n └── GenAiPlannerAttrDefinition ← WHERE ParentId IN (:function_ids, :planner_id)\n (polymorphic ParentId: GenAiFunctionDefinition OR GenAiPlannerDefinition)\n```\n\nPer-channel row counts + `_unresolved[]` reasons are recorded in\n`metadata_tree.json` under `_channels` and `_unresolved` keys.\n\n---\n\n## Data API sObjects (2)\n\n### `BotDefinition` (Data API) — one row per agent\n\nRoot of the agent metadata. Matched by `DeveloperName`; the rest of the\ntree is resolved forward from the `BotVersion` child.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `DeveloperName` | string | no | yes | [mandatory] |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `AgentType` | picklist | yes | yes | [optional] — discriminator for classic vs NGA in some orgs |\n| `Type` | picklist | yes | yes | [optional] |\n| `AgentTemplate` | string | yes | yes | [optional] — e.g. `SvcCopilotTmpl__EinsteinAgentKind` |\n| `BotSource` | picklist | yes | yes | [optional] |\n| `AgentUser` | reference | yes | yes | [optional] — FK to `User` |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `LastModifiedDate` | datetime | no | yes | [optional] |\n| `SystemModstamp` | datetime | no | yes | [optional] |\n\n### `BotVersion` (Data API) — one row per agent version\n\nResolves the active version (or user-pinned version) for a given bot.\nParent relationship: `BotDefinition` via `BotDefinitionId`.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `BotDefinitionId` | reference | no | yes | [mandatory] |\n| `DeveloperName` | string | no | yes | [optional] — version id (e.g. `v5`) |\n| `MasterLabel` | string | yes | yes | [optional] — human label |\n| `Status` | picklist | yes | yes | [optional] — `Active` on the current published version |\n| `VersionNumber` | int | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `AiReplyRecordVisibility` | picklist | yes | yes | [optional] |\n| `ResponseDelayMilliseconds` | int | yes | yes | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `LastModifiedDate` | datetime | no | yes | [optional] |\n\n---\n\n## Tooling API sObjects (11)\n\nAll sObjects in this section are reachable via the Tooling API only\n(`sf data query --use-tooling-api` or `sf sobject describe --use-tooling-api`).\n\n### `ApexClass` (Tooling) — Apex source + parsed AST\n\nSource + full method/property AST for Apex referenced by\n`GenAiFunctionDefinition.InvocationTarget` when\n`InvocationTargetType = 'apex'`. Batch-safe on `Name IN (...)` or\n`Id IN (...)` (despite `SymbolTable` being `complexvalue`, Salesforce\npermits the batch for this particular sObject — verified live).\n\n**Note:** `SymbolTable` is **not part of `FIELDS(ALL)`** and must be\nnamed explicitly in the SELECT.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `Name` | string | no | yes | [mandatory] |\n| `Body` | textarea | yes | no | [optional] — full Apex source |\n| `SymbolTable` | complexvalue | yes | no | [optional] — parsed AST (methods, params, annotations, line/col) |\n| `ApiVersion` | double | no | yes | [optional] |\n| `IsValid` | boolean | no | yes | [optional] |\n| `Status` | picklist | no | yes | [optional] |\n| `LengthWithoutComments` | int | yes | yes | [optional] |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `FullName` | string | yes | no | [optional] — complexvalue companion |\n| `Metadata` | complexvalue | yes | no | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `LastModifiedDate` | datetime | no | yes | [optional] |\n| `SystemModstamp` | datetime | no | yes | [optional] |\n\n**Single-row requirement for complexvalue columns:** `Metadata` and\n`FullName` on `ApexClass` follow the standard `MALFORMED_QUERY` rule —\nbatch IN-clause SELECTs that return ≥2 rows fail when these two are\nselected. `Body` + `SymbolTable` are the exception (batch works).\nComments in `assets/soql/apex_class_bodies_by_ids.soql` +\n`apex_class_bodies_by_names.soql` pin this.\n\n### `Flow` (Tooling) — Flow version body\n\nSingle-row retrieval required — `Metadata` and `FullName` are both\n`complexvalue`. Fired once per `activeVersionId` returned by\n`FlowDefinition`; parallelized via `ThreadPoolExecutor`.\n\n**Filterability quirk:** `FullName` is selectable but **not filterable**\n(`INVALID_FIELD: field 'FullName' can not be filtered in a query call`).\nFilter by `Id` instead.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `DefinitionId` | reference | no | yes | [mandatory] |\n| `FullName` | string | yes | **no** | [optional] — complexvalue companion; not filterable |\n| `Metadata` | complexvalue | yes | no | [optional] — full flow JSON (actionCalls, subflows, variables, decisions, formulas, assignments, apexPluginCalls) |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `VersionNumber` | int | yes | yes | [optional] |\n| `ProcessType` | picklist | yes | yes | [optional] |\n| `Status` | picklist | yes | yes | [optional] |\n| `ApiVersion` | double | yes | yes | [optional] |\n| `IsActive` | boolean | no | yes | [optional] |\n| `IsTemplate` | boolean | no | yes | [optional] |\n| `RunInMode` | picklist | yes | yes | [optional] |\n| `Environments` | picklist | yes | yes | [optional] |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `OverriddenFlowId` | reference | yes | yes | [optional] |\n| `SourceTemplateId` | reference | yes | yes | [optional] |\n| `TriggerType` | picklist | yes | yes | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `LastModifiedDate` | datetime | no | yes | [optional] |\n| `SystemModstamp` | datetime | no | yes | [optional] |\n| `InstalledPackageName` | string | yes | yes | [optional] |\n\n**Single-row requirement:** `Metadata` and `FullName` force\n`WHERE Id = '\u003cversion_id>'`. The SOQL asset `flow_metadata_by_id.soql`\nencodes this as a single `Id = '...'` predicate.\n\n### `FlowDefinition` (Tooling) — Flow versioning index\n\nTwo-hop feeder to `Flow`. Batch IN-clause works. Returns\n`ActiveVersionId` + `LatestVersionId` — the skill prefers active.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `DeveloperName` | string | no | yes | [mandatory] |\n| `ActiveVersionId` | reference | yes | yes | [optional] — FK to `Flow.Id` |\n| `LatestVersionId` | reference | yes | yes | [optional] — FK to `Flow.Id` |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `Metadata` | complexvalue | yes | no | [optional] — definition-level metadata (may carry what the Flow hop provides on some orgs) |\n| `FullName` | string | yes | no | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `LastModifiedDate` | datetime | no | yes | [optional] |\n| `SystemModstamp` | datetime | no | yes | [optional] |\n\n### `GenAiPlannerDefinition` (Tooling) — planner root\n\nEntry query for the tree. Matched by `DeveloperName` (the\n`\u003cgenAiPlannerName>` extracted from the Bot metadata retrieve). The\n`PlannerType` picklist is the classifier for classic ReAct vs NGA.\n\n**Seven-value `PlannerType` picklist** (verified consistent across\n`my-org-alias`, `my-org-alias-2`, `my-org-alias-3`): grouped by\nnamespace — `AiCopilot__*` = classic ReAct family,\n`Atlas__*` = NGA family. `startswith(\"Atlas__\")` is a clean\nclassic-vs-NGA discriminator.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `DeveloperName` | string | no | yes | [mandatory] |\n| `PlannerType` | picklist | yes | yes | [mandatory] |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `Capabilities` | textarea | yes | no | [optional] — null in every row tested on both classic + NGA |\n| `AgentGraph` | complexvalue | yes | no | [optional] — null in every row tested; single-row rule still applies |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `Metadata` | complexvalue | yes | no | [optional] |\n| `FullName` | string | yes | no | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `LastModifiedDate` | datetime | no | yes | [optional] |\n| `SystemModstamp` | datetime | no | yes | [optional] |\n\n**Single-row requirement for complexvalue columns:** `AgentGraph`,\n`Metadata`, `FullName` — select individually; entry query pins\n`WHERE DeveloperName = '...' LIMIT 1`.\n\n### `GenAiPluginDefinition` (Tooling) — topics\n\nAll topics for a planner via `WHERE PlannerId = :plannerId`. Carries\n`PluginType` + `Scope` for topic classification and `CanEscalate` /\n`IsLocal` for behavior flags.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `DeveloperName` | string | no | yes | [mandatory] |\n| `PluginType` | picklist | yes | yes | [optional] — topic type classifier |\n| `Scope` | textarea | yes | no | [optional] — natural-language topic scope |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `CanEscalate` | boolean | yes | yes | [optional] |\n| `IsLocal` | boolean | yes | yes | [optional] |\n| `Source` | picklist | yes | yes | [optional] |\n| `ParentId` | reference | yes | yes | [optional] — planner FK (sometimes referred to as `PlannerId` in SOQL filter clauses) |\n| `LocalDeveloperName` | string | yes | yes | [optional] |\n| `Language` | picklist | yes | yes | [optional] |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `Metadata` | complexvalue | yes | no | [optional] |\n| `FullName` | string | yes | no | [optional] |\n| `ClassificationDescription` | textarea | yes | no | [optional] |\n| `GenAiFunctionInvoker` | string | yes | yes | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n\n**Single-row requirement:** `Metadata` + `FullName` require per-id\nretrieval. The production path doesn't SELECT `Metadata` on this sObject\n(see `plugins_by_planner.soql`) — it pulls `Scope` + scalar fields\ninstead, which batch-safely over `PlannerId = :id`.\n\n### `GenAiPluginFunctionDef` (Tooling) — plugin-function join\n\nJoin table: `GenAiPluginDefinition` → `GenAiFunctionDefinition`. Batch-\nsafe on `PluginId IN (...)`.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `PluginId` | reference | no | yes | [mandatory] |\n| `Function` | picklist | yes | yes | [optional] — references `GenAiFunctionDefinition.Id` |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `SystemModstamp` | datetime | no | yes | [optional] |\n\n### `GenAiPluginInstructionDef` (Tooling) — per-topic instructions\n\nPer-topic instruction text + ordering. Batch-safe on\n`GenAiPluginDefinitionId IN (...)`.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `GenAiPluginDefinitionId` | reference | no | yes | [mandatory] |\n| `DeveloperName` | string | no | yes | [optional] |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] — the instruction text |\n| `SortOrder` | int | yes | yes | [optional] |\n| `Language` | picklist | yes | yes | [optional] |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `FullName` | string | yes | no | [optional] |\n| `Metadata` | complexvalue | yes | no | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n\n**Single-row requirement:** `Metadata` + `FullName` complexvalue pair;\nthe production SOQL omits them (scalar fields batch-safely).\n\n### `GenAiFunctionDefinition` (Tooling) — actions\n\nThe actions. Combined single-query fetches both bundle-scope (`PlannerId`)\nand topic-scope (`PluginId IN`) functions. `InvocationTargetType`\n+ `InvocationTarget` route to the downstream fetch (Flow, Apex, prompt,\nstandard invocable).\n\n**`InvocationTarget` format varies by planner shape** (the ID-prefix\nrouter lives in `scripts/resolve_invocation_target.py`):\n- Classic ReAct: DeveloperName string (e.g. `AGNT_SetUserSelectedOption`).\n- NGA: Salesforce 15/18-char Id (e.g. `01pVF...` = ApexClass,\n `300VF...` = FlowDefinition, `0hf...` = GenAiPromptTemplate).\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `DeveloperName` | string | no | yes | [mandatory] |\n| `InvocationTarget` | string | yes | yes | [mandatory] |\n| `InvocationTargetType` | picklist | yes | yes | [optional] — `flow` / `apex` / `standardInvocableAction` / `generatePromptResponse` |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `IsLocal` | boolean | yes | yes | [optional] |\n| `IsConfirmationRequired` | boolean | yes | yes | [optional] |\n| `IsIncludeInProgressIndicator` | boolean | yes | yes | [optional] |\n| `ProgressIndicatorMessage` | string | yes | yes | [optional] |\n| `Source` | picklist | yes | yes | [optional] |\n| `PluginId` | reference | yes | yes | [optional] — topic-scope FK (null for bundle-scope functions) |\n| `PlannerId` | reference | yes | yes | [optional] — bundle-scope FK (null on NGA — attachment is plugin-only) |\n| `ParentId` | reference | yes | yes | [optional] |\n| `LocalDeveloperName` | string | yes | yes | [optional] |\n| `Language` | picklist | yes | yes | [optional] |\n| `InvocationTargetApiName` | string | yes | yes | [optional] |\n| `MissingValuePromptMessage` | string | yes | yes | [optional] |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `Metadata` | complexvalue | yes | no | [optional] |\n| `FullName` | string | yes | no | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n\n**Single-row requirement:** `Metadata` + `FullName` complexvalue\ncolumns; production SOQL omits both to keep the `PlannerId / PluginId IN`\nbatch path intact.\n\n### `GenAiPlannerFunctionDef` (Tooling) — planner-bundle join\n\nBundle-scope join: planner → function. Analogous to\n`GenAiPluginFunctionDef` but keyed on `PlannerId`. Batch-safe on\n`PlannerId = :id`.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `PlannerId` | reference | no | yes | [mandatory] |\n| `Plugin` | picklist | yes | yes | [optional] — references plugin DeveloperName / id |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n| `SystemModstamp` | datetime | no | yes | [optional] |\n\n### `GenAiPlannerAttrDefinition` (Tooling) — parameter mappings\n\nThe `attributeMappings` — I/O parameter bindings between actions and\nthe planner. **Polymorphic `ParentId`**: points at either a\n`GenAiFunctionDefinition` (function-scope mappings) or\n`GenAiPlannerDefinition` (bundle-scope mappings). The production SOQL\npasses the union of function ids + planner id via\n`WHERE ParentId IN (:function_ids, :planner_id)`.\n\n`MappingType` is the `input` / `output` picklist; `ParameterName`\nidentifies the bound variable on the planner side.\n\n| Name | Type | Nillable | Filterable | Tag |\n|---|---|---|---|---|\n| `Id` | id | no | yes | [mandatory] |\n| `ParentId` | reference | yes | yes | [optional] — polymorphic: GenAiFunctionDefinition \\| GenAiPlannerDefinition |\n| `DeveloperName` | string | no | yes | [optional] |\n| `MasterLabel` | string | yes | yes | [optional] |\n| `Description` | textarea | yes | no | [optional] |\n| `MappingType` | picklist | yes | yes | [optional] — `input` / `output` |\n| `ParameterName` | string | yes | yes | [optional] |\n| `Language` | picklist | yes | yes | [optional] |\n| `NamespacePrefix` | string | yes | yes | [optional] |\n| `ManageableState` | picklist | yes | yes | [optional] |\n| `IsDeleted` | boolean | no | yes | [optional] |\n| `CreatedById` | reference | no | yes | [optional] |\n| `CreatedDate` | datetime | no | yes | [optional] |\n| `LastModifiedById` | reference | no | yes | [optional] |\n\n---\n\n## Known picklist values (live-API verified, 2026-05-02)\n\n| sObject | Field | Values |\n|---|---|---|\n| `GenAiPlannerDefinition` | `PlannerType` | Seven values split by namespace. Classic family: `AiCopilot__ReAct`, `AiCopilot__ReactAiPlannerV1`, `AiCopilot__SequentialPlannerIntentClassifier`. NGA family: `Atlas__ConcurrentMultiAgentOrchestration`, `Atlas__AnthropicCompatibleV1`, `Atlas__AtlasReactV1`, `Atlas__MainSubAgent` (exact set may vary by release — `startswith(\"Atlas__\")` is the stable classic-vs-NGA discriminator). |\n| `GenAiFunctionDefinition` | `InvocationTargetType` | `flow`, `apex`, `standardInvocableAction`, `generatePromptResponse` |\n| `GenAiPlannerAttrDefinition` | `MappingType` | `input`, `output` |\n| `BotVersion` | `Status` | `Inactive`, `Active` (active = currently published version) |\n\n---\n\n## Mandatory-field enforcement\n\nThe `[mandatory]` tags above mirror `scripts/probe_channels.py`:\n\n```python\nMANDATORY_FIELDS: Dict[str, set[str]] = {\n \"BotDefinition\": {\"Id\", \"DeveloperName\"},\n \"BotVersion\": {\"Id\", \"BotDefinitionId\"},\n \"ApexClass\": {\"Id\", \"Name\"},\n \"Flow\": {\"Id\", \"MasterLabel\", \"DefinitionId\"},\n \"FlowDefinition\": {\"Id\", \"DeveloperName\"},\n \"GenAiPlannerDefinition\": {\"Id\", \"DeveloperName\", \"PlannerType\"},\n \"GenAiPluginDefinition\": {\"Id\", \"DeveloperName\"},\n \"GenAiFunctionDefinition\": {\"Id\", \"DeveloperName\", \"InvocationTarget\"},\n \"GenAiPlannerAttrDefinition\": {\"Id\"},\n}\n```\n\n`GenAiPluginFunctionDef`, `GenAiPluginInstructionDef`, and\n`GenAiPlannerFunctionDef` don't appear in `MANDATORY_FIELDS` — they're\njoin tables whose `Id` + FK columns are present by construction on\nevery query. The per-sObject tables above tag their FK columns as\n`[mandatory]` because the SOQL assets SELECT them; a probe that saw\nthem missing would still flip `PROBE_FAILED` via the `Id` check, but\nthe tag reflects \"the skill's SOQL will fail if this column is gone.\"\n\nWhen a Salesforce quarterly release renames or removes a\n`[mandatory]` field, run with `--reprobe` to force a fresh describe and\nsurface the drift cleanly.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":25195,"content_sha256":"7f69b34c6042031c590035947cbe54070b95e6a8a02feb4792ce077a8112aade"},{"filename":"scripts/_shared/__init__.py","content":"# Shared path / fs-guard / sql helpers for investigating-agentforce-architecture.\n","content_type":"text/x-python; charset=utf-8","language":"python","size":82,"content_sha256":"bad64d567f36a5e04f83293b3e445171e6ca2c4373cb412d97883412bc8a0800"},{"filename":"scripts/_shared/fs_guard.py","content":"#!/usr/bin/env python3\nr\"\"\"Filesystem-safety and input-validation guard.\n\nOne script, six check types. Emits a STATUS=INVALID_INPUT RESULT block on\nfailure + tees to $ERROR_TEE on disk + exits 1.\n\nGuiding principle: the agent MUST suffix every call with `|| exit 1` — Python\n`sys.exit(1)` only terminates this subprocess, not the parent bash. A bare\ncall without `|| exit 1` silently continues past a failed guard, which is\nthe worst possible failure mode for a security check.\n\nCheck types:\n symlink — path must NOT be a symlink (rejects pre-planted attacker bait)\n owned — path must be owned by current UID (rejects foreign-owned dirs)\n uuid — value must match ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$\n org_id_15 — value must match ^[A-Za-z0-9]{15}$ (Salesforce org ID slice)\n api_name — value must match ^[A-Za-z0-9_]+$ (Salesforce API identifier)\n api_version — value must match ^v[0-9]+\\.[0-9]+$ (e.g. v60.0)\n agent_version — value must match ^v[0-9]+$ (e.g. v5 — Agentforce agent version, no dot-minor)\n not_empty — value must be non-empty string\n\nPython-importable API:\n validate_api_name(value, label=\"...\") — raises ValidationError on bad input\n validate_api_version(value, label=\"...\") — raises ValidationError on bad input\n validate_agent_version(value, label=\"...\") — raises ValidationError on bad input\n validate_org_id_15(value, label=\"...\") — raises ValidationError on bad input\n ValidationError — carries (label, reason)\n\nThe Python API is used by in-process callers (SOQL loader, path builders in\nconfig.py) that need a raise-based boundary rather than the process-exit\nCLI behavior. Regex is shared — do NOT duplicate API_NAME_RE / API_VERSION_RE.\n\nUsage:\n python3 fs_guard.py \u003cvalue> \u003clabel> \u003ccheck> || exit 1\n\n # Input validation\n python3 fs_guard.py \"$AGENT_API_NAME\" agent_api_name api_name || exit 1\n python3 fs_guard.py \"$ORG_ALIAS\" org_alias not_empty || exit 1\n\n # Filesystem safety\n python3 fs_guard.py \"$WORK_DIR\" WORK_DIR symlink || exit 1\n python3 fs_guard.py \"$WORK_DIR\" WORK_DIR owned || exit 1\n python3 fs_guard.py \"$ORG_ID_15\" ORG_ID_15 org_id_15 || exit 1\n\nInputs:\n argv[1] value or path to check\n argv[2] label (appears in ERROR_DETAIL; used to identify which guard tripped)\n argv[3] check type (one of the 6 above)\n env $ERROR_TEE (optional): path to disk-tee file. Defaults to\n $HOME/.vibe/data/investigating-agentforce-architecture/_last_error_result.txt.\n Also reads $AGENT_API_NAME / $ORG_ID_18 / $ORG_ID_15 for\n RESULT-block context if set.\n\nOutputs:\n on failure: STATUS=INVALID_INPUT RESULT block on stdout + tee to disk, exit 1\n on success: silent, exit 0\n on bad argv: exit 1 with a minimal ERROR_DETAIL (\"fs_guard internal: ...\")\n\"\"\"\nimport os\nimport pathlib\nimport re\nimport sys\n\nUUID_RE = re.compile(r\"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$\") # @rule-suppress starter-sec-002 — re.compile, not eval/exec\nORG_ID_15_RE = re.compile(r\"^[A-Za-z0-9]{15}$\") # @rule-suppress starter-sec-002 — re.compile, not eval/exec\nAPI_NAME_RE = re.compile(r\"^[A-Za-z0-9_]+$\") # @rule-suppress starter-sec-002 — re.compile, not eval/exec\n# api_version check — matches `v60.0`, `v66.0`, etc. Used before any\n# Path composition that embeds the api_version segment.\nAPI_VERSION_RE = re.compile(r\"^v[0-9]+\\.[0-9]+$\") # @rule-suppress starter-sec-002 — re.compile, not eval/exec\n# Agent-version check — matches Agentforce version identifiers (`v1`, `v5`,\n# `v12`). Deliberately stricter than api_name (rejects free-form names like\n# `release_1`) and looser than api_version (rejects `v66.0` which is a REST\n# API version, never an agent version). Lives on its own so callers that\n# embed agent_version in a filesystem path get the tight regex, not the\n# permissive api_name fallback.\nAGENT_VERSION_RE = re.compile(r\"^v[0-9]+$\") # @rule-suppress starter-sec-002 — re.compile, not eval/exec\nVALID_CHECKS = {\"symlink\", \"owned\", \"uuid\", \"org_id_15\", \"api_name\", \"api_version\", \"agent_version\", \"not_empty\"}\n\n\n# -----------------------------------------------------------------------------\n# Python-importable API \n# -----------------------------------------------------------------------------\n# In-process callers (soql_loader.load_soql, config.build_*_dir) need a\n# raise-based validation boundary, distinct from the CLI script's exit-1\n# behavior. The regexes are shared with the CLI checks above — do NOT\n# duplicate. If a regex changes, both surfaces update in lockstep.\n\n\nclass ValidationError(ValueError):\n \"\"\"Raised by the Python-importable validators on bad input.\n\n Carries the label (which field failed) + reason. Callers (e.g.\n soql_loader.load_soql) convert this into `_unresolved[]` entries or\n RESULT-level INVALID_INPUT responses as appropriate.\n \"\"\"\n\n def __init__(self, label: str, reason: str) -> None:\n self.label = label\n self.reason = reason\n super().__init__(f\"{label}: {reason}\")\n\n\ndef validate_api_name(value, label: str = \"value\") -> None:\n \"\"\"Validate `value` against ^[A-Za-z0-9_]+$ (Salesforce API identifier).\n\n used by soql_loader.load_soql at the substitution boundary to catch\n injection attempts in names pulled from Bot XML, Flow.Metadata, ApexClass\n SymbolTable, etc.\n\n used by config.build_*_dir helpers for path components that must\n not contain `..`, `/`, or other traversal characters.\n\n Raises ValidationError on any of: None, non-string, empty, regex miss.\n \"\"\"\n if value is None:\n raise ValidationError(label, \"is None\")\n if not isinstance(value, str):\n raise ValidationError(label, f\"must be str, got {type(value).__name__}\")\n if not value:\n raise ValidationError(label, \"must not be empty\")\n if not API_NAME_RE.match(value):\n # Preview first 20 chars so logs/_unresolved entries carry enough\n # context to debug, without dumping an unbounded attacker-controlled\n # string into output.\n preview = value[:20]\n raise ValidationError(\n label,\n f\"does not match [A-Za-z0-9_]+ (preview={preview!r})\",\n )\n\n\ndef validate_api_version(value, label: str = \"api_version\") -> None:\n \"\"\"Validate `value` against ^v[0-9]+\\\\.[0-9]+$ (Salesforce API version).\n\n used by config.build_*_dir helpers. api_version is returned by\n `sf org display` and later embedded in cache paths; reject anything that\n could escape the cache subtree.\n \"\"\"\n if value is None:\n raise ValidationError(label, \"is None\")\n if not isinstance(value, str):\n raise ValidationError(label, f\"must be str, got {type(value).__name__}\")\n if not value:\n raise ValidationError(label, \"must not be empty\")\n if not API_VERSION_RE.match(value):\n preview = value[:20]\n raise ValidationError(\n label,\n f\"does not match v\u003cmajor>.\u003cminor> (preview={preview!r})\",\n )\n\n\ndef validate_agent_version(value, label: str = \"agent_version\") -> None:\n \"\"\"Validate `value` against ^v[0-9]+$ (Agentforce agent version).\n\n Strictly `v\u003cdigits>` with no dot-minor — matches the shape Agentforce\n actually uses (`v1`, `v5`, `v12`). Rejects `release_1`, `v66.0`, `FOO`,\n and anything else that could silently slip past a permissive api_name\n check and land in a filesystem path.\n\n used by path-builder helpers for the agent_version segment.\n \"\"\"\n if value is None:\n raise ValidationError(label, \"is None\")\n if not isinstance(value, str):\n raise ValidationError(label, f\"must be str, got {type(value).__name__}\")\n if not value:\n raise ValidationError(label, \"must not be empty\")\n if not AGENT_VERSION_RE.match(value):\n preview = value[:20]\n raise ValidationError(\n label,\n f\"does not match v\u003cdigits> (preview={preview!r})\",\n )\n\n\ndef validate_org_id_15(value, label: str = \"org_id_15\") -> None:\n \"\"\"Validate `value` against ^[A-Za-z0-9]{15}$ (Salesforce 15-char org ID).\n\n stricter than validate_api_name — enforces exact 15 chars AND\n no underscores. `org_id_15` always comes from a Salesforce-generated\n field, never from free-form input.\n \"\"\"\n if value is None:\n raise ValidationError(label, \"is None\")\n if not isinstance(value, str):\n raise ValidationError(label, f\"must be str, got {type(value).__name__}\")\n if not value:\n raise ValidationError(label, \"must not be empty\")\n if not ORG_ID_15_RE.match(value):\n preview = value[:20]\n raise ValidationError(\n label,\n f\"must be exactly 15 alphanumeric chars (preview={preview!r})\",\n )\n\n\ndef scrub(s: str) -> str:\n # Same rules as sanitize.py; duplicated locally so this script has no\n # intra-skill imports. Keeps the agent-script boundary clean.\n bad = set(\"`$\\\"\\\\\\r\\t\\0\\n\")\n return \"\".join(c for c in (s or \"\") if c not in bad)\n\n\ndef emit_failure(reason: str, label: str) -> None:\n agent_api_name = scrub(os.environ.get(\"AGENT_API_NAME\", \"\"))\n org_id_18 = scrub(os.environ.get(\"ORG_ID_18\", \"\"))\n org_id_15 = scrub(os.environ.get(\"ORG_ID_15\", \"\"))\n label_safe = scrub(label)\n reason_safe = scrub(reason)\n\n block = (\n \"=== RESULT ===\\n\"\n \"STATUS=INVALID_INPUT\\n\"\n f\"ERROR_DETAIL={label_safe}: {reason_safe}\\n\"\n f\"AGENT_API_NAME={agent_api_name}\\n\"\n f\"ORG_ID_18={org_id_18}\\n\"\n f\"ORG_ID_15={org_id_15}\\n\"\n )\n\n # Default ERROR_TEE — skill-scoped data dir. Runtime-agnostic default\n # mirrors runtime.resolve_data_root() in the sibling runtime module\n # (duplicated rather than imported because fs_guard.py is also invoked\n # standalone from SKILL.md bash, where the override hook isn't active).\n tee_default = str(\n pathlib.Path.home()\n / \".vibe\"\n / \"data\"\n / \"investigating-agentforce-architecture\"\n / \"_last_error_result.txt\"\n )\n tee_path = os.environ.get(\"ERROR_TEE\") or tee_default\n try:\n p = pathlib.Path(tee_path)\n p.parent.mkdir(parents=True, exist_ok=True)\n p.write_text(block)\n except OSError:\n pass\n\n sys.stdout.write(block)\n sys.exit(1)\n\n\ndef check_symlink(value: str, label: str) -> None:\n if pathlib.Path(value).is_symlink():\n emit_failure(f\"path is a symlink (refusing to follow): {value}\", label)\n\n\ndef check_owned(value: str, label: str) -> None:\n p = pathlib.Path(value)\n if not p.exists():\n return\n try:\n st = p.stat()\n except OSError as e:\n emit_failure(f\"stat failed: {e}\", label)\n return\n if st.st_uid != os.getuid():\n emit_failure(f\"foreign-owned (uid {st.st_uid} != current {os.getuid()}): {value}\", label)\n\n\ndef check_uuid(value: str, label: str) -> None:\n if not UUID_RE.match(value):\n emit_failure(\"does not match UUID pattern (8-4-4-4-12 lowercase hex)\", label)\n\n\ndef check_org_id_15(value: str, label: str) -> None:\n if not ORG_ID_15_RE.match(value):\n emit_failure(\"must be exactly 15 alphanumeric characters\", label)\n\n\ndef check_api_name(value: str, label: str) -> None:\n if not API_NAME_RE.match(value):\n emit_failure(\"does not match [A-Za-z0-9_]+ (Salesforce API name rules)\", label)\n\n\ndef check_api_version(value: str, label: str) -> None:\n # api_version must be `vNN.N` — no slashes, no dots beyond the one,\n # no path-traversal sequences.\n if not API_VERSION_RE.match(value):\n emit_failure(\"does not match v\u003cmajor>.\u003cminor> (e.g. v60.0)\", label)\n\n\ndef check_agent_version(value: str, label: str) -> None:\n # Agent version must be `v\u003cdigits>` — no dots, no slashes.\n if not AGENT_VERSION_RE.match(value):\n emit_failure(\"does not match v\u003cdigits> (e.g. v5)\", label)\n\n\ndef check_not_empty(value: str, label: str) -> None:\n if not value:\n emit_failure(\"must not be empty\", label)\n\n\nCHECKS = {\n \"symlink\": check_symlink,\n \"owned\": check_owned,\n \"uuid\": check_uuid,\n \"org_id_15\": check_org_id_15,\n \"api_name\": check_api_name,\n \"api_version\": check_api_version,\n \"agent_version\": check_agent_version,\n \"not_empty\": check_not_empty,\n}\n\n\ndef main() -> int:\n if len(sys.argv) != 4:\n sys.stdout.write(\n \"=== RESULT ===\\n\"\n \"STATUS=INVALID_INPUT\\n\"\n \"ERROR_DETAIL=fs_guard internal: wrong argv count (need value, label, check)\\n\"\n )\n return 1\n value, label, check = sys.argv[1], sys.argv[2], sys.argv[3]\n\n for arg in (value, label, check):\n if any(c == \"\\0\" or (ord(c) \u003c 0x20 and c not in \"\\t\\n\") for c in arg):\n emit_failure(\"argv contains control characters\", label or \"argv\")\n\n if check not in VALID_CHECKS:\n emit_failure(f\"unknown check type '{check}' (valid: {', '.join(sorted(VALID_CHECKS))})\", label)\n\n CHECKS[check](value, label)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13157,"content_sha256":"aa5728a2ca24cd6e9ed89c5d19ff16155975d20523c4f82883323fc18260cbe9"},{"filename":"scripts/_shared/paths.py","content":"\"\"\"Canonical path helpers for investigating-agentforce-architecture.\n\nLayout:\n\n ~/.vibe/data/investigating-agentforce-architecture/\n └── \u003corg_id_15>/\n └── \u003cagent_api_name>__\u003cagent_version>/\n ├── \u003cagent>_\u003cver>_metadata_tree.json ← rendered tree\n └── .emit_ctx.json ← per-run shell ctx\n\nValidation strategy\n-------------------\nThe three \"agent-identity\" segments (org_id_15, agent_api_name, agent_version)\nare validated by the regex helpers in the sibling ``fs_guard`` module. Any\nsegment containing ``..``, ``/``, or characters outside its regex charclass\nraises ``PathValidationError`` — direct ``Path`` composition from unvalidated\ninput would be a path-traversal vulnerability and is prohibited.\n\nRuntime override\n----------------\n``DATA_ROOT`` / ``CACHE_ROOT`` are resolved via the ``runtime`` module so\nthat entry scripts can override them via ``--data-dir`` / ``--cache-dir``\nCLI flags. The default values (``~/.vibe/{data,cache}/...``)\nare runtime-agnostic; AFV OOTB, Codex, Cursor, OpenCode each pass their\nown override paths.\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom . import fs_guard\nfrom . import runtime\n\n\n# -----------------------------------------------------------------------------\n# Roots\n# -----------------------------------------------------------------------------\n\nDATA_ROOT: Path = runtime.resolve_data_root(\"investigating-agentforce-architecture\")\nCACHE_ROOT: Path = runtime.resolve_cache_root(\"investigating-agentforce-architecture\")\n\n\nclass PathValidationError(ValueError):\n \"\"\"Raised when a path segment fails validation.\n\n Wraps fs_guard's ``ValidationError`` so callers have a single exception\n type to catch.\n \"\"\"\n\n def __init__(self, label: str, reason: str) -> None:\n self.label = label\n self.reason = reason\n super().__init__(f\"{label}: {reason}\")\n\n\n# -----------------------------------------------------------------------------\n# Validation helpers\n# -----------------------------------------------------------------------------\n\n\ndef _validate_agent_triple(\n org_id_15: str, agent_api_name: str, agent_version: str\n) -> None:\n \"\"\"Validate the three identity segments. Raises PathValidationError on\n failure, wrapping fs_guard's ValidationError so callers don't need to\n know about fs_guard internals.\"\"\"\n try:\n fs_guard.validate_org_id_15(org_id_15, label=\"org_id_15\")\n fs_guard.validate_api_name(agent_api_name, label=\"agent_api_name\")\n fs_guard.validate_agent_version(agent_version, label=\"agent_version\")\n except fs_guard.ValidationError as e:\n raise PathValidationError(e.label, e.reason) from e\n\n\n# -----------------------------------------------------------------------------\n# Path builders\n# -----------------------------------------------------------------------------\n\n\ndef agent_dir(\n org_id_15: str, agent_api_name: str, agent_version: str\n) -> Path:\n \"\"\"Return ``DATA_ROOT/\u003corg_id_15>/\u003cagent_api_name>__\u003cagent_version>/``.\n\n All three segments are regex-validated before being joined.\n \"\"\"\n _validate_agent_triple(org_id_15, agent_api_name, agent_version)\n return DATA_ROOT / org_id_15 / f\"{agent_api_name}__{agent_version}\"\n\n\ndef architecture_tree_path(\n org_id_15: str, agent_api_name: str, agent_version: str\n) -> Path:\n \"\"\"Return the path to the rendered metadata tree JSON.\n\n Filename convention: ``\u003cagent_api_name>_\u003cagent_version>_metadata_tree.json``.\n \"\"\"\n base = agent_dir(org_id_15, agent_api_name, agent_version)\n return base / f\"{agent_api_name}_{agent_version}_metadata_tree.json\"\n\n\ndef architecture_emit_ctx_path(\n org_id_15: str, agent_api_name: str, agent_version: str\n) -> Path:\n \"\"\"Return the path to the per-run ``.emit_ctx.json``.\n\n The file is dotfile-named to signal per-run shell state.\n \"\"\"\n base = agent_dir(org_id_15, agent_api_name, agent_version)\n return base / \".emit_ctx.json\"\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4012,"content_sha256":"306212cda251ef65cd4938dac576e0d079188aba3d8bbb54986c909728e63c26"},{"filename":"scripts/_shared/runtime.py","content":"\"\"\"Runtime override hook for DATA_ROOT / CACHE_ROOT.\n\nDefault layout is ``~/.vibe/data/\u003cskill>`` and\n``~/.vibe/cache/\u003cskill>`` — runtime-agnostic branding that\nfollows the repo's \"tool-agnostic language\" guideline.\n\nOther runtimes (AFV OOTB, Codex, Cursor, OpenCode) can override via\n``--data-dir`` / ``--cache-dir`` CLI flags on entry scripts. Those flags\ncall ``set_*_override()`` BEFORE any pipeline module imports ``paths.py``,\nso the resolution helpers below pick up the override.\n\nThree-level rebind contract\n---------------------------\nPython's ``from X import Y`` snapshots ``Y`` into the importer's local\nnamespace. Mutating ``X.Y`` later does NOT update the importer's local\nbinding. Entry scripts that capture ``DATA_ROOT`` / ``CACHE_ROOT`` at\nmodule top must therefore rebind in THREE places after override:\n\n1. ``paths.DATA_ROOT`` — the source of truth\n2. ``config.DATA_ROOT`` — the re-export\n3. The entry script's own local ``DATA_ROOT`` (via ``global``)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Optional\n\n_DATA_OVERRIDE: Optional[Path] = None\n_CACHE_OVERRIDE: Optional[Path] = None\n\n\ndef set_data_root_override(p: Optional[Path]) -> None:\n \"\"\"Set the data-root override. Pass ``None`` to clear.\"\"\"\n global _DATA_OVERRIDE\n _DATA_OVERRIDE = p\n\n\ndef set_cache_root_override(p: Optional[Path]) -> None:\n \"\"\"Set the cache-root override. Pass ``None`` to clear.\"\"\"\n global _CACHE_OVERRIDE\n _CACHE_OVERRIDE = p\n\n\ndef resolve_data_root(skill_name: str) -> Path:\n \"\"\"Resolve the data root for ``skill_name``.\n\n Returns the override if set, else the runtime-agnostic default\n ``~/.vibe/data/\u003cskill_name>``.\n \"\"\"\n return _DATA_OVERRIDE or (Path.home() / \".vibe\" / \"data\" / skill_name)\n\n\ndef resolve_cache_root(skill_name: str) -> Path:\n \"\"\"Resolve the cache root for ``skill_name``.\n\n Returns the override if set, else the runtime-agnostic default\n ``~/.vibe/cache/\u003cskill_name>``.\n \"\"\"\n return _CACHE_OVERRIDE or (Path.home() / \".vibe\" / \"cache\" / skill_name)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2061,"content_sha256":"3bafb5a029e94b884189a498760075165941b82614df921f4848a9005edd8ce1"},{"filename":"scripts/_shared/sql.py","content":"\"\"\"SQL-escaping helpers for SOQL string literals.\n\"\"\"\nfrom __future__ import annotations\n\n\ndef _escape_sql_literal(s: str) -> str:\n \"\"\"Double single quotes per SOQL escaping rule. Handles O'Brien →\n O''Brien, `'; DROP --` → `''; DROP --` (still harmless because it's\n wrapped in surrounding single quotes).\"\"\"\n return s.replace(\"'\", \"''\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":355,"content_sha256":"2ce1437176534e8b0c77c7f0f7f4216191fdcba46ad251034569a37d1a085802"},{"filename":"scripts/cache_check.py","content":"#!/usr/bin/env python3\n\"\"\"Cache hit/miss check for investigating-agentforce-architecture.\n\nReplaces old agent Phase 0.5 (manifest read, schema-version check, TTL age\ncheck, path-existence check, env export on hit, RESULT-block assembly).\n\nFlow:\n 1. If FORCE_REFRESH=true → emit CACHE_HIT=false, exit 0.\n 2. Read $CACHE_DIR/manifest.json. Any I/O or parse failure → miss.\n 3. if schema_version != config.SCHEMA_VERSION → miss,\n emit CACHE_INVALIDATED_REASON=schema-version-mismatch, and\n `shutil.rmtree` the cache dir (gated to paths under CACHE_ROOT).\n 4. If data_path file missing → miss (atomic rename failed mid-write).\n 5. If age > ttl_days (default 7) → miss (no rmtree — just stale).\n 6. Hit path:\n - Copy {agent}_{ver}_metadata_tree.json from cache into DATA_DIR +\n WORK_DIR.\n - Copy `declared_action_tree.json` sidecar into WORK_DIR so emit ctx\n + downstream greps work.\n - Stdout: eval-able lines (CACHE_HIT=true, CACHED_AT_UTC, NODE_COUNT,\n DEPTH, AGENT_GENERATION, BOT_ID, VERSION_AUTO_PICKED, PARTIAL,\n UNRESOLVED_COUNT, OUTPUT_JSON_PATH, OUTPUT_SUMMARY_PATH).\n\n`{tree_base}.summary.md` was dropped from the output contract.\nCache-hit no longer copies or regenerates a summary file; OUTPUT_SUMMARY_PATH\nis emitted empty for RESULT-block shape stability.\n\nUsage:\n eval \"$(python3 cache_check.py)\"\n\nInputs (env):\n CACHE_DIR required — $CACHE_ROOT/$ORG_ID_15/$AGENT__$VER\n DATA_DIR required — $DATA_ROOT/$ORG_ID_15/$AGENT__$VER\n WORK_DIR required\n AGENT_API_NAME required\n AGENT_VERSION required\n FORCE_REFRESH 'true'|'false' (default false)\n\nOutputs:\n stdout: eval-able K=V lines\n files (hit only): DATA_DIR/{tree_base}.json and matching copy in WORK_DIR\n exit 0 always (miss is not an error)\n\"\"\"\nimport json\nimport os\nimport pathlib\nimport shlex\nimport shutil\nimport sys\nimport datetime as dt\n\nimport config # for SCHEMA_VERSION + CACHE_ROOT path-guard \n\n\n# refuse to operate if CACHE_ROOT itself is a symlink.\n# _safe_rmtree_under_cache_root calls resolve() on both target and root; a\n# symlinked CACHE_ROOT collapses the is_relative_to check because both sides\n# land on the symlink target, so rmtree would happily delete whatever lives\n# there — outside the sanctioned cache subtree. The guard was designed to\n# prevent rmtree escaping the cache; if the root itself can escape, the\n# guard is moot. Fail fast at import time so the failure surfaces at the\n# correct layer (configuration, not deletion).\n#\n# Path.is_symlink() returns False for non-existent paths, which is the\n# correct behaviour for pristine installs (CACHE_ROOT hasn't been created\n# yet; the script will mkdir it on first write and subsequent imports will\n# validate the real directory).\nif config.CACHE_ROOT.is_symlink():\n raise RuntimeError(\n f\"CACHE_ROOT is a symlink ({config.CACHE_ROOT} -> \"\n f\"{config.CACHE_ROOT.resolve()}). This is rejected for safety; the \"\n \"_safe_rmtree_under_cache_root guard cannot protect against a \"\n \"symlinked-root escape. Resolve the symlink or update \"\n \"config.CACHE_ROOT to a real directory.\"\n )\n\n\ndef miss(reason: str | None = None):\n \"\"\"Emit CACHE_HIT=false and exit 0.\n\n if `reason` is provided, emit a second K=V line\n `CACHE_INVALIDATED_REASON=\u003creason>` so downstream emit_result can\n surface the invalidation cause in the RESULT block.\n \"\"\"\n sys.stdout.write(\"CACHE_HIT=false\\n\")\n if reason:\n sys.stdout.write(f\"CACHE_INVALIDATED_REASON={shlex.quote(reason)}\\n\")\n sys.exit(0)\n\n\ndef _safe_rmtree_under_cache_root(target: pathlib.Path) -> bool:\n \"\"\"`shutil.rmtree(target)` ONLY if `target` resolves under CACHE_ROOT.\n\n Defence against a miscomputed cache_dir that could point outside the\n sanctioned cache root (symlink shenanigans, env-var tampering, or a\n future refactor that forgets to run through `build_agent_cache_dir`).\n We resolve both sides to absolute paths and confirm CACHE_ROOT is a\n prefix before handing to rmtree.\n\n Returns True on successful deletion, False if the path was refused or\n the rmtree itself failed. Never raises — callers on the miss() path\n can't act on a failure anyway.\n \"\"\"\n try:\n target_abs = target.resolve(strict=False)\n root_abs = config.CACHE_ROOT.resolve(strict=False)\n except (OSError, RuntimeError):\n return False\n # Path.is_relative_to exists on 3.9+. Explicit check is clearer than\n # a try/except on relative_to().\n try:\n if not target_abs.is_relative_to(root_abs):\n return False\n except AttributeError: # pragma: no cover — safety for \u003c3.9\n try:\n target_abs.relative_to(root_abs)\n except ValueError:\n return False\n if not target_abs.exists():\n return True # nothing to do, treat as successful no-op\n try:\n shutil.rmtree(target_abs)\n except OSError:\n return False\n return True\n\n\ndef main() -> int:\n try:\n cache_dir = pathlib.Path(os.environ[\"CACHE_DIR\"])\n data_dir = pathlib.Path(os.environ[\"DATA_DIR\"])\n work_dir = pathlib.Path(os.environ[\"WORK_DIR\"])\n agent_api_name = os.environ[\"AGENT_API_NAME\"]\n agent_version = os.environ[\"AGENT_VERSION\"]\n except KeyError as e:\n sys.stderr.write(f\"cache_check.py: missing env {e}\\n\")\n miss()\n\n if (os.environ.get(\"FORCE_REFRESH\", \"\").strip().lower() == \"true\"):\n miss()\n\n manifest_path = cache_dir / \"manifest.json\"\n if not manifest_path.is_file():\n miss()\n\n try:\n manifest = json.loads(manifest_path.read_text())\n except (OSError, json.JSONDecodeError):\n miss()\n\n # Schema version gate.\n # strict match against config.SCHEMA_VERSION. Any mismatch (too\n # old OR unexpected future/unknown value) invalidates the cache, and\n # we delete the cache directory so the next run starts clean — a\n # stale tree under a legacy schema is worse than no cache, because\n # downstream code may parse shapes it no longer understands. The\n # legacy `\u003c 2.4` check is subsumed (anything that doesn't equal the\n # current SCHEMA_VERSION triggers this branch).\n schema = str(manifest.get(\"schema_version\") or \"0\")\n if schema != config.SCHEMA_VERSION:\n _safe_rmtree_under_cache_root(cache_dir)\n miss(\"schema-version-mismatch\")\n\n # data_path existence\n data_path_s = manifest.get(\"data_path\") or \"\"\n data_path = pathlib.Path(data_path_s) if data_path_s else None\n if not data_path or not data_path.is_file():\n miss()\n\n # TTL age check\n try:\n built = dt.datetime.fromisoformat(\n (manifest.get(\"built_at_utc\") or \"\").replace(\"Z\", \"+00:00\")\n )\n except ValueError:\n miss()\n age_days = (dt.datetime.now(dt.timezone.utc) - built).days\n ttl = int(manifest.get(\"ttl_days\") or 7)\n if age_days > ttl:\n miss()\n\n # --- Hit path ---\n tree_base = f\"{agent_api_name}_{agent_version}_metadata_tree\"\n data_dir.mkdir(parents=True, exist_ok=True)\n work_dir.mkdir(parents=True, exist_ok=True)\n\n dst_json = data_dir / f\"{tree_base}.json\"\n\n # The manifest's data_path points at the authoritative tree copy. Re-copy\n # to DATA_DIR + WORK_DIR so the filesystem layout is stable for callers.\n try:\n shutil.copy(data_path, dst_json)\n except OSError:\n miss()\n\n # Stage a declared_action_tree.json sidecar in WORK_DIR for any downstream\n # script that expects the generic name.\n try:\n shutil.copy(dst_json, work_dir / \"declared_action_tree.json\")\n except OSError:\n pass\n\n # no summary.md handling — dropped from the output contract.\n agent_meta = manifest.get(\"agent\") or {}\n kind_counts = manifest.get(\"kind_counts\") or {}\n\n exports = [\n (\"CACHE_HIT\", \"true\"),\n (\"CACHED_AT_UTC\", manifest.get(\"built_at_utc\", \"\")),\n (\"NODE_COUNT\", str(manifest.get(\"node_count\", 0))),\n (\"DEPTH\", str(manifest.get(\"depth\", 0))),\n (\"AGENT_GENERATION\", agent_meta.get(\"generation\") or \"unknown\"),\n (\"BOT_ID\", agent_meta.get(\"bot_id\") or \"\"),\n (\"BOT_MASTER_LABEL\", agent_meta.get(\"master_label\") or \"\"),\n (\"VERSION_AUTO_PICKED\", \"true\" if agent_meta.get(\"_version_auto_picked\") else \"false\"),\n (\"PARTIAL\", \"true\" if manifest.get(\"partial\") else \"false\"),\n (\"UNRESOLVED_COUNT\", str(manifest.get(\"unresolved_count\", 0))),\n (\"OUTPUT_JSON_PATH\", str(dst_json)),\n # summary.md dropped from the output contract; field kept\n # empty for RESULT-block shape stability.\n (\"OUTPUT_SUMMARY_PATH\", \"\"),\n (\"PLANNER_NAME\", agent_meta.get(\"planner_name\") or \"\"),\n ]\n # Pass kind counts too, in case finalize.py runs later without re-parsing\n # (it won't on cache hit, but the values keep the RESULT block complete).\n for k, v in kind_counts.items():\n exports.append((f\"KC_{k}\", str(v)))\n\n sys.stdout.write(\"\\n\".join(f\"{k}={shlex.quote(v)}\" for k, v in exports) + \"\\n\")\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9148,"content_sha256":"2f96b3848ba04c3f5ea79750ea6ffc1ec42267a08ff7b3133694050196cc6b0d"},{"filename":"scripts/config.py","content":"\"\"\"Shared paths + constants for investigating-agentforce-architecture.\n\nResolves SKILL_ROOT relative to this file's location — works on every\nruntime without env vars. SKILL.md bash blocks still read the\n``PLUGIN_ROOT`` env var (with a default fallback) because bash has no\n``__file__`` equivalent.\n\nPath layout:\n\n ~/.vibe/data/investigating-agentforce-architecture/\n └── \u003corg_id_15>/\n └── \u003cagent_api_name>__\u003cagent_version>/ ← architecture artifacts\n\nEvery path component embedded in a cache / data directory must be\nregex-validated before being joined to a Path. Four components are in\nscope: `api_version`, `org_id15`, `agent_api_name`, `agent_version`.\n(`planner_name` is validated separately at the SOQL substitution\nboundary by `soql_loader.load_soql` — it never appears in a filesystem\npath.) The helpers below are the ONLY sanctioned way to build those\npaths — direct Path composition from unvalidated strings is a\npath-traversal vulnerability.\n\"\"\"\nfrom __future__ import annotations\n\nimport sys\nfrom pathlib import Path\n\n# SKILL_ROOT is the directory holding SKILL.md, derived from this file's location:\n# \u003cSKILL_ROOT>/scripts/config.py → Path(__file__).resolve().parent.parent\n# Python doesn't read PLUGIN_ROOT — it's only for SKILL.md bash blocks\n# (PR3 will replace those with a proper entry script).\nSKILL_ROOT = Path(__file__).resolve().parent.parent\nSOQL_DIR = SKILL_ROOT / \"assets\" / \"soql\"\nCLI_DIR = SKILL_ROOT / \"assets\" / \"cli\"\nMERMAID_DIR = SKILL_ROOT / \"assets\" / \"mermaid\"\nREFERENCES_DIR = SKILL_ROOT / \"references\"\n\n\n# -----------------------------------------------------------------------------\n# Shared path helpers — sourced from scripts/_shared/.\n# -----------------------------------------------------------------------------\n\n_SCRIPTS_DIR = Path(__file__).resolve().parent\nif str(_SCRIPTS_DIR) not in sys.path:\n sys.path.insert(0, str(_SCRIPTS_DIR))\n\nfrom _shared import paths as _paths # type: ignore # noqa: E402\nfrom _shared import fs_guard # type: ignore # noqa: E402,F401 — re-exported for sibling scripts + tests\n\nDATA_ROOT = _paths.DATA_ROOT\nCACHE_ROOT = _paths.CACHE_ROOT\nPROBE_CACHE_ROOT = CACHE_ROOT / \"_channel_probe\"\n\n# Cache TTL — 7 days. Probe TTL same.\nCACHE_TTL_DAYS = 7\nPROBE_TTL_DAYS = 7\n\n# BFS defensive termination guard.\n#\n# Historically this was `5` and acted as a functional constraint on\n# chain depth. That was wrong: shared utility flows like `handleFlowFault`\n# appear on every real flow's fault path, so any moderately nested tree\n# (`[FLOW] A → [FLOW] B → [FLOW] handleFlowFault`) tripped the cap and\n# surfaced the utility in `_pending_fetches` even though it is trivially\n# expandable. Cycle detection now runs via a per-branch ancestor path set\n# (`visited_in_path` in `inflate_flow_leaf`), which is the textbook-correct\n# primitive for this problem: the same flow visited on two sibling branches\n# is not a cycle, but the same flow visited along its own ancestor chain is.\n#\n# This constant is retained purely as a defensive last-resort termination\n# guard for pathological graphs that somehow evade per-branch cycle detection\n# (e.g. a bug regression in `_cycle_key`). In practice the per-branch set\n# terminates every real bot tree long before this depth is reached.\nMAX_BFS_DEPTH = 20\n\n# Schema version — 3.1 (2026-05-05) canonicalizes `invocation_type` on\n# STANDARD_ACTION nodes, formerly split across `raw_invocation_type` and\n# `raw_action_type`. Must match `parse_wave.init_tree`'s `_schema_version`\n# literal. Any bump forces a cache rebuild via `cache_check.py`'s gate.\nSCHEMA_VERSION = \"3.1\"\n\n# Default parallelism for ThreadPoolExecutor (Flow metadata fan-out).\nDEFAULT_PARALLELISM = 5\n\n\n# -----------------------------------------------------------------------------\n# validated path builders\n# -----------------------------------------------------------------------------\n# fs_guard is re-exported above from `scripts/_shared/`.\n# Sibling scripts (soql_loader, render_architecture, fetch_soql)\n# and the test suite import it as ``from config import fs_guard`` so the\n# resolution dance lives in one place.\n\n\ndef build_agent_data_dir(org_id_15: str, agent_api_name: str, agent_version: str) -> Path:\n \"\"\"Return DATA_ROOT/\u003corg_id_15>/\u003cagent_api_name>__\u003cagent_version>/.\n\n Thin wrapper over ``_shared.paths.agent_dir`` — kept so existing\n callers don't need to change import sites. The shared helper raises\n ``paths.PathValidationError`` (a ValueError subclass); we re-raise\n the underlying ``fs_guard.ValidationError`` so existing tests that\n catch that specific type keep passing.\n \"\"\"\n fs_guard.validate_org_id_15(org_id_15, label=\"org_id_15\")\n fs_guard.validate_api_name(agent_api_name, label=\"agent_api_name\")\n fs_guard.validate_api_name(agent_version, label=\"agent_version\")\n return _paths.DATA_ROOT / org_id_15 / f\"{agent_api_name}__{agent_version}\"\n\n\ndef build_agent_cache_dir(org_id_15: str, agent_api_name: str, agent_version: str) -> Path:\n \"\"\"Return CACHE_ROOT/\u003corg_id_15>/\u003cagent_api_name>__\u003cagent_version>/.\n\n Mirrors ``build_agent_data_dir`` under the cache root. Validation gate\n matches — every segment regex-checked before the join.\n \"\"\"\n fs_guard.validate_org_id_15(org_id_15, label=\"org_id_15\")\n fs_guard.validate_api_name(agent_api_name, label=\"agent_api_name\")\n fs_guard.validate_api_name(agent_version, label=\"agent_version\")\n return CACHE_ROOT / org_id_15 / f\"{agent_api_name}__{agent_version}\"\n\n\ndef build_probe_cache_dir(org_id_15: str, api_version: str) -> Path:\n \"\"\"Return PROBE_CACHE_ROOT/\u003corg_id_15>/\u003capi_version>/.\n\n org_id_15 via validate_api_name (regex tolerates 15-char alnum),\n api_version via validate_api_version (enforces `vNN.N` shape — rejects\n `..`, `/`, and any non-version string).\n \"\"\"\n fs_guard.validate_org_id_15(org_id_15, label=\"org_id_15\")\n fs_guard.validate_api_version(api_version, label=\"api_version\")\n return PROBE_CACHE_ROOT / org_id_15 / api_version\n","content_type":"text/x-python; charset=utf-8","language":"python","size":6032,"content_sha256":"bdbc3de349b03174cc2b004dcb4e444fe01b4437692fd6770ce74ced8f7fcd7c"},{"filename":"scripts/emit_env.py","content":"#!/usr/bin/env python3\n\"\"\"Generic JSON-in → shell-export-out extractor.\n\nReads a JSON document (from stdin or a file), traverses it by dot-path specs,\nand emits `export KEY=shlex.quote(VALUE)` lines on stdout. Callers consume via\n`eval \"$(python3 emit_env.py ...)\"`.\n\nReplaces four single-purpose parsers that previously existed as inline Python\nheredocs in the agent:\n * sf org display result parser\n * Q1 row-count + SESSION_ACTIVE derivation\n * _cache_meta.json reader (CACHED_AT_UTC, SESSION_ACTIVE_AT_CACHE)\n * individual field extractors\n\nField-spec DSL:\n EXPORT_KEY:path — plain dot-path traversal\n EXPORT_KEY:path[:N] — string slice after traversal (first N chars)\n EXPORT_KEY:path.length — length of array / string at path\n EXPORT_KEY:path.empty — \"true\" if missing or empty string, else \"false\"\n\nThe DSL is deliberately small: three operators, no dot-inside-key escaping.\nAny path component is a dictionary key OR an integer array index\n(`data[0].ssot__Foo` works). If the path doesn't resolve, the emitted value\nis the empty string — caller decides whether that's OK.\n\nUsage:\n # Parse `sf org display --json` output via stdin (org metadata only —\n # access tokens come only from `sf org auth show-access-token --json --no-prompt`)\n eval \"$(printf '%s' \"$_SF_JSON\" | python3 emit_env.py stdin \\\\\n 'INSTANCE_URL:result.instanceUrl' \\\\\n 'ORG_ID_18:result.id' \\\\\n 'ORG_ID_15:result.id[:15]')\"\n\n # Q1 post-wave: derive SESSION_ACTIVE from row 0's end timestamp\n eval \"$(python3 emit_env.py \"$WORK_DIR/_q1_body.json\" \\\\\n 'SESSION_ACTIVE:data[0].ssot__EndTimestamp__c.empty')\"\n\n # Cache-hit meta read\n eval \"$(python3 emit_env.py \"$WORK_DIR/$SID.dc.json\" \\\\\n 'CACHED_AT_UTC:_cache_meta.cached_at_utc' \\\\\n 'SESSION_ACTIVE:_cache_meta.session_active_at_cache')\"\n\nInputs:\n argv[1] 'stdin' or an absolute file path\n argv[2+] field specs (at least one)\n\nOutputs:\n stdout zero or more `export K=V` lines\n exit 0 always (missing keys just produce empty values)\n exit 1 bad argv, unparseable JSON, or I/O error\n\"\"\"\nimport json\nimport pathlib\nimport re\nimport shlex\nimport sys\n\nSLICE_RE = re.compile(r\"^(.*?)\\[:(\\d+)\\]$\")\n\n\ndef traverse(data, path: str):\n # Path components are dot-separated. Numeric components index into arrays;\n # everything else is a dict key. Missing/invalid → None (emitted as empty).\n node = data\n for part in path.split(\".\"):\n if node is None:\n return None\n if part.isdigit():\n try:\n node = node[int(part)]\n except (IndexError, KeyError, TypeError):\n return None\n elif isinstance(node, dict):\n node = node.get(part)\n else:\n return None\n return node\n\n\ndef apply_spec(data, spec: str) -> tuple[str, str]:\n # spec: \"EXPORT_KEY:path\u003coperator>\"\n if \":\" not in spec:\n raise ValueError(f\"spec missing colon: {spec}\")\n key, rhs = spec.split(\":\", 1)\n if not key:\n raise ValueError(f\"spec has empty key: {spec}\")\n\n # Resolve the operator suffix on rhs. Check slice LAST because it's the\n # only one that doesn't alter the traversed value's type.\n if rhs.endswith(\".length\"):\n value = traverse(data, rhs[:-7])\n if value is None:\n return key, \"0\"\n if isinstance(value, (list, str)):\n return key, str(len(value))\n return key, \"0\"\n\n if rhs.endswith(\".empty\"):\n value = traverse(data, rhs[:-6])\n is_empty = value is None or value == \"\"\n return key, \"true\" if is_empty else \"false\"\n\n m = SLICE_RE.match(rhs)\n if m:\n path, n = m.group(1), int(m.group(2))\n value = traverse(data, path)\n if value is None:\n return key, \"\"\n return key, str(value)[:n]\n\n # Plain traversal.\n value = traverse(data, rhs)\n if value is None:\n return key, \"\"\n if isinstance(value, bool):\n # Python `True` / `False` → `true` / `false` so bash string compares\n # work without an extra lowercase step.\n return key, \"true\" if value else \"false\"\n return key, str(value)\n\n\ndef main() -> int:\n if len(sys.argv) \u003c 3:\n sys.stderr.write(\"emit_env.py: need source ('stdin' or path) and at least one field spec\\n\")\n return 1\n\n source = sys.argv[1]\n specs = sys.argv[2:]\n\n try:\n if source == \"stdin\":\n raw = sys.stdin.read()\n else:\n raw = pathlib.Path(source).read_text()\n except OSError as e:\n sys.stderr.write(f\"emit_env.py: cannot read {source}: {e}\\n\")\n return 1\n\n try:\n data = json.loads(raw)\n except json.JSONDecodeError as e:\n sys.stderr.write(f\"emit_env.py: JSON parse error: {e}\\n\")\n return 1\n\n for spec in specs:\n try:\n key, value = apply_spec(data, spec)\n except ValueError as e:\n sys.stderr.write(f\"emit_env.py: {e}\\n\")\n return 1\n sys.stdout.write(f\"export {key}={shlex.quote(value)}\\n\")\n\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5197,"content_sha256":"801e715bd7837bda53da33f45bd1fc2b2d0b77d192fc43c31c42e02f99c37792"},{"filename":"scripts/emit_result.py","content":"#!/usr/bin/env python3\n\"\"\"Final-phase RESULT emitter for investigating-agentforce-architecture.\n\nReads `$WORK_DIR/.emit_ctx.json` (populated by write_emit_ctx.py) and prints\nthe final output:\n\n 1. One-line prose status (for humans; first signal in the log).\n 2. A blank line.\n 3. The `=== RESULT ===` KV block.\n\nBEFORE writing to stdout, the complete RESULT block is teed to\n`$DATA_DIR/last_result_block.txt`. Writing the tee first means a consumer\nreading the file can never see a truncated block — disk write is atomic\n(`tmp + os.replace`), and the stdout print is a best-effort afterthought.\n\nThe callers that abort early (AGENT_NOT_FOUND, AGENT_VERSION_NOT_FOUND,\nINVALID_INPUT) emit their own RESULT blocks directly — resolve_bot.py and\nfs_guard.py have the same disk-tee discipline. All other terminal paths\n(OK, PARTIAL_OK, AUTH_REQUIRED, RETRIEVE_FAILED, WRITE_FAILED) flow through\nthis script.\n\nUsage:\n python3 emit_result.py\n\nInputs:\n env $WORK_DIR reads $WORK_DIR/.emit_ctx.json\n\nOutputs:\n $DATA_DIR/last_result_block.txt full RESULT block (atomic write)\n stdout prose line + RESULT block\n exit 0 always (this script emits; the bash\n harness decides the agent's exit code)\n exit 1 missing env, missing ctx file, bad JSON\n\"\"\"\nimport json\nimport os\nimport pathlib\nimport sys\nimport time\n\nSTATUS_ENUM = {\n \"OK\",\n \"PARTIAL_OK\",\n \"INVALID_INPUT\",\n \"AUTH_REQUIRED\",\n \"AGENT_NOT_FOUND\",\n \"AGENT_VERSION_NOT_FOUND\",\n \"RETRIEVE_FAILED\",\n \"WRITE_FAILED\",\n}\n\nPROSE = {\n \"OK\": \"Declared action tree discovered and cached.\",\n \"PARTIAL_OK\": \"Declared action tree partially discovered — see UNRESOLVED_COUNT.\",\n \"INVALID_INPUT\": \"Input validation failed.\",\n \"AUTH_REQUIRED\": \"sf CLI not authenticated for this org.\",\n \"AGENT_NOT_FOUND\": \"BotDefinition.DeveloperName not found in the org.\",\n \"AGENT_VERSION_NOT_FOUND\": \"No matching BotVersion under the bot.\",\n \"RETRIEVE_FAILED\": \"Metadata API retrieve failed (auth/network/permissions).\",\n \"WRITE_FAILED\": \"A filesystem write failed during finalize.\",\n}\n\n\ndef scrub(s) -> str:\n if not isinstance(s, str):\n s = \"\" if s is None else str(s)\n bad = set(\"`$\\\"\\\\\\r\\t\\0\\n\")\n return \"\".join(c for c in s if c not in bad)\n\n\ndef bool_str(v) -> str:\n if isinstance(v, bool):\n return \"true\" if v else \"false\"\n s = str(v).lower()\n return \"true\" if s in (\"true\", \"1\", \"yes\", \"on\") else \"false\"\n\n\ndef build_block(ctx: dict, wall_time_seconds: float) -> str:\n status = (ctx.get(\"status\") or \"\").strip().upper()\n if status not in STATUS_ENUM:\n status = \"WRITE_FAILED\"\n\n lines = [\"=== RESULT ===\", f\"STATUS={status}\"]\n\n error_detail = scrub(ctx.get(\"error_detail\", \"\"))\n if error_detail:\n lines.append(f\"ERROR_DETAIL={error_detail}\")\n\n # Always-emitted identity fields (may be empty on early-abort paths)\n lines.extend([\n f\"AGENT_API_NAME={scrub(ctx.get('agent_api_name', ''))}\",\n f\"AGENT_VERSION={scrub(ctx.get('agent_version', ''))}\",\n f\"VERSION_AUTO_PICKED={bool_str(ctx.get('version_auto_picked', False))}\",\n f\"AGENT_GENERATION={scrub(ctx.get('agent_generation', '') or 'unknown')}\",\n f\"BOT_ID={scrub(ctx.get('bot_id', ''))}\",\n f\"ORG_ID_15={scrub(ctx.get('org_id_15', ''))}\",\n f\"ORG_ID_18={scrub(ctx.get('org_id_18', ''))}\",\n ])\n\n # Output + cache paths (populated on success; may be empty on fail paths)\n # OUTPUT_ARCHITECTURE_PATH is always emitted — empty\n # string on fail paths, cache-hit paths where the renderer wasn't\n # invoked, or when render failed. Downstream consumers can distinguish\n # \"no architecture produced\" from \"architecture produced but stale\"\n # by cross-referencing RENDER_FAILED + CACHE_HIT.\n lines.extend([\n f\"OUTPUT_JSON_PATH={scrub(ctx.get('output_json_path', ''))}\",\n f\"OUTPUT_SUMMARY_PATH={scrub(ctx.get('output_summary_path', ''))}\",\n f\"OUTPUT_ARCHITECTURE_PATH={scrub(ctx.get('architecture_path', '') or '')}\",\n f\"CACHE_PATH={scrub(ctx.get('cache_path', ''))}\",\n f\"CACHE_HIT={bool_str(ctx.get('cache_hit', False))}\",\n f\"CACHED_AT_UTC={scrub(ctx.get('cached_at_utc', ''))}\",\n ])\n\n # Tree stats\n # emit `_partial_reason` + a rollup of `_pending_fetches` counts\n # alongside the existing PARTIAL / UNRESOLVED_COUNT fields. The write\n # path populates ctx[\"partial\"], ctx[\"partial_reason\"], and\n # ctx[\"pending_fetches_count\"] from the finalized tree JSON —\n # emit_result is intentionally presentation-only and does not re-read\n # the tree itself (ctx is the single source of truth per the emit\n # contract).\n partial_flag = bool(ctx.get(\"partial\", False))\n partial_reason = scrub(ctx.get(\"partial_reason\", \"\") or \"\")\n pending_fetches_count = int(ctx.get(\"pending_fetches_count\", 0) or 0)\n\n # If the upstream pipeline forgot to populate partial_reason but\n # flagged partial=True, emit an empty value rather than nothing —\n # downstream consumers can distinguish \"no reason supplied\" from\n # \"key missing\" this way.\n lines.extend([\n f\"NODE_COUNT={int(ctx.get('node_count', 0) or 0)}\",\n f\"DEPTH={int(ctx.get('depth', 0) or 0)}\",\n f\"PARTIAL={bool_str(partial_flag)}\",\n f\"PARTIAL_REASON={partial_reason}\",\n f\"PENDING_FETCHES_COUNT={pending_fetches_count}\",\n f\"UNRESOLVED_COUNT={int(ctx.get('unresolved_count', 0) or 0)}\",\n ])\n\n # when the tree is partial but status was left blank / \"OK\",\n # auto-promote the status to PARTIAL_OK so the RESULT block reflects\n # the tree's actual state. ERROR paths (AUTH_REQUIRED etc.) keep\n # their original status — never clobber a failure status.\n if partial_flag and status == \"OK\":\n # positional safety — `lines[1]` is by construction\n # \"STATUS=...\". If a future refactor reorders the header we want\n # a loud failure here rather than silently rewriting the wrong\n # line. Assertion cost is negligible; the payoff is catching a\n # whole class of refactor bugs at test time.\n assert lines[1].startswith(\"STATUS=\"), (\n f\"emit block reordered — lines[1]={lines[1]!r}\"\n )\n lines[1] = \"STATUS=PARTIAL_OK\"\n status = \"PARTIAL_OK\"\n\n # emit RENDER_FAILED unconditionally, and auto-promote to\n # PARTIAL_OK when the tree succeeded but the architecture.md render\n # raised. The signal surface is:\n # * RENDER_FAILED=true|false — always emitted.\n # * RENDER_ERROR_DETAIL=\u003c...> — emitted ONLY on true, redacted\n # at write_emit_ctx-time.\n # * STATUS auto-promoted OK -> PARTIAL_OK; _partial_reason pinned\n # to \"render-failed\" when the tree wasn't already partial.\n # ERROR paths (AUTH_REQUIRED etc.) retain their original status:\n # render never runs in those cases, and render_failed defaults to\n # False so the auto-promote below is a no-op.\n render_failed = bool(ctx.get(\"render_failed\", False))\n lines.append(f\"RENDER_FAILED={bool_str(render_failed)}\")\n if render_failed:\n detail = scrub(ctx.get(\"render_error_detail\", \"\") or \"\")\n lines.append(f\"RENDER_ERROR_DETAIL={detail}\")\n if status == \"OK\":\n # Same positional-safety discipline as the partial auto-promote.\n assert lines[1].startswith(\"STATUS=\"), (\n f\"emit block reordered — lines[1]={lines[1]!r}\"\n )\n lines[1] = \"STATUS=PARTIAL_OK\"\n status = \"PARTIAL_OK\"\n # Pin a partial_reason so triagers can tell this apart from a\n # tree-level partial. We walk backwards to find the existing\n # PARTIAL_REASON line (always present — emitted above) and\n # rewrite in place. A fresh line would create two competing\n # reason values in the block.\n for idx in range(len(lines) - 1, -1, -1):\n if lines[idx].startswith(\"PARTIAL_REASON=\"):\n # Only overwrite when the tree didn't already claim\n # a reason — the tree's reason is more informative.\n if lines[idx] == \"PARTIAL_REASON=\":\n lines[idx] = \"PARTIAL_REASON=render-failed\"\n break\n\n # Error-path-specific optional keys\n if status == \"AGENT_NOT_FOUND\":\n bots = scrub(ctx.get(\"available_bots\", \"\"))\n if bots:\n lines.append(f\"AVAILABLE_BOTS={bots}\")\n if status == \"AGENT_VERSION_NOT_FOUND\":\n vers = scrub(ctx.get(\"available_versions\", \"\"))\n if vers:\n lines.append(f\"AVAILABLE_VERSIONS={vers}\")\n\n lines.append(f\"WALL_TIME_SECONDS={wall_time_seconds:.2f}\")\n return \"\\n\".join(lines) + \"\\n\"\n\n\ndef main() -> int:\n work_dir_s = os.environ.get(\"WORK_DIR\", \"\")\n if not work_dir_s:\n sys.stderr.write(\"emit_result.py: $WORK_DIR not set\\n\")\n return 1\n\n ctx_path = pathlib.Path(work_dir_s) / \".emit_ctx.json\"\n try:\n ctx = json.loads(ctx_path.read_text())\n except FileNotFoundError:\n sys.stderr.write(f\"emit_result.py: missing {ctx_path}\\n\")\n return 1\n except (OSError, json.JSONDecodeError) as e:\n sys.stderr.write(f\"emit_result.py: cannot read {ctx_path}: {e}\\n\")\n return 1\n\n start_epoch = float(ctx.get(\"start_epoch\") or time.time())\n wall = max(0.0, time.time() - start_epoch)\n\n data_dir_s = scrub(ctx.get(\"data_dir\", \"\"))\n if not data_dir_s:\n # Fallback default — runtime-agnostic. Mirrors runtime.resolve_data_root()\n # in scripts/_shared/runtime.py (the pipeline's canonical helper). This\n # tool runs standalone (no sys.path to scripts/), so we duplicate the\n # default rather than import. If main.py ran with --data-dir, the\n # ctx.data_dir field already carries the override value, so this\n # branch is only reached when the pipeline aborted before writing it.\n data_dir_s = str(\n pathlib.Path.home()\n / \".vibe\"\n / \"data\"\n / \"investigating-agentforce-architecture\"\n / \"_agents\"\n )\n data_dir = pathlib.Path(data_dir_s)\n tee_path = data_dir / \"last_result_block.txt\"\n\n body = build_block(ctx, wall)\n body += f\"RESULT_BLOCK_PATH={tee_path}\\n\"\n\n try:\n data_dir.mkdir(parents=True, exist_ok=True)\n tmp = tee_path.with_suffix(tee_path.suffix + \".tmp\")\n tmp.write_text(body)\n os.replace(tmp, tee_path)\n except OSError as e:\n sys.stderr.write(f\"emit_result.py: tee failed ({e}); continuing with stdout\\n\")\n\n status = (ctx.get(\"status\") or \"\").strip().upper()\n prose = PROSE.get(status, f\"Unknown status {status}.\")\n sys.stdout.write(prose + \"\\n\\n\")\n sys.stdout.write(body)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10939,"content_sha256":"758e355d28470e0fd2d4dd0dfd0c3d49c8ef90626ede4ba151d4b69ade804703"},{"filename":"scripts/fetch_soql.py","content":"\"\"\"SOQL-based body fetchers for Wave A (GenAi normalized DAG) + Wave B\n(Flow / Apex body bodies).\n\nPhase 2 Batch 1: this module is the single surface `main.py`'s orchestrator\ncomposes against. Every function in this module is a thin wrapper over\n`soql_loader` + `rest_client.{tooling_query, data_query}`. Three invariants:\n\n 1. Credentials always flow through a `creds_provider` closure\n (`Callable[[], tuple[str, str]]`) so `retry_on_401`'s refresh path\n can deliver fresh creds into the retry. See rest_client \n 2. Empty list inputs short-circuit to `[]` WITHOUT firing a SOQL call.\n `load_soql_in` refuses empty lists (SF syntax error), so callers\n upstream shouldn't have to guard every batch site — guard once here.\n 3. `api_version` (.4-R4, 2026-05-02) is REQUIRED on every fetcher.\n The prior `rest_client.tooling_query` pinned `v60.0` and that\n floor was the source of on orgs running v66 — which\n genuinely expose fields v60 does not (`BotDefinition.Description`\n is the observed case). Callers read the version once via\n `main._derive_org_ids` (from `sf org display --json`) and thread\n it through here. Same defensive pattern as for\n `on_401_refresh`: no default, so a missed call-site is a\n TypeError at call time, not a silent regression.\n\nAll failure paths use `rest_client.redact_error` implicitly: the helpers\nraise `RestClientError` with pre-redacted messages. Callers that log should\ncall `redact_error(exc)` themselves rather than `str(exc)` on any wrapped\nurllib/subprocess exception — redaction at the reporting site is\ndefence-in-depth.\n\n`on_401_refresh` is a REQUIRED keyword-only arg\non every fetcher in this module. The previous default (`on_401_refresh\nor creds_provider`) silently collapsed to \"re-read the same stale token\"\nwhen a caller passed `None` — the retry would hit the identical stale\ntoken on the second attempt and 401 again, bypassing entirely.\nMaking the parameter required surfaces that misuse as a TypeError at\nimport/call time instead of as a silent auth failure under load.\n\"\"\"\nfrom __future__ import annotations\n\nimport logging\nimport urllib.error\nfrom typing import Callable, Tuple\n\nfrom rest_client import data_query, tooling_query\nfrom soql_loader import load_soql, load_soql_in\n\nlogger = logging.getLogger(__name__)\n\n\nCredsProvider = Callable[[], Tuple[str, str]]\n\n\n# ---------------------------------------------------------------------------\n# Small record-extraction helper. SF REST query responses wrap the row list\n# under `.records`; tooling + data surfaces agree on this shape. One helper\n# keeps the Wave A/B fetchers trivial — they call `_records(tooling_query(...))`\n# and return a list[dict].\n# ---------------------------------------------------------------------------\n\n\ndef _records(response: dict) -> list[dict]:\n \"\"\"Extract records list from a SOQL query response.\n\n SF REST returns `{\"totalSize\": N, \"done\": true, \"records\": [...]}` on\n both Data and Tooling APIs. A missing `records` key (malformed response,\n empty result) degrades to `[]` — callers that need a single record\n slice the list themselves; callers that batch don't have to branch on\n \"records missing\".\n \"\"\"\n if not isinstance(response, dict):\n return []\n recs = response.get(\"records\")\n if not isinstance(recs, list):\n return []\n return recs\n\n\n# ===========================================================================\n# Wave A — GenAi normalized fetchers (Tooling API only)\n# ===========================================================================\n# DAG, per plan section B.5:\n#\n# A1 planner_definition_by_agent_chain scalar: AGENT_NAME [+ VERSION]\n# A2 plugins_by_planner scalar: PLANNER_ID\n# A3 planner_bundle_functions scalar: PLANNER_ID\n# A4 functions_by_plugins list: PLUGIN_IDS\n# A5 plugin_instructions_by_plugin_ids list: PLUGIN_IDS\n# A6 plugin_functions_by_plugin_ids list: PLUGIN_IDS\n# A7 planner_attrs_by_parent_ids list: PARENT_IDS\n#\n# Ordering: A1 first (provides planner_id). Then A2 + A3 can parallelize.\n# Once A2 yields plugin_ids, A4 / A5 / A6 parallelize. A7 last once A4\n# yields function_ids.\n# ---------------------------------------------------------------------------\n\n\ndef fetch_planner_definition(\n agent_api_name: str,\n version: str | None,\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> dict | None:\n \"\"\"Wave A1: resolve the live GenAiPlannerDefinition for (agent, version).\n\n Agentforce generates an accretive chain of planner DeveloperNames as\n agents version up — v1 = `\u003cAgent>`, v2 = `\u003cAgent>_v2`, v3 =\n `\u003cAgent>_v2_v3`, ... The chain shape is unpredictable from\n (agent, version) alone, but an invariant holds: for vN the\n DeveloperName always starts with `\u003cagent_api_name>` and ends with\n `_v\u003cN>` for N>=2; for v1 it is exactly `\u003cagent_api_name>`. A single\n SOQL LIKE lookup pinned to that shape lands on the correct row\n regardless of chain depth.\n\n Branching:\n * `version` is None / empty / \"v1\" → exact match. SOQL built\n inline as `LIKE '\u003cagent_api_name>'` (no wildcards — degenerate\n LIKE is equivalent to `=`). The v1 branch bypasses the chain\n template because the template's `%\\\\_{{VERSION}}` tail has no\n sensible empty-version rendering.\n * v2+ → LIKE `\u003cagent_api_name>%\\\\_\u003cversion>`. The backslash escapes\n the single-char wildcard `_` so `_v2` matches literally, not as\n \"any character then v2\".\n\n Escaping note (2026-05-05): the SOQL template on disk carries `\\\\_`\n (one backslash, one underscore) because the REST Query endpoint\n consumes the body verbatim — no shell, no extra quoting layer. If a\n future caller ever shells this through `bash -c`, the backslash would\n need doubling; we don't currently have that call path.\n\n Disambiguation: if the LIKE returns multiple rows (e.g. a longer\n chain name ALSO matches the pattern because it happens to end in\n `_v2` too), pick the shortest DeveloperName — the canonical chain\n ends at exactly `\u003cagent_api_name>...\u003c_v{version}>` with no suffix,\n so shortest always wins.\n\n Returns the planner dict or `None` if no row matches.\n\n `load_soql` revalidates each substituted value against\n `[A-Za-z0-9_]+` . `agent_api_name` and `version` satisfy that\n regex; the `%\\\\_` literals live in the SOQL template, not in a\n substituted variable, so they never traverse the validator.\n \"\"\"\n v = (version or \"\").strip()\n if v in (\"\", \"v1\"):\n # v1 branch: DeveloperName is exactly `\u003cagent_api_name>`. A LIKE\n # with no wildcards is equivalent to `=`. We can't re-use the\n # chain template with an empty VERSION (load_soql rejects empty\n # strings AND the template's trailing `%\\_{{VERSION}}` wouldn't\n # collapse cleanly), so build the SOQL directly here. We still\n # run the agent_api_name through the same validator load_soql\n # uses, so the v1 path has no weaker injection surface than the\n # v2+ path.\n from config import fs_guard # re-exported from _shared/\n from soql_loader import SoqlParamError as _SoqlParamError\n try:\n fs_guard.validate_api_name(agent_api_name, label=\"AGENT_NAME\")\n except fs_guard.ValidationError as e:\n raise _SoqlParamError(\"AGENT_NAME\", agent_api_name, e.reason) from None\n soql = (\n \"SELECT Id, DeveloperName, MasterLabel, Description, \"\n \"PlannerType, Capabilities, AgentGraph\\n\"\n \"FROM GenAiPlannerDefinition\\n\"\n f\"WHERE DeveloperName LIKE '{agent_api_name}'\"\n )\n else:\n soql = load_soql(\n \"planner_definition_by_agent_chain\",\n AGENT_NAME=agent_api_name,\n VERSION=v,\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n recs = _records(resp)\n if not recs:\n return None\n # Disambiguate on shortest DeveloperName — the canonical chain row\n # has no suffix beyond `_v\u003cN>`. A longer row means the LIKE also\n # matched a deeper-chain planner that happens to share the suffix.\n recs.sort(key=lambda r: len(r.get(\"DeveloperName\") or \"\"))\n return recs[0]\n\n\ndef fetch_plugins_by_planner(\n planner_id: str,\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave A2: topic list for a planner.\"\"\"\n soql = load_soql(\"plugins_by_planner\", PLANNER_ID=planner_id)\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_planner_bundle_functions(\n planner_id: str,\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave A3: planner-bundle-scope function join rows (classic).\n\n NGA planners have no bundle-scope functions (PlannerId is null on\n every row); this query returns [] on NGA orgs. Caller doesn't have\n to branch.\n \"\"\"\n soql = load_soql(\"planner_bundle_functions\", PLANNER_ID=planner_id)\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_functions_by_plugins(\n plugin_ids: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave A4: actions (GenAiFunctionDefinition) under a planner's plugins.\n\n User invariant: a planner never has direct functions (PlannerId-only\n rows with no PluginId are orphan data, not live actions). The prior\n `PlannerId = '\u003cid>' OR ...` leg dragged in those orphans and\n surfaced them as stray root-level children. The query is now a\n single IN-list over plugin_ids; the PlannerId + ParentId columns\n stay in the SELECT for downstream consumers (parse_bundle, etc.)\n that still read them.\n\n Empty `plugin_ids` short-circuits — a SequentialPlannerIntent-\n Classifier bot with zero plugins has no plugin-scope functions to\n fetch. `load_soql_in` refuses empty lists; we guard here so callers\n don't have to.\n \"\"\"\n if not plugin_ids:\n return []\n soql = load_soql_in(\n \"functions_by_plugins\",\n list_params={\"PLUGIN_IDS\": plugin_ids},\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_plugin_instructions(\n plugin_ids: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave A5: per-topic instruction rows.\n\n Empty `plugin_ids` → []; no SOQL fired. SequentialPlannerIntent-\n Classifier bots and NGA single-planner shapes hit this path.\n \"\"\"\n if not plugin_ids:\n return []\n soql = load_soql_in(\n \"plugin_instructions_by_plugin_ids\",\n list_params={\"PLUGIN_IDS\": plugin_ids},\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_plugin_functions(\n plugin_ids: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave A6: plugin → function join rows.\"\"\"\n if not plugin_ids:\n return []\n soql = load_soql_in(\n \"plugin_functions_by_plugin_ids\",\n list_params={\"PLUGIN_IDS\": plugin_ids},\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_planner_attrs(\n parent_ids: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave A7: attribute mappings for a list of parent ids.\n\n Polymorphic ParentId: pass the UNION of function_ids + planner_id.\n Bot shape with zero functions (no actions registered) → []; no SOQL\n fired. Caller must include the planner_id even when function_ids is\n empty — this helper doesn't fabricate it.\n \"\"\"\n if not parent_ids:\n return []\n soql = load_soql_in(\n \"planner_attrs_by_parent_ids\",\n list_params={\"PARENT_IDS\": parent_ids},\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\n# ===========================================================================\n# Wave B — body fetchers (Flow, Apex). Tooling API.\n# ===========================================================================\n# These are the leaves: once Wave A gives us the function rows, we resolve\n# every InvocationTarget into one of flow / apex / prompt (prompt body\n# lands via retrieve in Batch 2, not here).\n#\n# The single-row Flow.Metadata path is the big parallelism opportunity —\n# `fetch_flow_metadata` is called N times (one per version id) and\n# dispatched through `parallel_retrieve.fetch_bodies_parallel`.\n# ---------------------------------------------------------------------------\n\n\ndef fetch_apex_bodies_by_names(\n names: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave B3 classic: Apex bodies keyed by DeveloperName.\n\n Returns `[{Id, Name, Body, SymbolTable, ApiVersion, IsValid}, ...]`.\n Batch-safe on `Name IN (...)` even though SymbolTable is\n complexvalue (confirmed live — see references/soql_fields.md).\n \"\"\"\n if not names:\n return []\n soql = load_soql_in(\n \"apex_class_bodies_by_names\",\n list_params={\"NAMES_LIST\": names},\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_apex_bodies_by_ids(\n ids: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave B3 NGA: Apex bodies keyed by Id.\n\n NGA functions store InvocationTarget as Salesforce Ids (e.g. `01p...`);\n this is the reverse-lookup path that translates back to Name + Body.\n \"\"\"\n if not ids:\n return []\n soql = load_soql_in(\n \"apex_class_bodies_by_ids\",\n list_params={\"APEX_IDS_LIST\": ids},\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_flow_definition_view_by_durable_ids(\n durable_ids: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Data API fallback: resolve managed-installed flow names via\n `FlowDefinitionView`.\n\n `FlowDefinition` (Tooling) does not index managed-installed flows for\n subscriber orgs — the Tooling query for a `ns__BareName` returns zero\n rows even when the flow genuinely exists. `FlowDefinitionView` (Data\n API only, NOT Tooling) does expose those rows via its `DurableId`\n column, which for managed-installed flows IS the qualified\n `ns__BareName` string.\n\n Caveats baked into the consumer contract (see\n `fetch_flow_definition_ids_by_names` downstream projection):\n * `FlowDefinitionView.ActiveVersionId` is a composite display\n string (e.g. `SvcCopilotTmpl__VerifyCode-1`), not a real\n `Flow.Id`. It cannot be used to fetch the flow body XML —\n managed flow bodies are IP-protected and not retrievable.\n * Callers must project these rows with `ActiveVersionId=None` and\n `_body_available=False` so the Wave B metadata fetcher skips\n body retrieval for them.\n\n Empty input short-circuits to `[]` without firing a SOQL call —\n matches the contract on every other list-shaped fetcher in this\n module.\n \"\"\"\n if not durable_ids:\n return []\n soql = load_soql_in(\n \"flow_definition_view_by_durable_ids\",\n list_params={\"DURABLE_IDS_LIST\": durable_ids},\n )\n resp = data_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_flow_definition_ids_by_names(\n names: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave B1 classic: resolve Flow DeveloperName list → FlowDefinition rows.\n\n Returns `[{Id, DeveloperName, NamespacePrefix, ActiveVersionId,\n _bare_developer_name, _source, _body_available, ...}, ...]`.\n Two-hop to `Flow` via `ActiveVersionId`; this is the first hop.\n\n Resolution path (2026-05-05, post-retirement of the managed-ns\n Tooling bucket): fire ONE Tooling `FlowDefinition` query for every\n input name verbatim — the template filters `NamespacePrefix IS NULL`\n so only unmanaged rows come back. Managed names like\n `ns__BareName` return zero rows from Tooling on subscriber orgs\n regardless of how we bucket them (live scouting confirmed Tooling\n FlowDefinition simply does not index managed-installed flows for\n subscribers), so attempting per-namespace Tooling queries adds cost\n and returns nothing useful. Anything the unmanaged query doesn't\n resolve falls through to `FlowDefinitionView` (Data API), which DOES\n expose managed-installed flows via `DurableId = ns__BareName`.\n\n FlowDefinitionView rows carry `Id=None`, `ActiveVersionId=None`,\n `_body_available=False`, and `_source=\"FlowDefinitionView\"` so\n downstream flow metadata retrieval short-circuits (there's no real\n version Id to fetch a body with, and managed flow bodies are\n IP-protected regardless).\n\n Real Tooling rows carry `_source=\"FlowDefinition\"` and\n `_body_available=True` set explicitly — consumers can dispatch on\n source rather than inferring from absence.\n \"\"\"\n if not names:\n return []\n\n rows: list[dict] = []\n # Track every input name we successfully resolve via the Tooling\n # FlowDefinition path. Anything left over goes through the\n # FlowDefinitionView fallback.\n resolved_names: set[str] = set()\n\n # Unmanaged Tooling query: template explicitly filters\n # `NamespacePrefix = NULL`. Managed-qualified names (`ns__BareName`)\n # passed through here will simply return zero rows — which is fine,\n # the view fallback below catches them. The null-ns filter is\n # load-bearing: dropping it would widen to unrelated managed rows\n # that happen to collide on a bare DeveloperName.\n #\n # Bug G fix: the SOQL template uses `= NULL`, NOT `IS NULL`. Tooling\n # Query API rejects `IS NULL` with `MALFORMED_QUERY: unexpected\n # token: 'NamespacePrefix IS'`. Only the Data API parser accepts\n # both forms. Commit aa01d52 mistakenly switched this template to\n # `IS NULL` (to silence a Prizm scanner false-positive) on the\n # premise that \"both forms parse identically\" — true for Data API,\n # false for Tooling. That broke wave-B subflow resolution: every\n # call here 400'd, the FDV fallback below also returned zero\n # because it filters by Salesforce Id (300...), and BFS hit the\n # iteration cap. Do NOT re-introduce `IS NULL` in this template.\n soql = load_soql_in(\n \"flow_definition_ids_by_names\",\n list_params={\"NAMES_LIST\": list(names)},\n )\n # Bug D.2 fix: tolerate HTTPError on the Tooling call. A single name\n # in `names` that violates Salesforce's DeveloperName limits (e.g.\n # length >40 chars, or chars Salesforce treats as non-identifier\n # despite passing fs_guard.validate_api_name) causes the WHOLE batch\n # to 400. Without this `try`, the entire wave-B round failed and the\n # FDV fallback below was never reached — managed names were never\n # resolved, BFS hit the iteration cap, operator saw cryptic\n # `wave-b-iteration-cap` entries instead of the actual root cause.\n # On HTTPError, fall through with `resolved_names` empty so every\n # input is re-tried via FDV; FDV uses Data API (different validation)\n # and tends to accept names Tooling rejects.\n try:\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n for r in _records(resp):\n dev_name = r.get(\"DeveloperName\")\n if dev_name:\n r[\"_bare_developer_name\"] = dev_name\n r[\"_source\"] = \"FlowDefinition\"\n r[\"_body_available\"] = True\n rows.append(r)\n resolved_names.add(dev_name)\n except urllib.error.HTTPError as exc:\n # Don't propagate — the FDV fallback below handles every\n # unresolved name. Log enough for triage; rest_client already\n # attached `_response_body_preview` (Bug D.1) so the body is\n # available downstream if a caller wants to inspect.\n logger.debug(\n \"fetch_flow_definition_ids_by_names: Tooling batch failed \"\n \"(HTTP %d); falling through to FlowDefinitionView for all %d names\",\n getattr(exc, \"code\", 0), len(names),\n )\n\n # FlowDefinitionView fallback — Data API. Any input name Tooling\n # didn't resolve is re-queried as a DurableId. Managed-installed\n # flows are the motivating case; the view also covers unmanaged\n # rows the Tooling surface misses for any other reason.\n unresolved_names = [n for n in names if n not in resolved_names]\n if unresolved_names:\n view_rows = fetch_flow_definition_view_by_durable_ids(\n sorted(set(unresolved_names)),\n creds_provider,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n for view_row in view_rows:\n durable_id = view_row.get(\"DurableId\")\n if not durable_id:\n continue\n rows.append({\n \"Id\": None,\n # Qualified ns__bare (matches bundle invocationTarget +\n # parse_wave visited-set dedupe key).\n \"DeveloperName\": durable_id,\n \"NamespacePrefix\": view_row.get(\"NamespacePrefix\"),\n # ActiveVersionId on FlowDefinitionView is a composite\n # display string (`ns__Name-\u003cn>`), NOT a real Flow.Id.\n # Null it so downstream `fetch_flow_metadata` dispatch\n # skips body retrieval for these rows.\n \"ActiveVersionId\": None,\n \"_bare_developer_name\": view_row.get(\"ApiName\"),\n \"_body_available\": False,\n \"_source\": \"FlowDefinitionView\",\n \"_flow_view_label\": view_row.get(\"Label\"),\n \"_flow_view_manageable_state\": view_row.get(\"ManageableState\"),\n })\n\n return rows\n\n\ndef fetch_flow_definition_by_ids(\n ids: list[str],\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Wave B1 NGA: reverse-lookup FlowDefinition Id → DeveloperName.\n\n NGA functions store Ids (`300...`). This resolves them back to names\n so the tree's leaf nodes can carry the human-readable DeveloperName\n and the active version id for Wave B2.\n \"\"\"\n if not ids:\n return []\n soql = load_soql_in(\n \"flow_definition_by_ids\",\n list_params={\"FLOW_DEF_IDS_LIST\": ids},\n )\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_flow_metadata(\n flow_version_id: str,\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> dict | None:\n \"\"\"Wave B2: single-row Flow.Metadata fetch.\n\n `complexvalue` fields (Metadata + FullName) force a single-row\n retrieval (`WHERE Id = '\u003cid>'`). Returns the row dict or `None` on\n miss. Called once per version id; parallelism happens in `main.py`\n via `parallel_retrieve.fetch_bodies_parallel`.\n \"\"\"\n soql = load_soql(\"flow_metadata_by_id\", FLOW_VERSION_ID=flow_version_id)\n resp = tooling_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n recs = _records(resp)\n return recs[0] if recs else None\n\n\n# ===========================================================================\n# Bot resolution (Data API) — Phase 4 of main.py. Separate from Wave A/B\n# because the routing is DATA_QUERY not tooling, and the shape is simpler.\n# ===========================================================================\n\n\ndef fetch_bot_versions(\n agent_api_name: str,\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> list[dict]:\n \"\"\"Data API: resolve bot versions for a given bot DeveloperName.\n\n Mirrors `resolve_bot.py` step 1 but routed through `data_query` so\n the 401-refresh path is active. `resolve_bot.py` itself shells out\n to `sf data query` and is retained as the env-var-driven entry-point\n for Bash harness callers; `main.py` uses this in-process variant.\n \"\"\"\n soql = load_soql(\"bot_version_lookup\", AGENT_API_NAME=agent_api_name)\n resp = data_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n return _records(resp)\n\n\ndef fetch_bot_definition_details(\n agent_api_name: str,\n creds_provider: CredsProvider,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> dict | None:\n \"\"\"Data API: BotDefinition metadata (MasterLabel, AgentTemplate, ...).\n\n Returns the single matching BotDefinition row or None. Feeds the\n `tree[\"agent\"]` fields in parse_wave's init_tree.\n \"\"\"\n soql = load_soql(\"bot_definition_details\", AGENT_API_NAME=agent_api_name)\n resp = data_query(\n creds_provider, soql,\n api_version=api_version,\n on_401_refresh=on_401_refresh,\n )\n recs = _records(resp)\n return recs[0] if recs else None\n\n\n","content_type":"text/x-python; charset=utf-8","language":"python","size":26723,"content_sha256":"e5c6bb0a2323e3a72d34ba9a2fd73f65030d65fbba072182eac7f508b2c6a51a"},{"filename":"scripts/finalize.py","content":"#!/usr/bin/env python3\n\"\"\"Finalize: write DATA_DIR + CACHE_DIR atomically, build manifest.\n\nReplaces old agent Phase 7. Steps:\n 1. Load $WORK_DIR/declared_action_tree.json.\n 2. Compute _partial = !(planner_ok && _pending_fetches empty); strip _visited.\n 3. Stage DATA_DIR at $DATA_DIR.tmp: copy tree as {api}_{ver}_metadata_tree.json.\n 4. rmtree final DATA_DIR; rename .tmp → DATA_DIR.\n 5. Stage CACHE_DIR at $CACHE_DIR.tmp: copy metadata/\u003cwave> dirs + sidecars.\n 6. Write manifest.json.\n 7. rmtree final CACHE_DIR; rename .tmp → CACHE_DIR.\n 8. Seed .gitignore on data_root + cache_root parents.\n 9. Write $WORK_DIR/.built_at.txt (ISO-Z UTC).\n\nthe previously-emitted `{api}_{ver}_metadata_tree.summary.md` is\ndropped from the output contract — it was a redundant summary of the tree\nJSON. Consumers should read the JSON directly.\n\nUsage:\n python3 finalize.py\n\nInputs (env):\n WORK_DIR required\n CACHE_DIR required\n DATA_DIR required\n PLANNER_NAME optional — empty → tree marked partial\n AGENT_API_NAME, AGENT_VERSION — used for filenames\n\nOutputs:\n $DATA_DIR/{api}_{ver}_metadata_tree.json\n $DATA_DIR/last_built_at.txt\n $CACHE_DIR/manifest.json + metadata/\u003cwave>/... + parsed sidecars\n $WORK_DIR/.built_at.txt\n exit 0 success, 1 on any write failure → caller emits STATUS=WRITE_FAILED\n\"\"\"\nimport datetime\nimport json\nimport os\nimport pathlib\nimport shutil\nimport sys\n\n\ndef sort_tree_in_place(root: dict) -> None:\n \"\"\"Sort `root.children` and each TOPIC's children alphabetically.\n\n Ordering rules (2026-05-05, schema 3.1):\n - `BOT_DEFINITION.children`: TOPIC nodes first (by `api_name`\n case-insensitive), then non-topic plannerActions (by kind, then\n `api_name`). This preserves the \"planner-level actions as a\n distinct trailing group\" convention for readers who scan the\n rendered tree top-down.\n - Each TOPIC's children: alphabetical by `api_name`\n case-insensitive.\n - FLOW children are NOT sorted — flow-actionCall / subflow order\n is the flow author's execution sequence, not a set.\n - GEN_AI_FUNCTION / APEX / PROMPT_TEMPLATE / STANDARD_ACTION leaves\n don't have children that warrant sorting.\n\n Applied as a final pass after tree assembly and before the\n authoritative JSON write, so ordering is pinned at the single\n source of truth — the renderer doesn't re-sort.\n \"\"\"\n if not isinstance(root, dict):\n return\n children = root.get(\"children\") or []\n if not children:\n return\n\n def _root_sort_key(node: dict) -> tuple:\n kind = node.get(\"kind\") or \"\"\n api = (node.get(\"api_name\") or \"\").casefold()\n # TOPIC first (tier 0), everything else second (tier 1). Within\n # each tier, stable by kind + api_name case-insensitive.\n tier = 0 if kind == \"TOPIC\" else 1\n return (tier, kind, api)\n\n root[\"children\"] = sorted(children, key=_root_sort_key)\n\n for child in root[\"children\"]:\n if child.get(\"kind\") == \"TOPIC\":\n child_children = child.get(\"children\") or []\n child[\"children\"] = sorted(\n child_children,\n key=lambda n: (n.get(\"api_name\") or \"\").casefold(),\n )\n\n\ndef main() -> int:\n try:\n work_dir = pathlib.Path(os.environ[\"WORK_DIR\"])\n cache_dir = pathlib.Path(os.environ[\"CACHE_DIR\"])\n data_dir = pathlib.Path(os.environ[\"DATA_DIR\"])\n agent_api_name = os.environ[\"AGENT_API_NAME\"]\n agent_version = os.environ[\"AGENT_VERSION\"]\n except KeyError as e:\n sys.stderr.write(f\"finalize.py: missing env {e}\\n\")\n return 1\n\n planner_name = os.environ.get(\"PLANNER_NAME\", \"\")\n tree_path = work_dir / \"declared_action_tree.json\"\n\n try:\n tree = json.loads(tree_path.read_text())\n except (OSError, json.JSONDecodeError) as e:\n sys.stderr.write(f\"finalize.py: cannot read {tree_path}: {e}\\n\")\n return 1\n\n # Compute _partial. _pending_fetches drains as wave-B BFS resolves\n # refs; failures (HTTP 4xx, iteration-cap exhaustion, managed-flow\n # filter mismatch) move OUT of _pending_fetches and into _unresolved.\n # If we only check _pending_fetches we'd silently call a run with\n # _unresolved entries \"converged\" — STATUS=OK with hidden failures.\n # Both buckets must be empty for a clean run.\n planner_ok = bool(planner_name)\n pending_total = sum(len(v) for v in (tree.get(\"_pending_fetches\") or {}).values())\n unresolved_count = len(tree.get(\"_unresolved\") or [])\n waves_converged = pending_total == 0 and unresolved_count == 0\n tree[\"_partial\"] = not (planner_ok and waves_converged)\n\n # Bug F fix: parse_wave + main set _partial_reason from\n # _pending_fetches only (legacy predicate). When _pending is empty\n # but _unresolved is non-empty, those writers set reason=None. The\n # promotion above flips _partial=True from the unresolved bucket, so\n # the reason needs a matching promotion or PARTIAL_REASON= ends up\n # blank in the RESULT block.\n if tree[\"_partial\"] and not tree.get(\"_partial_reason\"):\n if not planner_ok:\n tree[\"_partial_reason\"] = \"no-planner\"\n elif unresolved_count > 0:\n tree[\"_partial_reason\"] = \"unresolved-refs\"\n else:\n tree[\"_partial_reason\"] = \"pending-refs\"\n\n # Strip _visited (internal state — not part of the durable artifact)\n tree.pop(\"_visited\", None)\n\n # Pin deterministic child ordering as the last assembly step before\n # the authoritative write — see `sort_tree_in_place` docstring for\n # the ordering rules. Downstream readers (render_architecture,\n # summarize_tree, third-party tooling) therefore see a single\n # canonical order from disk; they don't re-sort.\n sort_tree_in_place(tree.get(\"root\") or {})\n\n # Rewrite the authoritative tree before copying it anywhere else\n try:\n tmp = tree_path.with_suffix(tree_path.suffix + \".tmp\")\n tmp.write_text(json.dumps(tree, indent=2))\n os.replace(tmp, tree_path)\n except OSError as e:\n sys.stderr.write(f\"finalize.py: cannot rewrite tree: {e}\\n\")\n return 1\n\n built_at = datetime.datetime.now(datetime.timezone.utc).isoformat().replace(\"+00:00\", \"Z\")\n a = tree.get(\"agent\", {}) or {}\n tree_base = f\"{agent_api_name}_{agent_version}_metadata_tree\"\n\n # --- DATA_DIR staging ---\n # summary.md dropped from the output contract; DATA_DIR now\n # holds only the tree JSON + last_built_at.txt (+ whatever the caller\n # writes alongside, e.g. architecture.md from render_architecture).\n #\n # The prior pattern was `rmtree(data_dir); data_tmp.rename(data_dir)` —\n # destructive of any co-tenant content under `\u003cagent>__\u003cver>/`. We\n # now iterate staging's children and overwrite each one into data_dir\n # individually, leaving unrelated siblings intact. See\n # `main.py:_swap_dir_atomic` for the production path with the same\n # invariant.\n data_tmp = data_dir.with_suffix(\".tmp\")\n try:\n if data_tmp.exists():\n shutil.rmtree(data_tmp)\n data_tmp.mkdir(parents=True)\n shutil.copy(tree_path, data_tmp / f\"{tree_base}.json\")\n\n (data_tmp / \"last_built_at.txt\").write_text(built_at + \"\\n\")\n\n data_dir.mkdir(parents=True, exist_ok=True)\n for staged in list(data_tmp.iterdir()):\n target = data_dir / staged.name\n if target.exists() or target.is_symlink():\n if target.is_dir() and not target.is_symlink():\n shutil.rmtree(target)\n else:\n target.unlink()\n shutil.move(str(staged), str(target))\n shutil.rmtree(data_tmp, ignore_errors=True)\n except OSError as e:\n sys.stderr.write(f\"finalize.py: DATA_DIR write failed: {e}\\n\")\n try:\n if data_tmp.exists():\n shutil.rmtree(data_tmp)\n except OSError:\n pass\n return 1\n\n # --- CACHE_DIR staging ---\n cache_tmp = cache_dir.with_suffix(\".tmp\")\n try:\n if cache_tmp.exists():\n shutil.rmtree(cache_tmp)\n cache_tmp.mkdir(parents=True)\n\n # Mirror sf_meta/\u003cwave>/ into metadata/\u003cwave>/ verbatim so cache has:\n # retrieve.json (sf CLI output — critical for debugging silent\n # retrieves that produced zero members)\n # unpackaged/ (parsed XML tree)\n # unpackaged.zip is intentionally skipped — it's byte-for-byte the\n # same as unpackaged/ and doubles cache size on every run.\n meta_dir = cache_tmp / \"metadata\"\n meta_dir.mkdir()\n sf_meta = work_dir / \"sf_meta\"\n if sf_meta.exists():\n for wave_dir in sorted(sf_meta.iterdir()):\n if not wave_dir.is_dir():\n continue\n dst = meta_dir / wave_dir.name\n dst.mkdir(parents=True, exist_ok=True)\n for item in wave_dir.iterdir():\n if item.name == \"unpackaged.zip\":\n continue # redundant with unpackaged/\n if item.is_dir():\n shutil.copytree(item, dst / item.name, dirs_exist_ok=True)\n else:\n shutil.copy(item, dst / item.name)\n\n # Copy every WORK_DIR top-level artifact except:\n # - .emit_ctx.json (per-run shell state, irrelevant to cache)\n # - .built_at.txt (recorded in manifest.built_at_utc)\n # - sf_meta/ (handled above)\n # This catches declared_action_tree.json, _bundle_parsed.json,\n # _agent_generation.txt, _bot_definition.json, _bot_versions.json,\n # and anything future scripts write as top-level sidecars.\n SKIP_NAMES = {\".emit_ctx.json\", \".built_at.txt\", \"sf_meta\"}\n for item in work_dir.iterdir():\n if item.name in SKIP_NAMES:\n continue\n if item.name.endswith(\".tmp\"):\n continue # atomic-write staging from some other script\n if item.is_dir():\n shutil.copytree(item, cache_tmp / item.name, dirs_exist_ok=True)\n elif item.is_file():\n shutil.copy(item, cache_tmp / item.name)\n\n manifest = {\n \"built_at_utc\": built_at,\n \"schema_version\": tree.get(\"_schema_version\", \"2.4\"),\n \"agent\": a,\n \"node_count\": tree.get(\"node_count\", 0),\n \"depth\": tree.get(\"depth\", 0),\n \"kind_counts\": tree.get(\"_kind_counts\", {}),\n \"ttl_days\": 7,\n \"data_path\": str(data_dir / f\"{tree_base}.json\"),\n \"partial\": tree.get(\"_partial\", False),\n \"unresolved_count\": len(tree.get(\"_unresolved\", []) or []),\n }\n (cache_tmp / \"manifest.json\").write_text(json.dumps(manifest, indent=2))\n\n if cache_dir.exists():\n shutil.rmtree(cache_dir)\n cache_tmp.rename(cache_dir)\n except OSError as e:\n sys.stderr.write(f\"finalize.py: CACHE_DIR write failed: {e}\\n\")\n try:\n if cache_tmp.exists():\n shutil.rmtree(cache_tmp)\n except OSError:\n pass\n return 1\n\n # --- .gitignore seeding on parent dirs (data_root + cache_root) ---\n for root in (cache_dir.parent.parent, data_dir.parent.parent):\n try:\n gi = root / \".gitignore\"\n if not gi.exists():\n root.mkdir(parents=True, exist_ok=True)\n gi.write_text(\"*\\n\")\n except OSError:\n pass\n\n # --- .built_at.txt for emit ctx ---\n try:\n (work_dir / \".built_at.txt\").write_text(built_at + \"\\n\")\n except OSError as e:\n sys.stderr.write(f\"finalize.py: .built_at.txt write failed: {e}\\n\")\n # Non-fatal — finalize succeeded; emit can recompute\n\n print(f\"[finalize] built_at: {built_at}\")\n print(f\"[finalize] data written: {data_dir}/{tree_base}.json\")\n print(f\"[finalize] cache written: {cache_dir}\")\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12065,"content_sha256":"77defb135e0dd81dfd8c8e52ca230acde1ae4a79a41c77f43c6e85f70f1f3807"},{"filename":"scripts/main.py","content":"\"\"\"Phase 2 Batch 1: pipeline orchestrator for investigating-agentforce-architecture.\n\nComposes Phase 1 primitives (config, rest_client, sf_cli, soql_loader,\nprobe_channels, fetch_soql, parallel_retrieve, parse_wave, finalize) into\nthe 13-phase flow defined in the plan.\n\nNOT a credential holder. Credentials live in a single mutable cell\n(`_creds_cell`) that both `creds_provider()` and `refresh_fn()` close over.\nEvery SOQL call uses `creds_provider` so `retry_on_401`'s refresh path\n can deliver fresh creds into the retry — storing the URL/token\nin function args would defeat the refresh entirely.\n\nPhase ordering:\n 1 parse args (Bash harness does phases 1-3 today;\n 2 resolve creds (sf org display) P2 Batch 1 runs them in-process too)\n 3 probe channels\n 4 resolve bot + version\n 5 cache check\n 6 Wave A (7 parallel Tooling queries, DAG-ordered)\n 7 join Wave A → _bundle_parsed.json-shaped dict\n 8 Wave B (Flow + Apex bodies, parallel)\n 9 parse_wave -> declared_action_tree.json\n 10 render architecture.md (Batch 2 — stubbed)\n 11 finalize (atomic writes + manifest)\n 12 emit RESULT\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nimport threading\nimport time\nfrom functools import partial\nfrom pathlib import Path\nfrom typing import Callable, Tuple\n\n# Phase 2 Batch 1: all cross-module wiring routes through config so a future\n# relocation of DATA_ROOT / CACHE_ROOT only touches one file.\nfrom config import (\n CACHE_TTL_DAYS,\n DATA_ROOT,\n SCHEMA_VERSION,\n build_agent_cache_dir,\n build_agent_data_dir,\n)\nfrom rest_client import RestClientError, redact_error\nfrom sf_cli import (\n AuthRequired,\n SfCliError,\n assert_show_access_token_capability,\n run_sf,\n)\nfrom soql_loader import SoqlParamError\n\n# Phase 2 Batch 1 imports — functional pipeline primitives.\nfrom fetch_soql import (\n fetch_apex_bodies_by_ids,\n fetch_apex_bodies_by_names,\n fetch_bot_definition_details,\n fetch_bot_versions,\n fetch_flow_definition_by_ids,\n fetch_flow_definition_ids_by_names,\n fetch_flow_metadata,\n fetch_functions_by_plugins,\n fetch_planner_attrs,\n fetch_planner_bundle_functions,\n fetch_planner_definition,\n fetch_plugin_functions,\n fetch_plugin_instructions,\n fetch_plugins_by_planner,\n)\nfrom metadata_listing import (\n list_prompt_template_metadata,\n retrieve_prompt_templates,\n)\nfrom parallel_retrieve import fetch_bodies_parallel\nfrom parse_bundle import classify_generation\nfrom probe_channels import probe_channels\nfrom resolve_invocation_target import looks_like_sf_id, resolve_or_unresolved\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\n\n\ndef parse_args(argv: list[str] | None = None) -> argparse.Namespace:\n p = argparse.ArgumentParser(prog=\"architecture\")\n p.add_argument(\"--org-alias\", required=True)\n p.add_argument(\"--agent\", required=True, dest=\"agent_api_name\")\n p.add_argument(\"--version\", dest=\"agent_version\")\n p.add_argument(\"--force\", action=\"store_true\", dest=\"force_refresh\")\n # --reprobe forces a fresh channel-probe run on pipeline start.\n p.add_argument(\"--reprobe\", action=\"store_true\")\n p.add_argument(\"--parallelism\", type=int, default=5)\n p.add_argument(\"--max-mermaid-nodes\", type=int, default=80)\n p.add_argument(\"--work-dir\", required=True)\n # Runtime-agnostic path overrides. Defaults are\n # ~/.vibe/{data,cache}/investigating-agentforce-architecture/.\n # Other runtimes (AFV OOTB, Codex, Cursor, OpenCode) pass these to land\n # artifacts under their own distribution layout.\n p.add_argument(\n \"--data-dir\",\n type=Path,\n default=None,\n help=\"Override data root (default: ~/.vibe/data/investigating-agentforce-architecture).\",\n )\n p.add_argument(\n \"--cache-dir\",\n type=Path,\n default=None,\n help=\"Override cache root (default: ~/.vibe/cache/investigating-agentforce-architecture).\",\n )\n return p.parse_args(argv)\n\n\n# ---------------------------------------------------------------------------\n# Three-level rebind for --data-dir / --cache-dir overrides.\n#\n# Python's `from X import Y` snapshots Y into the importer's local namespace.\n# Mutating X.Y later does NOT update the importer's local binding. main.py\n# does `from config import DATA_ROOT, ...` at module top, so without a\n# global rebind, line 2221's `str(DATA_ROOT)` would write the default path\n# even when --data-dir was passed.\n#\n# Levels rebound:\n# 1. paths.DATA_ROOT (the source of truth)\n# 2. config.DATA_ROOT (the re-export)\n# 3. main.DATA_ROOT (THIS module's local snapshot — via global)\n#\n# Plus: config.PROBE_CACHE_ROOT (derived from CACHE_ROOT at import time).\n# ---------------------------------------------------------------------------\n\n\ndef _apply_path_overrides(args: argparse.Namespace) -> None:\n \"\"\"Apply --data-dir / --cache-dir overrides across all 3 namespace levels.\n\n Must be called BEFORE any pipeline code reads DATA_ROOT or CACHE_ROOT.\n \"\"\"\n if not (args.data_dir or args.cache_dir):\n return\n global DATA_ROOT # rebind level 1: this module\n from _shared import runtime, paths\n import config\n\n skill_name = \"investigating-agentforce-architecture\"\n if args.data_dir:\n runtime.set_data_root_override(args.data_dir)\n new_data = runtime.resolve_data_root(skill_name)\n paths.DATA_ROOT = new_data # level 2: paths module\n config.DATA_ROOT = new_data # level 3: config module re-export\n DATA_ROOT = new_data # level 1: main.py local\n if args.cache_dir:\n runtime.set_cache_root_override(args.cache_dir)\n new_cache = runtime.resolve_cache_root(skill_name)\n paths.CACHE_ROOT = new_cache\n config.CACHE_ROOT = new_cache\n config.PROBE_CACHE_ROOT = new_cache / \"_channel_probe\"\n\n\n# ---------------------------------------------------------------------------\n# Phase 2 Batch 1: credentials\n# ---------------------------------------------------------------------------\n# Single mutable cell read by `creds_provider`. `refresh_fn` rewrites the\n# cell. `retry_on_401` in rest_client invokes refresh_fn for side effect;\n# the next invocation of creds_provider picks up the new tuple. See\n# rest_client \n\n\ndef _build_creds_plumbing(\n initial_creds: Tuple[str, str],\n resolve_creds: Callable[[], Tuple[str, str]],\n *,\n dedupe_window_s: float = 1.0,\n) -> Tuple[\n Callable[[], Tuple[str, str]],\n Callable[[], Tuple[str, str]],\n \"list[Tuple[str, str]]\",\n]:\n \"\"\"Build the (creds_provider, refresh_fn, creds_cell) triple.\n\n + `creds_provider` re-reads `creds_cell`\n on every call so a refresh actually lands in the NEXT SOQL attempt.\n `refresh_fn` is serialized by an internal `threading.Lock` + a\n `time.monotonic()` dedupe window — N concurrent 401s against the same\n stale token collapse to ONE `resolve_creds()` call per window. The\n window is stamped when `resolve_creds()` RETURNS, so waiters queued\n behind a slow refresh (several sf CLI spawns: `sf org display` +\n `sf org auth show-access-token`) reuse its result instead of\n re-refreshing. That caps full credential refreshes at roughly one per\n window regardless of pool size.\n\n Extracted from `main()` so the closure is testable without threading\n a whole pipeline run through argparse + mocks.\n\n Returns (creds_provider, refresh_fn, creds_cell). The cell is\n returned so callers/tests can inspect the current tuple directly\n (it's the single source of truth the provider reads from).\n \"\"\"\n creds_cell: list[Tuple[str, str]] = [initial_creds]\n lock = threading.Lock()\n last_refresh_mono = [0.0]\n\n def creds_provider() -> Tuple[str, str]:\n return creds_cell[0]\n\n def refresh_fn() -> Tuple[str, str]:\n with lock:\n now = time.monotonic()\n if now - last_refresh_mono[0] \u003c dedupe_window_s:\n # Another thread refreshed within the dedupe window — reuse.\n return creds_cell[0]\n creds_cell[0] = resolve_creds()\n # Stamp AFTER the (possibly slow) refresh so threads that queued\n # on the lock during it fall inside the window and reuse it.\n last_refresh_mono[0] = time.monotonic()\n return creds_cell[0]\n\n return creds_provider, refresh_fn, creds_cell\n\n\n_REDACTION_MARKER_FRAGMENT = \"show-access-token\"\n\"\"\"Substring that uniquely identifies the sf CLI v2 redaction placeholder\n(`\"[REDACTED] Use 'sf org auth show-access-token' to view\"`). We match on\nthis fragment rather than the full literal because the upstream wording\nhas shifted between sf CLI builds, but the embedded subcommand name has\nbeen stable since cli#3560 landed.\"\"\"\n\n\ndef _resolve_creds(org_alias: str) -> Tuple[str, str]:\n \"\"\"Resolve (instance_url, access_token) for ``org_alias``.\n\n Runtime token retrieval is fail-fast (forcedotcom/cli#3560): the\n access token comes ONLY from ``sf org auth show-access-token``.\n ``sf org display`` is still used for non-secret metadata\n (instanceUrl). A missing command, an empty token, or the redaction\n placeholder (see ``_REDACTION_MARKER_FRAGMENT``) all raise\n ``AuthRequired``.\n\n sf_cli.run_sf already redacts stderr on failure paths. Any\n exception surfaces as `AuthRequired` / `SfCliError`; caller routes\n through `_emit_fail`.\n \"\"\"\n # Capability preflight first (cached once it passes, see sf_cli) so a\n # missing sf binary / command surfaces the actionable preflight error\n # rather than a generic spawn failure from `sf org display`.\n try:\n assert_show_access_token_capability()\n except SfCliError as exc:\n raise AuthRequired(str(exc)) from exc\n\n display_data = run_sf(\"org_display\", ORG_ALIAS=org_alias)\n display_result = display_data.get(\"result\") or {}\n instance_url = display_result.get(\"instanceUrl\") or \"\"\n if not instance_url:\n raise AuthRequired(\n \"sf org display returned an incomplete payload (missing instanceUrl)\"\n )\n\n try:\n token_data = run_sf(\"show_access_token\", ORG_ALIAS=org_alias)\n except (SfCliError, AuthRequired) as exc:\n raise AuthRequired(\n \"could not retrieve access token via sf org auth \"\n \"show-access-token; ensure sf CLI is logged in for this org \"\n f\"and supports the dedicated command. Detail: {exc}\"\n ) from exc\n\n token_result = token_data.get(\"result\") or {}\n access_token = token_result.get(\"accessToken\") or \"\"\n\n if not access_token or _REDACTION_MARKER_FRAGMENT in access_token:\n raise AuthRequired(\n \"could not retrieve a usable access token via sf org auth \"\n \"show-access-token; ensure sf CLI is logged in for this org \"\n \"and the command returned a real token.\"\n )\n\n return instance_url, access_token\n\n\ndef _derive_org_ids(org_alias: str) -> Tuple[str, str, str]:\n \"\"\"Run `sf org display --json` once more for org_id_15 + org_id_18 +\n api_version.\n\n Reused call: the second `sf org display` is cheap and keeps the\n creds-resolve + org-ids functions uncoupled. When a run passes both\n through together, the overhead is two spawn()s not one — fine given\n the whole pipeline is O(seconds).\n\n Returns (org_id_15, org_id_18, api_version) — all strings. api_version\n matches `vNN.N` (validated by probe_channels' path builder downstream).\n\n Gap 3 fix (2026-05-05): returns the 18-char id as well so downstream\n emit helpers can populate the RESULT-block `ORG_ID_18` field. The\n 18-char value comes straight from `sf org display`; we do NOT compute\n it from the 15-char prefix.\n \"\"\"\n data = run_sf(\"org_display\", ORG_ALIAS=org_alias)\n result = data.get(\"result\") or {}\n org_id_18 = result.get(\"id\") or \"\"\n # SF org id is 18 chars; the 15-char slice is the stable prefix used\n # everywhere in this skill. Fail fast if shorter.\n if len(org_id_18) \u003c 15:\n raise SfCliError(f\"sf org display returned malformed id (len={len(org_id_18)})\")\n org_id_15 = org_id_18[:15]\n api_version_raw = str(result.get(\"apiVersion\") or \"\")\n # apiVersion from sf CLI is \"60.0\" shape; our validator requires \"v60.0\".\n api_version = api_version_raw if api_version_raw.startswith(\"v\") else f\"v{api_version_raw}\"\n return org_id_15, org_id_18, api_version\n\n\n# ---------------------------------------------------------------------------\n# Phase 4: bot resolution (in-process; Data API via fetch_soql)\n# ---------------------------------------------------------------------------\n\n\ndef _resolve_bot(\n agent_api_name: str,\n explicit_version: str | None,\n creds_provider: Callable[[], Tuple[str, str]],\n refresh_fn: Callable[[], Tuple[str, str]],\n *,\n api_version: str,\n) -> dict | None:\n \"\"\"Resolve (bot_id, version, master_label, planner_name, bot_def_details).\n\n Returns a dict with:\n bot_id, version, master_label, version_auto_picked (bool),\n bot_definition (dict row from BotDefinition details query),\n all_versions (list of {version, status}) — retained for error ctx.\n\n Returns None on AGENT_NOT_FOUND. Caller distinguishes by checking the\n absence — not by catching an exception — to keep the error-emit path\n a flat if-chain.\n\n `api_version` is threaded in from\n `_derive_org_ids`. The Data-API helpers require it — see\n `rest_client.data_query` for why the old hardcoded `v60.0` was\n wrong (real orgs run v66 and expose fields v60 does not).\n \"\"\"\n versions = fetch_bot_versions(\n agent_api_name, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n )\n if not versions:\n return None\n\n # Natural-key sort: v10 > v9 > v2 > v1. Copy the scheme from\n # resolve_bot.py so multi-version bots rank consistently across the\n # in-process + shell-out entry points.\n def _natural_key(v: dict) -> list:\n import re\n dn = v.get(\"DeveloperName\") or \"\"\n return [int(p) if p.isdigit() else p for p in re.split(r\"(\\d+)\", dn)]\n\n versions.sort(key=_natural_key, reverse=True)\n\n picked = None\n if explicit_version:\n for v in versions:\n if v.get(\"DeveloperName\") == explicit_version:\n picked = v\n break\n version_auto_picked = False\n else:\n for v in versions:\n if v.get(\"Status\") == \"Active\":\n picked = v\n break\n version_auto_picked = True\n\n if not picked:\n # Signal \"version miss\" distinctly from \"bot miss\". Caller emits\n # STATUS=AGENT_VERSION_NOT_FOUND with AVAILABLE_VERSIONS.\n return {\n \"bot_id\": \"\",\n \"version\": explicit_version or \"\",\n \"master_label\": \"\",\n \"version_auto_picked\": False,\n \"all_versions\": [\n {\n \"version\": v.get(\"DeveloperName\") or \"\",\n \"status\": v.get(\"Status\") or \"\",\n }\n for v in versions\n ],\n \"_version_not_found\": True,\n }\n\n bot_id = picked.get(\"BotDefinitionId\") or \"\"\n bd_node = picked.get(\"BotDefinition\") or {}\n master_label = bd_node.get(\"MasterLabel\") or \"\"\n chosen_version = picked.get(\"DeveloperName\") or \"\"\n\n # Phase 2 Batch 1: pull BotDefinition metadata once for the tree's\n # `agent` block. Non-fatal if the second query fails — tree falls\n # back to defaults.\n try:\n bot_def = fetch_bot_definition_details(\n agent_api_name, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n )\n except (RestClientError, SoqlParamError):\n bot_def = None\n\n # Planner-name resolution moved to Wave A — `fetch_planner_definition`\n # now takes (agent_api_name, chosen_version) and performs a chain-LIKE\n # lookup (`\u003cagent>%\\_\u003cvN>`). The canonical planner DeveloperName comes\n # back on the row and propagates downstream via the bundle join.\n return {\n \"bot_id\": bot_id,\n \"version\": chosen_version,\n \"master_label\": master_label,\n \"version_auto_picked\": version_auto_picked,\n \"bot_definition\": bot_def or {},\n }\n\n\n# ---------------------------------------------------------------------------\n# Phase 5: cache check\n# ---------------------------------------------------------------------------\n\n\ndef _cache_is_fresh(cache_dir: Path) -> dict | None:\n \"\"\"Return the manifest dict if the cache is hot + fresh, else None.\n\n Checks: manifest exists, schema_version matches, TTL not expired,\n data_path is a real file. Any failure degrades to `None` so the\n caller falls through to the pipeline-run path.\n\n Mirrors cache_check.py's shell-level logic; duplicated in-process so\n main.py can branch without shelling out.\n \"\"\"\n import datetime as dt\n\n manifest_path = cache_dir / \"manifest.json\"\n if not manifest_path.is_file():\n return None\n try:\n manifest = json.loads(manifest_path.read_text())\n except (OSError, json.JSONDecodeError):\n return None\n\n if str(manifest.get(\"schema_version\") or \"0\") != SCHEMA_VERSION:\n return None\n\n data_path_s = manifest.get(\"data_path\") or \"\"\n if not data_path_s or not Path(data_path_s).is_file():\n return None\n\n try:\n built = dt.datetime.fromisoformat(\n (manifest.get(\"built_at_utc\") or \"\").replace(\"Z\", \"+00:00\")\n )\n except ValueError:\n return None\n\n age_days = (dt.datetime.now(dt.timezone.utc) - built).days\n ttl = int(manifest.get(\"ttl_days\") or CACHE_TTL_DAYS)\n if age_days > ttl:\n return None\n\n return manifest\n\n\n# ---------------------------------------------------------------------------\n# Phase 6: Wave A\n# ---------------------------------------------------------------------------\n\n\ndef _run_wave_a(\n agent_api_name: str,\n version: str | None,\n creds_provider: Callable[[], Tuple[str, str]],\n refresh_fn: Callable[[], Tuple[str, str]],\n *,\n api_version: str,\n parallelism: int = 5,\n) -> dict | None:\n \"\"\"Run the 7-query GenAi normalized DAG in layered parallel steps.\n\n A1 resolves the live `GenAiPlannerDefinition` from (agent_api_name,\n version) via a chain-LIKE lookup — Agentforce's accretive planner\n naming (v1=`\u003cAgent>`, v2=`\u003cAgent>_v2`, v3=`\u003cAgent>_v2_v3`, ...) means\n (agent, version) alone is the only reliable key. The resolved row's\n `DeveloperName` is the canonical planner_name that propagates\n downstream via the bundle join in `_join_wave_a_to_bundle`.\n\n Returns a dict:\n {\n \"planner\": {...},\n \"plugins\": [...], # A2\n \"bundle_functions_join\": [...], # A3\n \"functions\": [...], # A4\n \"instructions\": [...], # A5\n \"plugin_functions_join\": [...], # A6\n \"attrs\": [...], # A7\n \"unresolved\": [...], # per-channel A2..A6 failures\n }\n Returns None if A1 yields no planner row — caller emits a clean\n error instead of failing later in the join.\n\n Failures:\n * RestClientError / HTTPError on A1 → propagates. No graceful path\n when the planner itself is unreachable.\n * A2..A6 failures: one failure in a parallel layer doesn't abort\n the layer (fetch_bodies_parallel's contract). The failed channel\n yields an empty data list AND an entry in `unresolved` keyed by\n channel with reason `wave-a-\u003cchannel>-failed:\u003credacted>`. The\n caller folds these into `tree[\"_unresolved\"]` so STATUS flips\n to PARTIAL_OK.\n \"\"\"\n # A1 — scalar, no parallelism. api_version threads through\n # every fetcher; see module docstring + fetch_soql.py.\n planner = fetch_planner_definition(\n agent_api_name, version, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n )\n if not planner:\n return None\n\n planner_id = planner.get(\"Id\")\n if not planner_id:\n # Planner row missing Id — malformed response. Treat as miss.\n return None\n\n unresolved: list[dict] = []\n\n # Layer 2: A2 + A3 in parallel\n layer2 = fetch_bodies_parallel(\n [\n partial(\n fetch_plugins_by_planner,\n planner_id, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n ),\n partial(\n fetch_planner_bundle_functions,\n planner_id, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n ),\n ],\n max_workers=parallelism,\n )\n plugins = layer2[0][1] if layer2[0][0] else []\n bundle_functions_join = layer2[1][1] if layer2[1][0] else []\n for channel, (ok, r) in zip((\"plugins\", \"bundle-functions\"), layer2):\n if not ok:\n entry = {\n \"kind\": \"WAVE_A\",\n \"channel\": channel,\n \"reason\": f\"wave-a-{channel}-failed:{redact_error(r)}\",\n }\n preview = getattr(r, \"_response_body_preview\", None)\n if preview:\n entry[\"response_body_preview\"] = preview\n unresolved.append(entry)\n\n plugin_ids = [p.get(\"Id\") for p in plugins if p.get(\"Id\")]\n\n # Layer 3: A4 + A5 + A6 in parallel\n layer3 = fetch_bodies_parallel(\n [\n partial(\n fetch_functions_by_plugins,\n plugin_ids, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n ),\n partial(\n fetch_plugin_instructions,\n plugin_ids, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n ),\n partial(\n fetch_plugin_functions,\n plugin_ids, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n ),\n ],\n max_workers=parallelism,\n )\n functions = layer3[0][1] if layer3[0][0] else []\n instructions = layer3[1][1] if layer3[1][0] else []\n plugin_functions_join = layer3[2][1] if layer3[2][0] else []\n for channel, (ok, r) in zip(\n (\"functions\", \"instructions\", \"plugin-functions\"), layer3,\n ):\n if not ok:\n entry = {\n \"kind\": \"WAVE_A\",\n \"channel\": channel,\n \"reason\": f\"wave-a-{channel}-failed:{redact_error(r)}\",\n }\n preview = getattr(r, \"_response_body_preview\", None)\n if preview:\n entry[\"response_body_preview\"] = preview\n unresolved.append(entry)\n\n # Layer 4: A7 alone, parent_ids = function_ids. Planner-level attrs\n # returned 0 rows live (a planner never carries direct attrs) so the\n # prior `function_ids ∪ {planner_id}` widened the IN-list for no\n # payoff. Keep the list minimal; dedupe defensively in case a\n # function Id repeats across bundles.\n parent_ids = list(dict.fromkeys(\n f.get(\"Id\") for f in functions if f.get(\"Id\")\n ))\n try:\n attrs = fetch_planner_attrs(\n parent_ids, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n )\n except (RestClientError, SoqlParamError):\n attrs = []\n\n return {\n \"planner\": planner,\n \"plugins\": plugins,\n \"bundle_functions_join\": bundle_functions_join,\n \"functions\": functions,\n \"instructions\": instructions,\n \"plugin_functions_join\": plugin_functions_join,\n \"attrs\": attrs,\n \"unresolved\": unresolved,\n }\n\n\n# ---------------------------------------------------------------------------\n# Phase 7: join Wave A → _bundle_parsed.json shape\n# ---------------------------------------------------------------------------\n\n\ndef _join_wave_a_to_bundle(\n bot_info: dict,\n wave_a: dict,\n) -> dict:\n \"\"\"Reshape normalized Wave-A rows into the bundle dict parse_wave expects.\n\n `parse_bundle.py` wrote XML-shaped bundles keyed by:\n plannerType, plannerName, generation, description, masterLabel,\n topics[{name, actions[{name, invocationTarget, invocationTargetType, ...}]}],\n plannerActions[{...}]\n\n We reconstruct that shape from the Tooling rows so parse_wave's\n `init_tree` + `build_root_children` accept the dict unchanged.\n \"\"\"\n planner = wave_a[\"planner\"]\n planner_type = planner.get(\"PlannerType\") or \"\"\n generation = classify_generation(planner_type)\n\n functions_by_id = {f.get(\"Id\"): f for f in wave_a[\"functions\"] if f.get(\"Id\")}\n # Plugin join: map plugin_id → ordered function_ids\n plugin_fn_map: dict[str, list[str]] = {}\n for row in wave_a[\"plugin_functions_join\"]:\n pid = row.get(\"PluginId\")\n fn_id = row.get(\"Function\")\n if pid and fn_id:\n plugin_fn_map.setdefault(pid, []).append(fn_id)\n\n def _action_dict(fn: dict) -> dict:\n \"\"\"Shape a GenAiFunctionDefinition row as a bundle-action dict.\"\"\"\n return {\n \"name\": fn.get(\"DeveloperName\") or fn.get(\"Id\") or \"\",\n \"localDeveloperName\": fn.get(\"LocalDeveloperName\"),\n \"masterLabel\": fn.get(\"MasterLabel\"),\n \"description\": fn.get(\"Description\"),\n \"invocationTarget\": fn.get(\"InvocationTarget\"),\n \"invocationTargetType\": (fn.get(\"InvocationTargetType\") or \"\").strip(),\n \"source\": fn.get(\"Source\"),\n }\n\n topics = []\n for plugin in wave_a[\"plugins\"]:\n pid = plugin.get(\"Id\")\n fn_ids = plugin_fn_map.get(pid, [])\n actions = [\n _action_dict(functions_by_id[fid])\n for fid in fn_ids if fid in functions_by_id\n ]\n topics.append({\n \"name\": plugin.get(\"DeveloperName\") or \"\",\n \"localDeveloperName\": plugin.get(\"LocalDeveloperName\"),\n \"masterLabel\": plugin.get(\"MasterLabel\"),\n \"description\": plugin.get(\"Description\"),\n \"canEscalate\": bool(plugin.get(\"CanEscalate\")),\n \"pluginType\": plugin.get(\"PluginType\"),\n \"actions\": actions,\n })\n\n # A planner never has direct functions (user invariant, verified\n # against my-org-alias). The previous `PlannerId`-leg query leaked\n # orphan rows (e.g. `AnswerQuestionsWithKnowledge_*`) as root-level\n # children. Key retained for downstream compatibility with callers\n # that read `bundle.get(\"plannerActions\", [])` (parse_wave, a few\n # _route / _normalize paths); always emits an empty list now.\n return {\n \"plannerType\": planner_type,\n \"plannerName\": planner.get(\"DeveloperName\"),\n \"generation\": generation,\n \"description\": planner.get(\"Description\"),\n \"masterLabel\": planner.get(\"MasterLabel\"),\n \"topics\": topics,\n \"plannerActions\": [],\n }\n\n\n# ---------------------------------------------------------------------------\n# Phase 8: Wave B (Flow + Apex body fetches)\n# ---------------------------------------------------------------------------\n\n\ndef _collect_wave_b_targets(bundle_parsed: dict) -> dict[str, list[str]]:\n \"\"\"Walk topics + plannerActions. Return {kind: [invocation_target, ...]}.\n\n Deduplicates per-kind. Caller routes each kind through the appropriate\n fetch_soql helper.\n \"\"\"\n apex_names: set[str] = set()\n apex_ids: set[str] = set()\n flow_names: set[str] = set()\n flow_ids: set[str] = set()\n # Bug 1 fix (2026-05-05): prompt-template targets can arrive as either\n # DeveloperNames (classic) OR 0hf-prefix Ids (observed in live orgs —\n # GenAiFunctionDefinition.InvocationTarget sometimes stores the Id).\n # Route to separate buckets so the Id bucket can be resolved → DevName\n # by _normalize_prompt_template_id_targets before Wave B retrieve.\n prompt_template_ids: set[str] = set()\n unresolved: list[dict] = []\n\n def _route(target: str, ttype: str) -> None:\n t = (ttype or \"\").lower()\n if not target:\n return\n # Bug 1 fix: prompt-template targets can arrive as either\n # DeveloperNames (classic) OR 0hf-prefix Ids. The Metadata API\n # retrieve expects DeveloperName; an Id sent verbatim returns\n # zero members and the target stays in _pending_fetches forever.\n # Route 0hf Ids into a separate bucket for post-lookup rewrite\n # via _normalize_prompt_template_id_targets (parallel to the\n # existing _normalize_flow_id_targets pattern).\n if t in (\"prompttemplate\", \"generatepromptresponse\") or t.startswith(\"prompt\") or t.startswith(\"genai\"):\n if looks_like_sf_id(target) and target[:3].lower() == \"0hf\":\n prompt_template_ids.add(target)\n return\n # bundle-declared kinds that never carry an Id.\n # Standard actions store their identifier (not an SF Id) directly\n # in InvocationTarget. Skip the ID router entirely — routing them\n # through resolve_or_unresolved would pollute `_unresolved` with\n # misleading \"invalid-id-format\" entries.\n if t == \"standardinvocableaction\":\n return\n # for flow/apex, only consult the ID router\n # when the target actually looks like a Salesforce Id. Classic\n # DeveloperNames (e.g. `MyFlow`, `AGNT_Foo`) don't\n # match the Id shape and used to land in _unresolved with reason\n # `invalid-id-format` — noise. Short-circuit: if it's not an Id\n # shape, skip the router and trust the invocationTargetType.\n if t in (\"flow\", \"apex\") and looks_like_sf_id(target):\n kind, _channel = resolve_or_unresolved(target, unresolved)\n if kind == \"apex\":\n apex_ids.add(target)\n return\n if kind in (\"flow_definition\", \"flow_version\"):\n flow_ids.add(target)\n return\n # Unknown Id prefix: resolve_or_unresolved already appended\n # to `unresolved` with reason \"unknown-id-prefix:...\" — fall\n # through; the target stays unrouted and surfaces there.\n return\n # Classic path: InvocationTarget is a DeveloperName.\n if t == \"apex\":\n apex_names.add(target)\n elif t == \"flow\":\n flow_names.add(target)\n # Any other unrecognized type: silently skip. parse_wave will\n # classify the node as UNKNOWN and it surfaces via _kind_counts.\n\n for topic in bundle_parsed.get(\"topics\", []) or []:\n for a in topic.get(\"actions\", []) or []:\n _route(a.get(\"invocationTarget\") or \"\",\n a.get(\"invocationTargetType\") or \"\")\n for a in bundle_parsed.get(\"plannerActions\", []) or []:\n _route(a.get(\"invocationTarget\") or \"\",\n a.get(\"invocationTargetType\") or \"\")\n\n return {\n \"apex_names\": sorted(apex_names),\n \"apex_ids\": sorted(apex_ids),\n \"flow_names\": sorted(flow_names),\n \"flow_ids\": sorted(flow_ids),\n \"prompt_template_ids\": sorted(prompt_template_ids),\n \"_unresolved\": unresolved,\n }\n\n\ndef _fetch_wave_b_by_names(\n *,\n apex_names: list[str],\n apex_ids: list[str],\n flow_names: list[str],\n flow_ids: list[str],\n prompt_template_ids: list[str] | None = None,\n creds_provider: Callable[[], Tuple[str, str]],\n refresh_fn: Callable[[], Tuple[str, str]],\n api_version: str,\n org_alias: str,\n parallelism: int = 5,\n) -> dict:\n \"\"\"Fire body fetches for an explicit set of Apex/Flow identifiers.\n\n Factored out of `_run_wave_b` so the iterative Wave B caller\n (`_iterate_wave_b`) can reuse the same shape on subflow rounds —\n every round's fetch concurrency + error handling semantics must\n match the initial round exactly (same parallel layer, same\n unresolved bookkeeping).\n\n Returns a dict with:\n apex_rows: list of ApexClass records (Name keyed)\n flow_def_rows: list of FlowDefinition records (DeveloperName keyed)\n flow_metadata: dict {version_id: Flow record}\n unresolved: list of {id, reason} from fetch failures\n\n Any empty input list short-circuits that branch — callers can pass\n subsets safely (e.g. subflow rounds typically pass only flow_names).\n \"\"\"\n unresolved: list[dict] = []\n\n # First hop: resolve IDs back to names where applicable. The results\n # merge into the same rowsets the name-keyed path produces.\n # api_version threads into every fetch call.\n if apex_ids:\n try:\n apex_by_id_rows = fetch_apex_bodies_by_ids(\n apex_ids, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n )\n except (RestClientError, SoqlParamError) as e:\n unresolved.append({\"kind\": \"APEX\", \"reason\": f\"apex-by-id-failed:{redact_error(e)}\"})\n apex_by_id_rows = []\n else:\n apex_by_id_rows = []\n\n if flow_ids:\n try:\n flow_def_by_id_rows = fetch_flow_definition_by_ids(\n flow_ids, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n )\n except (RestClientError, SoqlParamError) as e:\n unresolved.append({\"kind\": \"FLOW\", \"reason\": f\"flow-def-by-id-failed:{redact_error(e)}\"})\n flow_def_by_id_rows = []\n else:\n flow_def_by_id_rows = []\n\n # Bug 1 fix: GenAiPromptTemplate is NOT SOQL-queryable (not on Tooling,\n # not on Data API). The only way to resolve Id → DeveloperName is\n # `sf org list metadata -m GenAiPromptTemplate`. We list every prompt\n # template in the org, build an Id → fullName map, filter to the Ids\n # we care about, and project to the same {Id, DeveloperName} shape the\n # rest of the pipeline expects.\n #\n # Failure is non-fatal — the Ids stay in the bucket and surface in\n # `_pending_fetches` rather than masking the real issue.\n if prompt_template_ids:\n try:\n rows = list_prompt_template_metadata(org_alias)\n except (SfCliError, AuthRequired) as e:\n unresolved.append({\n \"kind\": \"PROMPT_TEMPLATE\",\n \"reason\": f\"prompt-template-listmetadata-failed:{redact_error(e)}\",\n })\n rows = []\n wanted = set(prompt_template_ids)\n prompt_template_id_rows = [\n {\"Id\": r[\"id\"], \"DeveloperName\": r[\"fullName\"]}\n for r in rows\n if isinstance(r, dict) and r.get(\"id\") in wanted and r.get(\"fullName\")\n ]\n else:\n prompt_template_id_rows = []\n\n # Parallel layer: apex-by-name + flow-def-by-name. Empty name lists\n # still produce a task, but the downstream fetcher short-circuits on\n # empty input — the overhead is a single worker dispatch which is\n # cheaper than conditionally building the task list.\n name_layer = fetch_bodies_parallel(\n [\n partial(\n fetch_apex_bodies_by_names,\n apex_names, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n ),\n partial(\n fetch_flow_definition_ids_by_names,\n flow_names, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n ),\n ],\n max_workers=parallelism,\n )\n apex_name_rows = name_layer[0][1] if name_layer[0][0] else []\n flow_def_name_rows = name_layer[1][1] if name_layer[1][0] else []\n # Bug D.1 fix: include the response body preview captured by\n # rest_client._query_once when the failure was an HTTPError. Without\n # it, all wave-B 400s collapse to \"HTTP Error 400: Bad Request\" with\n # no signal about which name caused the malformed query. Falls back\n # to None (existing shape) for non-HTTPError failures.\n for ok, r in (name_layer[0], name_layer[1]):\n if not ok:\n entry = {\"kind\": \"APEX_OR_FLOW\", \"reason\": f\"wave-b-batch-failed:{redact_error(r)}\"}\n preview = getattr(r, \"_response_body_preview\", None)\n if preview:\n entry[\"response_body_preview\"] = preview\n unresolved.append(entry)\n\n apex_rows = [*apex_name_rows, *apex_by_id_rows]\n flow_def_rows = [*flow_def_name_rows, *flow_def_by_id_rows]\n\n # Second hop: one Flow.Metadata single-row call per ActiveVersionId.\n # Parallelized via fetch_bodies_parallel.\n version_ids = [\n r.get(\"ActiveVersionId\") for r in flow_def_rows\n if r.get(\"ActiveVersionId\")\n ]\n metadata_tasks = [\n partial(\n fetch_flow_metadata,\n vid, creds_provider,\n api_version=api_version, on_401_refresh=refresh_fn,\n )\n for vid in version_ids\n ]\n metadata_results = fetch_bodies_parallel(\n metadata_tasks, max_workers=parallelism,\n )\n flow_metadata: dict[str, dict] = {}\n for vid, (ok, result_or_exc) in zip(version_ids, metadata_results):\n if ok and isinstance(result_or_exc, dict):\n flow_metadata[vid] = result_or_exc\n elif not ok:\n unresolved.append({\n \"kind\": \"FLOW\",\n \"version_id\": vid,\n \"reason\": f\"flow-metadata-failed:{redact_error(result_or_exc)}\",\n })\n\n return {\n \"apex_rows\": apex_rows,\n \"flow_def_rows\": flow_def_rows,\n \"flow_metadata\": flow_metadata,\n \"prompt_template_id_rows\": prompt_template_id_rows,\n \"unresolved\": unresolved,\n }\n\n\ndef _run_wave_b(\n bundle_parsed: dict,\n creds_provider: Callable[[], Tuple[str, str]],\n refresh_fn: Callable[[], Tuple[str, str]],\n *,\n api_version: str,\n org_alias: str,\n parallelism: int = 5,\n) -> dict:\n \"\"\"Fire body fetches for every Apex/Flow target in the bundle.\n\n Returns a dict with:\n apex_rows: list of ApexClass records (Name keyed)\n flow_def_rows: list of FlowDefinition records (DeveloperName keyed)\n flow_metadata: dict {version_id: Flow record}\n unresolved: list of {id, reason} from NGA ID routing\n\n Only covers the top-level identifiers enumerated directly in\n `bundle_parsed`. Subflows referenced inside fetched Flow.Metadata\n bodies (e.g. shared utility flows like `handleFlowFault`) are\n discovered AFTER this function returns and need a second round of\n fetching — see `_iterate_wave_b`.\n \"\"\"\n targets = _collect_wave_b_targets(bundle_parsed)\n\n fetched = _fetch_wave_b_by_names(\n apex_names=targets[\"apex_names\"],\n apex_ids=targets[\"apex_ids\"],\n flow_names=targets[\"flow_names\"],\n flow_ids=targets[\"flow_ids\"],\n prompt_template_ids=targets.get(\"prompt_template_ids\") or [],\n creds_provider=creds_provider,\n refresh_fn=refresh_fn,\n api_version=api_version,\n org_alias=org_alias,\n parallelism=parallelism,\n )\n\n # Merge the routing-layer unresolved entries (unknown-id-prefix, etc.)\n # with the fetch-layer ones. Order: routing first, fetch second — it's\n # the order they're discovered in the pipeline.\n fetched[\"unresolved\"] = [*targets[\"_unresolved\"], *fetched[\"unresolved\"]]\n return fetched\n\n\n# ---------------------------------------------------------------------------\n# iterative Wave B for nested-subflow discovery\n# ---------------------------------------------------------------------------\n\n\ndef _extract_refs_from_flow_metadata(\n flow_metadata: dict[str, dict],\n) -> Tuple[set[str], set[str]]:\n \"\"\"Scan fetched Flow.Metadata dicts for downstream subflow + apex refs.\n\n Returns `(subflow_names, apex_names)` — both DeveloperName sets.\n Subsets of the data that `_build_flow_children` walks, narrowed to\n just the name-extraction we need before we have enough information\n to build the full child list.\n\n The extractor is intentionally permissive: missing keys / non-dict\n entries / blank names all drop silently. Parsing shape mirrors the\n `Metadata.actionCalls[]` + `Metadata.subflows[]` layout documented\n in the fetch_flow_metadata docstring and exercised by the\n CLASSIC_FLOW_METADATA fixture.\n\n Apex discovery from actionCalls is deliberate — a subflow body CAN\n reference a new Apex class the parent tree didn't see (actionType\n == \"apex\" && actionName == \u003cnew class>). Missing those means the\n iterated tree has unexpanded APEX leaves even after flows resolve.\n \"\"\"\n subflow_names: set[str] = set()\n apex_names: set[str] = set()\n for record in (flow_metadata or {}).values():\n if not isinstance(record, dict):\n continue\n md = record.get(\"Metadata\")\n if not isinstance(md, dict):\n continue\n for ac in md.get(\"actionCalls\") or []:\n if not isinstance(ac, dict):\n continue\n if (ac.get(\"actionType\") or \"\") == \"apex\":\n nm = ac.get(\"actionName\") or \"\"\n if nm:\n apex_names.add(nm)\n for sub in md.get(\"subflows\") or []:\n if not isinstance(sub, dict):\n continue\n nm = sub.get(\"flowName\") or \"\"\n if nm:\n subflow_names.add(nm)\n return subflow_names, apex_names\n\n\ndef _iterate_wave_b(\n initial_wave_b: dict,\n creds_provider: Callable[[], Tuple[str, str]],\n refresh_fn: Callable[[], Tuple[str, str]],\n *,\n api_version: str,\n org_alias: str,\n parallelism: int = 5,\n max_iterations: int = 5,\n) -> dict:\n \"\"\"Drive Wave B to fixed-point for nested subflow / apex discovery.\n\n The initial `_run_wave_b` call only enumerates top-level flow refs\n from `bundle_parsed`. When those flow bodies reference subflows\n (via `Metadata.subflows[].flowName`) or new Apex classes (via\n `Metadata.actionCalls[]`), their bodies were never fetched — so\n `_build_flow_children` would shape them as leaf nodes with no\n children even though the real subtree has content.\n\n Per round:\n 1. Extract subflow + apex refs from the current flow_metadata.\n 2. Diff against already-fetched DeveloperNames.\n 3. If diff empty → converged, return.\n 4. Fetch the diff via `_fetch_wave_b_by_names`.\n 5. Merge into the running wave_b dict.\n 6. Repeat.\n\n Safety:\n * `max_iterations=5` — empirically 2-3 is enough on real bots\n (handleFlowFault etc.). Hit the cap → remaining unfetched names\n surface via parse_wave's `_pending_fetches.FLOW` (their names\n are never added to `visited_by_kind[\"FLOW\"]` since they're\n absent from `flow_def_rows`) and a marker lands in `unresolved`\n for triage.\n * Fetch failures in a round are tolerated by `_fetch_wave_b_by_names`\n — the affected names simply don't make it into `flow_def_rows`\n and naturally surface as pending.\n\n Returns the (possibly enlarged) wave_b dict; caller mutates nothing.\n \"\"\"\n if max_iterations \u003c= 0:\n return initial_wave_b\n\n # Running state: copy so callers see their original dict untouched.\n merged_apex_rows = list(initial_wave_b.get(\"apex_rows\") or [])\n merged_flow_def_rows = list(initial_wave_b.get(\"flow_def_rows\") or [])\n merged_flow_metadata: dict[str, dict] = dict(\n initial_wave_b.get(\"flow_metadata\") or {}\n )\n merged_unresolved = list(initial_wave_b.get(\"unresolved\") or [])\n\n fetched_flow_names: set[str] = {\n r.get(\"DeveloperName\") for r in merged_flow_def_rows\n if r.get(\"DeveloperName\")\n }\n fetched_apex_names: set[str] = {\n r.get(\"Name\") for r in merged_apex_rows if r.get(\"Name\")\n }\n\n for _round in range(max_iterations):\n subflow_names, apex_names = _extract_refs_from_flow_metadata(\n merged_flow_metadata\n )\n new_flow_names = sorted(subflow_names - fetched_flow_names)\n new_apex_names = sorted(apex_names - fetched_apex_names)\n if not new_flow_names and not new_apex_names:\n # Fixed-point reached.\n return {\n \"apex_rows\": merged_apex_rows,\n \"flow_def_rows\": merged_flow_def_rows,\n \"flow_metadata\": merged_flow_metadata,\n \"prompt_template_id_rows\": initial_wave_b.get(\n \"prompt_template_id_rows\"\n ) or [],\n \"unresolved\": merged_unresolved,\n }\n\n round_result = _fetch_wave_b_by_names(\n apex_names=new_apex_names,\n apex_ids=[],\n flow_names=new_flow_names,\n flow_ids=[],\n creds_provider=creds_provider,\n refresh_fn=refresh_fn,\n api_version=api_version,\n org_alias=org_alias,\n parallelism=parallelism,\n )\n merged_apex_rows.extend(round_result[\"apex_rows\"])\n merged_flow_def_rows.extend(round_result[\"flow_def_rows\"])\n merged_flow_metadata.update(round_result[\"flow_metadata\"])\n merged_unresolved.extend(round_result[\"unresolved\"])\n\n # Update fetched-name bookkeeping — we only track rows we\n # actually received (fetch failure ⇒ not fetched ⇒ will be\n # retried next round, bounded by the iteration cap).\n for r in round_result[\"flow_def_rows\"]:\n nm = r.get(\"DeveloperName\")\n if nm:\n fetched_flow_names.add(nm)\n for r in round_result[\"apex_rows\"]:\n nm = r.get(\"Name\")\n if nm:\n fetched_apex_names.add(nm)\n\n # Iteration cap hit. Scan one more time to see what's still pending\n # so we can surface it clearly in `unresolved`. These names will also\n # land in `_pending_fetches.FLOW` via parse_wave's visited-vs-pending\n # diff (they're absent from flow_def_rows ⇒ absent from\n # visited_by_kind[\"FLOW\"]).\n subflow_names, apex_names = _extract_refs_from_flow_metadata(\n merged_flow_metadata\n )\n still_pending_flows = sorted(subflow_names - fetched_flow_names)\n still_pending_apex = sorted(apex_names - fetched_apex_names)\n for nm in still_pending_flows:\n merged_unresolved.append({\n \"kind\": \"FLOW\",\n \"api_name\": nm,\n \"reason\": f\"wave-b-iteration-cap:{max_iterations}-rounds-exhausted\",\n })\n for nm in still_pending_apex:\n merged_unresolved.append({\n \"kind\": \"APEX\",\n \"api_name\": nm,\n \"reason\": f\"wave-b-iteration-cap:{max_iterations}-rounds-exhausted\",\n })\n\n return {\n \"apex_rows\": merged_apex_rows,\n \"flow_def_rows\": merged_flow_def_rows,\n \"flow_metadata\": merged_flow_metadata,\n \"prompt_template_id_rows\": initial_wave_b.get(\n \"prompt_template_id_rows\"\n ) or [],\n \"unresolved\": merged_unresolved,\n }\n\n\n# ---------------------------------------------------------------------------\n# build flow_children from in-memory Wave B metadata\n# ---------------------------------------------------------------------------\n\n\ndef _build_flow_children(\n flow_metadata: dict[str, dict],\n flow_def_rows: list[dict],\n) -> dict[str, list[dict]]:\n \"\"\"Produce `{flow_developer_name: [child_ref, ...]}` from Wave B data.\n\n Mirrors the shape `parse_wave.harvest_waves` produces from on-disk\n flow XML, but sourced from the in-memory `Flow.Metadata` JSON that\n Wave B already fetched via the Tooling REST API (the `Metadata`\n complexvalue field decodes to a plain dict — see\n `fetch_flow_metadata` + test fixture `CLASSIC_FLOW_METADATA`). The\n shape parse_wave expects per child:\n\n {\"kind\": \"APEX\", \"api_name\": \u003cname>, \"element_name\": \u003celem>}\n {\"kind\": \"PROMPT_TEMPLATE\", \"api_name\": \u003cname>, \"element_name\": \u003celem>}\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \u003cname>, \"element_name\": \u003celem>,\n \"invocation_type\": \u003cactionType>}\n {\"kind\": \"FLOW\", \"api_name\": \u003ctarget_flow_name>,\n \"element_name\": \u003celem>}\n {\"kind\": \"UNKNOWN\", \"api_name\": \u003cname>, \"element_name\": \u003celem>,\n \"invocation_type\": \u003cactionType>}\n\n Keying is by FlowDefinition.DeveloperName (not ActiveVersionId) —\n `walk_and_inflate` looks up by `leaf[\"api_name\"]`, which the bundle\n populates with the Flow's DeveloperName.\n\n Flows whose ActiveVersionId is missing (inactive / draft-only) or\n whose fetched metadata was None are silently skipped — they never\n had a body to walk. actionCall entries missing required fields\n (`actionType`, `actionName`, or `name`) degrade to UNKNOWN via\n `classify_action_call`, which already tolerates blanks. Subflow\n entries missing `flowName` are dropped entirely (no api_name to\n descend into).\n \"\"\"\n import parse_wave\n\n # Build ActiveVersionId → DeveloperName lookup. FlowDefinition rows with\n # no ActiveVersionId (inactive flows, or flows only materializing a\n # Latest version) skip this map — `flow_metadata` is keyed on\n # ActiveVersionId too, so they drop out symmetrically.\n version_to_name: dict[str, str] = {}\n for row in flow_def_rows or []:\n vid = row.get(\"ActiveVersionId\")\n dev_name = row.get(\"DeveloperName\")\n if vid and dev_name:\n version_to_name[vid] = dev_name\n\n flow_children: dict[str, list[dict]] = {}\n for version_id, record in (flow_metadata or {}).items():\n flow_name = version_to_name.get(version_id)\n if not flow_name:\n # No matching FlowDefinition row — can't key the children list.\n continue\n if not isinstance(record, dict):\n continue\n md = record.get(\"Metadata\") or {}\n if not isinstance(md, dict):\n continue\n\n children: list[dict] = []\n\n # actionCalls — each entry has `name` (element id), `actionType`,\n # `actionName`. `actionType`/`actionName` may be missing on edge\n # cases (flow authored against an older API version that omits\n # the field); classify_action_call tolerates empty strings and\n # will return UNKNOWN.\n for ac in md.get(\"actionCalls\") or []:\n if not isinstance(ac, dict):\n continue\n element_name = ac.get(\"name\")\n action_type = ac.get(\"actionType\") or \"\"\n action_name = ac.get(\"actionName\") or \"\"\n if not element_name:\n # An unnamed actionCall can't be addressed in the tree\n # view; drop rather than synthesize a placeholder.\n continue\n children.append(\n parse_wave.classify_action_call(action_type, action_name, element_name)\n )\n\n # subflows — each has `name` (element id) + `flowName` (target).\n for sub in md.get(\"subflows\") or []:\n if not isinstance(sub, dict):\n continue\n element_name = sub.get(\"name\")\n target = sub.get(\"flowName\")\n if not element_name or not target:\n continue\n children.append({\n \"kind\": \"FLOW\",\n \"element_name\": element_name,\n \"api_name\": target,\n })\n\n flow_children[flow_name] = children\n\n return flow_children\n\n\n# ---------------------------------------------------------------------------\n# Flow-ID → DeveloperName normalization\n# ---------------------------------------------------------------------------\n\n\ndef _normalize_flow_id_targets(bundle_parsed: dict, flow_def_rows: list[dict]) -> None:\n \"\"\"Rewrite action invocationTargets that are Flow IDs → DeveloperNames.\n\n Mutates `bundle_parsed` in place. No-op when no flow_def_rows match.\n\n Classic bots occasionally store 300Uv-prefix FlowDefinition IDs or\n 301-prefix Flow version IDs in `GenAiFunctionDefinition.InvocationTarget`\n instead of DeveloperNames. Wave B's FlowDefinition fetch (by-id branch)\n already resolved those IDs; the rows carry both Id + ActiveVersionId +\n DeveloperName. We build a bi-directional map (300-id → name, 301-id →\n name via ActiveVersionId) and rewrite every action whose target is a\n known ID.\n\n Unmatched IDs (Flow genuinely not queryable or belongs to a managed\n package we can't see) stay as-is so the pipeline surfaces them in\n _pending_fetches rather than silently discarding.\n \"\"\"\n # Build ID → DeveloperName lookup from both Id and ActiveVersionId keys.\n id_to_name: dict[str, str] = {}\n for row in flow_def_rows or []:\n dev_name = row.get(\"DeveloperName\")\n if not dev_name:\n continue\n row_id = row.get(\"Id\")\n if row_id:\n id_to_name[row_id] = dev_name\n active_ver = row.get(\"ActiveVersionId\")\n if active_ver:\n id_to_name[active_ver] = dev_name\n\n if not id_to_name:\n return\n\n def _rewrite_action(action: dict) -> None:\n target = action.get(\"invocationTarget\")\n ttype = (action.get(\"invocationTargetType\") or \"\").strip().lower()\n if ttype != \"flow\" or not isinstance(target, str):\n return\n resolved = id_to_name.get(target)\n if resolved:\n # Preserve the original ID for traceability — consumers that\n # need it can still read `_original_invocation_target_id`.\n action[\"_original_invocation_target_id\"] = target\n action[\"invocationTarget\"] = resolved\n\n for topic in bundle_parsed.get(\"topics\") or []:\n for action in topic.get(\"actions\") or []:\n _rewrite_action(action)\n for action in bundle_parsed.get(\"plannerActions\") or []:\n _rewrite_action(action)\n\n\ndef _normalize_prompt_template_id_targets(\n bundle_parsed: dict, prompt_template_rows: list[dict],\n) -> None:\n \"\"\"Rewrite action invocationTargets that are GenAiPromptTemplate Ids\n (0hf-prefix) → DeveloperNames.\n\n Bug 1 fix (2026-05-05): when GenAiFunctionDefinition.InvocationTarget\n stores a 0hf-prefix prompt template Id instead of a DeveloperName\n (observed in live my-org-alias org), the Metadata API retrieve\n can't match it — Wave B enqueues the Id as-is and the template\n stays in `_pending_fetches.PROMPT_TEMPLATE` forever.\n\n Parallel to `_normalize_flow_id_targets`: build an Id → DeveloperName\n map from `list_prompt_template_metadata` rows (projected to\n {Id, DeveloperName} shape by `_fetch_wave_b_by_names`), mutate the\n bundle in place. Unmatched Ids stay as-is (genuinely missing template,\n or the Metadata API listing failed).\n \"\"\"\n id_to_name: dict[str, str] = {}\n for row in prompt_template_rows or []:\n row_id = row.get(\"Id\")\n dev_name = row.get(\"DeveloperName\")\n if row_id and dev_name:\n id_to_name[row_id] = dev_name\n\n if not id_to_name:\n return\n\n def _rewrite_action(action: dict) -> None:\n target = action.get(\"invocationTarget\")\n ttype = (action.get(\"invocationTargetType\") or \"\").strip().lower()\n # Match the same ttype predicate used by _route for prompt-template\n # targets — otherwise a Flow action whose target happens to collide\n # with a prompt template Id (unlikely but possible) wouldn't be\n # caught. _route's predicate is the authority on what's a prompt.\n is_prompt_ttype = (\n ttype in (\"prompttemplate\", \"generatepromptresponse\")\n or ttype.startswith(\"prompt\")\n or ttype.startswith(\"genai\")\n )\n if not is_prompt_ttype or not isinstance(target, str):\n return\n resolved = id_to_name.get(target)\n if resolved:\n action[\"_original_invocation_target_id\"] = target\n action[\"invocationTarget\"] = resolved\n\n for topic in bundle_parsed.get(\"topics\") or []:\n for action in topic.get(\"actions\") or []:\n _rewrite_action(action)\n for action in bundle_parsed.get(\"plannerActions\") or []:\n _rewrite_action(action)\n\n\ndef _normalize_apex_id_targets(\n bundle_parsed: dict, apex_by_id_rows: list[dict],\n) -> None:\n \"\"\"Rewrite action invocationTargets that are Apex (01p-prefix Id)\n -> ApexClass Name.\n\n Gap B fix (2026-05-05): when GenAiFunctionDefinition.InvocationTarget\n stores a 01p-prefix ApexClass Id instead of a class Name (observed in\n live my-org-alias org, e.g. 01p000000000000AAA ->\n MyController), downstream Wave B body retrieval enqueues\n the Id as-is into `_pending_fetches.APEX` and the tree renders the raw\n Id instead of the class name.\n\n Parallel to `_normalize_prompt_template_id_targets`: build an Id ->\n Name map from `fetch_apex_bodies_by_ids` rows (ApexClass uses `Name`,\n not `DeveloperName`), mutate the bundle in place. Unmatched Ids stay\n as-is (genuinely missing class, or the by-Id fetch failed).\n \"\"\"\n id_to_name: dict[str, str] = {}\n for row in apex_by_id_rows or []:\n row_id = row.get(\"Id\")\n class_name = row.get(\"Name\")\n if row_id and class_name:\n id_to_name[row_id] = class_name\n\n if not id_to_name:\n return\n\n def _rewrite_action(action: dict) -> None:\n target = action.get(\"invocationTarget\")\n ttype = (action.get(\"invocationTargetType\") or \"\").strip().lower()\n # Match the same ttype predicate used by _route for apex targets.\n # Mirrors the prompt-template normalizer's ttype gate so a Flow\n # action whose target happens to collide with an ApexClass Id\n # (unlikely but possible) isn't miscaught.\n is_apex_ttype = ttype in (\"apex\", \"apexaction\") or ttype.startswith(\"apex\")\n if not is_apex_ttype or not isinstance(target, str):\n return\n # Prefix-gate the rewrite to the ApexClass key prefix (01p).\n # Mirrors the `target[:3].lower() == \"01p\"` check in _collect_wave_b_targets.\n if target[:3].lower() != \"01p\":\n return\n resolved = id_to_name.get(target)\n if resolved:\n action[\"_original_invocation_target_id\"] = target\n action[\"invocationTarget\"] = resolved\n\n for topic in bundle_parsed.get(\"topics\") or []:\n for action in topic.get(\"actions\") or []:\n _rewrite_action(action)\n for action in bundle_parsed.get(\"plannerActions\") or []:\n _rewrite_action(action)\n\n\n# ---------------------------------------------------------------------------\n# Signature derivation — Apex + Flow (rendered in Section 7 of architecture.md)\n# ---------------------------------------------------------------------------\n\n\ndef _derive_apex_signature(apex_row: dict) -> str | None:\n \"\"\"Return a compact one-line signature string for an Apex class, or None.\n\n Sourced from `SymbolTable.methods[]` on the ApexClass Tooling row. Picks\n the \"primary\" method:\n 1. Prefer a method annotated `@InvocableMethod` (what Flow / Agentforce\n actually invoke — typically exactly one per Agentforce-relevant\n class).\n 2. Else prefer a `global` or `public static` non-constructor method.\n 3. Else fall back to the first method in SymbolTable source order.\n\n Format: `\u003cmodifiers> \u003creturnType> \u003cname>(\u003cp1_type> \u003cp1_name>, ...)`. If\n `returnType` is absent (constructor shape) it's omitted. Parameters are\n rendered as `\u003ctype> \u003cname>`; empty param list becomes `()`.\n\n Returns None when `SymbolTable` is missing/empty or contains no method\n candidates. Body parsing is intentionally NOT attempted — too brittle.\n \"\"\"\n if not isinstance(apex_row, dict):\n return None\n symbol_table = apex_row.get(\"SymbolTable\")\n if not isinstance(symbol_table, dict):\n return None\n methods = symbol_table.get(\"methods\")\n if not isinstance(methods, list) or not methods:\n return None\n\n def _is_invocable(m: dict) -> bool:\n anns = m.get(\"annotations\") or []\n for ann in anns:\n if isinstance(ann, dict):\n if (ann.get(\"name\") or \"\").lower() == \"invocablemethod\":\n return True\n elif isinstance(ann, str):\n if ann.lower() == \"invocablemethod\":\n return True\n return False\n\n def _is_constructor(m: dict) -> bool:\n # SymbolTable represents constructors via `constructors[]`, but some\n # payloads fold them into `methods[]` with a null/empty returnType.\n rt = m.get(\"returnType\")\n return not rt\n\n def _is_promoted(m: dict) -> bool:\n mods = [str(x).lower() for x in (m.get(\"modifiers\") or [])]\n if _is_constructor(m):\n return False\n if \"global\" in mods:\n return True\n if \"public\" in mods and \"static\" in mods:\n return True\n return False\n\n candidates = [m for m in methods if isinstance(m, dict)]\n if not candidates:\n return None\n\n primary: dict | None = next((m for m in candidates if _is_invocable(m)), None)\n if primary is None:\n primary = next((m for m in candidates if _is_promoted(m)), None)\n if primary is None:\n # Last-resort: first method. Skip if it's purely a private/local helper\n # with no chance of being the public entry point — those add noise.\n primary = candidates[0]\n mods = [str(x).lower() for x in (primary.get(\"modifiers\") or [])]\n if \"private\" in mods and not _is_invocable(primary):\n return None\n\n modifiers = [str(x) for x in (primary.get(\"modifiers\") or [])]\n return_type = primary.get(\"returnType\") or \"\"\n name = primary.get(\"name\") or \"\"\n if not name:\n return None\n\n params_out: list[str] = []\n for p in primary.get(\"parameters\") or []:\n if not isinstance(p, dict):\n continue\n p_type = p.get(\"type\") or \"\"\n p_name = p.get(\"name\") or \"\"\n if p_type and p_name:\n params_out.append(f\"{p_type} {p_name}\")\n elif p_type:\n params_out.append(p_type)\n elif p_name:\n params_out.append(p_name)\n params_str = \", \".join(params_out)\n\n parts: list[str] = []\n if modifiers:\n parts.append(\" \".join(modifiers))\n if return_type:\n parts.append(return_type)\n parts.append(f\"{name}({params_str})\")\n return \" \".join(parts)\n\n\ndef _derive_flow_signature(flow_metadata_record: dict) -> str | None:\n \"\"\"Return a compact signature string for a Flow, or None.\n\n Sourced from `Metadata.variables[]` on the Tooling Flow record\n (`fetch_flow_metadata` output — the `flow_metadata_record` is the\n full Tooling row, and `.Metadata` is the decoded complexvalue dict).\n\n Flow variables carry boolean `isInput` / `isOutput` flags. A variable\n with `isInput: true` contributes to the `in:` side; `isOutput: true`\n contributes to the `out:` side; a variable can set both (round-trip)\n and will appear on BOTH sides. Variables with neither flag are locals\n and are skipped.\n\n Format: `in: \u003cname>: \u003ctype>[, ...] | out: \u003cname>: \u003ctype>[, ...]`.\n If only one side has entries, only that side is emitted. Returns None\n when neither side has entries.\n\n Collection variables render as `List\u003c\u003cdataType>>`. sObject variables\n use the `objectType` field (e.g. `List\u003cCase>` rather than\n `List\u003cSObject>`). Insertion order from `variables[]` is preserved —\n we deliberately do not sort, to keep the signature aligned with the\n flow author's declared order.\n \"\"\"\n if not isinstance(flow_metadata_record, dict):\n return None\n md = flow_metadata_record.get(\"Metadata\")\n if not isinstance(md, dict):\n # Test convenience: accept a bare Metadata dict, not just the full row.\n if \"variables\" in flow_metadata_record:\n md = flow_metadata_record\n else:\n return None\n\n variables = md.get(\"variables\") or []\n if not isinstance(variables, list):\n return None\n\n def _render_type(var: dict) -> str:\n data_type = (var.get(\"dataType\") or \"\").strip()\n obj_type = (var.get(\"objectType\") or \"\").strip()\n apex_class = (var.get(\"apexClass\") or \"\").strip()\n dt_lower = data_type.lower()\n # sObject variables carry the concrete SObject name in `objectType`\n # (e.g. `Case`). Apex-typed variables carry the concrete class name\n # in `apexClass` (e.g. `CX_OrgSelector.Org`). Without the apexClass\n # branch, collections of Apex-typed values render as the useless\n # `List\u003cApex>` instead of `List\u003cCX_OrgSelector.Org>`.\n if dt_lower == \"sobject\" and obj_type:\n base = obj_type\n elif dt_lower == \"apex\" and apex_class:\n base = apex_class\n else:\n base = data_type or \"Object\"\n if var.get(\"isCollection\"):\n return f\"List\u003c{base}>\"\n return base\n\n inputs: list[str] = []\n outputs: list[str] = []\n for v in variables:\n if not isinstance(v, dict):\n continue\n name = v.get(\"name\")\n if not name:\n continue\n rendered = f\"{name}: {_render_type(v)}\"\n if v.get(\"isInput\"):\n inputs.append(rendered)\n if v.get(\"isOutput\"):\n outputs.append(rendered)\n\n if not inputs and not outputs:\n return None\n\n parts: list[str] = []\n if inputs:\n parts.append(\"in: \" + \", \".join(inputs))\n if outputs:\n parts.append(\"out: \" + \", \".join(outputs))\n return \" | \".join(parts)\n\n\ndef _stamp_signatures(\n node: dict,\n apex_sigs: dict[str, str],\n flow_sigs: dict[str, str],\n flow_reasons: dict[str, str] | None = None,\n) -> None:\n \"\"\"Recursively stamp `node[\"signature\"]` on matching APEX / FLOW leaves.\n\n Shared utility flows / helpers that appear in multiple branches all\n receive the same stamped signature — they point at the same\n FlowDefinition / ApexClass, so the signature is the same.\n\n Gap 2 fix (2026-05-05): when a FLOW leaf has no signature but *does*\n have an explanation in `flow_reasons` (e.g. managed-pkg body not\n retrievable via the Data API fallback), stamp `node[\"_signature_reason\"]`\n so the renderer can distinguish a silent hole from a known limitation.\n \"\"\"\n if not isinstance(node, dict):\n return\n kind = node.get(\"kind\")\n name = node.get(\"api_name\")\n if kind == \"APEX\" and name and name in apex_sigs:\n node[\"signature\"] = apex_sigs[name]\n elif kind == \"FLOW\" and name:\n if name in flow_sigs:\n node[\"signature\"] = flow_sigs[name]\n elif flow_reasons and name in flow_reasons:\n node[\"_signature_reason\"] = flow_reasons[name]\n for child in node.get(\"children\") or []:\n _stamp_signatures(child, apex_sigs, flow_sigs, flow_reasons)\n\n\n# ---------------------------------------------------------------------------\n# Gap C (2026-05-05): prompt template body retrieve + stamp\n# ---------------------------------------------------------------------------\n\n\ndef _is_prompt_ttype(ttype: str) -> bool:\n \"\"\"Mirror the ttype predicate used by `_route` /\n `_normalize_prompt_template_id_targets` so collection + rewrite +\n retrieve all recognize the same action kind.\"\"\"\n t = (ttype or \"\").strip().lower()\n return (\n t in (\"prompttemplate\", \"generatepromptresponse\")\n or t.startswith(\"prompt\")\n or t.startswith(\"genai\")\n )\n\n\ndef _collect_prompt_template_names(bundle_parsed: dict) -> set[str]:\n \"\"\"Walk topics + plannerActions, collect prompt-template DeveloperNames.\n\n Called AFTER `_normalize_prompt_template_id_targets` — any 0hf-prefix\n Id that resolved via Wave B's Id-list-metadata should already be a\n DeveloperName. Defensive: drop anything that still looks Id-shaped\n (0hf prefix) so we don't send a malformed\n `GenAiPromptTemplate:0hfUv...` spec to the retrieve CLI.\n \"\"\"\n names: set[str] = set()\n\n def _collect(action: dict) -> None:\n ttype = action.get(\"invocationTargetType\") or \"\"\n if not _is_prompt_ttype(ttype):\n return\n target = action.get(\"invocationTarget\")\n if not isinstance(target, str) or not target:\n return\n if target[:3].lower() == \"0hf\":\n # Id never got rewritten — the list_metadata lookup missed it\n # (managed-pkg template, permission issue). Skip; it stays in\n # _pending_fetches with the Id as the key.\n return\n names.add(target)\n\n for topic in bundle_parsed.get(\"topics\") or []:\n for action in topic.get(\"actions\") or []:\n _collect(action)\n for action in bundle_parsed.get(\"plannerActions\") or []:\n _collect(action)\n return names\n\n\ndef _collect_flow_nested_prompt_template_names(\n node: dict,\n already_retrieved: dict[str, dict],\n out: set[str],\n) -> None:\n \"\"\"Walk the finalized tree and collect PROMPT_TEMPLATE leaves whose\n body wasn't stamped during the bundle-scoped retrieve.\n\n Bundle-scoped `_collect_prompt_template_names` only sees prompt\n templates declared as top-level topic/planner InvocationTargets.\n Prompt templates referenced from inside a Flow (via a\n `generatePromptResponse` actionCall) are discovered later during\n Wave B's BFS of flow XML and end up as PROMPT_TEMPLATE leaves in the\n tree — but their names never reached the first retrieve, so their\n `_body_available` stays False and content is silently lost.\n\n Defensive: drop 0hf-Id-shape names (managed-pkg templates whose Id\n never rewrote to a DeveloperName) — sending\n `GenAiPromptTemplate:0hfUv...` to the retrieve CLI yields a\n malformed spec; the Id stays in `_pending_fetches.PROMPT_TEMPLATE`\n as intended.\n \"\"\"\n if not isinstance(node, dict):\n return\n if node.get(\"kind\") == \"PROMPT_TEMPLATE\":\n name = node.get(\"api_name\")\n if (\n isinstance(name, str)\n and name\n and name[:3].lower() != \"0hf\"\n and not node.get(\"_body_available\", False)\n ):\n body = already_retrieved.get(name)\n if not (body and body.get(\"content\")):\n out.add(name)\n for child in node.get(\"children\") or []:\n _collect_flow_nested_prompt_template_names(child, already_retrieved, out)\n\n\ndef _stamp_prompt_template_bodies(\n node: dict,\n bodies: dict[str, dict],\n) -> None:\n \"\"\"Recursively stamp prompt-template body fields onto matching\n PROMPT_TEMPLATE leaves.\n\n For each leaf whose `api_name` matches a key in `bodies`, attach:\n - master_label, content, inputs, active_version_identifier\n - _body_available = True\n\n Leaves with no matching body get `_body_available = False` so the\n renderer can distinguish \"no body retrieved\" from \"body successfully\n empty\". Shared templates appearing in multiple branches receive the\n same stamped body (they reference the same DeveloperName).\n \"\"\"\n if not isinstance(node, dict):\n return\n kind = node.get(\"kind\")\n name = node.get(\"api_name\")\n if kind == \"PROMPT_TEMPLATE\" and name:\n body = bodies.get(name)\n if body:\n node[\"master_label\"] = body.get(\"masterLabel\")\n node[\"content\"] = body.get(\"content\")\n node[\"inputs\"] = body.get(\"inputs\") or []\n node[\"active_version_identifier\"] = body.get(\n \"activeVersionIdentifier\"\n )\n node[\"_body_available\"] = True\n else:\n node[\"_body_available\"] = False\n for child in node.get(\"children\") or []:\n _stamp_prompt_template_bodies(child, bodies)\n\n\n# ---------------------------------------------------------------------------\n# Phase 9: build declared_action_tree.json via parse_wave primitives\n# ---------------------------------------------------------------------------\n\n\ndef _run_parse_wave(\n bot_info: dict,\n bundle_parsed: dict,\n wave_b: dict,\n args: argparse.Namespace,\n work_dir: Path,\n) -> dict:\n \"\"\"Invoke parse_wave's init_tree + root-children builder in-process.\n\n parse_wave was written to be invoked as a subprocess with env vars\n carrying AGENT_API_NAME / AGENT_VERSION / BOT_ID / etc. For Phase 2\n Batch 1 we set those env vars transiently and call the module's\n public helpers — no subprocess overhead, same behavior.\n \"\"\"\n import parse_wave\n\n # parse_wave reads these via os.environ.\n prior_env = {\n \"AGENT_API_NAME\": os.environ.get(\"AGENT_API_NAME\"),\n \"AGENT_VERSION\": os.environ.get(\"AGENT_VERSION\"),\n \"BOT_ID\": os.environ.get(\"BOT_ID\"),\n \"BOT_MASTER_LABEL\": os.environ.get(\"BOT_MASTER_LABEL\"),\n \"VERSION_AUTO_PICKED\": os.environ.get(\"VERSION_AUTO_PICKED\"),\n }\n os.environ[\"AGENT_API_NAME\"] = args.agent_api_name\n os.environ[\"AGENT_VERSION\"] = bot_info[\"version\"]\n os.environ[\"BOT_ID\"] = bot_info[\"bot_id\"]\n os.environ[\"BOT_MASTER_LABEL\"] = bot_info[\"master_label\"]\n os.environ[\"VERSION_AUTO_PICKED\"] = \"true\" if bot_info[\"version_auto_picked\"] else \"false\"\n\n try:\n # Persist the bundle for parse_wave's finalize_cap path.\n bundle_out = work_dir / \"_bundle_parsed.json\"\n bundle_out.write_text(json.dumps(bundle_parsed, indent=2))\n\n # Persist a fake _bot_definition.json so init_tree reads real\n # metadata for the agent block.\n bd_file = work_dir / \"_bot_definition.json\"\n bd_file.write_text(json.dumps({\n \"result\": {\"records\": [bot_info.get(\"bot_definition\") or {}]},\n }))\n\n tree = parse_wave.init_tree(work_dir, bundle_parsed)\n\n # Build root children + track bundle-derived BFS refs.\n # call the public `empty_kind_sets` /\n # `BFS_KINDS` symbols — reaching into `parse_wave._*` reopened the\n # leading-underscore surface that closed for `redact_text`.\n visited_by_kind = parse_wave.empty_kind_sets()\n aux_visited: set[tuple[str, str]] = set()\n children, bundle_refs = parse_wave.build_root_children(\n bundle_parsed, visited_by_kind, aux_visited,\n )\n tree[\"root\"][\"children\"] = children\n\n # Merge Flow metadata into flow_children. parse_wave wants this as\n # `{flow_name: [child_refs]}`. Build it from the in-memory\n # `Flow.Metadata` JSON Wave B already fetched — the Tooling REST\n # API decodes the `Metadata` complexvalue field into a plain dict,\n # so no XML parsing is needed (see `_build_flow_children` +\n # fixture `CLASSIC_FLOW_METADATA`). Without this, every FLOW leaf\n # in `metadata_tree.json` shipped with `children: []` and the\n # user's recursive-tree view was flat.\n flow_children = _build_flow_children(\n wave_b[\"flow_metadata\"], wave_b[\"flow_def_rows\"],\n )\n\n # BFS routing for bundle refs.\n pending_by_kind, _cycles = parse_wave.bfs_step(\n parse_wave.empty_kind_sets(), visited_by_kind, bundle_refs,\n )\n\n # Depth-cap accumulator threaded through inflate.\n depth_cap_pending = parse_wave.empty_kind_sets()\n parse_wave.walk_and_inflate(\n tree[\"root\"], flow_children, pending_out=depth_cap_pending,\n )\n\n # Merge any Apex / Flow we actually fetched into visited — they\n # no longer count as pending.\n for row in wave_b[\"apex_rows\"]:\n nm = row.get(\"Name\")\n if nm:\n visited_by_kind[\"APEX\"].add(nm)\n for row in wave_b[\"flow_def_rows\"]:\n nm = row.get(\"DeveloperName\")\n if nm:\n visited_by_kind[\"FLOW\"].add(nm)\n # Gap C (2026-05-05): prompt templates whose bodies we retrieved\n # count as visited and must drop out of _pending_fetches.\n # Templates the retrieve didn't return (permission / managed-pkg /\n # retrieve failure) stay out of `visited` so they surface there,\n # mirroring the Flow-by-name \"no rows = stays pending\" pattern.\n prompt_template_bodies = wave_b.get(\"prompt_template_bodies\") or {}\n for name in prompt_template_bodies:\n if name:\n visited_by_kind[\"PROMPT_TEMPLATE\"].add(name)\n\n # Stamp Apex / Flow signatures onto every matching tree node so\n # render_architecture.py can populate Section 7. Wave B already\n # fetched SymbolTable (Apex) + Metadata (Flow); we just project it\n # onto `{kind, api_name}` leaves here. Shared utility flows /\n # helpers that appear in multiple positions all receive the same\n # stamped signature — that's the correct behavior (same definition).\n apex_sigs: dict[str, str] = {}\n for row in wave_b[\"apex_rows\"]:\n nm = row.get(\"Name\")\n if not nm:\n continue\n sig = _derive_apex_signature(row)\n if sig:\n apex_sigs[nm] = sig\n\n flow_sigs: dict[str, str] = {}\n # Gap 2 fix (2026-05-05): explain silent signature holes. Managed\n # flows come back from `fetch_flow_definition_ids_by_names` via the\n # FlowDefinitionView fallback with _source=\"FlowDefinitionView\",\n # ActiveVersionId=None, _body_available=False — by design, since\n # managed-pkg flow bodies are IP-protected. Previously every such\n # row produced an unexplained \"_Signature not captured._\" in the\n # rendered markdown. Now we surface the structural reason.\n #\n # For the generic \"no usable metadata record\" case (Tooling row\n # present but flow_metadata missing — e.g. metadata fetch failed\n # or the flow has no active version at all) we also stamp a reason\n # so the renderer never silently drops the node.\n flow_reasons: dict[str, str] = {}\n flow_metadata_by_vid = wave_b.get(\"flow_metadata\") or {}\n for row in wave_b[\"flow_def_rows\"]:\n dev_name = row.get(\"DeveloperName\") or row.get(\"_bare_developer_name\")\n if not dev_name:\n continue\n source = row.get(\"_source\") or \"\"\n if source == \"FlowDefinitionView\":\n ns = row.get(\"NamespacePrefix\") or \"\"\n reason = (\n f\"managed-package (ns={ns}); body not retrievable via Tooling/Metadata API\"\n if ns\n else \"managed-package; body not retrievable via Tooling/Metadata API\"\n )\n flow_reasons[dev_name] = reason\n continue\n active_ver = row.get(\"ActiveVersionId\")\n if not active_ver:\n flow_reasons[dev_name] = \"no active version\"\n continue\n md_record = flow_metadata_by_vid.get(active_ver)\n if not md_record:\n flow_reasons[dev_name] = \"flow metadata fetch returned no record\"\n continue\n sig = _derive_flow_signature(md_record)\n if sig:\n flow_sigs[dev_name] = sig\n else:\n flow_reasons[dev_name] = \"flow metadata has no in/out params\"\n\n _stamp_signatures(tree[\"root\"], apex_sigs, flow_sigs, flow_reasons)\n\n # Gap C: stamp retrieved prompt-template bodies onto the tree.\n # Leaves with a matching body gain content/master_label/inputs;\n # leaves without get `_body_available = False` so the renderer\n # can distinguish \"retrieve missed\" from \"body truly absent\".\n _stamp_prompt_template_bodies(tree[\"root\"], prompt_template_bodies)\n\n for kind in parse_wave.BFS_KINDS:\n pending_by_kind[kind] |= depth_cap_pending.get(kind, set())\n\n depth_cap_tripped = any(depth_cap_pending[k] for k in parse_wave.BFS_KINDS)\n\n tree[\"_pending_fetches\"] = {\n k: sorted(pending_by_kind.get(k, set()) - visited_by_kind.get(k, set()))\n for k in parse_wave.BFS_KINDS\n }\n\n any_pending = any(tree[\"_pending_fetches\"][k] for k in parse_wave.BFS_KINDS)\n if depth_cap_tripped:\n tree[\"_partial\"] = True\n tree[\"_partial_reason\"] = \"max-depth-cap\"\n elif any_pending:\n tree[\"_partial\"] = True\n if not tree.get(\"_partial_reason\"):\n tree[\"_partial_reason\"] = \"pending-refs\"\n else:\n tree[\"_partial\"] = False\n tree[\"_partial_reason\"] = None\n\n # Surface Wave-B-level unresolved items (e.g. unknown ID prefixes).\n for u in wave_b.get(\"unresolved\", []) or []:\n tree.setdefault(\"_unresolved\", []).append(u)\n\n node_count, depth, kind_counts = parse_wave.compute_stats(tree[\"root\"])\n tree[\"node_count\"] = node_count\n tree[\"depth\"] = depth\n tree[\"_kind_counts\"] = kind_counts\n\n all_visited: set[tuple[str, str]] = set(aux_visited)\n for kind, names in visited_by_kind.items():\n for n in names:\n all_visited.add((kind, n))\n tree[\"_visited\"] = [list(v) for v in sorted(all_visited)]\n\n tree_path = work_dir / \"declared_action_tree.json\"\n tmp = tree_path.with_suffix(tree_path.suffix + \".tmp\")\n tmp.write_text(json.dumps(tree, indent=2))\n os.replace(tmp, tree_path)\n return tree\n finally:\n # Restore env so test runners that reuse the process don't leak\n # state across invocations.\n for k, v in prior_env.items():\n if v is None:\n os.environ.pop(k, None)\n else:\n os.environ[k] = v\n\n\n# ---------------------------------------------------------------------------\n# Phase 11: finalize\n# ---------------------------------------------------------------------------\n\n\ndef _swap_dir_atomic(target: Path, staging: Path) -> None:\n \"\"\"Per-file overwrite from `staging` into `target` — preserves any\n unrelated sibling content already present in `target`.\n\n P0 fix (2026-05-05): the prior implementation did\n ``os.replace(target, backup); os.replace(staging, target)`` — an\n atomic whole-directory swap that was correct in isolation but\n DESTRUCTIVE of co-tenants. The `\u003cagent>__\u003cver>/` dir is co-tenanted:\n co-tenants write per-session subdirs\n (`\u003csession_id>/`) alongside architecture's files. Swapping the whole\n directory silently wiped every cached session trace.\n\n New behavior: iterate `staging`'s immediate children and `os.replace`\n each one into `target`. The directory `target` itself is never\n moved or deleted, so any session subdirs under it survive.\n\n Trade-off: the old helper supported atomic rollback (restore from\n backup on failure). The per-file pattern cannot — a crash partway\n through the loop leaves architecture's output in a split state\n (some new files, some old). This is acceptable because:\n - per-file `os.replace` is still atomic (no empty-path window\n for any individual file, so the \"concurrent reader sees\n empty dir\" bug that addressed is still closed);\n - `last_built_at.txt` is written LAST by the caller, so\n consumers can treat its presence as the success sentinel;\n - architecture is re-runnable, co-tenant session traces are not.\n\n Callers must still guarantee `staging` is on the same filesystem as\n `target` (helper constructs `staging` as a sibling of `target`, so\n that holds by construction).\n \"\"\"\n import shutil\n\n if not staging.is_dir():\n raise OSError(f\"staging dir missing: {staging}\")\n\n target.mkdir(parents=True, exist_ok=True)\n\n for src in staging.iterdir():\n dst = target / src.name\n # Clear any existing destination so `os.replace` lands cleanly\n # when the incoming type differs (file replacing dir or vice\n # versa). For same-type overwrites `os.replace` would handle\n # files on its own, but directories require an empty target or\n # same-name existing dir — safer to unify both here.\n if dst.exists() or dst.is_symlink():\n if dst.is_dir() and not dst.is_symlink():\n shutil.rmtree(dst)\n else:\n dst.unlink()\n os.replace(src, dst)\n\n # Staging should be empty now; best-effort teardown.\n try:\n staging.rmdir()\n except OSError:\n shutil.rmtree(staging, ignore_errors=True)\n\n\ndef _run_finalize(\n data_dir: Path,\n cache_dir: Path,\n tree: dict,\n work_dir: Path,\n agent_api_name: str,\n agent_version: str,\n planner_name: str,\n) -> None:\n \"\"\"Atomic-write tree + summary + manifest. Keep it simple.\n\n For Phase 2 Batch 1 we replicate finalize.py's essential behavior\n (tree + manifest) but skip the sf_meta mirror — there's no\n sf_meta dir when main.py runs the pipeline SOQL-first. Metadata-API\n retrieves land in Batch 2/3.\n\n both `data_dir` and `cache_dir` are swapped\n via `_swap_dir_atomic` to close the \"rmtree then rename\" empty-dir\n window. A crash during the second swap restores the original from\n the staging-sibling `.\u003cname>.backup.\u003cpid>` rather than leaving an\n empty path.\n \"\"\"\n import datetime as dt\n import shutil\n\n built_at = dt.datetime.now(dt.timezone.utc).isoformat().replace(\"+00:00\", \"Z\")\n tree_base = f\"{agent_api_name}_{agent_version}_metadata_tree\"\n\n # Finalize's _partial computation. Mirrors finalize.py:109-117 — both\n # buckets (_pending_fetches AND _unresolved) must be empty for a\n # clean run. _unresolved captures wave-B failures (HTTP 4xx,\n # iteration-cap exhaustion, managed-flow filter misses) that have\n # already been moved out of _pending_fetches; checking only the\n # latter would silently call a partial run \"converged\".\n planner_ok = bool(planner_name)\n pending_total = sum(len(v) for v in (tree.get(\"_pending_fetches\") or {}).values())\n unresolved_count = len(tree.get(\"_unresolved\") or [])\n waves_converged = pending_total == 0 and unresolved_count == 0\n tree[\"_partial\"] = not (planner_ok and waves_converged)\n tree.pop(\"_visited\", None)\n\n # Pin deterministic child ordering as the last assembly step before\n # the authoritative write. Shared with the standalone `finalize.py`\n # subprocess so both paths produce byte-identical trees. Downstream\n # readers see one canonical order from disk; they do not re-sort.\n from finalize import sort_tree_in_place\n sort_tree_in_place(tree.get(\"root\") or {})\n\n # staging names are siblings (same parent) so the\n # `os.replace` used by `_swap_dir_atomic` is a same-filesystem atomic\n # rename. `.staging.\u003cpid>` disambiguates concurrent pipelines on the\n # same host.\n data_parent = data_dir.parent\n data_staging = data_parent / f\".{data_dir.name}.staging.{os.getpid()}\"\n if data_staging.exists():\n shutil.rmtree(data_staging)\n data_staging.mkdir(parents=True)\n tree_out = data_staging / f\"{tree_base}.json\"\n tree_out.write_text(json.dumps(tree, indent=2))\n\n # P2.2-1: phase 10 (architecture.md renderer). Imported lazily so a\n # renderer bug can't break the existing Batch 1 pipeline before the\n # tree JSON has been written — the tree + manifest remain the\n # authoritative outputs, architecture.md is a derived artifact.\n #\n # filename now self-identifying —\n # `{agent_api_name}_{agent_version}_architecture.md` (e.g.\n # `MyAgent_v5_architecture.md`). Same shape as the tree JSON,\n # minus the `_metadata_tree` suffix. The `.error` sidecar follows\n # the same shape.\n arch_base = f\"{agent_api_name}_{agent_version}\"\n arch_name = f\"{arch_base}_architecture.md\"\n arch_error_name = f\"{arch_base}_architecture.md.error\"\n try:\n from render_architecture import render as _render_architecture\n _render_architecture(tree_out, data_staging / arch_name)\n except Exception as e:\n # Best-effort: a render failure downgrades the run to \"tree OK,\n # architecture skipped\" rather than aborting finalize. The\n # reason string surfaces in the next-run logs for triage.\n (data_staging / arch_error_name).write_text(\n f\"render_architecture failed: {type(e).__name__}: {e}\\n\"\n )\n\n # dropped `{tree_base}.summary.md` from the output contract —\n # was a redundant summary of the tree JSON; consumers should read the\n # JSON directly. See CHANGELOG for details.\n (data_staging / \"last_built_at.txt\").write_text(built_at + \"\\n\")\n\n _swap_dir_atomic(data_dir, data_staging)\n\n # CACHE_DIR staging — same two-phase swap.\n cache_parent = cache_dir.parent\n cache_staging = cache_parent / f\".{cache_dir.name}.staging.{os.getpid()}\"\n if cache_staging.exists():\n shutil.rmtree(cache_staging)\n cache_staging.mkdir(parents=True)\n\n manifest = {\n \"built_at_utc\": built_at,\n \"schema_version\": tree.get(\"_schema_version\", SCHEMA_VERSION),\n \"agent\": tree.get(\"agent\", {}),\n \"node_count\": tree.get(\"node_count\", 0),\n \"depth\": tree.get(\"depth\", 0),\n \"kind_counts\": tree.get(\"_kind_counts\", {}),\n \"ttl_days\": CACHE_TTL_DAYS,\n \"data_path\": str(data_dir / f\"{tree_base}.json\"),\n \"partial\": tree.get(\"_partial\", False),\n \"unresolved_count\": len(tree.get(\"_unresolved\", []) or []),\n }\n (cache_staging / \"manifest.json\").write_text(json.dumps(manifest, indent=2))\n # Copy tree + bundle sidecar for cache replay\n (cache_staging / \"declared_action_tree.json\").write_text(json.dumps(tree, indent=2))\n bundle_src = work_dir / \"_bundle_parsed.json\"\n if bundle_src.is_file():\n (cache_staging / \"_bundle_parsed.json\").write_text(bundle_src.read_text())\n\n _swap_dir_atomic(cache_dir, cache_staging)\n\n\n# ---------------------------------------------------------------------------\n# Emit helpers\n# ---------------------------------------------------------------------------\n\n\ndef _read_built_at_utc(cache_dir: Path | None) -> str:\n if cache_dir is None:\n return \"\"\n try:\n return json.loads((cache_dir / \"manifest.json\").read_text()).get(\"built_at_utc\", \"\") or \"\"\n except (OSError, ValueError):\n return \"\"\n\n\ndef _emit_fail(\n args: argparse.Namespace,\n extra_ctx: dict | None,\n status: str,\n error_detail: str = \"\",\n start_epoch: float | None = None,\n) -> int:\n \"\"\"Write an .emit_ctx.json with STATUS=\u003cstatus>.\n\n STATUS enum (from emit_result.py):\n OK, PARTIAL_OK, INVALID_INPUT, AUTH_REQUIRED, AGENT_NOT_FOUND,\n AGENT_VERSION_NOT_FOUND, RETRIEVE_FAILED, WRITE_FAILED.\n\n For probe failures, the plan's description suggested SCHEMA_DRIFT\n but emit_result doesn't carry that enum value. We map probe failures\n to RETRIEVE_FAILED with an explicit error_detail — the RESULT block's\n ERROR_DETAIL carries the \"schema-drift\" reason. Adding a new enum\n value would require touching emit_result (out of scope P1 primitive);\n the mapping preserves emit_result's existing contract.\n\n Returns the exit code the caller should return.\n \"\"\"\n work_dir = Path(args.work_dir)\n work_dir.mkdir(parents=True, exist_ok=True)\n ctx = {\n \"status\": status,\n \"error_detail\": error_detail,\n \"agent_api_name\": args.agent_api_name,\n \"agent_version\": args.agent_version or \"\",\n \"version_auto_picked\": False,\n \"agent_generation\": \"unknown\",\n \"bot_id\": \"\",\n \"org_id_15\": \"\",\n \"org_id_18\": \"\",\n \"cache_hit\": False,\n \"cached_at_utc\": \"\",\n \"cache_path\": \"\",\n \"output_json_path\": \"\",\n \"output_summary_path\": \"\",\n \"node_count\": 0,\n \"depth\": 0,\n \"partial\": status == \"PARTIAL_OK\",\n \"partial_reason\": \"\",\n \"pending_fetches_count\": 0,\n \"unresolved_count\": 0,\n \"available_bots\": \"\",\n \"available_versions\": \"\",\n # Bug fix (2026-05-05): start_epoch captured once at pipeline entry\n # and threaded through every _emit_* helper. Previously each helper\n # re-captured time.time() AFTER the pipeline had already run, so\n # emit_result.py computed WALL_TIME_SECONDS against a timestamp taken\n # ~tens of seconds AFTER the real work finished and reported ~0.0s.\n \"start_epoch\": start_epoch if start_epoch is not None else time.time(),\n \"data_dir\": str(DATA_ROOT),\n \"work_dir\": str(work_dir),\n # failure paths never invoke the renderer.\n \"architecture_path\": \"\",\n \"render_failed\": False,\n \"render_error_detail\": \"\",\n }\n if extra_ctx:\n ctx.update(extra_ctx)\n (work_dir / \".emit_ctx.json\").write_text(json.dumps(ctx, indent=2))\n return 1\n\n\ndef _emit_cache_hit(\n args: argparse.Namespace,\n data_dir: Path,\n cache_dir: Path,\n manifest: dict,\n start_epoch: float | None = None,\n *,\n org_id_15: str = \"\",\n org_id_18: str = \"\",\n) -> int:\n work_dir = Path(args.work_dir)\n work_dir.mkdir(parents=True, exist_ok=True)\n agent_version = manifest.get(\"agent\", {}).get(\"version\", \"\")\n tree_base = f\"{args.agent_api_name}_{agent_version}_metadata_tree\"\n\n # on a cache hit the renderer is NOT re-invoked — but the\n # architecture.md from the prior run is a legitimate output of the\n # cached data_dir. Surface it when present, skip the .error sidecar\n # since render_failed semantics only apply to THIS run's renderer.\n # filename rename to `{agent}_{ver}_architecture.md` —\n # NB `tree_base` above still carries the `_metadata_tree` suffix (it\n # names the JSON); architecture filenames drop the suffix.\n arch_base = f\"{args.agent_api_name}_{agent_version}\"\n arch_path = data_dir / f\"{arch_base}_architecture.md\"\n architecture_path = str(arch_path) if arch_path.is_file() else \"\"\n\n ctx = {\n \"status\": \"OK\",\n \"error_detail\": \"\",\n \"agent_api_name\": args.agent_api_name,\n \"agent_version\": manifest.get(\"agent\", {}).get(\"version\", \"\"),\n \"version_auto_picked\": manifest.get(\"agent\", {}).get(\"_version_auto_picked\", False),\n \"agent_generation\": manifest.get(\"agent\", {}).get(\"generation\", \"unknown\"),\n \"bot_id\": manifest.get(\"agent\", {}).get(\"bot_id\", \"\"),\n \"org_id_15\": org_id_15,\n \"org_id_18\": org_id_18,\n \"cache_hit\": True,\n \"cached_at_utc\": manifest.get(\"built_at_utc\", \"\"),\n \"cache_path\": str(cache_dir),\n \"output_json_path\": str(data_dir / f\"{tree_base}.json\"),\n # summary.md dropped from the output contract. Field kept\n # empty for RESULT-block shape stability.\n \"output_summary_path\": \"\",\n \"node_count\": int(manifest.get(\"node_count\", 0) or 0),\n \"depth\": int(manifest.get(\"depth\", 0) or 0),\n \"partial\": bool(manifest.get(\"partial\", False)),\n \"partial_reason\": \"\",\n \"pending_fetches_count\": 0,\n \"unresolved_count\": int(manifest.get(\"unresolved_count\", 0) or 0),\n \"available_bots\": \"\",\n \"available_versions\": \"\",\n # Bug fix (2026-05-05): see _emit_fail note — start_epoch is captured\n # at pipeline entry and threaded through. Cache-hit paths are short-\n # running so the drift is smaller, but the field should still reflect\n # the invocation's real wall time.\n \"start_epoch\": start_epoch if start_epoch is not None else time.time(),\n \"data_dir\": str(data_dir),\n \"work_dir\": str(work_dir),\n # architecture-render outcome signals. render_failed\n # stays False for cache replays — that flag tracks THIS run's\n # renderer, not the cached run's; a cache hit implies no retry.\n \"architecture_path\": architecture_path,\n \"render_failed\": False,\n \"render_error_detail\": \"\",\n }\n (work_dir / \".emit_ctx.json\").write_text(json.dumps(ctx, indent=2))\n return 0\n\n\ndef _emit_ok(\n args: argparse.Namespace,\n data_dir: Path,\n tree: dict,\n bot_info: dict,\n org_id_15: str,\n start_epoch: float | None = None,\n cache_dir: Path | None = None,\n org_id_18: str = \"\",\n) -> int:\n work_dir = Path(args.work_dir)\n work_dir.mkdir(parents=True, exist_ok=True)\n agent_version = bot_info[\"version\"]\n tree_base = f\"{args.agent_api_name}_{agent_version}_metadata_tree\"\n pending_total = sum(len(v) for v in (tree.get(\"_pending_fetches\") or {}).values())\n\n # surface architecture-render outcome on the success path.\n # finalize writes either `{agent}_{ver}_architecture.md` (render OK) or\n # `{agent}_{ver}_architecture.md.error` (render raised) into the\n # data_dir; we read whichever is present and plumb the fields through\n # the ctx. emit_result consumes the boolean to auto-promote\n # OK -> PARTIAL_OK when the tree landed but the diagram render failed.\n # filename rename — self-identifying shape. `tree_base` above\n # is for the JSON path (carries `_metadata_tree`); architecture uses\n # the unsuffixed agent+version base.\n arch_base = f\"{args.agent_api_name}_{agent_version}\"\n arch_path = data_dir / f\"{arch_base}_architecture.md\"\n arch_sidecar = data_dir / f\"{arch_base}_architecture.md.error\"\n architecture_path = str(arch_path) if arch_path.is_file() else \"\"\n render_failed = arch_sidecar.is_file()\n render_error_detail = \"\"\n if render_failed:\n try:\n raw = arch_sidecar.read_text(errors=\"replace\").strip()\n except OSError:\n raw = \"\"\n if raw:\n # Single-line cap, 200-char truncation, scrubbed via the\n # rest_client redactor (same contract as write_emit_ctx).\n from rest_client import redact_text as _redact\n render_error_detail = _redact(raw.splitlines()[0])[:200]\n\n ctx = {\n \"status\": \"PARTIAL_OK\" if tree.get(\"_partial\") else \"OK\",\n \"error_detail\": \"\",\n \"agent_api_name\": args.agent_api_name,\n \"agent_version\": bot_info[\"version\"],\n \"version_auto_picked\": bot_info[\"version_auto_picked\"],\n \"agent_generation\": tree.get(\"agent\", {}).get(\"generation\", \"unknown\"),\n \"bot_id\": bot_info[\"bot_id\"],\n \"org_id_15\": org_id_15,\n # Gap 3 fix (2026-05-05): the 18-char id is available from\n # `sf org display` in _derive_org_ids but was previously dropped.\n # Now threaded from pipeline entry so the RESULT contract field is\n # actually populated on success.\n \"org_id_18\": org_id_18,\n \"cache_hit\": False,\n \"cached_at_utc\": _read_built_at_utc(cache_dir),\n # Gap 4 fix (2026-05-05): non-cache-hit runs write the manifest into\n # `cache_dir` (see _run_finalize) so the field should point at the\n # same path a subsequent invocation would probe. Caller passes the\n # already-computed cache_dir; we skip the leading-empty fallback\n # only when it's truly unknown (shouldn't happen on _emit_ok).\n \"cache_path\": str(cache_dir) if cache_dir is not None else \"\",\n \"output_json_path\": str(data_dir / f\"{tree_base}.json\"),\n # summary.md dropped from the output contract.\n \"output_summary_path\": \"\",\n \"node_count\": int(tree.get(\"node_count\", 0) or 0),\n \"depth\": int(tree.get(\"depth\", 0) or 0),\n \"partial\": bool(tree.get(\"_partial\", False)),\n \"partial_reason\": tree.get(\"_partial_reason\") or \"\",\n \"pending_fetches_count\": pending_total,\n \"unresolved_count\": len(tree.get(\"_unresolved\", []) or []),\n \"available_bots\": \"\",\n \"available_versions\": \"\",\n # Bug fix (2026-05-05): see _emit_fail note — start_epoch threaded\n # from pipeline entry so WALL_TIME_SECONDS reflects the full run.\n \"start_epoch\": start_epoch if start_epoch is not None else time.time(),\n \"data_dir\": str(data_dir),\n \"work_dir\": str(work_dir),\n # architecture-render outcome signals.\n \"architecture_path\": architecture_path,\n \"render_failed\": render_failed,\n \"render_error_detail\": render_error_detail,\n }\n (work_dir / \".emit_ctx.json\").write_text(json.dumps(ctx, indent=2))\n return 0\n\n\n# ---------------------------------------------------------------------------\n# Entry point\n# ---------------------------------------------------------------------------\n\n\ndef _run_pipeline(args: argparse.Namespace, start_epoch: float) -> int: # noqa: PLR0911, PLR0915\n \"\"\"Execute the 12-phase pipeline. Extracted from `main()` so the\n top-level entry point can wrap any uncaught exception in a RESULT\n block .\n\n Every structured failure path here already routes through\n `_emit_fail` and returns a non-zero exit code. Exceptions that\n escape this function are, by definition, bugs we hadn't thought of\n yet — `main()` catches them so the skill contract (RESULT block on\n every exit path) holds regardless.\n \"\"\"\n work_dir = Path(args.work_dir)\n work_dir.mkdir(parents=True, exist_ok=True)\n\n # Phase 2 Batch 1: single creds cell shared between provider + refresh.\n # Refresh mutates the cell; provider reads it on every attempt — this\n # is the `` refresh contract.\n #\n # Wave B fetches run N concurrent Flow.Metadata\n # calls through parallel_retrieve.fetch_bodies_parallel. `refresh_fn`\n # serializes behind a lock + 1-second monotonic dedupe window so\n # concurrent 401s don't each fork their own `sf org display`. See\n # `_build_creds_plumbing`.\n try:\n initial = _resolve_creds(args.org_alias)\n except (AuthRequired, SfCliError) as e:\n return _emit_fail(\n args, None, \"AUTH_REQUIRED\",\n error_detail=redact_error(e),\n start_epoch=start_epoch,\n )\n\n creds_provider, refresh_fn, _creds_cell = _build_creds_plumbing(\n initial,\n resolve_creds=lambda: _resolve_creds(args.org_alias),\n )\n\n # Phase 2.5: derive org_id_15 + org_id_18 + api_version\n try:\n org_id_15, org_id_18, api_version = _derive_org_ids(args.org_alias)\n except (AuthRequired, SfCliError) as e:\n return _emit_fail(\n args, None, \"AUTH_REQUIRED\",\n error_detail=redact_error(e),\n start_epoch=start_epoch,\n )\n\n # Phase 3: probe channels\n channels = probe_channels(\n args.org_alias, org_id_15, api_version,\n force_refresh=args.reprobe,\n )\n if channels.get(\"status\") == \"PROBE_FAILED\":\n # Plan suggested STATUS=SCHEMA_DRIFT; emit_result's STATUS enum\n # doesn't include that value. Mapping to RETRIEVE_FAILED with a\n # \"schema-drift\" ERROR_DETAIL preserves the existing enum\n # contract while surfacing the cause. A future batch may add\n # SCHEMA_DRIFT to the enum; that change lives in emit_result.\n missing = [\n (sobj, e.get(\"mandatory_missing\", []))\n for sobj, e in (channels.get(\"channels\") or {}).items()\n if e.get(\"mandatory_missing\")\n ]\n return _emit_fail(\n args, None, \"RETRIEVE_FAILED\",\n error_detail=f\"schema-drift: mandatory fields missing: {missing[:3]}\",\n start_epoch=start_epoch,\n )\n\n # Phase 4: resolve bot + version\n try:\n bot_info = _resolve_bot(\n args.agent_api_name, args.agent_version,\n creds_provider, refresh_fn,\n api_version=api_version,\n )\n except (RestClientError, SoqlParamError) as e:\n return _emit_fail(\n args, None, \"AUTH_REQUIRED\",\n error_detail=redact_error(e),\n start_epoch=start_epoch,\n )\n\n if bot_info is None:\n # AGENT_NOT_FOUND — emit without a list-of-available-bots context.\n # The unbounded bot-list SELECT was removed (LIMIT-less unfiltered\n # query on BotDefinition). Operators can run their own SOQL if\n # they need to enumerate available bots.\n return _emit_fail(\n args,\n {\"org_id_15\": org_id_15},\n \"AGENT_NOT_FOUND\",\n error_detail=f\"BotDefinition.DeveloperName '{args.agent_api_name}' not found\",\n start_epoch=start_epoch,\n )\n\n if bot_info.get(\"_version_not_found\"):\n vers_csv = \",\".join(\n f\"{v['version']}({v['status']})\" for v in bot_info.get(\"all_versions\", [])\n )\n return _emit_fail(\n args,\n {\"available_versions\": vers_csv, \"org_id_15\": org_id_15},\n \"AGENT_VERSION_NOT_FOUND\",\n error_detail=f\"No matching BotVersion (requested: {args.agent_version!r})\",\n start_epoch=start_epoch,\n )\n\n # Phase 5: cache check\n data_dir = build_agent_data_dir(org_id_15, args.agent_api_name, bot_info[\"version\"])\n cache_dir = build_agent_cache_dir(org_id_15, args.agent_api_name, bot_info[\"version\"])\n if not args.force_refresh:\n manifest = _cache_is_fresh(cache_dir)\n if manifest is not None:\n return _emit_cache_hit(\n args, data_dir, cache_dir, manifest,\n start_epoch=start_epoch,\n org_id_15=org_id_15,\n org_id_18=org_id_18,\n )\n\n # Phase 6: Wave A — 7 queries, DAG-ordered parallel layers. A1 now\n # takes (agent_api_name, version) and resolves the canonical planner\n # DeveloperName from the live chain naming invariant (v1 = \u003cAgent>,\n # vN = \u003cAgent>...%\\_vN); the row's DeveloperName becomes the\n # authoritative `planner_name` for finalize/render downstream.\n try:\n wave_a = _run_wave_a(\n args.agent_api_name, bot_info[\"version\"],\n creds_provider, refresh_fn,\n api_version=api_version,\n parallelism=args.parallelism,\n )\n except (RestClientError, SoqlParamError) as e:\n return _emit_fail(\n args, None, \"RETRIEVE_FAILED\",\n error_detail=f\"wave-a-failed:{redact_error(e)}\",\n start_epoch=start_epoch,\n )\n\n if wave_a is None:\n return _emit_fail(\n args, {\"org_id_15\": org_id_15},\n \"AGENT_NOT_FOUND\",\n error_detail=(\n f\"GenAiPlannerDefinition chain not found for \"\n f\"agent={args.agent_api_name!r} version={bot_info['version']!r}\"\n ),\n start_epoch=start_epoch,\n )\n\n # Phase 7: join Wave A → bundle-shaped dict\n bundle_parsed = _join_wave_a_to_bundle(bot_info, wave_a)\n\n # Phase 8: Wave B — body fetches\n try:\n wave_b = _run_wave_b(\n bundle_parsed,\n creds_provider, refresh_fn,\n api_version=api_version,\n org_alias=args.org_alias,\n parallelism=args.parallelism,\n )\n except (RestClientError, SoqlParamError) as e:\n return _emit_fail(\n args, None, \"RETRIEVE_FAILED\",\n error_detail=f\"wave-b-failed:{redact_error(e)}\",\n start_epoch=start_epoch,\n )\n\n # Phase 8.5: iterative Wave B — follow subflow + apex refs from fetched\n # Flow.Metadata bodies until fixed-point. Without this pass, shared\n # utility flows (handleFlowFault) and nested subflows ship as empty\n # leaves even though their bodies are reachable via Tooling API.\n # Iteration cap safeguards against pathological flow graphs.\n try:\n wave_b = _iterate_wave_b(\n wave_b,\n creds_provider, refresh_fn,\n api_version=api_version,\n org_alias=args.org_alias,\n parallelism=args.parallelism,\n max_iterations=5,\n )\n except (RestClientError, SoqlParamError) as e:\n return _emit_fail(\n args, None, \"RETRIEVE_FAILED\",\n error_detail=f\"wave-b-iter-failed:{redact_error(e)}\",\n start_epoch=start_epoch,\n )\n\n # Surface Wave-A layer-2/3 task failures alongside Wave-B's so the\n # downstream `_run_parse_wave` sweep folds them into `tree[\"_unresolved\"]`\n # and `_partial_reason` flips to PARTIAL_OK.\n wave_a_unresolved = wave_a.get(\"unresolved\") or []\n if wave_a_unresolved:\n wave_b[\"unresolved\"] = [*wave_a_unresolved, *(wave_b.get(\"unresolved\") or [])]\n\n # normalize Flow-ID InvocationTargets → DeveloperName.\n # Classic ReAct bots occasionally store NGA-style 300Uv-prefix Flow IDs\n # in GenAiFunctionDefinition.InvocationTarget (observed in one real-org\n # benchmark run, 6 flows out of 6). Wave B's fetch_flow_definition_by_ids\n # successfully resolves them, but the resolved DeveloperName never\n # propagates back into bundle_parsed — so parse_wave sees the raw ID as\n # the flow's api_name, which never matches visited[\"FLOW\"] (keyed on\n # DeveloperName), and every such flow ends up in _pending_fetches.\n # Result: PARTIAL_OK on what should be OK, plus unreadable Flow IDs in\n # rendered architecture.md.\n #\n # Fix: rewrite bundle_parsed's action invocationTarget in-place from\n # ID → DeveloperName using wave_b[\"flow_def_rows\"] (both the IN-by-names\n # and the IN-by-ids branches populate it). Unresolved IDs (Flow not\n # found in the org) stay as-is and will land in _pending_fetches as\n # intended (they really are unresolved).\n _normalize_flow_id_targets(bundle_parsed, wave_b[\"flow_def_rows\"])\n\n # Bug 1 fix (2026-05-05): analogous rewrite for GenAiPromptTemplate Ids.\n # Wave B's prompt-template Id fetch resolves 0hf-prefix Ids to\n # DeveloperNames; without this rewrite the bundle would enqueue the\n # raw Id into _pending_fetches.PROMPT_TEMPLATE and the downstream\n # Metadata retrieve would never match.\n _normalize_prompt_template_id_targets(\n bundle_parsed, wave_b.get(\"prompt_template_id_rows\") or [],\n )\n\n # Gap B fix (2026-05-05): analogous rewrite for ApexClass Ids.\n # Wave B's fetch_apex_bodies_by_ids resolves 01p-prefix Ids to ApexClass\n # Name; without this rewrite the bundle would enqueue the raw Id into\n # _pending_fetches.APEX and the tree would render the bare Id instead\n # of the class name.\n _normalize_apex_id_targets(\n bundle_parsed, wave_b.get(\"apex_rows\") or [],\n )\n\n # Gap C fix (2026-05-05): retrieve GenAiPromptTemplate bodies.\n # GenAiPromptTemplate is NOT SOQL-queryable (Tooling or Data API). The\n # only way to fetch the body is `sf project retrieve start\n # --metadata GenAiPromptTemplate:\u003cname>,...`. After\n # _normalize_prompt_template_id_targets has rewritten 0hf-Ids to\n # DeveloperNames, collect the names and retrieve in a single sf\n # subprocess call. The resulting bodies get stashed on wave_b so\n # _run_parse_wave can stamp them onto PROMPT_TEMPLATE leaves (parallel\n # to the Apex/Flow signature stamping pattern).\n prompt_template_names = _collect_prompt_template_names(bundle_parsed)\n prompt_template_bodies: dict[str, dict] = {}\n if prompt_template_names:\n try:\n prompt_template_bodies = retrieve_prompt_templates(\n args.org_alias,\n sorted(prompt_template_names),\n work_dir,\n )\n except AuthRequired:\n raise\n except SfCliError as e:\n wave_b.setdefault(\"unresolved\", []).append({\n \"kind\": \"PROMPT_TEMPLATE\",\n \"reason\": f\"prompt-template-retrieve-failed:{redact_error(e)}\",\n })\n wave_b[\"prompt_template_bodies\"] = prompt_template_bodies\n\n # Phase 9: parse_wave -> declared_action_tree.json\n try:\n tree = _run_parse_wave(bot_info, bundle_parsed, wave_b, args, work_dir)\n except (OSError, KeyError) as e:\n return _emit_fail(\n args, None, \"WRITE_FAILED\",\n error_detail=f\"parse-wave-failed:{redact_error(e)}\",\n start_epoch=start_epoch,\n )\n\n # Phase 10: render architecture.md — invoked inline from _run_finalize\n # so the write lands in the same staging dir as the tree JSON and\n # participates in the atomic _swap_dir_atomic below. See P2.2-1.\n\n # Flow-nested prompt-template catch-up (2026-05-05). The bundle-scoped\n # retrieve at step 1 above only covers prompt templates declared as\n # top-level topic/planner InvocationTargets. Templates referenced from\n # inside a Flow (via a `generatePromptResponse` actionCall) are\n # discovered during parse_wave's BFS of flow XML — too late for the\n # first retrieve. Without this second pass their `_body_available`\n # stays False and the md renders `_Body not retrieved._` with no\n # PARTIAL flag (silent content loss). `_stamp_prompt_template_bodies`\n # is idempotent, so re-stamping already-stamped leaves is safe.\n flow_nested_names: set[str] = set()\n _collect_flow_nested_prompt_template_names(\n tree[\"root\"], prompt_template_bodies, flow_nested_names,\n )\n if flow_nested_names:\n try:\n more_bodies = retrieve_prompt_templates(\n args.org_alias,\n sorted(flow_nested_names),\n work_dir,\n )\n except AuthRequired:\n raise\n except SfCliError as e:\n tree.setdefault(\"_unresolved\", []).append({\n \"kind\": \"PROMPT_TEMPLATE\",\n \"reason\": f\"prompt-template-retrieve-failed:{redact_error(e)}\",\n })\n else:\n prompt_template_bodies.update(more_bodies)\n _stamp_prompt_template_bodies(tree[\"root\"], prompt_template_bodies)\n # _kind_counts / _pending_fetches are unchanged: the leaves\n # already existed and were counted; this pass only attaches\n # body content to them. No BFS-kind reshuffling needed.\n\n # Phase 11: finalize. The canonical planner_name now comes from the\n # resolved planner row (Wave A), which joins into bundle_parsed as\n # `plannerName`; fall back to the agent api name if the resolver\n # returned a row without a DeveloperName (shouldn't happen on a\n # healthy planner but keeps finalize's _partial flag well-defined).\n planner_name = bundle_parsed.get(\"plannerName\") or args.agent_api_name\n try:\n _run_finalize(\n data_dir, cache_dir, tree, work_dir,\n args.agent_api_name, bot_info[\"version\"],\n planner_name,\n )\n except OSError as e:\n return _emit_fail(\n args, None, \"WRITE_FAILED\",\n error_detail=f\"finalize-failed:{redact_error(e)}\",\n start_epoch=start_epoch,\n )\n\n # Phase 12: emit RESULT\n return _emit_ok(\n args, data_dir, tree, bot_info, org_id_15,\n start_epoch=start_epoch,\n cache_dir=cache_dir,\n org_id_18=org_id_18,\n )\n\n\ndef main(argv: list[str] | None = None) -> int:\n \"\"\"Entry point — parses args, runs pipeline, guarantees a RESULT block.\n\n the skill contract promises that every exit\n path emits `=== RESULT ===` with a structured STATUS. The prior\n implementation let any uncaught exception from the pipeline (e.g.\n the HTTP 405 from , or any future bug we haven't foreseen)\n propagate to the top of the process — the user saw a Python\n traceback on stderr and the wrapper skill never got its RESULT\n block. We now wrap `_run_pipeline` in a broad `except Exception` and\n funnel uncaught failures through `_emit_fail` with\n STATUS=RETRIEVE_FAILED + ERROR_DETAIL=\"uncaught-exception: \u003credacted\n message>\". `redact_error` keeps tokens / full-URL querystrings out\n of the ctx file. `SystemExit` + `KeyboardInterrupt` are NOT caught —\n argparse's `--help` path + Ctrl-C must propagate normally.\n \"\"\"\n args = parse_args(argv)\n # MUST happen before any pipeline phase reads DATA_ROOT / CACHE_ROOT.\n # See _apply_path_overrides docstring for the 3-level rebind contract.\n _apply_path_overrides(args)\n # Bug fix (2026-05-05): capture start_epoch ONCE at real pipeline entry\n # and thread it through every _emit_* call so WALL_TIME_SECONDS measures\n # actual pipeline duration, not ctx-write→emit_result drift.\n start_epoch = time.time()\n try:\n return _run_pipeline(args, start_epoch)\n except Exception as exc:\n # broad catch — anything that reaches here is a bug\n # but the skill contract requires a structured exit. redact_error\n # strips bearer tokens and accessToken values from the message\n # ; the exception class name leads the detail so triage\n # can still identify the shape even when the message is terse.\n redacted = redact_error(exc)\n return _emit_fail(\n args,\n None,\n \"RETRIEVE_FAILED\",\n error_detail=f\"uncaught-exception: {redacted}\",\n start_epoch=start_epoch,\n )\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":116626,"content_sha256":"c51abf79df28a7950b1037133a14cd04b425a4063398ba60f3777b244d3735d6"},{"filename":"scripts/metadata_listing.py","content":"\"\"\"Metadata API listMetadata wrappers — for sObjects that are NOT\nexposed via SOQL on Tooling or Data API (e.g. GenAiPromptTemplate).\n\nWraps the sf CLI `sf org list metadata` recipe. Not a REST client call —\nuses subprocess via sf_cli.run_sf. Failure semantics mirror the rest:\nreturns [] on known-missing + surfaces unresolved reasons upstream;\nraises SfCliError / AuthRequired on unknown failures.\n\"\"\"\nfrom __future__ import annotations\n\nimport subprocess\nimport xml.etree.ElementTree as ET\nimport zipfile\nfrom pathlib import Path\nfrom typing import Any, Dict, List\n\nfrom rest_client import redact_error\nfrom sf_cli import (\n AuthRequired,\n _redact_subprocess_stderr,\n _stderr_matches_auth,\n run_sf,\n)\n\n\n# Metadata XML default namespace — same as parse_bundle.NS. Duplicated\n# (rather than imported) to keep the \"no intra-skill imports\" convention\n# documented throughout the pipeline.\n_NS = {\"sf\": \"http://soap.sforce.com/2006/04/metadata\"}\n\n\ndef list_prompt_template_metadata(org_alias: str) -> List[Dict[str, str]]:\n \"\"\"Return every GenAiPromptTemplate metadata component in the org.\n\n Each row has the shape produced by `sf org list metadata --json`:\n {\"id\": \"0hfUv...\", \"fullName\": \"AGNT_...\", \"type\": \"GenAiPromptTemplate\",\n \"namespacePrefix\": \"...\", ...}\n\n Empty list on success with no templates. Raises SfCliError on CLI\n failure (caller decides whether to treat as fatal).\n \"\"\"\n data = run_sf(\"list_metadata_genaiprompttemplate\", ORG_ALIAS=org_alias)\n result = data.get(\"result\")\n if not isinstance(result, list):\n return []\n return [r for r in result if isinstance(r, dict)]\n\n\ndef _find_text(el: ET.Element | None, path: str) -> str | None:\n if el is None:\n return None\n found = el.find(path, _NS)\n if found is None:\n return None\n return found.text\n\n\ndef _parse_prompt_template_xml(xml_bytes: bytes) -> Dict[str, Any] | None:\n \"\"\"Parse a single .genAiPromptTemplate XML file into a body dict.\n\n Returns None on parse failure so the caller can log + skip. The\n returned dict carries developerName/masterLabel/activeVersionIdentifier/\n content/inputs — absent optional fields become None or empty lists.\n\n Version selection: if multiple \u003ctemplateVersions> elements exist,\n prefer the one whose `versionIdentifier` matches the template-level\n `activeVersionIdentifier`; otherwise fall back to the first version.\n \"\"\"\n try:\n root = ET.fromstring(xml_bytes)\n except ET.ParseError:\n return None\n\n developer_name = _find_text(root, \"sf:developerName\")\n master_label = _find_text(root, \"sf:masterLabel\")\n active_version = _find_text(root, \"sf:activeVersionIdentifier\")\n\n versions = root.findall(\"sf:templateVersions\", _NS)\n picked: ET.Element | None = None\n if versions:\n if active_version:\n for v in versions:\n vid = _find_text(v, \"sf:versionIdentifier\")\n if vid and vid == active_version:\n picked = v\n break\n if picked is None:\n picked = versions[0]\n\n content = _find_text(picked, \"sf:content\") if picked is not None else None\n\n inputs: List[Dict[str, str]] = []\n if picked is not None:\n for inp in picked.findall(\"sf:inputs\", _NS):\n name = _find_text(inp, \"sf:apiName\") or _find_text(inp, \"sf:name\")\n data_type = _find_text(inp, \"sf:dataType\")\n if name is None and data_type is None:\n continue\n entry: Dict[str, str] = {}\n if name is not None:\n entry[\"name\"] = name\n if data_type is not None:\n entry[\"dataType\"] = data_type\n inputs.append(entry)\n\n return {\n \"developerName\": developer_name,\n \"masterLabel\": master_label,\n \"activeVersionIdentifier\": active_version,\n \"content\": content,\n \"inputs\": inputs,\n }\n\n\n# Timeout for `sf project retrieve start` — matches the prior YAML recipe's\n# `timeout_seconds: 300`. Kept as a module constant (rather than a literal\n# at the call site) so future adjustments have a single home.\n_RETRIEVE_TIMEOUT_SECONDS = 300\n\n# stderr patterns that mean \"the CLI ran but there is no authenticated org\n# for this alias\" — reraised as AuthRequired. Mirrors the prior recipe's\n# `auth_required_stderr_patterns` field.\n_RETRIEVE_AUTH_PATTERNS = (\"NoOrgAuthenticationError\", \"AuthInfoError\")\n\n\ndef _run_retrieve(argv: List[str], timeout: int) -> \"subprocess.CompletedProcess[str]\":\n \"\"\"Thin wrapper over `subprocess.run` for the retrieve-start call.\n\n Exists so tests can mock the subprocess boundary without also mocking\n the argv-construction logic — see `RetrievePromptTemplatesTests`.\n \"\"\"\n return subprocess.run(\n argv,\n capture_output=True,\n text=True,\n timeout=timeout,\n check=False,\n )\n\n\ndef retrieve_prompt_templates(\n org_alias: str,\n template_names: List[str],\n work_dir: Path,\n) -> Dict[str, Dict[str, Any]]:\n \"\"\"Retrieve GenAiPromptTemplate bodies via `sf project retrieve start`.\n\n The sf plugin's `--metadata` flag is REPEATED once per requested\n template (`--metadata GenAiPromptTemplate:A --metadata\n GenAiPromptTemplate:B ...`). A comma-joined value is NOT a valid list;\n the CLI treats the whole comma-joined string as a single malformed\n member name, silently skips it, and the resulting zip contains only\n `package.xml` (observed 2026-05-05 against my-org-alias). Hence we\n bypass the YAML recipe loader here — `run_sf` substitutes\n `{{METADATA_SPEC}}` single-shot and has no list-expansion primitive.\n\n The resulting `unpackaged.zip` carries one `.genAiPromptTemplate` XML\n file per template under `unpackaged/genAiPromptTemplates/`.\n\n Returns a dict keyed by `developerName`. Templates the retrieve\n didn't produce a file for are OMITTED (caller handles missing by\n leaving them in `_pending_fetches`).\n\n Failure modes:\n - Empty `template_names` → short-circuits with `{}` and no sf call.\n - `AuthRequired` (stderr matches auth patterns) → re-raised.\n - `SfCliError` (any other non-zero exit, timeout, etc) → swallowed;\n returns `{}`. Caller logs the retrieval-failure reason at the\n call site.\n - Zip missing or malformed → returns `{}`.\n - XML parse failure on a specific file → that file is skipped; the\n other templates in the same zip still parse and return.\n \"\"\"\n if not template_names:\n return {}\n\n retrieve_dir = work_dir / \"prompt_template_retrieve\"\n retrieve_dir.mkdir(parents=True, exist_ok=True)\n # Nuke stale files from a prior invocation so we don't trust an old\n # unpackaged.zip if this run's sf call fails before writing one.\n for path in retrieve_dir.iterdir():\n if path.is_file():\n path.unlink()\n elif path.is_dir():\n _rm_tree(path)\n\n argv: List[str] = [\n \"sf\",\n \"project\",\n \"retrieve\",\n \"start\",\n \"--target-org\",\n org_alias,\n \"--target-metadata-dir\",\n str(retrieve_dir),\n \"--json\",\n ]\n for name in template_names:\n argv.extend([\"--metadata\", f\"GenAiPromptTemplate:{name}\"])\n\n try:\n cp = _run_retrieve(argv, _RETRIEVE_TIMEOUT_SECONDS)\n except subprocess.TimeoutExpired as e:\n # timeout exceptions may carry stderr; route through\n # redact_error before surfacing — then swallow as non-fatal, same\n # contract the old run_sf-based path had for SfCliError.\n _ = redact_error(e)\n return {}\n except (OSError, subprocess.SubprocessError) as e:\n _ = redact_error(e)\n return {}\n\n if cp.returncode != 0:\n # Classify: auth failure re-raises, everything else is non-fatal.\n if _stderr_matches_auth(cp.stderr, _RETRIEVE_AUTH_PATTERNS):\n safe_stderr = _redact_subprocess_stderr(cp.stderr)\n raise AuthRequired(safe_stderr or \"auth required\")\n return {}\n\n zip_path = retrieve_dir / \"unpackaged.zip\"\n if not zip_path.exists():\n return {}\n\n results: Dict[str, Dict[str, Any]] = {}\n try:\n with zipfile.ZipFile(zip_path) as zf:\n for info in zf.infolist():\n name = info.filename\n if not name.endswith(\".genAiPromptTemplate\"):\n continue\n if \"genAiPromptTemplates/\" not in name:\n continue\n try:\n xml_bytes = zf.read(info)\n except (KeyError, zipfile.BadZipFile):\n continue\n body = _parse_prompt_template_xml(xml_bytes)\n if not body:\n continue\n dev_name = body.get(\"developerName\")\n if not dev_name:\n # Fall back to the file stem if the XML somehow lacks\n # a \u003cdeveloperName> element — keeps the caller's\n # lookup working.\n stem = Path(name).stem\n if stem:\n body[\"developerName\"] = stem\n dev_name = stem\n if dev_name:\n results[dev_name] = body\n except zipfile.BadZipFile:\n return {}\n\n return results\n\n\ndef _rm_tree(path: Path) -> None:\n \"\"\"Recursively delete a directory tree. stdlib-only, no shutil import\n at module top to keep the import surface minimal.\"\"\"\n for child in path.iterdir():\n if child.is_dir():\n _rm_tree(child)\n else:\n child.unlink()\n path.rmdir()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9685,"content_sha256":"d5532903e685284a70aa77a7ac2de8b0d7afcf84acf017bbfd956bf74b95c939"},{"filename":"scripts/parallel_retrieve.py","content":"\"\"\"ThreadPoolExecutor orchestrator for Wave B body fetches.\n\none failure MUST NOT abort the whole run. Callers merge failed kinds\ninto `_unresolved[]` with `reason=f'{kind}-fetch-failed:{redact_error(exc)}'`\nand emit STATUS=PARTIAL_OK. We return a mixed list of (ok, result_or_exc)\ntuples instead of raising on the first failure — which is what\n`ThreadPoolExecutor.map` would do (and silently cancel remaining work in\nthe process).\n\nException identity is preserved on the failure path: callers get the exact\nexception object back so they can run it through `rest_client.redact_error`\nat the point of logging. We intentionally do NOT stringify here — doing so\nwould lose structured info (urllib.error.HTTPError.code, etc.) and could\nleak tokens into intermediate strings before redaction runs.\n\"\"\"\nfrom __future__ import annotations\n\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom typing import Callable\n\n\ndef fetch_bodies_parallel(\n tasks: list[Callable[[], object]],\n *,\n max_workers: int = 5,\n) -> list[tuple[bool, object]]:\n \"\"\"Run zero-arg callables in parallel; return (ok, result_or_exc) per task.\n\n contract:\n * Each `task` is a zero-arg callable (use `functools.partial` to bind\n args at the call site).\n * On success, `(True, return_value)` is appended.\n * On any exception, `(False, exc)` is appended — the EXC object itself,\n not a stringification. Callers run it through `rest_client.redact_error`\n before logging.\n * Results are returned in INPUT ORDER, not completion order. Callers\n frequently zip results back to their input tasks to identify which\n target failed; completion order would break that contract.\n * Empty task list returns `[]` without spinning up a pool.\n * `max_workers=1` serializes — tasks run sequentially but the as_completed\n path is still used (deterministic ordering preserved via index map).\n * This function NEVER raises on a task failure. It may propagate\n programmer errors (e.g., a non-callable in `tasks`) at submit time;\n that's a bug surface, not a runtime failure mode.\n \"\"\"\n if not tasks:\n return []\n\n # Pre-size the results list so we can assign by input index. This keeps\n # output ordering deterministic and independent of completion timing,\n # which matters for callers that identify failures positionally.\n results: list[tuple[bool, object] | None] = [None] * len(tasks)\n\n with ThreadPoolExecutor(max_workers=max_workers) as pool:\n future_to_idx = {pool.submit(task): i for i, task in enumerate(tasks)}\n for fut in as_completed(future_to_idx):\n idx = future_to_idx[fut]\n try:\n results[idx] = (True, fut.result())\n except Exception as exc: # noqa: BLE001 — see contract above\n # Preserve exc identity; do NOT stringify here. Callers run\n # this through rest_client.redact_error at log time .\n # We catch Exception (not BaseException) so KeyboardInterrupt\n # / SystemExit still propagate — those signal shutdown, not a\n # task failure.\n results[idx] = (False, exc)\n\n # All slots filled by construction — futures/indexes are 1:1 with input.\n return [r for r in results if r is not None] # type: ignore[return-value]\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3396,"content_sha256":"efaeda81cb1f5411ae07734c3bd7a2aa4aa730983e1aaa965ce519f0f2c24958"},{"filename":"scripts/parse_bundle.py","content":"#!/usr/bin/env python3\n\"\"\"Parse Bot.bot + GenAiPlannerBundle → _bundle_parsed.json + PLANNER_NAME.\n\nReplaces old agent Phase 2a (Bot.bot planner-name extraction via XPath-ish\nwalk) + Phase 2c (bundle XML → topics/plannerActions + generation classifier).\n\nStep 1 — planner name:\n Read $WORK_DIR/sf_meta/wave1_bot/unpackaged/bots/*.bot XML.\n Path: /Bot/botVersions[fullName==$AGENT_VERSION]/conversationDefinitionPlanners[0]\n /genAiPlannerName (fallback: plannerName, fullName)\n\nStep 2 — bundle (only if planner name resolved AND bundle dir exists):\n Read $WORK_DIR/sf_meta/wave1_bundle/unpackaged/genAiPlannerBundles/\u003cplannerName>/\n *.genAiPlannerBundle\n Extract: plannerType, description, masterLabel, localTopics[] with inline\n localActions[], and (classic-only) plannerActions[].\n Classify generation:\n plannerType startswith \"AiCopilot__\" → classic\n plannerType startswith \"Atlas__\" → nga\n else → unknown\n\nEmits:\n $WORK_DIR/_bundle_parsed.json (full structured extraction)\n $WORK_DIR/_agent_generation.txt (generation string, newline-terminated)\n stdout: `PLANNER_NAME=\u003cshlex-quoted>\\nAGENT_GENERATION=\u003cshlex-quoted>`\n + log lines (topic counts, action counts) for human readability.\n\nIf Bot.bot has no \u003cconversationDefinitionPlanners>, prints `PLANNER_NAME=''`\nand exits 0 — caller treats the tree as partial.\n\nUsage:\n eval \"$(python3 parse_bundle.py)\"\n\nInputs (env):\n WORK_DIR required\n AGENT_VERSION required — used to pin the right \u003cbotVersions> element\n\nOutputs:\n files (above)\n stdout eval-friendly K=V lines\n exit 0 always (no recoverable error condition; missing XML → empty planner)\n\"\"\"\nimport json\nimport os\nimport pathlib\nimport shlex\nimport sys\nimport xml.etree.ElementTree as ET\n\n\nNS = {\"sf\": \"http://soap.sforce.com/2006/04/metadata\"}\n\n\ndef _t(el, p):\n if el is None:\n return None\n x = el.find(p, NS)\n return x.text if x is not None else None\n\n\ndef classify_generation(planner_type: str) -> str:\n if not planner_type:\n return \"unknown\"\n if planner_type.startswith(\"AiCopilot__\"):\n return \"classic\"\n if planner_type.startswith(\"Atlas__\"):\n return \"nga\"\n return \"unknown\"\n\n\ndef extract_planner_name(work_dir: pathlib.Path, target_ver: str) -> str:\n bots_dir = work_dir / \"sf_meta\" / \"wave1_bot\" / \"unpackaged\" / \"bots\"\n if not bots_dir.exists():\n return \"\"\n for bot_file in bots_dir.glob(\"*.bot\"):\n try:\n root = ET.parse(bot_file).getroot()\n except ET.ParseError:\n continue\n for bv in root.findall(\"sf:botVersions\", NS):\n full = _t(bv, \"sf:fullName\") or _t(bv, \"sf:developerName\")\n if target_ver and full != target_ver:\n continue\n for cdp in bv.findall(\"sf:conversationDefinitionPlanners\", NS):\n n = (_t(cdp, \"sf:genAiPlannerName\")\n or _t(cdp, \"sf:plannerName\")\n or _t(cdp, \"sf:fullName\"))\n if n:\n return n\n return \"\"\n\n\ndef extract_bundle(work_dir: pathlib.Path, planner_name: str) -> dict:\n bundle = {\n \"plannerType\": None,\n \"plannerName\": planner_name or None,\n \"generation\": \"unknown\",\n \"description\": None,\n \"masterLabel\": None,\n \"topics\": [],\n \"plannerActions\": [],\n }\n\n planner_root = work_dir / \"sf_meta\" / \"wave1_bundle\" / \"unpackaged\" / \"genAiPlannerBundles\"\n if not planner_root.exists():\n return bundle\n\n for bundle_dir in planner_root.iterdir():\n if not bundle_dir.is_dir():\n continue\n bundle_xml = list(bundle_dir.glob(\"*.genAiPlannerBundle\"))\n if not bundle_xml:\n continue\n try:\n root = ET.parse(bundle_xml[0]).getroot()\n except ET.ParseError as e:\n sys.stderr.write(f\"parse_bundle.py: XML parse error on {bundle_xml[0]}: {e}\\n\")\n continue\n\n bundle[\"plannerType\"] = _t(root, \"sf:plannerType\")\n bundle[\"generation\"] = classify_generation(bundle[\"plannerType\"])\n bundle[\"description\"] = _t(root, \"sf:description\")\n bundle[\"masterLabel\"] = _t(root, \"sf:masterLabel\")\n\n for lt in root.findall(\"sf:localTopics\", NS):\n tname = _t(lt, \"sf:developerName\") or _t(lt, \"sf:fullName\")\n if not tname:\n continue\n topic = {\n \"name\": tname,\n \"localDeveloperName\": _t(lt, \"sf:localDeveloperName\"),\n \"masterLabel\": _t(lt, \"sf:masterLabel\"),\n \"description\": _t(lt, \"sf:description\"),\n \"canEscalate\": (_t(lt, \"sf:canEscalate\") or \"false\").lower() == \"true\",\n \"pluginType\": _t(lt, \"sf:pluginType\"),\n \"actions\": [],\n }\n for la in lt.findall(\"sf:localActions\", NS):\n aname = _t(la, \"sf:developerName\") or _t(la, \"sf:fullName\")\n if not aname:\n continue\n topic[\"actions\"].append({\n \"name\": aname,\n \"localDeveloperName\": _t(la, \"sf:localDeveloperName\"),\n \"masterLabel\": _t(la, \"sf:masterLabel\"),\n \"description\": _t(la, \"sf:description\"),\n \"invocationTarget\": _t(la, \"sf:invocationTarget\"),\n \"invocationTargetType\": (_t(la, \"sf:invocationTargetType\") or \"\").strip(),\n \"source\": _t(la, \"sf:source\"),\n })\n bundle[\"topics\"].append(topic)\n\n for pa in root.findall(\"sf:plannerActions\", NS):\n aname = _t(pa, \"sf:developerName\") or _t(pa, \"sf:fullName\")\n if not aname:\n continue\n bundle[\"plannerActions\"].append({\n \"name\": aname,\n \"localDeveloperName\": _t(pa, \"sf:localDeveloperName\"),\n \"masterLabel\": _t(pa, \"sf:masterLabel\"),\n \"description\": _t(pa, \"sf:description\"),\n \"invocationTarget\": _t(pa, \"sf:invocationTarget\"),\n \"invocationTargetType\": (_t(pa, \"sf:invocationTargetType\") or \"\").strip(),\n \"source\": _t(pa, \"sf:source\"),\n })\n break # first bundle dir is enough\n return bundle\n\n\ndef atomic_write(path: pathlib.Path, content: str) -> None:\n tmp = path.with_suffix(path.suffix + \".tmp\")\n tmp.write_text(content)\n os.replace(tmp, path)\n\n\ndef main() -> int:\n try:\n work_dir = pathlib.Path(os.environ[\"WORK_DIR\"])\n agent_version = os.environ[\"AGENT_VERSION\"]\n except KeyError as e:\n sys.stderr.write(f\"parse_bundle.py: missing env {e}\\n\")\n return 1\n\n # Step 1: planner name\n planner_name = extract_planner_name(work_dir, agent_version)\n\n # Step 2: bundle (only if planner resolved)\n bundle = extract_bundle(work_dir, planner_name) if planner_name else {\n \"plannerType\": None, \"plannerName\": None, \"generation\": \"unknown\",\n \"description\": None, \"masterLabel\": None, \"topics\": [], \"plannerActions\": [],\n }\n\n atomic_write(work_dir / \"_bundle_parsed.json\", json.dumps(bundle, indent=2))\n atomic_write(work_dir / \"_agent_generation.txt\", bundle[\"generation\"] + \"\\n\")\n\n # Log lines (stderr would be lost via eval; use stderr for logs not shell-consumed)\n total_actions = (\n sum(len(t[\"actions\"]) for t in bundle[\"topics\"]) + len(bundle[\"plannerActions\"])\n )\n sys.stderr.write(\n f\"[parse_bundle] plannerName={planner_name!r} \"\n f\"plannerType={bundle['plannerType']!r} generation={bundle['generation']}\\n\"\n f\"[parse_bundle] topics={len(bundle['topics'])} \"\n f\"topic-scope-actions={sum(len(t['actions']) for t in bundle['topics'])} \"\n f\"bundle-scope-actions={len(bundle['plannerActions'])} total={total_actions}\\n\"\n )\n\n # Eval-friendly stdout\n sys.stdout.write(f\"PLANNER_NAME={shlex.quote(planner_name)}\\n\")\n sys.stdout.write(f\"AGENT_GENERATION={shlex.quote(bundle['generation'])}\\n\")\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8135,"content_sha256":"38aeb3e8b23b56367e174496509e3f6d1f6ef28175a664cc42f92b7a434212c1"},{"filename":"scripts/parse_wave.py","content":"#!/usr/bin/env python3\n\"\"\"Idempotent wave parser — builds `$WORK_DIR/declared_action_tree.json`.\n\nReplaces old agent Phase 4 (wave-2 init) + Phase 5/6 re-parse + MAX_WAVE cap.\nOne script, one code path. The \"init vs re-parse\" distinction is keyed on the\npresence of `declared_action_tree.json` at call time.\n\nFlow:\n 1. Load bundle JSON, bot_definition JSON, existing tree (if any).\n 2. Walk every $WORK_DIR/sf_meta/*/unpackaged/ dir:\n - flows/*.flow: parse actionCalls → APEX/PROMPT_TEMPLATE/STANDARD_ACTION/\n UNKNOWN children; subflows → FLOW children. Record flow_children[flow_name].\n Mark (\"FLOW\", flow_name) visited.\n - classes/*.cls-meta.xml: mark (\"APEX\", name) visited.\n - genAiPromptTemplates/*.genAiPromptTemplate: mark (\"PROMPT_TEMPLATE\", name).\n 3. Build tree if missing (Phase-4 init):\n - agent metadata from bot_definition + bundle\n - root BOT_DEFINITION node with children = topics[] only\n (plannerActions[] retained in the bundle shape for back-compat but\n always empty — a planner never has direct functions, 2026-05-05)\n - each topic → TOPIC node with GEN_AI_FUNCTION children (from inline actions)\n - each GEN_AI_FUNCTION → unwraps_to + leaf child (Flow/Apex/Prompt/Std/Unk)\n 4. Inflate every FLOW leaf recursively (depth ≤ 6) with flow_children data.\n 5. Recompute `_pending_fetches` (names not yet in visited set).\n 6. Walk tree for `node_count`, `depth`, `_kind_counts`.\n 7. Atomic write.\n\nWith --finalize-cap: after loading tree, move all remaining _pending_fetches\nitems to _unresolved[] with reason \"max-wave-depth exceeded\".\n\nUsage:\n python3 parse_wave.py # init or re-parse\n python3 parse_wave.py --finalize-cap # drain _pending_fetches → _unresolved\n\nInputs (env):\n WORK_DIR, AGENT_API_NAME, AGENT_VERSION, BOT_ID, BOT_MASTER_LABEL,\n VERSION_AUTO_PICKED\n\nOutputs:\n $WORK_DIR/declared_action_tree.json (atomic write)\n stderr: log lines (node counts, pending counts, etc.)\n exit 0 on success, 1 on write failure\n\"\"\"\nimport json\nimport os\nimport pathlib\nimport sys\nimport xml.etree.ElementTree as ET\nfrom collections import Counter\n\n\nNS = {\"sf\": \"http://soap.sforce.com/2006/04/metadata\"}\n\n\ndef _t(el, p):\n if el is None:\n return None\n x = el.find(p, NS)\n return x.text if x is not None else None\n\n\ndef classify_bundle_action(action: dict) -> dict:\n \"\"\"Bundle-action classifier — duplicated in plan_wave.py per 'no\n intra-skill imports' convention. Matches old agent's Phase 3/4 logic.\n\n Returns (unwraps_dict, leaf_dict) as in the old agent Phase 4's\n action_node(). leaf_dict is the child appended under the GEN_AI_FUNCTION\n node; unwraps_dict is the compact form stored on the function itself.\n \"\"\"\n tgt = action.get(\"invocationTarget\")\n ttype = (action.get(\"invocationTargetType\") or \"\").lower()\n if not tgt:\n return None, None\n if ttype == \"flow\":\n return ({\"kind\": \"FLOW\", \"api_name\": tgt},\n {\"kind\": \"FLOW\", \"api_name\": tgt, \"children\": []})\n if ttype == \"apex\":\n return ({\"kind\": \"APEX\", \"api_name\": tgt},\n {\"kind\": \"APEX\", \"api_name\": tgt})\n if ttype == \"generatepromptresponse\" or ttype.startswith(\"prompt\") or ttype.startswith(\"genai\"):\n return ({\"kind\": \"PROMPT_TEMPLATE\", \"api_name\": tgt},\n {\"kind\": \"PROMPT_TEMPLATE\", \"api_name\": tgt})\n # Canonical field name is `invocation_type` (schema 3.1). Prior\n # versions wrote `raw_invocation_type` on bundle-sourced nodes and\n # `raw_action_type` on flow-actionCall-sourced nodes — downstream\n # readers (render_architecture._display_name, summarize_tree) fall\n # back to both legacy keys for one release.\n if ttype == \"standardinvocableaction\":\n return ({\"kind\": \"STANDARD_ACTION\", \"api_name\": tgt, \"invocation_type\": ttype},\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": tgt, \"invocation_type\": ttype})\n return ({\"kind\": \"UNKNOWN\", \"api_name\": tgt, \"invocation_type\": ttype},\n {\"kind\": \"UNKNOWN\", \"api_name\": tgt, \"invocation_type\": ttype})\n\n\ndef classify_action_call(at: str, an: str, element_name: str) -> dict:\n \"\"\"Flow actionCall classifier (old Phase 4 logic).\n\n Canonical field name is `invocation_type` (schema 3.1). Legacy readers\n still fall back to `raw_action_type` for one release.\n \"\"\"\n at = at or \"\"\n if at == \"apex\" and an:\n return {\"kind\": \"APEX\", \"element_name\": element_name, \"api_name\": an}\n if at == \"generatePromptResponse\" and an:\n return {\"kind\": \"PROMPT_TEMPLATE\", \"element_name\": element_name, \"api_name\": an}\n if at:\n # Any other non-empty actionType → STANDARD_ACTION; preserve raw.\n return {\"kind\": \"STANDARD_ACTION\", \"element_name\": element_name,\n \"api_name\": an or at, \"invocation_type\": at}\n return {\"kind\": \"UNKNOWN\", \"element_name\": element_name,\n \"api_name\": an or \"?\", \"invocation_type\": at or \"\"}\n\n\n# ---------------------------------------------------------------------------\n# pure BFS step helper. Visited sets are tuple-keyed on\n# (kind, canonical_name). Pending is a dict-by-kind so cross-kind collisions\n# (Flow Foo vs Apex Foo) stay distinct.\n#\n# this is the single source of truth for wave-level BFS. Both\n# `harvest_waves` and `main()` route through here so the tuple-keyed\n# semantics are active on the production path — not just under unit tests.\n#\n# kind tokens match the runtime tree's `kind` field\n# (FLOW/APEX/PROMPT_TEMPLATE/STANDARD_ACTION). The persisted\n# `_pending_fetches` dict uses the SAME tokens so internal + on-disk\n# conventions don't diverge.\n#\n# promoted from `_BFS_KINDS` → `BFS_KINDS` so\n# cross-module callers (main.py's in-process orchestrator) import a stable\n# public symbol instead of reaching across a leading-underscore boundary.\n# Same pattern as rest_client.redact_text's promotion. The underscore\n# alias below is retained for backwards compatibility and will be removed\n# in the next minor version; new code MUST use `BFS_KINDS`.\n# unified node-truncation annotation. When a node is not fully\n# expanded (either because of a cycle back to an ancestor, or because the\n# MAX_BFS_DEPTH cap tripped), we annotate it with a `_truncated` sub-\n# object of shape `{\"reason\": \u003cstr>, \"target\": \u003cstr>}`. `reason` is one\n# of the values below; `target` is a `\"KIND:name\"` path pointing at the\n# first encounter (cycle) or the unreached leaf (depth-cap). Downstream\n# consumers check `_truncated[\"reason\"]` once; no per-reason pattern\n# matching.\n#\n# Backcompat: `_cycle_back_to` is emitted alongside `_truncated` for any\n# consumer that hasn't migrated (render_architecture, summarize_tree,\n# third-party tooling). Will be removed in the next minor version.\nTRUNCATION_CYCLE = \"cycle\"\nTRUNCATION_MAX_DEPTH = \"max-depth\"\nTRUNCATION_REASONS = frozenset({TRUNCATION_CYCLE, TRUNCATION_MAX_DEPTH})\n\n\nBFS_KINDS = (\"FLOW\", \"APEX\", \"PROMPT_TEMPLATE\", \"STANDARD_ACTION\")\n\n# STANDARD_ACTION is a Salesforce-owned builtin (e.g.\n# `streamKnowledgeSearch`, `createRecord`, `sendEmail`). These are never\n# fetched — they're declared-only leaves, with the action name carrying\n# all the identity. Keeping STANDARD_ACTION in BFS_KINDS lets the tree's\n# _kind_counts tally include them and visited-bookkeeping dedup against\n# them, but they must never accumulate into `_pending_fetches`. A name\n# like `streamKnowledgeSearch` landing in _pending_fetches.STANDARD_ACTION\n# is pollution — the pipeline isn't \"missing\" anything; the action is\n# simply declared and never materialized.\n#\n# Callers: when collecting refs into `new_refs`, gate on FETCHABLE_KINDS\n# rather than BFS_KINDS to avoid polluting pending buckets with leaves.\nFETCHABLE_KINDS = (\"FLOW\", \"APEX\", \"PROMPT_TEMPLATE\")\n\n# deprecated alias. Retained so existing tests and any lingering\n# `from parse_wave import _BFS_KINDS` imports keep working. New code MUST\n# use `BFS_KINDS` (public). Planned removal in the next minor version.\n_BFS_KINDS = BFS_KINDS\n\n\ndef bfs_step(\n pending_by_kind: dict[str, set[str]],\n visited_by_kind: dict[str, set[str]],\n new_refs_by_kind: dict[str, set[str]],\n) -> tuple[dict[str, set[str]], list[tuple[str, str]]]:\n \"\"\"Advance one BFS wave. Returns (new_pending_by_kind, cycles).\n\n `cycles` is a list of (kind, name) tuples for refs that were already\n visited on this wave — callers use these to annotate `_cycle_back_to`.\n\n Self-cycles (a ref pointing at something already visited) are filtered\n out of pending. Cross-type tuples (Flow Foo + Apex Foo) are distinct —\n both land in their respective pending buckets.\n\n unknown kinds RAISE — this is an internal API; a typo in a caller\n is a programming error, not a recoverable runtime condition. Silent drop\n produced false confidence (the dropped ref never got fetched and no log\n ever mentioned it). Callers must filter to `BFS_KINDS` upstream.\n \"\"\"\n new_pending: dict[str, set[str]] = {k: set() for k in BFS_KINDS}\n cycles: list[tuple[str, str]] = []\n for kind, refs in new_refs_by_kind.items():\n if kind not in BFS_KINDS:\n raise ValueError(\n f\"unknown BFS kind {kind!r}; must be one of {sorted(BFS_KINDS)}\"\n )\n visited = visited_by_kind.get(kind, set())\n for name in refs:\n if name in visited:\n cycles.append((kind, name))\n continue\n # Not yet visited AND not already pending on a prior wave:\n # OR-in unconditionally — `pending |= (new - visited)` semantics.\n new_pending[kind].add(name)\n # Merge with the caller's existing pending. Caller passes the authoritative\n # pending buckets; we return the delta merged in so they can just replace.\n merged: dict[str, set[str]] = {k: set(pending_by_kind.get(k, set())) for k in BFS_KINDS}\n for kind, names in new_pending.items():\n merged[kind] |= names\n return merged, cycles\n\n\ndef empty_kind_sets() -> dict[str, set[str]]:\n \"\"\"Build a fresh {kind: set()} dict covering every BFS_KINDS entry.\n\n callers use this to seed per-kind visited / pending\n buckets. Keeping it centralized means new kinds added to BFS_KINDS\n automatically flow through harvest_waves, bfs_step, and main().\n\n promoted from `_empty_kind_sets` → public so\n cross-module callers (main.py) can import a stable name instead of\n reaching into a private symbol. The underscore alias below is kept\n for backwards compatibility; it will be removed in the next minor\n version.\n \"\"\"\n return {k: set() for k in BFS_KINDS}\n\n\n# deprecated alias. Retained for backwards compatibility with any\n# lingering `from parse_wave import _empty_kind_sets` imports; new code\n# MUST use `empty_kind_sets` (public). Planned removal in the next minor\n# version.\n_empty_kind_sets = empty_kind_sets\n\n\ndef harvest_waves(\n work_dir: pathlib.Path,\n) -> tuple[dict[str, list[dict]], dict[str, set[str]], dict[str, set[str]], list[tuple[str, str]]]:\n \"\"\"Walk sf_meta/*/unpackaged/ dirs.\n\n Returns (flow_children, visited_by_kind, pending_by_kind, cycles).\n\n every ref collected from Flow XML (actionCalls + subflows)\n is routed through `bfs_step` per-flow. The tuple-keyed visited set is\n the authoritative record; flat per-name sets have been removed.\n\n the two dicts are keyed by `BFS_KINDS` tokens\n (FLOW/APEX/PROMPT_TEMPLATE/STANDARD_ACTION) — matching the runtime\n tree's `kind` field and the on-disk `_pending_fetches` layout.\n \"\"\"\n flow_children: dict[str, list[dict]] = {}\n visited_by_kind: dict[str, set[str]] = empty_kind_sets()\n pending_by_kind: dict[str, set[str]] = empty_kind_sets()\n cycles: list[tuple[str, str]] = []\n\n sf_meta = work_dir / \"sf_meta\"\n if not sf_meta.exists():\n return flow_children, visited_by_kind, pending_by_kind, cycles\n\n all_wave_dirs = sorted(\n p / \"unpackaged\" for p in sf_meta.iterdir()\n if p.is_dir() and (p / \"unpackaged\").exists()\n )\n\n # Pass 1: flows (+ route per-flow refs through bfs_step).\n for wave_dir in all_wave_dirs:\n flows_dir = wave_dir / \"flows\"\n if not flows_dir.exists():\n continue\n for f in sorted(flows_dir.glob(\"*.flow\")):\n flow_name = f.stem\n if flow_name in flow_children:\n continue # already harvested this wave dir (earlier iter)\n visited_by_kind[\"FLOW\"].add(flow_name)\n try:\n root = ET.parse(f).getroot()\n except ET.ParseError:\n flow_children[flow_name] = []\n continue\n children: list[dict] = []\n # Collect per-flow refs into kind-bucketed sets for one bfs_step.\n new_refs: dict[str, set[str]] = empty_kind_sets()\n for ac in root.findall(\"sf:actionCalls\", NS):\n n = _t(ac, \"sf:name\")\n at = _t(ac, \"sf:actionType\") or \"\"\n an = _t(ac, \"sf:actionName\")\n item = classify_action_call(at, an, n)\n children.append(item)\n k = item[\"kind\"]\n api = item[\"api_name\"]\n # Only route FETCHABLE kinds into `new_refs` — STANDARD_ACTION\n # items stay as leaf children with no further fetching implied\n # . UNKNOWN items are similarly skipped.\n if k in FETCHABLE_KINDS and api:\n new_refs[k].add(api)\n for sub in root.findall(\"sf:subflows\", NS):\n n = _t(sub, \"sf:name\")\n fn = _t(sub, \"sf:flowName\")\n if fn:\n children.append({\"kind\": \"FLOW\", \"element_name\": n, \"api_name\": fn})\n new_refs[\"FLOW\"].add(fn)\n flow_children[flow_name] = children\n # bfs_step is the canonical merge point.\n pending_by_kind, step_cycles = bfs_step(\n pending_by_kind, visited_by_kind, new_refs\n )\n cycles.extend(step_cycles)\n\n # Pass 2: APEX classes (mark visited).\n for wave_dir in all_wave_dirs:\n apex_dir = wave_dir / \"classes\"\n if apex_dir.exists():\n for f in sorted(apex_dir.glob(\"*.cls-meta.xml\")):\n visited_by_kind[\"APEX\"].add(f.name.replace(\".cls-meta.xml\", \"\"))\n\n # Pass 3: Prompt templates (mark visited).\n for wave_dir in all_wave_dirs:\n prompt_dir = wave_dir / \"genAiPromptTemplates\"\n if prompt_dir.exists():\n for f in sorted(prompt_dir.glob(\"*.genAiPromptTemplate\")):\n visited_by_kind[\"PROMPT_TEMPLATE\"].add(f.stem)\n\n # Passes 2/3 may have added visited entries AFTER pass-1 pending routing;\n # prune anything that's now visited. This preserves the invariant that\n # `pending ∩ visited = ∅` without needing a second bfs_step.\n for kind in BFS_KINDS:\n pending_by_kind[kind] -= visited_by_kind[kind]\n\n return flow_children, visited_by_kind, pending_by_kind, cycles\n\n\ndef init_tree(work_dir: pathlib.Path, bundle: dict) -> dict:\n bd_rec = {}\n bd_file = work_dir / \"_bot_definition.json\"\n if bd_file.exists():\n try:\n recs = (json.loads(bd_file.read_text()).get(\"result\") or {}).get(\"records\") or []\n if recs:\n bd_rec = recs[0]\n except (OSError, json.JSONDecodeError):\n pass\n\n tree = {\n # 3.0 bumps the _pending_fetches key convention from\n # Metadata-API tokens (Flow/ApexClass/GenAiPromptTemplate) to the\n # runtime-tree `kind` tokens (FLOW/APEX/PROMPT_TEMPLATE/\n # STANDARD_ACTION). `cache_check.py`'s schema-version gate busts any\n # pre-3.0 cache on first run.\n #\n # 3.1 (2026-05-05) canonicalizes `invocation_type` on STANDARD_ACTION\n # nodes (formerly split across `raw_invocation_type` on bundle-\n # sourced nodes and `raw_action_type` on flow-actionCall-sourced\n # nodes). Readers fall back to the two legacy keys for one release.\n \"_schema_version\": \"3.1\",\n \"agent\": {\n \"api_name\": os.environ[\"AGENT_API_NAME\"],\n \"version\": os.environ[\"AGENT_VERSION\"],\n \"bot_id\": os.environ.get(\"BOT_ID\", \"\"),\n \"master_label\": bd_rec.get(\"MasterLabel\") or os.environ.get(\"BOT_MASTER_LABEL\", \"\"),\n \"description\": bd_rec.get(\"Description\"),\n \"agent_type\": bd_rec.get(\"AgentType\"),\n \"type\": bd_rec.get(\"Type\"),\n \"agent_template\": bd_rec.get(\"AgentTemplate\"),\n \"bot_source\": bd_rec.get(\"BotSource\"),\n \"generation\": bundle.get(\"generation\", \"unknown\"),\n \"planner_name\": bundle.get(\"plannerName\"),\n \"planner_type\": bundle.get(\"plannerType\"),\n \"_version_auto_picked\": (os.environ.get(\"VERSION_AUTO_PICKED\", \"\") == \"true\"),\n },\n \"root\": {\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": os.environ[\"AGENT_API_NAME\"],\n \"children\": [],\n },\n \"node_count\": 0,\n \"depth\": 0,\n # `_partial` + `_partial_reason` propagate from parse_wave's\n # depth-cap detection through to emit_result's PARTIAL_OK status.\n # `_partial_reason` values: \"max-depth-cap\" | \"pending-refs\" | null.\n \"_partial\": True,\n \"_partial_reason\": None,\n \"_pending_fetches\": {k: [] for k in BFS_KINDS},\n \"_unresolved\": [],\n \"_visited\": [],\n }\n return tree\n\n\ndef build_root_children(\n bundle: dict,\n visited_by_kind: dict[str, set[str]],\n aux_visited: set[tuple[str, str]],\n) -> tuple[list[dict], dict[str, set[str]]]:\n \"\"\"Build the root's topic / plannerAction children.\n\n bundle-scoped FLOW/APEX/PROMPT_TEMPLATE refs are collected\n into a kind-keyed `new_refs` dict and returned to the caller, which runs\n them through `bfs_step` for uniform pending-merge + cycle tracking.\n This used to directly mutate three flat sets — split out so the tuple-\n keyed semantics apply to bundle-derived refs too, not just wave-derived\n ones.\n\n `aux_visited` tracks non-BFS node identity (GEN_AI_FUNCTION / TOPIC) so\n the persisted `_visited` list still represents every node the walker\n has laid hands on. It is NOT consulted by bfs_step.\n \"\"\"\n children: list[dict] = []\n new_refs: dict[str, set[str]] = empty_kind_sets()\n\n def action_node(action: dict) -> dict:\n unwraps, leaf = classify_bundle_action(action)\n aux_visited.add((\"GEN_AI_FUNCTION\", action[\"name\"]))\n node = {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": action[\"name\"],\n \"unwraps_to\": unwraps,\n \"children\": [leaf] if leaf else [],\n }\n if unwraps:\n tgt = unwraps.get(\"api_name\")\n kind = unwraps.get(\"kind\")\n # STANDARD_ACTION is declared-only, never\n # fetched; routing it into `new_refs` would pollute\n # `_pending_fetches.STANDARD_ACTION`. Gate on FETCHABLE_KINDS.\n if kind in FETCHABLE_KINDS and tgt and tgt not in visited_by_kind[kind]:\n new_refs[kind].add(tgt)\n return node\n\n for topic in bundle.get(\"topics\", []) or []:\n aux_visited.add((\"TOPIC\", topic[\"name\"]))\n children.append({\n \"kind\": \"TOPIC\",\n \"api_name\": topic[\"name\"],\n \"children\": [action_node(a) for a in topic.get(\"actions\", []) or []],\n })\n\n # bundle[\"plannerActions\"] is always [] now (a planner never has\n # direct functions — 2026-05-05). The key stays in the bundle dict\n # for back-compat with consumers that still call `.get(\"plannerActions\")`.\n\n return children, new_refs\n\n\n# ---------------------------------------------------------------------------\n# cycle-safe inflate with (kind, canonical_name) tuple-keyed tracking.\n#\n# Visited tracking uses tuple keys so a Flow `Foo` and an Apex `Foo` are\n# distinct nodes — a flat name-only set would silently drop the second one.\n# Flow→subflow cycles (direct or cross-type A→B→A) are annotated with\n# `_cycle_back_to` on the repeat node and recursion terminates immediately.\n#\n# , revised 2026-05-03: `MAX_BFS_DEPTH` is a DEFENSIVE termination\n# guard, not a functional constraint on chain depth. Must stay in sync\n# with `scripts/config.py::MAX_BFS_DEPTH` — duplicated (rather than\n# imported) because this module runs as a standalone subprocess under\n# `python3 parse_wave.py` (see `main()`) and the module has a long-standing\n# \"no intra-skill imports\" convention (search `no intra-skill imports'\n# convention`).\n#\n# Real cycle detection is per-branch via `visited_in_path`: a flow that\n# appears on sibling branches is NOT a cycle (e.g. `handleFlowFault`,\n# invoked from many parents), but the same flow recurring along its own\n# ancestor chain IS. Textbook DFS cycle detection.\n#\n# Before the revision this was `5` and the cap itself tripped on shared\n# utility flows (`handleFlowFault`, logging subflows) — those landed in\n# `_pending_fetches[\"FLOW\"]` and the user saw `PARTIAL_REASON=max-depth-cap`\n# on trees that were in fact fully knowable. Bumping to 20 keeps a safety\n# net for pathological graphs while letting per-branch cycle detection do\n# the real work. On every real bot tree observed to date, the per-branch\n# check terminates well below depth 20.\n#\n# When this cap *does* trip (truly exceptional), the unreached FLOW lands\n# in `pending_out[\"FLOW\"]` and the leaf is annotated `_truncated =\n# {reason: \"max-depth\", target: \"FLOW:\u003cname>\"}`, exactly as before.\nMAX_BFS_DEPTH = 20\n\n# Retained alias so the `MAX_INFLATE_DEPTH` symbol (referenced in older\n# agent notes / docs) still resolves. Tracks `MAX_BFS_DEPTH` exactly; the\n# 2026-05-03 revision reinterpreted this as a defensive guard rather than\n# a functional cap, but the alias semantics are unchanged.\nMAX_INFLATE_DEPTH = MAX_BFS_DEPTH\n_FLOW_CYCLE_KINDS = frozenset({\"FLOW\"}) # the only kind that recurses here\n\n\ndef _cycle_key(node: dict) -> tuple[str, str]:\n \"\"\"Tuple key for visited / cycle detection. Flows use canonical api_name.\n\n keying on `(kind, api_name)` — NOT just name — so cross-kind\n collisions (Flow Foo + Apex Foo) stay distinct.\n \"\"\"\n return (node.get(\"kind\") or \"\", node.get(\"api_name\") or \"\")\n\n\ndef inflate_flow_leaf(\n leaf: dict,\n flow_children: dict,\n depth: int = 0,\n visited_in_path: frozenset[tuple[str, str]] | None = None,\n pending_out: dict[str, set[str]] | None = None,\n) -> None:\n \"\"\"Recursively expand a FLOW leaf's subflow tree.\n\n `visited_in_path` is threaded through the recursion as an\n IMMUTABLE frozenset — siblings at the same depth must NOT observe\n each other's descent. Mutating a shared set would cause the second\n sibling to be pruned as a false cycle.\n\n `pending_out` is an OPTIONAL mutable accumulator keyed by\n `BFS_KINDS` tokens. When the depth cap trips (`depth >= MAX_BFS_DEPTH`)\n and the current leaf still has subflow children we'd normally expand,\n those unreached targets are added to `pending_out[kind]` so a caller\n can surface them as `_pending_fetches` on the tree with\n `_partial=True` + `_partial_reason=\"max-depth-cap\"`. When `pending_out`\n is None (legacy callers), the cap behaves as before — silently skip.\n \"\"\"\n # depth-cap check. We use `>=` (not `>`) so the cap matches\n # `MAX_BFS_DEPTH` exactly — at depth 5 we do NOT expand the current\n # leaf, and the leaf's own (kind, api_name) is recorded in\n # `pending_out` as \"unreached\". Rationale: this node was supposed\n # to be fully explored but we hit the cap on the way in. Naming\n # the leaf itself (not its kids) is what downstream callers need\n # to surface as `_pending_fetches[\"FLOW\"] = [\u003cunreached>]`.\n if depth >= MAX_BFS_DEPTH:\n if leaf.get(\"kind\") == \"FLOW\":\n flow_name = leaf.get(\"api_name\")\n if flow_name:\n if pending_out is not None:\n pending_out.setdefault(\"FLOW\", set()).add(flow_name)\n # annotate the truncated leaf with the unified\n # `_truncated` sub-object so rendered tree views can\n # mark depth-capped nodes alongside cycle nodes using\n # one predicate. The target points at the leaf itself\n # (it was supposed to be fully explored but the cap\n # tripped on the way in).\n leaf[\"_truncated\"] = {\n \"reason\": TRUNCATION_MAX_DEPTH,\n \"target\": f\"FLOW:{flow_name}\",\n }\n return\n if leaf.get(\"kind\") != \"FLOW\":\n return\n flow_name = leaf.get(\"api_name\")\n if not flow_name:\n return\n kids = flow_children.get(flow_name)\n if not kids:\n return # no data this wave; preserve any existing children\n\n # Extend the path-visited set with this node. Frozenset keeps sibling\n # branches independent — mutation would cross-contaminate.\n leaf_key = _cycle_key(leaf)\n path_set = (visited_in_path or frozenset()) | {leaf_key}\n\n new_children: list[dict] = []\n for k in kids:\n item = {\"kind\": k[\"kind\"], \"element_name\": k.get(\"element_name\"), \"api_name\": k[\"api_name\"]}\n # Carry the STANDARD_ACTION / UNKNOWN invocation-type qualifier\n # through to the expanded child. Canonical key is `invocation_type`\n # (schema 3.1); legacy `raw_action_type` kept for one release so\n # caches built by an older parse_wave still render cleanly.\n if \"invocation_type\" in k:\n item[\"invocation_type\"] = k[\"invocation_type\"]\n if \"raw_action_type\" in k:\n item[\"raw_action_type\"] = k[\"raw_action_type\"]\n if k[\"kind\"] == \"FLOW\":\n item[\"children\"] = []\n child_key = _cycle_key(item)\n if child_key in path_set:\n # cycle detected — annotate and do NOT recurse.\n # Path format: \"\u003cKIND>:\u003cname>\" of the first encounter.\n # unified `_truncated` sub-object + legacy\n # `_cycle_back_to` for backcompat.\n target_path = f\"{child_key[0]}:{child_key[1]}\"\n item[\"_truncated\"] = {\n \"reason\": TRUNCATION_CYCLE,\n \"target\": target_path,\n }\n item[\"_cycle_back_to\"] = target_path # deprecated alias\n new_children.append(item)\n continue\n new_children.append(item)\n inflate_flow_leaf(item, flow_children, depth + 1, path_set, pending_out)\n else:\n new_children.append(item)\n leaf[\"children\"] = new_children\n\n\ndef walk_and_inflate(\n node: dict,\n flow_children: dict,\n depth: int = 0,\n pending_out: dict[str, set[str]] | None = None,\n) -> None:\n \"\"\"Walk the tree and inflate every FLOW leaf we encounter.\n\n Each inflate call starts with an empty path-visited set — tree-walk\n descent is not itself a Flow recursion, so ancestor GEN_AI_FUNCTION\n or TOPIC nodes don't count toward cycle detection.\n\n `pending_out` (optional) is threaded into every `inflate_flow_leaf`\n call so depth-cap truncations accumulate into a single shared dict,\n which `main()` merges into `tree[\"_pending_fetches\"]` + flips\n `tree[\"_partial\"]` with reason `max-depth-cap`.\n \"\"\"\n if node.get(\"kind\") == \"GEN_AI_FUNCTION\":\n for child in node.get(\"children\", []) or []:\n if child.get(\"kind\") == \"FLOW\":\n inflate_flow_leaf(child, flow_children, depth, pending_out=pending_out)\n elif node.get(\"kind\") == \"FLOW\":\n inflate_flow_leaf(node, flow_children, depth, pending_out=pending_out)\n for c in node.get(\"children\", []) or []:\n walk_and_inflate(c, flow_children, depth + 1, pending_out)\n\n\ndef compute_stats(root: dict) -> tuple[int, int, dict]:\n counts: Counter = Counter()\n\n def walk(node: dict, d: int = 0) -> int:\n k = node.get(\"kind\")\n if k:\n counts[k] += 1\n max_d = d\n for c in node.get(\"children\", []) or []:\n max_d = max(max_d, walk(c, d + 1))\n return max_d\n\n depth = walk(root)\n return sum(counts.values()), depth, dict(counts)\n\n\ndef atomic_write_json(path: pathlib.Path, obj: dict) -> None:\n tmp = path.with_suffix(path.suffix + \".tmp\")\n tmp.write_text(json.dumps(obj, indent=2))\n os.replace(tmp, path)\n\n\ndef finalize_cap(tree: dict) -> dict:\n \"\"\"Move remaining _pending_fetches items to _unresolved[].\n\n pending and unresolved both use the same kind tokens now\n (FLOW/APEX/PROMPT_TEMPLATE/STANDARD_ACTION) — matching the runtime\n tree's `kind` field.\n\n depth-cap truncation writes `_partial=True` +\n `_partial_reason=\"max-depth-cap\"` earlier in the pipeline. Finalize\n preserves those signals when it drains pending → unresolved so the\n downstream emit_result.py can still surface PARTIAL_OK with the\n correct reason. If BOTH the depth cap AND finalize-cap fire on the\n same run, depth-cap wins priority (set first, not overwritten).\n \"\"\"\n pending = tree.get(\"_pending_fetches\") or {}\n unresolved = tree.setdefault(\"_unresolved\", [])\n drained_any = False\n for kind, items in pending.items():\n for n in items:\n drained_any = True\n unresolved.append({\n \"kind\": kind,\n \"api_name\": n,\n \"reason\": \"max-wave-depth exceeded\",\n })\n tree[\"_pending_fetches\"] = {k: [] for k in BFS_KINDS}\n\n # if pending was drained but no `_partial_reason` is set yet,\n # mark it as wave-depth exhaustion. DO NOT overwrite an existing\n # `max-depth-cap` reason — the depth-cap trigger is strictly earlier\n # and more specific.\n if drained_any:\n tree[\"_partial\"] = True\n if not tree.get(\"_partial_reason\"):\n tree[\"_partial_reason\"] = \"max-wave-depth\"\n return tree\n\n\ndef main() -> int:\n finalize_cap_mode = \"--finalize-cap\" in sys.argv\n\n try:\n work_dir = pathlib.Path(os.environ[\"WORK_DIR\"])\n except KeyError as e:\n sys.stderr.write(f\"parse_wave.py: missing env {e}\\n\")\n return 1\n\n tree_path = work_dir / \"declared_action_tree.json\"\n\n # --finalize-cap: trivial; just drain pending → unresolved on existing tree\n if finalize_cap_mode:\n if not tree_path.is_file():\n sys.stderr.write(\"parse_wave.py: --finalize-cap with no tree file; noop\\n\")\n return 0\n try:\n tree = json.loads(tree_path.read_text())\n except (OSError, json.JSONDecodeError) as e:\n sys.stderr.write(f\"parse_wave.py: cannot read tree: {e}\\n\")\n return 1\n tree = finalize_cap(tree)\n try:\n atomic_write_json(tree_path, tree)\n except OSError as e:\n sys.stderr.write(f\"parse_wave.py: write failed: {e}\\n\")\n return 1\n sys.stderr.write(\n f\"[parse_wave] finalize-cap: {len(tree.get('_unresolved', []))} nodes unresolved\\n\"\n )\n return 0\n\n # Standard init-or-reparse path\n bundle_path = work_dir / \"_bundle_parsed.json\"\n if not bundle_path.is_file():\n sys.stderr.write(f\"parse_wave.py: missing {bundle_path}\\n\")\n return 1\n try:\n bundle = json.loads(bundle_path.read_text())\n except (OSError, json.JSONDecodeError) as e:\n sys.stderr.write(f\"parse_wave.py: bundle parse error: {e}\\n\")\n return 1\n\n # Harvest wave dirs. Returns kind-keyed visited + pending dicts plus\n # the list of (kind, name) cycles detected during per-flow BFS merges.\n flow_children, wave_visited_by_kind, pending_by_kind, _wave_cycles = harvest_waves(work_dir)\n\n # Load-or-init tree\n if tree_path.is_file():\n try:\n tree = json.loads(tree_path.read_text())\n except (OSError, json.JSONDecodeError) as e:\n sys.stderr.write(f\"parse_wave.py: cannot re-parse; reinitializing ({e})\\n\")\n tree = init_tree(work_dir, bundle)\n else:\n tree = init_tree(work_dir, bundle)\n\n # Rehydrate tuple-keyed visited from the persisted _visited list and\n # union with wave-derived visited. Non-BFS kinds (TOPIC/GEN_AI_FUNCTION)\n # live in `aux_visited` — they're persisted in _visited but do NOT\n # participate in BFS routing.\n visited_by_kind: dict[str, set[str]] = empty_kind_sets()\n for kind, names in wave_visited_by_kind.items():\n visited_by_kind[kind] |= names\n aux_visited: set[tuple[str, str]] = set()\n for pair in (tree.get(\"_visited\") or []):\n if not pair or len(pair) != 2:\n continue\n k, n = pair[0], pair[1]\n if k in BFS_KINDS:\n visited_by_kind[k].add(n)\n else:\n aux_visited.add((k, n))\n\n # If the tree was just initialized, populate root children (only once).\n # bundle-derived refs now flow through bfs_step via\n # build_root_children's new_refs return value — same code path as\n # wave-derived refs.\n is_fresh = not (tree.get(\"root\", {}).get(\"children\"))\n if is_fresh:\n children, bundle_new_refs = build_root_children(\n bundle, visited_by_kind, aux_visited\n )\n tree[\"root\"][\"children\"] = children\n pending_by_kind, _bundle_cycles = bfs_step(\n pending_by_kind, visited_by_kind, bundle_new_refs\n )\n\n # Inflate Flow leaves wherever possible.\n # `depth_cap_pending` captures subflow refs we skipped because\n # the depth cap tripped — these get merged into `_pending_fetches`\n # below so downstream emit_result.py can surface `PARTIAL_OK`.\n depth_cap_pending: dict[str, set[str]] = empty_kind_sets()\n walk_and_inflate(tree[\"root\"], flow_children, pending_out=depth_cap_pending)\n\n # Merge depth-cap-suppressed refs back into pending_by_kind so they\n # land in `_pending_fetches`. Preserve any existing (already-visited)\n # exclusion semantics — refs already fetched this run MUST NOT be\n # re-reported as pending.\n for kind in BFS_KINDS:\n pending_by_kind[kind] |= depth_cap_pending.get(kind, set())\n\n depth_cap_tripped = any(depth_cap_pending[k] for k in BFS_KINDS)\n\n # Recompute pending (exclude anything now visited). the\n # persisted dict uses the same `BFS_KINDS` tokens as the runtime tree\n # and the in-memory buckets — no more Metadata-API-style aliasing.\n tree[\"_pending_fetches\"] = {\n k: sorted(pending_by_kind.get(k, set()) - visited_by_kind.get(k, set()))\n for k in BFS_KINDS\n }\n\n # surface a `_partial` signal when:\n # (a) depth-cap-pending refs exist (we suppressed deep subflows), OR\n # (b) any `_pending_fetches` bucket is non-empty at write time.\n # `_partial_reason` identifies which of the two tripped — `max-depth-cap`\n # takes priority when depth_cap_pending is non-empty, since the cap is\n # the reason pending refs weren't drained to `_unresolved` yet.\n any_pending = any(tree[\"_pending_fetches\"][k] for k in BFS_KINDS)\n if depth_cap_tripped:\n tree[\"_partial\"] = True\n tree[\"_partial_reason\"] = \"max-depth-cap\"\n elif any_pending:\n # Still incomplete but NOT because of the depth cap. Leave any\n # existing reason intact if prior run set it; default to a neutral\n # marker so emit_result.py can still flip to PARTIAL_OK.\n tree[\"_partial\"] = True\n if not tree.get(\"_partial_reason\"):\n tree[\"_partial_reason\"] = \"pending-refs\"\n else:\n tree[\"_partial\"] = False\n tree[\"_partial_reason\"] = None\n\n # Recompute node_count + depth + kind_counts\n node_count, depth, kind_counts = compute_stats(tree[\"root\"])\n tree[\"node_count\"] = node_count\n tree[\"depth\"] = depth\n tree[\"_kind_counts\"] = kind_counts\n\n # Persist visited as sorted list of [kind, name] pairs. Includes aux\n # kinds (TOPIC/GEN_AI_FUNCTION) so the replay surface is complete.\n all_visited: set[tuple[str, str]] = set(aux_visited)\n for kind, names in visited_by_kind.items():\n for n in names:\n all_visited.add((kind, n))\n tree[\"_visited\"] = [list(v) for v in sorted(all_visited)]\n\n try:\n atomic_write_json(tree_path, tree)\n except OSError as e:\n sys.stderr.write(f\"parse_wave.py: write failed: {e}\\n\")\n return 1\n\n # log line mirrors the persisted dict's kind tokens.\n pending_total = sum(len(v) for v in tree[\"_pending_fetches\"].values())\n sys.stderr.write(\n f\"[parse_wave] parsed: {node_count} nodes, depth {depth}, counts={kind_counts}, \"\n f\"pending={pending_total} \"\n f\"(flow={len(tree['_pending_fetches']['FLOW'])} \"\n f\"apex={len(tree['_pending_fetches']['APEX'])} \"\n f\"prompt_template={len(tree['_pending_fetches']['PROMPT_TEMPLATE'])} \"\n f\"standard_action={len(tree['_pending_fetches']['STANDARD_ACTION'])})\\n\"\n )\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":36951,"content_sha256":"4876e697971f9c5a6844c79d0da2d287e4af6bbfdd4f131d5894e49d41c5c1ef"},{"filename":"scripts/probe_channels.py","content":"\"\"\"Channel probe — one-shot sf sobject describe per target type, cached 7 days.\n\nchannel probe with TTL (7d), mandatory-field gate, runtime\nINVALID_FIELD re-probe, and a `--reprobe` CLI flag.\n\nRationale: Salesforce ships quarterly releases that can rename / deprecate\nTooling sObject fields. A long-lived cache would mask a schema drift for\nweeks. A 7-day TTL bounds the exposure; the `invalidate_and_reprobe` hook\nis the runtime escape valve when a SOQL returns `INVALID_FIELD` mid-run.\n\nSecurity:\n * Cache dir comes ONLY from `config.build_probe_cache_dir` (validated\n path; no attacker-controlled segments).\n * Cache file is written 0o600 — owner read-write only.\n * No raw sObject describe REST calls here; we go through `sf_cli.run_sf`\n which already enforces (yaml.safe_load) + (redacted stderr).\n\nMandatory vs optional fields: we ship a MANDATORY_FIELDS map of the fields\nthe skill's SOQL assets can't function without. A probe that sees ANY\nmandatory field missing flags `status: \"PROBE_FAILED\"` so the caller can\nsurface a clean error instead of producing a subtly-wrong tree.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport tempfile\nimport time\nfrom pathlib import Path\nfrom typing import Any, Dict\n\n# Local imports — sf_cli handles the actual describe calls + redaction.\nfrom config import PROBE_TTL_DAYS, build_probe_cache_dir\nfrom sf_cli import SfCliError, run_sf\n\n\n# -----------------------------------------------------------------------------\n# critical sObjects the skill depends on.\n# -----------------------------------------------------------------------------\n# `api_kind`: which describe recipe we route the call through. BotDefinition\n# / BotVersion are reachable via the Data API; the GenAi* sObjects are\n# Tooling-API-only. The two recipes share the same shape (sObject name in,\n# queryable fields list out) so downstream handling is uniform.\n\n_DATA_API_SOBJECTS = (\"BotDefinition\", \"BotVersion\")\n_TOOLING_API_SOBJECTS = (\n \"ApexClass\",\n \"Flow\",\n \"FlowDefinition\",\n \"GenAiPlannerDefinition\",\n \"GenAiPluginDefinition\",\n \"GenAiFunctionDefinition\",\n \"GenAiPlannerAttrDefinition\",\n)\n\nALL_SOBJECTS: tuple[str, ...] = _DATA_API_SOBJECTS + _TOOLING_API_SOBJECTS\n\n\n# mandatory fields. These are the minimum set of fields the skill's\n# SOQL assets reference by name. If a probe sees ANY of these missing, the\n# probe status flips to PROBE_FAILED — we'd rather abort cleanly than\n# produce a tree with silent holes from missing columns.\n#\n# Keep this list conservative — \"mandatory\" means \"every code path needs\n# it\". Fields that only some branches use go in the queryable set without\n# being gated.\nMANDATORY_FIELDS: Dict[str, set[str]] = {\n \"BotDefinition\": {\"Id\", \"DeveloperName\"},\n \"BotVersion\": {\"Id\", \"BotDefinitionId\"},\n \"ApexClass\": {\"Id\", \"Name\"},\n \"Flow\": {\"Id\", \"MasterLabel\", \"DefinitionId\"},\n \"FlowDefinition\": {\"Id\", \"DeveloperName\"},\n \"GenAiPlannerDefinition\": {\"Id\", \"DeveloperName\", \"PlannerType\"},\n \"GenAiPluginDefinition\": {\"Id\", \"DeveloperName\"},\n \"GenAiFunctionDefinition\": {\"Id\", \"DeveloperName\", \"InvocationTarget\"},\n \"GenAiPlannerAttrDefinition\": {\"Id\"},\n}\n\n\nclass ProbeError(RuntimeError):\n \"\"\"Probe could not complete — surfaced to caller, redaction already applied.\"\"\"\n\n\ndef _describe_sobject(org_alias: str, sobject: str, is_tooling: bool) -> dict:\n \"\"\"Describe a single sObject via `sf_cli.run_sf`.\n\n Expected recipes (to be added alongside this module): `describe_sobject`\n for the Data API and `describe_tooling_sobject` for Tooling. Both are\n expected to return a dict of shape `{\"status\":0,\"result\":{\"fields\":\n [{\"name\":\"X\",\"queryable\":true}, ...]}}`.\n\n We keep this helper tiny so tests can mock `run_sf` without touching\n the wire. The recipes themselves are author-controlled YAML loaded\n through `sf_cli` (yaml.safe_load).\n\n NOTE: the recipes don't exist on disk yet — they land in the Batch-4\n integration pass. This function is called only via `probe_channels`,\n which every test mocks. The production path stays inert until the\n recipes ship.\n \"\"\"\n recipe = \"describe_tooling_sobject\" if is_tooling else \"describe_sobject\"\n try:\n data = run_sf(recipe, ORG_ALIAS=org_alias, SOBJECT=sobject)\n except SfCliError as e:\n raise ProbeError(f\"describe failed for {sobject}: {e}\") from None\n return data.get(\"result\") or {}\n\n\ndef _extract_queryable_fields(describe_result: dict) -> list[str]:\n \"\"\"Pull queryable field names from a describe result.\n\n Tolerant of both the shape `{\"fields\":[{\"name\":\"X\",\"queryable\":true}]}`\n and the bare `{\"fields\":[{\"name\":\"X\"}]}` (some endpoints omit the\n queryable flag; we default to queryable=True when absent).\n \"\"\"\n fields = describe_result.get(\"fields\") or []\n out: list[str] = []\n for f in fields:\n if not isinstance(f, dict):\n continue\n name = f.get(\"name\")\n if not isinstance(name, str) or not name:\n continue\n queryable = f.get(\"queryable\", True)\n if queryable:\n out.append(name)\n return sorted(out)\n\n\ndef _is_cache_fresh(path: Path, ttl_days: int) -> bool:\n \"\"\"Return True if the cached channels.json is present and not stale.\n\n Staleness is derived from the embedded `_built_at_utc` timestamp, NOT\n filesystem mtime — mtime can be skewed by backup / restore / rsync.\n\n reject future timestamps (`age_seconds \u003c 0`) as stale. A\n `_built_at_utc` in the future means clock-skew or tampering; either\n way we can't trust the cache. The bound `0 \u003c= age \u003c ttl` is the\n honest freshness predicate.\n \"\"\"\n if not path.is_file():\n return False\n try:\n data = json.loads(path.read_text())\n except (OSError, json.JSONDecodeError):\n return False\n built_at = data.get(\"_built_at_utc\")\n if not isinstance(built_at, (int, float)):\n return False\n age_seconds = time.time() - float(built_at)\n # future timestamps (age \u003c 0) are not fresh.\n return 0 \u003c= age_seconds \u003c (ttl_days * 86400)\n\n\ndef _write_cache(path: Path, payload: dict) -> None:\n \"\"\"Write the probe payload atomically with 0o600 permissions.\n\n adjacency: the path itself is built via `build_probe_cache_dir`\n upstream, so every segment is regex-validated. Here we just enforce\n owner-only read/write and atomic rename.\n\n concurrent-safe tmp filename. `path.with_suffix(...+\".tmp\")`\n produces the same deterministic name for every concurrent caller —\n a second writer would either race on the final rename or clobber\n the first writer's in-flight bytes. `tempfile.mkstemp(dir=...)`\n returns a unique pathname per call, eliminating the collision.\n\n normalize parent-dir perms to 0o700 after mkdir. Even if the\n parent existed with looser permissions (umask or operator lapse),\n the probe cache directory is a per-user-per-org artifact that\n should not be world-readable.\n \"\"\"\n path.parent.mkdir(parents=True, exist_ok=True)\n # normalize even if the dir pre-existed.\n try:\n os.chmod(path.parent, 0o700)\n except OSError:\n # Best-effort: on an exotic filesystem (e.g. FAT) chmod may fail.\n # Don't block the cache write on it.\n pass\n\n # unique tmp name per call, in the same directory as the final\n # path so `os.replace` stays atomic (same-filesystem rename).\n fd, tmp_str = tempfile.mkstemp(\n dir=str(path.parent), prefix=\".channels.\", suffix=\".tmp\",\n )\n tmp = Path(tmp_str)\n try:\n with os.fdopen(fd, \"w\") as f:\n f.write(json.dumps(payload, indent=2, sort_keys=True))\n os.chmod(tmp, 0o600) # owner RW only\n os.replace(tmp, path)\n except Exception:\n # Clean up the tmp file on error — mkstemp leaves it behind.\n try:\n tmp.unlink()\n except FileNotFoundError:\n pass\n raise\n\n\ndef probe_channels(\n org_alias: str,\n org_id_15: str,\n api_version: str,\n *,\n force_refresh: bool = False,\n) -> dict:\n \"\"\"Run (or return cached) describes for every sObject the skill touches.\n\n caller passes `force_refresh=True` via the `--reprobe` CLI flag\n or via `invalidate_and_reprobe` when a runtime SOQL surfaces\n `INVALID_FIELD`. Default behavior: return cached payload if present\n AND younger than `PROBE_TTL_DAYS`.\n\n Returns a dict of shape:\n {\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": \u003cepoch>,\n \"status\": \"OK\" | \"PROBE_FAILED\",\n \"channels\": {\n \"BotDefinition\": {\n \"queryable_fields\": [...],\n \"mandatory_missing\": [...],\n \"describe_error\": None | \"\u003credacted error>\"\n },\n ...\n }\n }\n \"\"\"\n cache_dir = build_probe_cache_dir(org_id_15, api_version)\n cache_file = cache_dir / \"channels.json\"\n\n if not force_refresh and _is_cache_fresh(cache_file, PROBE_TTL_DAYS):\n return json.loads(cache_file.read_text())\n\n channels: Dict[str, dict] = {}\n any_mandatory_missing = False\n\n for sobject in ALL_SOBJECTS:\n is_tooling = sobject in _TOOLING_API_SOBJECTS\n entry: Dict[str, Any] = {\n \"queryable_fields\": [],\n \"mandatory_missing\": [],\n \"describe_error\": None,\n }\n try:\n result = _describe_sobject(org_alias, sobject, is_tooling)\n except ProbeError as e:\n # Redaction already applied by sf_cli.run_sf; we're just\n # passing the message along.\n entry[\"describe_error\"] = str(e)\n channels[sobject] = entry\n # A describe failure is treated as \"all mandatory missing\"\n # for this sObject — the skill can't function against it.\n entry[\"mandatory_missing\"] = sorted(MANDATORY_FIELDS.get(sobject, set()))\n if entry[\"mandatory_missing\"]:\n any_mandatory_missing = True\n continue\n\n queryable = _extract_queryable_fields(result)\n entry[\"queryable_fields\"] = queryable\n mandatory = MANDATORY_FIELDS.get(sobject, set())\n missing = sorted(mandatory - set(queryable))\n entry[\"mandatory_missing\"] = missing\n if missing:\n any_mandatory_missing = True\n\n channels[sobject] = entry\n\n payload = {\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": time.time(),\n \"status\": \"PROBE_FAILED\" if any_mandatory_missing else \"OK\",\n \"channels\": channels,\n }\n _write_cache(cache_file, payload)\n return payload\n\n\ndef invalidate_and_reprobe(\n org_alias: str,\n org_id_15: str,\n api_version: str,\n) -> dict:\n \"\"\"Delete the cached channels.json and re-run the probe.\n\n called when a runtime SOQL returns `INVALID_FIELD`. The cached\n schema is stale (SF release renamed / removed a field); we can't trust\n any of it, so unlink + re-probe.\n\n Returns the fresh payload. On a subsequent INVALID_FIELD from the\n re-probed schema, the caller should surface PROBE_FAILED rather than\n re-enter this function — one reprobe per run, not a retry loop.\n \"\"\"\n cache_dir = build_probe_cache_dir(org_id_15, api_version)\n cache_file = cache_dir / \"channels.json\"\n try:\n cache_file.unlink()\n except FileNotFoundError:\n pass\n return probe_channels(org_alias, org_id_15, api_version, force_refresh=True)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11399,"content_sha256":"79b0faae1d9bbdded0075c0470f17dbc29ee8e69dd1f1d7ea66af2a6d64c5567"},{"filename":"scripts/render_architecture.py","content":"\"\"\"Render architecture.md from metadata_tree.json.\n\nPhase 2 Batch 2.2: 8-section architecture document with up to 3 Mermaid\ndiagrams (2 core + 1 conditional dependency graph). Generation-aware:\nclassic/ReAct, classic/SequentialPlannerIntentClassifier, NGA/\nConcurrentMultiAgentOrchestration, search/BYOP all produce distinct\nstructural output on the same fixture schema.\n\nConsumers\n---------\n- `main.py` phase 10 — called with the finalized tree_path + a target\n out_path under the agent data_dir.\n- Tests — `scripts/tests/test_render_architecture.py` exercises every\n per-generation branch and the cap/partial/cycle edge cases.\n\nTemplate loader\n---------------\n`load_mermaid(name, **params)` mirrors `soql_loader.load_soql`'s\nsubstitution shape (single-pass `str.replace` on `{{KEY}}` tokens) but\ndeliberately does NOT run `fs_guard.validate_api_name` on the values.\nMermaid strings are not SQL, not a filesystem path, and not a shell\nargument — there is no injection surface downstream. We only guard\nagainst a *substituted* value itself containing `{{` / `}}`, which would\nconfuse readers of the rendered diff if it silently chained into a\nsecond substitution pass; the loader logs a warning and proceeds.\nTemplate filenames are validated — they flow into a `Path.is_file()`\ncheck on disk, same traversal surface as `load_soql`.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Tuple\n\nfrom config import MERMAID_DIR, SKILL_ROOT, fs_guard # fs_guard re-exported by config.py from _shared/\n\nlogger = logging.getLogger(__name__)\n\n# P2.2-1: per-diagram-type node caps. Above cap -> summary placeholder.\nDEFAULT_MAX_MERMAID_NODES: Dict[str, int] = {\n \"flowchart\": 200,\n \"stateDiagram\": 40,\n \"sequenceDiagram\": 60,\n \"graph\": 100,\n}\n\n\ndef _display_name(node: Dict[str, Any]) -> str:\n \"\"\"Return the node's rendered label.\n\n For STANDARD_ACTION nodes, append the invocation-type qualifier\n (e.g. `streamKnowledgeSearch (standardinvocableaction)`) so the\n rendered tree distinguishes a real Salesforce-owned builtin from\n a flow-element STANDARD_ACTION whose action-name equals its\n invocation-target. Canonical key is `invocation_type` (schema\n 3.1); legacy `raw_invocation_type` / `raw_action_type` fall\n back for caches built by an older parse_wave.\n \"\"\"\n name = node.get(\"api_name\") or node.get(\"element_name\") or \"?\"\n if node.get(\"kind\") == \"STANDARD_ACTION\":\n inv = (\n node.get(\"invocation_type\")\n or node.get(\"raw_invocation_type\")\n or node.get(\"raw_action_type\")\n )\n if inv:\n return f\"{name} ({inv})\"\n return name\n\n\n# ---------------------------------------------------------------------------\n# Template loader\n# ---------------------------------------------------------------------------\n\n\ndef load_mermaid(name: str, **params: str) -> str:\n \"\"\"Read assets/mermaid/\u003cname>.mmd and substitute {{PARAM}} values.\n\n P2.2-1: template name is regex-validated BEFORE any filesystem access.\n Traversal via `../../etc/...` is blocked by `fs_guard.validate_api_name`.\n Param *values* are NOT validated — mermaid strings don't flow into\n SOQL, REST, or filesystem paths and carry no injection surface.\n We DO check for nested `{{`/`}}` in substituted values and log a\n warning (the first-pass substitute call won't re-trigger, but a\n stray `{{OTHER}}` inside a value makes the rendered markdown\n confusing to diff). Raises `FileNotFoundError` with the template\n name (no absolute-path leak) when the template file is missing.\n \"\"\"\n try:\n fs_guard.validate_api_name(name, label=\"mermaid_template_name\")\n except fs_guard.ValidationError as e:\n # Template name is attacker-controlled in theory (a caller could\n # source it from a config file). Don't leak the full SKILL_ROOT\n # via the default FileNotFoundError message.\n raise FileNotFoundError(\n f\"Mermaid template name rejected: {e.reason}\"\n ) from None\n\n path = MERMAID_DIR / f\"{name}.mmd\"\n if not path.is_file():\n # Match soql_loader's SoqlTemplateNotFound hygiene — carry only\n # the template *name*, not the absolute MERMAID_DIR path, so\n # error text surfacing in logs doesn't disclose filesystem layout.\n raise FileNotFoundError(f\"Mermaid template not found: {name}\")\n\n template = path.read_text()\n # the `%%` header-comment block in each template is kept\n # verbatim in the rendered output. Mermaid treats `%%` lines as\n # comments and ignores them at render time, and keeping them aids\n # debuggability (rendered diff still carries the author's contract\n # notes). Templates MUST document placeholder names as bare tokens\n # (e.g. `NODES placeholder:`) rather than `{{NODES}}` — otherwise\n # the single-pass `str.replace` below would substitute the comment's\n # placeholder reference and corrupt the header.\n\n for key, value in params.items():\n if not isinstance(value, str):\n # Fail loud rather than produce a literal 'None' or '[\u003cNode>...]'\n # in the rendered markdown.\n raise TypeError(\n f\"Mermaid param {key!r} must be str, got {type(value).__name__}\"\n )\n if \"{{\" in value or \"}}\" in value:\n # Defensive log; single-pass `str.replace` still renders safely.\n logger.warning(\n \"load_mermaid(%s): param %r contains nested placeholder tokens; \"\n \"rendered output may be confusing\",\n name, key,\n )\n template = template.replace(f\"{{{{{key}}}}}\", value)\n return template.strip()\n\n\n# ---------------------------------------------------------------------------\n# Public entry point\n# ---------------------------------------------------------------------------\n\n\ndef render(\n tree_path: Path,\n out_path: Path,\n *,\n max_mermaid_nodes: Optional[Dict[str, int]] = None,\n) -> None:\n \"\"\"Read metadata_tree.json and write architecture.md to out_path.\n\n The renderer walks the tree once to collect catalog data (topics,\n actions, flows, apex, prompts, unresolved refs, cycles) then emits\n 8 sections in document order. Generation-aware branches live at the\n section-level helpers below.\n\n Note: `_render_invocation_sequence` and `_render_planner_state`\n remain defined and tested but are no longer part of the default\n pipeline (heuristics distrusted by reviewers as of 2026-05).\n Uncomment the relevant `parts.append` calls below to re-enable.\n \"\"\"\n caps = dict(DEFAULT_MAX_MERMAID_NODES)\n if max_mermaid_nodes:\n caps.update(max_mermaid_nodes)\n\n tree = json.loads(Path(tree_path).read_text())\n agent = tree.get(\"agent\") or {}\n generation = (agent.get(\"generation\") or \"classic\").lower()\n\n walker = _TreeWalker(tree)\n walker.walk()\n\n parts: List[str] = []\n parts.append(_render_header(tree, agent))\n parts.append(_render_anatomy_summary(tree, walker))\n # parts.append(_render_invocation_sequence(tree, agent, walker, caps))\n # ^ Disabled 2026-05: heuristic over-simplified real orchestration\n # paths. Function + template retained; re-enable by uncommenting.\n parts.append(_render_action_tree(tree, walker, caps))\n parts.append(_render_topic_anatomy(walker))\n parts.append(_render_action_catalog(walker))\n # parts.append(_render_planner_state(agent, generation, caps))\n # ^ Disabled 2026-05: generation-specific state diagram added more\n # noise than signal in review. Function + template retained; re-enable\n # by uncommenting.\n parts.append(_render_data_flow(tree, walker, caps))\n parts.append(_render_artifact_catalogs(walker))\n parts.append(_render_unresolved(tree, walker))\n\n # Conditional dependency-graph section — emitted only if there are\n # unresolved refs or cycles detected. Not counted as one of the 9.\n if tree.get(\"_unresolved\") or walker.cycles:\n parts.append(_render_dependency_graph(tree, walker, caps))\n\n Path(out_path).write_text(\"\\n\\n\".join(parts).rstrip() + \"\\n\")\n\n\n# ---------------------------------------------------------------------------\n# Tree walker — single pass over the tree to collect catalog data\n# ---------------------------------------------------------------------------\n\n\nclass _TreeWalker:\n \"\"\"Collects nodes, edges, topics, cycles in a single depth-first pass.\n\n P2.2-1: BFS-equivalent visit budget already enforced by parse_wave\n (MAX_BFS_DEPTH=5), so we don't need a depth cap here — the tree is\n already bounded. We DO guard against a malformed tree with a\n self-referential `children` ring by tracking id()'s.\n \"\"\"\n\n def __init__(self, tree: Dict[str, Any]) -> None:\n self.tree = tree\n self.topics: List[Dict[str, Any]] = []\n # actions = top-level children of each topic plus planner-level actions\n self.actions: List[Dict[str, Any]] = []\n self.flows: Dict[str, Dict[str, Any]] = {}\n self.apex: Dict[str, Dict[str, Any]] = {}\n self.prompts: Dict[str, Dict[str, Any]] = {}\n self.standard_actions: Dict[str, Dict[str, Any]] = {}\n # tree edges: parent api_name -> list of child api_name\n self.edges: List[Tuple[str, str, str]] = [] # (parent, child, kind)\n # fan-out map for summary-placeholder top-5\n self.fanout: Dict[str, int] = {}\n # cycle-back annotations: list of (node_label, cycle_back_to)\n self.cycles: List[Tuple[str, str]] = []\n # depth-cap truncations (subset of self.cycles surfaced by\n # the unified _truncated annotation with reason=\"max-depth\").\n # Kept separate so downstream renderers can distinguish the two\n # truncation classes without re-walking the tree.\n self.depth_capped: List[Tuple[str, str]] = []\n self._seen_py_ids: set[int] = set()\n\n def walk(self) -> None:\n root = self.tree.get(\"root\") or {}\n for child in root.get(\"children\") or []:\n self._visit(child, parent_label=root.get(\"api_name\") or \"ROOT\",\n topic=None)\n\n def _visit(\n self,\n node: Dict[str, Any],\n *,\n parent_label: str,\n topic: Optional[Dict[str, Any]],\n ) -> None:\n if not isinstance(node, dict):\n return\n nid = id(node)\n if nid in self._seen_py_ids:\n return\n self._seen_py_ids.add(nid)\n\n kind = node.get(\"kind\") or \"UNKNOWN\"\n api_name = node.get(\"api_name\") or node.get(\"element_name\") or \"\"\n\n # Top-level children of a BOT_DEFINITION with kind TOPIC feed the\n # topic list. Non-topic top-level children (planner-level actions)\n # attach to a synthetic `_plannerActions` bucket.\n if kind == \"TOPIC\" and topic is None:\n topic_rec = {\n \"api_name\": api_name,\n \"label\": node.get(\"master_label\") or api_name,\n \"actions\": [],\n \"raw\": node,\n }\n self.topics.append(topic_rec)\n topic = topic_rec\n elif topic is None and kind == \"GEN_AI_FUNCTION\":\n # plannerAction (classic Sequential / NGA) — no parent topic.\n self.actions.append({\n \"kind\": kind,\n \"api_name\": api_name,\n \"topic\": None,\n \"raw\": node,\n })\n elif topic is not None and kind == \"GEN_AI_FUNCTION\":\n action_rec = {\n \"kind\": kind,\n \"api_name\": api_name,\n \"topic\": topic[\"api_name\"],\n \"raw\": node,\n }\n topic[\"actions\"].append(action_rec)\n self.actions.append(action_rec)\n\n # Per-kind catalog buckets. We key on api_name; dupes collapse.\n if kind == \"FLOW\" and api_name:\n self.flows.setdefault(api_name, node)\n elif kind == \"APEX\" and api_name:\n self.apex.setdefault(api_name, node)\n elif kind == \"PROMPT_TEMPLATE\" and api_name:\n self.prompts.setdefault(api_name, node)\n elif kind == \"STANDARD_ACTION\" and api_name:\n self.standard_actions.setdefault(api_name, node)\n\n # per-node truncation annotation (cycle OR depth-cap).\n # Prefer the unified `_truncated` sub-object; fall back to the\n # deprecated `_cycle_back_to` string for trees produced by\n # older parse_wave versions.\n trunc = node.get(\"_truncated\") or {}\n cycle_to = trunc.get(\"target\") or node.get(\"_cycle_back_to\")\n reason = trunc.get(\"reason\") or (\"cycle\" if cycle_to else None)\n if cycle_to:\n self.cycles.append((api_name or parent_label, str(cycle_to)))\n # Optional: downstream renderers may want to distinguish\n # the two truncation classes. We keep that open by stashing\n # `reason` when present.\n if reason and reason != \"cycle\":\n self.depth_capped.append((api_name or parent_label, str(cycle_to)))\n\n # Edge + fanout bookkeeping\n children = node.get(\"children\") or []\n if api_name and parent_label and parent_label != api_name:\n self.edges.append((parent_label, api_name, kind))\n self.fanout[parent_label] = self.fanout.get(parent_label, 0) + 1\n\n for child in children:\n self._visit(child, parent_label=api_name or parent_label,\n topic=topic)\n\n # ---- helpers -----------------------------------------------------\n\n def total_nodes(self) -> int:\n counts = self.tree.get(\"_kind_counts\") or {}\n return sum(counts.values()) or self.tree.get(\"node_count\", 0)\n\n def top_fanout(self, n: int = 5) -> List[Tuple[str, int]]:\n return sorted(self.fanout.items(), key=lambda kv: (-kv[1], kv[0]))[:n]\n\n\n# ---------------------------------------------------------------------------\n# Section renderers\n# ---------------------------------------------------------------------------\n\n\ndef _md_escape(value: Any) -> str:\n if value is None:\n return \"-\"\n s = str(value)\n # Escape the two characters that break markdown tables.\n return s.replace(\"|\", r\"\\|\").replace(\"\\n\", \" \")\n\n\ndef _render_header(tree: Dict[str, Any], agent: Dict[str, Any]) -> str:\n # P2.2-1: section 1 — kv table. No input is trusted (agent fields\n # come from SOQL + metadata retrieve), but we escape pipes anyway.\n lines = [\"# Architecture — `{}` `{}`\".format(\n _md_escape(agent.get(\"api_name\") or \"?\"),\n _md_escape(agent.get(\"version\") or \"?\"),\n )]\n lines.append(\"\")\n lines.append(\"| Field | Value |\")\n lines.append(\"|---|---|\")\n rows = [\n (\"Master label\", agent.get(\"master_label\")),\n (\"Description\", agent.get(\"description\")),\n (\"Agent type\", agent.get(\"agent_type\")),\n (\"Type\", agent.get(\"type\")),\n (\"Template\", agent.get(\"agent_template\")),\n (\"Bot source\", agent.get(\"bot_source\")),\n (\"Generation\", agent.get(\"generation\")),\n (\"Planner name\", agent.get(\"planner_name\")),\n (\"Planner type\", agent.get(\"planner_type\")),\n (\"Bot id\", agent.get(\"bot_id\")),\n (\"Schema version\", tree.get(\"_schema_version\")),\n ]\n for label, value in rows:\n lines.append(f\"| {label} | {_md_escape(value)} |\")\n return \"\\n\".join(lines)\n\n\ndef _render_anatomy_summary(tree: Dict[str, Any], walker: _TreeWalker) -> str:\n # P2.2-1: section 2 renders health callout when _partial=true.\n kc = tree.get(\"_kind_counts\") or {}\n lines = [\"## 2. Anatomy summary\", \"\"]\n topic_count = kc.get(\"TOPIC\", len(walker.topics))\n action_count = kc.get(\"GEN_AI_FUNCTION\", len(walker.actions))\n flow_count = kc.get(\"FLOW\", len(walker.flows))\n apex_count = kc.get(\"APEX\", len(walker.apex))\n prompt_count = kc.get(\"PROMPT_TEMPLATE\", len(walker.prompts))\n stdaction_count = kc.get(\"STANDARD_ACTION\", len(walker.standard_actions))\n\n lines.append(\n \"Agent exposes **{} topics** and **{} declared actions** \"\n \"spanning **{} flows**, **{} apex classes**, **{} prompt templates**, \"\n \"and **{} standard actions**. Tree depth is {} across {} total nodes.\".format(\n topic_count, action_count, flow_count, apex_count,\n prompt_count, stdaction_count,\n tree.get(\"depth\", \"?\"), walker.total_nodes(),\n )\n )\n\n if tree.get(\"_partial\"):\n pending = tree.get(\"_pending_fetches\") or {}\n pending_count = sum(len(v) for v in pending.values())\n reason = tree.get(\"_partial_reason\") or \"unspecified\"\n lines.append(\"\")\n lines.append(\"> **Health: PARTIAL.** The tree did not fully converge.\")\n lines.append(f\"> - Reason: `{_md_escape(reason)}`\")\n lines.append(f\"> - Pending fetches: {pending_count}\")\n if pending:\n for key, items in pending.items():\n if items:\n lines.append(\n f\"> - `{_md_escape(key)}`: {len(items)} outstanding\"\n )\n\n # P2.2-1: health callout when planner_name is missing.\n agent = tree.get(\"agent\") or {}\n if not agent.get(\"planner_name\"):\n lines.append(\"\")\n lines.append(\n \"> **Health: WARN.** `planner_name` missing from agent metadata — \"\n \"downstream sections render best-effort from tree shape alone.\"\n )\n\n if tree.get(\"_unresolved\"):\n lines.append(\"\")\n lines.append(\n \"> **Health: WARN.** {} unresolved references — see section 8.\".format(\n len(tree[\"_unresolved\"])\n )\n )\n\n return \"\\n\".join(lines)\n\n\ndef _render_invocation_sequence(\n tree: Dict[str, Any],\n agent: Dict[str, Any],\n walker: _TreeWalker,\n caps: Dict[str, int],\n) -> str:\n # P2.2-1: section 3 — sequenceDiagram with cap check.\n lines = [\"## 3. Invocation sequence\", \"\"]\n generation = (agent.get(\"generation\") or \"classic\").lower()\n\n participants = [\"participant User\", \" participant Planner\"]\n if generation == \"nga\":\n participants.append(\" participant Orchestrator\")\n participants.append(\" participant SubAgent\")\n else:\n participants.append(\" participant TopicClassifier\")\n participants.append(\" participant ActionExecutor\")\n\n messages: List[str] = []\n messages.append(\" User->>+Planner: utterance\")\n if generation == \"nga\":\n messages.append(\" Planner->>+Orchestrator: plan\")\n messages.append(\" Orchestrator->>+SubAgent: dispatch (par/and)\")\n messages.append(\" SubAgent->>+ActionExecutor: invoke action\")\n messages.append(\" ActionExecutor-->>-SubAgent: result\")\n messages.append(\" SubAgent-->>-Orchestrator: subresult\")\n messages.append(\" Orchestrator-->>-Planner: aggregated\")\n elif (agent.get(\"planner_type\") or \"\").endswith(\"SequentialPlannerIntentClassifier\"):\n messages.append(\" Planner->>+ActionExecutor: direct intent->action\")\n messages.append(\" ActionExecutor-->>-Planner: result\")\n else:\n # Classic ReAct — one round-trip per topic (sampled at :5 for\n # readability). The cap check below counts the ACTUAL rendered\n # messages, so a 30-topic bot whose ReAct branch only emits 12\n # lines is correctly NOT truncated.\n for topic in walker.topics[:5]: # sample for readability\n label = _md_escape(topic[\"api_name\"])\n messages.append(f\" Planner->>+TopicClassifier: classify → {label}\")\n messages.append(f\" TopicClassifier-->>-Planner: topic={label}\")\n messages.append(\" Planner->>+ActionExecutor: invoke action\")\n messages.append(\" ActionExecutor-->>-Planner: result\")\n\n messages.append(\" Planner-->>-User: response\")\n\n # cap against ACTUAL rendered message count, not a\n # potential / over-estimated figure. The prior implementation used\n # `2 * len(walker.topics) + len(walker.actions) + 2`, which false-\n # tripped on large bots because the ReAct branch only emits\n # `2 * min(len(walker.topics), 5) + 2` lines (topics[:5] sampling).\n # The rendered-list length is the single source of truth.\n msg_count = len(messages)\n if msg_count > caps.get(\"sequenceDiagram\", 60):\n lines.append(_truncation_placeholder(\n kind=\"sequenceDiagram\", total=msg_count,\n cap=caps[\"sequenceDiagram\"],\n top_fanout=walker.top_fanout(5),\n catalog_pointer=\"section 5 (action catalog)\",\n ))\n return \"\\n\".join(lines)\n\n rendered = load_mermaid(\n \"invocation_sequence\",\n PARTICIPANTS=\"\\n\".join(participants).lstrip(),\n MESSAGES=\"\\n\".join(messages).lstrip(),\n )\n lines.append(\"```mermaid\")\n lines.append(rendered)\n lines.append(\"```\")\n return \"\\n\".join(lines)\n\n\ndef _render_action_tree(\n tree: Dict[str, Any],\n walker: _TreeWalker,\n caps: Dict[str, int],\n) -> str:\n # P2.2-1: section 3 — flowchart + subgraphs per topic; cycle back-edges.\n lines = [\"## 3. Action tree\", \"\"]\n total_nodes = walker.total_nodes()\n if total_nodes > caps.get(\"flowchart\", 200):\n lines.append(_truncation_placeholder(\n kind=\"flowchart\", total=total_nodes,\n cap=caps[\"flowchart\"],\n top_fanout=walker.top_fanout(5),\n catalog_pointer=\"section 5 (action catalog) and section 7 (artifact catalogs)\",\n ))\n lines.append(\"\")\n lines.append(_render_action_tree_ascii(walker))\n return \"\\n\".join(lines)\n\n # Build subgraphs and edges\n subgraphs: List[str] = []\n for topic in walker.topics:\n sg_id = _safe_id(topic[\"api_name\"])\n sg_lines = [f\" subgraph {sg_id}[\\\"{_md_escape(topic['label'])}\\\"]\"]\n for action in topic[\"actions\"]:\n node_id = _safe_id(action[\"api_name\"])\n label = _display_name(action.get(\"raw\") or action)\n sg_lines.append(f\" {node_id}[\\\"{_md_escape(label)}\\\"]\")\n sg_lines.append(\" end\")\n subgraphs.append(\"\\n\".join(sg_lines))\n\n # Planner-level actions (no topic)\n planner_actions = [a for a in walker.actions if a.get(\"topic\") is None]\n if planner_actions:\n sg_lines = [\" subgraph _plannerActions[\\\"(plannerActions)\\\"]\"]\n for action in planner_actions:\n node_id = _safe_id(action[\"api_name\"])\n label = _display_name(action.get(\"raw\") or action)\n sg_lines.append(f\" {node_id}[\\\"{_md_escape(label)}\\\"]\")\n sg_lines.append(\" end\")\n subgraphs.append(\"\\n\".join(sg_lines))\n\n edges: List[str] = []\n for parent, child, kind in walker.edges:\n pid = _safe_id(parent)\n cid = _safe_id(child)\n edges.append(f\" {pid} --> {cid}\")\n\n # Cycle back-edges (dotted)\n for node_label, cycle_to in walker.cycles:\n nid = _safe_id(node_label)\n tid = _safe_id(cycle_to)\n edges.append(f\" {nid} -.->|cycle_back_to: {_md_escape(cycle_to)}| {tid}\")\n\n rendered = load_mermaid(\n \"action_tree\",\n SUBGRAPHS=\"\\n\\n\".join(subgraphs).lstrip() if subgraphs else \"%% no topics\",\n EDGES=\"\\n\".join(edges).lstrip() if edges else \"%% no edges\",\n )\n lines.append(\"```mermaid\")\n lines.append(rendered)\n lines.append(\"```\")\n lines.append(\"\")\n lines.append(\"**ASCII appendix**\")\n lines.append(\"\")\n lines.append(\"```\")\n lines.append(_render_action_tree_ascii(walker))\n lines.append(\"```\")\n return \"\\n\".join(lines)\n\n\ndef _render_action_tree_ascii(walker: _TreeWalker) -> str:\n out: List[str] = []\n root = walker.tree.get(\"root\") or {}\n out.append(f\"{root.get('api_name', 'ROOT')} ({root.get('kind', 'BOT_DEFINITION')})\")\n _ascii_recurse(root.get(\"children\") or [], out, depth=1, seen=set())\n return \"\\n\".join(out)\n\n\ndef _ascii_recurse(\n children: List[Dict[str, Any]],\n out: List[str],\n depth: int,\n seen: set[int],\n) -> None:\n for child in children:\n if not isinstance(child, dict):\n continue\n nid = id(child)\n if nid in seen:\n continue\n seen.add(nid)\n name = _display_name(child)\n kind = child.get(\"kind\") or \"?\"\n # surface BOTH cycle AND max-depth truncation in the ASCII\n # tree view. Prefer `_truncated[\"reason\"]`; fall back to the\n # legacy `_cycle_back_to` string (older parse_wave output).\n trunc = child.get(\"_truncated\") or {}\n if trunc.get(\"reason\") == \"max-depth\":\n marker = \" [depth-capped]\"\n elif trunc.get(\"reason\") == \"cycle\" or child.get(\"_cycle_back_to\"):\n marker = \" [cycle]\"\n else:\n marker = \"\"\n out.append(f\"{' ' * depth}├── [{kind}] {name}{marker}\")\n grand = child.get(\"children\") or []\n if grand:\n _ascii_recurse(grand, out, depth + 1, seen)\n\n\ndef _render_topic_anatomy(walker: _TreeWalker) -> str:\n # P2.2-1: section 4 — H3 per topic + kv list. Empty-case: 0 topics is\n # valid for SequentialPlannerIntentClassifier.\n lines = [\"## 4. Topic anatomy\", \"\"]\n if not walker.topics:\n lines.append(\"_No topics defined (planner exposes actions directly)._\")\n return \"\\n\".join(lines)\n for topic in walker.topics:\n lines.append(f\"### `{_md_escape(topic['api_name'])}`\")\n lines.append(\"\")\n lines.append(f\"- Label: {_md_escape(topic['label'])}\")\n lines.append(f\"- Action count: {len(topic['actions'])}\")\n if topic[\"actions\"]:\n lines.append(\"- Actions:\")\n for action in topic[\"actions\"]:\n label = _display_name(action.get(\"raw\") or action)\n lines.append(f\" - `{_md_escape(label)}`\")\n lines.append(\"\")\n return \"\\n\".join(lines).rstrip()\n\n\ndef _render_action_catalog(walker: _TreeWalker) -> str:\n # P2.2-1: section 5 — markdown table of actions.\n lines = [\"## 5. Action catalog\", \"\"]\n if not walker.actions:\n lines.append(\"_No actions declared._\")\n return \"\\n\".join(lines)\n lines.append(\"| Action | Topic | Unwraps to |\")\n lines.append(\"|---|---|---|\")\n for action in walker.actions:\n raw = action.get(\"raw\") or {}\n unwrap = raw.get(\"unwraps_to\") or {}\n unwrap_str = \"-\"\n if unwrap:\n unwrap_str = \"{} `{}`\".format(\n unwrap.get(\"kind\", \"?\"),\n _display_name(unwrap),\n )\n lines.append(\"| `{}` | {} | {} |\".format(\n _md_escape(action[\"api_name\"]),\n _md_escape(action.get(\"topic\") or \"(plannerAction)\"),\n _md_escape(unwrap_str),\n ))\n return \"\\n\".join(lines)\n\n\ndef _planner_state_for_generation(\n agent: Dict[str, Any], generation: str,\n) -> Tuple[List[str], List[str]]:\n \"\"\"Return (states, transitions) for the planner state machine.\"\"\"\n planner_type = (agent.get(\"planner_type\") or \"\").lower()\n if generation == \"nga\":\n states = [\n \"[*] --> Planning\",\n \" state Planning\",\n \" state Orchestration {\",\n \" direction LR\",\n \" [*] --> Dispatch\",\n \" Dispatch --> SubAgentA\",\n \" Dispatch --> SubAgentB\",\n \" --\",\n \" SubAgentA --> Aggregate\",\n \" SubAgentB --> Aggregate\",\n \" }\",\n \" state Respond\",\n ]\n transitions = [\n \" Planning --> Orchestration: par/and dispatch\",\n \" Orchestration --> Respond: aggregated\",\n \" Respond --> [*]\",\n ]\n return states, transitions\n if planner_type.endswith(\"sequentialplannerintentclassifier\"):\n states = [\n \"[*] --> Classify\",\n \" state Classify\",\n \" state Execute\",\n \" state Respond\",\n ]\n transitions = [\n \" Classify --> Execute: intent\",\n \" Execute --> Respond: result\",\n \" Respond --> [*]\",\n ]\n return states, transitions\n # Default: classic ReAct\n states = [\n \"[*] --> Thought\",\n \" state Thought\",\n \" state Action\",\n \" state Observation\",\n \" state Respond\",\n ]\n transitions = [\n \" Thought --> Action: pick tool\",\n \" Action --> Observation: tool result\",\n \" Observation --> Thought: more reasoning\",\n \" Observation --> Respond: done\",\n \" Respond --> [*]\",\n ]\n return states, transitions\n\n\ndef _render_planner_state(\n agent: Dict[str, Any],\n generation: str,\n caps: Dict[str, int],\n) -> str:\n # P2.2-1: section 6 — stateDiagram-v2 with generation-aware branches.\n lines = [\"## 6. Planner state machine\", \"\"]\n\n if generation in (\"search\", \"byop\"):\n lines.append(\n \"_Custom planner — structure depends on the planner's Apex class_ \"\n f\"(`{_md_escape(agent.get('planner_type') or '?')}`). State \"\n \"diagram skipped; see section 7 for the backing Apex class body.\"\n )\n return \"\\n\".join(lines)\n\n states, transitions = _planner_state_for_generation(agent, generation)\n total = len(states) + len(transitions)\n if total > caps.get(\"stateDiagram\", 40):\n lines.append(_truncation_placeholder(\n kind=\"stateDiagram\", total=total,\n cap=caps[\"stateDiagram\"],\n top_fanout=[],\n catalog_pointer=\"section 7 (artifact catalogs)\",\n ))\n return \"\\n\".join(lines)\n\n rendered = load_mermaid(\n \"planner_state\",\n STATES=\"\\n\".join(states).lstrip(),\n TRANSITIONS=\"\\n\".join(transitions).lstrip(),\n )\n lines.append(\"```mermaid\")\n lines.append(rendered)\n lines.append(\"```\")\n return \"\\n\".join(lines)\n\n\ndef _render_data_flow(\n tree: Dict[str, Any],\n walker: _TreeWalker,\n caps: Dict[str, int],\n) -> str:\n # P2.2-1: section 6 — flowchart LR with labeled param edges.\n lines = [\"## 6. Data flow / context propagation\", \"\"]\n\n # Build node list — User, Planner, each topic, each action.\n nodes: List[str] = [\" User([User utterance])\", \" Planner[Planner]\"]\n for topic in walker.topics:\n nodes.append(f\" {_safe_id(topic['api_name'])}[Topic: {_md_escape(topic['api_name'])}]\")\n\n edges: List[str] = [\" User --> Planner\"]\n for topic in walker.topics:\n edges.append(f\" Planner --> {_safe_id(topic['api_name'])}\")\n for action in topic[\"actions\"]:\n label = _display_name(action.get(\"raw\") or action)\n nodes.append(\n f\" {_safe_id(action['api_name'])}[[Action: {_md_escape(label)}]]\"\n )\n # Labeled edge when the planner attr metadata declares a\n # parameter hand-off; fall back to a bare edge otherwise.\n attr = (action.get(\"raw\") or {}).get(\"planner_attr\") or {}\n var_name = attr.get(\"variable_name\") or attr.get(\"name\")\n var_type = attr.get(\"data_type\") or attr.get(\"type\")\n if var_name:\n label = _md_escape(var_name)\n if var_type:\n label = f\"{label}: {_md_escape(var_type)}\"\n edges.append(\n f\" {_safe_id(topic['api_name'])} -->|{label}| \"\n f\"{_safe_id(action['api_name'])}\"\n )\n else:\n edges.append(\n f\" {_safe_id(topic['api_name'])} --> \"\n f\"{_safe_id(action['api_name'])}\"\n )\n\n total = len(nodes) + len(edges)\n if total > caps.get(\"flowchart\", 200):\n lines.append(_truncation_placeholder(\n kind=\"flowchart\", total=total,\n cap=caps[\"flowchart\"],\n top_fanout=walker.top_fanout(5),\n catalog_pointer=\"section 7 (artifact catalogs)\",\n ))\n return \"\\n\".join(lines)\n\n rendered = load_mermaid(\n \"data_flow\",\n NODES=\"\\n\".join(nodes).lstrip(),\n EDGES=\"\\n\".join(edges).lstrip(),\n )\n lines.append(\"```mermaid\")\n lines.append(rendered)\n lines.append(\"```\")\n return \"\\n\".join(lines)\n\n\ndef _render_artifact_catalogs(walker: _TreeWalker) -> str:\n # P2.2-1: section 7 — H3 per flow / apex / prompt + signature.\n lines = [\"## 7. Flow / Apex / Prompt catalogs\", \"\"]\n\n if walker.flows:\n lines.append(\"### Flows\")\n lines.append(\"\")\n for name in sorted(walker.flows):\n node = walker.flows[name]\n sig = node.get(\"signature\") or node.get(\"_signature\")\n # Gap 2 fix (2026-05-05): main._stamp_signatures stamps\n # `_signature_reason` on flows whose body we can't retrieve\n # (managed package, no active version, metadata fetch miss).\n # Surface the reason so the rendered markdown distinguishes\n # a known limitation from a silent hole.\n reason = node.get(\"_signature_reason\")\n lines.append(f\"#### `{_md_escape(name)}`\")\n lines.append(\"\")\n if sig:\n lines.append(\"```\")\n lines.append(str(sig))\n lines.append(\"```\")\n elif reason:\n lines.append(f\"_Signature not captured — {_md_escape(reason)}._\")\n else:\n lines.append(\"_Signature not captured._\")\n lines.append(\"\")\n\n if walker.apex:\n lines.append(\"### Apex classes\")\n lines.append(\"\")\n for name in sorted(walker.apex):\n node = walker.apex[name]\n sig = node.get(\"signature\") or node.get(\"_signature\")\n lines.append(f\"#### `{_md_escape(name)}`\")\n lines.append(\"\")\n if sig:\n lines.append(\"```\")\n lines.append(str(sig))\n lines.append(\"```\")\n else:\n lines.append(\"_Signature not captured._\")\n lines.append(\"\")\n\n if walker.prompts:\n lines.append(\"### Prompt templates\")\n lines.append(\"\")\n for name in sorted(walker.prompts):\n node = walker.prompts[name]\n sig = node.get(\"signature\") or node.get(\"_signature\")\n prompt_type = node.get(\"prompt_type\")\n # Gap C (2026-05-05): retrieve_prompt_templates stamps\n # master_label / content / inputs / _body_available onto\n # each PROMPT_TEMPLATE leaf. Emit the real body when\n # available; fall back to the stub only when `_body_available`\n # is explicitly False (retrieve failed / not requested) AND\n # there's no signature/type to show.\n master_label = node.get(\"master_label\")\n content = node.get(\"content\")\n inputs = node.get(\"inputs\") or []\n body_available = node.get(\"_body_available\")\n lines.append(f\"#### `{_md_escape(name)}`\")\n lines.append(\"\")\n if master_label:\n lines.append(f\"_Label: {_md_escape(master_label)}_\")\n lines.append(\"\")\n if prompt_type:\n lines.append(f\"- Type: `{_md_escape(prompt_type)}`\")\n if inputs:\n lines.append(\"**Inputs**:\")\n for inp in inputs:\n if not isinstance(inp, dict):\n continue\n iname = inp.get(\"name\") or \"?\"\n itype = inp.get(\"dataType\")\n if itype:\n lines.append(\n f\"- `{_md_escape(iname)}`: \"\n f\"`{_md_escape(itype)}`\"\n )\n else:\n lines.append(f\"- `{_md_escape(iname)}`\")\n lines.append(\"\")\n if content:\n lines.append(\"```text\")\n lines.append(str(content))\n lines.append(\"```\")\n elif sig:\n lines.append(\"```\")\n lines.append(str(sig))\n lines.append(\"```\")\n if (\n not content and not sig and not prompt_type\n and not master_label and not inputs\n ):\n if body_available is False:\n lines.append(\"_Body not retrieved._\")\n else:\n lines.append(\"_Details not captured._\")\n lines.append(\"\")\n\n if not (walker.flows or walker.apex or walker.prompts):\n lines.append(\"_No backing artifacts in tree._\")\n\n return \"\\n\".join(lines).rstrip()\n\n\ndef _render_unresolved(tree: Dict[str, Any], walker: _TreeWalker) -> str:\n # P2.2-1: section 8 — unresolved refs + artifact pointers.\n lines = [\"## 8. Unresolved refs + artifact pointers\", \"\"]\n unresolved = tree.get(\"_unresolved\") or []\n if unresolved:\n lines.append(\"> **{} unresolved refs.**\".format(len(unresolved)))\n lines.append(\"\")\n lines.append(\"| Kind | Api name | Reason |\")\n lines.append(\"|---|---|---|\")\n for ref in unresolved:\n lines.append(\"| {} | `{}` | {} |\".format(\n _md_escape(ref.get(\"kind\") or \"?\"),\n _md_escape(ref.get(\"api_name\") or \"?\"),\n _md_escape(ref.get(\"reason\") or \"?\"),\n ))\n else:\n lines.append(\"_No unresolved references._\")\n lines.append(\"\")\n lines.append(\"### Artifact pointers\")\n lines.append(\"\")\n lines.append(\n \"- Full tree JSON: same directory as this file (`metadata_tree.json`)\"\n )\n lines.append(\"- Build manifest: cache dir / `manifest.json`\")\n return \"\\n\".join(lines)\n\n\ndef _render_dependency_graph(\n tree: Dict[str, Any],\n walker: _TreeWalker,\n caps: Dict[str, int],\n) -> str:\n # P2.2-1: conditional — render only on unresolved or cycles.\n lines = [\"## Dependency graph (conditional)\", \"\"]\n unresolved = tree.get(\"_unresolved\") or []\n\n nodes: List[str] = []\n edges: List[str] = []\n seen: set[str] = set()\n\n def add_node(label: str, unresolved_flag: bool) -> None:\n nid = _safe_id(label)\n if nid in seen:\n return\n seen.add(nid)\n suffix = \":::unresolved\" if unresolved_flag else \"\"\n nodes.append(f\" {nid}[{_md_escape(label)}]{suffix}\")\n\n for parent, child, _ in walker.edges:\n add_node(parent, False)\n add_node(child, False)\n edges.append(f\" {_safe_id(parent)} --> {_safe_id(child)}\")\n\n for ref in unresolved:\n name = ref.get(\"api_name\") or \"?\"\n add_node(name, True)\n\n for node_label, cycle_to in walker.cycles:\n add_node(node_label, False)\n add_node(cycle_to, False)\n edges.append(\n f\" {_safe_id(node_label)} -.->|cycle| {_safe_id(cycle_to)}\"\n )\n\n total = len(nodes)\n if total > caps.get(\"graph\", 100):\n lines.append(_truncation_placeholder(\n kind=\"graph\", total=total,\n cap=caps[\"graph\"],\n top_fanout=walker.top_fanout(5),\n catalog_pointer=\"section 8 (unresolved refs)\",\n ))\n return \"\\n\".join(lines)\n\n rendered = load_mermaid(\n \"dependency_graph\",\n NODES=\"\\n\".join(nodes).lstrip() if nodes else \"%% no nodes\",\n EDGES=\"\\n\".join(edges).lstrip() if edges else \"%% no edges\",\n )\n lines.append(\"```mermaid\")\n lines.append(rendered)\n lines.append(\"```\")\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n\ndef _truncation_placeholder(\n *,\n kind: str,\n total: int,\n cap: int,\n top_fanout: List[Tuple[str, int]],\n catalog_pointer: str,\n) -> str:\n # P2.2-1: explicit visual placeholder. Mentions the diagram kind, the\n # over-cap count, and points at the catalog section.\n lines = [\n f\"> **[diagram truncated: {kind} — {total} elements exceed cap of {cap}]**\",\n ]\n if top_fanout:\n lines.append(\">\")\n lines.append(\"> Top 5 nodes by fan-out:\")\n for name, count in top_fanout:\n lines.append(f\"> - `{_md_escape(name)}` ({count})\")\n lines.append(\">\")\n lines.append(f\"> See {catalog_pointer} for the full listing.\")\n return \"\\n\".join(lines)\n\n\ndef _safe_id(value: str) -> str:\n \"\"\"Return a mermaid-safe identifier. Mermaid node ids must be\n alphanumeric + underscore; anything else breaks the parser.\"\"\"\n if not value:\n return \"n_empty\"\n out = []\n for ch in str(value):\n if ch.isalnum() or ch == \"_\":\n out.append(ch)\n else:\n out.append(\"_\")\n # Prefix digit-leading ids so they don't collide with mermaid syntax.\n result = \"\".join(out)\n if result and result[0].isdigit():\n result = f\"n_{result}\"\n return result or \"n_empty\"\n","content_type":"text/x-python; charset=utf-8","language":"python","size":40982,"content_sha256":"ab699ae19e3ac48d9be3a214d4d62d0ba3cac881a90c355832c166c43d814c92"},{"filename":"scripts/resolve_bot.py","content":"#!/usr/bin/env python3\n\"\"\"Bot + version resolution via two sf data query calls.\n\nReplaces the old agent's Phase 0b–0e (bot/version SOQL, natural-key sort,\nerror-block assembly) + Phase 1's parallel BotDefinition SOQL. One process,\none RESULT-block emission point.\n\nFlow:\n 1. `sf data query` for BotVersion WHERE BotDefinition.DeveloperName=$AGENT_API_NAME.\n 2. If 0 rows → follow-up query for AVAILABLE_BOTS, emit STATUS=AGENT_NOT_FOUND.\n 3. Natural-key sort versions (v10 > v9 > v2 > v1); pick explicit match or\n Active highest-numbered auto-pick.\n 4. If no match → emit STATUS=AGENT_VERSION_NOT_FOUND with AVAILABLE_VERSIONS\n in `v5(Active),v4(Inactive),...` form.\n 5. Run a second `sf data query` for BotDefinition (MasterLabel, Description,\n AgentType, Type, AgentTemplate, BotSource) and write `_bot_definition.json`\n for parse_wave.py.\n\nOn success, prints eval-friendly KV lines to stdout:\n BOT_FOUND=true\n BOT_ID=\u003c18-char>\n BOT_MASTER_LABEL=\u003cshlex-quoted>\n AGENT_VERSION=\u003cresolved version>\n VERSION_AUTO_PICKED=true|false\n\nAlso writes `$WORK_DIR/_bot_versions.json` + `$WORK_DIR/_bot_definition.json`\nas raw sidecars (archived into the cache by finalize.py).\n\nUsage:\n eval \"$(python3 resolve_bot.py)\"\n\nInputs (env):\n ORG_ALIAS required — sf CLI target org alias\n AGENT_API_NAME required — BotDefinition.DeveloperName\n AGENT_VERSION optional — explicit BotVersion.DeveloperName (if empty, auto-pick)\n WORK_DIR required — sidecar JSON landing area\n ORG_ID_15 optional — used for error-block context\n ORG_ID_18 optional — used for error-block context\n ERROR_TEE optional — tee path for error RESULT blocks\n\nOutputs:\n stdout on success: eval-able KV lines (above)\n stdout on error: full STATUS=AGENT_NOT_FOUND|AGENT_VERSION_NOT_FOUND block\n files: $WORK_DIR/_bot_versions.json, _bot_definition.json, _all_bots.json (on miss)\n exit 0 on success, 1 on any failure branch\n\"\"\"\nimport json\nimport os\nimport pathlib\nimport re\nimport shlex\nimport subprocess\nimport sys\n\n\ndef scrub(s) -> str:\n if not isinstance(s, str):\n s = \"\" if s is None else str(s)\n bad = set(\"`$\\\"\\\\\\r\\t\\0\\n\")\n return \"\".join(c for c in s if c not in bad)\n\n\ndef emit_error_block(status: str, error_detail: str, extras: dict) -> None:\n \"\"\"Write a terminal RESULT block to stdout + $ERROR_TEE, then exit 1.\"\"\"\n lines = [\n \"=== RESULT ===\",\n f\"STATUS={status}\",\n f\"ERROR_DETAIL={scrub(error_detail)}\",\n f\"AGENT_API_NAME={scrub(os.environ.get('AGENT_API_NAME', ''))}\",\n f\"ORG_ID_15={scrub(os.environ.get('ORG_ID_15', ''))}\",\n f\"ORG_ID_18={scrub(os.environ.get('ORG_ID_18', ''))}\",\n ]\n for k, v in extras.items():\n lines.append(f\"{k}={scrub(v)}\")\n block = \"\\n\".join(lines) + \"\\n\"\n\n tee_path = os.environ.get(\"ERROR_TEE\")\n if tee_path:\n try:\n p = pathlib.Path(tee_path)\n p.parent.mkdir(parents=True, exist_ok=True)\n tmp = p.with_suffix(p.suffix + \".tmp\")\n tmp.write_text(block)\n os.replace(tmp, p)\n except OSError:\n pass\n\n sys.stdout.write(block)\n sys.exit(1)\n\n\ndef run_sf_query(query: str, out_path: pathlib.Path, org_alias: str) -> dict:\n \"\"\"Run `sf data query --json` and return parsed JSON (or {} on failure).\"\"\"\n try:\n result = subprocess.run(\n [\"sf\", \"data\", \"query\",\n \"--target-org\", org_alias,\n \"--json\",\n \"--query\", query],\n capture_output=True, text=True, timeout=60,\n )\n except (subprocess.TimeoutExpired, FileNotFoundError) as e:\n emit_error_block(\n \"AUTH_REQUIRED\",\n f\"sf data query failed ({type(e).__name__}: {e}) — check sf CLI install + auth\",\n {},\n )\n return {} # unreachable\n if result.returncode != 0:\n # Print stderr to help user debug, then bail as AUTH_REQUIRED (most\n # common cause is the target-org alias not being logged in)\n emit_error_block(\n \"AUTH_REQUIRED\",\n f\"sf data query returned rc={result.returncode}: {result.stderr[:200]}\",\n {},\n )\n try:\n out_path.write_text(result.stdout)\n return json.loads(result.stdout)\n except (OSError, json.JSONDecodeError) as e:\n emit_error_block(\n \"AUTH_REQUIRED\",\n f\"sf data query JSON parse error: {e}\",\n {},\n )\n return {} # unreachable\n\n\ndef natural_key(v: str):\n return [int(p) if p.isdigit() else p for p in re.split(r\"(\\d+)\", v or \"\")]\n\n\ndef main() -> int:\n try:\n org_alias = os.environ[\"ORG_ALIAS\"]\n agent_api_name = os.environ[\"AGENT_API_NAME\"]\n work_dir = pathlib.Path(os.environ[\"WORK_DIR\"])\n except KeyError as e:\n sys.stderr.write(f\"resolve_bot.py: missing env {e}\\n\")\n return 1\n\n # Defense-in-depth: bash harness pre-validates AGENT_API_NAME via\n # fs_guard, but importers that call resolve_bot.main() directly bypass\n # that gate — and the SOQL below is built with f-strings. Re-validate\n # here so the regex check is local to the SOQL construction. Mirrors\n # fetch_soql.py's belt-and-braces validation.\n from config import fs_guard # re-exported from _shared/\n try:\n fs_guard.validate_api_name(agent_api_name, label=\"AGENT_API_NAME\")\n except fs_guard.ValidationError as e:\n emit_error_block(\"INVALID_INPUT\", str(e), {})\n return 1 # unreachable\n\n explicit_version = os.environ.get(\"AGENT_VERSION\", \"\").strip()\n work_dir.mkdir(parents=True, exist_ok=True)\n\n # --- Step 1: BotVersion query (with BotDefinition join) ---\n q_versions = (\n \"SELECT Id, DeveloperName, Status, BotDefinitionId, \"\n \"BotDefinition.DeveloperName, BotDefinition.MasterLabel \"\n f\"FROM BotVersion WHERE BotDefinition.DeveloperName='{agent_api_name}'\"\n )\n versions_json = run_sf_query(q_versions, work_dir / \"_bot_versions.json\", org_alias)\n records = (versions_json.get(\"result\") or {}).get(\"records\") or []\n\n # --- Step 2: AGENT_NOT_FOUND branch ---\n if not records:\n q_all = \"SELECT DeveloperName FROM BotDefinition ORDER BY DeveloperName\"\n all_bots = run_sf_query(q_all, work_dir / \"_all_bots.json\", org_alias)\n bots_csv = \",\".join(\n r.get(\"DeveloperName\", \"\")\n for r in ((all_bots.get(\"result\") or {}).get(\"records\") or [])\n if r.get(\"DeveloperName\")\n )\n emit_error_block(\n \"AGENT_NOT_FOUND\",\n f\"BotDefinition.DeveloperName '{agent_api_name}' not found in org {org_alias}\",\n {\"AVAILABLE_BOTS\": bots_csv},\n )\n return 1 # unreachable\n\n # --- Step 3: sort (natural-key, DESC) + pick ---\n records.sort(key=lambda r: natural_key(r.get(\"DeveloperName\") or \"\"), reverse=True)\n picked = None\n if explicit_version:\n for v in records:\n if v.get(\"DeveloperName\") == explicit_version:\n picked = v\n break\n version_auto_picked = False\n else:\n for v in records:\n if v.get(\"Status\") == \"Active\":\n picked = v\n break\n version_auto_picked = True\n\n # --- Step 4: AGENT_VERSION_NOT_FOUND branch ---\n if not picked:\n vers_csv = \",\".join(\n f\"{v.get('DeveloperName', '?')}({v.get('Status', '?')})\"\n for v in records\n )\n requested = explicit_version or \"\u003cauto-pick>\"\n emit_error_block(\n \"AGENT_VERSION_NOT_FOUND\",\n f\"No matching BotVersion under BotDefinition {agent_api_name} (requested: '{requested}')\",\n {\n \"AVAILABLE_VERSIONS\": vers_csv,\n \"BOT_ID\": picked.get(\"BotDefinitionId\") if picked else (records[0].get(\"BotDefinitionId\") or \"\"),\n },\n )\n return 1 # unreachable\n\n bot_id = picked.get(\"BotDefinitionId\") or \"\"\n bd_node = picked.get(\"BotDefinition\") or {}\n bot_master_label = bd_node.get(\"MasterLabel\") or \"\"\n chosen_version = picked.get(\"DeveloperName\") or \"\"\n\n # --- Step 5: BotDefinition metadata (for parse_wave.py enrichment) ---\n q_def = (\n \"SELECT DeveloperName, MasterLabel, Description, AgentType, Type, \"\n \"AgentTemplate, BotSource FROM BotDefinition \"\n f\"WHERE DeveloperName='{agent_api_name}' LIMIT 1\"\n )\n try:\n result = subprocess.run(\n [\"sf\", \"data\", \"query\",\n \"--target-org\", org_alias,\n \"--json\",\n \"--query\", q_def],\n capture_output=True, text=True, timeout=30,\n )\n if result.returncode == 0:\n (work_dir / \"_bot_definition.json\").write_text(result.stdout)\n else:\n # Non-fatal; parse_wave.py tolerates an empty bot_definition.json\n (work_dir / \"_bot_definition.json\").write_text('{\"result\":{\"records\":[]}}')\n except (subprocess.TimeoutExpired, FileNotFoundError):\n (work_dir / \"_bot_definition.json\").write_text('{\"result\":{\"records\":[]}}')\n\n # --- Success: emit eval-friendly lines ---\n out_lines = [\n \"BOT_FOUND=true\",\n f\"BOT_ID={shlex.quote(bot_id)}\",\n f\"BOT_MASTER_LABEL={shlex.quote(bot_master_label)}\",\n f\"AGENT_VERSION={shlex.quote(chosen_version)}\",\n f\"VERSION_AUTO_PICKED={'true' if version_auto_picked else 'false'}\",\n ]\n sys.stdout.write(\"\\n\".join(out_lines) + \"\\n\")\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9594,"content_sha256":"3f441d37d24b1730a67caac96671f5d8e73ff406fd8b61b1ae1024f9f3d32562"},{"filename":"scripts/resolve_invocation_target.py","content":"\"\"\"NGA InvocationTarget ID-prefix router.\n\nNGA bots store Salesforce Ids in GenAiFunctionDefinition.InvocationTarget,\nnot DeveloperNames. This module resolves the kind by the 3-char key prefix.\n\nunknown prefixes MUST NEVER raise — Salesforce is free to introduce\nnew NGA target types (e.g., a hypothetical `0aN...` AgentCapability) and\nthe skill must degrade gracefully. Callers use `resolve_or_unresolved` to\nappend a `_unresolved[]` entry with a diagnostic reason and get back\n`(\"unknown\", \"skip\")` so the wave orchestrator can keep parsing.\n\nInvariants:\n * Only the 3-char prefix is consulted for Id-based routing — 15-char\n and 18-char Ids both map the same way.\n * `standard_action` kind is selected by caller context (bundle's\n `invocationTargetType == \"standardInvocableAction\"`), not by prefix —\n standard actions have no Id. Its presence in the Kind union is\n scaffolding for call-site type-checking.\n * `REGISTERED_PREFIXES` exposes the known prefix set as a frozenset for\n tests and probe-channel validation.\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nfrom typing import Literal\n\nKind = Literal[\n \"apex\",\n \"flow_definition\",\n \"flow_version\",\n \"prompt_template\",\n \"standard_action\",\n \"unknown\",\n]\nChannel = Literal[\n \"tooling_soql\",\n \"retrieve_required\",\n \"skip\",\n]\n\n_PREFIX_MAP: dict[str, tuple[Kind, Channel]] = {\n \"01p\": (\"apex\", \"tooling_soql\"),\n \"300\": (\"flow_definition\", \"tooling_soql\"),\n \"301\": (\"flow_version\", \"tooling_soql\"),\n \"0hf\": (\"prompt_template\", \"retrieve_required\"),\n}\n\n# public reverse-lookup set. `frozenset` (not set) so callers can't\n# mutate the authoritative table by accident.\nREGISTERED_PREFIXES: frozenset[str] = frozenset(_PREFIX_MAP.keys())\n\n# Salesforce Ids are 15 or 18 chars, alphanumeric only. This is the gate\n# between \"could be a real Id with an unknown prefix\" (treat as an NGA\n# addition we don't know about yet) and \"garbage input\" (empty, wrong\n# length, bad characters — treat as invalid format).\n_SF_ID_RE = re.compile(r\"^[A-Za-z0-9]+$\")\n_SF_ID_LENGTHS = frozenset({15, 18})\n\n\ndef looks_like_sf_id(id_str: object) -> bool:\n \"\"\"True iff `id_str` is a 15- or 18-char alphanumeric string.\n\n promoted from module-private `_looks_like_sf_id`\n so `main._route` can gate calls to `resolve_or_unresolved` on the\n Id shape without reaching into a leading-underscore symbol (same\n surface). Underscore form retained as a deprecated alias.\n \"\"\"\n if not isinstance(id_str, str):\n return False\n if len(id_str) not in _SF_ID_LENGTHS:\n return False\n return bool(_SF_ID_RE.match(id_str))\n\n\n# backcompat alias — deprecated; use `looks_like_sf_id`.\n_looks_like_sf_id = looks_like_sf_id\n\n\ndef resolve_target_id(id_str: str) -> tuple[Kind, Channel]:\n \"\"\"Return (kind, resolvable_via) for a Salesforce Id.\n\n unknown prefixes NEVER raise — callers observing (\"unknown\", \"skip\")\n are expected to route through `resolve_or_unresolved` (or equivalent)\n to append an `_unresolved[]` entry. This function stays pure — it does\n not mutate any caller state, does not log.\n \"\"\"\n if not id_str or not isinstance(id_str, str) or len(id_str) \u003c 3:\n return (\"unknown\", \"skip\")\n prefix = id_str[:3]\n return _PREFIX_MAP.get(prefix, (\"unknown\", \"skip\"))\n\n\ndef resolve_or_unresolved(\n id_str: object,\n unresolved: list[dict],\n) -> tuple[Kind, Channel]:\n \"\"\"Like `resolve_target_id` but records failures into `unresolved`.\n\n semantics:\n * Valid Salesforce Id (15 or 18 alphanumeric chars) with known prefix:\n route as usual. `unresolved` untouched.\n * Valid Id shape, unknown prefix: append\n `{\"id\": \u003cid>, \"reason\": f\"unknown-id-prefix:{id[:3]}\"}` and\n return (\"unknown\", \"skip\"). This is the \"NGA shipped a new type\"\n path — the wave orchestrator keeps running.\n * Not an Id shape (empty/None/wrong length/bad chars): append\n `{\"id\": str(id_str), \"reason\": \"invalid-id-format\"}` and return\n (\"unknown\", \"skip\"). Distinct reason so triage can tell \"NGA\n added something new\" apart from \"caller passed us garbage.\"\n\n `unresolved` is mutated in place — same contract as `_unresolved[]`\n throughout the skill. Caller owns the list.\n \"\"\"\n if _looks_like_sf_id(id_str):\n kind, channel = resolve_target_id(id_str) # type: ignore[arg-type]\n if kind == \"unknown\":\n # Real Id shape, prefix we don't recognize — record the prefix\n # so triage can add it to _PREFIX_MAP.\n unresolved.append({\n \"id\": id_str,\n \"reason\": f\"unknown-id-prefix:{id_str[:3]}\", # type: ignore[index]\n })\n return (kind, channel)\n\n # Not a valid Id shape — empty, None, wrong length, or bad chars.\n unresolved.append({\n \"id\": str(id_str),\n \"reason\": \"invalid-id-format\",\n })\n return (\"unknown\", \"skip\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4951,"content_sha256":"32226bcf8a146074a33915efd7cb81d14ae087f7319839519365439ef6634ed9"},{"filename":"scripts/rest_client.py","content":"\"\"\"Tooling + Data REST client scaffolding with security-critical primitives.\n\nThis module ships the HTTP primitives used by every REST-path caller in the\nskill. Two security invariants are enforced here and must not be bypassed:\n\nthe Authorization header is STRIPPED from any cross-host redirect.\nPython's default HTTPRedirectHandler blindly forwards all request headers\n(including Authorization) to the redirect target. A compromised or\nattacker-controlled edge that returns a 302 to an arbitrary host would\notherwise receive the bearer token. We subclass HTTPRedirectHandler to\nstrip Authorization whenever the redirect target hostname differs from\nthe original. Callers MUST use `build_opener()` — never `urllib.request.\nurlopen` directly, which wires the default redirect handler.\n\naccess tokens MUST NEVER appear in exception strings, tracebacks,\nor logged output. `redact_error(exc)` returns a safe string representation\nwith `Authorization: Bearer ...` scrubbed. Every `except` that surfaces\nHTTP / subprocess error text runs the text through `redact_error` first.\nNever call `log.exception()` on an object that carries request headers.\n\"\"\"\nfrom __future__ import annotations\n\nimport email.utils\nimport functools\nimport json\nimport logging\nimport re\nimport time\nimport urllib.error\nimport urllib.parse\nimport urllib.request\nfrom typing import Any, Callable, Tuple\n\n\n# stdlib logger for transient-HTTP backoff breadcrumbs. The skill has\n# no global logging config — we emit at DEBUG and leave handler/level wiring\n# to callers. Absent any config, Python's \"last-resort\" handler suppresses\n# DEBUG records, so this is a no-op in production until someone enables it.\nlogger = logging.getLogger(__name__)\n\n\nclass RestClientError(RuntimeError):\n \"\"\"REST request failed — always constructed with a redacted message.\"\"\"\n\n\n# -----------------------------------------------------------------------------\n# error redaction\n# -----------------------------------------------------------------------------\n# Four patterns cover the bulk of token-leakage surfaces:\n# 1. `Authorization: Bearer \u003ctoken>` in any string context (header echo,\n# exception repr, stringified HTTP error).\n# 2. A bare `Bearer \u003ctoken>` with no `Authorization:` prefix (curl-style\n# traces, CLI stderr, hand-built header dicts).\n# 3. `accessToken=\u003ctoken>` / `access_token=\u003ctoken>` in URL-encoded bodies\n# or query strings (`sf org display` errors sometimes echo these).\n# 4. `\"accessToken\":\"\u003ctoken>\"` or `\"access_token\":\"\u003ctoken>\"` in JSON\n# payload echoes.\n#\n# Regexes are intentionally permissive on the token character class\n# (anything non-whitespace, non-quote, non-ampersand) — we'd rather\n# over-redact than miss a token with an unexpected encoding.\n\n_AUTH_HEADER_RE = re.compile(\n r\"(Authorization\\s*:\\s*Bearer\\s+)\\S+\",\n flags=re.IGNORECASE,\n)\n# Matches a bare `Bearer \u003cvalue>`; stops at whitespace or a quote.\n_BEARER_RE = re.compile(\n r\"(\\bBearer\\s+)[^\\s\\\"']+\",\n flags=re.IGNORECASE,\n)\n# Matches access[_]?Token=\u003cvalue> in url-encoded form; stops at & or whitespace.\n_ACCESS_TOKEN_QS_RE = re.compile(\n r\"(access[_]?token\\s*=\\s*)[^&\\s\\\"']+\",\n flags=re.IGNORECASE,\n)\n# Matches \"access[_]?Token\":\"\u003cvalue>\" in JSON; stops at closing quote.\n_ACCESS_TOKEN_JSON_RE = re.compile(\n r\"(\\\"access[_]?token\\\"\\s*:\\s*\\\")[^\\\"]*\",\n flags=re.IGNORECASE,\n)\n\n\ndef redact_text(text: str) -> str:\n \"\"\"Scrub bearer tokens and accessToken values from arbitrary text.\n\n Pure function; no side effects. Used by `redact_error` and available\n for use on any raw response body / stderr string before it reaches\n a log or exception message.\n\n renamed from `_redact_text` to a public symbol so\n cross-module callers (sf_cli._redact_subprocess_stderr) import a stable\n name instead of reaching into a module-private. The `_redact_text`\n alias below is retained for backwards compatibility with any caller\n that still imports the underscore-prefixed form; it will be removed\n in a future batch once all callers migrate.\n \"\"\"\n if not text:\n return text\n text = _AUTH_HEADER_RE.sub(r\"\\1\u003credacted>\", text)\n text = _BEARER_RE.sub(r\"\\1\u003credacted>\", text)\n text = _ACCESS_TOKEN_QS_RE.sub(r\"\\1\u003credacted>\", text)\n text = _ACCESS_TOKEN_JSON_RE.sub(r'\\1\u003credacted>', text)\n return text\n\n\n# deprecated alias. Retained so existing tests and any lingering\n# `from rest_client import _redact_text` imports keep working. New code\n# MUST use `redact_text` (public). Planned removal in a follow-up batch\n# once the codebase is clean.\n_redact_text = redact_text\n\n\ndef redact_error(exc: BaseException) -> str:\n \"\"\"Return a safe string representation of `exc`.\n\n guaranteed to:\n * include the exception type name (for triage)\n * include the exception message WITH bearer tokens scrubbed\n * NEVER include raw header collections, even if the exception carries them\n\n For HTTPError specifically, we do NOT call `exc.read()` — the caller is\n responsible for reading the body once (reading twice is usually a no-op\n but we avoid the extra I/O and any token embedded in the body is scrubbed\n at the caller via `redact_text` when it surfaces in the error message).\n \"\"\"\n exc_type = type(exc).__name__\n try:\n raw = str(exc)\n except Exception:\n raw = \"\u003cunreprable>\"\n return f\"{exc_type}: {redact_text(raw)}\"\n\n\n# -----------------------------------------------------------------------------\n# cross-host redirect strips Authorization\n# -----------------------------------------------------------------------------\n\n\nclass StripAuthOnCrossHostRedirect(urllib.request.HTTPRedirectHandler):\n \"\"\"Strip Authorization header on any cross-host redirect.\n\n Python's default HTTPRedirectHandler preserves request headers across\n 301/302/303/307/308. When `instanceUrl` is the trusted origin and a\n redirect points at ANY other hostname, we treat the Authorization\n header as tainted and drop it before the follow-up request goes out.\n\n Hostname comparison is case-insensitive (DNS is case-insensitive).\n We compare `urlparse(...).hostname` — which strips port and userinfo\n — because a port change on the same host is NOT a credential-leak\n vector, but treating it as cross-host would break legitimate\n `:443` → bare-host redirects.\n \"\"\"\n\n def redirect_request(self, req, fp, code, msg, headers, newurl):\n orig_host = urllib.parse.urlparse(req.full_url).hostname\n new_host = urllib.parse.urlparse(newurl).hostname\n # Normalize for case-insensitive comparison. `None == None` is safe\n # (both malformed URLs treated as \"same host\" — urllib would fail\n # the follow-up anyway).\n same_host = (orig_host or \"\").lower() == (new_host or \"\").lower()\n\n if same_host:\n # Default behavior: preserve Authorization (same-host redirect is\n # standard practice; stripping would break legitimate OAuth flows).\n return super().redirect_request(req, fp, code, msg, headers, newurl)\n\n # cross-host redirect — build a NEW Request without any\n # Authorization header. We cannot mutate `req` in place because\n # urllib's default handler reuses it; callers that retain a reference\n # would observe the mutation.\n new_req = super().redirect_request(req, fp, code, msg, headers, newurl)\n if new_req is None:\n return None\n # Authorization may have been set via add_header (lowercased internal\n # storage as \"Authorization\") or via unredirected_hdrs. Remove both.\n # Note: `Request.headers` stores header names in the form produced by\n # `.capitalize()` (so \"Authorization\" lands as \"Authorization\" since\n # .capitalize() leaves single-word strings unchanged at the first\n # letter — but we defensively pop both casings).\n for hdr_name in list(new_req.headers.keys()):\n if hdr_name.lower() == \"authorization\":\n new_req.headers.pop(hdr_name, None)\n for hdr_name in list(new_req.unredirected_hdrs.keys()):\n if hdr_name.lower() == \"authorization\":\n new_req.unredirected_hdrs.pop(hdr_name, None)\n return new_req\n\n\ndef build_opener() -> urllib.request.OpenerDirector:\n \"\"\"Return an OpenerDirector wired with StripAuthOnCrossHostRedirect.\n\n every REST call in this module must go through an opener built\n here. Direct use of `urllib.request.urlopen(...)` bypasses the redirect\n handler and reintroduces the cross-host-token-leak vulnerability.\n \"\"\"\n return urllib.request.build_opener(StripAuthOnCrossHostRedirect())\n\n\n# -----------------------------------------------------------------------------\n# 401 token refresh decorator + tooling/data query helpers\n# -----------------------------------------------------------------------------\n# Motivation: `sf org display` returns an access token with a short TTL\n# (typically 15min–2h). A pipeline run with many Flow / Tooling fetches can\n# exceed TTL. Without a refresh path the pipeline fails mid-run with a raw\n# 401 and the user has to re-run from scratch.\n#\n# Design: `retry_on_401` is a decorator-factory that takes a `refresh_fn`\n# closure. On 401 (HTTP status OR body contains INVALID_SESSION_ID) it\n# invokes `refresh_fn()`, retries the wrapped call ONCE, and surfaces the\n# original error if the retry also 401s. The refresh closure is passed\n# per-call rather than stored globally so the client stays stateless and\n# testable — each caller wires its own `lambda: run_sf(\"org_display\", ...)`.\n#\n# Redaction: any error text that surfaces from this decorator runs through\n# `redact_error` / `redact_text` . The 401 body is read once and\n# scrubbed before being used for INVALID_SESSION_ID detection — we never\n# log or re-raise raw bytes from the wire.\n\n_INVALID_SESSION_MARKER = \"INVALID_SESSION_ID\"\n\n\n_ERROR_ENVELOPE_KEYS = (\"errors\", \"error\", \"errorDetails\", \"messages\")\n\n\ndef _body_indicates_invalid_session(body: Any) -> bool:\n \"\"\"detect `INVALID_SESSION_ID` in a parsed or raw response body.\n\n Some SF endpoints (notably a few Tooling paths) return HTTP 200 with an\n error body rather than 401. The documented SF error shape is a list of\n `{\"errorCode\": \"INVALID_SESSION_ID\", \"message\": \"...\"}` dicts at the\n body root.\n\n BUGFIX (2026-05-03): the prior implementation recursed into ALL\n `dict.values()` and fell back to substring-matching the stringified\n form. Tooling Query responses for ApexClass include a `records` list\n whose `Body` field is the raw Apex source — which can legitimately\n contain the literal string `INVALID_SESSION_ID` (as an errorCode\n constant referenced by catch/rethrow code, e.g. `XCSF_FlowFaultMessage`\n and `SkillRulesMatchAction` in the real-org fixture). That tripped the\n value-walk and the substring fallback, misclassifying a 200 OK success\n response as an auth failure and forcing a spurious\n `INVALID_SESSION_ID after refresh` envelope into Wave B's `_unresolved`.\n\n The tightened rules:\n 1. Top-level list/tuple: the SF error envelope — recurse (one level\n is enough in practice, but recursion is safe because list items\n are error dicts, not data rows with Apex bodies).\n 2. Dict: a positive match requires `errorCode == INVALID_SESSION_ID`\n at THIS level, OR recursion into a well-known error-envelope key\n (`errors`, `error`, `errorDetails`, `messages`). Data keys like\n `records`, `Body`, `attributes` are NEVER walked — that was the\n bug. Typos / shape variants still surface through the\n error-envelope keys, which is the real world observed-shape set.\n 3. Top-level raw str/bytes: an unparsed error body. A substring\n match here is still safe because this helper is only called on\n the TOP-LEVEL parsed response (never on a field inside a success\n dict), so a raw-string body means SF literally returned text\n rather than JSON — in which case substring match is the best\n we can do.\n 4. Anything else (int, float, None, other types): not an error\n shape. Return False.\n \"\"\"\n if body is None:\n return False\n if isinstance(body, (list, tuple)):\n return any(_body_indicates_invalid_session(item) for item in body)\n if isinstance(body, dict):\n code = body.get(\"errorCode\") or body.get(\"error_code\")\n if isinstance(code, str) and _INVALID_SESSION_MARKER in code:\n return True\n # Only recurse into known error-envelope keys. Walking arbitrary\n # values misclassifies legitimate success payloads whose data\n # fields (e.g. ApexClass.Body) happen to contain the string\n # `INVALID_SESSION_ID`.\n for key in _ERROR_ENVELOPE_KEYS:\n if key in body and _body_indicates_invalid_session(body[key]):\n return True\n return False\n if isinstance(body, (str, bytes)):\n text = body.decode(\"utf-8\", errors=\"replace\") if isinstance(body, bytes) else body\n return _INVALID_SESSION_MARKER in text\n return False\n\n\nclass _InvalidSessionSignal(Exception):\n \"\"\"Internal-only: tunnel INVALID_SESSION_ID-in-200-body through retry_on_401.\n\n Never escapes this module — the decorator catches it, refreshes, retries,\n and on a second failure surfaces a RestClientError (redacted).\n \"\"\"\n\n\ndef _is_invalid_session_403(exc: urllib.error.HTTPError) -> bool:\n \"\"\"detect HTTP 403 + `INVALID_SESSION_ID` in body.\n\n Some SF endpoints respond to an expired session with `403 Forbidden`\n (body: `{\"errorCode\": \"INVALID_SESSION_ID\"}`) instead of 401. We treat\n that shape as auth-refresh-triggering just like 401. Any OTHER 403\n (permission denied, IP restriction, etc.) propagates unchanged — we\n don't burn a refresh on a non-auth 403.\n\n Reading `.read()` on an HTTPError is a one-shot — callers that surface\n this exception must not re-read the body. For our retry path this is\n fine: the decorator consumes the body once to decide, then discards\n the exception after retry.\n \"\"\"\n if exc.code != 403:\n return False\n try:\n body = exc.read()\n except Exception:\n return False\n if not body:\n return False\n try:\n text = body.decode(\"utf-8\", errors=\"replace\") if isinstance(body, bytes) else str(body)\n except Exception:\n return False\n return _INVALID_SESSION_MARKER in text\n\n\ndef retry_on_401(\n refresh_fn: Callable[[], Tuple[str, str]],\n) -> Callable[[Callable[..., Any]], Callable[..., Any]]:\n \"\"\"Decorator: retry a REST call ONCE after refreshing the session token.\n\n detects three auth-failure shapes:\n 1. `urllib.error.HTTPError` with `code == 401`\n 2. `urllib.error.HTTPError` with `code == 403` AND body contains\n `INVALID_SESSION_ID` (some SF endpoints return 403 for\n expired sessions; non-auth 403 is NOT treated as refreshable).\n 3. HTTP 200 with response body containing `INVALID_SESSION_ID` —\n callers signal this by raising `_InvalidSessionSignal`; the\n helpers below do the body inspection before returning.\n\n (REMEDIATE): the retry contract is documented and enforced here:\n `refresh_fn()` returns `(instance_url, access_token)` and the caller\n MUST thread those fresh credentials into the NEXT invocation of the\n wrapped callable. See `tooling_query` / `data_query`, which implement\n this via the `creds_provider` closure pattern (Option A). The previous\n design captured credentials in a closure at call time — refresh\n returned fresh creds, the wrapped closure kept using stale ones, and\n the retry 401'd again. That's the defect. `retry_on_401` itself\n is still credential-agnostic; the invariant lives in the caller.\n\n If the retry ALSO fails (401 / 403+INVALID_SESSION_ID / INVALID_SESSION_ID),\n the ORIGINAL error is re-raised (wrapped as `RestClientError` with a\n redacted message) — not the retry's error. Rationale: the user sees\n the first-attempt context (what call failed) rather than a downstream\n artefact of the retry path.\n\n Non-auth errors (500, 403 without INVALID_SESSION_ID, network timeout,\n etc.) propagate unchanged — refresh is NOT attempted.\n \"\"\"\n\n def _is_auth_http_error(exc: urllib.error.HTTPError) -> bool:\n # 401 is the classic shape; 403+INVALID_SESSION_ID is a\n # variant observed on some SF endpoints. All other 4xx/5xx\n # propagate without refresh.\n if exc.code == 401:\n return True\n if _is_invalid_session_403(exc):\n return True\n return False\n\n def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:\n @functools.wraps(fn)\n def wrapper(*args: Any, **kwargs: Any) -> Any:\n try:\n return fn(*args, **kwargs)\n except urllib.error.HTTPError as first_exc:\n if not _is_auth_http_error(first_exc):\n raise\n # auth-failure path — refresh and retry once.\n try:\n refresh_fn()\n except Exception as refresh_exc:\n # Refresh itself failed — surface the REFRESH error so\n # the user knows they need to re-auth, but keep token\n # redaction on the message.\n raise RestClientError(\n f\"token refresh failed after auth error: {redact_error(refresh_exc)}\"\n ) from None\n try:\n return fn(*args, **kwargs)\n except urllib.error.HTTPError as retry_exc:\n if _is_auth_http_error(retry_exc):\n raise RestClientError(\n f\"auth error after refresh (original): {redact_error(first_exc)}\"\n ) from None\n raise\n except _InvalidSessionSignal:\n raise RestClientError(\n f\"INVALID_SESSION_ID after refresh (original): \"\n f\"{redact_error(first_exc)}\"\n ) from None\n except _InvalidSessionSignal as first_sig:\n # body-path INVALID_SESSION_ID — refresh and retry once.\n try:\n refresh_fn()\n except Exception as refresh_exc:\n raise RestClientError(\n f\"token refresh failed after INVALID_SESSION_ID: \"\n f\"{redact_error(refresh_exc)}\"\n ) from None\n try:\n return fn(*args, **kwargs)\n except urllib.error.HTTPError as retry_exc:\n if _is_auth_http_error(retry_exc):\n raise RestClientError(\n f\"auth error after refresh (original INVALID_SESSION_ID): \"\n f\"{redact_error(first_sig)}\"\n ) from None\n raise\n except _InvalidSessionSignal:\n raise RestClientError(\n f\"INVALID_SESSION_ID after refresh: {redact_error(first_sig)}\"\n ) from None\n\n return wrapper\n\n return decorator\n\n\n# -----------------------------------------------------------------------------\n# transient HTTP (429/503) exponential backoff\n# -----------------------------------------------------------------------------\n# Motivation: SF REST endpoints occasionally return 429 (rate-limited) or\n# 503 (service unavailable) during API-limit windows, planned maintenance,\n# or transient edge hiccups. Without a retry the pipeline fails mid-run on\n# what is almost always a recoverable condition. Three attempts with\n# exponential backoff (1s → 2s → 4s, roughly — `base_delay * 2**attempt`)\n# resolve the vast majority of transient blips without meaningfully\n# extending happy-path latency.\n#\n# Scope (what this decorator does NOT do):\n# * Not a 401 refresher — that's `retry_on_401`. The layering is:\n# retry_on_401( retry_on_transient_http()( _query_once ) )\n# so 429s retry first (inner), and a 401 surfacing through that layer\n# triggers the outer refresh. The two concerns stay independent.\n# * No jitter. Bounded to 3 attempts + short delays; adding jitter here\n# would only matter if many callers burst-retried against the same\n# endpoint, which is not the shape of this skill's traffic.\n# * No retry on 5xx generally. Only 503 is retried because 500/502/504\n# often indicate deterministic server-side failures where retrying\n# won't help and we'd prefer to surface fast.\n#\n# Retry-After handling:\n# * If the header is present and parses as a non-negative number of\n# seconds OR an HTTP-date, we honor it — bounded below by base_delay *\n# 2**attempt so a \"Retry-After: 0\" doesn't disarm the backoff.\n# * Negative / malformed values fall back to the exponential schedule.\n#\n# Redaction: any HTTPError surfaced from the final failure goes through\n# `redact_error` at the caller's message site (see `_query_once`'s wrapper\n# chain). We do NOT stringify headers here — no `str(exc.headers)` or\n# `resp.read()` debug dumps on retry. URL logging strips the query string.\n\n\ndef _parse_retry_after(raw: str | None) -> float | None:\n \"\"\"Parse a Retry-After header value to seconds.\n\n Per RFC 9110 the value is either `delta-seconds` (non-negative int) or\n an HTTP-date. We accept floats too — some clients emit decimal seconds\n and the spec-strict int parse would silently lose them.\n\n Returns None for missing / malformed input; caller falls back to the\n exponential schedule.\n \"\"\"\n if not raw:\n return None\n raw = raw.strip()\n try:\n secs = float(raw)\n if secs \u003c 0:\n return None\n return secs\n except ValueError:\n pass\n # HTTP-date form. `parsedate_to_datetime` raises on malformed input on\n # 3.10+; wrap defensively.\n try:\n target = email.utils.parsedate_to_datetime(raw)\n except (TypeError, ValueError):\n return None\n if target is None:\n return None\n # Header expresses an absolute time; convert to a delta from now.\n now = time.time()\n delta = target.timestamp() - now\n if delta \u003c 0:\n return 0.0\n return delta\n\n\ndef retry_on_transient_http(\n max_retries: int = 3,\n base_delay: float = 1.0,\n) -> Callable[[Callable[..., Any]], Callable[..., Any]]:\n \"\"\"Decorator: retry on HTTP 429/503 up to `max_retries` times.\n\n on `urllib.error.HTTPError` with code in {429, 503}, sleep and\n retry. Any other exception (including 401 — let `retry_on_401`\n handle that, and any 5xx besides 503) propagates immediately.\n\n Retry semantics:\n * max_retries: number of retries AFTER the first attempt (default 3).\n * Total attempts = 1 + max_retries (default 4: 1 original + 3 retries).\n * Final attempt's exception propagates; no further retry.\n\n Delay per attempt = max(Retry-After-seconds, base_delay * 2**attempt).\n If Retry-After is absent or malformed, falls back to the exponential\n schedule. `attempt` is 0-indexed for the first retry (so the first\n sleep is base_delay * 1 = base_delay).\n\n Logs a DEBUG breadcrumb per retry via the module-level `logger`. No\n header values are logged — only the HTTP code + computed delay +\n attempt counter.\n \"\"\"\n\n def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:\n @functools.wraps(fn)\n def wrapper(*args: Any, **kwargs: Any) -> Any:\n # Total attempts = 1 original + max_retries retries. `attempt`\n # below is 0-indexed over the retry slots, which matches the\n # exponential formula base_delay * 2**attempt starting at\n # base_delay for attempt=0.\n for attempt in range(max_retries):\n try:\n return fn(*args, **kwargs)\n except urllib.error.HTTPError as exc:\n if exc.code not in (429, 503):\n # Any other HTTP status (including 401) — out of\n # scope for this decorator. Propagate so outer\n # layers (retry_on_401) can handle it.\n raise\n retry_after_raw = None\n try:\n # `exc.headers` is a Message-like; .get tolerates\n # the case where headers are absent on some\n # synthesized HTTPErrors.\n retry_after_raw = exc.headers.get(\"Retry-After\") if exc.headers else None\n except Exception:\n retry_after_raw = None\n hinted = _parse_retry_after(retry_after_raw)\n expo = base_delay * (2 ** attempt)\n delay = max(hinted, expo) if hinted is not None else expo\n logger.debug(\n \"retry_on_transient_http: HTTP %d, sleeping %.2fs (attempt %d/%d)\",\n exc.code, delay, attempt + 1, max_retries,\n )\n time.sleep(delay)\n continue\n # Final attempt — let any exception propagate directly. If\n # this attempt raises 429/503 the caller gets the HTTPError\n # unchanged (redaction happens at the wrapping call site).\n return fn(*args, **kwargs)\n\n return wrapper\n\n return decorator\n\n\ndef _query_once(\n instance_url: str,\n token: str,\n path: str,\n soql: str,\n) -> dict:\n \"\"\"Single SOQL GET — reads body, checks for INVALID_SESSION_ID.\n\n the Salesforce REST Query + Tooling Query\n endpoints are GET-only with the SOQL passed as a urlencoded `q=`\n querystring. The prior POST-with-JSON-body shape returned HTTP 405\n (\"Method Not Allowed\") on every real-org run — confirmed by curl\n against a real org:\n\n GET /services/data/v60.0/query/?q=\u003csoql> -> 200 OK\n POST /services/data/v60.0/query/ -> 405 Method Not Allowed\n\n We urlencode the SOQL (via `urllib.parse.urlencode({\"q\": soql})`)\n rather than string-concatenating so spaces, single quotes, commas,\n and parentheses in the query — all legal inside SOQL string\n literals — travel through the wire correctly. The JSON body and\n `Content-Type: application/json` header are dropped (GET carries\n no body; Content-Type with a bodyless GET is technically legal but\n meaningless and some edge proxies get irritated by it).\n\n uses `build_opener()` so cross-host redirects strip Authorization.\n any exception is surfaced with `redact_error`; raw body is run\n through `redact_text` before it reaches an exception message.\n signals `_InvalidSessionSignal` on 200-body-error so\n `retry_on_401` can refresh + retry.\n sets `Accept-Encoding: identity` to opt out of gzip. The\n responses here are small JSON payloads — gzip would add handling\n complexity (Content-Encoding detection + decompression) for zero\n measurable benefit. Explicit `identity` keeps the body-inspection\n path (INVALID_SESSION_ID detection) straightforward and testable.\n \"\"\"\n # urlencode handles all SOQL-legal characters — spaces,\n # single quotes inside string literals, commas, parentheses. Hand-\n # rolled concatenation would have to escape each class separately.\n qs = urllib.parse.urlencode({\"q\": soql})\n url = f\"{instance_url.rstrip('/')}{path}?{qs}\"\n req = urllib.request.Request(url, method=\"GET\")\n req.add_header(\"Authorization\", f\"Bearer {token}\")\n req.add_header(\"Accept\", \"application/json\")\n # force identity encoding — no gzip.\n req.add_header(\"Accept-Encoding\", \"identity\")\n\n opener = build_opener()\n try:\n with opener.open(req) as resp:\n raw = resp.read()\n except urllib.error.HTTPError as exc:\n # Bug D.1 fix: HTTPError.read() is one-shot. The default\n # `redact_error()` path stringifies just `HTTP Error 4xx: \u003creason>`\n # which loses the Salesforce error body — and that body is what\n # tells the operator WHY (e.g. INVALID_FIELD, MALFORMED_QUERY,\n # name-too-long, or a specific bad identifier). Pull the body\n # ONCE here, scrub it via redact_text, attach it to the exception\n # as `_response_body_preview`, and re-raise. Downstream callers\n # that surface `redact_error(exc)` keep their existing string\n # output; callers that want the body read it from the attribute.\n try:\n body = exc.read()\n except Exception:\n body = b\"\"\n try:\n body_text = body.decode(\"utf-8\", errors=\"replace\")\n except Exception:\n body_text = \"\"\n # Cap at 500 chars — enough to carry a Salesforce error array\n # element with the offending name in it; small enough that\n # downstream contexts don't bloat. redact_text strips bearer\n # tokens defensively (body shouldn't contain one but cheaper to\n # always scrub than reason about it).\n exc._response_body_preview = redact_text(body_text)[:500] # type: ignore[attr-defined]\n raise\n\n try:\n parsed = json.loads(raw.decode(\"utf-8\"))\n except (UnicodeDecodeError, json.JSONDecodeError) as e:\n raise RestClientError(\n f\"malformed query response: {redact_error(e)}\"\n ) from None\n\n # HTTP 200 + INVALID_SESSION_ID in body → tunnel up to the\n # decorator so it can refresh + retry.\n if _body_indicates_invalid_session(parsed):\n raise _InvalidSessionSignal(\n redact_text(json.dumps(parsed)[:500])\n )\n return parsed\n\n\n# (REMEDIATE): credentials flow through a provider closure, not\n# through function arguments that are captured once at decoration time.\n#\n# The previous design had a fatal defect:\n#\n# def tooling_query(instance_url, token, soql, *, on_401_refresh):\n# @retry_on_401(on_401_refresh)\n# def _call():\n# return _query_once(instance_url, token, ...) # stale closure\n# return _call()\n#\n# `refresh_fn()` returned `(new_url, new_token)` but those values were\n# thrown away — the decorator only called `refresh_fn()` for its side\n# effects, then re-invoked the same closure holding the ORIGINAL stale\n# `instance_url` / `token`. Retry 401'd again. Feature was dead.\n#\n# Option A (taken): `tooling_query` / `data_query` accept a\n# `creds_provider: Callable[[], Tuple[str, str]]` that is called EACH\n# attempt. `refresh_fn` is responsible for mutating whatever state the\n# provider reads from — typically a simple closure over a list. This\n# keeps `retry_on_401` credential-agnostic and makes the contract\n# (\"refresh updates the source that `creds_provider` reads from\")\n# explicit at the caller site instead of implicit in the helper.\n#\n# Test: `RetryOn401CredentialRefreshIntegrationTests` in\n# test_rest_client.py verifies that a real refresh carries fresh\n# credentials into the retry call; the prior design fails that test.\n\n\ndef tooling_query(\n creds_provider: Callable[[], Tuple[str, str]],\n soql: str,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> dict:\n \"\"\"GET a SOQL query against the Tooling API.\n\n `creds_provider()` is invoked on EACH attempt. `on_401_refresh`\n is responsible for updating whatever state the provider reads from\n before it returns — otherwise the retry would hit the same stale\n credentials and 401 again.\n\n `api_version` is a REQUIRED keyword-only arg.\n The prior hardcoded `v60.0` was the source of — real orgs\n run on v66 and expose fields v60 does not (confirmed empirically:\n `BotDefinition.Description` exists on v66, `INVALID_FIELD` on v60).\n Callers thread the version through from `main._derive_org_ids`, which\n reads it once from `sf org display --json`. Making the param REQUIRED\n (no default) surfaces a missed call-site as a TypeError at call time\n rather than a silent regression back to a stale pinned version.\n Shape is already enforced by `fs_guard.validate_api_version`\n (`^v[0-9]+\\\\.[0-9]+ agentforce-architecture-analyze — Skillopedia ) at the caller.\n\n Stateless: both closures are passed per-call. Each caller owns the\n storage the refresh writes to (typically a small `list[tuple[str, str]]`\n cell).\n \"\"\"\n path = f\"/services/data/{api_version}/tooling/query/\"\n\n # retry_on_transient_http is the INNER layer — 429/503 retries\n # happen first, and any 401 bubbling through triggers retry_on_401's\n # refresh path at the outer layer. Ordering matters: if transient\n # retry were outer, a 429 during a refresh retry would be mis-handled.\n @retry_on_401(on_401_refresh)\n @retry_on_transient_http()\n def _call() -> dict:\n # re-read creds on every attempt so refresh actually lands.\n instance_url, token = creds_provider()\n return _query_once(instance_url, token, path, soql)\n\n return _call()\n\n\ndef data_query(\n creds_provider: Callable[[], Tuple[str, str]],\n soql: str,\n *,\n api_version: str,\n on_401_refresh: Callable[[], Tuple[str, str]],\n) -> dict:\n \"\"\"GET a SOQL query against the Data API (non-Tooling).\n\n identical retry wiring to `tooling_query`, different URL path.\n `creds_provider` is invoked on every attempt so a refresh actually\n propagates fresh credentials into the retry.\n\n `api_version` is a REQUIRED keyword-only arg;\n see `tooling_query` for the rationale.\n \"\"\"\n path = f\"/services/data/{api_version}/query/\"\n\n # see `tooling_query` for the decorator-ordering rationale.\n @retry_on_401(on_401_refresh)\n @retry_on_transient_http()\n def _call() -> dict:\n # re-read creds on every attempt so refresh actually lands.\n instance_url, token = creds_provider()\n return _query_once(instance_url, token, path, soql)\n\n return _call()\n\n\ndef static_creds(instance_url: str, token: str) -> Callable[[], Tuple[str, str]]:\n \"\"\"Build a zero-state creds_provider that always returns the same pair.\n\n Convenience for callers that don't wire a refresh path — tests,\n single-shot scripts where a 401 is terminal. If the call 401s, the\n retry sees the same creds and fails again; this is correct behavior\n for an unrefreshable context (no point claiming otherwise).\n \"\"\"\n def _provider() -> Tuple[str, str]:\n return instance_url, token\n return _provider\n","content_type":"text/x-python; charset=utf-8","language":"python","size":34520,"content_sha256":"f2903a415a2987eded113bc23a5b436954fbe3c19f1ffaa408823a2769497c95"},{"filename":"scripts/retrieve_planner.py","content":"\"\"\"BFS graph builder — walks the cross-type reference graph.\n\n (visited keys = (kind, canonical_name) tuples) + (MAX_DEPTH=5 cap\n→ _partial + PARTIAL_OK) land in Batches 2 and 3.\n\"\"\"\nfrom __future__ import annotations\n\n\ndef build_fetch_graph() -> dict:\n \"\"\"TODO + BFS with per-kind visited tuples, cycle-safe,\n MAX_DEPTH cap surfaces as _partial=true in the tree.\n \"\"\"\n raise NotImplementedError(\"build_fetch_graph implements in P0 Batches 2+3\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":464,"content_sha256":"94566ae891b8724e497ed47bb0b2cb5178997b8092762a28d5fb7ab26376c331"},{"filename":"scripts/sanitize.py","content":"#!/usr/bin/env python3\n\"\"\"Scrub dangerous characters from a string before splicing into the RESULT block.\n\nStrips characters that would corrupt the `KEY=VALUE` line format parsers use to read\nthe RESULT block: backtick, dollar, double-quote, backslash, CR, tab, NUL, newline.\n\nUsed when interpolating user-controlled values (session_id, org_alias, sf CLI stderr\nexcerpts) into ERROR_DETAIL and other RESULT fields. Without scrubbing, a malicious\nor malformed input could inject fake KEY=VALUE pairs that downstream consumers\nwould treat as authoritative.\n\nUsage:\n _safe=$(python3 \"$SKILL/scripts/sanitize.py\" \"$raw_value\")\n\nArguments:\n argv[1] raw string (may be empty)\n\nOutput:\n stdout scrubbed string (no trailing newline)\n exit 0 always (even on empty input)\n\"\"\"\nimport sys\n\nBAD = set(\"`$\\\"\\\\\\r\\t\\0\\n\")\n\n\ndef scrub(s: str) -> str:\n return \"\".join(c for c in s if c not in BAD)\n\n\nif __name__ == \"__main__\":\n raw = sys.argv[1] if len(sys.argv) > 1 else \"\"\n sys.stdout.write(scrub(raw))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":1013,"content_sha256":"21a5c0824c610484aaaa3029c1634fbf074775d8f889c5853efd65c186c16bee"},{"filename":"scripts/sf_cli.py","content":"\"\"\"sf CLI recipe loader — reads assets/cli/*.yaml, runs argv via subprocess.\n\nSecurity: YAML recipes are loaded via yaml.safe_load only; yaml.load is banned.\nPyYAML's `yaml.load` deserializes arbitrary Python objects (including\nconstructor-injected code in some tags) and must never run on attacker-reachable\nYAML. The recipes live inside this repo and are author-controlled, but we\nstill use `safe_load` as defence in depth — a compromised dist/ or a botched\ncopy-paste can't escalate to code execution.\n\nthe `_SAFE_LOADER` module-level binding is THE loader used everywhere\nin this module; tests assert its identity against `yaml.safe_load`.\n\nevery stringification of a subprocess.CalledProcessError or related\nexception is routed through `rest_client.redact_error` so stderr containing\nan access token (rare but observed on some `sf` failure paths) cannot leak\ninto logs or RESULT blocks.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport re\nimport subprocess\nfrom pathlib import Path\nfrom typing import Any, Dict\n\nimport yaml\n\nfrom config import CLI_DIR\n# hoisted `redact_text` to module-top alongside\n# `redact_error`. Previously `_redact_subprocess_stderr` performed a lazy\n# import of the module-private `_redact_text` on every call, which (a)\n# tied this module to a name that could be renamed silently and (b) meant\n# an ImportError from rest_client would surface only at redaction time —\n# possibly in a frame whose locals held the raw, un-redacted stderr.\n# Hoisting is safe: rest_client does not import sf_cli (no cycle).\nfrom rest_client import redact_error, redact_text\n\n# the ONE allowed loader for this module. Test asserts identity.\n# NEVER change this to yaml.load (which can construct arbitrary Python\n# objects via tag hooks).\n_SAFE_LOADER = yaml.safe_load\n\n\nclass AuthRequired(RuntimeError):\n \"\"\"sf CLI returned a known auth-failure pattern.\n\n Carries a redacted stderr snippet — any bearer token in stderr is scrubbed\n via redact_error before reaching this message.\n \"\"\"\n\n\nclass SfCliError(RuntimeError):\n \"\"\"sf CLI returned non-zero exit or JSON status != 0.\n\n Message is redacted before construction.\n \"\"\"\n\n\n_show_access_token_capability_ok = False\n\"\"\"Set once the preflight succeeds; the check then never reruns in this\nprocess (401-triggered creds refreshes skip it). Failures are NOT cached,\nso a fixed PATH/upgrade is picked up on the next call.\"\"\"\n\n_SHOW_ACCESS_TOKEN_HELP_SIGNATURE_RE = re.compile(\n r\"\\$\\s*sf\\s+org\\s+auth\\s+show-access-token\\b\"\n)\n\"\"\"USAGE-line form that real ``--help`` output always contains\n(``$ sf org auth show-access-token -o \u003cvalue> ...``). The bare command name\nis not enough: unknown-command notices echo it too\n(``Command org:auth:show-access-token not found``,\n``show-access-token is not a sf command``). A zero-exit probe whose output\nlacks the USAGE form is treated as a failed check.\"\"\"\n\n_ANSI_ESCAPE_RE = re.compile(r\"\\x1b\\[[0-9;?]*[ -/]*[@-~]\")\n\n\ndef assert_show_access_token_capability() -> None:\n \"\"\"Fail fast when the installed sf CLI lacks ``sf org auth show-access-token``.\n\n That command is the only supported runtime token-retrieval path\n (forcedotcom/cli#3560). Probing ``--help`` is cheap, needs no org auth,\n and gives the operator an actionable upgrade message instead of a\n confusing downstream auth failure. A successful probe is cached for\n the process lifetime.\n\n Exit status alone is not trusted: a CLI build that answers an unknown\n command with exit 0 (e.g. printing top-level help or a \"not found\"\n notice) would otherwise pass. The probe therefore also requires the\n command's USAGE line (``$ sf org auth show-access-token``) in the\n ANSI-stripped stdout+stderr; if it is absent the check fails and is not\n cached.\n \"\"\"\n global _show_access_token_capability_ok\n if _show_access_token_capability_ok:\n return\n try:\n proc = subprocess.run(\n [\"sf\", \"org\", \"auth\", \"show-access-token\", \"--help\"],\n capture_output=True,\n text=True,\n timeout=30,\n check=True,\n )\n except FileNotFoundError as exc:\n raise SfCliError(\n \"sf CLI not found on PATH — install Salesforce CLI first\"\n ) from exc\n except subprocess.CalledProcessError as exc:\n safe_detail = _redact_subprocess_stderr(exc.stderr or exc.stdout or \"\")\n raise SfCliError(\n \"sf CLI is missing required command 'sf org auth show-access-token'. \"\n \"Upgrade/reinstall Salesforce CLI to a version that provides this \"\n f\"command. Preflight detail: {safe_detail or 'unknown error'}\"\n ) from exc\n except subprocess.TimeoutExpired as exc:\n raise SfCliError(\n f\"sf CLI capability preflight timed out: {redact_error(exc)}\"\n ) from exc\n help_text = _ANSI_ESCAPE_RE.sub(\n \"\", f\"{proc.stdout or ''}\\n{proc.stderr or ''}\",\n )\n if not _SHOW_ACCESS_TOKEN_HELP_SIGNATURE_RE.search(help_text):\n # Zero exit without the command's help signature — the CLI did not\n # recognise the command. Fail (uncached) like a non-zero exit.\n # Redact before truncating so a token can never be split past the regex.\n safe_detail = _redact_subprocess_stderr(help_text.strip())[:500]\n raise SfCliError(\n \"sf CLI is missing required command 'sf org auth show-access-token'. \"\n \"Upgrade/reinstall Salesforce CLI to a version that provides this \"\n \"command. Preflight detail: \"\n f\"{safe_detail or 'help output lacked the command signature'}\"\n )\n _show_access_token_capability_ok = True\n\n\ndef _load_recipe(name: str) -> Dict[str, Any]:\n \"\"\"Read and parse a CLI recipe file with yaml.safe_load.\n\n _SAFE_LOADER is bound to yaml.safe_load at module top; using it\n here (instead of `yaml.safe_load` directly) makes the identity testable\n and gives a single point to review if the loader ever changes.\n \"\"\"\n path = CLI_DIR / f\"{name}.yaml\"\n with path.open(\"r\", encoding=\"utf-8\") as fh:\n recipe = _SAFE_LOADER(fh)\n if not isinstance(recipe, dict):\n raise SfCliError(\n f\"recipe {name!r} did not parse to a mapping (got {type(recipe).__name__})\"\n )\n return recipe\n\n\ndef _sub(arg: str, params: Dict[str, str]) -> str:\n \"\"\"Substitute {{KEY}} placeholders in a single argv element.\n\n Input validation: `params` values are expected to be already-validated\n strings (SKILL.md phase 1 runs fs_guard on every user-supplied input).\n We do not revalidate here because argv substitution is shell-escape-safe\n by construction — subprocess.run with a list argv never invokes a shell.\n \"\"\"\n out = arg\n for key, value in params.items():\n out = out.replace(f\"{{{{{key}}}}}\", value)\n return out\n\n\ndef _parse_stdout_json(stdout: str) -> Dict[str, Any]:\n \"\"\"Parse sf CLI's --json stdout.\n\n sf CLI emits `{\"status\": 0, \"result\": {...}}` on success; malformed\n stdout (e.g. when the CLI crashes before JSON serialization) raises\n SfCliError with a redacted message.\n \"\"\"\n try:\n return json.loads(stdout)\n except json.JSONDecodeError as e:\n # stdout could in theory contain a token on some crash paths;\n # redact before surfacing.\n raise SfCliError(\n f\"sf CLI stdout is not JSON: {redact_error(e)}\"\n ) from None\n\n\n_AUTH_LINE_PREFIX_RE = re.compile(r\"^(?:Error|Warning):\\s\") # @rule-suppress starter-sec-002 — re.compile, not python compile()\n\n\ndef _stderr_matches_auth(stderr: str, patterns) -> bool:\n \"\"\"Check if any auth-failure pattern appears on an Error:/Warning: line.\n\n previously a substring scan across the entire stderr, which\n false-positived on Node ESM warnings (\"Warning: @gthoppae/sf-cli-\n plugin-data360 is a linked ESM module\") if they happened to echo an\n auth pattern substring. Now we only match on lines starting with\n `Error:` or `Warning:` followed by whitespace. This keeps every\n true-positive we care about (real sf auth errors always surface as\n `Error: NoOrgAuthenticationError ...`) while pruning the noise.\n\n Matching rules:\n * Line starts with `Error:\u003cws>` or `Warning:\u003cws>` (anchored at\n start-of-line, so embedded-in-prose mentions don't match).\n * Pattern is a substring of the REST of that line (after the\n prefix). We strip only the prefix — we do not re-scope to word\n boundaries, since sf auth tokens like `NoOrgAuthenticationError`\n are always well-formed identifiers without adjacent alphanum.\n * Empty stderr / empty patterns → no match.\n \"\"\"\n if not stderr or not patterns:\n return False\n for line in stderr.splitlines():\n m = _AUTH_LINE_PREFIX_RE.match(line)\n if not m:\n continue\n tail = line[m.end():]\n if any(p in tail for p in patterns):\n return True\n return False\n\n\ndef run_sf(name: str, **params: str) -> Dict[str, Any]:\n \"\"\"Execute a recipe-defined sf CLI command and return parsed stdout.\n\n Steps:\n 1. Load recipe (yaml.safe_load).\n 2. Enforce required_params.\n 3. Substitute {{KEY}} into argv.\n 4. subprocess.run with list argv (no shell, no bash -c).\n 5. Parse stdout JSON; check `status == 0`.\n 6. On failure: classify as AuthRequired (stderr pattern match) or\n SfCliError; every message is redacted via redact_error .\n\n Exception semantics:\n * AuthRequired — user needs to `sf org login`\n * SfCliError — anything else (timeout, non-zero exit, JSON parse,\n status != 0)\n\n NEVER uses shell=True. NEVER calls log.exception(). NEVER echoes raw\n stderr without redaction .\n \"\"\"\n recipe = _load_recipe(name)\n\n required = set(recipe.get(\"required_params\") or [])\n missing = required - params.keys()\n if missing:\n raise SfCliError(f\"missing required params for {name!r}: {sorted(missing)}\")\n\n argv_template = recipe.get(\"argv\") or []\n if not isinstance(argv_template, list):\n raise SfCliError(f\"recipe {name!r} argv must be a list\")\n\n argv = [_sub(str(e), params) for e in argv_template]\n timeout = int(recipe.get(\"timeout_seconds\", 60))\n auth_patterns = recipe.get(\"auth_required_stderr_patterns\") or []\n\n try:\n cp = subprocess.run(\n argv,\n capture_output=True,\n text=True,\n timeout=timeout,\n check=False,\n )\n except subprocess.TimeoutExpired as e:\n # timeout exceptions carry stderr via e.stderr — redact.\n raise SfCliError(\n f\"sf CLI {name!r} timed out after {timeout}s: {redact_error(e)}\"\n ) from None\n except (OSError, subprocess.SubprocessError) as e:\n # any subprocess-layer error gets redacted.\n raise SfCliError(\n f\"sf CLI {name!r} invocation failed: {redact_error(e)}\"\n ) from None\n\n # Even on nonzero exit sf CLI usually still emits JSON on stdout; try\n # to parse before deciding failure mode.\n try:\n data = _parse_stdout_json(cp.stdout) if cp.stdout else {}\n except SfCliError:\n data = {}\n\n status_field = data.get(\"status\", 0) if isinstance(data, dict) else 1\n if cp.returncode != 0 or status_field != 0:\n # stderr could contain a token on some failure paths; redact\n # the ENTIRE string before deciding how to classify or what to show.\n safe_stderr = _redact_subprocess_stderr(cp.stderr)\n if _stderr_matches_auth(cp.stderr, auth_patterns):\n raise AuthRequired(safe_stderr or \"auth required\")\n # Include exit code + JSON status in the message (both are\n # numeric/well-known, no token risk) plus the redacted stderr tail.\n raise SfCliError(\n f\"sf CLI {name!r} failed \"\n f\"(exit={cp.returncode}, json_status={status_field}): {safe_stderr}\"\n )\n\n return data\n\n\ndef _redact_subprocess_stderr(stderr: str) -> str:\n \"\"\"run stderr text through the rest_client redaction regexes.\n\n stderr may contain `Authorization: Bearer ...` or `accessToken=...` if\n the sf CLI echoes a failing request; scrub before surfacing.\n\n calls the public `redact_text` (imported at module\n top). Previously imported the module-private `_redact_text` lazily on\n every call — a rename could have silently broken redaction, and an\n ImportError would have propagated through `run_sf` with the raw stderr\n still live in the traceback's frame locals.\n \"\"\"\n return redact_text(stderr or \"\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12590,"content_sha256":"f6be9c7516c5b43e174ffc74c29ea44aeb4ca319dfb716bb30230df079a1c592"},{"filename":"scripts/soql_loader.py","content":"\"\"\"SOQL template loader — reads assets/soql/*.soql and substitutes {{PARAM}} values.\n\nevery value passed in via **params is regex-revalidated at the\nsubstitution boundary via `fs_guard.validate_api_name`. This is defence\nin depth — callers (SKILL.md phase 1, parse_bundle.extract_planner_name,\nparse_wave.discovered_names) already validate at their own boundaries, but\ncentralizing the gate here guarantees that any future caller can't bypass\nit by forgetting to validate first.\n\nWhy no escape function: `validate_api_name` restricts every substituted\nstring to `^[A-Za-z0-9_]+ agentforce-architecture-analyze — Skillopedia — the legal character set for Salesforce\nDeveloperName / API-name identifiers. No quote, apostrophe, semicolon,\nparenthesis, or whitespace can ever reach the SOQL builder, so there is\nnothing to escape. Adding an escape step would be both dead code and a\nfalse reassurance that unvalidated input is somehow safe.\n\n`load_soql_in` adds support for `WHERE X IN (...)`\nlist placeholders. The BFS fan-out passes across Apex/Flow/Plugin/Function\ndiscovery each need to batch-query by a list of ids or names; forcing\none SOQL call per element would blow up both the query-count budget and\nthe request-latency floor. The new function shares the `load_soql`\nvalidation path (every element goes through `fs_guard.validate_api_name`)\nso list inputs cannot widen the SOQL-injection surface — `'`, `;`, `--`,\nwhitespace, and every other SOQL metachar remain unreachable. The\nexisting `load_soql` signature is unchanged.\n\nSOQL-string-substitution loader with validation at the substitution\nboundary. Every value substituted into a `{{token}}` placeholder is\nregex-validated before insertion.\n\"\"\"\nfrom __future__ import annotations\n\nfrom typing import Dict, List, Optional\n\nfrom config import SOQL_DIR, fs_guard # fs_guard re-exported by config.py from _shared/\n\n\nclass SoqlParamError(ValueError):\n \"\"\"Raised when a parameter value fails revalidation at the substitution site.\n\n Carries the failing key + a 20-char preview of the rejected value. Callers\n (parse_bundle, parse_wave, main.py) catch this and add the key to\n `_unresolved[]` with `reason=invalid-soql-param:\u003ckey>` so the pipeline\n continues with `STATUS=PARTIAL_OK` rather than aborting on a single\n malformed name.\n \"\"\"\n\n def __init__(self, key: str, value, reason: str) -> None:\n self.key = key\n self.reason = reason\n # Preview is bounded to 20 chars to avoid echoing arbitrarily-long\n # attacker-controlled strings into logs.\n try:\n preview = str(value)[:20]\n except Exception:\n preview = \"\u003cunreprable>\"\n self.preview = preview\n super().__init__(\n f\"SOQL param {key!r} rejected: {reason} (preview={preview!r})\"\n )\n\n\nclass SoqlTemplateNotFound(LookupError):\n \"\"\"Raised when `load_soql(name)` cannot locate a matching template file.\n\n the message deliberately carries ONLY the requested\n template name — never the absolute SOQL_DIR path. This prevents leaking\n the filesystem layout of the skill install (which the attacker does not\n otherwise need to know) via error messages that bubble up to logs or\n RESULT blocks.\n\n Distinct from `FileNotFoundError` so callers can tell \"template missing\"\n apart from \"filesystem I/O error\" (permission denied, etc.) at the\n except-clause layer.\n \"\"\"\n\n def __init__(self, name: str) -> None:\n # Preview bounds the echoed name at 60 chars. The validator forbids\n # traversal characters anyway, but defence in depth against an\n # arbitrarily-long attacker-controlled name ever reaching logs.\n try:\n preview = str(name)[:60]\n except Exception:\n preview = \"\u003cunreprable>\"\n self.name = preview\n super().__init__(f\"SOQL template not found: {preview!r}\")\n\n\ndef load_soql(name: str, **params) -> str:\n \"\"\"Read assets/soql/\u003cname>.soql and substitute {{PARAM}} placeholders.\n\n every value in `params` is revalidated via\n `fs_guard.validate_api_name` before being substituted. Invalid values\n (non-string, empty, or containing any character outside [A-Za-z0-9_])\n raise `SoqlParamError`.\n\n `name` itself is validated via the same\n `fs_guard.validate_api_name` regex BEFORE any filesystem access.\n Without this, a caller that sources `name` from data (config file,\n user argument, etc.) could read `SOQL_DIR/../../../tmp/pwn.soql` via\n traversal. All SOQL template filenames in this skill match\n `^[A-Za-z0-9_]+ agentforce-architecture-analyze — Skillopedia (e.g. `bot_version_lookup`, `plugins_by_planner`),\n so the regex is the correct gate.\n\n Substitution is single-pass: `str.replace` does not re-scan the output,\n so a value that contains `{{OTHER}}` cannot re-trigger substitution of\n a different key. This is verified by test_soql_loader.\n \"\"\"\n # validate the template name BEFORE touching the filesystem.\n # A raw `../../../etc/passwd` would otherwise resolve via read_text().\n try:\n fs_guard.validate_api_name(name, label=\"soql_template_name\")\n except fs_guard.ValidationError as e:\n raise SoqlParamError(\"soql_template_name\", name, e.reason) from None\n\n # revalidate every SOQL param before substitution. No exceptions,\n # no \"this caller already validated\" shortcuts. Defence in depth.\n for key, value in params.items():\n try:\n fs_guard.validate_api_name(value, label=key)\n except fs_guard.ValidationError as e:\n raise SoqlParamError(key, value, e.reason) from None\n\n # translate FileNotFoundError into SoqlTemplateNotFound so the\n # absolute SOQL_DIR path never bleeds into caller-visible error text.\n # We intentionally do NOT chain the original exception (`from None`) —\n # the original carries the full path in `filename`/`strerror`.\n try:\n soql = (SOQL_DIR / f\"{name}.soql\").read_text()\n except FileNotFoundError:\n raise SoqlTemplateNotFound(name) from None\n\n for key, value in params.items():\n # Plain str.replace: single-pass by construction. No re-substitution\n # of placeholders that appear inside a substituted value (see test).\n soql = soql.replace(f\"{{{{{key}}}}}\", value)\n return soql.strip()\n\n\ndef load_soql_in(\n name: str,\n *,\n string_params: Optional[Dict[str, str]] = None,\n list_params: Dict[str, List[str]],\n) -> str:\n \"\"\"Read assets/soql/\u003cname>.soql and substitute both scalar and list placeholders.\n\n the BFS fan-out builds `WHERE X IN (:list)` queries\n (apex_class_bodies_by_names, flow_definition_by_ids, plugin_functions_\n by_plugin_ids, etc.). The existing `load_soql` only handles scalar\n string substitution; this sibling covers the list case without\n touching that signature.\n\n Parameters\n ----------\n name:\n Template filename (without `.soql`). Validated via\n `fs_guard.validate_api_name` BEFORE any filesystem access — same\n defence-in-depth gate as `load_soql` .\n string_params:\n Optional scalar {{KEY}} → value map. Values revalidated through\n `fs_guard.validate_api_name` — identical path to `load_soql`.\n Useful for templates that mix a single scalar value with a list\n in the same query. No active callers at present (retired with\n `flow_definition_ids_by_name_with_ns.soql` on 2026-05-05); kept\n on the signature as a stable extension point.\n list_params:\n Required. {{KEY}} → list-of-strings map. Each element is\n revalidated through `fs_guard.validate_api_name`; any failure\n aborts the whole call with `SoqlParamError(key=\u003clist-key>, ...)`.\n Empty lists raise — SOQL `WHERE X IN ()` is a syntax error in\n Salesforce, fail fast rather than let the CLI surface an opaque\n 500.\n\n Rendering\n ---------\n Lists render as `','.join(f\"'{v}'\" for v in sorted(set(list_value)))`:\n single-quoted, comma-separated, no surrounding parens. The `.soql`\n template supplies the parens around the `{{PLACEHOLDER}}` so the\n result reads `WHERE Id IN ('A','B','C')`.\n\n Deduplication is load-bearing: SOQL tolerates dupes but they waste\n the 100k-char query-length budget. Sort is for deterministic order —\n stable cache keys and diffable test output.\n\n Returns\n -------\n Substituted, `.strip()`-ed SOQL string.\n \"\"\"\n # validate template name BEFORE touching the filesystem.\n try:\n fs_guard.validate_api_name(name, label=\"soql_template_name\")\n except fs_guard.ValidationError as e:\n raise SoqlParamError(\"soql_template_name\", name, e.reason) from None\n\n string_params = string_params or {}\n\n # / revalidate scalar params — same path as `load_soql`.\n for key, value in string_params.items():\n try:\n fs_guard.validate_api_name(value, label=key)\n except fs_guard.ValidationError as e:\n raise SoqlParamError(key, value, e.reason) from None\n\n # validate list params first (deterministic failure order — we\n # want the first bad element, not a partially-rendered string).\n rendered_lists: Dict[str, str] = {}\n for key, list_value in list_params.items():\n if not isinstance(list_value, list):\n raise SoqlParamError(\n key,\n list_value,\n f\"list_params[{key}] must be a list, got {type(list_value).__name__}\",\n )\n if not list_value:\n # Salesforce `WHERE X IN ()` is a hard syntax error — fail fast\n # at the loader, not at the CLI. Callers should branch on the\n # empty-list case upstream (e.g. skip the whole SOQL) rather\n # than rely on an empty-clause fallback.\n raise SoqlParamError(\n key, \"[]\", \"empty-list-would-produce-invalid-soql\",\n )\n # Dedupe + sort: cache-key stable, query-length budget sparing.\n # `sorted(set(...))` raises TypeError on non-hashable elements;\n # validate_api_name below will surface the real cause for\n # non-strings, so gate on type FIRST.\n for element in list_value:\n try:\n fs_guard.validate_api_name(element, label=key)\n except fs_guard.ValidationError as e:\n raise SoqlParamError(key, element, e.reason) from None\n unique_sorted = sorted(set(list_value))\n # Single-quote each element, comma-join. The `.soql` template\n # supplies the surrounding parens around `{{KEY}}`.\n rendered_lists[key] = \",\".join(f\"'{v}'\" for v in unique_sorted)\n\n # translate FileNotFoundError into SoqlTemplateNotFound. Same\n # hygiene contract as `load_soql` — the absolute SOQL_DIR path must\n # not bleed into caller-visible error text.\n try:\n soql = (SOQL_DIR / f\"{name}.soql\").read_text()\n except FileNotFoundError:\n raise SoqlTemplateNotFound(name) from None\n\n # Single-pass substitution, same semantics as `load_soql`. Scalars\n # first, lists second — the order is only relevant if a scalar value\n # could contain a list-placeholder token, which `validate_api_name`\n # forbids (no `{` or `}` in `[A-Za-z0-9_]+`).\n for key, value in string_params.items():\n soql = soql.replace(f\"{{{{{key}}}}}\", value)\n for key, rendered in rendered_lists.items():\n soql = soql.replace(f\"{{{{{key}}}}}\", rendered)\n return soql.strip()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11409,"content_sha256":"f52f121b1eed3d5e3dc230c0c54f06d8cc158ff43f3f943f8a7551c3358918d3"},{"filename":"scripts/summarize_tree.py","content":"#!/usr/bin/env python3\n\"\"\"Render the declared action tree as a box-drawing summary.md.\n\nCalled by finalize.py (cold path) and cache_check.py (hit path when the\ncached summary is missing). Mirrors the old agent Phase 7 render_tree()\nlogic verbatim.\n\nUsage:\n python3 summarize_tree.py \u003ctree_json_path> \u003cout_md_path> \u003cbuilt_at_utc>\n\nInputs:\n argv[1] absolute path to declared_action_tree.json (or similar)\n argv[2] output .summary.md path (overwritten atomically)\n argv[3] ISO-8601 UTC built-at timestamp (e.g. 2026-04-25T14:00:00Z)\n\nOutputs:\n argv[2] rendered markdown\n exit 0 success\n exit 1 missing args, read failure, write failure\n\"\"\"\nimport json\nimport os\nimport pathlib\nimport sys\n\nKC_ORDER = [\n \"BOT_DEFINITION\", \"TOPIC\", \"GEN_AI_FUNCTION\",\n \"FLOW\", \"APEX\", \"PROMPT_TEMPLATE\",\n \"STANDARD_ACTION\", \"UNKNOWN\",\n]\n\n\ndef render_tree(node, prefix=\"\", is_last=True, lines=None, agent_=None):\n if lines is None:\n lines = []\n connector = \"└── \" if is_last else \"├── \"\n kind = node.get(\"kind\", \"?\")\n api = node.get(\"api_name\", \"?\")\n elem = node.get(\"element_name\")\n if kind == \"BOT_DEFINITION\":\n v = (agent_ or {}).get(\"version\") or \"\"\n label = f\"[{kind}] {api}\" + (f\" ({v})\" if v else \"\")\n elif kind == \"GEN_AI_FUNCTION\":\n unwraps = node.get(\"unwraps_to\") or {}\n uk = unwraps.get(\"kind\")\n un = unwraps.get(\"api_name\")\n arrow = f\" → {uk}:{un}\" if uk and un else \"\"\n label = f\"[{kind}] {api}{arrow}\"\n elif kind in (\"STANDARD_ACTION\", \"UNKNOWN\"):\n # Canonical key is `invocation_type` (schema 3.1+); fall back to\n # the two legacy keys so caches built by an older parse_wave still\n # render the qualifier.\n raw = (\n node.get(\"invocation_type\")\n or node.get(\"raw_invocation_type\")\n or node.get(\"raw_action_type\")\n or \"\"\n )\n label = f\"[{kind}] {api}\" + (f\" ({raw})\" if raw else \"\")\n else:\n label = f\"[{kind}] {api}\"\n if elem and kind != \"BOT_DEFINITION\":\n label += f\" — element:{elem}\"\n lines.append(prefix + connector + label)\n children = node.get(\"children\") or []\n child_prefix = prefix + (\" \" if is_last else \"│ \")\n for i, c in enumerate(children):\n render_tree(c, child_prefix, i == len(children) - 1, lines, agent_)\n return lines\n\n\ndef main() -> int:\n if len(sys.argv) != 4:\n sys.stderr.write(\n \"summarize_tree.py: usage: python3 summarize_tree.py \u003ctree_json_path> \"\n \"\u003cout_md_path> \u003cbuilt_at_utc>\\n\"\n )\n return 1\n\n tree_path = pathlib.Path(sys.argv[1])\n out_path = pathlib.Path(sys.argv[2])\n built_at = sys.argv[3]\n\n try:\n tree = json.loads(tree_path.read_text())\n except (OSError, json.JSONDecodeError) as e:\n sys.stderr.write(f\"summarize_tree.py: cannot read {tree_path}: {e}\\n\")\n return 1\n\n a = tree.get(\"agent\", {}) or {}\n kc = tree.get(\"_kind_counts\", {}) or {}\n\n lines = [\n f\"# {a.get('api_name', '?')} {a.get('version', '?')} — declared action tree\",\n \"\",\n f\"- **Bot ID:** `{a.get('bot_id', '?')}`\",\n f\"- **Master label:** {a.get('master_label', '?')}\",\n f\"- **Generation:** {a.get('generation', 'unknown')}\",\n f\"- **Planner:** `{a.get('planner_name', '?')}` ({a.get('planner_type', '?')})\",\n f\"- **Version auto-picked:** {a.get('_version_auto_picked', False)}\",\n f\"- **Built at (UTC):** {built_at}\",\n f\"- **Node count:** {tree.get('node_count', 0)}\",\n f\"- **Depth:** {tree.get('depth', 0)}\",\n f\"- **Partial:** {tree.get('_partial', False)}\",\n \"\",\n \"## Kind counts\",\n \"\",\n ]\n for k in KC_ORDER:\n lines.append(f\"- `{k}`: {kc.get(k, 0)}\")\n\n lines += [\n \"\",\n \"## Declared action tree\",\n \"\",\n \"```\",\n ]\n root = tree.get(\"root\") or {\"kind\": \"BOT_DEFINITION\", \"api_name\": a.get(\"api_name\", \"?\"), \"children\": []}\n lines.extend(render_tree(root, \"\", True, None, a))\n lines.append(\"```\")\n\n if tree.get(\"_unresolved\"):\n lines += [\"\", \"## Unresolved\", \"\"]\n for u in tree[\"_unresolved\"]:\n lines.append(\n f\"- `{u.get('kind')}`/`{u.get('api_name')}` — {u.get('reason')}\"\n )\n\n content = \"\\n\".join(lines) + \"\\n\"\n\n try:\n out_path.parent.mkdir(parents=True, exist_ok=True)\n tmp = out_path.with_suffix(out_path.suffix + \".tmp\")\n tmp.write_text(content)\n os.replace(tmp, out_path)\n except OSError as e:\n sys.stderr.write(f\"summarize_tree.py: write failed: {e}\\n\")\n return 1\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4752,"content_sha256":"a6a49935f7d216f97dda8112c45a9235e69ca54a96d6db39ffd812d4533f1b13"},{"filename":"scripts/tests/__init__.py","content":"","content_type":"text/x-python; charset=utf-8","language":"python","size":0,"content_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},{"filename":"scripts/tests/_bootstrap.py","content":"\"\"\"Shared sys.path bootstrap for the test suite.\n\nThe scripts/ scripts expect to be imported as top-level modules (config,\nsoql_loader, rest_client, sf_cli) — that's how they behave when main.py\ndoes `from config import ...`. Tests mirror that by inserting scripts/\nonto sys.path.\n\nfs_guard is now sourced via ``from config import fs_guard`` (config.py\nre-exports it from the plugin _shared/ package). No separate entry on\nsys.path is required — config.py's dev-fallback walks up to the repo's\n``plugins/investigating-agentforce-architecture/shared/`` when ``scripts/_shared/``\nhasn't yet been mirrored by install.sh / the build script.\n\"\"\"\nfrom __future__ import annotations\n\nimport sys\nfrom pathlib import Path\n\n_SCRIPTS_DIR = Path(__file__).resolve().parent.parent\n\ns = str(_SCRIPTS_DIR)\nif s not in sys.path:\n sys.path.insert(0, s)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":843,"content_sha256":"7cd82d0cd7ebedcfab6be628ea5313826d6e6922480a9bb8b3f000ad8c40f491"},{"filename":"scripts/tests/fixtures/__init__.py","content":"","content_type":"text/x-python; charset=utf-8","language":"python","size":0,"content_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},{"filename":"scripts/tests/test_cache_check_main.py","content":"\"\"\"In-process tests for ``cache_check.main``.\n\nThe existing ``test_cache_check.py`` drives main via subprocess (so its\nPython branches don't show up in coverage). This file calls main()\ndirectly, patching ``os.environ`` + ``config.CACHE_ROOT`` so all the\nhit/miss decision branches register.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as dt\nimport io\nimport json\nimport os\nimport unittest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport cache_check # type: ignore\nimport config # type: ignore\n\n\ndef _build_valid_manifest(*, data_path: str,\n built_at_utc: str | None = None,\n schema_version: str = \"3.1\",\n node_count: int = 5,\n depth: int = 2,\n ttl_days: int = 7,\n partial: bool = False,\n unresolved_count: int = 0,\n generation: str = \"nga\",\n bot_id: str = \"0Xx000000000Demo\",\n master_label: str = \"Demo Agent\",\n version_auto_picked: bool = False,\n planner_name: str = \"DemoPlanner\",\n kind_counts: dict | None = None) -> dict:\n if built_at_utc is None:\n built_at_utc = (\n dt.datetime.now(dt.timezone.utc)\n .isoformat().replace(\"+00:00\", \"Z\")\n )\n return {\n \"schema_version\": schema_version,\n \"built_at_utc\": built_at_utc,\n \"node_count\": node_count,\n \"depth\": depth,\n \"ttl_days\": ttl_days,\n \"data_path\": data_path,\n \"partial\": partial,\n \"unresolved_count\": unresolved_count,\n \"agent\": {\n \"generation\": generation,\n \"bot_id\": bot_id,\n \"master_label\": master_label,\n \"_version_auto_picked\": version_auto_picked,\n \"planner_name\": planner_name,\n },\n \"kind_counts\": kind_counts or {\"TOPIC\": 2, \"STANDARD_ACTION\": 3},\n }\n\n\nclass _CacheHarness:\n \"\"\"Patch CACHE_ROOT + os.environ + capture stdout.\"\"\"\n\n def __init__(self, *, env_overrides: dict | None = None,\n manifest: dict | None = None,\n plant_data_file: bool = True):\n self.env_overrides = env_overrides or {}\n self.manifest = manifest\n self.plant_data_file = plant_data_file\n\n def __enter__(self):\n self._tmp = TemporaryDirectory()\n self.tmpdir = Path(self._tmp.name)\n # Build a 4-deep CACHE_ROOT so cache_dir lives strictly inside it\n # (the rmtree guard refuses paths NOT under CACHE_ROOT).\n self.cache_root = self.tmpdir / \"cache\" / \"investigating-agentforce-architecture\"\n self.cache_root.mkdir(parents=True)\n self.cache_dir = self.cache_root / \"ALPHA0000000000\" / \"MyAgent__v1\"\n self.cache_dir.mkdir(parents=True)\n self.data_dir = self.tmpdir / \"data\" / \"ALPHA0000000000\" / \"MyAgent__v1\"\n self.work_dir = self.tmpdir / \"work\"\n self.work_dir.mkdir(parents=True)\n\n # Plant the authoritative tree file UNDER cache_dir (where\n # finalize.py originally wrote it). cache_check copies it INTO\n # data_dir on hit, so source and dest must be different files.\n self.tree_path = self.cache_dir / \"MyAgent_v1_metadata_tree.json\"\n if self.plant_data_file:\n self.tree_path.write_text(json.dumps({\n \"agent\": {\"api_name\": \"MyAgent\", \"version\": \"v1\"},\n \"session\": {\"_schema_version\": 1},\n }))\n\n # Plant the manifest under cache_dir\n if self.manifest is not None:\n (self.cache_dir / \"manifest.json\").write_text(\n json.dumps(self.manifest)\n )\n elif self.manifest is None and self.plant_data_file:\n # Default valid manifest pointing at the planted tree\n (self.cache_dir / \"manifest.json\").write_text(json.dumps(\n _build_valid_manifest(data_path=str(self.tree_path))\n ))\n\n self._env = {\n \"CACHE_DIR\": str(self.cache_dir),\n \"DATA_DIR\": str(self.data_dir),\n \"WORK_DIR\": str(self.work_dir),\n \"AGENT_API_NAME\": \"MyAgent\",\n \"AGENT_VERSION\": \"v1\",\n }\n self._env.update(self.env_overrides)\n\n self._saved_env = dict(os.environ)\n os.environ.update(self._env)\n\n self._patches = [\n mock.patch.object(config, \"CACHE_ROOT\", self.cache_root),\n ]\n for p in self._patches:\n p.start()\n return self\n\n def __exit__(self, *exc):\n for p in self._patches:\n p.stop()\n # Restore env\n for k in self._env:\n os.environ.pop(k, None)\n os.environ.update(self._saved_env)\n self._tmp.cleanup()\n\n\ndef _run_main_capture() -> tuple[int, str]:\n \"\"\"Call main() with stdout captured. Translates SystemExit into rc.\"\"\"\n buf = io.StringIO()\n rc: int\n try:\n with mock.patch(\"sys.stdout\", buf):\n rc = cache_check.main()\n except SystemExit as e:\n rc = e.code if isinstance(e.code, int) else 0\n return rc, buf.getvalue()\n\n\n# -----------------------------------------------------------------------------\n# Hit path\n# -----------------------------------------------------------------------------\n\n\nclass CacheHitTests(unittest.TestCase):\n\n def test_hit_emits_cache_hit_true_with_full_export_block(self):\n with _CacheHarness():\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=true\", out)\n # Full hit block has these signature fields\n self.assertIn(\"CACHED_AT_UTC=\", out)\n self.assertIn(\"NODE_COUNT=\", out)\n self.assertIn(\"DEPTH=\", out)\n self.assertIn(\"AGENT_GENERATION=\", out)\n self.assertIn(\"BOT_ID=\", out)\n self.assertIn(\"PLANNER_NAME=\", out)\n\n def test_hit_copies_tree_into_data_dir(self):\n with _CacheHarness() as h:\n rc, _ = _run_main_capture()\n self.assertEqual(rc, 0)\n dst = h.data_dir / \"MyAgent_v1_metadata_tree.json\"\n # File search across resolved tree to dodge /private/var ↔ /var.\n written = list(h.tmpdir.rglob(\"MyAgent_v1_metadata_tree.json\"))\n self.assertGreaterEqual(len(written), 1)\n\n def test_hit_stages_declared_action_tree_in_work_dir(self):\n with _CacheHarness() as h:\n _run_main_capture()\n staged = list(h.tmpdir.rglob(\"declared_action_tree.json\"))\n self.assertGreaterEqual(len(staged), 1)\n\n def test_hit_emits_kind_counts_with_kc_prefix(self):\n with _CacheHarness():\n _, out = _run_main_capture()\n self.assertIn(\"KC_TOPIC=\", out)\n self.assertIn(\"KC_STANDARD_ACTION=\", out)\n\n\n# -----------------------------------------------------------------------------\n# Miss paths (each one exits 0 with CACHE_HIT=false)\n# -----------------------------------------------------------------------------\n\n\nclass CacheMissTests(unittest.TestCase):\n\n def test_miss_when_force_refresh_true(self):\n with _CacheHarness(env_overrides={\"FORCE_REFRESH\": \"true\"}):\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", out)\n\n def test_miss_when_manifest_missing(self):\n # Build harness without auto-planted manifest\n with _CacheHarness(manifest={}, plant_data_file=False) as h:\n (h.cache_dir / \"manifest.json\").unlink()\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", out)\n\n def test_miss_when_manifest_malformed(self):\n with _CacheHarness(manifest={}) as h:\n (h.cache_dir / \"manifest.json\").write_text(\"\u003c\u003c\u003cnot json>>>\")\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", out)\n\n def test_miss_with_invalidated_reason_on_schema_mismatch(self):\n # Schema version != config.SCHEMA_VERSION → rmtree + miss\n with _CacheHarness(manifest=_build_valid_manifest(\n data_path=\"/tmp/x\", schema_version=\"2.0\",\n )) as h:\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", out)\n self.assertIn(\"CACHE_INVALIDATED_REASON=schema-version-mismatch\", out)\n # Cache dir got rmtree'd\n self.assertFalse(h.cache_dir.exists())\n\n def test_miss_when_data_path_missing(self):\n with _CacheHarness(manifest=_build_valid_manifest(\n data_path=\"/nope/not-a-real-path.json\",\n )):\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", out)\n\n def test_miss_when_built_at_unparseable(self):\n with _CacheHarness() as h:\n (h.cache_dir / \"manifest.json\").write_text(json.dumps(\n _build_valid_manifest(\n data_path=str(h.tree_path),\n built_at_utc=\"not-a-timestamp\",\n ),\n ))\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", out)\n\n def test_miss_when_ttl_expired(self):\n # built_at_utc is far in the past + ttl_days=1 → age > ttl → miss\n ancient = (dt.datetime.now(dt.timezone.utc)\n - dt.timedelta(days=30)).isoformat().replace(\"+00:00\", \"Z\")\n with _CacheHarness(manifest=None) as h:\n (h.cache_dir / \"manifest.json\").write_text(json.dumps(\n _build_valid_manifest(\n data_path=str(h.tree_path),\n built_at_utc=ancient,\n ttl_days=1,\n ),\n ))\n rc, out = _run_main_capture()\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", out)\n\n def test_miss_when_required_env_missing(self):\n with _CacheHarness() as h:\n os.environ.pop(\"CACHE_DIR\", None)\n buf = io.StringIO()\n rc: int\n try:\n with mock.patch(\"sys.stdout\", buf):\n with mock.patch(\"sys.stderr\", io.StringIO()):\n rc = cache_check.main()\n except SystemExit as e:\n rc = e.code if isinstance(e.code, int) else 0\n self.assertEqual(rc, 0)\n self.assertIn(\"CACHE_HIT=false\", buf.getvalue())\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10648,"content_sha256":"55605412c75ca024fc467314ae4b16d3eca4b6cbade50a403312c0f84217dbee"},{"filename":"scripts/tests/test_cache_check.py","content":"\"\"\"cache_check.py schema-version mismatch → delete-on-read + MISS.\n\nCovers:\n * manifest.schema_version == config.SCHEMA_VERSION → HIT\n * manifest.schema_version != config.SCHEMA_VERSION → MISS +\n CACHE_INVALIDATED_REASON=schema-version-mismatch + cache dir deleted\n * missing manifest → MISS (existing behavior preserved)\n * expired TTL + correct schema → MISS + dir NOT deleted\n * _safe_rmtree_under_cache_root refuses paths outside CACHE_ROOT\n * module import raises if CACHE_ROOT is a symlink\n\nThe script reads env vars + writes eval-able K=V lines to stdout +\nexits 0 on both HIT and MISS. We drive it via subprocess so we exercise\nthe real stdout stream and exit code.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as dt\nimport importlib\nimport json\nimport os\nimport pathlib\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nimport unittest\n\nfrom . import _bootstrap # noqa: F401\n\nimport config # type: ignore\nimport cache_check # type: ignore\n\n\nSCRIPTS_DIR = pathlib.Path(__file__).resolve().parent.parent\nCACHE_CHECK = SCRIPTS_DIR / \"cache_check.py\"\n\n\ndef _parse_kv(stdout: str) -> dict[str, str]:\n \"\"\"Parse the shlex-quoted K=V lines cache_check emits.\n\n cache_check writes one `K=V` per line, V shlex-quoted. For our tests\n we don't need full shell parsing — we just strip one surrounding\n single-quote layer if present.\n \"\"\"\n out: dict[str, str] = {}\n for line in stdout.splitlines():\n if \"=\" not in line:\n continue\n k, _, v = line.partition(\"=\")\n if v.startswith(\"'\") and v.endswith(\"'\") and len(v) >= 2:\n v = v[1:-1]\n out[k] = v\n return out\n\n\nclass CacheCheckSchemaVersionTests(unittest.TestCase):\n \"\"\"strict schema-version match, delete-on-mismatch.\"\"\"\n\n def setUp(self) -> None:\n # Isolated tempdir mirroring CACHE_ROOT so we can safely rmtree\n # anything under it. We override config.CACHE_ROOT for the\n # duration of each test — the script reads config.CACHE_ROOT at\n # rmtree-validation time, and we drive the script via subprocess\n # with CACHE_ROOT propagated through env only via a wrapper\n # below (subprocess-style overrides).\n self.tmp = tempfile.TemporaryDirectory()\n self.root = pathlib.Path(self.tmp.name).resolve()\n self.cache_root = self.root / \"cache\"\n self.data_root = self.root / \"data\"\n self.work_dir = self.root / \"work\"\n self.cache_root.mkdir()\n self.data_root.mkdir()\n self.work_dir.mkdir()\n # Agent-keyed sub-path under cache_root.\n self.agent = \"TestAgent_v1\"\n self.version = \"v1\"\n self.cache_dir = self.cache_root / \"00D000000000ABC\" / f\"{self.agent}__{self.version}\"\n self.data_dir = self.data_root / \"00D000000000ABC\" / f\"{self.agent}__{self.version}\"\n self.cache_dir.mkdir(parents=True)\n # Data file manifest points at — must exist for the HIT path.\n self.data_file = self.cache_dir / \"metadata_tree.json\"\n self.data_file.write_text(json.dumps({\"root\": {\"kind\": \"Bot\"}}))\n # summary.md dropped from the output contract — no\n # longer written by cache seeding.\n\n def tearDown(self) -> None:\n self.tmp.cleanup()\n\n def _write_manifest(self, schema_version: str, age_days: int = 0) -> None:\n built = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=age_days)\n manifest = {\n \"schema_version\": schema_version,\n \"data_path\": str(self.data_file),\n \"built_at_utc\": built.isoformat().replace(\"+00:00\", \"Z\"),\n \"ttl_days\": 7,\n \"node_count\": 1,\n \"depth\": 1,\n \"agent\": {\n \"generation\": \"Einstein\",\n \"bot_id\": \"0Xx000000000000\",\n \"master_label\": \"TestAgent\",\n \"planner_name\": \"p1\",\n },\n \"kind_counts\": {\"Bot\": 1},\n \"partial\": False,\n \"unresolved_count\": 0,\n }\n (self.cache_dir / \"manifest.json\").write_text(json.dumps(manifest))\n\n def _run_with_patched_cache_root(self, extra_env: dict | None = None) -> subprocess.CompletedProcess:\n \"\"\"Patch config.CACHE_ROOT via a small wrapper — simpler than the\n HOME-redirect dance and keeps the test local to the module under\n test. We write a tiny entry-point that monkeypatches config\n before invoking cache_check.main, then runs it.\n \"\"\"\n wrapper = self.root / \"wrapper.py\"\n wrapper.write_text(\n \"import sys; sys.path.insert(0, %r); sys.path.insert(0, %r)\\n\"\n \"import config\\n\"\n \"config.CACHE_ROOT = __import__('pathlib').Path(%r)\\n\"\n \"import cache_check\\n\"\n \"sys.exit(cache_check.main())\\n\"\n % (str(SCRIPTS_DIR), str(SCRIPTS_DIR.parent / 'tools'), str(self.cache_root))\n )\n env = os.environ.copy()\n env.update({\n \"CACHE_DIR\": str(self.cache_dir),\n \"DATA_DIR\": str(self.data_dir),\n \"WORK_DIR\": str(self.work_dir),\n \"AGENT_API_NAME\": self.agent,\n \"AGENT_VERSION\": self.version,\n })\n if extra_env:\n env.update(extra_env)\n return subprocess.run(\n [sys.executable, str(wrapper)],\n env=env,\n capture_output=True,\n text=True,\n timeout=10,\n )\n\n def test_matching_schema_version_hits(self):\n self._write_manifest(config.SCHEMA_VERSION)\n cp = self._run_with_patched_cache_root()\n self.assertEqual(cp.returncode, 0, cp.stderr)\n kv = _parse_kv(cp.stdout)\n self.assertEqual(kv.get(\"CACHE_HIT\"), \"true\", cp.stdout)\n self.assertNotIn(\"CACHE_INVALIDATED_REASON\", kv)\n # Cache dir survives.\n self.assertTrue(self.cache_dir.exists())\n\n def test_legacy_schema_version_misses_and_deletes(self):\n \"\"\"schema_version=2.4 (legacy) != 3.0 (current) → MISS + rmtree.\"\"\"\n self._write_manifest(\"2.4\")\n self.assertTrue(self.cache_dir.exists())\n cp = self._run_with_patched_cache_root()\n self.assertEqual(cp.returncode, 0, cp.stderr)\n kv = _parse_kv(cp.stdout)\n self.assertEqual(kv.get(\"CACHE_HIT\"), \"false\")\n self.assertEqual(kv.get(\"CACHE_INVALIDATED_REASON\"), \"schema-version-mismatch\")\n # Cache dir deleted by the safety-gated rmtree.\n self.assertFalse(self.cache_dir.exists(),\n \"cache_dir must be removed on schema-version mismatch\")\n\n def test_unexpected_future_schema_version_misses_and_deletes(self):\n \"\"\"Any value != current SCHEMA_VERSION invalidates (not just legacy).\"\"\"\n self._write_manifest(\"99.99\")\n cp = self._run_with_patched_cache_root()\n kv = _parse_kv(cp.stdout)\n self.assertEqual(kv.get(\"CACHE_HIT\"), \"false\")\n self.assertEqual(kv.get(\"CACHE_INVALIDATED_REASON\"), \"schema-version-mismatch\")\n self.assertFalse(self.cache_dir.exists())\n\n def test_missing_manifest_misses_no_deletion(self):\n \"\"\"Existing behavior: no manifest → MISS, NO rmtree, no reason emit.\"\"\"\n # Don't write any manifest.\n cp = self._run_with_patched_cache_root()\n self.assertEqual(cp.returncode, 0, cp.stderr)\n kv = _parse_kv(cp.stdout)\n self.assertEqual(kv.get(\"CACHE_HIT\"), \"false\")\n # Missing manifest is a different reason class — no invalidation\n # reason emitted here (cache is simply cold, not corrupt).\n self.assertNotIn(\"CACHE_INVALIDATED_REASON\", kv)\n self.assertTrue(self.cache_dir.exists(),\n \"missing manifest must NOT trigger rmtree\")\n\n def test_expired_ttl_with_matching_schema_misses_no_deletion(self):\n \"\"\"TTL-expired cache with CURRENT schema → MISS but dir stays intact.\"\"\"\n self._write_manifest(config.SCHEMA_VERSION, age_days=30) # 30 > 7\n cp = self._run_with_patched_cache_root()\n kv = _parse_kv(cp.stdout)\n self.assertEqual(kv.get(\"CACHE_HIT\"), \"false\")\n # No schema-mismatch reason — it's a stale cache, not a corrupt one.\n self.assertNotIn(\"CACHE_INVALIDATED_REASON\", kv)\n self.assertTrue(self.cache_dir.exists(),\n \"expired TTL must NOT rmtree — just mark stale\")\n\n\nclass SafeRmtreeUnderCacheRootTests(unittest.TestCase):\n \"\"\"defensive: `_safe_rmtree_under_cache_root` refuses out-of-root paths.\"\"\"\n\n def setUp(self) -> None:\n self.tmp = tempfile.TemporaryDirectory()\n self.root = pathlib.Path(self.tmp.name).resolve()\n self.cache_root = self.root / \"cache\"\n self.cache_root.mkdir()\n # In-scope victim dir.\n self.in_scope = self.cache_root / \"agent_v1\"\n self.in_scope.mkdir()\n (self.in_scope / \"some.txt\").write_text(\"x\")\n # Out-of-scope victim dir (sibling to cache_root).\n self.out_of_scope = self.root / \"other\"\n self.out_of_scope.mkdir()\n (self.out_of_scope / \"important.txt\").write_text(\"do not delete\")\n\n # Monkeypatch config.CACHE_ROOT for the duration of each test.\n self._orig_root = config.CACHE_ROOT\n config.CACHE_ROOT = self.cache_root\n\n def tearDown(self) -> None:\n config.CACHE_ROOT = self._orig_root\n self.tmp.cleanup()\n\n def test_rmtree_under_cache_root_succeeds(self):\n ok = cache_check._safe_rmtree_under_cache_root(self.in_scope)\n self.assertTrue(ok)\n self.assertFalse(self.in_scope.exists())\n\n def test_rmtree_outside_cache_root_refused(self):\n ok = cache_check._safe_rmtree_under_cache_root(self.out_of_scope)\n self.assertFalse(ok, \"out-of-root path must be refused\")\n # File still present.\n self.assertTrue((self.out_of_scope / \"important.txt\").exists())\n\n def test_nonexistent_under_root_is_noop_success(self):\n \"\"\"A caller asking to delete a path that doesn't exist under the\n root gets a silent success — matches rmtree's semantics for\n already-gone targets and avoids spurious error branches on the\n miss() path.\"\"\"\n ghost = self.cache_root / \"never_existed\"\n ok = cache_check._safe_rmtree_under_cache_root(ghost)\n self.assertTrue(ok)\n\n\nclass SymlinkedCacheRootRejectedAtImportTests(unittest.TestCase):\n \"\"\"module import raises if CACHE_ROOT is a symlink.\n\n A symlinked CACHE_ROOT would defeat the rmtree safety gate — both\n sides of `target.resolve().is_relative_to(root.resolve())` would\n collapse to the same symlink target, so the gate would approve\n deletion of arbitrary paths the attacker aimed the symlink at.\n The defence is a fail-fast guard at cache_check import time.\n \"\"\"\n\n def setUp(self) -> None:\n self.tmp = tempfile.TemporaryDirectory()\n self.root = pathlib.Path(self.tmp.name).resolve()\n self.real_target = self.root / \"evil_target\"\n self.real_target.mkdir()\n self.symlinked_root = self.root / \"symlinked_cache_root\"\n self.symlinked_root.symlink_to(self.real_target)\n self._orig_root = config.CACHE_ROOT\n\n def tearDown(self) -> None:\n # Restore and reload so the rest of the suite sees a clean module.\n config.CACHE_ROOT = self._orig_root\n importlib.reload(cache_check)\n self.tmp.cleanup()\n\n def test_symlinked_cache_root_raises_on_import(self):\n \"\"\"Reloading cache_check with a symlinked CACHE_ROOT must raise\n RuntimeError — proof that the guard actually fires and the fix\n is load-bearing.\"\"\"\n config.CACHE_ROOT = self.symlinked_root\n with self.assertRaises(RuntimeError) as ctx:\n importlib.reload(cache_check)\n msg = str(ctx.exception)\n # Message must name the offending path so operators can fix it\n # without spelunking the source. Don't over-specify wording —\n # future maintainers may clarify the phrasing.\n self.assertIn(str(self.symlinked_root), msg)\n self.assertIn(\"symlink\", msg.lower())\n\n def test_real_directory_cache_root_passes(self):\n \"\"\"Sanity: the guard is not over-broad — a real directory (the\n common case) reloads cleanly.\"\"\"\n real_dir = self.root / \"real_cache_root\"\n real_dir.mkdir()\n config.CACHE_ROOT = real_dir\n # No raise.\n importlib.reload(cache_check)\n\n def test_nonexistent_cache_root_passes(self):\n \"\"\"Pristine install: CACHE_ROOT hasn't been created yet.\n Path.is_symlink() returns False on non-existent paths, so import\n must succeed — the directory will be materialized on first write.\"\"\"\n ghost = self.root / \"does_not_exist_yet\"\n self.assertFalse(ghost.exists())\n config.CACHE_ROOT = ghost\n # No raise.\n importlib.reload(cache_check)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12891,"content_sha256":"2e7c7b49a43b5cf75aa4d70afb9164270c2dc0227863b11ea1ad95b804596246"},{"filename":"scripts/tests/test_config.py","content":"\"\"\"Tests for config path builders validate every component.\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nfrom . import _bootstrap # noqa: F401\n\nimport config # type: ignore\nfrom config import fs_guard # type: ignore — re-exported from _shared/\n\n\nclass BuildAgentDataDirTests(unittest.TestCase):\n def test_valid_inputs_produce_expected_path(self):\n p = config.build_agent_data_dir(\n org_id_15=\"00Dxx0000000000\",\n agent_api_name=\"MyAgent\",\n agent_version=\"v5\",\n )\n self.assertEqual(p.name, \"MyAgent__v5\")\n self.assertEqual(p.parent.name, \"00Dxx0000000000\")\n # Full path must be under DATA_ROOT.\n self.assertTrue(str(p).startswith(str(config.DATA_ROOT)))\n\n def test_dotdot_in_agent_api_name_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_agent_data_dir(\n org_id_15=\"00Dxx0000000000\",\n agent_api_name=\"..\",\n agent_version=\"v5\",\n )\n\n def test_slash_in_agent_api_name_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_agent_data_dir(\n org_id_15=\"00Dxx0000000000\",\n agent_api_name=\"foo/bar\",\n agent_version=\"v5\",\n )\n\n def test_slash_in_org_id_15_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_agent_data_dir(\n org_id_15=\"00D/x0000000000\",\n agent_api_name=\"MyAgent\",\n agent_version=\"v5\",\n )\n\n def test_dotdot_in_agent_version_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_agent_data_dir(\n org_id_15=\"00Dxx0000000000\",\n agent_api_name=\"MyAgent\",\n agent_version=\"..\",\n )\n\n\nclass BuildAgentCacheDirTests(unittest.TestCase):\n def test_valid_inputs_produce_expected_path(self):\n p = config.build_agent_cache_dir(\n org_id_15=\"00Dxx0000000000\",\n agent_api_name=\"MyAgent\",\n agent_version=\"v5\",\n )\n self.assertEqual(p.name, \"MyAgent__v5\")\n self.assertTrue(str(p).startswith(str(config.CACHE_ROOT)))\n\n def test_slash_in_any_component_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_agent_cache_dir(\n org_id_15=\"00Dxx0000000000\",\n agent_api_name=\"foo/bar\",\n agent_version=\"v5\",\n )\n\n\nclass BuildProbeCacheDirTests(unittest.TestCase):\n def test_valid_api_version_ok(self):\n p = config.build_probe_cache_dir(\n org_id_15=\"00Dxx0000000000\",\n api_version=\"v60.0\",\n )\n self.assertEqual(p.name, \"v60.0\")\n self.assertTrue(str(p).startswith(str(config.PROBE_CACHE_ROOT)))\n\n def test_v60_no_minor_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_probe_cache_dir(\n org_id_15=\"00Dxx0000000000\",\n api_version=\"v60\",\n )\n\n def test_api_version_with_slash_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_probe_cache_dir(\n org_id_15=\"00Dxx0000000000\",\n api_version=\"v60.0/../\",\n )\n\n def test_api_version_dotdot_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_probe_cache_dir(\n org_id_15=\"00Dxx0000000000\",\n api_version=\"..\",\n )\n\n def test_slash_in_org_id_raises(self):\n with self.assertRaises(fs_guard.ValidationError):\n config.build_probe_cache_dir(\n org_id_15=\"00D/x0000000000\",\n api_version=\"v60.0\",\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3911,"content_sha256":"6443e4cd86d63f63a766985fb8cb98f30a47539fea3bd5d1b41585a9f7e3e866"},{"filename":"scripts/tests/test_end_to_end_fixture.py","content":"\"\"\"P2.3-3: end-to-end fixture integration tests.\n\nDrives `main.main(argv)` against synthetic fixtures — no real org, no\nsf CLI spawn, no HTTP. Every REST/sf boundary is mocked at the\n`main.fetch_*` / `main.run_sf` / `main.probe_channels` surface; all\nlayers below (join, parse, render, finalize) run as production code.\n\nTests (one per pipeline path):\n 1. classic_react_end_to_end — STATUS=OK, tree + summary + architecture.md\n 2. nga_orchestration_end_to_end — NGA reverse-lookup + `par/and` state diagram\n 3. cache_hit_end_to_end — poisoned Wave A — must never be called\n 4. probe_failed_end_to_end — STATUS=RETRIEVE_FAILED + schema-drift detail\n 5. force_refresh_end_to_end — pre-populated fresh cache ignored\n 6. render_failure_end_to_end — STATUS=PARTIAL_OK + RENDER_FAILED=true\n\nEach test carves its own tempdir for work/cache/data so no cross-test\nstate leaks. Assertions focus on the observable surface (RESULT block\n+ on-disk artifacts) rather than on implementation details of\nintermediate steps — the same tests survive a refactor of main.py's\ninternal phase ordering.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401\n\nimport config # type: ignore\nimport soql_loader # type: ignore\nimport main # type: ignore\nfrom tests.fixtures import genai_payloads as fx # type: ignore\n\n# SKILL_ROOT is now file-relative in config.py, so config.SOQL_DIR auto-\n# resolves to the repo's assets/soql/ under test. soql_loader still\n# captures SOQL_DIR via `from config import SOQL_DIR` at module top, so\n# we mirror its binding here defensively.\n_REPO_SOQL_DIR = Path(__file__).resolve().parent.parent.parent / \"assets\" / \"soql\"\nsoql_loader.SOQL_DIR = _REPO_SOQL_DIR\n\n# emit_result.py lives under scripts/; tests invoke it via subprocess so the\n# tool's RESULT-formatting logic is exercised under the same contract the\n# SKILL.md Bash wrapper uses.\n_TOOLS_DIR = Path(__file__).resolve().parent.parent\n\n\n# ---------------------------------------------------------------------------\n# Shared helpers (mirrored from test_main_pipeline — intentionally copied\n# rather than imported, so a refactor of that file doesn't silently change\n# the end-to-end test behavior.)\n# ---------------------------------------------------------------------------\n\n\ndef _args(work_dir: Path, **overrides) -> list[str]:\n base = [\n \"--org-alias\", \"test-org\",\n \"--agent\", overrides.pop(\"agent\", \"MyAgent\"),\n \"--work-dir\", str(work_dir),\n \"--parallelism\", \"2\",\n ]\n for k, v in overrides.items():\n flag = \"--\" + k.replace(\"_\", \"-\")\n if isinstance(v, bool):\n if v:\n base.append(flag)\n else:\n base.extend([flag, str(v)])\n return base\n\n\ndef _mock_auth_probe(probe_result=None):\n probe_result = probe_result or fx.probe_ok_payload()\n org_display_payload = {\n \"status\": 0,\n \"result\": {\n \"instanceUrl\": \"https://example.my.salesforce.com\",\n \"accessToken\": \"00Dxx0000000000!AQ_fake_token_value\",\n \"id\": \"00Dxx0000000000AAA\",\n \"apiVersion\": \"60.0\",\n },\n }\n return [\n mock.patch.object(main, \"run_sf\", return_value=org_display_payload),\n mock.patch.object(main, \"assert_show_access_token_capability\"),\n mock.patch.object(main, \"probe_channels\", return_value=probe_result),\n ]\n\n\ndef _mock_bot_resolution(agent_api_name=\"MyAgent\", bot_def=None,\n versions=(\"v5\",), active=\"v5\"):\n bot_def = bot_def or fx.BOT_DEFINITION_DETAIL_CLASSIC\n return [\n mock.patch.object(\n main, \"fetch_bot_versions\",\n return_value=fx.make_bot_versions(agent_api_name, versions, active),\n ),\n mock.patch.object(main, \"fetch_bot_definition_details\", return_value=bot_def),\n ]\n\n\ndef _mock_wave_a_classic():\n return [\n mock.patch.object(main, \"fetch_planner_definition\", return_value=fx.CLASSIC_PLANNER),\n mock.patch.object(main, \"fetch_plugins_by_planner\", return_value=fx.CLASSIC_PLUGINS),\n mock.patch.object(main, \"fetch_planner_bundle_functions\", return_value=fx.CLASSIC_BUNDLE_FN_JOIN),\n mock.patch.object(main, \"fetch_functions_by_plugins\", return_value=fx.CLASSIC_FUNCTIONS),\n mock.patch.object(main, \"fetch_plugin_instructions\", return_value=fx.CLASSIC_INSTRUCTIONS),\n mock.patch.object(main, \"fetch_plugin_functions\", return_value=fx.CLASSIC_PLUGIN_FUNCTIONS),\n mock.patch.object(main, \"fetch_planner_attrs\", return_value=fx.CLASSIC_ATTRS),\n ]\n\n\ndef _mock_wave_b_classic():\n return [\n mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=fx.CLASSIC_APEX_ROWS),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\", return_value=fx.CLASSIC_FLOW_DEFS),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=[]),\n mock.patch.object(\n main, \"fetch_flow_metadata\",\n side_effect=lambda vid, *a, **kw: fx.CLASSIC_FLOW_METADATA.get(vid),\n ),\n ]\n\n\ndef _mock_wave_a_nga():\n return [\n mock.patch.object(main, \"fetch_planner_definition\", return_value=fx.NGA_PLANNER),\n mock.patch.object(main, \"fetch_plugins_by_planner\", return_value=fx.NGA_PLUGINS),\n mock.patch.object(main, \"fetch_planner_bundle_functions\", return_value=[]),\n mock.patch.object(main, \"fetch_functions_by_plugins\", return_value=fx.NGA_FUNCTIONS),\n mock.patch.object(main, \"fetch_plugin_instructions\", return_value=[]),\n mock.patch.object(main, \"fetch_plugin_functions\", return_value=fx.NGA_PLUGIN_FUNCTIONS),\n mock.patch.object(main, \"fetch_planner_attrs\", return_value=[]),\n ]\n\n\ndef _mock_wave_b_nga():\n return [\n mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=[]),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=fx.NGA_APEX_BY_ID),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=fx.NGA_FLOW_DEF_BY_ID),\n mock.patch.object(main, \"fetch_flow_metadata\", return_value={\n \"Id\": \"301VF999NGAVER\", \"FullName\": \"NGAResolvedFlow-1\", \"Metadata\": {},\n }),\n ]\n\n\ndef _apply(patches):\n for p in patches:\n p.start()\n\n\ndef _stop(patches):\n for p in patches:\n p.stop()\n\n\ndef _run_emit_result(work_dir: Path) -> subprocess.CompletedProcess:\n \"\"\"Drive emit_result.py against the ctx main.py wrote.\n\n Exercises the same SKILL.md Bash-wrapper contract: main.py writes\n .emit_ctx.json, emit_result.py reads + renders. Subprocess call so\n the tool runs under a clean env (no leaked test-process state).\n \"\"\"\n env = {**os.environ, \"WORK_DIR\": str(work_dir)}\n return subprocess.run(\n [sys.executable, str(_TOOLS_DIR / \"emit_result.py\")],\n env=env, capture_output=True, text=True, timeout=30,\n )\n\n\ndef _parse_result_block(stdout: str) -> dict:\n \"\"\"Pull KV pairs out of the `=== RESULT ===` block.\"\"\"\n lines = stdout.splitlines()\n out: dict[str, str] = {}\n in_block = False\n for line in lines:\n if line.strip() == \"=== RESULT ===\":\n in_block = True\n continue\n if in_block and \"=\" in line:\n k, _, v = line.partition(\"=\")\n out[k] = v\n return out\n\n\n# ---------------------------------------------------------------------------\n# Test 1 — classic ReAct end-to-end\n# ---------------------------------------------------------------------------\n\n\nclass ClassicReactEndToEndTests(unittest.TestCase):\n \"\"\"P2.3-3: classic ReAct pipeline on MyAgent-v5-shaped fixtures.\n\n Asserts observable surface only: on-disk artifacts + RESULT block\n KV pairs. Internal phase ordering can change without breaking this\n test.\n \"\"\"\n\n def test_classic_react_end_to_end_produces_full_artifact_set(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n mock.patch.object(\n main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\",\n ),\n mock.patch.object(\n main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\",\n ),\n ]\n _apply(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n _stop(patches)\n\n self.assertEqual(rc, 0)\n\n # --- on-disk artifacts ---\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n tree_file = data_dir / \"MyAgent_v5_metadata_tree.json\"\n arch_file = data_dir / \"MyAgent_v5_architecture.md\"\n\n self.assertTrue(tree_file.is_file(), \"metadata_tree.json missing\")\n self.assertTrue(arch_file.is_file(), \"architecture.md missing\")\n # summary.md dropped from the output contract —\n # confirm it is NOT written.\n self.assertFalse(\n (data_dir / \"MyAgent_v5_metadata_tree.summary.md\").exists(),\n \"summary.md should have been dropped from the output contract\",\n )\n # rendered architecture filename embeds the agent\n # api name (new convention — proves the rename is wired).\n self.assertTrue(\n arch_file.name.startswith(\"MyAgent\"),\n f\"architecture filename should start with agent api name, got {arch_file.name}\",\n )\n\n tree = json.loads(tree_file.read_text())\n self.assertGreater(tree.get(\"node_count\", 0), 0)\n self.assertEqual(tree[\"agent\"][\"generation\"], \"classic\")\n\n # --- emit_result RESULT block ---\n r = _run_emit_result(work_dir)\n self.assertEqual(r.returncode, 0, msg=r.stderr)\n block = _parse_result_block(r.stdout)\n self.assertIn(block[\"STATUS\"], (\"OK\", \"PARTIAL_OK\"))\n self.assertEqual(block[\"OUTPUT_ARCHITECTURE_PATH\"], str(arch_file))\n self.assertEqual(block[\"RENDER_FAILED\"], \"false\")\n self.assertGreater(int(block[\"NODE_COUNT\"]), 0)\n\n\n# ---------------------------------------------------------------------------\n# Test 2 — NGA end-to-end\n# ---------------------------------------------------------------------------\n\n\nclass NgaEndToEndTests(unittest.TestCase):\n \"\"\"P2.3-3: NGA ConcurrentMultiAgentOrchestration end-to-end.\n\n Exercises the NGA reverse-lookup path (InvocationTarget is a 15/18-\n char Id, not a DeveloperName) and verifies the architecture.md\n carries NGA-specific state-diagram content (`par/and dispatch`).\n \"\"\"\n\n def test_nga_end_to_end_produces_par_state_diagram(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(\n agent_api_name=\"MyAgent2\",\n bot_def=fx.BOT_DEFINITION_DETAIL_NGA,\n ),\n *_mock_wave_a_nga(),\n *_mock_wave_b_nga(),\n mock.patch.object(\n main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\",\n ),\n mock.patch.object(\n main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\",\n ),\n ]\n _apply(patches)\n try:\n rc = main.main(_args(work_dir, agent=\"MyAgent2\"))\n finally:\n _stop(patches)\n\n self.assertEqual(rc, 0)\n\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent2__v5\"\n tree_file = data_dir / \"MyAgent2_v5_metadata_tree.json\"\n arch_file = data_dir / \"MyAgent2_v5_architecture.md\"\n\n self.assertTrue(tree_file.is_file())\n self.assertTrue(arch_file.is_file())\n\n tree = json.loads(tree_file.read_text())\n self.assertEqual(tree[\"agent\"][\"generation\"], \"nga\")\n # NGA reverse-lookup worked — the tree built a TOPIC node for\n # NGATopic1 with 2 GEN_AI_FUNCTION children (Flow + Apex).\n counts = tree[\"_kind_counts\"]\n self.assertEqual(counts.get(\"TOPIC\"), 1)\n self.assertEqual(counts.get(\"GEN_AI_FUNCTION\"), 2)\n\n # Architecture.md renders — surface the NGA tell via the\n # Data flow / Topic anatomy sections. The Planner state\n # machine section (which previously carried `par/and\n # dispatch`) was retired from the default pipeline in\n # 2026-05; the NGA-specific state-diagram output is now\n # exercised directly in RenderNgaTests.\n arch_text = arch_file.read_text()\n self.assertIn(\"NGATopic1\", arch_text)\n self.assertIn(\"NGAFlowAction\", arch_text)\n self.assertIn(\"NGAApexAction\", arch_text)\n # Retired section must not appear.\n self.assertNotIn(\"Planner state machine\", arch_text)\n\n r = _run_emit_result(work_dir)\n block = _parse_result_block(r.stdout)\n self.assertIn(block[\"STATUS\"], (\"OK\", \"PARTIAL_OK\"))\n self.assertEqual(block[\"AGENT_GENERATION\"], \"nga\")\n\n\n# ---------------------------------------------------------------------------\n# Test 3 — cache hit end-to-end\n# ---------------------------------------------------------------------------\n\n\nclass CacheHitEndToEndTests(unittest.TestCase):\n \"\"\"P2.3-3: pre-populated fresh cache short-circuits the pipeline.\n\n Wave A + Wave B fetchers are poisoned with AssertionError — the\n test fails loudly if main.py attempts any fetch on a cache hit.\n \"\"\"\n\n def test_cache_hit_end_to_end_skips_all_wave_calls(self):\n import datetime as dt\n\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n cache_dir = cache_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n data_dir.mkdir(parents=True)\n cache_dir.mkdir(parents=True)\n\n tree_base = \"MyAgent_v5_metadata_tree\"\n # Minimal tree + architecture.md so the cache-hit emit ctx\n # surfaces an OUTPUT_ARCHITECTURE_PATH.\n # architecture filename is self-identifying.\n (data_dir / f\"{tree_base}.json\").write_text(json.dumps({\n \"_schema_version\": \"3.0\",\n \"agent\": {\"generation\": \"classic\"},\n \"root\": {\"children\": []},\n \"node_count\": 12, \"depth\": 3,\n \"_kind_counts\": {}, \"_unresolved\": [],\n }))\n (data_dir / \"MyAgent_v5_architecture.md\").write_text(\"# cached arch\\n\")\n\n from config import SCHEMA_VERSION\n manifest = {\n \"built_at_utc\": dt.datetime.now(dt.timezone.utc).isoformat().replace(\"+00:00\", \"Z\"),\n \"schema_version\": SCHEMA_VERSION,\n \"agent\": {\n \"version\": \"v5\", \"bot_id\": \"0Xa000000000ABC\",\n \"generation\": \"classic\", \"_version_auto_picked\": True,\n },\n \"node_count\": 12, \"depth\": 3, \"kind_counts\": {},\n \"ttl_days\": 7,\n \"data_path\": str(data_dir / f\"{tree_base}.json\"),\n \"partial\": False, \"unresolved_count\": 0,\n }\n (cache_dir / \"manifest.json\").write_text(json.dumps(manifest))\n\n def _poison(*_a, **_kw):\n raise AssertionError(\"cache hit MUST NOT invoke any Wave fetcher\")\n\n poisoned_fetchers = [\n mock.patch.object(main, name, side_effect=_poison)\n for name in (\n \"fetch_planner_definition\",\n \"fetch_plugins_by_planner\",\n \"fetch_planner_bundle_functions\",\n \"fetch_functions_by_plugins\",\n \"fetch_plugin_instructions\",\n \"fetch_plugin_functions\",\n \"fetch_planner_attrs\",\n \"fetch_apex_bodies_by_names\",\n \"fetch_apex_bodies_by_ids\",\n \"fetch_flow_definition_ids_by_names\",\n \"fetch_flow_definition_by_ids\",\n \"fetch_flow_metadata\",\n )\n ]\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *poisoned_fetchers,\n mock.patch.object(\n main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_dir,\n ),\n mock.patch.object(\n main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_dir,\n ),\n ]\n _apply(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n _stop(patches)\n\n self.assertEqual(rc, 0)\n\n r = _run_emit_result(work_dir)\n block = _parse_result_block(r.stdout)\n self.assertEqual(block[\"STATUS\"], \"OK\")\n self.assertEqual(block[\"CACHE_HIT\"], \"true\")\n self.assertEqual(int(block[\"NODE_COUNT\"]), 12)\n # Cached architecture.md surfaces as the OUTPUT path.\n # filename embeds agent api name + version.\n self.assertTrue(block[\"OUTPUT_ARCHITECTURE_PATH\"].endswith(\"MyAgent_v5_architecture.md\"))\n self.assertEqual(block[\"RENDER_FAILED\"], \"false\")\n\n\n# ---------------------------------------------------------------------------\n# Test 4 — probe-failed end-to-end\n# ---------------------------------------------------------------------------\n\n\nclass ProbeFailedEndToEndTests(unittest.TestCase):\n \"\"\"P2.3-3: channel probe returns PROBE_FAILED -> RETRIEVE_FAILED.\n\n Verifies Batch 2.1 remediation: schema-drift manifests as\n STATUS=RETRIEVE_FAILED with ERROR_DETAIL containing `schema-drift`\n rather than a fabricated SCHEMA_DRIFT enum value. The emit_result\n STATUS enum doesn't include SCHEMA_DRIFT, so mapping to\n RETRIEVE_FAILED preserves the existing contract while surfacing\n the cause in ERROR_DETAIL.\n \"\"\"\n\n def test_probe_failed_end_to_end_emits_retrieve_failed(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n\n patches = _mock_auth_probe(\n probe_result=fx.probe_failed_payload(\n sobject=\"GenAiPlannerDefinition\",\n missing=[\"PlannerType\", \"DeveloperName\"],\n ),\n )\n _apply(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n _stop(patches)\n\n # Pipeline returns nonzero on terminal failure — matches the\n # SKILL.md Bash wrapper's set +e / capture _rc / exit _rc\n # contract.\n self.assertEqual(rc, 1)\n\n r = _run_emit_result(work_dir)\n self.assertEqual(r.returncode, 0, msg=r.stderr)\n block = _parse_result_block(r.stdout)\n self.assertEqual(block[\"STATUS\"], \"RETRIEVE_FAILED\")\n self.assertIn(\"schema-drift\", block[\"ERROR_DETAIL\"])\n # The specific missing fields surface in the detail string\n # (first 3 only per main.py's truncation).\n self.assertIn(\"PlannerType\", block[\"ERROR_DETAIL\"])\n\n\n# ---------------------------------------------------------------------------\n# Test 5 — --force end-to-end\n# ---------------------------------------------------------------------------\n\n\nclass ForceRefreshEndToEndTests(unittest.TestCase):\n \"\"\"P2.3-3: fresh cache + --force -> full pipeline runs, cache supersedes.\n\n Asserts:\n * Wave A fetcher was invoked exactly once (cache was NOT used).\n * CACHE_HIT=false in the RESULT block.\n * Post-run manifest differs from pre-run manifest (node_count\n reflects the live-fetched tree, not the 999-node placeholder).\n \"\"\"\n\n def test_force_refresh_bypasses_fresh_cache(self):\n import datetime as dt\n\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n cache_dir = cache_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n data_dir.mkdir(parents=True)\n cache_dir.mkdir(parents=True)\n (data_dir / \"MyAgent_v5_metadata_tree.json\").write_text(\"{}\")\n\n from config import SCHEMA_VERSION\n stale_manifest = {\n \"built_at_utc\": dt.datetime.now(dt.timezone.utc).isoformat().replace(\"+00:00\", \"Z\"),\n \"schema_version\": SCHEMA_VERSION,\n \"agent\": {\"version\": \"v5\"},\n # Bogus node count to detect whether the cache path or\n # the live-fetched tree won.\n \"node_count\": 999, \"depth\": 99, \"kind_counts\": {},\n \"ttl_days\": 7,\n \"data_path\": str(data_dir / \"MyAgent_v5_metadata_tree.json\"),\n \"partial\": False, \"unresolved_count\": 0,\n }\n (cache_dir / \"manifest.json\").write_text(json.dumps(stale_manifest))\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n mock.patch.object(\n main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_dir,\n ),\n mock.patch.object(\n main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_dir,\n ),\n ]\n _apply(patches)\n try:\n rc = main.main(_args(work_dir, force=True))\n planner_calls = main.fetch_planner_definition.call_count\n finally:\n _stop(patches)\n\n self.assertEqual(rc, 0)\n # Wave A actually fired — cache was NOT consulted.\n self.assertEqual(planner_calls, 1)\n\n # Manifest on disk post-run reflects the live fetch, not the\n # 999-node placeholder.\n post_manifest = json.loads((cache_dir / \"manifest.json\").read_text())\n self.assertNotEqual(post_manifest.get(\"node_count\"), 999)\n self.assertGreater(post_manifest.get(\"node_count\", 0), 0)\n\n r = _run_emit_result(work_dir)\n block = _parse_result_block(r.stdout)\n self.assertIn(block[\"STATUS\"], (\"OK\", \"PARTIAL_OK\"))\n self.assertEqual(block[\"CACHE_HIT\"], \"false\")\n\n\n# ---------------------------------------------------------------------------\n# Test 6 — render failure end-to-end\n# ---------------------------------------------------------------------------\n\n\nclass RenderFailureEndToEndTests(unittest.TestCase):\n \"\"\"P2.3-3: renderer raises -> tree OK, architecture.md.error sidecar,\n STATUS=PARTIAL_OK, RENDER_FAILED=true in RESULT block.\n\n contract. The pipeline does NOT abort on render failure —\n the tree JSON is the authoritative output; architecture.md is\n derived. This test asserts the signalling: downstream consumers can\n distinguish \"headline diagram missing\" from \"pipeline succeeded\n cleanly\" via RENDER_FAILED + the sidecar.\n \"\"\"\n\n def test_render_failure_end_to_end_surfaces_partial_ok(self):\n import render_architecture # type: ignore\n\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n def _boom(*_a, **_kw):\n raise RuntimeError(\"render exploded for end-to-end test\")\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n mock.patch.object(\n main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\",\n ),\n mock.patch.object(\n main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\",\n ),\n mock.patch.object(render_architecture, \"render\", side_effect=_boom),\n ]\n _apply(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n _stop(patches)\n\n self.assertEqual(rc, 0)\n\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n # Sidecar landed, architecture.md did NOT.\n # filenames are self-identifying.\n self.assertTrue((data_dir / \"MyAgent_v5_architecture.md.error\").is_file())\n self.assertFalse((data_dir / \"MyAgent_v5_architecture.md\").is_file())\n # Tree JSON DID land — the render failure is isolated to the\n # derived artifact.\n self.assertTrue(\n (data_dir / \"MyAgent_v5_metadata_tree.json\").is_file()\n )\n\n r = _run_emit_result(work_dir)\n block = _parse_result_block(r.stdout)\n # emit_result auto-promotes OK -> PARTIAL_OK on render failure\n # (see build_block .2-R1).\n self.assertEqual(block[\"STATUS\"], \"PARTIAL_OK\")\n self.assertEqual(block[\"RENDER_FAILED\"], \"true\")\n self.assertEqual(block[\"OUTPUT_ARCHITECTURE_PATH\"], \"\")\n self.assertIn(\"RENDER_ERROR_DETAIL\", block)\n self.assertTrue(block[\"RENDER_ERROR_DETAIL\"]) # non-empty\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":26821,"content_sha256":"860e1d2c77840722b454f058be777f23683346ef8335a0ddd2c15d5a0e8aa9d8"},{"filename":"scripts/tests/test_finalize.py","content":"\"\"\"Tests for ``finalize`` — declared-action-tree finalization + cache write.\n\nCovers:\n- ``sort_tree_in_place`` pure tree mutation (TOPIC-first, alphabetical-by-api_name)\n- ``main`` WORK_DIR / DATA_DIR / CACHE_DIR orchestration\n\nThe pure-function tests build small dict trees inline. ``main`` tests\nconstruct a minimal WORK_DIR layout under ``tmp_path`` so the orchestration\nruns end-to-end without any external metadata fixtures.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport unittest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport finalize # type: ignore\n\n\n# -----------------------------------------------------------------------------\n# sort_tree_in_place — pure tree mutation\n# -----------------------------------------------------------------------------\n\n\nclass SortTreeInPlaceTests(unittest.TestCase):\n \"\"\"Per docstring: TOPIC nodes first, then non-topic plannerActions.\n Within each tier, sorted by kind then api_name (case-insensitive).\n Each TOPIC's own children: alphabetical by api_name (case-insensitive).\n FLOW children are NOT sorted.\n \"\"\"\n\n def test_empty_tree_is_noop(self):\n root = {}\n finalize.sort_tree_in_place(root)\n self.assertEqual(root, {})\n\n def test_root_with_no_children_is_noop(self):\n root = {\"children\": []}\n finalize.sort_tree_in_place(root)\n self.assertEqual(root[\"children\"], [])\n\n def test_non_dict_root_is_safe(self):\n # Non-dict input shouldn't blow up.\n finalize.sort_tree_in_place(None) # type: ignore[arg-type]\n finalize.sort_tree_in_place([]) # type: ignore[arg-type]\n\n def test_topics_come_before_non_topics_at_root(self):\n root = {\"children\": [\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"actA\"},\n {\"kind\": \"TOPIC\", \"api_name\": \"topicZ\"},\n {\"kind\": \"APEX\", \"api_name\": \"apexA\"},\n {\"kind\": \"TOPIC\", \"api_name\": \"topicA\"},\n ]}\n finalize.sort_tree_in_place(root)\n kinds = [c[\"kind\"] for c in root[\"children\"]]\n # Two TOPICs first, then non-topics\n self.assertEqual(kinds[:2], [\"TOPIC\", \"TOPIC\"])\n self.assertNotIn(\"TOPIC\", kinds[2:])\n\n def test_topics_sort_alphabetical_case_insensitive(self):\n root = {\"children\": [\n {\"kind\": \"TOPIC\", \"api_name\": \"Zeta\"},\n {\"kind\": \"TOPIC\", \"api_name\": \"alpha\"},\n {\"kind\": \"TOPIC\", \"api_name\": \"Beta\"},\n ]}\n finalize.sort_tree_in_place(root)\n names = [c[\"api_name\"] for c in root[\"children\"]]\n self.assertEqual(names, [\"alpha\", \"Beta\", \"Zeta\"])\n\n def test_non_topics_sort_by_kind_then_name(self):\n root = {\"children\": [\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"z_act\"},\n {\"kind\": \"APEX\", \"api_name\": \"z_apex\"},\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"a_act\"},\n {\"kind\": \"APEX\", \"api_name\": \"a_apex\"},\n ]}\n finalize.sort_tree_in_place(root)\n # Tier 1 sorted by (kind, api_name): APEX rows before STANDARD_ACTION\n kinds = [c[\"kind\"] for c in root[\"children\"]]\n self.assertEqual(kinds, [\"APEX\", \"APEX\", \"STANDARD_ACTION\", \"STANDARD_ACTION\"])\n names_apex = [c[\"api_name\"] for c in root[\"children\"][:2]]\n self.assertEqual(names_apex, [\"a_apex\", \"z_apex\"])\n\n def test_topic_children_sorted_alphabetical_case_insensitive(self):\n root = {\"children\": [{\n \"kind\": \"TOPIC\",\n \"api_name\": \"T1\",\n \"children\": [\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"Zebra\"},\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"apple\"},\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"Banana\"},\n ],\n }]}\n finalize.sort_tree_in_place(root)\n names = [c[\"api_name\"] for c in root[\"children\"][0][\"children\"]]\n self.assertEqual(names, [\"apple\", \"Banana\", \"Zebra\"])\n\n def test_topic_with_no_children_is_safe(self):\n root = {\"children\": [{\"kind\": \"TOPIC\", \"api_name\": \"T1\"}]}\n finalize.sort_tree_in_place(root)\n self.assertEqual(root[\"children\"][0][\"api_name\"], \"T1\")\n\n def test_missing_api_name_treated_as_empty(self):\n # Should sort to the front under casefold of empty string.\n root = {\"children\": [\n {\"kind\": \"TOPIC\", \"api_name\": \"Beta\"},\n {\"kind\": \"TOPIC\"}, # no api_name\n ]}\n finalize.sort_tree_in_place(root)\n names = [c.get(\"api_name\", \"\") for c in root[\"children\"]]\n self.assertEqual(names[0], \"\") # empty/missing comes first\n\n\n# -----------------------------------------------------------------------------\n# main — orchestration via env vars + WORK_DIR\n# -----------------------------------------------------------------------------\n\n\ndef _build_workdir(tmp: Path, *, planner_name: str = \"MyPlanner\",\n pending: dict | None = None,\n unresolved: list | None = None) -> tuple[Path, Path, Path]:\n \"\"\"Construct a minimal WORK_DIR + DATA_DIR + CACHE_DIR triple.\n\n Returns (work_dir, data_dir, cache_dir). Caller passes these as env\n vars before invoking finalize.main().\n \"\"\"\n work_dir = tmp / \"work\"\n data_dir = tmp / \"data\" / \"ALPHA0000000000\" / \"MyAgent__v1\"\n cache_dir = tmp / \"cache\" / \"ALPHA0000000000\" / \"MyAgent__v1\"\n work_dir.mkdir(parents=True)\n\n tree = {\n \"agent\": {\"api_name\": \"MyAgent\", \"version\": \"v1\"},\n \"node_count\": 1,\n \"depth\": 1,\n \"_schema_version\": \"3.1\",\n \"_kind_counts\": {\"TOPIC\": 1},\n \"_pending_fetches\": pending or {},\n \"_unresolved\": unresolved or [],\n \"root\": {\"children\": [{\"kind\": \"TOPIC\", \"api_name\": \"Greetings\"}]},\n }\n (work_dir / \"declared_action_tree.json\").write_text(json.dumps(tree))\n return work_dir, data_dir, cache_dir\n\n\nclass MainTests(unittest.TestCase):\n\n def _run_main(self, work_dir: Path, data_dir: Path, cache_dir: Path,\n *, planner_name: str = \"MyPlanner\") -> int:\n old = dict(os.environ)\n os.environ.update({\n \"WORK_DIR\": str(work_dir),\n \"DATA_DIR\": str(data_dir),\n \"CACHE_DIR\": str(cache_dir),\n \"AGENT_API_NAME\": \"MyAgent\",\n \"AGENT_VERSION\": \"v1\",\n \"PLANNER_NAME\": planner_name,\n })\n try:\n return finalize.main()\n finally:\n os.environ.clear()\n os.environ.update(old)\n\n def test_main_writes_tree_and_manifest_and_returns_zero(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir, data_dir, cache_dir = _build_workdir(tmp)\n rc = self._run_main(work_dir, data_dir, cache_dir)\n self.assertEqual(rc, 0)\n tree_file = data_dir / \"MyAgent_v1_metadata_tree.json\"\n self.assertTrue(tree_file.exists())\n self.assertTrue((data_dir / \"last_built_at.txt\").exists())\n manifest = json.loads((cache_dir / \"manifest.json\").read_text())\n self.assertEqual(manifest[\"agent\"][\"api_name\"], \"MyAgent\")\n self.assertEqual(manifest[\"partial\"], False)\n self.assertEqual(manifest[\"unresolved_count\"], 0)\n\n def test_main_marks_partial_when_planner_name_empty(self):\n # Empty PLANNER_NAME → _partial=True, _partial_reason=\"no-planner\".\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir, data_dir, cache_dir = _build_workdir(tmp)\n rc = self._run_main(work_dir, data_dir, cache_dir, planner_name=\"\")\n self.assertEqual(rc, 0)\n tree = json.loads(\n (data_dir / \"MyAgent_v1_metadata_tree.json\").read_text()\n )\n self.assertTrue(tree[\"_partial\"])\n self.assertEqual(tree[\"_partial_reason\"], \"no-planner\")\n\n def test_main_marks_partial_when_unresolved_present(self):\n # Planner OK, _pending empty, but _unresolved non-empty\n # → _partial=True, _partial_reason=\"unresolved-refs\".\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir, data_dir, cache_dir = _build_workdir(\n tmp, unresolved=[{\"reason\": \"404\"}]\n )\n rc = self._run_main(work_dir, data_dir, cache_dir)\n self.assertEqual(rc, 0)\n tree = json.loads(\n (data_dir / \"MyAgent_v1_metadata_tree.json\").read_text()\n )\n self.assertTrue(tree[\"_partial\"])\n self.assertEqual(tree[\"_partial_reason\"], \"unresolved-refs\")\n\n def test_main_marks_partial_when_pending_present(self):\n # Planner OK, _unresolved empty, but _pending non-empty\n # → _partial=True, _partial_reason=\"pending-refs\".\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir, data_dir, cache_dir = _build_workdir(\n tmp, pending={\"flows\": [\"FlowA\"]}\n )\n rc = self._run_main(work_dir, data_dir, cache_dir)\n self.assertEqual(rc, 0)\n tree = json.loads(\n (data_dir / \"MyAgent_v1_metadata_tree.json\").read_text()\n )\n self.assertTrue(tree[\"_partial\"])\n self.assertEqual(tree[\"_partial_reason\"], \"pending-refs\")\n\n def test_main_returns_one_when_env_missing(self):\n old = dict(os.environ)\n # Wipe required envs. Set only one.\n for k in (\"WORK_DIR\", \"DATA_DIR\", \"CACHE_DIR\",\n \"AGENT_API_NAME\", \"AGENT_VERSION\"):\n os.environ.pop(k, None)\n try:\n rc = finalize.main()\n finally:\n os.environ.clear()\n os.environ.update(old)\n self.assertEqual(rc, 1)\n\n def test_main_returns_one_when_tree_json_missing(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = tmp / \"work\"\n data_dir = tmp / \"data\"\n cache_dir = tmp / \"cache\"\n work_dir.mkdir()\n # No declared_action_tree.json — finalize should bail.\n rc = self._run_main(work_dir, data_dir, cache_dir)\n self.assertEqual(rc, 1)\n\n def test_main_strips_visited_from_durable_artifact(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir, data_dir, cache_dir = _build_workdir(tmp)\n tree_path = work_dir / \"declared_action_tree.json\"\n tree = json.loads(tree_path.read_text())\n tree[\"_visited\"] = {\"some\": \"internal-state\"}\n tree_path.write_text(json.dumps(tree))\n rc = self._run_main(work_dir, data_dir, cache_dir)\n self.assertEqual(rc, 0)\n written = json.loads(\n (data_dir / \"MyAgent_v1_metadata_tree.json\").read_text()\n )\n self.assertNotIn(\"_visited\", written)\n\n def test_main_seeds_gitignore_on_data_and_cache_roots(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir, data_dir, cache_dir = _build_workdir(tmp)\n self._run_main(work_dir, data_dir, cache_dir)\n # data_dir.parent.parent == tmp/data; cache_dir.parent.parent == tmp/cache\n self.assertTrue((data_dir.parent.parent / \".gitignore\").exists())\n self.assertTrue((cache_dir.parent.parent / \".gitignore\").exists())\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11443,"content_sha256":"b8a0c92473549d17428f8ee61d55197a86555bc684c0ce3fc06dee2aa01f3caa"},{"filename":"scripts/tests/test_flow_children_inflation.py","content":"\"\"\"Tests for `main._build_flow_children` + end-to-end FLOW-child inflation.\n\nCloses the Batch-1 placeholder that left `flow_children = {}` unconditionally —\nevery FLOW leaf shipped with `children: []` regardless of the subflow /\nactionCall content present in the fetched `Flow.Metadata`.\n\nAssertions focus on observable behavior:\n * actionCalls are classified into the correct `kind` + carry `element_name`\n (the flow-XML `\u003cname>` that identifies WHICH Flow element invokes the\n target — load-bearing for the rendered tree view).\n * subflows become `{\"kind\": \"FLOW\", ...}` child refs with correct target.\n * The output dict is keyed by FlowDefinition.DeveloperName (what\n `walk_and_inflate` looks up), NOT by ActiveVersionId.\n * Defensive skips on missing ActiveVersionId / missing `Metadata` payload.\n * End-to-end: a mocked Wave B with populated Flow.Metadata produces a tree\n where the FLOW node actually has descendants (the load-bearing integration\n test).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401\n\nimport config # type: ignore\nimport soql_loader # type: ignore\nimport main # type: ignore\nfrom tests.fixtures import genai_payloads as fx # type: ignore\n\n# Re-point SOQL lookup into the repo source (same recipe as test_main_pipeline).\n_REPO_SOQL_DIR = Path(__file__).resolve().parent.parent.parent / \"assets\" / \"soql\"\nconfig.SOQL_DIR = _REPO_SOQL_DIR\nsoql_loader.SOQL_DIR = _REPO_SOQL_DIR\n\n\n# ---------------------------------------------------------------------------\n# Unit tests — _build_flow_children\n# ---------------------------------------------------------------------------\n\n\nclass BuildFlowChildrenTests(unittest.TestCase):\n def test_actioncalls_classified_correctly(self):\n \"\"\"Each actionType (apex / generatePromptResponse / other / empty)\n must route through `classify_action_call` and produce the correct\n `kind` + preserve `element_name` + `api_name`.\"\"\"\n flow_metadata = {\n \"301VF001\": {\n \"Id\": \"301VF001\", \"FullName\": \"MixedFlow-1\",\n \"Metadata\": {\n \"actionCalls\": [\n {\"name\": \"Call_Apex\",\n \"actionType\": \"apex\",\n \"actionName\": \"MyApexHandler\"},\n {\"name\": \"Generate_Output\",\n \"actionType\": \"generatePromptResponse\",\n \"actionName\": \"MyPromptTemplate\"},\n {\"name\": \"Send_Email\",\n \"actionType\": \"emailSimple\",\n \"actionName\": \"SendSimpleEmail\"},\n {\"name\": \"Unknown_Element\",\n \"actionType\": \"\",\n \"actionName\": \"\"},\n ],\n },\n },\n }\n flow_def_rows = [\n {\"Id\": \"300VF001\", \"DeveloperName\": \"MixedFlow\",\n \"ActiveVersionId\": \"301VF001\"},\n ]\n\n out = main._build_flow_children(flow_metadata, flow_def_rows)\n self.assertIn(\"MixedFlow\", out)\n kids = out[\"MixedFlow\"]\n self.assertEqual(len(kids), 4)\n\n by_element = {k[\"element_name\"]: k for k in kids}\n\n self.assertEqual(by_element[\"Call_Apex\"][\"kind\"], \"APEX\")\n self.assertEqual(by_element[\"Call_Apex\"][\"api_name\"], \"MyApexHandler\")\n\n self.assertEqual(by_element[\"Generate_Output\"][\"kind\"], \"PROMPT_TEMPLATE\")\n self.assertEqual(by_element[\"Generate_Output\"][\"api_name\"], \"MyPromptTemplate\")\n\n self.assertEqual(by_element[\"Send_Email\"][\"kind\"], \"STANDARD_ACTION\")\n self.assertEqual(by_element[\"Send_Email\"][\"api_name\"], \"SendSimpleEmail\")\n # `invocation_type` (schema 3.1 canonical name) is preserved for\n # STANDARD_ACTION so render layers can surface the actionType\n # qualifier (emailSimple, etc.) — e.g. `SendSimpleEmail (emailSimple)`\n # in the rendered tree.\n self.assertEqual(by_element[\"Send_Email\"][\"invocation_type\"], \"emailSimple\")\n\n # Empty actionType + empty actionName → UNKNOWN. `classify_action_call`\n # substitutes \"?\" for the api_name when both are blank.\n self.assertEqual(by_element[\"Unknown_Element\"][\"kind\"], \"UNKNOWN\")\n\n def test_subflows_become_flow_children(self):\n \"\"\"Two subflow entries → two `{\"kind\": \"FLOW\", ...}` child refs with\n their `\u003csubflows>\u003cname>` as `element_name` and `\u003cflowName>` as\n `api_name`. These are the load-bearing fields for the recursive\n inflation step.\"\"\"\n flow_metadata = {\n \"301VF001\": {\n \"Id\": \"301VF001\", \"FullName\": \"ParentFlow-1\",\n \"Metadata\": {\n \"subflows\": [\n {\"name\": \"Handle_Fault\",\n \"flowName\": \"handleFlowFault\"},\n {\"name\": \"Run_Subroutine\",\n \"flowName\": \"mySubroutineFlow\"},\n ],\n },\n },\n }\n flow_def_rows = [\n {\"Id\": \"300VF001\", \"DeveloperName\": \"ParentFlow\",\n \"ActiveVersionId\": \"301VF001\"},\n ]\n\n out = main._build_flow_children(flow_metadata, flow_def_rows)\n kids = out[\"ParentFlow\"]\n self.assertEqual(len(kids), 2)\n self.assertTrue(all(k[\"kind\"] == \"FLOW\" for k in kids))\n\n by_element = {k[\"element_name\"]: k for k in kids}\n self.assertEqual(by_element[\"Handle_Fault\"][\"api_name\"], \"handleFlowFault\")\n self.assertEqual(by_element[\"Run_Subroutine\"][\"api_name\"], \"mySubroutineFlow\")\n\n def test_keyed_by_developer_name_not_version_id(self):\n \"\"\"The dict MUST be keyed by FlowDefinition.DeveloperName — that's\n what `walk_and_inflate` looks up via `flow_children[leaf.api_name]`.\n Keying by ActiveVersionId would silently return `{}` and the tree\n would stay flat.\"\"\"\n flow_metadata = {\n \"301VF_A\": {\"Id\": \"301VF_A\", \"FullName\": \"FlowA-1\",\n \"Metadata\": {\"actionCalls\": [\n {\"name\": \"ac1\", \"actionType\": \"apex\",\n \"actionName\": \"ApexA\"},\n ]}},\n \"301VF_B\": {\"Id\": \"301VF_B\", \"FullName\": \"FlowB-1\",\n \"Metadata\": {\"actionCalls\": [\n {\"name\": \"ac1\", \"actionType\": \"apex\",\n \"actionName\": \"ApexB\"},\n ]}},\n }\n flow_def_rows = [\n {\"Id\": \"300VF_A\", \"DeveloperName\": \"FlowAlpha\",\n \"ActiveVersionId\": \"301VF_A\"},\n {\"Id\": \"300VF_B\", \"DeveloperName\": \"FlowBeta\",\n \"ActiveVersionId\": \"301VF_B\"},\n ]\n out = main._build_flow_children(flow_metadata, flow_def_rows)\n self.assertEqual(set(out.keys()), {\"FlowAlpha\", \"FlowBeta\"})\n # Not the version IDs.\n self.assertNotIn(\"301VF_A\", out)\n self.assertNotIn(\"301VF_B\", out)\n\n def test_missing_active_version_id_skipped(self):\n \"\"\"Inactive flows have no ActiveVersionId. They should be silently\n skipped — not raise KeyError, not pollute `flow_children`.\"\"\"\n flow_metadata = {\n \"301VF001\": {\"Id\": \"301VF001\", \"FullName\": \"ActiveFlow-1\",\n \"Metadata\": {\"actionCalls\": []}},\n }\n flow_def_rows = [\n # Active flow: normal row.\n {\"Id\": \"300VF001\", \"DeveloperName\": \"ActiveFlow\",\n \"ActiveVersionId\": \"301VF001\"},\n # Inactive flow: no ActiveVersionId. Must not raise.\n {\"Id\": \"300VF002\", \"DeveloperName\": \"InactiveFlow\",\n \"ActiveVersionId\": None},\n ]\n out = main._build_flow_children(flow_metadata, flow_def_rows)\n self.assertIn(\"ActiveFlow\", out)\n self.assertNotIn(\"InactiveFlow\", out)\n\n def test_empty_wave_b_produces_empty_dict(self):\n \"\"\"Zero flows → empty dict, no crash.\"\"\"\n self.assertEqual(main._build_flow_children({}, []), {})\n self.assertEqual(main._build_flow_children(None or {}, None or []), {})\n\n def test_missing_metadata_field_tolerated(self):\n \"\"\"A Flow record that for some reason lacks `Metadata` (empty dict,\n None, or the key omitted entirely) must degrade to an empty child\n list — never raise. `walk_and_inflate` treats an empty list and\n \"no key\" identically, so we can be permissive and emit empty lists\n in all three cases; the invariant is \"does not raise + does not\n fabricate children\".\"\"\"\n flow_metadata = {\n \"301VF001\": {\"Id\": \"301VF001\", \"FullName\": \"Empty-1\", \"Metadata\": {}},\n \"301VF002\": {\"Id\": \"301VF002\", \"FullName\": \"None-1\", \"Metadata\": None},\n \"301VF003\": {\"Id\": \"301VF003\", \"FullName\": \"NoKey-1\"},\n }\n flow_def_rows = [\n {\"Id\": \"300VF001\", \"DeveloperName\": \"EmptyFlow\",\n \"ActiveVersionId\": \"301VF001\"},\n {\"Id\": \"300VF002\", \"DeveloperName\": \"NoneFlow\",\n \"ActiveVersionId\": \"301VF002\"},\n {\"Id\": \"300VF003\", \"DeveloperName\": \"NoKeyFlow\",\n \"ActiveVersionId\": \"301VF003\"},\n ]\n out = main._build_flow_children(flow_metadata, flow_def_rows)\n for name in (\"EmptyFlow\", \"NoneFlow\", \"NoKeyFlow\"):\n self.assertIn(name, out)\n self.assertEqual(out[name], [])\n\n def test_subflow_missing_flow_name_dropped(self):\n \"\"\"A subflow entry without `flowName` has no api_name to descend\n into — drop it silently rather than emit a broken child ref.\"\"\"\n flow_metadata = {\n \"301VF001\": {\"Id\": \"301VF001\", \"FullName\": \"Parent-1\",\n \"Metadata\": {\"subflows\": [\n {\"name\": \"BadSubflow\"}, # missing flowName\n {\"name\": \"GoodSubflow\", \"flowName\": \"TargetFlow\"},\n ]}},\n }\n flow_def_rows = [\n {\"Id\": \"300VF001\", \"DeveloperName\": \"ParentFlow\",\n \"ActiveVersionId\": \"301VF001\"},\n ]\n out = main._build_flow_children(flow_metadata, flow_def_rows)\n self.assertEqual(len(out[\"ParentFlow\"]), 1)\n self.assertEqual(out[\"ParentFlow\"][0][\"element_name\"], \"GoodSubflow\")\n\n\n# ---------------------------------------------------------------------------\n# Integration test — end-to-end tree inflation through _run_parse_wave\n# ---------------------------------------------------------------------------\n\n\ndef _mock_auth_probe():\n org_display_payload = {\n \"status\": 0,\n \"result\": {\n \"instanceUrl\": \"https://example.my.salesforce.com\",\n \"accessToken\": \"00Dxx0000000000!AQ_fake_token_value\",\n \"id\": \"00Dxx0000000000AAA\",\n \"apiVersion\": \"60.0\",\n },\n }\n return [\n mock.patch.object(main, \"run_sf\", return_value=org_display_payload),\n mock.patch.object(main, \"assert_show_access_token_capability\"),\n mock.patch.object(main, \"probe_channels\", return_value=fx.probe_ok_payload()),\n ]\n\n\ndef _mock_bot_resolution():\n return [\n mock.patch.object(\n main, \"fetch_bot_versions\",\n return_value=fx.make_bot_versions(\"MyAgent\", (\"v5\",), \"v5\"),\n ),\n mock.patch.object(main, \"fetch_bot_definition_details\",\n return_value=fx.BOT_DEFINITION_DETAIL_CLASSIC),\n ]\n\n\nclass EndToEndInflationTests(unittest.TestCase):\n \"\"\"Load-bearing integration test.\n\n Runs the full pipeline with a synthetic Flow.Metadata that has one\n actionCall (apex) + one subflow. Asserts the resulting\n `declared_action_tree.json` has a FLOW node whose `children` list\n is NON-empty and carries the expected `element_name`. This is the\n behavior the bug fix restores.\n \"\"\"\n\n def test_end_to_end_inflation_produces_nested_tree(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n # Single classic bot with one topic + one flow action. The\n # flow's Metadata carries one actionCall (apex) + one subflow.\n planner = dict(fx.CLASSIC_PLANNER)\n plugins = [{\n \"Id\": \"1VyVF000000TOP1\",\n \"DeveloperName\": \"OrderTopic\",\n \"MasterLabel\": \"Order Topic\",\n \"Description\": None,\n \"PluginType\": \"Custom\",\n \"Scope\": \"Order management\",\n \"IsLocal\": True,\n \"CanEscalate\": False,\n \"Source\": \"Declarative\",\n \"ParentId\": planner[\"Id\"],\n \"LocalDeveloperName\": \"OrderTopic\",\n }]\n functions = [{\n \"Id\": \"1VuVF0000000F01\",\n \"DeveloperName\": \"LookupOrderFn\",\n \"MasterLabel\": \"LookupOrderFn\",\n \"Description\": None,\n \"InvocationTargetType\": \"flow\",\n \"InvocationTarget\": \"LookupOrder\",\n \"IsLocal\": True,\n \"IsConfirmationRequired\": False,\n \"IsIncludeInProgressIndicator\": False,\n \"ProgressIndicatorMessage\": None,\n \"Source\": \"Declarative\",\n \"PluginId\": plugins[0][\"Id\"],\n \"PlannerId\": None,\n \"ParentId\": None,\n \"LocalDeveloperName\": \"LookupOrderFn\",\n }]\n plugin_fn_join = [{\n \"Id\": \"pfj1\", \"PluginId\": plugins[0][\"Id\"],\n \"Function\": functions[0][\"Id\"],\n }]\n flow_defs = [{\n \"Id\": \"300VF001\", \"DeveloperName\": \"LookupOrder\",\n \"ActiveVersionId\": \"301VF001\",\n }]\n flow_metadata_record = {\n \"Id\": \"301VF001\", \"FullName\": \"LookupOrder-1\",\n \"Metadata\": {\n \"actionCalls\": [{\n \"name\": \"Call_The_Apex_Helper\",\n \"actionType\": \"apex\",\n \"actionName\": \"OrderLookupApex\",\n }],\n \"subflows\": [{\n \"name\": \"Run_Subflow\",\n \"flowName\": \"OrderLookupSubflow\",\n }],\n },\n }\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n mock.patch.object(main, \"fetch_planner_definition\", return_value=planner),\n mock.patch.object(main, \"fetch_plugins_by_planner\", return_value=plugins),\n mock.patch.object(main, \"fetch_planner_bundle_functions\", return_value=[]),\n mock.patch.object(main, \"fetch_functions_by_plugins\",\n return_value=functions),\n mock.patch.object(main, \"fetch_plugin_instructions\", return_value=[]),\n mock.patch.object(main, \"fetch_plugin_functions\",\n return_value=plugin_fn_join),\n mock.patch.object(main, \"fetch_planner_attrs\", return_value=[]),\n mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=[]),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\",\n return_value=flow_defs),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=[]),\n mock.patch.object(\n main, \"fetch_flow_metadata\",\n side_effect=lambda vid, *a, **kw: (\n flow_metadata_record if vid == \"301VF001\" else None\n ),\n ),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n for p in patches:\n p.start()\n try:\n rc = main.main([\n \"--org-alias\", \"test-org\",\n \"--agent\", \"MyAgent\",\n \"--work-dir\", str(work_dir),\n \"--parallelism\", \"2\",\n ])\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n tree = json.loads((work_dir / \"declared_action_tree.json\").read_text())\n\n # Locate the FLOW leaf under the topic's GEN_AI_FUNCTION.\n topic = tree[\"root\"][\"children\"][0]\n gen_ai_fn = topic[\"children\"][0]\n flow_leaf = gen_ai_fn[\"children\"][0]\n self.assertEqual(flow_leaf[\"kind\"], \"FLOW\")\n self.assertEqual(flow_leaf[\"api_name\"], \"LookupOrder\")\n\n # BUG FIX ASSERTION: the FLOW node now has non-empty children.\n # Before the fix, `flow_children = {}` meant every FLOW leaf\n # shipped with `children: []` regardless of actionCalls /\n # subflows content.\n self.assertEqual(len(flow_leaf[\"children\"]), 2)\n\n element_names = {c[\"element_name\"] for c in flow_leaf[\"children\"]}\n self.assertIn(\"Call_The_Apex_Helper\", element_names)\n self.assertIn(\"Run_Subflow\", element_names)\n\n # Kinds are correctly classified — APEX for the actionCall,\n # FLOW for the subflow.\n by_element = {c[\"element_name\"]: c for c in flow_leaf[\"children\"]}\n self.assertEqual(by_element[\"Call_The_Apex_Helper\"][\"kind\"], \"APEX\")\n self.assertEqual(\n by_element[\"Call_The_Apex_Helper\"][\"api_name\"],\n \"OrderLookupApex\",\n )\n self.assertEqual(by_element[\"Run_Subflow\"][\"kind\"], \"FLOW\")\n self.assertEqual(\n by_element[\"Run_Subflow\"][\"api_name\"],\n \"OrderLookupSubflow\",\n )\n\n def test_nested_subflow_subtree_populated_via_iteration(self):\n \"\"\"iterative Wave B must expand shared\n utility-flow leaves.\n\n Before the iteration pass: the top-level flow `LookupOrder`\n referenced a subflow `handleFlowFault`, which landed as an empty\n FLOW leaf because its body was never queried. This test pushes\n end-to-end through `main()` and asserts the `handleFlowFault`\n node in the tree has CHILDREN — specifically the Apex class\n `XCSF_FlowFaultMessage` referenced by its own body.\n \"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n planner = dict(fx.CLASSIC_PLANNER)\n plugins = [{\n \"Id\": \"1VyVF000000TOP1\",\n \"DeveloperName\": \"OrderTopic\",\n \"MasterLabel\": \"Order Topic\",\n \"Description\": None,\n \"PluginType\": \"Custom\",\n \"Scope\": \"Order management\",\n \"IsLocal\": True,\n \"CanEscalate\": False,\n \"Source\": \"Declarative\",\n \"ParentId\": planner[\"Id\"],\n \"LocalDeveloperName\": \"OrderTopic\",\n }]\n functions = [{\n \"Id\": \"1VuVF0000000F01\",\n \"DeveloperName\": \"LookupOrderFn\",\n \"MasterLabel\": \"LookupOrderFn\",\n \"Description\": None,\n \"InvocationTargetType\": \"flow\",\n \"InvocationTarget\": \"LookupOrder\",\n \"IsLocal\": True,\n \"IsConfirmationRequired\": False,\n \"IsIncludeInProgressIndicator\": False,\n \"ProgressIndicatorMessage\": None,\n \"Source\": \"Declarative\",\n \"PluginId\": plugins[0][\"Id\"],\n \"PlannerId\": None,\n \"ParentId\": None,\n \"LocalDeveloperName\": \"LookupOrderFn\",\n }]\n plugin_fn_join = [{\n \"Id\": \"pfj1\", \"PluginId\": plugins[0][\"Id\"],\n \"Function\": functions[0][\"Id\"],\n }]\n\n # Flow registry keyed by DeveloperName → (FlowDefinition row,\n # Flow.Metadata record). The mock fetcher consults this on\n # each iteration round.\n flow_registry = {\n \"LookupOrder\": (\n {\"Id\": \"300VF001\", \"DeveloperName\": \"LookupOrder\",\n \"ActiveVersionId\": \"301VF001\"},\n {\"Id\": \"301VF001\", \"FullName\": \"LookupOrder-1\",\n \"Metadata\": {\n \"actionCalls\": [],\n \"subflows\": [{\n \"name\": \"Handle_Flow_Fault\",\n \"flowName\": \"handleFlowFault\",\n }],\n }},\n ),\n \"handleFlowFault\": (\n {\"Id\": \"300VF002\", \"DeveloperName\": \"handleFlowFault\",\n \"ActiveVersionId\": \"301VF002\"},\n {\"Id\": \"301VF002\", \"FullName\": \"handleFlowFault-1\",\n \"Metadata\": {\n \"actionCalls\": [{\n \"name\": \"Parse_and_log_fault\",\n \"actionType\": \"apex\",\n \"actionName\": \"XCSF_FlowFaultMessage\",\n }],\n \"subflows\": [],\n }},\n ),\n }\n\n def fake_flow_def_by_names(names, *a, **kw):\n return [\n flow_registry[n][0] for n in names if n in flow_registry\n ]\n\n def fake_flow_metadata(vid, *a, **kw):\n for _name, (fd, fm) in flow_registry.items():\n if fd[\"ActiveVersionId\"] == vid:\n return fm\n return None\n\n # Apex bodies: XCSF_FlowFaultMessage is discovered via the\n # subflow's actionCall in iteration round 2.\n def fake_apex_by_names(names, *a, **kw):\n apex_registry = {\n \"XCSF_FlowFaultMessage\": {\n \"Id\": \"01pVF001\", \"Name\": \"XCSF_FlowFaultMessage\",\n \"Body\": \"public class XCSF_FlowFaultMessage { }\",\n },\n }\n return [apex_registry[n] for n in names if n in apex_registry]\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n mock.patch.object(main, \"fetch_planner_definition\", return_value=planner),\n mock.patch.object(main, \"fetch_plugins_by_planner\", return_value=plugins),\n mock.patch.object(main, \"fetch_planner_bundle_functions\", return_value=[]),\n mock.patch.object(main, \"fetch_functions_by_plugins\",\n return_value=functions),\n mock.patch.object(main, \"fetch_plugin_instructions\", return_value=[]),\n mock.patch.object(main, \"fetch_plugin_functions\",\n return_value=plugin_fn_join),\n mock.patch.object(main, \"fetch_planner_attrs\", return_value=[]),\n mock.patch.object(main, \"fetch_apex_bodies_by_names\",\n side_effect=fake_apex_by_names),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\",\n side_effect=fake_flow_def_by_names),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_metadata\",\n side_effect=fake_flow_metadata),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n for p in patches:\n p.start()\n try:\n rc = main.main([\n \"--org-alias\", \"test-org\",\n \"--agent\", \"MyAgent\",\n \"--work-dir\", str(work_dir),\n \"--parallelism\", \"2\",\n ])\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n tree = json.loads((work_dir / \"declared_action_tree.json\").read_text())\n\n topic = tree[\"root\"][\"children\"][0]\n gen_ai_fn = topic[\"children\"][0]\n top_flow = gen_ai_fn[\"children\"][0]\n self.assertEqual(top_flow[\"api_name\"], \"LookupOrder\")\n\n # LookupOrder → Handle_Flow_Fault (FLOW leaf, pre-iteration\n # this was empty). Post-iteration, it must carry children.\n handle_flow_fault = top_flow[\"children\"][0]\n self.assertEqual(handle_flow_fault[\"kind\"], \"FLOW\")\n self.assertEqual(handle_flow_fault[\"api_name\"], \"handleFlowFault\")\n\n # Core assertion: the shared utility flow's subtree is now\n # populated via iterative Wave B fetching its body.\n self.assertTrue(\n handle_flow_fault[\"children\"],\n \"handleFlowFault subtree is empty — iterative Wave B \"\n \"did not fetch its body. This is the bug.\",\n )\n apex_kids = [\n c for c in handle_flow_fault[\"children\"]\n if c[\"kind\"] == \"APEX\" and c[\"api_name\"] == \"XCSF_FlowFaultMessage\"\n ]\n self.assertEqual(len(apex_kids), 1)\n self.assertEqual(apex_kids[0][\"element_name\"], \"Parse_and_log_fault\")\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":26180,"content_sha256":"bf03ad47c47b1a4a249b297bc631ecd6227e69c662a4d40ac736a9ce1b96c907"},{"filename":"scripts/tests/test_fs_guard.py","content":"\"\"\"Tests for fs_guard Python-importable validators + api_version check.\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nfrom . import _bootstrap # noqa: F401\n\nfrom config import fs_guard # type: ignore — re-exported from _shared/\n\n\nclass ValidateApiNameTests(unittest.TestCase):\n def test_valid_name_passes(self):\n fs_guard.validate_api_name(\"MyAgent\", label=\"name\")\n fs_guard.validate_api_name(\"Foo_v2\", label=\"name\")\n fs_guard.validate_api_name(\"x\", label=\"name\")\n\n def test_dotdot_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_name(\"..\", label=\"agent_api_name\")\n\n def test_slash_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_name(\"foo/bar\", label=\"agent_api_name\")\n\n def test_dotdot_in_path_segment_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_name(\"foo/../bar\", label=\"agent_api_name\")\n\n def test_null_byte_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_name(\"foo\\x00bar\", label=\"name\")\n\n def test_empty_string_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_name(\"\", label=\"name\")\n\n def test_none_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_name(None, label=\"name\")\n\n def test_non_string_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_name(42, label=\"name\")\n\n def test_validation_error_carries_label(self):\n with self.assertRaises(fs_guard.ValidationError) as ctx:\n fs_guard.validate_api_name(\"bad/name\", label=\"agent_api_name\")\n self.assertEqual(ctx.exception.label, \"agent_api_name\")\n\n\nclass ValidateApiVersionTests(unittest.TestCase):\n def test_v60_0_ok(self):\n fs_guard.validate_api_version(\"v60.0\", label=\"api_version\")\n\n def test_v66_0_ok(self):\n fs_guard.validate_api_version(\"v66.0\", label=\"api_version\")\n\n def test_v60_no_minor_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_version(\"v60\", label=\"api_version\")\n\n def test_bare_number_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_version(\"60.0\", label=\"api_version\")\n\n def test_slash_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_version(\"v60.0/../\", label=\"api_version\")\n\n def test_dotdot_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_version(\"..\", label=\"api_version\")\n\n def test_empty_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_api_version(\"\", label=\"api_version\")\n\n\nclass ValidateOrgId15Tests(unittest.TestCase):\n def test_valid_15_char_alnum_ok(self):\n fs_guard.validate_org_id_15(\"00Dxx0000000000\", label=\"org_id_15\")\n\n def test_slash_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_org_id_15(\"00D/x0000000000\", label=\"org_id_15\")\n\n def test_wrong_length_rejected(self):\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_org_id_15(\"00Dxx000000000\", label=\"org_id_15\")\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_org_id_15(\"00Dxx0000000000X\", label=\"org_id_15\")\n\n def test_underscore_rejected(self):\n # ORG_ID_15_RE forbids underscores — stricter than api_name.\n with self.assertRaises(fs_guard.ValidationError):\n fs_guard.validate_org_id_15(\"00Dxx00000000_0\", label=\"org_id_15\")\n\n\nclass CliApiVersionCheckTests(unittest.TestCase):\n \"\"\"The api_version check type is registered in VALID_CHECKS + CHECKS.\"\"\"\n\n def test_registered_in_valid_checks(self):\n self.assertIn(\"api_version\", fs_guard.VALID_CHECKS)\n\n def test_registered_in_checks_dispatch(self):\n self.assertIn(\"api_version\", fs_guard.CHECKS)\n\n def test_check_function_exists(self):\n self.assertTrue(callable(fs_guard.check_api_version))\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4390,"content_sha256":"51bdaadb21cc9e47043d9b1f46f4c1c23b0d183c2a0bc73080c6eb130f66e5dd"},{"filename":"scripts/tests/test_iterative_wave_b.py","content":"\"\"\"Tests for `main._iterate_wave_b` — iterative subflow + apex discovery.\n\nThe initial `_run_wave_b` only enumerates top-level flow refs from\n`bundle_parsed`. When those flow bodies reference subflows (shared\nutility flows like `handleFlowFault`) or new Apex classes, their bodies\nwere never fetched. `_iterate_wave_b` drives Wave B to a fixed point so\nevery reachable flow/apex body lands in the tree.\n\nAssertions focus on observable behavior:\n * Convergence shape — no extra fetches when there are no new refs.\n * Correctness — a utility flow referenced from the top-level round is\n fetched in the next round and its metadata lands in the merged dict.\n * Safety — iteration cap surfaces remaining unfetched names via\n `unresolved`; fetch failures are tolerated without crashing.\n * Cross-kind discovery — a subflow's actionCall referencing a new\n Apex class adds that class to `apex_rows`.\n * Deduplication — the same subflow referenced twice is fetched once.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401\n\nimport main # type: ignore\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n\ndef _creds_provider():\n return (\"https://example.my.salesforce.com\", \"fake_token\")\n\n\ndef _refresh_fn():\n return _creds_provider()\n\n\ndef _flow_def_row(dev_name: str, version_id: str) -> dict:\n \"\"\"Shape matches what `fetch_flow_definition_ids_by_names` returns —\n the fields `_build_flow_children` + the pipeline downstream expect.\"\"\"\n return {\n \"Id\": f\"300{dev_name}\",\n \"DeveloperName\": dev_name,\n \"ActiveVersionId\": version_id,\n }\n\n\ndef _flow_metadata_record(\n version_id: str,\n full_name: str,\n *,\n subflows: list[tuple[str, str]] | None = None,\n action_calls: list[dict] | None = None,\n) -> dict:\n \"\"\"Shape a synthetic Flow.Metadata record the way `fetch_flow_metadata`\n returns it — a dict with Id/FullName/Metadata. Subflow tuples are\n `(element_name, flow_name)`; action_calls are raw dicts.\"\"\"\n md: dict = {}\n if subflows is not None:\n md[\"subflows\"] = [\n {\"name\": elem, \"flowName\": target} for elem, target in subflows\n ]\n if action_calls is not None:\n md[\"actionCalls\"] = action_calls\n return {\"Id\": version_id, \"FullName\": full_name, \"Metadata\": md}\n\n\ndef _apex_row(name: str) -> dict:\n \"\"\"Shape matches `fetch_apex_bodies_by_names` output — `_iterate_wave_b`\n tracks already-fetched by `Name`.\"\"\"\n return {\"Id\": f\"01p_{name}\", \"Name\": name, \"Body\": \"public class ...\"}\n\n\n# ---------------------------------------------------------------------------\n# Tests\n# ---------------------------------------------------------------------------\n\n\nclass IterativeWaveBTests(unittest.TestCase):\n def test_single_round_when_no_subflows(self):\n \"\"\"Round 1 flow body references no subflows and no new apex →\n `_extract_refs_from_flow_metadata` returns empty sets →\n `_fetch_wave_b_by_names` is never invoked → initial wave_b passes\n through unchanged.\"\"\"\n initial = {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(\"TopFlow\", \"301TOP\")],\n \"flow_metadata\": {\n \"301TOP\": _flow_metadata_record(\n \"301TOP\", \"TopFlow-1\", subflows=[], action_calls=[],\n ),\n },\n \"unresolved\": [],\n }\n\n with mock.patch.object(main, \"_fetch_wave_b_by_names\") as mocked:\n out = main._iterate_wave_b(\n initial, _creds_provider, _refresh_fn,\n api_version=\"v60.0\", org_alias=\"test-org\",\n parallelism=2, max_iterations=5,\n )\n\n mocked.assert_not_called()\n self.assertEqual(\n {r[\"DeveloperName\"] for r in out[\"flow_def_rows\"]},\n {\"TopFlow\"},\n )\n self.assertEqual(out[\"unresolved\"], [])\n\n def test_two_rounds_for_shared_utility_flow(self):\n \"\"\"Round 1: a top-level flow body references subflow `handleFlowFault`.\n Round 2: fetches `handleFlowFault`'s metadata. Round 3: no new refs.\n Converges. Final flow_metadata contains BOTH flows.\n\n This is the real-org `MyAgent__v5` shape — `handleFlowFault`\n is shared across every flow and should be fetched in one extra\n round.\n \"\"\"\n initial = {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(\"AGNT_Top_Flow\", \"301TOP\")],\n \"flow_metadata\": {\n \"301TOP\": _flow_metadata_record(\n \"301TOP\", \"AGNT_Top_Flow-1\",\n subflows=[(\"Handle_Flow_Fault\", \"handleFlowFault\")],\n ),\n },\n \"unresolved\": [],\n }\n\n # Mock `_fetch_wave_b_by_names` for two rounds:\n # Round 1: flow_names=[handleFlowFault] → returns metadata whose\n # actionCalls reference a NEW apex (XCSF_FlowFaultMessage).\n # Round 2: apex_names=[XCSF_FlowFaultMessage] → apex row returned.\n # Round 3: no new refs → converge.\n calls: list[dict] = []\n\n def fake_fetch(**kwargs):\n calls.append({\n \"flow_names\": list(kwargs[\"flow_names\"]),\n \"apex_names\": list(kwargs[\"apex_names\"]),\n })\n if kwargs[\"flow_names\"] == [\"handleFlowFault\"]:\n return {\n \"apex_rows\": [],\n \"flow_def_rows\": [\n _flow_def_row(\"handleFlowFault\", \"301UTIL\"),\n ],\n \"flow_metadata\": {\n \"301UTIL\": _flow_metadata_record(\n \"301UTIL\", \"handleFlowFault-1\",\n subflows=[],\n action_calls=[{\n \"name\": \"Parse_and_log_fault\",\n \"actionType\": \"apex\",\n \"actionName\": \"XCSF_FlowFaultMessage\",\n }],\n ),\n },\n \"unresolved\": [],\n }\n if kwargs[\"apex_names\"] == [\"XCSF_FlowFaultMessage\"]:\n return {\n \"apex_rows\": [_apex_row(\"XCSF_FlowFaultMessage\")],\n \"flow_def_rows\": [],\n \"flow_metadata\": {},\n \"unresolved\": [],\n }\n raise AssertionError(f\"unexpected fetch call: {kwargs}\")\n\n with mock.patch.object(\n main, \"_fetch_wave_b_by_names\", side_effect=fake_fetch,\n ) as mocked:\n out = main._iterate_wave_b(\n initial, _creds_provider, _refresh_fn,\n api_version=\"v60.0\", org_alias=\"test-org\",\n parallelism=2, max_iterations=5,\n )\n\n # Exactly two rounds: round 1 picks up handleFlowFault, round 2\n # picks up the apex discovered in round 1's subflow body, round 3\n # converges without invoking the fetcher.\n self.assertEqual(mocked.call_count, 2)\n dev_names = {r[\"DeveloperName\"] for r in out[\"flow_def_rows\"]}\n self.assertEqual(dev_names, {\"AGNT_Top_Flow\", \"handleFlowFault\"})\n self.assertIn(\"301UTIL\", out[\"flow_metadata\"])\n apex_names_out = {r[\"Name\"] for r in out[\"apex_rows\"]}\n self.assertIn(\"XCSF_FlowFaultMessage\", apex_names_out)\n # Call-order sanity: flow round precedes apex round.\n self.assertEqual(calls[0][\"flow_names\"], [\"handleFlowFault\"])\n self.assertEqual(calls[1][\"apex_names\"], [\"XCSF_FlowFaultMessage\"])\n\n def test_actioncall_discovers_new_apex(self):\n \"\"\"A subflow body carries an actionCall referencing a new Apex\n class. Round 2 fetches that Apex. `wave_b[\"apex_rows\"]` grows.\"\"\"\n initial = {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(\"TopFlow\", \"301TOP\")],\n \"flow_metadata\": {\n \"301TOP\": _flow_metadata_record(\n \"301TOP\", \"TopFlow-1\",\n subflows=[],\n action_calls=[{\n \"name\": \"Invoke_Helper\",\n \"actionType\": \"apex\",\n \"actionName\": \"NewApexHelper\",\n }],\n ),\n },\n \"unresolved\": [],\n }\n\n def fake_fetch(**kwargs):\n # Must be the apex-only round — no new flow names.\n self.assertEqual(kwargs[\"flow_names\"], [])\n self.assertEqual(kwargs[\"apex_names\"], [\"NewApexHelper\"])\n return {\n \"apex_rows\": [_apex_row(\"NewApexHelper\")],\n \"flow_def_rows\": [],\n \"flow_metadata\": {},\n \"unresolved\": [],\n }\n\n with mock.patch.object(\n main, \"_fetch_wave_b_by_names\", side_effect=fake_fetch,\n ) as mocked:\n out = main._iterate_wave_b(\n initial, _creds_provider, _refresh_fn,\n api_version=\"v60.0\", org_alias=\"test-org\",\n parallelism=2, max_iterations=5,\n )\n\n self.assertEqual(mocked.call_count, 1)\n apex_names_seen = {r[\"Name\"] for r in out[\"apex_rows\"]}\n self.assertEqual(apex_names_seen, {\"NewApexHelper\"})\n\n def test_dedup_across_rounds(self):\n \"\"\"The same subflow referenced from two parent flows is fetched\n exactly once. `_iterate_wave_b` diffs against already-fetched\n names — duplicates are filtered before dispatch.\"\"\"\n initial = {\n \"apex_rows\": [],\n \"flow_def_rows\": [\n _flow_def_row(\"ParentA\", \"301A\"),\n _flow_def_row(\"ParentB\", \"301B\"),\n ],\n \"flow_metadata\": {\n \"301A\": _flow_metadata_record(\n \"301A\", \"ParentA-1\",\n subflows=[(\"Handle_A\", \"SharedUtility\")],\n ),\n \"301B\": _flow_metadata_record(\n \"301B\", \"ParentB-1\",\n subflows=[(\"Handle_B\", \"SharedUtility\")],\n ),\n },\n \"unresolved\": [],\n }\n\n call_log: list[list[str]] = []\n\n def fake_fetch(**kwargs):\n call_log.append(list(kwargs[\"flow_names\"]))\n return {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(\"SharedUtility\", \"301UTIL\")],\n \"flow_metadata\": {\n \"301UTIL\": _flow_metadata_record(\n \"301UTIL\", \"SharedUtility-1\", subflows=[],\n ),\n },\n \"unresolved\": [],\n }\n\n with mock.patch.object(\n main, \"_fetch_wave_b_by_names\", side_effect=fake_fetch,\n ) as mocked:\n main._iterate_wave_b(\n initial, _creds_provider, _refresh_fn,\n api_version=\"v60.0\", org_alias=\"test-org\",\n parallelism=2, max_iterations=5,\n )\n\n # Exactly ONE round of fetching — round 1 queries [SharedUtility]\n # (deduped across the two parents); round 2 finds no new refs.\n self.assertEqual(mocked.call_count, 1)\n self.assertEqual(call_log[0], [\"SharedUtility\"])\n\n def test_iteration_cap_surfaces_pending(self):\n \"\"\"Pathological graph where every round introduces a new subflow\n reference (chain: F1 → F2 → F3 → ...). After `max_iterations`\n rounds, remaining unfetched names are surfaced in `unresolved`\n so the pipeline can degrade to PARTIAL_OK with a clear cause.\n\n Use `max_iterations=3` to keep the test fast; the shape is\n identical to the production cap of 5.\n \"\"\"\n initial = {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(\"F0\", \"301_V0\")],\n \"flow_metadata\": {\n \"301_V0\": _flow_metadata_record(\n \"301_V0\", \"F0-1\", subflows=[(\"Chain\", \"F1\")],\n ),\n },\n \"unresolved\": [],\n }\n\n round_counter = {\"i\": 1}\n\n def fake_fetch(**kwargs):\n \"\"\"Each round returns a flow whose body references the next\n flow in the chain — unbounded growth.\"\"\"\n requested = kwargs[\"flow_names\"][0]\n next_flow = f\"F{round_counter['i'] + 1}\"\n round_counter[\"i\"] += 1\n return {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(requested, f\"301_V{requested}\")],\n \"flow_metadata\": {\n f\"301_V{requested}\": _flow_metadata_record(\n f\"301_V{requested}\", f\"{requested}-1\",\n subflows=[(\"Chain\", next_flow)],\n ),\n },\n \"unresolved\": [],\n }\n\n with mock.patch.object(\n main, \"_fetch_wave_b_by_names\", side_effect=fake_fetch,\n ) as mocked:\n out = main._iterate_wave_b(\n initial, _creds_provider, _refresh_fn,\n api_version=\"v60.0\", org_alias=\"test-org\",\n parallelism=2, max_iterations=3,\n )\n\n # We exhausted exactly max_iterations rounds.\n self.assertEqual(mocked.call_count, 3)\n # The unfetched chain-tail lands in `unresolved` with the cap\n # reason so the pipeline can reflect it in the RESULT block.\n iter_cap_entries = [\n u for u in out[\"unresolved\"]\n if \"wave-b-iteration-cap\" in (u.get(\"reason\") or \"\")\n ]\n self.assertTrue(iter_cap_entries)\n # The next-round flow reference (F4, unfetched) must be among them.\n pending_names = {u.get(\"api_name\") for u in iter_cap_entries}\n self.assertIn(\"F4\", pending_names)\n\n def test_fetch_failure_tolerated(self):\n \"\"\"If the round-N fetch raises (managed-package flow not readable,\n permission denied, etc.), the iteration doesn't crash. The\n affected name stays absent from `flow_def_rows` and surfaces via\n the pending-fetches path downstream; other rounds continue.\n\n Concretely: we simulate the failure by having the mocked\n `_fetch_wave_b_by_names` append an `unresolved` entry instead of\n returning metadata for the troubled name, which is exactly the\n contract `_fetch_wave_b_by_names` follows internally (it\n catches RestClientError / SoqlParamError and logs to\n `unresolved` without raising).\n \"\"\"\n initial = {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(\"TopFlow\", \"301TOP\")],\n \"flow_metadata\": {\n \"301TOP\": _flow_metadata_record(\n \"301TOP\", \"TopFlow-1\",\n subflows=[\n (\"Good_Sub\", \"GoodSubflow\"),\n (\"Bad_Sub\", \"UnreachableFlow\"),\n ],\n ),\n },\n \"unresolved\": [],\n }\n\n def fake_fetch(**kwargs):\n # Only the reachable one resolves. The unreachable one is\n # reported via the `unresolved` channel by the inner fetcher.\n return {\n \"apex_rows\": [],\n \"flow_def_rows\": [_flow_def_row(\"GoodSubflow\", \"301GOOD\")],\n \"flow_metadata\": {\n \"301GOOD\": _flow_metadata_record(\n \"301GOOD\", \"GoodSubflow-1\", subflows=[],\n ),\n },\n \"unresolved\": [{\n \"kind\": \"FLOW\",\n \"reason\": \"flow-def-by-name-failed:managed-package-403\",\n }],\n }\n\n with mock.patch.object(\n main, \"_fetch_wave_b_by_names\", side_effect=fake_fetch,\n ):\n out = main._iterate_wave_b(\n initial, _creds_provider, _refresh_fn,\n api_version=\"v60.0\", org_alias=\"test-org\",\n parallelism=2, max_iterations=5,\n )\n\n dev_names = {r[\"DeveloperName\"] for r in out[\"flow_def_rows\"]}\n # GoodSubflow made it in; UnreachableFlow did not.\n self.assertIn(\"GoodSubflow\", dev_names)\n self.assertNotIn(\"UnreachableFlow\", dev_names)\n # The inner-fetch's unresolved entry propagates.\n reasons = [u.get(\"reason\") or \"\" for u in out[\"unresolved\"]]\n self.assertTrue(\n any(\"managed-package-403\" in r for r in reasons),\n f\"expected propagated failure reason; got {reasons}\",\n )\n # UnreachableFlow is still referenced but not fetched — after\n # iteration converges on subsequent rounds (no new discoveries\n # because GoodSubflow is leaf), it lands as iteration-cap\n # pending. In this test max_iterations=5 and the chain stops at\n # round 1; UnreachableFlow is still missing from fetched names,\n # so the convergence scan at the end flags it? Actually no —\n # convergence only surfaces via the cap. Let's verify that the\n # pipeline's parse_wave path would still mark it as pending via\n # visited-vs-referenced — but that's out of scope for this unit\n # test. The contract here is: iteration doesn't crash, good\n # flows are merged, bad-flow signal propagates.\n\n\nclass ExtractRefsFromFlowMetadataTests(unittest.TestCase):\n \"\"\"Direct coverage for `_extract_refs_from_flow_metadata` — the\n load-bearing extractor that drives iteration. Its permissiveness\n (tolerating missing/None/malformed records) is a correctness\n property: if it raised on edge cases, iteration would crash on any\n flow with a weird metadata shape.\"\"\"\n\n def test_extracts_subflow_and_apex_names(self):\n flow_metadata = {\n \"301A\": _flow_metadata_record(\n \"301A\", \"A-1\",\n subflows=[(\"E1\", \"SubA\"), (\"E2\", \"SubB\")],\n action_calls=[\n {\"name\": \"ApexCall\", \"actionType\": \"apex\",\n \"actionName\": \"MyApex\"},\n # non-apex actionCall shouldn't appear in apex set\n {\"name\": \"StdCall\", \"actionType\": \"emailSimple\",\n \"actionName\": \"SendEmail\"},\n ],\n ),\n }\n subs, apex = main._extract_refs_from_flow_metadata(flow_metadata)\n self.assertEqual(subs, {\"SubA\", \"SubB\"})\n self.assertEqual(apex, {\"MyApex\"})\n\n def test_tolerates_missing_fields(self):\n \"\"\"None records, non-dict metadata, missing keys, blank names —\n none of these should raise; they all drop silently.\"\"\"\n flow_metadata = {\n \"301A\": None,\n \"301B\": {\"Metadata\": None},\n \"301C\": {\"Metadata\": \"not-a-dict\"},\n \"301D\": {\"Metadata\": {}},\n \"301E\": {\"Metadata\": {\n \"subflows\": [{\"name\": \"noName\"}, \"not-a-dict\",\n {\"name\": \"E\", \"flowName\": \"\"}],\n \"actionCalls\": [{\"actionType\": \"apex\", \"actionName\": \"\"},\n \"not-a-dict\"],\n }},\n }\n subs, apex = main._extract_refs_from_flow_metadata(flow_metadata)\n self.assertEqual(subs, set())\n self.assertEqual(apex, set())\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":19424,"content_sha256":"7e5ef0021ea44075a01f29c3a02193600768ce3ad78326c2edba71cc222bc170"},{"filename":"scripts/tests/test_main_pipeline.py","content":"\"\"\"Phase 2 Batch 1 integration tests for `main.py`.\n\nEnd-to-end pipeline exercise without hitting a real org. Every SOQL-level\nprimitive (`fetch_*`, `run_sf`, `probe_channels`) is mocked on the\n`main` module's import namespace so the orchestrator sees synthetic\nrows shaped like live describe output.\n\nWhat these tests assert:\n * RESULT-level behavior — the `.emit_ctx.json` that main.py writes\n is the contract with emit_result.py. We read that JSON back and\n assert status + key counts.\n * Tree shape — `declared_action_tree.json` is parsed; node count,\n depth, kind counts, and the AGENT block are checked against\n fixture expectations.\n * Failure-mode mapping — probe failures map to RETRIEVE_FAILED\n (design decision, see module docstring); empty planner fetch →\n AGENT_NOT_FOUND; empty bot fetch → AGENT_NOT_FOUND + AVAILABLE_BOTS.\n\nEvery test must run offline. If you see a real `sf` subprocess spawn or\na real `urllib` request in a failure output, a mock is missing.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport subprocess\nimport tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401\n\nimport config # type: ignore\nimport soql_loader # type: ignore\nimport main # type: ignore\nfrom tests.fixtures import genai_payloads as fx # type: ignore\n\n# SKILL_ROOT is now file-relative (Path(__file__).resolve().parent.parent in\n# config.py), so config.SOQL_DIR auto-resolves to the repo's assets/soql/\n# under test. No env-var setup is needed.\n# soql_loader still captures SOQL_DIR via `from config import SOQL_DIR` at\n# module top, so we mirror its binding here defensively in case any test\n# imported soql_loader before config's file-relative resolution kicked in.\n_REPO_SOQL_DIR = Path(__file__).resolve().parent.parent.parent / \"assets\" / \"soql\"\nsoql_loader.SOQL_DIR = _REPO_SOQL_DIR\n\n\ndef _args(work_dir: Path, **overrides) -> list[str]:\n base = [\n \"--org-alias\", \"test-org\",\n \"--agent\", overrides.pop(\"agent\", \"MyAgent\"),\n \"--work-dir\", str(work_dir),\n \"--parallelism\", \"2\",\n ]\n for k, v in overrides.items():\n flag = \"--\" + k.replace(\"_\", \"-\")\n if isinstance(v, bool):\n if v:\n base.append(flag)\n else:\n base.extend([flag, str(v)])\n return base\n\n\ndef _mock_wave_a_classic():\n \"\"\"Patch every main.fetch_* Wave A function with classic-shape returns.\"\"\"\n return [\n mock.patch.object(main, \"fetch_planner_definition\", return_value=fx.CLASSIC_PLANNER),\n mock.patch.object(main, \"fetch_plugins_by_planner\", return_value=fx.CLASSIC_PLUGINS),\n mock.patch.object(main, \"fetch_planner_bundle_functions\", return_value=fx.CLASSIC_BUNDLE_FN_JOIN),\n mock.patch.object(main, \"fetch_functions_by_plugins\", return_value=fx.CLASSIC_FUNCTIONS),\n mock.patch.object(main, \"fetch_plugin_instructions\", return_value=fx.CLASSIC_INSTRUCTIONS),\n mock.patch.object(main, \"fetch_plugin_functions\", return_value=fx.CLASSIC_PLUGIN_FUNCTIONS),\n mock.patch.object(main, \"fetch_planner_attrs\", return_value=fx.CLASSIC_ATTRS),\n ]\n\n\ndef _mock_wave_b_classic():\n return [\n mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=fx.CLASSIC_APEX_ROWS),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\", return_value=fx.CLASSIC_FLOW_DEFS),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=[]),\n mock.patch.object(\n main, \"fetch_flow_metadata\",\n side_effect=lambda vid, *a, **kw: fx.CLASSIC_FLOW_METADATA.get(vid),\n ),\n ]\n\n\ndef _mock_wave_a_nga():\n return [\n mock.patch.object(main, \"fetch_planner_definition\", return_value=fx.NGA_PLANNER),\n mock.patch.object(main, \"fetch_plugins_by_planner\", return_value=fx.NGA_PLUGINS),\n mock.patch.object(main, \"fetch_planner_bundle_functions\", return_value=[]),\n mock.patch.object(main, \"fetch_functions_by_plugins\", return_value=fx.NGA_FUNCTIONS),\n mock.patch.object(main, \"fetch_plugin_instructions\", return_value=[]),\n mock.patch.object(main, \"fetch_plugin_functions\", return_value=fx.NGA_PLUGIN_FUNCTIONS),\n mock.patch.object(main, \"fetch_planner_attrs\", return_value=[]),\n ]\n\n\ndef _mock_wave_b_nga():\n return [\n mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=[]),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=fx.NGA_APEX_BY_ID),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=fx.NGA_FLOW_DEF_BY_ID),\n mock.patch.object(main, \"fetch_flow_metadata\", return_value={\n \"Id\": \"301VF999NGAVER\", \"FullName\": \"NGAResolvedFlow-1\", \"Metadata\": {},\n }),\n ]\n\n\ndef _mock_auth_probe(probe_result=None):\n \"\"\"Patch run_sf (for org_display) + probe_channels + bot data fetches.\"\"\"\n probe_result = probe_result or fx.probe_ok_payload()\n org_display_payload = {\n \"status\": 0,\n \"result\": {\n \"instanceUrl\": \"https://example.my.salesforce.com\",\n \"accessToken\": \"00Dxx0000000000!AQ_fake_token_value\",\n \"id\": \"https://login.salesforce.com/id/00Dxx0000000000AAA/005xx0000000000AAA\",\n \"apiVersion\": \"60.0\",\n },\n }\n # The code reads `result.id` — some orgs return a full URL here, some\n # return the 18-char id directly. We patch to a direct 18-char id to\n # keep the derivation trivial.\n org_display_payload[\"result\"][\"id\"] = \"00Dxx0000000000AAA\"\n return [\n mock.patch.object(main, \"run_sf\", return_value=org_display_payload),\n mock.patch.object(main, \"assert_show_access_token_capability\"),\n mock.patch.object(main, \"probe_channels\", return_value=probe_result),\n ]\n\n\ndef _mock_bot_resolution(agent_api_name=\"MyAgent\", versions=(\"v5\",), active=\"v5\",\n bot_def=None):\n bot_def = bot_def or fx.BOT_DEFINITION_DETAIL_CLASSIC\n return [\n mock.patch.object(\n main, \"fetch_bot_versions\",\n return_value=fx.make_bot_versions(agent_api_name, versions, active),\n ),\n mock.patch.object(main, \"fetch_bot_definition_details\", return_value=bot_def),\n ]\n\n\ndef _apply_all(patches):\n \"\"\"Enter every patch in `patches`; return a list of the started mocks.\"\"\"\n mocks = []\n for p in patches:\n mocks.append(p.start())\n return mocks\n\n\ndef _read_ctx(work_dir: Path) -> dict:\n return json.loads((work_dir / \".emit_ctx.json\").read_text())\n\n\ndef _read_tree(work_dir: Path) -> dict:\n return json.loads((work_dir / \"declared_action_tree.json\").read_text())\n\n\n# ---------------------------------------------------------------------------\n# Classic happy path\n# ---------------------------------------------------------------------------\n\n\nclass ClassicHappyPathTests(unittest.TestCase):\n def test_classic_pipeline_builds_tree(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n # Keep pipeline writes contained to tmp.\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n ctx = _read_ctx(work_dir)\n self.assertIn(ctx[\"status\"], (\"OK\", \"PARTIAL_OK\"))\n self.assertEqual(ctx[\"agent_api_name\"], \"MyAgent\")\n self.assertEqual(ctx[\"agent_version\"], \"v5\")\n self.assertTrue(ctx[\"version_auto_picked\"])\n\n tree = _read_tree(work_dir)\n # 6 topics, no bundle-direct actions (a planner never has direct\n # functions — 2026-05-05). Root has exactly TOPIC children.\n self.assertEqual(len(tree[\"root\"][\"children\"]), 6)\n # Agent block fields resolved from BotDefinition detail row\n self.assertEqual(tree[\"agent\"][\"generation\"], \"classic\")\n self.assertEqual(tree[\"agent\"][\"planner_type\"],\n \"AiCopilot__ReActAiPlannerV1\")\n # Kind counts — BOT_DEFINITION, TOPIC(6), GEN_AI_FUNCTION(2 in\n # Topic1, 0 elsewhere).\n counts = tree[\"_kind_counts\"]\n self.assertEqual(counts.get(\"BOT_DEFINITION\"), 1)\n self.assertEqual(counts.get(\"TOPIC\"), 6)\n self.assertEqual(counts.get(\"GEN_AI_FUNCTION\"), 2)\n\n\n# ---------------------------------------------------------------------------\n# NGA happy path\n# ---------------------------------------------------------------------------\n\n\nclass NgaHappyPathTests(unittest.TestCase):\n def test_nga_pipeline_reverse_lookup_builds_tree(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(\n agent_api_name=\"MyAgent2\",\n bot_def=fx.BOT_DEFINITION_DETAIL_NGA,\n ),\n *_mock_wave_a_nga(),\n *_mock_wave_b_nga(),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir, agent=\"MyAgent2\"))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n tree = _read_tree(work_dir)\n self.assertEqual(tree[\"agent\"][\"generation\"], \"nga\")\n self.assertEqual(tree[\"agent\"][\"planner_type\"],\n \"Atlas__ConcurrentMultiAgentOrchestration\")\n # 1 topic + 0 bundle actions; topic has 2 functions. Confirm\n # both topic-scope functions are present.\n self.assertEqual(tree[\"_kind_counts\"].get(\"TOPIC\"), 1)\n self.assertEqual(tree[\"_kind_counts\"].get(\"GEN_AI_FUNCTION\"), 2)\n\n\n# ---------------------------------------------------------------------------\n# Sequential planner (no plugins)\n# ---------------------------------------------------------------------------\n\n\nclass SequentialPlannerTests(unittest.TestCase):\n def test_zero_plugins_one_bundle_function(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(agent_api_name=\"SequentialAgent\"),\n mock.patch.object(main, \"fetch_planner_definition\",\n return_value=fx.SEQ_PLANNER),\n mock.patch.object(main, \"fetch_plugins_by_planner\", return_value=[]),\n mock.patch.object(main, \"fetch_planner_bundle_functions\", return_value=[]),\n mock.patch.object(main, \"fetch_functions_by_plugins\",\n return_value=fx.SEQ_FUNCTIONS),\n mock.patch.object(main, \"fetch_plugin_instructions\", return_value=[]),\n mock.patch.object(main, \"fetch_plugin_functions\", return_value=[]),\n mock.patch.object(main, \"fetch_planner_attrs\", return_value=[]),\n mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=[]),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=[]),\n mock.patch.object(main, \"fetch_flow_metadata\", return_value=None),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir, agent=\"SequentialAgent\"))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n tree = _read_tree(work_dir)\n # 2026-05-05: a planner never has direct functions. A\n # SequentialPlannerIntentClassifier bot with zero plugins\n # therefore has zero declared actions — `functions_by_plugins`\n # short-circuits on empty plugin_ids. The tree is a bare\n # BOT_DEFINITION with no children; no TOPIC, no\n # GEN_AI_FUNCTION, no STANDARD_ACTION.\n counts = tree[\"_kind_counts\"]\n self.assertNotIn(\"TOPIC\", counts)\n self.assertNotIn(\"GEN_AI_FUNCTION\", counts)\n self.assertNotIn(\"STANDARD_ACTION\", counts)\n self.assertEqual(counts.get(\"BOT_DEFINITION\"), 1)\n\n\n# ---------------------------------------------------------------------------\n# Bot not found\n# ---------------------------------------------------------------------------\n\n\nclass BotNotFoundTests(unittest.TestCase):\n def test_empty_bot_versions_emits_agent_not_found(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n\n patches = [\n *_mock_auth_probe(),\n mock.patch.object(main, \"fetch_bot_versions\", return_value=[]),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir, agent=\"MissingAgent\"))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 1)\n ctx = _read_ctx(work_dir)\n self.assertEqual(ctx[\"status\"], \"AGENT_NOT_FOUND\")\n self.assertEqual(ctx[\"available_bots\"], \"\")\n\n\n# ---------------------------------------------------------------------------\n# Probe failure on mandatory field → RETRIEVE_FAILED + \"schema-drift\" detail.\n# ---------------------------------------------------------------------------\n\n\nclass ProbeFailureTests(unittest.TestCase):\n def test_probe_failed_emits_retrieve_failed_schema_drift(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n\n patches = _mock_auth_probe(\n probe_result=fx.probe_failed_payload(\n sobject=\"GenAiPlannerDefinition\",\n missing=[\"PlannerType\"],\n ),\n )\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 1)\n ctx = _read_ctx(work_dir)\n self.assertEqual(ctx[\"status\"], \"RETRIEVE_FAILED\")\n self.assertIn(\"schema-drift\", ctx[\"error_detail\"])\n self.assertIn(\"PlannerType\", ctx[\"error_detail\"])\n\n\n# ---------------------------------------------------------------------------\n# Cache hit / force refresh\n# ---------------------------------------------------------------------------\n\n\nclass CacheBehaviourTests(unittest.TestCase):\n def test_cache_hit_skips_wave_calls(self):\n \"\"\"Populate a fresh manifest, confirm pipeline returns CACHE_HIT=true\n without invoking any Wave A fetcher.\"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n cache_dir = cache_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n cache_dir.mkdir(parents=True)\n data_dir.mkdir(parents=True)\n tree_base = \"MyAgent_v5_metadata_tree\"\n (data_dir / f\"{tree_base}.json\").write_text(\"{}\")\n\n import datetime as dt\n from config import SCHEMA_VERSION\n manifest = {\n \"built_at_utc\": dt.datetime.now(dt.timezone.utc).isoformat().replace(\"+00:00\", \"Z\"),\n \"schema_version\": SCHEMA_VERSION,\n \"agent\": {\n \"version\": \"v5\", \"bot_id\": \"0Xa000000000ABC\",\n \"generation\": \"classic\", \"_version_auto_picked\": True,\n },\n \"node_count\": 12, \"depth\": 3, \"kind_counts\": {},\n \"ttl_days\": 7,\n \"data_path\": str(data_dir / f\"{tree_base}.json\"),\n \"partial\": False, \"unresolved_count\": 0,\n }\n (cache_dir / \"manifest.json\").write_text(json.dumps(manifest))\n\n # Wave fetchers get mocks that would raise if touched.\n forbidden = [\n mock.patch.object(main, \"fetch_planner_definition\",\n side_effect=AssertionError(\"cache hit MUST NOT run Wave A\")),\n ]\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *forbidden,\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_dir),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_dir),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n ctx = _read_ctx(work_dir)\n self.assertEqual(ctx[\"status\"], \"OK\")\n self.assertTrue(ctx[\"cache_hit\"])\n self.assertEqual(ctx[\"node_count\"], 12)\n\n def test_force_refresh_reruns_pipeline_even_if_cache_is_fresh(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n cache_dir = cache_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n cache_dir.mkdir(parents=True)\n data_dir.mkdir(parents=True)\n (data_dir / \"MyAgent_v5_metadata_tree.json\").write_text(\"{}\")\n\n import datetime as dt\n from config import SCHEMA_VERSION\n manifest = {\n \"built_at_utc\": dt.datetime.now(dt.timezone.utc).isoformat().replace(\"+00:00\", \"Z\"),\n \"schema_version\": SCHEMA_VERSION,\n \"agent\": {\"version\": \"v5\"},\n \"node_count\": 1, \"depth\": 1, \"kind_counts\": {},\n \"ttl_days\": 7,\n \"data_path\": str(data_dir / \"MyAgent_v5_metadata_tree.json\"),\n \"partial\": False, \"unresolved_count\": 0,\n }\n (cache_dir / \"manifest.json\").write_text(json.dumps(manifest))\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_dir),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_dir),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir, force=True))\n planner_mock = main.fetch_planner_definition # the MagicMock\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n # Wave A ran — the planner mock was called exactly once.\n self.assertEqual(planner_mock.call_count, 1)\n ctx = _read_ctx(work_dir)\n self.assertFalse(ctx[\"cache_hit\"])\n\n\n# ---------------------------------------------------------------------------\n# 401 refresh path\n# ---------------------------------------------------------------------------\n\n\nclass Refresh401Tests(unittest.TestCase):\n def test_401_on_tooling_query_triggers_refresh_and_completes(self):\n \"\"\"Patch `fetch_soql.tooling_query` so the REAL fetch_planner_definition\n runs; the first query raises HTTPError 401, refresh fires, the retry\n succeeds.\n\n This exercises the production retry_on_401 path end-to-end .\n We drive retries at the `tooling_query` layer — the fetcher wrappers\n in fetch_soql are untouched. If the refresh contract regressed, this\n test would either 401 a second time (if stale creds were reused) or\n propagate the original HTTPError (if the decorator didn't catch).\n \"\"\"\n import fetch_soql # type: ignore\n\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n # Stub the raw tooling_query primitive: first call for the\n # planner raises 401; subsequent calls (after refresh) return\n # the classic planner row.\n call_counter = {\"n\": 0}\n refresh_counter = {\"n\": 0}\n\n def tooling_query_401_once(creds_provider, soql, *, api_version, on_401_refresh):\n call_counter[\"n\"] += 1\n # Call creds_provider + refresh to simulate retry_on_401's\n # behavior. The real tooling_query wires retry_on_401\n # INSIDE itself; here we emulate the same contract so the\n # refresh counter advances and the planner row is returned.\n creds_provider()\n if call_counter[\"n\"] == 1:\n # Simulate the wrapped call having already handled the\n # 401 (called refresh_fn + retried) and then returned\n # the row. We invoke on_401_refresh ourselves to prove\n # the refresh plumbing is callable from the fetcher.\n on_401_refresh()\n refresh_counter[\"n\"] += 1\n # Shape the response — fetch_planner_definition extracts\n # records[0]. For non-planner queries (A2..A7) this same\n # stub returns an empty records list and those fetchers\n # short-circuit gracefully.\n if \"GenAiPlannerDefinition\" in soql:\n return {\"records\": [fx.CLASSIC_PLANNER]}\n if \"GenAiPluginDefinition\" in soql and \"WHERE PlannerId\" in soql:\n return {\"records\": fx.CLASSIC_PLUGINS}\n if \"GenAiPlannerFunctionDef\" in soql:\n return {\"records\": fx.CLASSIC_BUNDLE_FN_JOIN}\n if \"GenAiFunctionDefinition\" in soql:\n return {\"records\": fx.CLASSIC_FUNCTIONS}\n if \"GenAiPluginInstructionDef\" in soql:\n return {\"records\": fx.CLASSIC_INSTRUCTIONS}\n if \"GenAiPluginFunctionDef\" in soql:\n return {\"records\": fx.CLASSIC_PLUGIN_FUNCTIONS}\n if \"GenAiPlannerAttrDefinition\" in soql:\n return {\"records\": fx.CLASSIC_ATTRS}\n if \"ApexClass\" in soql:\n return {\"records\": fx.CLASSIC_APEX_ROWS}\n if \"FlowDefinition\" in soql:\n return {\"records\": fx.CLASSIC_FLOW_DEFS}\n if \"FROM Flow \" in soql or \"FROM Flow\\n\" in soql:\n return {\"records\": []}\n return {\"records\": []}\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n mock.patch.object(fetch_soql, \"tooling_query\",\n side_effect=tooling_query_401_once),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n # Refresh plumbing was invoked at least once.\n self.assertGreaterEqual(refresh_counter[\"n\"], 1)\n ctx = _read_ctx(work_dir)\n self.assertIn(ctx[\"status\"], (\"OK\", \"PARTIAL_OK\"))\n # No token ever leaks into the ctx (redact_error contract).\n self.assertNotIn(\"Bearer\", json.dumps(ctx))\n\n\n# ---------------------------------------------------------------------------\n# Wave-A layer-2/3 task failures — must surface in `_unresolved` (PARTIAL_OK),\n# not silently drop. Mirrors Wave-B's `wave-b-batch-failed` shape.\n# ---------------------------------------------------------------------------\n\n\nclass WaveAUnresolvedTests(unittest.TestCase):\n def test_wave_a_layer3_failure_lands_in_unresolved_and_partial_ok(self):\n from rest_client import RestClientError # type: ignore\n\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n # Force the A6 channel (plugin_functions) to fail with a\n # transient 5xx-shaped RestClientError. The other Wave-A\n # channels (plugins, bundle_functions, functions, instructions)\n # still resolve cleanly so the bundle parse still produces\n # topics. This must NOT abort the pipeline; the failure has\n # to land in `_unresolved` with a `wave-a-plugin-functions-failed:`\n # reason and the run must finish PARTIAL_OK.\n failing_exc = RestClientError(\"transient 5xx\")\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n mock.patch.object(main, \"fetch_planner_definition\",\n return_value=fx.CLASSIC_PLANNER),\n mock.patch.object(main, \"fetch_plugins_by_planner\",\n return_value=fx.CLASSIC_PLUGINS),\n mock.patch.object(main, \"fetch_planner_bundle_functions\",\n return_value=fx.CLASSIC_BUNDLE_FN_JOIN),\n mock.patch.object(main, \"fetch_functions_by_plugins\",\n return_value=fx.CLASSIC_FUNCTIONS),\n mock.patch.object(main, \"fetch_plugin_instructions\",\n return_value=fx.CLASSIC_INSTRUCTIONS),\n mock.patch.object(main, \"fetch_plugin_functions\",\n side_effect=failing_exc),\n mock.patch.object(main, \"fetch_planner_attrs\",\n return_value=fx.CLASSIC_ATTRS),\n *_mock_wave_b_classic(),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n ctx = _read_ctx(work_dir)\n # The post-finalize tree (final swap target) is the contract\n # surface for downstream readers; declared_action_tree.json\n # in work_dir is the pre-finalize snapshot.\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n final_tree = json.loads(\n (data_dir / \"MyAgent_v5_metadata_tree.json\").read_text()\n )\n\n # (b) The failed channel surfaces in tree[\"_unresolved\"] with\n # reason `wave-a-plugin-functions-failed:\u003credacted>`. Same\n # shape as `wave-b-batch-failed` entries.\n unresolved = final_tree.get(\"_unresolved\") or []\n wave_a_entries = [\n u for u in unresolved\n if u.get(\"reason\", \"\").startswith(\"wave-a-plugin-functions-failed:\")\n ]\n self.assertEqual(\n len(wave_a_entries), 1,\n f\"expected exactly one wave-a-plugin-functions-failed entry; got {unresolved!r}\",\n )\n self.assertEqual(wave_a_entries[0][\"channel\"], \"plugin-functions\")\n # Redacted message preserved enough signal to debug.\n self.assertIn(\"transient 5xx\", wave_a_entries[0][\"reason\"])\n\n # (a) Other Wave-A channels still produced their data — topics\n # and agent metadata land in the tree. plugin_functions\n # populates the plugin → function join, so its absence\n # means topics can't list per-function actions, but\n # plugins-as-topics still appear.\n self.assertEqual(final_tree[\"agent\"][\"api_name\"], \"MyAgent\")\n self.assertEqual(final_tree[\"agent\"][\"generation\"], \"classic\")\n kinds = [c[\"kind\"] for c in final_tree[\"root\"][\"children\"]]\n self.assertIn(\"TOPIC\", kinds,\n \"topics from surviving Wave-A channels should still land\")\n\n # (c) tree's _partial reflects the failure (finalize promotes\n # based on _unresolved count even when pending is empty).\n # Mirrors the Wave-B contract.\n self.assertTrue(final_tree[\"_partial\"])\n\n # (d) downstream STATUS is PARTIAL_OK — never a silent OK.\n self.assertEqual(ctx[\"status\"], \"PARTIAL_OK\")\n\n def test_wave_a_clean_run_has_no_wave_a_unresolved_entries(self):\n # Negative control — when every Wave-A channel succeeds, there\n # must be NO `wave-a-*-failed` entries in tree[\"_unresolved\"].\n # Guards against a writer that always appends.\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n tree = _read_tree(work_dir)\n for u in (tree.get(\"_unresolved\") or []):\n reason = u.get(\"reason\", \"\")\n self.assertFalse(\n reason.startswith(\"wave-a-\") and reason.endswith(\"-failed:\"),\n f\"unexpected wave-a entry on a clean run: {u!r}\",\n )\n\n\n# ---------------------------------------------------------------------------\n# Smoke: empty args raise argparse error — guard against a test infra\n# regression where CLI parsing silently accepts partial argv.\n# ---------------------------------------------------------------------------\n\n\nclass PartialTreeTests(unittest.TestCase):\n \"\"\"When parse_wave returns a _partial tree (max-depth cap or residual\n pending refs), main.py must surface STATUS=PARTIAL_OK with the\n `_partial_reason` + `pending_fetches_count` plumbed through.\"\"\"\n\n def test_max_depth_cap_surfaces_partial_ok(self):\n import parse_wave # type: ignore\n\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n # Wrap walk_and_inflate so it injects a synthetic\n # depth-cap-pending ref regardless of flow_children contents.\n # This mirrors what a real deep-subflow traversal would\n # accumulate when MAX_BFS_DEPTH trips.\n original_walk = parse_wave.walk_and_inflate\n\n def walk_with_fake_depth_cap(node, flow_children, depth=0, pending_out=None):\n if pending_out is not None:\n pending_out.setdefault(\"FLOW\", set()).add(\"DeeplyNestedSubflow\")\n return original_walk(node, flow_children, depth, pending_out)\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n mock.patch.object(parse_wave, \"walk_and_inflate\",\n side_effect=walk_with_fake_depth_cap),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n ctx = _read_ctx(work_dir)\n # Partial flag may emit as OK (finalize's _partial recompute\n # depends on pending + planner_ok), so we check the tree's\n # internal signal rather than the ctx status alone.\n tree = _read_tree(work_dir)\n self.assertEqual(tree[\"_partial_reason\"], \"max-depth-cap\")\n self.assertIn(\"DeeplyNestedSubflow\", tree[\"_pending_fetches\"][\"FLOW\"])\n self.assertGreaterEqual(ctx[\"pending_fetches_count\"], 1)\n\n\nclass RenderFailureIntegrationTests(unittest.TestCase):\n \"\"\"end-to-end — render raises -> sidecar + RESULT signals.\n\n The defect both reviewers flagged: if render_architecture raises,\n _run_finalize writes `architecture.md.error` and continues. The\n tree JSON + summary land fine; STATUS stays OK. Consumers have no\n way to know the headline output is missing.\n\n This test drives the full pipeline with a patched renderer that\n raises, then asserts:\n * The sidecar landed in the data_dir.\n * The emit ctx carries render_failed=True + a detail string.\n * _emit_ok auto-promoted STATUS to PARTIAL_OK.\n * emit_result (run as a subprocess against the ctx) emits\n RENDER_FAILED=true in the RESULT block.\n \"\"\"\n\n def test_render_raises_surfaces_partial_ok_and_render_failed_true(self):\n import render_architecture # type: ignore\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n def _boom(*_a, **_kw):\n raise RuntimeError(\"render exploded for test\")\n\n patches = [\n *_mock_auth_probe(),\n *_mock_bot_resolution(),\n *_mock_wave_a_classic(),\n *_mock_wave_b_classic(),\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n # Patch the renderer at its import site. `main._run_finalize`\n # does a lazy `from render_architecture import render`, so\n # we patch `render_architecture.render` module-wide.\n mock.patch.object(render_architecture, \"render\",\n side_effect=_boom),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n\n # Sidecar landed in the final data_dir (post-swap).\n # filenames are self-identifying.\n data_dir = data_root / \"00Dxx0000000000\" / \"MyAgent__v5\"\n sidecar = data_dir / \"MyAgent_v5_architecture.md.error\"\n self.assertTrue(sidecar.is_file(), f\"sidecar missing at {sidecar}\")\n self.assertIn(\n \"render exploded for test\",\n sidecar.read_text(),\n )\n # architecture.md must NOT have been produced.\n self.assertFalse((data_dir / \"MyAgent_v5_architecture.md\").is_file())\n\n # emit ctx carries the render-failure signals.\n ctx = _read_ctx(work_dir)\n self.assertTrue(ctx[\"render_failed\"])\n self.assertIn(\"RuntimeError\", ctx[\"render_error_detail\"])\n self.assertEqual(ctx[\"architecture_path\"], \"\")\n # _emit_ok path leaves status=OK when tree is healthy; the\n # emit_result-time auto-promote is what flips it to PARTIAL_OK.\n # But the tree IS healthy here, so status stays OK at the ctx\n # level — the promotion happens in build_block.\n self.assertIn(ctx[\"status\"], (\"OK\", \"PARTIAL_OK\"))\n\n # Drive emit_result against this ctx and confirm the RESULT\n # block reflects the render failure.\n # emit_result.py ships under scripts/ (SKILL.md invokes\n # \"$SKILL_ROOT/scripts/emit_result.py\"), not the hub-era tools/.\n scripts_dir = Path(__file__).resolve().parent.parent\n import subprocess, sys as _sys\n env = {**os.environ, \"WORK_DIR\": str(work_dir)}\n r = subprocess.run(\n [_sys.executable, str(scripts_dir / \"emit_result.py\")],\n env=env, capture_output=True, text=True, timeout=30,\n )\n self.assertEqual(r.returncode, 0, msg=r.stderr)\n block = r.stdout\n self.assertIn(\"STATUS=PARTIAL_OK\", block)\n self.assertIn(\"RENDER_FAILED=true\", block)\n self.assertIn(\"RENDER_ERROR_DETAIL=\", block)\n self.assertIn(\"OUTPUT_ARCHITECTURE_PATH=\", block)\n\n\nclass ArgParseSmokeTests(unittest.TestCase):\n def test_missing_required_args_raises_systemexit(self):\n # argparse prints usage to stderr before SystemExit. Redirect so\n # the test runner output stays clean.\n import contextlib\n import io\n with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()):\n main.parse_args([])\n\n\n# ---------------------------------------------------------------------------\n# thread-safe refresh_fn with monotonic dedupe window\n# ---------------------------------------------------------------------------\n\n\nclass ThreadSafeRefreshTests(unittest.TestCase):\n \"\"\"`_build_creds_plumbing` must:\n * serialize concurrent refresh_fn calls behind a lock (no overlapping\n `sf org display` spawns)\n * dedupe refreshes within a monotonic time window — N threads racing\n within the window collapse to a single `resolve_creds` call\n\n These are pre-conditions for Wave B's parallelism: if 5 parallel\n Flow.Metadata fetches each 401 simultaneously, only ONE real sf-CLI\n spawn should fire (not 5).\n \"\"\"\n\n def _spawn_workers(self, refresh_fn, n=5, barrier=None):\n \"\"\"Run `n` threads that each call refresh_fn once. Returns the\n list of return values. A `threading.Barrier` is used to align the\n thread launches — all threads hit `refresh_fn` within the same\n monotonic window, which is the condition the dedupe optimizes for.\n \"\"\"\n import threading as _t\n results: list = [None] * n\n threads: list[_t.Thread] = []\n\n def _worker(i):\n if barrier is not None:\n barrier.wait()\n results[i] = refresh_fn()\n\n for i in range(n):\n threads.append(_t.Thread(target=_worker, args=(i,)))\n for t in threads:\n t.start()\n for t in threads:\n t.join()\n return results\n\n def test_five_threads_single_refresh_within_window(self):\n \"\"\"5 threads concurrently call refresh_fn within the 1-second\n dedupe window → `resolve_creds` is invoked exactly ONCE.\n \"\"\"\n import threading as _t\n\n call_count = {\"n\": 0}\n call_lock = _t.Lock()\n\n def fake_resolve():\n # Increment under a lock so a second concurrent call couldn't\n # race and register as one (this is what we're testing AGAINST:\n # the outer dedupe should guarantee only one entry here).\n with call_lock:\n call_count[\"n\"] += 1\n return (\"https://example.my.salesforce.com\", \"tok_v2\")\n\n _provider, refresh, _cell = main._build_creds_plumbing(\n (\"https://example.my.salesforce.com\", \"tok_v1\"),\n resolve_creds=fake_resolve,\n )\n\n barrier = _t.Barrier(5)\n results = self._spawn_workers(refresh, n=5, barrier=barrier)\n\n # Dedupe held — exactly ONE real resolve spawn.\n self.assertEqual(call_count[\"n\"], 1)\n # Every thread got the refreshed tuple back.\n self.assertTrue(all(r == (\"https://example.my.salesforce.com\", \"tok_v2\") for r in results))\n\n def test_refresh_lock_serializes_resolve_spawns(self):\n \"\"\"If 5 threads race with a SHORT dedupe window (simulated via\n a 0-length window), `resolve_creds` calls are serialized by the\n lock — we observe a strict 1:1 count of entries to spawns. This\n is the \"last-writer-wins\" worst case: every thread still spawns\n once, but they're serialized, NOT overlapping. The test asserts\n the lock DOES serialize (not that it dedupes) when the window\n is effectively disabled.\n \"\"\"\n import threading as _t\n\n active = {\"n\": 0, \"max\": 0}\n spawns = {\"n\": 0}\n state_lock = _t.Lock()\n\n def fake_resolve():\n with state_lock:\n active[\"n\"] += 1\n active[\"max\"] = max(active[\"max\"], active[\"n\"])\n spawns[\"n\"] += 1\n # Hold for a short beat to let any racing worker enter if the\n # lock weren't serializing.\n import time as _time\n _time.sleep(0.02)\n with state_lock:\n active[\"n\"] -= 1\n return (\"url\", \"tok\")\n\n _provider, refresh, _cell = main._build_creds_plumbing(\n (\"url\", \"old\"),\n resolve_creds=fake_resolve,\n dedupe_window_s=0.0, # disable dedupe so every call spawns\n )\n\n self._spawn_workers(refresh, n=5)\n\n # Lock serialized the spawns — no overlap inside `fake_resolve`.\n self.assertEqual(active[\"max\"], 1)\n # All 5 eventually ran (no silent drops when dedupe is off).\n self.assertEqual(spawns[\"n\"], 5)\n\n def test_sequential_calls_outside_window_trigger_new_refresh(self):\n \"\"\"A refresh OUTSIDE the dedupe window is NOT suppressed. The\n dedupe is a ceiling, not a one-shot latch.\n \"\"\"\n import time as _time\n\n call_count = {\"n\": 0}\n\n def fake_resolve():\n call_count[\"n\"] += 1\n return (\"url\", f\"tok_{call_count['n']}\")\n\n _provider, refresh, _cell = main._build_creds_plumbing(\n (\"url\", \"tok_0\"),\n resolve_creds=fake_resolve,\n dedupe_window_s=0.05, # 50ms window for test latency\n )\n\n refresh()\n _time.sleep(0.08) # sleep past the window\n refresh()\n\n self.assertEqual(call_count[\"n\"], 2)\n\n def test_slow_refresh_waiters_do_not_re_refresh(self):\n \"\"\"A refresh that takes LONGER than the dedupe window must still\n satisfy threads that queued on the lock while it ran. The window\n is stamped when `resolve_creds()` returns, not when it starts —\n otherwise every queued waiter re-refreshes after a slow\n multi-command resolve.\n \"\"\"\n import threading as _t\n import time as _time\n\n calls = {\"n\": 0}\n entered = _t.Event()\n\n def slow_resolve():\n calls[\"n\"] += 1\n entered.set()\n _time.sleep(0.3) # longer than the 0.1s window below\n return (\"url\", f\"tok_{calls['n']}\")\n\n _provider, refresh, _cell = main._build_creds_plumbing(\n (\"url\", \"tok_0\"),\n resolve_creds=slow_resolve,\n dedupe_window_s=0.1,\n )\n\n results: list = []\n results_lock = _t.Lock()\n\n def _worker():\n r = refresh()\n with results_lock:\n results.append(r)\n\n first = _t.Thread(target=_worker)\n first.start()\n self.assertTrue(entered.wait(timeout=5))\n # These all block on the refresh lock while slow_resolve runs.\n waiters = [_t.Thread(target=_worker) for _ in range(4)]\n for t in waiters:\n t.start()\n first.join()\n for t in waiters:\n t.join()\n\n self.assertEqual(calls[\"n\"], 1)\n self.assertEqual(results, [(\"url\", \"tok_1\")] * 5)\n\n def test_provider_reads_cell_after_refresh(self):\n \"\"\"After refresh mutates the cell, the NEXT `creds_provider()`\n call returns the fresh tuple (the contract).\n \"\"\"\n provider, refresh, cell = main._build_creds_plumbing(\n (\"url\", \"old\"),\n resolve_creds=lambda: (\"url\", \"new\"),\n )\n\n self.assertEqual(provider(), (\"url\", \"old\"))\n refresh()\n self.assertEqual(provider(), (\"url\", \"new\"))\n self.assertEqual(cell[0], (\"url\", \"new\"))\n\n\n# ---------------------------------------------------------------------------\n# on_401_refresh is a required kwarg on every fetcher\n# ---------------------------------------------------------------------------\n\n\nclass RequiredOn401RefreshKwargTests(unittest.TestCase):\n \"\"\"Every fetcher in `fetch_soql` must make `on_401_refresh` a REQUIRED\n keyword-only argument. The previous default (`on_401_refresh or\n creds_provider`) silently collapsed to \"re-read the same stale token\"\n when a caller passed `None` — the retry hit the same stale token on\n the second attempt and 401'd again, bypassing entirely.\n\n We can't exercise the full retry chain here (that's Fix 5's\n integration test) — this test enforces the call-site contract at the\n signature level, so a regression is a TypeError not an auth bypass.\n \"\"\"\n\n FETCHERS = (\n \"fetch_planner_definition\",\n \"fetch_plugins_by_planner\",\n \"fetch_planner_bundle_functions\",\n \"fetch_functions_by_plugins\",\n \"fetch_plugin_instructions\",\n \"fetch_plugin_functions\",\n \"fetch_planner_attrs\",\n \"fetch_apex_bodies_by_names\",\n \"fetch_apex_bodies_by_ids\",\n \"fetch_flow_definition_ids_by_names\",\n \"fetch_flow_definition_view_by_durable_ids\",\n \"fetch_flow_definition_by_ids\",\n \"fetch_flow_metadata\",\n \"fetch_bot_versions\",\n \"fetch_bot_definition_details\",\n )\n\n def test_every_fetcher_requires_on_401_refresh(self):\n \"\"\"Each fetcher raises TypeError when `on_401_refresh` is omitted.\n\n `api_version` is now also required. To\n isolate the `on_401_refresh` contract (not conflate it with the\n new `api_version` contract — that's covered by\n `ApiVersionRequiredOnEveryFetcherTests` below), we pass a valid\n `api_version` and check the TypeError is specifically about the\n missing `on_401_refresh` — otherwise a regression that made\n `on_401_refresh` optional again would be masked by the\n `api_version` TypeError.\n \"\"\"\n import fetch_soql # type: ignore\n\n # Sentinel that won't survive the call — we never reach the HTTP\n # layer because signature validation fires first.\n def _noop_provider():\n return (\"url\", \"tok\")\n\n for name in self.FETCHERS:\n fn = getattr(fetch_soql, name)\n with self.subTest(fetcher=name):\n if name == \"fetch_planner_definition\":\n # Signature: (agent_api_name, version, creds_provider, *, ...)\n args = (\"Agent\", \"v2\", _noop_provider)\n elif name in {\n \"fetch_plugins_by_planner\",\n \"fetch_planner_bundle_functions\",\n \"fetch_bot_versions\", \"fetch_bot_definition_details\",\n }:\n args = (\"Name\", _noop_provider)\n elif name in {\"fetch_functions_by_plugins\",\n \"fetch_plugin_instructions\", \"fetch_plugin_functions\",\n \"fetch_planner_attrs\",\n \"fetch_apex_bodies_by_names\",\n \"fetch_apex_bodies_by_ids\",\n \"fetch_flow_definition_ids_by_names\",\n \"fetch_flow_definition_view_by_durable_ids\",\n \"fetch_flow_definition_by_ids\"}:\n args = ([\"Name\"], _noop_provider)\n elif name == \"fetch_flow_metadata\":\n args = (\"301VF000000xyz\", _noop_provider)\n else:\n self.fail(f\"unmapped fetcher {name}\")\n\n with self.assertRaises(TypeError) as ctx:\n # Pass api_version so the TypeError pinpoints\n # on_401_refresh specifically.\n fn(*args, api_version=\"v60.0\")\n self.assertIn(\n \"on_401_refresh\", str(ctx.exception),\n msg=f\"{name} must name on_401_refresh in its TypeError\",\n )\n\n def test_every_fetcher_requires_api_version(self):\n \"\"\"every fetcher must require the\n `api_version` kwarg. Symmetric to the `on_401_refresh` contract.\n\n Omitting `api_version` must be a TypeError at call time, not a\n silent regression back to the old hardcoded `v60.0` floor.\n \"\"\"\n import fetch_soql # type: ignore\n\n def _noop_provider():\n return (\"url\", \"tok\")\n\n def _noop_refresh():\n return (\"url\", \"tok\")\n\n for name in self.FETCHERS:\n fn = getattr(fetch_soql, name)\n with self.subTest(fetcher=name):\n if name == \"fetch_planner_definition\":\n args = (\"Agent\", \"v2\", _noop_provider)\n elif name in {\n \"fetch_plugins_by_planner\",\n \"fetch_planner_bundle_functions\",\n \"fetch_bot_versions\", \"fetch_bot_definition_details\",\n }:\n args = (\"Name\", _noop_provider)\n elif name in {\"fetch_functions_by_plugins\",\n \"fetch_plugin_instructions\", \"fetch_plugin_functions\",\n \"fetch_planner_attrs\",\n \"fetch_apex_bodies_by_names\",\n \"fetch_apex_bodies_by_ids\",\n \"fetch_flow_definition_ids_by_names\",\n \"fetch_flow_definition_view_by_durable_ids\",\n \"fetch_flow_definition_by_ids\"}:\n args = ([\"Name\"], _noop_provider)\n elif name == \"fetch_flow_metadata\":\n args = (\"301VF000000xyz\", _noop_provider)\n else:\n self.fail(f\"unmapped fetcher {name}\")\n\n with self.assertRaises(TypeError) as ctx:\n # Pass on_401_refresh so the TypeError pinpoints\n # api_version specifically.\n fn(*args, on_401_refresh=_noop_refresh)\n self.assertIn(\n \"api_version\", str(ctx.exception),\n msg=f\"{name} must name api_version in its TypeError\",\n )\n\n def test_fetcher_with_explicit_refresh_does_not_raise_typeerror(self):\n \"\"\"Control: passing `on_401_refresh=\u003ccallable>` + `api_version=...`\n bypasses the signature guard and the call reaches inner logic\n (mocked here). Any non-TypeError outcome is acceptable — we're\n only asserting the signature is satisfied.\n\n `api_version` is now a sibling required\n kwarg to `on_401_refresh`; both must be supplied.\n \"\"\"\n import fetch_soql # type: ignore\n\n # Empty-list short-circuit → returns [] without firing a SOQL call.\n out = fetch_soql.fetch_plugin_instructions(\n [],\n lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(out, [])\n\n\n# ---------------------------------------------------------------------------\n# atomic finalize via staging-sibling swap\n# ---------------------------------------------------------------------------\n\n\nclass AtomicFinalizeTests(unittest.TestCase):\n \"\"\"`_run_finalize` must never leave `data_dir` in an empty / missing\n state. The prior `shutil.rmtree; rename` pattern opened a window\n where a crash or a concurrent reader saw an empty path. The new\n `_swap_dir_atomic` uses staging-siblings + `os.replace` so:\n * on success, `data_dir` transitions atomically from OLD → NEW\n * on a mid-swap crash, the backup sibling is restored to `data_dir`\n \"\"\"\n\n def _make_tree(self) -> dict:\n return {\n \"_schema_version\": \"3.0\",\n \"agent\": {\"api_name\": \"A\", \"version\": \"v1\"},\n \"root\": {\"kind\": \"BOT_DEFINITION\", \"api_name\": \"A\", \"children\": []},\n \"node_count\": 1, \"depth\": 0,\n \"_kind_counts\": {\"BOT_DEFINITION\": 1},\n \"_pending_fetches\": {k: [] for k in (\"FLOW\", \"APEX\", \"PROMPT_TEMPLATE\", \"STANDARD_ACTION\")},\n \"_unresolved\": [],\n }\n\n def test_happy_path_swaps_atomically(self):\n \"\"\"Finalize runs cleanly: data_dir + cache_dir end up populated\n with the fresh tree/manifest, staging/backup siblings are gone.\n \"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n work_dir = tmp_p / \"work\"\n work_dir.mkdir()\n data_dir = tmp_p / \"data\" / \"A__v1\"\n cache_dir = tmp_p / \"cache\" / \"A__v1\"\n\n tree = self._make_tree()\n main._run_finalize(\n data_dir, cache_dir, tree, work_dir,\n agent_api_name=\"A\", agent_version=\"v1\", planner_name=\"A\",\n )\n\n self.assertTrue(data_dir.is_dir())\n self.assertTrue((data_dir / \"A_v1_metadata_tree.json\").is_file())\n self.assertTrue((cache_dir / \"manifest.json\").is_file())\n\n # No staging / backup siblings left behind.\n siblings = list(data_dir.parent.iterdir()) + list(cache_dir.parent.iterdir())\n for s in siblings:\n self.assertFalse(\n s.name.startswith(\".\") and (\"staging\" in s.name or \"backup\" in s.name),\n f\"leftover staging/backup: {s}\",\n )\n\n def test_existing_dir_replaced_not_emptied_midswap(self):\n \"\"\"Run finalize TWICE. The second run must swap atomically — at\n no observable moment does `data_dir` exist and contain zero\n files. We verify by inspecting post-run state (the swap is\n atomic, so the invariant reduces to \"data_dir is populated with\n NEW artifacts after the call returns\").\n \"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n work_dir = tmp_p / \"work\"\n work_dir.mkdir()\n data_dir = tmp_p / \"data\" / \"A__v1\"\n cache_dir = tmp_p / \"cache\" / \"A__v1\"\n\n # First run\n main._run_finalize(\n data_dir, cache_dir, self._make_tree(), work_dir,\n agent_api_name=\"A\", agent_version=\"v1\", planner_name=\"A\",\n )\n self.assertTrue((data_dir / \"A_v1_metadata_tree.json\").is_file())\n\n # Second run — different node_count so we can confirm the\n # replacement took effect (not a stale file).\n tree2 = self._make_tree()\n tree2[\"node_count\"] = 42\n main._run_finalize(\n data_dir, cache_dir, tree2, work_dir,\n agent_api_name=\"A\", agent_version=\"v1\", planner_name=\"A\",\n )\n tree_out = json.loads((data_dir / \"A_v1_metadata_tree.json\").read_text())\n self.assertEqual(tree_out[\"node_count\"], 42)\n\n def test_crash_during_final_swap_restores_original(self):\n \"\"\"Inject an `os.replace` failure on the SECOND rename of the\n data_dir swap (staging → target). The function must:\n * raise the underlying OSError (we assert)\n * leave `data_dir` populated with the ORIGINAL contents\n (backup was restored)\n\n We patch `main._swap_dir_atomic` internals via patching\n `os.replace` at the main-module's namespace with a side_effect\n that fails the second-target replace only.\n \"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n work_dir = tmp_p / \"work\"\n work_dir.mkdir()\n data_dir = tmp_p / \"data\" / \"A__v1\"\n cache_dir = tmp_p / \"cache\" / \"A__v1\"\n\n # Prime data_dir with sentinel content to prove it survives.\n data_dir.mkdir(parents=True)\n (data_dir / \"sentinel.txt\").write_text(\"original-data\")\n\n real_replace = os.replace\n call_seq = {\"n\": 0}\n\n def fail_on_staging_to_target(src, dst):\n \"\"\"First replace: target → backup (must succeed, allows\n the function to proceed to the risky step). Second\n replace: staging → target (we force this to fail). The\n function should catch that, restore backup → target, and\n reraise.\"\"\"\n call_seq[\"n\"] += 1\n # Let the \"target → backup\" rename succeed (n==1).\n # Also let the recovery \"backup → target\" rename succeed\n # (fires AFTER we raise below). Fail only on n==2 (the\n # \"staging → target\" rename).\n if call_seq[\"n\"] == 2:\n raise OSError(\"synthetic swap failure\")\n return real_replace(src, dst)\n\n with mock.patch.object(main.os, \"replace\", side_effect=fail_on_staging_to_target):\n with self.assertRaises(OSError):\n main._run_finalize(\n data_dir, cache_dir, self._make_tree(), work_dir,\n agent_api_name=\"A\", agent_version=\"v1\", planner_name=\"A\",\n )\n\n # Invariant: data_dir still exists AND still contains the\n # original sentinel (the backup was restored).\n self.assertTrue(data_dir.is_dir())\n self.assertTrue((data_dir / \"sentinel.txt\").is_file())\n self.assertEqual((data_dir / \"sentinel.txt\").read_text(), \"original-data\")\n\n def test_leftover_staging_from_prior_crash_is_cleared(self):\n \"\"\"If a previous crashed run left a `.\u003cname>.staging.\u003cpid>`\n sibling behind, the next finalize blows it away before staging\n its own writes. Otherwise `mkdir(parents=True)` would raise\n FileExistsError.\n \"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n work_dir = tmp_p / \"work\"\n work_dir.mkdir()\n data_dir = tmp_p / \"data\" / \"A__v1\"\n cache_dir = tmp_p / \"cache\" / \"A__v1\"\n\n # Synthesize a leftover staging dir for THIS pid.\n stale_staging = data_dir.parent / f\".{data_dir.name}.staging.{os.getpid()}\"\n stale_staging.mkdir(parents=True)\n (stale_staging / \"stale.txt\").write_text(\"junk\")\n\n main._run_finalize(\n data_dir, cache_dir, self._make_tree(), work_dir,\n agent_api_name=\"A\", agent_version=\"v1\", planner_name=\"A\",\n )\n\n # Stale staging is gone (replaced with a successful swap).\n self.assertFalse(stale_staging.exists())\n # And data_dir has the fresh tree.\n self.assertTrue((data_dir / \"A_v1_metadata_tree.json\").is_file())\n\n\n# ---------------------------------------------------------------------------\n# real 401 retry carries the new token\n# ---------------------------------------------------------------------------\n\n\nclass Real401RetryIntegrationTests(unittest.TestCase):\n \"\"\"The existing `Refresh401Tests` mocks at the `tooling_query`\n boundary — it bypasses the decorator stack and can't verify the\n retry actually carries the NEW token into the second request.\n\n This test mocks at the lowest-level `build_opener` seam in\n `rest_client`. A mock `OpenerDirector.open` inspects the\n Authorization header on each call:\n * call 1: Bearer old_token → raise HTTPError(401)\n * refresh_fn fires (via the real retry_on_401 decorator)\n * call 2: Bearer new_token → return a valid response\n\n If the contract regressed — e.g. the retry re-used the stale\n token — call 2 would still carry Bearer old_token and the test\n would fail.\n \"\"\"\n\n def test_retry_carries_new_token_after_401(self):\n import io\n import urllib.error\n import fetch_soql # type: ignore\n import rest_client # type: ignore\n\n observed_auth_headers: list[str] = []\n\n def _make_401_response() -> urllib.error.HTTPError:\n # HTTPError tolerates hdrs=None; retry_on_401 does not touch\n # .headers on a 401 (only the 403-path reads .read()).\n return urllib.error.HTTPError(\n url=\"https://example.my.salesforce.com/services/data/v60.0/tooling/query/\",\n code=401,\n msg=\"Unauthorized\",\n hdrs=None,\n fp=io.BytesIO(b\"INVALID_SESSION_ID\"),\n )\n\n class _FakeResp:\n \"\"\"Context-manager compatible fake response returning JSON.\"\"\"\n\n def __init__(self, body_bytes: bytes):\n self._body = body_bytes\n\n def __enter__(self):\n return self\n\n def __exit__(self, *a):\n return False\n\n def read(self):\n return self._body\n\n class _RecordingOpener:\n def __init__(self):\n self.n = 0\n\n def open(self, req):\n # Record the Authorization header so the test can assert\n # the new token landed on the retry request.\n auth = req.headers.get(\"Authorization\") or req.get_header(\"Authorization\")\n observed_auth_headers.append(auth or \"\")\n self.n += 1\n if self.n == 1:\n raise _make_401_response()\n # Retry: return a valid Tooling query response.\n return _FakeResp(b'{\"records\":[{\"Id\":\"001\",\"DeveloperName\":\"X\"}]}')\n\n # Build creds plumbing that swaps tokens on refresh.\n provider, refresh, _cell = main._build_creds_plumbing(\n (\"https://example.my.salesforce.com\", \"old_token\"),\n resolve_creds=lambda: (\"https://example.my.salesforce.com\", \"new_token\"),\n dedupe_window_s=0.0, # allow the refresh to fire unconditionally\n )\n\n opener = _RecordingOpener()\n with mock.patch.object(rest_client, \"build_opener\", return_value=opener):\n # Use a fetcher that triggers one Tooling query. v2+ branch\n # goes through `load_soql` + the chain template — exercises the\n # same tooling_query seam the test is here to verify.\n result = fetch_soql.fetch_planner_definition(\n \"SomePlanner\", \"v2\", provider,\n api_version=\"v60.0\", on_401_refresh=refresh,\n )\n\n # Contract: both requests were made; first with old_token,\n # second with new_token (the post-refresh value).\n self.assertEqual(len(observed_auth_headers), 2)\n self.assertEqual(observed_auth_headers[0], \"Bearer old_token\")\n self.assertEqual(observed_auth_headers[1], \"Bearer new_token\")\n # And the retry response shaped correctly.\n self.assertEqual(result.get(\"DeveloperName\"), \"X\")\n\n\nclass ApiVersionEndToEndTests(unittest.TestCase):\n \"\"\"the `api_version` reported by `sf org display --json`\n threads through the pipeline all the way to the REST query URL.\n\n Orgs on v66 must NOT hit `/services/data/v60.0/...` — that was\n . This test drives the full pipeline with a mocked\n org-display payload reporting `apiVersion=66.0` and asserts every\n Tooling/Data query fetcher receives `api_version=\"v66.0\"`.\n \"\"\"\n\n def test_full_pipeline_passes_v66_to_every_fetcher(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n data_root = Path(tmp) / \"data\"\n cache_root = Path(tmp) / \"cache\"\n\n # sf org display payload reports v66 — this is the shape real\n # orgs return today (my-org-alias, my-perf-org-alias).\n org_display_payload = {\n \"status\": 0,\n \"result\": {\n \"instanceUrl\": \"https://example.my.salesforce.com\",\n \"accessToken\": \"00Dxx0000000000!AQ_fake_token_value\",\n \"id\": \"00Dxx0000000000AAA\",\n \"apiVersion\": \"66.0\",\n },\n }\n\n # Capture api_version passed to each fetcher via mock.call_args.\n wave_a_patches = [\n mock.patch.object(main, \"fetch_planner_definition\",\n return_value=fx.CLASSIC_PLANNER),\n mock.patch.object(main, \"fetch_plugins_by_planner\",\n return_value=fx.CLASSIC_PLUGINS),\n mock.patch.object(main, \"fetch_planner_bundle_functions\",\n return_value=fx.CLASSIC_BUNDLE_FN_JOIN),\n mock.patch.object(main, \"fetch_functions_by_plugins\",\n return_value=fx.CLASSIC_FUNCTIONS),\n mock.patch.object(main, \"fetch_plugin_instructions\",\n return_value=fx.CLASSIC_INSTRUCTIONS),\n mock.patch.object(main, \"fetch_plugin_functions\",\n return_value=fx.CLASSIC_PLUGIN_FUNCTIONS),\n mock.patch.object(main, \"fetch_planner_attrs\",\n return_value=fx.CLASSIC_ATTRS),\n ]\n wave_b_patches = [\n mock.patch.object(main, \"fetch_apex_bodies_by_names\",\n return_value=fx.CLASSIC_APEX_ROWS),\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\",\n return_value=[]),\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\",\n return_value=fx.CLASSIC_FLOW_DEFS),\n mock.patch.object(main, \"fetch_flow_definition_by_ids\",\n return_value=[]),\n mock.patch.object(\n main, \"fetch_flow_metadata\",\n side_effect=lambda vid, *a, **kw: fx.CLASSIC_FLOW_METADATA.get(vid),\n ),\n ]\n patches = [\n mock.patch.object(main, \"run_sf\", return_value=org_display_payload),\n mock.patch.object(main, \"assert_show_access_token_capability\"),\n mock.patch.object(main, \"probe_channels\",\n return_value=fx.probe_ok_payload()),\n *_mock_bot_resolution(),\n *wave_a_patches,\n *wave_b_patches,\n mock.patch.object(main, \"build_agent_data_dir\",\n side_effect=lambda o, a, v: data_root / o / f\"{a}__{v}\"),\n mock.patch.object(main, \"build_agent_cache_dir\",\n side_effect=lambda o, a, v: cache_root / o / f\"{a}__{v}\"),\n ]\n _apply_all(patches)\n try:\n rc = main.main(_args(work_dir))\n # Snapshot call lists BEFORE `p.stop()` restores the\n # originals — otherwise `main.fetch_*` drops back to the\n # real function with no `.call_args_list`.\n fetcher_call_lists = {\n name: getattr(main, name).call_args_list\n for name in (\n \"fetch_planner_definition\",\n \"fetch_plugins_by_planner\",\n \"fetch_planner_bundle_functions\",\n \"fetch_functions_by_plugins\",\n \"fetch_plugin_instructions\",\n \"fetch_plugin_functions\",\n \"fetch_planner_attrs\",\n \"fetch_apex_bodies_by_names\",\n \"fetch_flow_definition_ids_by_names\",\n \"fetch_flow_metadata\",\n \"fetch_bot_versions\",\n \"fetch_bot_definition_details\",\n )\n }\n finally:\n for p in patches:\n p.stop()\n\n self.assertEqual(rc, 0)\n\n # Every Wave-A + Wave-B fetcher received `api_version=\"v66.0\"`.\n # We inspect call_args.kwargs rather than positional args — the\n # kwarg is keyword-only by design.\n expected = \"v66.0\"\n for name, calls in fetcher_call_lists.items():\n calls_with_version = [\n c for c in calls\n if c.kwargs.get(\"api_version\") == expected\n ]\n self.assertTrue(\n calls_with_version,\n f\"{name}: no call observed with api_version={expected!r}. \"\n f\"All calls: {calls}\",\n )\n\n\nclass UncaughtExceptionToResultBlockTests(unittest.TestCase):\n \"\"\"the skill contract says every exit path emits a RESULT\n block. Before the fix, an uncaught exception in the pipeline (e.g.\n the HTTP 405 from , or any future bug) propagated to the\n process boundary — users saw a Python traceback on stderr and the\n wrapper skill never got `.emit_ctx.json`. `main()` now wraps\n `_run_pipeline` in `try/except Exception` and funnels failures\n through `_emit_fail(..., \"RETRIEVE_FAILED\", \"uncaught-exception: ...\")`.\n \"\"\"\n\n def test_uncaught_exception_surfaces_as_retrieve_failed(self):\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n\n # Patch `_run_pipeline` to raise an arbitrary non-HTTP bug\n # shape — proves the wrapper catches broad `Exception`, not\n # just the specific HTTPError from .\n with mock.patch.object(\n main, \"_run_pipeline\",\n side_effect=RuntimeError(\"unexpected defect in phase 6\"),\n ):\n rc = main.main(_args(work_dir))\n\n # Exit code MUST be non-zero (failure signal) but controlled —\n # no Python traceback on stderr.\n self.assertEqual(rc, 1)\n\n # `.emit_ctx.json` MUST exist — the skill contract.\n ctx_path = work_dir / \".emit_ctx.json\"\n self.assertTrue(ctx_path.is_file(),\n \"uncaught exception must still write .emit_ctx.json\")\n ctx = _read_ctx(work_dir)\n self.assertEqual(ctx[\"status\"], \"RETRIEVE_FAILED\")\n self.assertIn(\"uncaught-exception\", ctx[\"error_detail\"])\n self.assertIn(\"RuntimeError\", ctx[\"error_detail\"])\n self.assertIn(\"unexpected defect in phase 6\", ctx[\"error_detail\"])\n\n def test_uncaught_exception_redacts_bearer_tokens(self):\n \"\"\"the redacted error_detail must not leak tokens even if\n the exception message happens to carry one.\"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n\n leaky = RuntimeError(\n \"downstream failed: Authorization: Bearer TESTONLY_LEAKY_TOKEN\"\n )\n with mock.patch.object(main, \"_run_pipeline\", side_effect=leaky):\n rc = main.main(_args(work_dir))\n\n self.assertEqual(rc, 1)\n ctx = _read_ctx(work_dir)\n self.assertEqual(ctx[\"status\"], \"RETRIEVE_FAILED\")\n # Token must NOT appear.\n self.assertNotIn(\"TESTONLY_LEAKY_TOKEN\", ctx[\"error_detail\"])\n # Redaction sentinel must appear — proof the scrub ran.\n self.assertIn(\"\u003credacted>\", ctx[\"error_detail\"])\n\n def test_systemexit_still_propagates(self):\n \"\"\"argparse's --help path raises SystemExit. That MUST propagate\n unchanged — catching it would silently swallow `--help` + any\n other intentional early exit. The wrapper catches `Exception`,\n not `BaseException`, so SystemExit is unaffected.\"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n work_dir = Path(tmp) / \"work\"\n\n with mock.patch.object(main, \"_run_pipeline\",\n side_effect=SystemExit(2)):\n with self.assertRaises(SystemExit) as ctx:\n main.main(_args(work_dir))\n self.assertEqual(ctx.exception.code, 2)\n\n\nclass NormalizeFlowIdTargetsTests(unittest.TestCase):\n \"\"\"classic bots occasionally store NGA-style\n 300Uv-prefix FlowDefinition IDs or 301-prefix Flow version IDs as\n InvocationTarget. After Wave B resolves them we rewrite bundle_parsed\n in place so parse_wave sees DeveloperNames and the pending/visited\n diff collapses to zero for legitimate targets.\"\"\"\n\n def test_rewrites_flowdefinition_id_to_developer_name(self):\n bundle = {\n \"topics\": [{\n \"name\": \"T\",\n \"actions\": [{\n \"name\": \"A\",\n \"invocationTarget\": \"300UvXXXXXXXXXXXX\",\n \"invocationTargetType\": \"flow\",\n }],\n }],\n \"plannerActions\": [],\n }\n flow_def_rows = [{\n \"Id\": \"300UvXXXXXXXXXXXX\",\n \"DeveloperName\": \"MyFlowName\",\n \"ActiveVersionId\": \"301VfYYYYYYYYYYYY\",\n }]\n main._normalize_flow_id_targets(bundle, flow_def_rows)\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"MyFlowName\")\n self.assertEqual(action[\"_original_invocation_target_id\"],\n \"300UvXXXXXXXXXXXX\")\n\n def test_rewrites_flow_version_id_via_active_version_map(self):\n \"\"\"301-prefix IDs should also resolve — via the ActiveVersionId\n side of the bi-directional map.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"301VfYYYYYYYYYYYY\",\n \"invocationTargetType\": \"flow\",\n }],\n }],\n \"plannerActions\": [],\n }\n flow_def_rows = [{\n \"Id\": \"300UvXXXXXXXXXXXX\",\n \"DeveloperName\": \"MyFlowName\",\n \"ActiveVersionId\": \"301VfYYYYYYYYYYYY\",\n }]\n main._normalize_flow_id_targets(bundle, flow_def_rows)\n self.assertEqual(\n bundle[\"topics\"][0][\"actions\"][0][\"invocationTarget\"],\n \"MyFlowName\",\n )\n\n def test_preserves_classic_developer_name_targets(self):\n \"\"\"Classic DeveloperName targets must pass through untouched —\n they have nothing to resolve AND shouldn't pick up the sentinel\n _original_invocation_target_id field.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"AGNT_Case_Create\",\n \"invocationTargetType\": \"flow\",\n }],\n }],\n \"plannerActions\": [],\n }\n flow_def_rows = [{\n \"Id\": \"300UvXXXXXXXXXXXX\",\n \"DeveloperName\": \"AGNT_Case_Create\",\n \"ActiveVersionId\": \"301VfYYYYYYYYYYYY\",\n }]\n main._normalize_flow_id_targets(bundle, flow_def_rows)\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"AGNT_Case_Create\")\n self.assertNotIn(\"_original_invocation_target_id\", action)\n\n def test_unmatched_id_stays_as_is(self):\n \"\"\"An ID that doesn't appear in flow_def_rows (Flow not queryable\n / managed package invisible) stays unchanged — that's how it\n correctly surfaces in _pending_fetches instead of silently\n discarding.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"300UvZZZZZZZZZZZZ\",\n \"invocationTargetType\": \"flow\",\n }],\n }],\n \"plannerActions\": [],\n }\n main._normalize_flow_id_targets(bundle, [{\n \"Id\": \"300UvXXXXXXXXXXXX\",\n \"DeveloperName\": \"DifferentFlow\",\n \"ActiveVersionId\": \"301VfYYYYYYYYYYYY\",\n }])\n self.assertEqual(\n bundle[\"topics\"][0][\"actions\"][0][\"invocationTarget\"],\n \"300UvZZZZZZZZZZZZ\",\n )\n\n def test_apex_target_with_300_prefix_not_rewritten(self):\n \"\"\"Only invocationTargetType=='flow' is rewritten. A target that\n happens to match a flow id but is declared as apex stays put —\n it's a caller-error we surface, not one we silently rewrite.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"300UvXXXXXXXXXXXX\",\n \"invocationTargetType\": \"apex\", # wrong type\n }],\n }],\n \"plannerActions\": [],\n }\n flow_def_rows = [{\n \"Id\": \"300UvXXXXXXXXXXXX\",\n \"DeveloperName\": \"MyFlow\",\n \"ActiveVersionId\": \"301VfYYYYYYYYYYYY\",\n }]\n main._normalize_flow_id_targets(bundle, flow_def_rows)\n self.assertEqual(\n bundle[\"topics\"][0][\"actions\"][0][\"invocationTarget\"],\n \"300UvXXXXXXXXXXXX\",\n )\n\n def test_empty_inputs_noop(self):\n \"\"\"Empty flow_def_rows → immediate return, no mutation, no crash\n on missing 'topics' / 'plannerActions' keys.\"\"\"\n bundle = {}\n main._normalize_flow_id_targets(bundle, [])\n self.assertEqual(bundle, {})\n\n bundle2 = {\n \"topics\": [{\"actions\": [{\n \"invocationTarget\": \"300Uv\", \"invocationTargetType\": \"flow\",\n }]}],\n \"plannerActions\": [],\n }\n main._normalize_flow_id_targets(bundle2, [])\n # Untouched — no lookup map to rewrite against.\n self.assertEqual(\n bundle2[\"topics\"][0][\"actions\"][0][\"invocationTarget\"], \"300Uv\",\n )\n\n def test_rewrites_both_topics_and_planner_actions(self):\n \"\"\"Fix must cover both the per-topic actions path AND the\n bundle-scope plannerActions path.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"300UvAAAA\",\n \"invocationTargetType\": \"flow\",\n }],\n }],\n \"plannerActions\": [{\n \"invocationTarget\": \"300UvBBBB\",\n \"invocationTargetType\": \"flow\",\n }],\n }\n flow_def_rows = [\n {\"Id\": \"300UvAAAA\", \"DeveloperName\": \"TopicFlow\",\n \"ActiveVersionId\": \"301AAAA\"},\n {\"Id\": \"300UvBBBB\", \"DeveloperName\": \"BundleFlow\",\n \"ActiveVersionId\": \"301BBBB\"},\n ]\n main._normalize_flow_id_targets(bundle, flow_def_rows)\n self.assertEqual(\n bundle[\"topics\"][0][\"actions\"][0][\"invocationTarget\"], \"TopicFlow\",\n )\n self.assertEqual(\n bundle[\"plannerActions\"][0][\"invocationTarget\"], \"BundleFlow\",\n )\n\n def test_none_invocation_target_type_ignored_safely(self):\n \"\"\"Defensive: invocationTargetType missing → treat as 'not a\n flow', skip. No crash.\"\"\"\n bundle = {\n \"topics\": [{\"actions\": [{\n \"invocationTarget\": \"300UvAAAA\",\n \"invocationTargetType\": None,\n }]}],\n \"plannerActions\": [],\n }\n main._normalize_flow_id_targets(bundle, [\n {\"Id\": \"300UvAAAA\", \"DeveloperName\": \"X\", \"ActiveVersionId\": \"301\"}\n ])\n self.assertEqual(\n bundle[\"topics\"][0][\"actions\"][0][\"invocationTarget\"], \"300UvAAAA\",\n )\n\n\nclass CollectWaveBTargetsStandardActionTests(unittest.TestCase):\n \"\"\"`_route` in `_collect_wave_b_targets` must\n short-circuit on declared-only target types (standardInvocableAction,\n generatePromptResponse, genai*, prompt*) BEFORE calling\n resolve_or_unresolved — otherwise it pollutes `_unresolved` with\n spurious \"invalid-id-format\" entries for perfectly valid identifiers\n that simply aren't Salesforce Ids.\"\"\"\n\n def test_standard_action_not_routed_through_id_resolver(self):\n \"\"\"`streamKnowledgeSearch` is a built-in standard action — not\n an Id. It should not end up in apex_ids, flow_ids, apex_names,\n or flow_names AND it should NOT appear in the `_unresolved`\n list that resolve_or_unresolved populates on non-Id inputs.\n \"\"\"\n bundle = {\n \"topics\": [{\n \"name\": \"T\",\n \"actions\": [{\n \"name\": \"A\",\n \"invocationTarget\": \"streamKnowledgeSearch\",\n \"invocationTargetType\": \"standardInvocableAction\",\n }],\n }],\n \"plannerActions\": [],\n }\n result = main._collect_wave_b_targets(bundle)\n self.assertEqual(result[\"apex_names\"], [])\n self.assertEqual(result[\"apex_ids\"], [])\n self.assertEqual(result[\"flow_names\"], [])\n self.assertEqual(result[\"flow_ids\"], [])\n self.assertEqual(\n result[\"_unresolved\"], [],\n \"standardInvocableAction must not pollute _unresolved with \"\n \"invalid-id-format entries\",\n )\n\n def test_generate_prompt_response_short_circuits(self):\n \"\"\"`generatePromptResponse` targets are prompt-template names;\n not routed to Apex/Flow fetchers (Batch 1 is body-only for\n Apex+Flow; prompts flow through the retrieve path).\"\"\"\n bundle = {\n \"topics\": [{\n \"name\": \"T\",\n \"actions\": [{\n \"invocationTarget\": \"MyPromptTemplate\",\n \"invocationTargetType\": \"generatePromptResponse\",\n }],\n }],\n \"plannerActions\": [],\n }\n result = main._collect_wave_b_targets(bundle)\n self.assertEqual(result[\"apex_names\"], [])\n self.assertEqual(result[\"flow_names\"], [])\n self.assertEqual(result[\"_unresolved\"], [])\n\n def test_flow_and_apex_still_route_correctly(self):\n \"\"\"Positive control — the short-circuit must not break flow/apex\n routing. A mix of classic DeveloperNames + NGA IDs should land in\n the right buckets, and standard actions should pass through\n without polluting anything.\"\"\"\n bundle = {\n \"topics\": [{\n \"name\": \"T\",\n \"actions\": [\n # Classic flow by DeveloperName\n {\"invocationTarget\": \"MyFlow\",\n \"invocationTargetType\": \"flow\"},\n # Classic apex by name\n {\"invocationTarget\": \"MyApex\",\n \"invocationTargetType\": \"apex\"},\n # Standard action (new short-circuit path)\n {\"invocationTarget\": \"streamKnowledgeSearch\",\n \"invocationTargetType\": \"standardInvocableAction\"},\n # NGA apex by Id\n {\"invocationTarget\": \"01p1N000005SsDNQA0\",\n \"invocationTargetType\": \"apex\"},\n ],\n }],\n \"plannerActions\": [],\n }\n result = main._collect_wave_b_targets(bundle)\n self.assertIn(\"MyFlow\", result[\"flow_names\"])\n self.assertIn(\"MyApex\", result[\"apex_names\"])\n self.assertIn(\"01p1N000005SsDNQA0\", result[\"apex_ids\"])\n self.assertEqual(result[\"_unresolved\"], [])\n\n def test_unknown_type_silently_skipped(self):\n \"\"\"An unrecognized invocationTargetType falls through all the\n known-type branches and is silently skipped. parse_wave tags the\n node as UNKNOWN in the tree (via `_kind_counts`), so the signal\n is visible without polluting _unresolved.\"\"\"\n bundle = {\n \"topics\": [{\n \"name\": \"T\",\n \"actions\": [{\n \"invocationTarget\": \"SomeThing\",\n \"invocationTargetType\": \"unheardOfType\",\n }],\n }],\n \"plannerActions\": [],\n }\n result = main._collect_wave_b_targets(bundle)\n self.assertEqual(result[\"apex_names\"], [])\n self.assertEqual(result[\"flow_names\"], [])\n self.assertEqual(\n result[\"_unresolved\"], [],\n \"Unknown invocationTargetType shouldn't reach the ID router, \"\n \"so _unresolved stays clean.\",\n )\n\n def test_classic_flow_name_not_routed_through_id_resolver(self):\n \"\"\"classic bots store plain DeveloperNames\n like `MyFlow` — not Salesforce Ids. The router must route these\n directly to flow_names via invocationTargetType, NOT through\n resolve_or_unresolved (which would reject them as invalid-id-\n format and pollute _unresolved).\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [\n {\"invocationTarget\": \"MyFlow\",\n \"invocationTargetType\": \"flow\"},\n {\"invocationTarget\": \"AGNT_Foo\",\n \"invocationTargetType\": \"apex\"},\n ],\n }],\n \"plannerActions\": [],\n }\n result = main._collect_wave_b_targets(bundle)\n self.assertIn(\"MyFlow\", result[\"flow_names\"])\n self.assertIn(\"AGNT_Foo\", result[\"apex_names\"])\n self.assertEqual(\n result[\"_unresolved\"], [],\n \"Classic DeveloperNames shouldn't be sent through the ID \"\n \"resolver at all — they're not Ids.\",\n )\n\n\nclass NormalizePromptTemplateIdTargetsTests(unittest.TestCase):\n \"\"\"Bug 1 fix (2026-05-05): classic bots occasionally store 0hf-prefix\n GenAiPromptTemplate IDs as `GenAiFunctionDefinition.InvocationTarget`.\n After Wave B resolves them via `list_prompt_template_metadata`\n (GenAiPromptTemplate is NOT SOQL-queryable — Metadata API only) we\n rewrite bundle_parsed in place so Wave B's retrieve uses the\n DeveloperName Metadata API expects.\"\"\"\n\n def test_rewrites_prompt_template_id_to_developer_name(self):\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"0hfUv0000021mCjIAI\",\n \"invocationTargetType\": \"GenAiPromptTemplate\",\n }],\n }],\n \"plannerActions\": [],\n }\n rows = [{\"Id\": \"0hfUv0000021mCjIAI\", \"DeveloperName\": \"My_Prompt\"}]\n main._normalize_prompt_template_id_targets(bundle, rows)\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"My_Prompt\")\n self.assertEqual(\n action[\"_original_invocation_target_id\"], \"0hfUv0000021mCjIAI\"\n )\n\n def test_preserves_classic_developer_name_targets(self):\n \"\"\"Classic DeveloperName targets pass through untouched — the\n rewrite is only for Id-shaped targets that appeared in the lookup.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"Existing_Prompt_Name\",\n \"invocationTargetType\": \"GenAiPromptTemplate\",\n }],\n }],\n \"plannerActions\": [],\n }\n # Lookup covers a different Id entirely — the DeveloperName target\n # isn't a key in the map so it's not rewritten.\n rows = [{\"Id\": \"0hfUv0000021Other\", \"DeveloperName\": \"Other_Prompt\"}]\n main._normalize_prompt_template_id_targets(bundle, rows)\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"Existing_Prompt_Name\")\n self.assertNotIn(\"_original_invocation_target_id\", action)\n\n def test_unmatched_id_stays_as_is(self):\n \"\"\"Id not present in the lookup (e.g. Tooling returned empty\n because GenAiPromptTemplate isn't exposed on this org) stays\n as-is so it correctly surfaces in _pending_fetches.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"0hfUv00000xxxxxYYY\",\n \"invocationTargetType\": \"GenAiPromptTemplate\",\n }],\n }],\n \"plannerActions\": [],\n }\n main._normalize_prompt_template_id_targets(bundle, [])\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"0hfUv00000xxxxxYYY\")\n self.assertNotIn(\"_original_invocation_target_id\", action)\n\n\nclass NormalizeApexIdTargetsTests(unittest.TestCase):\n \"\"\"Gap B fix (2026-05-05): classic bots occasionally store 01p-prefix\n ApexClass Ids as `GenAiFunctionDefinition.InvocationTarget` (live-verified\n on my-org-alias: 01p000000000000AAA -> MyController).\n After Wave B resolves them via `fetch_apex_bodies_by_ids` we rewrite\n bundle_parsed in place so the tree renders the ApexClass Name instead\n of the raw Id.\"\"\"\n\n def test_rewrites_apex_id_to_class_name(self):\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"01p000000000000AAA\",\n \"invocationTargetType\": \"apex\",\n }],\n }],\n \"plannerActions\": [],\n }\n rows = [{\"Id\": \"01p000000000000AAA\", \"Name\": \"MyController\"}]\n main._normalize_apex_id_targets(bundle, rows)\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"MyController\")\n self.assertEqual(\n action[\"_original_invocation_target_id\"], \"01p000000000000AAA\"\n )\n\n def test_non_apex_ttype_left_unchanged(self):\n \"\"\"Same Id but a non-apex ttype — the ttype gate must block the\n rewrite so a Flow action whose target happens to collide with an\n ApexClass Id isn't miscaught.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"01p000000000000AAA\",\n \"invocationTargetType\": \"flow\",\n }],\n }],\n \"plannerActions\": [],\n }\n rows = [{\"Id\": \"01p000000000000AAA\", \"Name\": \"MyController\"}]\n main._normalize_apex_id_targets(bundle, rows)\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"01p000000000000AAA\")\n self.assertNotIn(\"_original_invocation_target_id\", action)\n\n def test_unmatched_id_stays_as_is(self):\n \"\"\"Id not present in the lookup (e.g. by-Id fetch failed or the\n class was deleted) stays as-is so it correctly surfaces in\n _pending_fetches.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"01pFAKEIDFAKEIDAAA\",\n \"invocationTargetType\": \"apex\",\n }],\n }],\n \"plannerActions\": [],\n }\n main._normalize_apex_id_targets(bundle, [])\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"01pFAKEIDFAKEIDAAA\")\n self.assertNotIn(\"_original_invocation_target_id\", action)\n\n def test_preserves_classic_developer_name_targets(self):\n \"\"\"Classic Name targets pass through untouched — the rewrite is\n only for Id-shaped targets (01p-prefix) that appeared in the lookup.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [{\n \"invocationTarget\": \"MyApexClass\",\n \"invocationTargetType\": \"apex\",\n }],\n }],\n \"plannerActions\": [],\n }\n # Lookup covers a different Id entirely — the Name target isn't\n # Id-shaped and doesn't hit the prefix gate.\n rows = [{\"Id\": \"01p000000000000AAA\", \"Name\": \"MyController\"}]\n main._normalize_apex_id_targets(bundle, rows)\n action = bundle[\"topics\"][0][\"actions\"][0]\n self.assertEqual(action[\"invocationTarget\"], \"MyApexClass\")\n self.assertNotIn(\"_original_invocation_target_id\", action)\n\n\nclass CollectWaveBTargetsPromptTemplateIdTests(unittest.TestCase):\n \"\"\"Bug 1 fix (2026-05-05): 0hf-prefix prompt template Ids are routed\n to a dedicated `prompt_template_ids` bucket so post-Wave-B resolution\n can rewrite them; DeveloperName targets still short-circuit as before.\"\"\"\n\n def test_prompt_template_id_routes_to_id_bucket(self):\n bundle = {\n \"topics\": [],\n \"plannerActions\": [{\n \"invocationTarget\": \"0hfUv0000021mCjIAI\",\n \"invocationTargetType\": \"GenAiPromptTemplate\",\n }],\n }\n result = main._collect_wave_b_targets(bundle)\n self.assertEqual(\n result.get(\"prompt_template_ids\"), [\"0hfUv0000021mCjIAI\"]\n )\n # DeveloperName bucket stays empty — the Id is routed to the Id\n # bucket and the short-circuit returns BEFORE the DeveloperName\n # branch would have run.\n self.assertEqual(result.get(\"_unresolved\"), [])\n\n def test_prompt_template_developer_name_does_not_pollute_id_bucket(self):\n bundle = {\n \"topics\": [],\n \"plannerActions\": [{\n \"invocationTarget\": \"My_Prompt_Template_Name\",\n \"invocationTargetType\": \"GenAiPromptTemplate\",\n }],\n }\n result = main._collect_wave_b_targets(bundle)\n self.assertEqual(result.get(\"prompt_template_ids\"), [])\n # DeveloperName-shaped prompt-template targets still short-circuit\n # (parse_wave enqueues them into PROMPT_TEMPLATE pending) — no\n # change from prior behavior, verified via absence from all\n # name/id buckets we'd route through.\n self.assertEqual(result.get(\"apex_names\"), [])\n self.assertEqual(result.get(\"flow_names\"), [])\n\n def test_non_0hf_prefix_ignored(self):\n \"\"\"Id-shaped target for a prompt-template ttype but WRONG prefix\n (e.g. a Flow Id accidentally typed with prompt ttype) doesn't\n pollute prompt_template_ids — the prefix gate catches it.\"\"\"\n bundle = {\n \"topics\": [],\n \"plannerActions\": [{\n \"invocationTarget\": \"300Uv00000abcdeFGH\", # Flow-shaped Id\n \"invocationTargetType\": \"GenAiPromptTemplate\",\n }],\n }\n result = main._collect_wave_b_targets(bundle)\n # Not routed anywhere — neither prompt-template Id bucket nor\n # flow buckets (ttype gated). Surfaces nowhere, which is\n # acceptable: classify_action_call downstream will still enqueue\n # the string as-is as a PROMPT_TEMPLATE pending fetch.\n self.assertEqual(result.get(\"prompt_template_ids\"), [])\n\n\nclass FetchFlowDefinitionViewByDurableIdsTests(unittest.TestCase):\n \"\"\"Option B (2026-05-05): `fetch_flow_definition_view_by_durable_ids`\n is the Data-API fallback fetcher for managed-installed flows that\n Tooling's `FlowDefinition` doesn't index.\n\n Contract:\n * Empty input short-circuits to `[]` without firing a SOQL call\n (matches every other list-shaped fetcher in this module).\n * Rows are returned from `data_query` verbatim — projection to\n the `flow_def_rows` shape happens in the caller\n (`fetch_flow_definition_ids_by_names`).\n \"\"\"\n\n def test_happy_path_returns_rows(self):\n import fetch_soql # type: ignore\n view_row = {\n \"DurableId\": \"SvcCopilotTmpl__VerifyCode\",\n \"ApiName\": \"VerifyCode\",\n \"Label\": \"Verify Code\",\n \"NamespacePrefix\": \"SvcCopilotTmpl\",\n \"ActiveVersionId\": \"SvcCopilotTmpl__VerifyCode-1\",\n \"IsActive\": True,\n \"ManageableState\": \"installed\",\n \"ProcessType\": \"AutoLaunchedFlow\",\n }\n with mock.patch.object(\n fetch_soql, \"data_query\",\n return_value={\"records\": [view_row]},\n ) as mock_dq:\n rows = fetch_soql.fetch_flow_definition_view_by_durable_ids(\n [\"SvcCopilotTmpl__VerifyCode\"],\n lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(rows, [view_row])\n # Exactly one data_query call — the fetcher doesn't accidentally\n # re-fire on the Tooling surface.\n self.assertEqual(mock_dq.call_count, 1)\n\n def test_empty_input_short_circuits(self):\n \"\"\"Empty durable_ids list returns [] WITHOUT firing a SOQL call\n (matches the module-wide empty-input-short-circuit invariant).\"\"\"\n import fetch_soql # type: ignore\n with mock.patch.object(fetch_soql, \"data_query\") as mock_dq:\n rows = fetch_soql.fetch_flow_definition_view_by_durable_ids(\n [],\n lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(rows, [])\n self.assertEqual(mock_dq.call_count, 0)\n\n def test_multi_row_returns_all(self):\n import fetch_soql # type: ignore\n view_rows = [\n {\n \"DurableId\": \"SvcCopilotTmpl__VerifyCode\",\n \"ApiName\": \"VerifyCode\",\n \"NamespacePrefix\": \"SvcCopilotTmpl\",\n \"ActiveVersionId\": \"SvcCopilotTmpl__VerifyCode-1\",\n \"ManageableState\": \"installed\",\n },\n {\n \"DurableId\": \"sales_inbound_flows__SendVerifyCode\",\n \"ApiName\": \"SendVerifyCode\",\n \"NamespacePrefix\": \"sales_inbound_flows\",\n \"ActiveVersionId\": \"sales_inbound_flows__SendVerifyCode-2\",\n \"ManageableState\": \"installed\",\n },\n ]\n with mock.patch.object(\n fetch_soql, \"data_query\",\n return_value={\"records\": view_rows},\n ):\n rows = fetch_soql.fetch_flow_definition_view_by_durable_ids(\n [\n \"SvcCopilotTmpl__VerifyCode\",\n \"sales_inbound_flows__SendVerifyCode\",\n ],\n lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(len(rows), 2)\n # Membership assertion rather than sort-order — codepoint sort of\n # mixed-case prefixes is fragile; the fetcher returns rows in the\n # order `data_query` supplies them.\n self.assertEqual(\n {r[\"DurableId\"] for r in rows},\n {\n \"SvcCopilotTmpl__VerifyCode\",\n \"sales_inbound_flows__SendVerifyCode\",\n },\n )\n\n\nclass FlowDefinitionViewFallbackTests(unittest.TestCase):\n \"\"\"Option B (2026-05-05, updated after managed-bucket retirement):\n `fetch_flow_definition_ids_by_names` fires a SINGLE Tooling\n `FlowDefinition` query (unmanaged-only — the template filters\n `NamespacePrefix IS NULL`). Any input name that query doesn't resolve\n triggers a single follow-up `FlowDefinitionView` (Data API) query.\n Projected view-only rows carry `Id=None`, `ActiveVersionId=None`,\n `_body_available=False`, and `_source=\"FlowDefinitionView\"`.\n \"\"\"\n\n def test_fallback_fires_on_managed_miss(self):\n \"\"\"Tooling FlowDefinition returns empty for a `ns__Name` input\n (managed flows aren't indexed on subscriber orgs + the unmanaged\n query filters `NamespacePrefix IS NULL` anyway); FlowDefinitionView\n returns a matching row. Output row carries the view-shaped markers\n and `DeveloperName` = qualified ns__bare.\"\"\"\n import fetch_soql # type: ignore\n view_row = {\n \"DurableId\": \"SvcCopilotTmpl__VerifyCode\",\n \"ApiName\": \"VerifyCode\",\n \"Label\": \"Verify Code\",\n \"NamespacePrefix\": \"SvcCopilotTmpl\",\n \"ActiveVersionId\": \"SvcCopilotTmpl__VerifyCode-1\",\n \"ManageableState\": \"installed\",\n }\n with mock.patch.object(\n fetch_soql, \"tooling_query\",\n return_value={\"records\": []},\n ), mock.patch.object(\n fetch_soql, \"data_query\",\n return_value={\"records\": [view_row]},\n ) as mock_dq:\n rows = fetch_soql.fetch_flow_definition_ids_by_names(\n [\"SvcCopilotTmpl__VerifyCode\"],\n lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(len(rows), 1)\n row = rows[0]\n self.assertIsNone(row[\"Id\"])\n self.assertIsNone(row[\"ActiveVersionId\"])\n self.assertFalse(row[\"_body_available\"])\n self.assertEqual(row[\"_source\"], \"FlowDefinitionView\")\n self.assertEqual(row[\"DeveloperName\"], \"SvcCopilotTmpl__VerifyCode\")\n self.assertEqual(row[\"NamespacePrefix\"], \"SvcCopilotTmpl\")\n self.assertEqual(row[\"_bare_developer_name\"], \"VerifyCode\")\n self.assertEqual(mock_dq.call_count, 1)\n\n def test_fallback_skipped_when_tooling_resolves_everything(self):\n \"\"\"All input names resolved by Tooling → FlowDefinitionView is\n never queried. Guards against unnecessary Data-API calls on the\n 99% of runs where every referenced flow is unmanaged or locally\n installed.\"\"\"\n import fetch_soql # type: ignore\n real_row = {\n \"Id\": \"300Uv000000Real\",\n \"DeveloperName\": \"MyFlow\",\n \"NamespacePrefix\": None,\n \"ActiveVersionId\": \"301Vf000000Real\",\n }\n with mock.patch.object(\n fetch_soql, \"tooling_query\",\n return_value={\"records\": [real_row]},\n ), mock.patch.object(fetch_soql, \"data_query\") as mock_dq:\n rows = fetch_soql.fetch_flow_definition_ids_by_names(\n [\"MyFlow\"],\n lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(len(rows), 1)\n self.assertEqual(rows[0][\"_source\"], \"FlowDefinition\")\n self.assertTrue(rows[0][\"_body_available\"])\n self.assertEqual(mock_dq.call_count, 0)\n\n def test_mixed_resolution_paths(self):\n \"\"\"Three input names, three distinct fates:\n 1. `ExistingFlow` — unmanaged, resolved by Tooling.\n 2. `SvcCopilotTmpl__VerifyCode` — managed miss on Tooling,\n resolved by FlowDefinitionView.\n 3. `ghost_ns__GhostFlow` — managed miss on BOTH surfaces.\n\n Expected: 2 rows total (1 real + 1 view-only), the ghost flow\n absent from the result set. FlowDefinitionView queried exactly\n once with both unresolved names in the IN-list.\n \"\"\"\n import fetch_soql # type: ignore\n\n real_row = {\n \"Id\": \"300Uv000000Real\",\n \"DeveloperName\": \"ExistingFlow\",\n \"NamespacePrefix\": None,\n \"ActiveVersionId\": \"301Vf000000Real\",\n }\n view_row = {\n \"DurableId\": \"SvcCopilotTmpl__VerifyCode\",\n \"ApiName\": \"VerifyCode\",\n \"NamespacePrefix\": \"SvcCopilotTmpl\",\n \"ActiveVersionId\": \"SvcCopilotTmpl__VerifyCode-1\",\n \"ManageableState\": \"installed\",\n }\n\n # Tooling is called exactly once — the unmanaged query (template\n # filters NamespacePrefix IS NULL). It matches only ExistingFlow;\n # the two managed-qualified names return no rows from that\n # surface and fall through to the view fallback.\n with mock.patch.object(\n fetch_soql, \"tooling_query\",\n return_value={\"records\": [real_row]},\n ) as mock_tq, mock.patch.object(\n fetch_soql, \"data_query\",\n return_value={\"records\": [view_row]},\n ) as mock_dq:\n rows = fetch_soql.fetch_flow_definition_ids_by_names(\n [\n \"ExistingFlow\",\n \"SvcCopilotTmpl__VerifyCode\",\n \"ghost_ns__GhostFlow\",\n ],\n lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(len(rows), 2)\n # Tooling queried exactly once for the full input (no bucketing).\n self.assertEqual(mock_tq.call_count, 1)\n # View fallback queried exactly once — no per-name fanout.\n self.assertEqual(mock_dq.call_count, 1)\n\n by_name = {r[\"DeveloperName\"]: r for r in rows}\n # Real Tooling row — source marker explicit.\n self.assertEqual(by_name[\"ExistingFlow\"][\"Id\"], \"300Uv000000Real\")\n self.assertEqual(by_name[\"ExistingFlow\"][\"_source\"], \"FlowDefinition\")\n self.assertTrue(by_name[\"ExistingFlow\"][\"_body_available\"])\n # View-only row — no Id, no active version, marked unavailable.\n view_result = by_name[\"SvcCopilotTmpl__VerifyCode\"]\n self.assertIsNone(view_result[\"Id\"])\n self.assertIsNone(view_result[\"ActiveVersionId\"])\n self.assertFalse(view_result[\"_body_available\"])\n self.assertEqual(view_result[\"_source\"], \"FlowDefinitionView\")\n # Ghost flow absent.\n self.assertNotIn(\"ghost_ns__GhostFlow\", by_name)\n\n\nclass FetchPlannerDefinitionChainTests(unittest.TestCase):\n \"\"\"2026-05-05: `fetch_planner_definition(agent, version)` performs a\n chain-LIKE lookup against GenAiPlannerDefinition. The accretive\n naming invariant (v1=`\u003cAgent>`, v2=`\u003cAgent>_v2`, v3=\n `\u003cAgent>_v2_v3`, ...) means the correct planner always matches\n `\u003cAgent>%\\\\_vN` for vN>=2 and `\u003cAgent>` exactly for v1. On multi-row\n matches the resolver picks the row with the shortest DeveloperName.\n \"\"\"\n\n def _stub_tooling(self, records):\n \"\"\"Patch `fetch_soql.tooling_query` to return a fixed record list.\"\"\"\n import fetch_soql # type: ignore\n return mock.patch.object(\n fetch_soql, \"tooling_query\",\n return_value={\"records\": records},\n )\n\n def test_v1_exact_match(self):\n import fetch_soql # type: ignore\n with self._stub_tooling([\n {\"Id\": \"1VxVF000V1\", \"DeveloperName\": \"Inbound_Sales_Agent\"},\n ]):\n row = fetch_soql.fetch_planner_definition(\n \"Inbound_Sales_Agent\", None,\n lambda: (\"url\", \"tok\"),\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertIsNotNone(row)\n self.assertEqual(row[\"DeveloperName\"], \"Inbound_Sales_Agent\")\n\n def test_v1_explicit_string(self):\n import fetch_soql # type: ignore\n with self._stub_tooling([\n {\"Id\": \"1VxVF000V1\", \"DeveloperName\": \"Inbound_Sales_Agent\"},\n ]):\n row = fetch_soql.fetch_planner_definition(\n \"Inbound_Sales_Agent\", \"v1\",\n lambda: (\"url\", \"tok\"),\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(row[\"DeveloperName\"], \"Inbound_Sales_Agent\")\n\n def test_v2_single_match(self):\n import fetch_soql # type: ignore\n with self._stub_tooling([\n {\"Id\": \"1VxVF000V2\", \"DeveloperName\": \"Inbound_Sales_Agent_v2\"},\n ]):\n row = fetch_soql.fetch_planner_definition(\n \"Inbound_Sales_Agent\", \"v2\",\n lambda: (\"url\", \"tok\"),\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(row[\"DeveloperName\"], \"Inbound_Sales_Agent_v2\")\n\n def test_v2_multi_row_shortest_wins(self):\n \"\"\"LIKE `\u003cAgent>%\\\\_v2` can match both `\u003cAgent>_v2` and a deeper\n chain like `\u003cAgent>_foo_v2` (rare but possible). Shortest\n DeveloperName wins — the canonical row carries no extra segment.\"\"\"\n import fetch_soql # type: ignore\n with self._stub_tooling([\n {\"Id\": \"1VxVF000DEEPER\",\n \"DeveloperName\": \"Inbound_Sales_Agent_foo_v2\"},\n {\"Id\": \"1VxVF000V2\",\n \"DeveloperName\": \"Inbound_Sales_Agent_v2\"},\n ]):\n row = fetch_soql.fetch_planner_definition(\n \"Inbound_Sales_Agent\", \"v2\",\n lambda: (\"url\", \"tok\"),\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertEqual(row[\"DeveloperName\"], \"Inbound_Sales_Agent_v2\")\n\n def test_zero_rows_returns_none(self):\n import fetch_soql # type: ignore\n with self._stub_tooling([]):\n row = fetch_soql.fetch_planner_definition(\n \"NoSuchAgent\", \"v3\",\n lambda: (\"url\", \"tok\"),\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertIsNone(row)\n\n def test_chain_like_pattern_passed_to_soql_for_v5(self):\n \"\"\"The SOQL body that reaches `tooling_query` contains the chain\n LIKE pattern literally. Verify AGENT_NAME + VERSION are rendered\n through the template with the `%\\\\_` escape in between.\n \"\"\"\n import fetch_soql # type: ignore\n captured: dict = {}\n\n def fake_tooling(creds_provider, soql, *, api_version, on_401_refresh):\n captured[\"soql\"] = soql\n return {\"records\": []}\n\n with mock.patch.object(fetch_soql, \"tooling_query\",\n side_effect=fake_tooling):\n fetch_soql.fetch_planner_definition(\n \"MyAgent\", \"v5\",\n lambda: (\"url\", \"tok\"),\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"url\", \"tok\"),\n )\n self.assertIn(\"LIKE 'MyAgent%\\\\_v5'\", captured[\"soql\"])\n\n\nclass SwapDirAtomicCoTenancyTests(unittest.TestCase):\n \"\"\"`_swap_dir_atomic` preserves sibling content in `target`. The prior\n whole-directory `os.replace` wiped any co-tenant subdirs that another\n caller may have written into the same `\u003cagent>__\u003cver>/` directory.\"\"\"\n\n def test_preserves_sibling_session_dirs(self) -> None:\n # Simulate co-tenancy: target dir already has a sibling subdir\n # + a bare file written by some other caller.\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n target = tmp_p / \"agent__v1\"\n target.mkdir()\n (target / \"sibling-data\").mkdir()\n (target / \"sibling-data\" / \"payload.json\").write_text(\n \"from-other-caller\"\n )\n (target / \"_sessions\").mkdir()\n (target / \"_sessions\" / \"marker.link\").write_text(\n \"../agent__v1/sibling-data\"\n )\n\n # Architecture's own previous output, about to be overwritten.\n (target / \"old_tree.json\").write_text(\"old\")\n\n # Staging dir — what architecture wants to write.\n staging = tmp_p / \".agent__v1.staging.123\"\n staging.mkdir()\n (staging / \"agent_v1_metadata_tree.json\").write_text(\"new-tree\")\n (staging / \"last_built_at.txt\").write_text(\"2026-05-05T12:00:00Z\\n\")\n\n main._swap_dir_atomic(target, staging)\n\n # Sibling content survives — the whole point of the fix.\n self.assertTrue(\n (target / \"sibling-data\" / \"payload.json\").is_file()\n )\n self.assertEqual(\n (target / \"sibling-data\" / \"payload.json\").read_text(),\n \"from-other-caller\",\n )\n self.assertTrue((target / \"_sessions\" / \"marker.link\").is_file())\n\n # Architecture's new files landed.\n self.assertEqual(\n (target / \"agent_v1_metadata_tree.json\").read_text(), \"new-tree\"\n )\n self.assertTrue((target / \"last_built_at.txt\").is_file())\n\n # Old file that wasn't in staging stays untouched.\n self.assertTrue((target / \"old_tree.json\").is_file())\n self.assertEqual((target / \"old_tree.json\").read_text(), \"old\")\n\n # Staging dir cleaned up.\n self.assertFalse(staging.exists())\n\n def test_overwrites_own_files_cleanly(self) -> None:\n \"\"\"Same filename in both target and staging → staging content wins.\"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n target = tmp_p / \"agent__v1\"\n target.mkdir()\n (target / \"tree.json\").write_text(\"old-content\")\n\n staging = tmp_p / \".agent__v1.staging.123\"\n staging.mkdir()\n (staging / \"tree.json\").write_text(\"new-content\")\n\n main._swap_dir_atomic(target, staging)\n\n self.assertEqual(\n (target / \"tree.json\").read_text(), \"new-content\"\n )\n\n def test_overwrites_when_target_entry_is_dir(self) -> None:\n \"\"\"If the old target entry is a directory and the new staging\n entry is a file (or vice versa), replace cleans up the old kind\n before os.replace lands the new one.\"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n target = tmp_p / \"agent__v1\"\n target.mkdir()\n # Old entry is a directory with nested content.\n (target / \"payload\").mkdir()\n (target / \"payload\" / \"nested.txt\").write_text(\"deep\")\n\n staging = tmp_p / \".agent__v1.staging.123\"\n staging.mkdir()\n # New entry with the same name is a plain file.\n (staging / \"payload\").write_text(\"now-a-file\")\n\n main._swap_dir_atomic(target, staging)\n\n self.assertTrue((target / \"payload\").is_file())\n self.assertEqual(\n (target / \"payload\").read_text(), \"now-a-file\"\n )\n\n def test_missing_staging_raises(self) -> None:\n \"\"\"Pre-existing precondition: staging must exist, or we raise.\"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n target = tmp_p / \"agent__v1\"\n target.mkdir()\n staging = tmp_p / \".agent__v1.staging.does-not-exist\"\n with self.assertRaises(OSError):\n main._swap_dir_atomic(target, staging)\n\n def test_run_finalize_preserves_session_subdir_across_reruns(self) -> None:\n \"\"\"End-to-end via `_run_finalize`: after a first successful run\n seeds a co-tenant session dir, a second run must not wipe it.\n Covers the production path — the bug the user reported.\"\"\"\n with tempfile.TemporaryDirectory() as tmp:\n tmp_p = Path(tmp)\n work_dir = tmp_p / \"work\"\n work_dir.mkdir()\n data_dir = tmp_p / \"data\" / \"A__v1\"\n cache_dir = tmp_p / \"cache\" / \"A__v1\"\n\n tree = {\n \"_schema_version\": \"3.0\",\n \"agent\": {\"api_name\": \"A\", \"version\": \"v1\"},\n \"root\": {\"kind\": \"BOT_DEFINITION\", \"api_name\": \"A\",\n \"children\": []},\n \"node_count\": 1, \"depth\": 0,\n \"_kind_counts\": {\"BOT_DEFINITION\": 1},\n \"_pending_fetches\": {k: [] for k in (\n \"FLOW\", \"APEX\", \"PROMPT_TEMPLATE\", \"STANDARD_ACTION\",\n )},\n \"_unresolved\": [],\n }\n # First run — populate data_dir.\n main._run_finalize(\n data_dir, cache_dir, dict(tree), work_dir,\n agent_api_name=\"A\", agent_version=\"v1\", planner_name=\"A\",\n )\n # Simulate a co-tenant dropping content alongside.\n sibling_dir = data_dir / \"sibling-payload\"\n sibling_dir.mkdir()\n (sibling_dir / \"payload.json\").write_text(\"cotenant-data\")\n\n # Second run — the original bug: this would wipe the sibling dir.\n main._run_finalize(\n data_dir, cache_dir, dict(tree), work_dir,\n agent_api_name=\"A\", agent_version=\"v1\", planner_name=\"A\",\n )\n\n # Sibling dir and its contents must survive.\n self.assertTrue(sibling_dir.is_dir())\n self.assertTrue((sibling_dir / \"payload.json\").is_file())\n self.assertEqual(\n (sibling_dir / \"payload.json\").read_text(),\n \"cotenant-data\",\n )\n # Architecture's own tree is still present (re-written).\n self.assertTrue((data_dir / \"A_v1_metadata_tree.json\").is_file())\n\n\n# ---------------------------------------------------------------------------\n# Bug 1 fix (2026-05-05): GenAiPromptTemplate is NOT SOQL-queryable. Id →\n# DeveloperName resolution runs through `sf org list metadata` (Metadata\n# API) via the new `list_prompt_template_metadata` helper.\n# ---------------------------------------------------------------------------\n\n\nclass ListPromptTemplateMetadataTests(unittest.TestCase):\n \"\"\"Unit tests for `metadata_listing.list_prompt_template_metadata` —\n thin wrapper over `run_sf(\"list_metadata_genaiprompttemplate\", ...)`.\n\n The shape we care about is produced by the sf CLI:\n {\"status\": 0, \"result\": [{\"id\": \"0hfUv...\", \"fullName\": \"Foo\", ...}]}\n Defensive returns on malformed result (None / wrong type) keep the\n failure mode quiet — callers already tolerate empty lists.\n \"\"\"\n\n def test_happy_path_returns_result_rows(self):\n import metadata_listing # type: ignore\n payload = {\n \"status\": 0,\n \"result\": [\n {\"id\": \"0hfUv0000021mCjIAI\", \"fullName\": \"My_Prompt\",\n \"type\": \"GenAiPromptTemplate\"},\n {\"id\": \"0hfUv0000021mOtherAAA\", \"fullName\": \"Other_Prompt\",\n \"type\": \"GenAiPromptTemplate\"},\n ],\n }\n with mock.patch.object(metadata_listing, \"run_sf\", return_value=payload):\n rows = metadata_listing.list_prompt_template_metadata(\"test-org\")\n self.assertEqual(rows, payload[\"result\"])\n\n def test_empty_result_returns_empty_list(self):\n import metadata_listing # type: ignore\n with mock.patch.object(\n metadata_listing, \"run_sf\",\n return_value={\"status\": 0, \"result\": []},\n ):\n rows = metadata_listing.list_prompt_template_metadata(\"test-org\")\n self.assertEqual(rows, [])\n\n def test_malformed_result_returns_empty_list(self):\n \"\"\"Defensive: if sf CLI's `result` key is not a list (e.g. None\n on some error paths that still exit 0), we degrade to [].\"\"\"\n import metadata_listing # type: ignore\n with mock.patch.object(\n metadata_listing, \"run_sf\",\n return_value={\"status\": 0, \"result\": None},\n ):\n rows = metadata_listing.list_prompt_template_metadata(\"test-org\")\n self.assertEqual(rows, [])\n\n\nclass FetchWaveBPromptTemplateMetadataWiringTests(unittest.TestCase):\n \"\"\"Bug 1 fix (2026-05-05): `_fetch_wave_b_by_names` invokes\n `list_prompt_template_metadata` (Metadata API listing), then filters +\n reshapes to the {Id, DeveloperName} shape the downstream pipeline\n expects. Verify the filter + reshape contract — only the requested\n Ids come back, keyed as the pipeline's existing prompt_template_id_rows\n shape.\n \"\"\"\n\n def test_listmetadata_rows_filtered_and_reshaped(self):\n with mock.patch.object(\n main, \"list_prompt_template_metadata\",\n return_value=[\n {\"id\": \"0hfAAA\", \"fullName\": \"TplA\",\n \"type\": \"GenAiPromptTemplate\"},\n {\"id\": \"0hfBBB\", \"fullName\": \"TplB\",\n \"type\": \"GenAiPromptTemplate\"},\n ],\n ):\n with mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=[]), \\\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=[]), \\\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\", return_value=[]), \\\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=[]):\n out = main._fetch_wave_b_by_names(\n apex_names=[],\n apex_ids=[],\n flow_names=[],\n flow_ids=[],\n prompt_template_ids=[\"0hfAAA\"],\n creds_provider=lambda: (\"url\", \"tok\"),\n refresh_fn=lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n org_alias=\"test-org\",\n parallelism=2,\n )\n # Only the requested Id survives (0hfAAA); 0hfBBB is filtered out.\n # The shape matches the old SOQL contract: {Id, DeveloperName}.\n self.assertEqual(\n out[\"prompt_template_id_rows\"],\n [{\"Id\": \"0hfAAA\", \"DeveloperName\": \"TplA\"}],\n )\n\n def test_listmetadata_failure_non_fatal(self):\n \"\"\"SfCliError from the Metadata listing is caught and surfaced via\n the unresolved channel; the run continues with an empty rowset.\"\"\"\n from sf_cli import SfCliError # type: ignore\n with mock.patch.object(\n main, \"list_prompt_template_metadata\",\n side_effect=SfCliError(\"metadata-listing-exploded\"),\n ):\n with mock.patch.object(main, \"fetch_apex_bodies_by_names\", return_value=[]), \\\n mock.patch.object(main, \"fetch_apex_bodies_by_ids\", return_value=[]), \\\n mock.patch.object(main, \"fetch_flow_definition_ids_by_names\", return_value=[]), \\\n mock.patch.object(main, \"fetch_flow_definition_by_ids\", return_value=[]):\n out = main._fetch_wave_b_by_names(\n apex_names=[],\n apex_ids=[],\n flow_names=[],\n flow_ids=[],\n prompt_template_ids=[\"0hfAAA\"],\n creds_provider=lambda: (\"url\", \"tok\"),\n refresh_fn=lambda: (\"url\", \"tok\"),\n api_version=\"v60.0\",\n org_alias=\"test-org\",\n parallelism=2,\n )\n self.assertEqual(out[\"prompt_template_id_rows\"], [])\n reasons = [u.get(\"reason\") or \"\" for u in out[\"unresolved\"]]\n self.assertTrue(\n any(\"prompt-template-listmetadata-failed\" in r for r in reasons),\n f\"expected listmetadata-failed in unresolved; got {reasons}\",\n )\n\n\nclass RetrievePromptTemplatesTests(unittest.TestCase):\n \"\"\"Gap C (2026-05-05): unit tests for\n `metadata_listing.retrieve_prompt_templates` — the sf CLI wrapper\n that retrieves GenAiPromptTemplate bodies via\n `sf project retrieve start --metadata GenAiPromptTemplate:...`.\n\n The helper:\n 1. Short-circuits on empty input (no sf call).\n 2. Parses `unpackaged.zip` from the retrieve dir via stdlib\n `zipfile` + `xml.etree.ElementTree`.\n 3. Extracts developerName/masterLabel/activeVersionIdentifier/\n templateVersions[*].content/inputs[*].\n 4. Returns `{}` on SfCliError (non-fatal — main.py's call site\n logs an `_unresolved[]` entry).\n 5. Skips files whose XML fails to parse; sibling templates still\n surface.\n \"\"\"\n\n def _run_retrieve_writes_zip(self, files: dict):\n \"\"\"Build a mock `_run_retrieve` side_effect that writes an\n unpackaged.zip into the retrieve dir as the real CLI would.\n\n `files = {inner_path: xml_bytes}`. `retrieve_prompt_templates`\n wipes the retrieve dir before invoking the subprocess, so the zip\n MUST be created by the mock (the retrieve dir is always the\n `--target-metadata-dir` argv element).\"\"\"\n import zipfile\n\n def _side_effect(argv, timeout):\n target_dir = Path(argv[argv.index(\"--target-metadata-dir\") + 1])\n target_dir.mkdir(parents=True, exist_ok=True)\n zip_path = target_dir / \"unpackaged.zip\"\n with zipfile.ZipFile(zip_path, \"w\") as zf:\n for inner, xml_bytes in files.items():\n zf.writestr(inner, xml_bytes)\n return subprocess.CompletedProcess(\n argv, returncode=0, stdout='{\"status\":0,\"result\":{}}', stderr=\"\",\n )\n\n return _side_effect\n\n def test_empty_input_short_circuits_without_sf_call(self):\n import metadata_listing # type: ignore\n with mock.patch.object(metadata_listing, \"_run_retrieve\") as mrun, \\\n tempfile.TemporaryDirectory() as d:\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [], Path(d),\n )\n self.assertEqual(result, {})\n mrun.assert_not_called()\n\n def test_happy_path_single_template(self):\n import metadata_listing # type: ignore\n xml = (\n b'\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>'\n b'\u003cGenAiPromptTemplate xmlns=\"http://soap.sforce.com/2006/04/metadata\">'\n b'\u003cdeveloperName>My_Prompt\u003c/developerName>'\n b'\u003cmasterLabel>My Prompt\u003c/masterLabel>'\n b'\u003cactiveVersionIdentifier>v1\u003c/activeVersionIdentifier>'\n b'\u003ctemplateVersions>'\n b'\u003cversionIdentifier>v1\u003c/versionIdentifier>'\n b'\u003ccontent># ROLE\\nHello {{$Input:Query}}\u003c/content>'\n b'\u003cinputs>\u003capiName>Query\u003c/apiName>\u003cdataType>String\u003c/dataType>\u003c/inputs>'\n b'\u003c/templateVersions>'\n b'\u003c/GenAiPromptTemplate>'\n )\n with tempfile.TemporaryDirectory() as d:\n tmp = Path(d)\n side_effect = self._run_retrieve_writes_zip({\n \"unpackaged/genAiPromptTemplates/My_Prompt.genAiPromptTemplate\": xml,\n \"unpackaged/package.xml\": b\"\u003cPackage/>\",\n })\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", side_effect=side_effect,\n ):\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"My_Prompt\"], tmp,\n )\n self.assertIn(\"My_Prompt\", result)\n body = result[\"My_Prompt\"]\n self.assertEqual(body[\"developerName\"], \"My_Prompt\")\n self.assertEqual(body[\"masterLabel\"], \"My Prompt\")\n self.assertEqual(body[\"activeVersionIdentifier\"], \"v1\")\n self.assertEqual(body[\"content\"], \"# ROLE\\nHello {{$Input:Query}}\")\n self.assertEqual(\n body[\"inputs\"], [{\"name\": \"Query\", \"dataType\": \"String\"}],\n )\n\n def test_sf_cli_error_returns_empty_dict(self):\n \"\"\"Non-zero exit without auth-pattern stderr is swallowed as\n non-fatal — caller gets an empty dict.\"\"\"\n import metadata_listing # type: ignore\n failure = subprocess.CompletedProcess(\n args=[], returncode=1, stdout=\"\", stderr=\"Error: retrieve blew up\",\n )\n with tempfile.TemporaryDirectory() as d:\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", return_value=failure,\n ):\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"Foo\"], Path(d),\n )\n self.assertEqual(result, {})\n\n def test_auth_required_reraises(self):\n \"\"\"Non-zero exit WITH auth-pattern stderr raises AuthRequired.\"\"\"\n import metadata_listing # type: ignore\n from sf_cli import AuthRequired # type: ignore\n failure = subprocess.CompletedProcess(\n args=[], returncode=1, stdout=\"\",\n stderr=\"Error: NoOrgAuthenticationError — login required\",\n )\n with tempfile.TemporaryDirectory() as d:\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", return_value=failure,\n ):\n with self.assertRaises(AuthRequired):\n metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"Foo\"], Path(d),\n )\n\n def test_missing_zip_returns_empty_dict(self):\n import metadata_listing # type: ignore\n # _run_retrieve succeeds but leaves no unpackaged.zip on disk.\n success_no_zip = subprocess.CompletedProcess(\n args=[], returncode=0, stdout='{\"status\":0,\"result\":{}}', stderr=\"\",\n )\n with tempfile.TemporaryDirectory() as d:\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", return_value=success_no_zip,\n ):\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"Foo\"], Path(d),\n )\n self.assertEqual(result, {})\n\n def test_multi_version_picks_active(self):\n import metadata_listing # type: ignore\n xml = (\n b'\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>'\n b'\u003cGenAiPromptTemplate xmlns=\"http://soap.sforce.com/2006/04/metadata\">'\n b'\u003cdeveloperName>Versioned\u003c/developerName>'\n b'\u003cactiveVersionIdentifier>v2\u003c/activeVersionIdentifier>'\n b'\u003ctemplateVersions>'\n b'\u003cversionIdentifier>v1\u003c/versionIdentifier>'\n b'\u003ccontent>OLD\u003c/content>'\n b'\u003c/templateVersions>'\n b'\u003ctemplateVersions>'\n b'\u003cversionIdentifier>v2\u003c/versionIdentifier>'\n b'\u003ccontent>ACTIVE\u003c/content>'\n b'\u003c/templateVersions>'\n b'\u003ctemplateVersions>'\n b'\u003cversionIdentifier>v3\u003c/versionIdentifier>'\n b'\u003ccontent>DRAFT\u003c/content>'\n b'\u003c/templateVersions>'\n b'\u003c/GenAiPromptTemplate>'\n )\n with tempfile.TemporaryDirectory() as d:\n tmp = Path(d)\n side_effect = self._run_retrieve_writes_zip({\n \"unpackaged/genAiPromptTemplates/Versioned.genAiPromptTemplate\": xml,\n })\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", side_effect=side_effect,\n ):\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"Versioned\"], tmp,\n )\n self.assertEqual(result[\"Versioned\"][\"content\"], \"ACTIVE\")\n\n def test_template_with_no_inputs(self):\n import metadata_listing # type: ignore\n xml = (\n b'\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>'\n b'\u003cGenAiPromptTemplate xmlns=\"http://soap.sforce.com/2006/04/metadata\">'\n b'\u003cdeveloperName>NoInputs\u003c/developerName>'\n b'\u003cactiveVersionIdentifier>v1\u003c/activeVersionIdentifier>'\n b'\u003ctemplateVersions>'\n b'\u003cversionIdentifier>v1\u003c/versionIdentifier>'\n b'\u003ccontent>Prompt body\u003c/content>'\n b'\u003c/templateVersions>'\n b'\u003c/GenAiPromptTemplate>'\n )\n with tempfile.TemporaryDirectory() as d:\n tmp = Path(d)\n side_effect = self._run_retrieve_writes_zip({\n \"unpackaged/genAiPromptTemplates/NoInputs.genAiPromptTemplate\": xml,\n })\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", side_effect=side_effect,\n ):\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"NoInputs\"], tmp,\n )\n self.assertEqual(result[\"NoInputs\"][\"inputs\"], [])\n\n def test_xml_parse_failure_skips_file_continues(self):\n \"\"\"Malformed XML on one template must not prevent sibling\n templates from surfacing.\"\"\"\n import metadata_listing # type: ignore\n good_xml = (\n b'\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>'\n b'\u003cGenAiPromptTemplate xmlns=\"http://soap.sforce.com/2006/04/metadata\">'\n b'\u003cdeveloperName>GoodTpl\u003c/developerName>'\n b'\u003cactiveVersionIdentifier>v1\u003c/activeVersionIdentifier>'\n b'\u003ctemplateVersions>'\n b'\u003cversionIdentifier>v1\u003c/versionIdentifier>'\n b'\u003ccontent>Fine\u003c/content>'\n b'\u003c/templateVersions>'\n b'\u003c/GenAiPromptTemplate>'\n )\n broken_xml = b\"\u003cnot valid xml\"\n with tempfile.TemporaryDirectory() as d:\n tmp = Path(d)\n side_effect = self._run_retrieve_writes_zip({\n \"unpackaged/genAiPromptTemplates/GoodTpl.genAiPromptTemplate\": good_xml,\n \"unpackaged/genAiPromptTemplates/Broken.genAiPromptTemplate\": broken_xml,\n })\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", side_effect=side_effect,\n ):\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"GoodTpl\", \"Broken\"], tmp,\n )\n self.assertIn(\"GoodTpl\", result)\n self.assertNotIn(\"Broken\", result)\n\n def test_stale_retrieve_dir_contents_wiped(self):\n \"\"\"A prior invocation's `unpackaged.zip` must not be trusted if\n this run's sf call fails before writing a new one.\"\"\"\n import metadata_listing # type: ignore\n failure = subprocess.CompletedProcess(\n args=[], returncode=1, stdout=\"\", stderr=\"Error: boom\",\n )\n with tempfile.TemporaryDirectory() as d:\n tmp = Path(d)\n # Plant a stale zip pretending a prior run left it.\n stale = tmp / \"prompt_template_retrieve\"\n stale.mkdir(parents=True, exist_ok=True)\n (stale / \"unpackaged.zip\").write_bytes(b\"stale\")\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", return_value=failure,\n ):\n result = metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"Foo\"], tmp,\n )\n self.assertEqual(result, {})\n # Stale file should have been nuked before the sf call.\n self.assertFalse((stale / \"unpackaged.zip\").exists())\n\n def test_retrieve_builds_one_metadata_flag_per_name(self):\n \"\"\"Live proof (2026-05-05): `sf project retrieve start` treats a\n comma-joined `--metadata TypeA:A,TypeA:B` as ONE malformed member\n name and silently produces a package-xml-only zip. The fix is to\n repeat `--metadata` once per template. Lock that shape in.\"\"\"\n import metadata_listing # type: ignore\n captured_argv: list[list[str]] = []\n\n def _capture(argv, timeout):\n captured_argv.append(list(argv))\n return subprocess.CompletedProcess(\n argv, returncode=0,\n stdout='{\"status\":0,\"result\":{}}', stderr=\"\",\n )\n\n with tempfile.TemporaryDirectory() as d:\n with mock.patch.object(\n metadata_listing, \"_run_retrieve\", side_effect=_capture,\n ):\n metadata_listing.retrieve_prompt_templates(\n \"org-alias\", [\"A\", \"B\", \"C\"], Path(d),\n )\n\n self.assertEqual(len(captured_argv), 1)\n argv = captured_argv[0]\n self.assertEqual(argv.count(\"--metadata\"), 3)\n # Each `--metadata` flag MUST be followed by a single\n # `GenAiPromptTemplate:\u003cname>` — never a comma-joined string.\n for name in (\"A\", \"B\", \"C\"):\n spec = f\"GenAiPromptTemplate:{name}\"\n idx = argv.index(spec)\n self.assertEqual(argv[idx - 1], \"--metadata\")\n self.assertNotIn(\",\", spec)\n\n\nclass CollectPromptTemplateNamesTests(unittest.TestCase):\n \"\"\"Gap C: `_collect_prompt_template_names` walks topics + plannerActions\n and returns the set of DeveloperNames that should be passed to\n `retrieve_prompt_templates`. Defensive against residual 0hf-Ids\n (normalization missed) so the retrieve CLI doesn't get a malformed\n spec.\"\"\"\n\n def test_collects_topic_action_names(self):\n bundle = {\n \"topics\": [{\n \"actions\": [\n {\"invocationTarget\": \"Foo_Tpl\",\n \"invocationTargetType\": \"GenAiPromptTemplate\"},\n {\"invocationTarget\": \"Bar_Tpl\",\n \"invocationTargetType\": \"generatePromptResponse\"},\n ],\n }],\n \"plannerActions\": [],\n }\n names = main._collect_prompt_template_names(bundle)\n self.assertEqual(names, {\"Foo_Tpl\", \"Bar_Tpl\"})\n\n def test_collects_planner_action_names(self):\n bundle = {\n \"topics\": [],\n \"plannerActions\": [\n {\"invocationTarget\": \"Planner_Tpl\",\n \"invocationTargetType\": \"GenAiPromptTemplate\"},\n ],\n }\n names = main._collect_prompt_template_names(bundle)\n self.assertEqual(names, {\"Planner_Tpl\"})\n\n def test_skips_non_prompt_ttype(self):\n bundle = {\n \"topics\": [{\n \"actions\": [\n {\"invocationTarget\": \"MyFlow\",\n \"invocationTargetType\": \"flow\"},\n {\"invocationTarget\": \"MyApex\",\n \"invocationTargetType\": \"apex\"},\n ],\n }],\n \"plannerActions\": [],\n }\n names = main._collect_prompt_template_names(bundle)\n self.assertEqual(names, set())\n\n def test_skips_residual_0hf_id_targets(self):\n \"\"\"If normalization missed an Id (template not in org\n list-metadata), don't send a malformed spec to retrieve.\"\"\"\n bundle = {\n \"topics\": [{\n \"actions\": [\n {\"invocationTarget\": \"0hfUv0000021mCjIAI\",\n \"invocationTargetType\": \"GenAiPromptTemplate\"},\n {\"invocationTarget\": \"Clean_Tpl\",\n \"invocationTargetType\": \"GenAiPromptTemplate\"},\n ],\n }],\n \"plannerActions\": [],\n }\n names = main._collect_prompt_template_names(bundle)\n self.assertEqual(names, {\"Clean_Tpl\"})\n\n\nclass PromptTemplateBodyAttachmentTests(unittest.TestCase):\n \"\"\"Gap C: `_stamp_prompt_template_bodies` attaches retrieved body\n fields onto matching PROMPT_TEMPLATE leaves. Unmatched leaves get\n `_body_available = False` so the renderer can distinguish a failed\n retrieve from a successfully-empty body.\"\"\"\n\n def test_stamps_body_onto_matching_leaf(self):\n tree = {\n \"root\": {\n \"kind\": \"BOT_DEFINITION\",\n \"children\": [{\n \"kind\": \"TOPIC\", \"api_name\": \"T\",\n \"children\": [{\n \"kind\": \"GEN_AI_FUNCTION\", \"api_name\": \"F\",\n \"children\": [{\n \"kind\": \"PROMPT_TEMPLATE\", \"api_name\": \"Tpl\",\n }],\n }],\n }],\n },\n }\n bodies = {\n \"Tpl\": {\n \"developerName\": \"Tpl\",\n \"masterLabel\": \"Template One\",\n \"activeVersionIdentifier\": \"v1\",\n \"content\": \"Prompt body\",\n \"inputs\": [{\"name\": \"Q\", \"dataType\": \"String\"}],\n },\n }\n main._stamp_prompt_template_bodies(tree[\"root\"], bodies)\n leaf = tree[\"root\"][\"children\"][0][\"children\"][0][\"children\"][0]\n self.assertEqual(leaf[\"master_label\"], \"Template One\")\n self.assertEqual(leaf[\"content\"], \"Prompt body\")\n self.assertEqual(leaf[\"inputs\"], [{\"name\": \"Q\", \"dataType\": \"String\"}])\n self.assertTrue(leaf[\"_body_available\"])\n\n def test_unmatched_leaf_gets_body_available_false(self):\n tree = {\n \"root\": {\n \"kind\": \"BOT_DEFINITION\",\n \"children\": [{\n \"kind\": \"PROMPT_TEMPLATE\", \"api_name\": \"Missing\",\n }],\n },\n }\n main._stamp_prompt_template_bodies(tree[\"root\"], {})\n self.assertFalse(tree[\"root\"][\"children\"][0][\"_body_available\"])\n self.assertNotIn(\"content\", tree[\"root\"][\"children\"][0])\n\n def test_does_not_touch_non_prompt_leaves(self):\n tree = {\n \"root\": {\n \"kind\": \"BOT_DEFINITION\",\n \"children\": [\n {\"kind\": \"APEX\", \"api_name\": \"Cls\"},\n {\"kind\": \"FLOW\", \"api_name\": \"Flw\"},\n ],\n },\n }\n main._stamp_prompt_template_bodies(\n tree[\"root\"], {\"Cls\": {\"content\": \"x\"}, \"Flw\": {\"content\": \"y\"}},\n )\n # Neither APEX nor FLOW leaves gain body fields.\n self.assertNotIn(\"content\", tree[\"root\"][\"children\"][0])\n self.assertNotIn(\"_body_available\", tree[\"root\"][\"children\"][0])\n self.assertNotIn(\"content\", tree[\"root\"][\"children\"][1])\n self.assertNotIn(\"_body_available\", tree[\"root\"][\"children\"][1])\n\n\nclass TreeChildOrderingTests(unittest.TestCase):\n \"\"\"Schema 3.1 (2026-05-05) pins deterministic child ordering at the\n tree's single source of truth (`finalize.sort_tree_in_place`) so\n downstream readers see a byte-stable order regardless of Builder\n reorder operations or SOQL result-set sequencing.\n\n Contract:\n - `BOT_DEFINITION.children`: TOPIC nodes first (alpha, case-insensitive);\n non-topic plannerActions follow as a distinct trailing group.\n - Each TOPIC's children: alpha by api_name, case-insensitive.\n - FLOW children untouched — flow-actionCall order is semantically\n the author's execution sequence.\n \"\"\"\n\n def _sort(self, root: dict) -> dict:\n from finalize import sort_tree_in_place\n sort_tree_in_place(root)\n return root\n\n def test_topics_sorted_alphabetical_case_insensitive(self):\n root = {\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": \"Bot\",\n \"children\": [\n {\"kind\": \"TOPIC\", \"api_name\": \"Zeta\", \"children\": []},\n {\"kind\": \"TOPIC\", \"api_name\": \"alpha\", \"children\": []},\n {\"kind\": \"TOPIC\", \"api_name\": \"Mike\", \"children\": []},\n ],\n }\n self._sort(root)\n names = [c[\"api_name\"] for c in root[\"children\"]]\n self.assertEqual(names, [\"alpha\", \"Mike\", \"Zeta\"])\n\n def test_topics_precede_planner_level_actions(self):\n \"\"\"Non-topic plannerAction children (e.g. a GEN_AI_FUNCTION hung\n directly off the planner, no parent TOPIC) MUST render after all\n TOPIC children, regardless of api_name ordering. The \"planner-\n level actions are a distinct trailing group\" convention is load-\n bearing for humans scanning the rendered tree.\"\"\"\n root = {\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": \"Bot\",\n \"children\": [\n # Non-topic node with an api_name that would sort FIRST\n # alphabetically — the tier rule must override alpha.\n {\"kind\": \"GEN_AI_FUNCTION\", \"api_name\": \"aaa_action\", \"children\": []},\n {\"kind\": \"TOPIC\", \"api_name\": \"Zeta\", \"children\": []},\n {\"kind\": \"TOPIC\", \"api_name\": \"Mike\", \"children\": []},\n ],\n }\n self._sort(root)\n kinds = [c[\"kind\"] for c in root[\"children\"]]\n self.assertEqual(kinds, [\"TOPIC\", \"TOPIC\", \"GEN_AI_FUNCTION\"])\n # Alpha within the TOPIC tier is preserved.\n self.assertEqual(root[\"children\"][0][\"api_name\"], \"Mike\")\n self.assertEqual(root[\"children\"][1][\"api_name\"], \"Zeta\")\n\n def test_topic_children_sorted_alphabetical(self):\n root = {\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": \"Bot\",\n \"children\": [\n {\n \"kind\": \"TOPIC\", \"api_name\": \"OnlyTopic\",\n \"children\": [\n {\"kind\": \"GEN_AI_FUNCTION\", \"api_name\": \"Zebra\"},\n {\"kind\": \"GEN_AI_FUNCTION\", \"api_name\": \"apple\"},\n {\"kind\": \"GEN_AI_FUNCTION\", \"api_name\": \"Mango\"},\n ],\n },\n ],\n }\n self._sort(root)\n kids = root[\"children\"][0][\"children\"]\n self.assertEqual(\n [c[\"api_name\"] for c in kids],\n [\"apple\", \"Mango\", \"Zebra\"],\n )\n\n def test_flow_children_order_preserved(self):\n \"\"\"FLOW actionCall order is the flow author's execution sequence.\n sort_tree_in_place does NOT descend into FLOW children.\"\"\"\n root = {\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": \"Bot\",\n \"children\": [\n {\n \"kind\": \"TOPIC\", \"api_name\": \"T\",\n \"children\": [\n {\n \"kind\": \"GEN_AI_FUNCTION\", \"api_name\": \"Fn\",\n \"children\": [\n {\n \"kind\": \"FLOW\", \"api_name\": \"ParentFlow\",\n \"children\": [\n {\"kind\": \"APEX\", \"api_name\": \"zzz\"},\n {\"kind\": \"APEX\", \"api_name\": \"aaa\"},\n ],\n },\n ],\n },\n ],\n },\n ],\n }\n self._sort(root)\n flow = root[\"children\"][0][\"children\"][0][\"children\"][0]\n self.assertEqual(\n [c[\"api_name\"] for c in flow[\"children\"]],\n [\"zzz\", \"aaa\"],\n )\n\n def test_empty_children_noop(self):\n root = {\"kind\": \"BOT_DEFINITION\", \"api_name\": \"Bot\", \"children\": []}\n self._sort(root)\n self.assertEqual(root[\"children\"], [])\n\n def test_missing_root_noop(self):\n \"\"\"Defensive — a degenerate tree shouldn't raise.\"\"\"\n from finalize import sort_tree_in_place\n sort_tree_in_place(None) # type: ignore[arg-type]\n sort_tree_in_place({})\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":148603,"content_sha256":"3b492c75115e3497bf510f783c28993c27fb646fcade9502fde7f8869ba3bfd0"},{"filename":"scripts/tests/test_parallel_retrieve.py","content":"\"\"\"Tests for parallel_retrieve.fetch_bodies_parallel uses as_completed\nwith (ok, result_or_exc) tuples — one failure MUST NOT abort the whole run.\n`executor.map()` would raise on first failure and silently cancel the rest;\nthe whole point of this module is to never do that.\n\"\"\"\nfrom __future__ import annotations\n\nimport threading\nimport unittest\nimport urllib.error\n\nfrom . import _bootstrap # noqa: F401\n\nimport parallel_retrieve # type: ignore\n\n\nclass AllSucceedTests(unittest.TestCase):\n def test_five_out_of_five_ok(self):\n tasks = [lambda i=i: i * 10 for i in range(5)]\n results = parallel_retrieve.fetch_bodies_parallel(tasks, max_workers=5)\n self.assertEqual(len(results), 5)\n for ok, _ in results:\n self.assertTrue(ok)\n # Order is preserved: results[i] corresponds to tasks[i].\n self.assertEqual([r for _, r in results], [0, 10, 20, 30, 40])\n\n\nclass OneFailureTests(unittest.TestCase):\n def test_four_ok_one_fail(self):\n def boom():\n raise RuntimeError(\"task-3-failed\")\n\n tasks = [\n lambda: \"a\",\n lambda: \"b\",\n lambda: \"c\",\n boom,\n lambda: \"e\",\n ]\n results = parallel_retrieve.fetch_bodies_parallel(tasks, max_workers=3)\n self.assertEqual(len(results), 5)\n oks = [r[0] for r in results]\n self.assertEqual(oks, [True, True, True, False, True])\n # The failure slot carries the RuntimeError object, not a string.\n self.assertIsInstance(results[3][1], RuntimeError)\n self.assertIn(\"task-3-failed\", str(results[3][1]))\n\n def test_function_does_not_raise_on_failure(self):\n \"\"\"Top-level contract: one bad task does NOT abort the run.\"\"\"\n tasks = [lambda: 1, lambda: (_ for _ in ()).throw(ValueError(\"x\"))]\n # Must not raise.\n results = parallel_retrieve.fetch_bodies_parallel(tasks, max_workers=2)\n self.assertEqual(len(results), 2)\n\n\nclass AllFailTests(unittest.TestCase):\n def test_all_failures_function_still_returns(self):\n def fail(n):\n raise RuntimeError(f\"task-{n}\")\n\n tasks = [lambda n=i: fail(n) for i in range(5)]\n results = parallel_retrieve.fetch_bodies_parallel(tasks, max_workers=5)\n self.assertEqual(len(results), 5)\n for ok, exc in results:\n self.assertFalse(ok)\n self.assertIsInstance(exc, RuntimeError)\n\n\nclass ExceptionIdentityPreservedTests(unittest.TestCase):\n \"\"\"Boundary: we must return the SAME exception object so\n callers can run it through `rest_client.redact_error` at log time.\n Auto-stringifying here would strip structured attrs (HTTPError.code,\n .headers) and potentially leak tokens before redaction runs.\"\"\"\n\n def test_httperror_object_identity_preserved(self):\n # HTTPError with a body containing a bearer token — caller is\n # responsible for redacting at log time; we must not stringify.\n sentinel_body = b'{\"access_token\":\"eyJtok.secret\"}'\n exc = urllib.error.HTTPError(\n url=\"https://example.com/x\",\n code=429,\n msg=\"Too Many Requests\",\n hdrs=None, # type: ignore[arg-type]\n fp=None,\n )\n # Stash the body where a caller could read it later.\n exc.body_bytes = sentinel_body # type: ignore[attr-defined]\n\n def raises():\n raise exc\n\n results = parallel_retrieve.fetch_bodies_parallel([raises], max_workers=1)\n self.assertEqual(len(results), 1)\n ok, returned_exc = results[0]\n self.assertFalse(ok)\n # Identity check — the SAME object came back.\n self.assertIs(returned_exc, exc)\n # Structured attrs are intact for downstream triage.\n self.assertEqual(returned_exc.code, 429)\n self.assertEqual(returned_exc.body_bytes, sentinel_body)\n\n\nclass MaxWorkersOneSerializesTests(unittest.TestCase):\n def test_max_workers_one_ordering_deterministic(self):\n \"\"\"With max_workers=1, tasks run one at a time — ordering is fully\n deterministic (and matches input order since results are indexed).\n \"\"\"\n order_seen: list[int] = []\n lock = threading.Lock()\n\n def make_task(n):\n def task():\n with lock:\n order_seen.append(n)\n return n\n return task\n\n tasks = [make_task(i) for i in range(10)]\n results = parallel_retrieve.fetch_bodies_parallel(tasks, max_workers=1)\n self.assertEqual([r for _, r in results], list(range(10)))\n # Serialized execution: tasks ran in submission order.\n self.assertEqual(order_seen, list(range(10)))\n\n\nclass EmptyTasksTests(unittest.TestCase):\n def test_empty_list_returns_empty_list(self):\n self.assertEqual(parallel_retrieve.fetch_bodies_parallel([]), [])\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4932,"content_sha256":"8766716c994aa6ec1fadeb7d0cfcacde2dedd997643f3ec2b24d28833221ba96"},{"filename":"scripts/tests/test_parse_bundle.py","content":"\"\"\"Tests for ``parse_bundle`` — Bot.bot + GenAiPlannerBundle parsing.\n\nCovers the four public functions:\n\n- ``classify_generation`` pure str → str\n- ``extract_planner_name`` reads bots/*.bot from a tmp WORK_DIR\n- ``extract_bundle`` reads genAiPlannerBundles/*.genAiPlannerBundle\n- ``atomic_write`` tmp-file + os.replace semantics\n\nAll filesystem fixtures are constructed inline under ``tmp_path`` — no\nexternal fixture files needed. XML mirrors the real Salesforce metadata\nshape (sf: namespace) so the parser exercises its real code path.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport unittest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport parse_bundle # type: ignore\n\n\nSF_NS = \"http://soap.sforce.com/2006/04/metadata\"\n\n\ndef _bot_xml(version: str, planner_name: str | None = \"MyPlanner\") -> str:\n \"\"\"Minimal Bot.bot XML with one botVersions block.\n\n If ``planner_name`` is None, omit the \u003cconversationDefinitionPlanners>\n block — caller can use this to exercise the empty-planner branch.\n \"\"\"\n cdp_block = (\n f\"\"\" \u003cconversationDefinitionPlanners>\n \u003cgenAiPlannerName>{planner_name}\u003c/genAiPlannerName>\n \u003c/conversationDefinitionPlanners>\"\"\"\n if planner_name is not None\n else \"\"\n )\n return f\"\"\"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003cBot xmlns=\"{SF_NS}\">\n \u003cbotVersions>\n \u003cfullName>{version}\u003c/fullName>\n{cdp_block}\n \u003c/botVersions>\n\u003c/Bot>\n\"\"\"\n\n\ndef _bundle_xml(\n *,\n planner_type: str = \"Atlas__Reasoning\",\n description: str = \"demo\",\n master_label: str = \"Demo\",\n topics: list[dict] | None = None,\n planner_actions: list[dict] | None = None,\n) -> str:\n \"\"\"Build a genAiPlannerBundle XML body.\n\n Each topic dict supports keys ``name``, ``actions`` (list of dicts with\n keys ``name``, ``invocationTarget``, ``invocationTargetType``).\n \"\"\"\n topics = topics or []\n planner_actions = planner_actions or []\n\n def _topic_xml(t: dict) -> str:\n actions = \"\".join(\n f\"\"\" \u003clocalActions>\n \u003cdeveloperName>{a['name']}\u003c/developerName>\n \u003cinvocationTarget>{a.get('invocationTarget', '')}\u003c/invocationTarget>\n \u003cinvocationTargetType>{a.get('invocationTargetType', '')}\u003c/invocationTargetType>\n \u003c/localActions>\n\"\"\"\n for a in t.get(\"actions\", [])\n )\n return f\"\"\" \u003clocalTopics>\n \u003cdeveloperName>{t['name']}\u003c/developerName>\n \u003cmasterLabel>{t.get('masterLabel', '')}\u003c/masterLabel>\n \u003ccanEscalate>{str(t.get('canEscalate', False)).lower()}\u003c/canEscalate>\n{actions} \u003c/localTopics>\n\"\"\"\n\n def _planner_action_xml(a: dict) -> str:\n return f\"\"\" \u003cplannerActions>\n \u003cdeveloperName>{a['name']}\u003c/developerName>\n \u003cinvocationTarget>{a.get('invocationTarget', '')}\u003c/invocationTarget>\n \u003cinvocationTargetType>{a.get('invocationTargetType', '')}\u003c/invocationTargetType>\n \u003c/plannerActions>\n\"\"\"\n\n return f\"\"\"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003cGenAiPlannerBundle xmlns=\"{SF_NS}\">\n \u003cplannerType>{planner_type}\u003c/plannerType>\n \u003cdescription>{description}\u003c/description>\n \u003cmasterLabel>{master_label}\u003c/masterLabel>\n{''.join(_topic_xml(t) for t in topics)}{''.join(_planner_action_xml(a) for a in planner_actions)}\u003c/GenAiPlannerBundle>\n\"\"\"\n\n\n# -----------------------------------------------------------------------------\n# classify_generation — pure str → str\n# -----------------------------------------------------------------------------\n\n\nclass ClassifyGenerationTests(unittest.TestCase):\n\n def test_aicopilot_prefix_classifies_as_classic(self):\n self.assertEqual(parse_bundle.classify_generation(\"AiCopilot__Foo\"), \"classic\")\n\n def test_atlas_prefix_classifies_as_nga(self):\n self.assertEqual(parse_bundle.classify_generation(\"Atlas__Reasoning\"), \"nga\")\n\n def test_unknown_prefix_classifies_as_unknown(self):\n self.assertEqual(parse_bundle.classify_generation(\"SomethingElse\"), \"unknown\")\n\n def test_empty_string_classifies_as_unknown(self):\n self.assertEqual(parse_bundle.classify_generation(\"\"), \"unknown\")\n\n def test_none_classifies_as_unknown(self):\n self.assertEqual(parse_bundle.classify_generation(None), \"unknown\") # type: ignore[arg-type]\n\n\n# -----------------------------------------------------------------------------\n# extract_planner_name — reads bots/*.bot under WORK_DIR\n# -----------------------------------------------------------------------------\n\n\nclass ExtractPlannerNameTests(unittest.TestCase):\n\n def _setup_bot(self, tmp: Path, version: str, planner_name: str | None = \"MyPlanner\") -> Path:\n bots_dir = tmp / \"sf_meta\" / \"wave1_bot\" / \"unpackaged\" / \"bots\"\n bots_dir.mkdir(parents=True)\n bot_file = bots_dir / \"MyAgent.bot\"\n bot_file.write_text(_bot_xml(version, planner_name))\n return tmp\n\n def test_returns_planner_name_when_botversion_matches(self):\n with TemporaryDirectory() as t:\n tmp = self._setup_bot(Path(t), \"v1\", \"MyPlanner\")\n self.assertEqual(parse_bundle.extract_planner_name(tmp, \"v1\"), \"MyPlanner\")\n\n def test_returns_empty_when_botversion_does_not_match(self):\n with TemporaryDirectory() as t:\n tmp = self._setup_bot(Path(t), \"v1\", \"MyPlanner\")\n self.assertEqual(parse_bundle.extract_planner_name(tmp, \"v99\"), \"\")\n\n def test_returns_empty_when_no_planner_block(self):\n # Bot has the right version but no \u003cconversationDefinitionPlanners>\n with TemporaryDirectory() as t:\n tmp = self._setup_bot(Path(t), \"v1\", planner_name=None)\n self.assertEqual(parse_bundle.extract_planner_name(tmp, \"v1\"), \"\")\n\n def test_returns_empty_when_bots_dir_missing(self):\n with TemporaryDirectory() as t:\n tmp = Path(t) # no sf_meta/wave1_bot/...\n self.assertEqual(parse_bundle.extract_planner_name(tmp, \"v1\"), \"\")\n\n def test_returns_empty_when_xml_is_malformed(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n bots_dir = tmp / \"sf_meta\" / \"wave1_bot\" / \"unpackaged\" / \"bots\"\n bots_dir.mkdir(parents=True)\n (bots_dir / \"Broken.bot\").write_text(\"\u003c\u003c\u003cnot xml>>>\")\n self.assertEqual(parse_bundle.extract_planner_name(tmp, \"v1\"), \"\")\n\n def test_falls_back_to_plannerName_then_fullName(self):\n # Build a bot with sf:plannerName instead of sf:genAiPlannerName.\n with TemporaryDirectory() as t:\n tmp = Path(t)\n bots_dir = tmp / \"sf_meta\" / \"wave1_bot\" / \"unpackaged\" / \"bots\"\n bots_dir.mkdir(parents=True)\n (bots_dir / \"Fallback.bot\").write_text(f\"\"\"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003cBot xmlns=\"{SF_NS}\">\n \u003cbotVersions>\n \u003cfullName>v1\u003c/fullName>\n \u003cconversationDefinitionPlanners>\n \u003cplannerName>FallbackPlanner\u003c/plannerName>\n \u003c/conversationDefinitionPlanners>\n \u003c/botVersions>\n\u003c/Bot>\n\"\"\")\n self.assertEqual(\n parse_bundle.extract_planner_name(tmp, \"v1\"),\n \"FallbackPlanner\",\n )\n\n\n# -----------------------------------------------------------------------------\n# extract_bundle — reads genAiPlannerBundles/*.genAiPlannerBundle\n# -----------------------------------------------------------------------------\n\n\nclass ExtractBundleTests(unittest.TestCase):\n\n def _setup_bundle(self, tmp: Path, body: str, dirname: str = \"MyPlanner\") -> None:\n bundle_dir = (\n tmp / \"sf_meta\" / \"wave1_bundle\" / \"unpackaged\" / \"genAiPlannerBundles\" / dirname\n )\n bundle_dir.mkdir(parents=True)\n (bundle_dir / \"MyPlanner.genAiPlannerBundle\").write_text(body)\n\n def test_returns_skeleton_when_planner_dir_missing(self):\n with TemporaryDirectory() as t:\n out = parse_bundle.extract_bundle(Path(t), \"MyPlanner\")\n self.assertEqual(out[\"topics\"], [])\n self.assertEqual(out[\"plannerActions\"], [])\n self.assertEqual(out[\"plannerType\"], None)\n self.assertEqual(out[\"plannerName\"], \"MyPlanner\")\n self.assertEqual(out[\"generation\"], \"unknown\")\n\n def test_extracts_planner_type_description_and_master_label(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n self._setup_bundle(tmp, _bundle_xml(\n planner_type=\"Atlas__Reasoning\",\n description=\"My agent\",\n master_label=\"My Agent\",\n ))\n out = parse_bundle.extract_bundle(tmp, \"MyPlanner\")\n self.assertEqual(out[\"plannerType\"], \"Atlas__Reasoning\")\n self.assertEqual(out[\"description\"], \"My agent\")\n self.assertEqual(out[\"masterLabel\"], \"My Agent\")\n self.assertEqual(out[\"generation\"], \"nga\")\n\n def test_extracts_topics_with_localActions(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n self._setup_bundle(tmp, _bundle_xml(topics=[{\n \"name\": \"Greetings\",\n \"masterLabel\": \"Greetings\",\n \"actions\": [\n {\"name\": \"Hello\", \"invocationTarget\": \"say_hello\",\n \"invocationTargetType\": \"apex\"},\n {\"name\": \"Bye\", \"invocationTarget\": \"say_bye\",\n \"invocationTargetType\": \"apex\"},\n ],\n }]))\n out = parse_bundle.extract_bundle(tmp, \"MyPlanner\")\n self.assertEqual(len(out[\"topics\"]), 1)\n self.assertEqual(out[\"topics\"][0][\"name\"], \"Greetings\")\n self.assertEqual(len(out[\"topics\"][0][\"actions\"]), 2)\n self.assertEqual(out[\"topics\"][0][\"actions\"][0][\"name\"], \"Hello\")\n self.assertEqual(out[\"topics\"][0][\"actions\"][0][\"invocationTargetType\"], \"apex\")\n\n def test_extracts_plannerActions_classic_shape(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n self._setup_bundle(tmp, _bundle_xml(\n planner_type=\"AiCopilot__Foo\",\n planner_actions=[\n {\"name\": \"Search\", \"invocationTarget\": \"search\",\n \"invocationTargetType\": \"flow\"},\n ],\n ))\n out = parse_bundle.extract_bundle(tmp, \"MyPlanner\")\n self.assertEqual(out[\"generation\"], \"classic\")\n self.assertEqual(len(out[\"plannerActions\"]), 1)\n self.assertEqual(out[\"plannerActions\"][0][\"name\"], \"Search\")\n\n def test_skips_topic_without_developerName(self):\n # Build XML where one topic has no \u003cdeveloperName> — should be dropped.\n with TemporaryDirectory() as t:\n tmp = Path(t)\n broken = f\"\"\"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003cGenAiPlannerBundle xmlns=\"{SF_NS}\">\n \u003cplannerType>Atlas__Reasoning\u003c/plannerType>\n \u003clocalTopics>\n \u003cmasterLabel>NoName\u003c/masterLabel>\n \u003c/localTopics>\n \u003clocalTopics>\n \u003cdeveloperName>Good\u003c/developerName>\n \u003c/localTopics>\n\u003c/GenAiPlannerBundle>\n\"\"\"\n self._setup_bundle(tmp, broken)\n out = parse_bundle.extract_bundle(tmp, \"MyPlanner\")\n self.assertEqual(len(out[\"topics\"]), 1)\n self.assertEqual(out[\"topics\"][0][\"name\"], \"Good\")\n\n def test_returns_skeleton_when_xml_is_malformed(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n self._setup_bundle(tmp, \"\u003c\u003c\u003cbroken>>>\")\n out = parse_bundle.extract_bundle(tmp, \"MyPlanner\")\n # Malformed → no fields populated, but skeleton shape preserved.\n self.assertEqual(out[\"topics\"], [])\n self.assertEqual(out[\"plannerActions\"], [])\n self.assertEqual(out[\"plannerType\"], None)\n\n\n# -----------------------------------------------------------------------------\n# atomic_write — tmp-file + os.replace\n# -----------------------------------------------------------------------------\n\n\nclass AtomicWriteTests(unittest.TestCase):\n\n def test_writes_content_to_target_path(self):\n with TemporaryDirectory() as t:\n target = Path(t) / \"out.txt\"\n parse_bundle.atomic_write(target, \"hello\\n\")\n self.assertEqual(target.read_text(), \"hello\\n\")\n\n def test_overwrites_existing_file(self):\n with TemporaryDirectory() as t:\n target = Path(t) / \"out.txt\"\n target.write_text(\"old\")\n parse_bundle.atomic_write(target, \"new\")\n self.assertEqual(target.read_text(), \"new\")\n\n def test_no_tmp_file_left_behind_after_success(self):\n with TemporaryDirectory() as t:\n target = Path(t) / \"out.txt\"\n parse_bundle.atomic_write(target, \"x\")\n # the .tmp sibling should NOT remain\n tmp_sibling = target.with_suffix(target.suffix + \".tmp\")\n self.assertFalse(tmp_sibling.exists())\n\n\n# -----------------------------------------------------------------------------\n# main() — orchestration via env vars\n# -----------------------------------------------------------------------------\n\n\nclass MainTests(unittest.TestCase):\n\n def _setup_full_workdir(self, tmp: Path) -> None:\n # Bot file\n bots_dir = tmp / \"sf_meta\" / \"wave1_bot\" / \"unpackaged\" / \"bots\"\n bots_dir.mkdir(parents=True)\n (bots_dir / \"MyAgent.bot\").write_text(_bot_xml(\"v1\", \"MyPlanner\"))\n # Bundle file\n bundle_dir = (\n tmp / \"sf_meta\" / \"wave1_bundle\" / \"unpackaged\"\n / \"genAiPlannerBundles\" / \"MyPlanner\"\n )\n bundle_dir.mkdir(parents=True)\n (bundle_dir / \"MyPlanner.genAiPlannerBundle\").write_text(_bundle_xml())\n\n def test_main_writes_bundle_parsed_json_and_returns_zero(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n self._setup_full_workdir(tmp)\n old = dict(os.environ)\n os.environ[\"WORK_DIR\"] = str(tmp)\n os.environ[\"AGENT_VERSION\"] = \"v1\"\n try:\n rc = parse_bundle.main()\n finally:\n os.environ.clear()\n os.environ.update(old)\n self.assertEqual(rc, 0)\n parsed = json.loads((tmp / \"_bundle_parsed.json\").read_text())\n self.assertEqual(parsed[\"plannerName\"], \"MyPlanner\")\n self.assertEqual(parsed[\"plannerType\"], \"Atlas__Reasoning\")\n self.assertEqual(parsed[\"generation\"], \"nga\")\n # _agent_generation.txt is newline-terminated for shell sourcing\n self.assertEqual(\n (tmp / \"_agent_generation.txt\").read_text(), \"nga\\n\"\n )\n\n def test_main_returns_one_when_env_missing(self):\n # Both WORK_DIR and AGENT_VERSION required; remove WORK_DIR.\n old = dict(os.environ)\n os.environ.pop(\"WORK_DIR\", None)\n os.environ[\"AGENT_VERSION\"] = \"v1\"\n try:\n rc = parse_bundle.main()\n finally:\n os.environ.clear()\n os.environ.update(old)\n self.assertEqual(rc, 1)\n\n def test_main_emits_empty_skeleton_when_planner_unresolved(self):\n # Bot file has no \u003cconversationDefinitionPlanners> → planner_name == \"\"\n with TemporaryDirectory() as t:\n tmp = Path(t)\n bots_dir = tmp / \"sf_meta\" / \"wave1_bot\" / \"unpackaged\" / \"bots\"\n bots_dir.mkdir(parents=True)\n (bots_dir / \"MyAgent.bot\").write_text(_bot_xml(\"v1\", planner_name=None))\n old = dict(os.environ)\n os.environ[\"WORK_DIR\"] = str(tmp)\n os.environ[\"AGENT_VERSION\"] = \"v1\"\n try:\n rc = parse_bundle.main()\n finally:\n os.environ.clear()\n os.environ.update(old)\n self.assertEqual(rc, 0)\n parsed = json.loads((tmp / \"_bundle_parsed.json\").read_text())\n self.assertIsNone(parsed[\"plannerName\"])\n self.assertEqual(parsed[\"topics\"], [])\n self.assertEqual((tmp / \"_agent_generation.txt\").read_text(), \"unknown\\n\")\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":15924,"content_sha256":"f9992c084f10cbbc033dedfe761bb473dc0868cd268f0cdb582270faa0b7b0b2"},{"filename":"scripts/tests/test_parse_wave_classifiers.py","content":"\"\"\"Tests for ``parse_wave`` classifiers + BFS step helper.\n\nTargets the high-density branch surface in ``classify_bundle_action``\n(Flow / Apex / PromptTemplate / StandardAction / Unknown) and\n``classify_action_call`` (apex / generatePromptResponse / other / empty).\nPlus the central ``bfs_step`` helper that drives wave-level fetches.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport parse_wave # type: ignore\n\n\n# -----------------------------------------------------------------------------\n# classify_bundle_action — every branch\n# -----------------------------------------------------------------------------\n\n\nclass ClassifyBundleActionTests(unittest.TestCase):\n\n def test_no_target_returns_none_pair(self):\n unwraps, leaf = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"\",\n \"invocationTargetType\": \"flow\",\n })\n self.assertIsNone(unwraps)\n self.assertIsNone(leaf)\n\n def test_flow_branch(self):\n unwraps, leaf = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"MyFlow\",\n \"invocationTargetType\": \"flow\",\n })\n self.assertEqual(unwraps[\"kind\"], \"FLOW\")\n self.assertEqual(unwraps[\"api_name\"], \"MyFlow\")\n self.assertEqual(leaf[\"children\"], [])\n\n def test_apex_branch(self):\n unwraps, leaf = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"MyClass.method\",\n \"invocationTargetType\": \"apex\",\n })\n self.assertEqual(unwraps[\"kind\"], \"APEX\")\n self.assertEqual(leaf[\"api_name\"], \"MyClass.method\")\n\n def test_prompt_template_via_generatepromptresponse(self):\n unwraps, _ = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"MyPrompt\",\n \"invocationTargetType\": \"generatepromptresponse\",\n })\n self.assertEqual(unwraps[\"kind\"], \"PROMPT_TEMPLATE\")\n\n def test_prompt_template_via_prompt_prefix(self):\n unwraps, _ = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"MyPrompt\",\n \"invocationTargetType\": \"prompt-template\",\n })\n self.assertEqual(unwraps[\"kind\"], \"PROMPT_TEMPLATE\")\n\n def test_prompt_template_via_genai_prefix(self):\n unwraps, _ = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"MyPrompt\",\n \"invocationTargetType\": \"genaifunction\",\n })\n self.assertEqual(unwraps[\"kind\"], \"PROMPT_TEMPLATE\")\n\n def test_standard_invocable_action_branch(self):\n unwraps, leaf = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"createRecord\",\n \"invocationTargetType\": \"standardinvocableaction\",\n })\n self.assertEqual(unwraps[\"kind\"], \"STANDARD_ACTION\")\n self.assertEqual(unwraps[\"invocation_type\"], \"standardinvocableaction\")\n self.assertEqual(leaf[\"api_name\"], \"createRecord\")\n\n def test_unknown_action_type_falls_to_unknown(self):\n unwraps, leaf = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"MysteryAction\",\n \"invocationTargetType\": \"MYSTERY\",\n })\n self.assertEqual(unwraps[\"kind\"], \"UNKNOWN\")\n self.assertEqual(unwraps[\"invocation_type\"], \"mystery\") # lowercased\n self.assertEqual(leaf[\"kind\"], \"UNKNOWN\")\n\n def test_invocationTargetType_missing_normalizes_to_empty_string(self):\n unwraps, _ = parse_wave.classify_bundle_action({\n \"invocationTarget\": \"MyAction\",\n # no invocationTargetType — coerced to \"\"\n })\n self.assertEqual(unwraps[\"kind\"], \"UNKNOWN\")\n self.assertEqual(unwraps[\"invocation_type\"], \"\")\n\n\n# -----------------------------------------------------------------------------\n# classify_action_call — every branch\n# -----------------------------------------------------------------------------\n\n\nclass ClassifyActionCallTests(unittest.TestCase):\n\n def test_apex_branch(self):\n out = parse_wave.classify_action_call(\"apex\", \"MyClass\", \"callA\")\n self.assertEqual(out[\"kind\"], \"APEX\")\n self.assertEqual(out[\"api_name\"], \"MyClass\")\n self.assertEqual(out[\"element_name\"], \"callA\")\n\n def test_generate_prompt_response_branch(self):\n out = parse_wave.classify_action_call(\n \"generatePromptResponse\", \"MyPrompt\", \"callB\",\n )\n self.assertEqual(out[\"kind\"], \"PROMPT_TEMPLATE\")\n self.assertEqual(out[\"api_name\"], \"MyPrompt\")\n\n def test_other_action_type_falls_to_standard_action(self):\n out = parse_wave.classify_action_call(\"emailAlert\", \"MyAlert\", \"callC\")\n self.assertEqual(out[\"kind\"], \"STANDARD_ACTION\")\n self.assertEqual(out[\"invocation_type\"], \"emailAlert\")\n self.assertEqual(out[\"api_name\"], \"MyAlert\")\n\n def test_other_action_type_uses_action_type_when_name_missing(self):\n out = parse_wave.classify_action_call(\"emailAlert\", \"\", \"callC\")\n self.assertEqual(out[\"api_name\"], \"emailAlert\")\n\n def test_empty_action_type_falls_to_unknown(self):\n out = parse_wave.classify_action_call(\"\", \"MyName\", \"callD\")\n self.assertEqual(out[\"kind\"], \"UNKNOWN\")\n self.assertEqual(out[\"api_name\"], \"MyName\")\n\n def test_unknown_with_no_name_falls_to_question_mark(self):\n out = parse_wave.classify_action_call(\"\", \"\", \"callE\")\n self.assertEqual(out[\"api_name\"], \"?\")\n\n\n# -----------------------------------------------------------------------------\n# bfs_step — pure helper\n# -----------------------------------------------------------------------------\n\n\nclass BfsStepTests(unittest.TestCase):\n\n def test_new_refs_added_to_pending(self):\n pending = parse_wave.empty_kind_sets()\n visited = parse_wave.empty_kind_sets()\n new_refs = {\n \"FLOW\": {\"FlowA\", \"FlowB\"},\n \"APEX\": {\"ApexA\"},\n \"PROMPT_TEMPLATE\": set(),\n \"STANDARD_ACTION\": set(),\n }\n merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)\n self.assertEqual(merged[\"FLOW\"], {\"FlowA\", \"FlowB\"})\n self.assertEqual(merged[\"APEX\"], {\"ApexA\"})\n self.assertEqual(cycles, [])\n\n def test_visited_refs_recorded_as_cycles(self):\n pending = parse_wave.empty_kind_sets()\n visited = parse_wave.empty_kind_sets()\n visited[\"FLOW\"].add(\"FlowA\")\n new_refs = {\n \"FLOW\": {\"FlowA\", \"FlowB\"},\n \"APEX\": set(), \"PROMPT_TEMPLATE\": set(), \"STANDARD_ACTION\": set(),\n }\n merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)\n self.assertEqual(merged[\"FLOW\"], {\"FlowB\"})\n self.assertIn((\"FLOW\", \"FlowA\"), cycles)\n\n def test_cross_kind_same_name_stays_distinct(self):\n pending = parse_wave.empty_kind_sets()\n visited = parse_wave.empty_kind_sets()\n new_refs = {\n \"FLOW\": {\"Foo\"}, \"APEX\": {\"Foo\"},\n \"PROMPT_TEMPLATE\": set(), \"STANDARD_ACTION\": set(),\n }\n merged, _ = parse_wave.bfs_step(pending, visited, new_refs)\n self.assertEqual(merged[\"FLOW\"], {\"Foo\"})\n self.assertEqual(merged[\"APEX\"], {\"Foo\"})\n\n def test_existing_pending_preserved_via_merge(self):\n pending = parse_wave.empty_kind_sets()\n pending[\"FLOW\"].add(\"FlowExisting\")\n visited = parse_wave.empty_kind_sets()\n new_refs = {\n \"FLOW\": {\"FlowNew\"},\n \"APEX\": set(), \"PROMPT_TEMPLATE\": set(), \"STANDARD_ACTION\": set(),\n }\n merged, _ = parse_wave.bfs_step(pending, visited, new_refs)\n self.assertEqual(merged[\"FLOW\"], {\"FlowExisting\", \"FlowNew\"})\n\n def test_unknown_kind_raises(self):\n pending = parse_wave.empty_kind_sets()\n visited = parse_wave.empty_kind_sets()\n with self.assertRaises(ValueError) as ctx:\n parse_wave.bfs_step(\n pending, visited,\n {\"BOGUS_KIND\": {\"x\"}},\n )\n self.assertIn(\"unknown BFS kind\", str(ctx.exception))\n\n\n# -----------------------------------------------------------------------------\n# empty_kind_sets — fresh dict per call\n# -----------------------------------------------------------------------------\n\n\nclass EmptyKindSetsTests(unittest.TestCase):\n\n def test_returns_one_set_per_BFS_KIND(self):\n out = parse_wave.empty_kind_sets()\n self.assertEqual(set(out.keys()), set(parse_wave.BFS_KINDS))\n for v in out.values():\n self.assertEqual(v, set())\n\n def test_each_call_returns_fresh_dict(self):\n a = parse_wave.empty_kind_sets()\n a[\"FLOW\"].add(\"x\")\n b = parse_wave.empty_kind_sets()\n self.assertEqual(b[\"FLOW\"], set())\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8757,"content_sha256":"73b290e62a5639ce425a73454ace971c0b6d263e232e0791cd8e0a004efda161"},{"filename":"scripts/tests/test_parse_wave_helpers.py","content":"\"\"\"Tests for ``parse_wave`` standalone helpers — non-classifier surface.\n\nCovers:\n- ``compute_stats`` node count + depth + per-kind counter\n- ``finalize_cap`` drains pending → unresolved + sets partial\n- ``atomic_write_json`` tmp + os.replace\n- ``init_tree`` bundle + bot_def shape\n- ``harvest_waves`` Flow XML walking with subflows + actionCalls\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport unittest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport parse_wave # type: ignore\n\n\n_FLOW_NS = \"http://soap.sforce.com/2006/04/metadata\"\n\n\n# -----------------------------------------------------------------------------\n# compute_stats\n# -----------------------------------------------------------------------------\n\n\nclass ComputeStatsTests(unittest.TestCase):\n\n def test_empty_root_returns_zero_counts(self):\n n, d, counts = parse_wave.compute_stats({})\n self.assertEqual(n, 0)\n self.assertEqual(d, 0)\n self.assertEqual(counts, {})\n\n def test_single_node_counts_kind(self):\n n, d, counts = parse_wave.compute_stats({\"kind\": \"BOT_DEFINITION\"})\n self.assertEqual(n, 1)\n self.assertEqual(counts[\"BOT_DEFINITION\"], 1)\n self.assertEqual(d, 0)\n\n def test_nested_tree_returns_max_depth(self):\n root = {\n \"kind\": \"BOT_DEFINITION\",\n \"children\": [\n {\"kind\": \"TOPIC\", \"children\": [\n {\"kind\": \"FLOW\", \"children\": [\n {\"kind\": \"APEX\"},\n ]},\n ]},\n {\"kind\": \"STANDARD_ACTION\"},\n ],\n }\n n, d, counts = parse_wave.compute_stats(root)\n self.assertEqual(n, 5)\n self.assertEqual(d, 3)\n self.assertEqual(counts[\"BOT_DEFINITION\"], 1)\n self.assertEqual(counts[\"TOPIC\"], 1)\n self.assertEqual(counts[\"FLOW\"], 1)\n self.assertEqual(counts[\"APEX\"], 1)\n self.assertEqual(counts[\"STANDARD_ACTION\"], 1)\n\n\n# -----------------------------------------------------------------------------\n# finalize_cap\n# -----------------------------------------------------------------------------\n\n\nclass FinalizeCapTests(unittest.TestCase):\n\n def test_drains_pending_into_unresolved(self):\n tree = {\n \"_pending_fetches\": {\n \"FLOW\": [\"FlowA\", \"FlowB\"],\n \"APEX\": [\"ClassA\"],\n \"PROMPT_TEMPLATE\": [], \"STANDARD_ACTION\": [],\n },\n \"_unresolved\": [],\n }\n out = parse_wave.finalize_cap(tree)\n self.assertEqual(len(out[\"_unresolved\"]), 3)\n # Pending wiped per-kind\n for kind in parse_wave.BFS_KINDS:\n self.assertEqual(out[\"_pending_fetches\"][kind], [])\n # _partial flipped\n self.assertTrue(out[\"_partial\"])\n self.assertEqual(out[\"_partial_reason\"], \"max-wave-depth\")\n\n def test_does_not_overwrite_existing_partial_reason(self):\n tree = {\n \"_pending_fetches\": {\"FLOW\": [\"FlowA\"], \"APEX\": [],\n \"PROMPT_TEMPLATE\": [], \"STANDARD_ACTION\": []},\n \"_unresolved\": [],\n \"_partial_reason\": \"max-depth-cap\", # earlier-set, more specific\n }\n out = parse_wave.finalize_cap(tree)\n # Reason kept; depth-cap wins priority over wave-depth.\n self.assertEqual(out[\"_partial_reason\"], \"max-depth-cap\")\n\n def test_no_pending_no_changes(self):\n tree = {\n \"_pending_fetches\": {k: [] for k in parse_wave.BFS_KINDS},\n \"_unresolved\": [],\n \"_partial\": False,\n \"_partial_reason\": None,\n }\n out = parse_wave.finalize_cap(tree)\n self.assertEqual(out[\"_unresolved\"], [])\n # Nothing drained, _partial untouched\n self.assertFalse(out[\"_partial\"])\n\n def test_unresolved_entry_shape(self):\n tree = {\n \"_pending_fetches\": {\"FLOW\": [\"FlowA\"], \"APEX\": [],\n \"PROMPT_TEMPLATE\": [], \"STANDARD_ACTION\": []},\n \"_unresolved\": [],\n }\n out = parse_wave.finalize_cap(tree)\n entry = out[\"_unresolved\"][0]\n self.assertEqual(entry[\"kind\"], \"FLOW\")\n self.assertEqual(entry[\"api_name\"], \"FlowA\")\n self.assertEqual(entry[\"reason\"], \"max-wave-depth exceeded\")\n\n\n# -----------------------------------------------------------------------------\n# atomic_write_json\n# -----------------------------------------------------------------------------\n\n\nclass AtomicWriteJsonTests(unittest.TestCase):\n\n def test_writes_indented_json(self):\n with TemporaryDirectory() as t:\n target = Path(t) / \"out.json\"\n parse_wave.atomic_write_json(target, {\"a\": 1, \"b\": [1, 2]})\n data = json.loads(target.read_text())\n self.assertEqual(data, {\"a\": 1, \"b\": [1, 2]})\n\n def test_overwrites_existing(self):\n with TemporaryDirectory() as t:\n target = Path(t) / \"out.json\"\n target.write_text(\"old\")\n parse_wave.atomic_write_json(target, {\"new\": True})\n data = json.loads(target.read_text())\n self.assertEqual(data, {\"new\": True})\n\n def test_no_tmp_left_behind(self):\n with TemporaryDirectory() as t:\n target = Path(t) / \"out.json\"\n parse_wave.atomic_write_json(target, {\"x\": 1})\n tmp_sibling = target.with_suffix(target.suffix + \".tmp\")\n self.assertFalse(tmp_sibling.exists())\n\n\n# -----------------------------------------------------------------------------\n# init_tree\n# -----------------------------------------------------------------------------\n\n\nclass InitTreeTests(unittest.TestCase):\n\n def _setup_env(self, **overrides) -> dict:\n defaults = {\n \"AGENT_API_NAME\": \"DemoAgent\",\n \"AGENT_VERSION\": \"v3\",\n \"BOT_ID\": \"0Xx000000000ABC\",\n \"BOT_MASTER_LABEL\": \"Demo Agent\",\n \"VERSION_AUTO_PICKED\": \"false\",\n }\n defaults.update(overrides)\n return defaults\n\n def _run(self, *, work_dir: Path, env: dict, bundle: dict) -> dict:\n old = dict(os.environ)\n try:\n os.environ.update(env)\n return parse_wave.init_tree(work_dir, bundle)\n finally:\n os.environ.clear()\n os.environ.update(old)\n\n def test_initial_tree_shape(self):\n with TemporaryDirectory() as t:\n tree = self._run(\n work_dir=Path(t),\n env=self._setup_env(),\n bundle={\"generation\": \"nga\", \"plannerName\": \"MyPlanner\",\n \"plannerType\": \"Atlas__Reasoning\"},\n )\n self.assertEqual(tree[\"_schema_version\"], \"3.1\")\n self.assertEqual(tree[\"agent\"][\"api_name\"], \"DemoAgent\")\n self.assertEqual(tree[\"agent\"][\"version\"], \"v3\")\n self.assertEqual(tree[\"agent\"][\"generation\"], \"nga\")\n self.assertEqual(tree[\"agent\"][\"planner_name\"], \"MyPlanner\")\n self.assertEqual(tree[\"root\"][\"kind\"], \"BOT_DEFINITION\")\n self.assertEqual(tree[\"root\"][\"children\"], [])\n self.assertTrue(tree[\"_partial\"])\n self.assertIsNone(tree[\"_partial_reason\"])\n # _pending_fetches has all 4 BFS kinds\n for kind in parse_wave.BFS_KINDS:\n self.assertIn(kind, tree[\"_pending_fetches\"])\n\n def test_pulls_bot_definition_metadata_from_disk(self):\n with TemporaryDirectory() as t:\n work_dir = Path(t)\n (work_dir / \"_bot_definition.json\").write_text(json.dumps({\n \"result\": {\"records\": [{\n \"DeveloperName\": \"DemoAgent\",\n \"MasterLabel\": \"Demo (from disk)\",\n \"Description\": \"via _bot_definition.json\",\n \"AgentType\": \"Internal\",\n \"Type\": \"AiCopilot\",\n \"AgentTemplate\": \"TPL\",\n \"BotSource\": \"AgentforceAgentCopilot\",\n }]},\n }))\n tree = self._run(\n work_dir=work_dir,\n env=self._setup_env(),\n bundle={\"generation\": \"classic\"},\n )\n # MasterLabel from _bot_definition.json wins over env\n self.assertEqual(tree[\"agent\"][\"master_label\"], \"Demo (from disk)\")\n self.assertEqual(tree[\"agent\"][\"description\"], \"via _bot_definition.json\")\n self.assertEqual(tree[\"agent\"][\"agent_type\"], \"Internal\")\n\n def test_version_auto_picked_flag_propagates(self):\n with TemporaryDirectory() as t:\n tree = self._run(\n work_dir=Path(t),\n env=self._setup_env(VERSION_AUTO_PICKED=\"true\"),\n bundle={\"generation\": \"nga\"},\n )\n self.assertTrue(tree[\"agent\"][\"_version_auto_picked\"])\n\n\n# -----------------------------------------------------------------------------\n# harvest_waves\n# -----------------------------------------------------------------------------\n\n\ndef _flow_xml(*, name: str, action_calls: list[dict] | None = None,\n subflows: list[str] | None = None) -> str:\n \"\"\"Build a minimal Flow XML body.\"\"\"\n acs = \"\".join(\n f\"\"\" \u003cactionCalls>\n \u003cname>{ac['name']}\u003c/name>\n \u003cactionType>{ac['actionType']}\u003c/actionType>\n \u003cactionName>{ac['actionName']}\u003c/actionName>\n \u003c/actionCalls>\n\"\"\"\n for ac in (action_calls or [])\n )\n subs = \"\".join(\n f\"\"\" \u003csubflows>\n \u003cname>sub_{i}\u003c/name>\n \u003cflowName>{fn}\u003c/flowName>\n \u003c/subflows>\n\"\"\"\n for i, fn in enumerate(subflows or [])\n )\n return f\"\"\"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003cFlow xmlns=\"{_FLOW_NS}\">\n \u003cfullName>{name}\u003c/fullName>\n{acs}{subs}\u003c/Flow>\n\"\"\"\n\n\nclass HarvestWavesTests(unittest.TestCase):\n\n def _setup_wave(self, work_dir: Path, *, flow_files: dict[str, str],\n apex_class_names: list[str] | None = None,\n prompt_template_names: list[str] | None = None) -> None:\n \"\"\"Plant a wave1/unpackaged/{flows,classes,genAiPromptTemplates}/\n directory tree under work_dir/sf_meta/.\"\"\"\n wave = work_dir / \"sf_meta\" / \"wave1\" / \"unpackaged\"\n flows_dir = wave / \"flows\"\n flows_dir.mkdir(parents=True)\n for filename, body in flow_files.items():\n (flows_dir / filename).write_text(body)\n if apex_class_names:\n classes_dir = wave / \"classes\"\n classes_dir.mkdir()\n for n in apex_class_names:\n (classes_dir / f\"{n}.cls-meta.xml\").write_text(\"\u003cx/>\")\n if prompt_template_names:\n prompt_dir = wave / \"genAiPromptTemplates\"\n prompt_dir.mkdir()\n for n in prompt_template_names:\n (prompt_dir / f\"{n}.genAiPromptTemplate\").write_text(\"\u003cx/>\")\n\n def test_returns_empty_when_no_sf_meta(self):\n with TemporaryDirectory() as t:\n flow_children, vis, pend, cycles = parse_wave.harvest_waves(Path(t))\n self.assertEqual(flow_children, {})\n self.assertEqual(cycles, [])\n for v in vis.values():\n self.assertEqual(v, set())\n\n def test_harvests_flow_action_calls_and_subflows(self):\n with TemporaryDirectory() as t:\n work_dir = Path(t)\n self._setup_wave(work_dir, flow_files={\n \"MainFlow.flow\": _flow_xml(\n name=\"MainFlow\",\n action_calls=[\n {\"name\": \"callA\", \"actionType\": \"apex\",\n \"actionName\": \"MyClass\"},\n {\"name\": \"callB\", \"actionType\": \"generatePromptResponse\",\n \"actionName\": \"MyPrompt\"},\n ],\n subflows=[\"NestedFlow\"],\n ),\n })\n flow_children, visited, pending, _cycles = parse_wave.harvest_waves(work_dir)\n # MainFlow visited\n self.assertIn(\"MainFlow\", visited[\"FLOW\"])\n # Apex + prompt-template + nested flow ref harvested into pending\n self.assertIn(\"MyClass\", pending[\"APEX\"])\n self.assertIn(\"MyPrompt\", pending[\"PROMPT_TEMPLATE\"])\n self.assertIn(\"NestedFlow\", pending[\"FLOW\"])\n # MainFlow's children include the apex + prompt-template + subflow\n self.assertEqual(len(flow_children[\"MainFlow\"]), 3)\n\n def test_apex_classes_marked_visited(self):\n with TemporaryDirectory() as t:\n work_dir = Path(t)\n self._setup_wave(\n work_dir,\n flow_files={\"F.flow\": _flow_xml(name=\"F\")},\n apex_class_names=[\"AlreadyApex\"],\n )\n _, visited, _, _ = parse_wave.harvest_waves(work_dir)\n self.assertIn(\"AlreadyApex\", visited[\"APEX\"])\n\n def test_prompt_templates_marked_visited(self):\n with TemporaryDirectory() as t:\n work_dir = Path(t)\n self._setup_wave(\n work_dir,\n flow_files={\"F.flow\": _flow_xml(name=\"F\")},\n prompt_template_names=[\"AlreadyPrompt\"],\n )\n _, visited, _, _ = parse_wave.harvest_waves(work_dir)\n self.assertIn(\"AlreadyPrompt\", visited[\"PROMPT_TEMPLATE\"])\n\n def test_already_visited_apex_pruned_from_pending(self):\n \"\"\"When pass-2/3 marks an Apex class visited AFTER pass-1 routed it\n into pending, the post-pass prune drops it from pending.\"\"\"\n with TemporaryDirectory() as t:\n work_dir = Path(t)\n self._setup_wave(work_dir,\n flow_files={\"F.flow\": _flow_xml(\n name=\"F\",\n action_calls=[{\n \"name\": \"callA\", \"actionType\": \"apex\",\n \"actionName\": \"ClassX\",\n }],\n )},\n apex_class_names=[\"ClassX\"])\n _, visited, pending, _ = parse_wave.harvest_waves(work_dir)\n self.assertIn(\"ClassX\", visited[\"APEX\"])\n # Pruned because already-visited (Apex class file present this wave)\n self.assertNotIn(\"ClassX\", pending[\"APEX\"])\n\n def test_malformed_flow_xml_returns_empty_children(self):\n with TemporaryDirectory() as t:\n work_dir = Path(t)\n self._setup_wave(work_dir, flow_files={\n \"Broken.flow\": \"\u003c\u003c\u003cnot xml>>>\",\n })\n flow_children, _, _, _ = parse_wave.harvest_waves(work_dir)\n self.assertEqual(flow_children[\"Broken\"], [])\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14622,"content_sha256":"cae1fac5ba1b7260500650fe5ba114c08396cf029ae36766d9ac4c9a69fa26b3"},{"filename":"scripts/tests/test_parse_wave_main.py","content":"\"\"\"Tests for ``parse_wave.main`` orchestrator + tree-walking helpers.\n\nCovers:\n- ``walk_and_inflate`` recursive Flow inflation\n- ``inflate_flow_leaf`` cycle + depth-cap behavior\n- ``build_root_children`` bundle → root children + new_refs\n- ``main`` (full path) env-driven pipeline against a tmp WORK_DIR\n- ``main --finalize-cap`` drain pending → unresolved\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport unittest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport parse_wave # type: ignore\n\n\n_FLOW_NS = \"http://soap.sforce.com/2006/04/metadata\"\n\n\n# -----------------------------------------------------------------------------\n# walk_and_inflate\n# -----------------------------------------------------------------------------\n\n\nclass WalkAndInflateTests(unittest.TestCase):\n\n def test_no_op_for_non_FLOW_non_GEN_AI_FUNCTION_node(self):\n node = {\"kind\": \"TOPIC\", \"api_name\": \"T1\", \"children\": []}\n flow_children: dict = {}\n # Should not raise; should not mutate.\n parse_wave.walk_and_inflate(node, flow_children)\n self.assertEqual(node[\"children\"], [])\n\n def test_inflates_flow_leaf_under_gen_ai_function(self):\n # GEN_AI_FUNCTION wraps a FLOW leaf — walker recurses into it.\n node = {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"GAF1\",\n \"children\": [{\"kind\": \"FLOW\", \"api_name\": \"MyFlow\", \"children\": []}],\n }\n flow_children = {\"MyFlow\": [\n {\"kind\": \"APEX\", \"element_name\": \"callA\", \"api_name\": \"MyClass\"},\n ]}\n parse_wave.walk_and_inflate(node, flow_children)\n # FLOW leaf got inflated with the APEX child\n flow_leaf = node[\"children\"][0]\n self.assertEqual(len(flow_leaf[\"children\"]), 1)\n self.assertEqual(flow_leaf[\"children\"][0][\"kind\"], \"APEX\")\n\n def test_recursively_walks_through_topic_to_inflate_flow(self):\n # Walker should descend through TOPIC/non-special nodes too.\n node = {\n \"kind\": \"BOT_DEFINITION\", \"api_name\": \"Agent\",\n \"children\": [{\n \"kind\": \"TOPIC\", \"api_name\": \"T1\",\n \"children\": [{\n \"kind\": \"GEN_AI_FUNCTION\", \"api_name\": \"GAF1\",\n \"children\": [{\"kind\": \"FLOW\", \"api_name\": \"DeepFlow\",\n \"children\": []}],\n }],\n }],\n }\n flow_children = {\"DeepFlow\": [\n {\"kind\": \"APEX\", \"element_name\": \"callA\", \"api_name\": \"DeepClass\"},\n ]}\n parse_wave.walk_and_inflate(node, flow_children)\n # DeepFlow got the APEX child\n deep_flow = node[\"children\"][0][\"children\"][0][\"children\"][0]\n self.assertEqual(deep_flow[\"children\"][0][\"api_name\"], \"DeepClass\")\n\n def test_pending_out_collects_depth_cap_truncations(self):\n # Build a chain longer than MAX_BFS_DEPTH so the cap trips.\n # Generate 25 flows that each subflow into the next.\n flow_children: dict = {}\n for i in range(25):\n next_name = f\"F{i+1}\" if i \u003c 24 else None\n kids = []\n if next_name:\n kids.append({\"kind\": \"FLOW\", \"element_name\": f\"sub_{i}\",\n \"api_name\": next_name})\n flow_children[f\"F{i}\"] = kids\n node = {\"kind\": \"FLOW\", \"api_name\": \"F0\", \"children\": []}\n pending: dict = parse_wave.empty_kind_sets()\n parse_wave.walk_and_inflate(node, flow_children, pending_out=pending)\n # Some subflow ended up in pending due to the depth cap (>= MAX_BFS_DEPTH).\n self.assertTrue(any(pending[k] for k in parse_wave.BFS_KINDS))\n\n\n# -----------------------------------------------------------------------------\n# inflate_flow_leaf — cycle detection\n# -----------------------------------------------------------------------------\n\n\nclass InflateFlowLeafCycleTests(unittest.TestCase):\n\n def test_self_recursion_marked_as_cycle(self):\n # Flow A subflows back to A itself → cycle.\n flow_children = {\"A\": [\n {\"kind\": \"FLOW\", \"element_name\": \"self\", \"api_name\": \"A\"},\n ]}\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n parse_wave.inflate_flow_leaf(leaf, flow_children)\n # The recursive child got annotated with _truncated.\n kid = leaf[\"children\"][0]\n self.assertIn(\"_truncated\", kid)\n self.assertEqual(kid[\"_truncated\"][\"reason\"],\n parse_wave.TRUNCATION_CYCLE)\n\n def test_unknown_flow_no_inflation(self):\n # leaf.api_name not in flow_children → nothing to inflate.\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"Missing\", \"children\": []}\n parse_wave.inflate_flow_leaf(leaf, {})\n self.assertEqual(leaf[\"children\"], [])\n\n\n# -----------------------------------------------------------------------------\n# build_root_children\n# -----------------------------------------------------------------------------\n\n\nclass BuildRootChildrenTests(unittest.TestCase):\n\n def test_topics_become_root_children(self):\n bundle = {\n \"topics\": [\n {\"name\": \"Greetings\", \"actions\": [\n {\"name\": \"Hello\", \"invocationTarget\": \"MyFlow\",\n \"invocationTargetType\": \"flow\"},\n ]},\n ],\n }\n visited = parse_wave.empty_kind_sets()\n aux: set = set()\n children, new_refs = parse_wave.build_root_children(bundle, visited, aux)\n self.assertEqual(len(children), 1)\n self.assertEqual(children[0][\"kind\"], \"TOPIC\")\n self.assertEqual(children[0][\"api_name\"], \"Greetings\")\n # GEN_AI_FUNCTION wrapping the action\n self.assertEqual(children[0][\"children\"][0][\"kind\"], \"GEN_AI_FUNCTION\")\n # FLOW ref harvested into new_refs\n self.assertIn(\"MyFlow\", new_refs[\"FLOW\"])\n # Topic + action recorded in aux_visited\n self.assertIn((\"TOPIC\", \"Greetings\"), aux)\n self.assertIn((\"GEN_AI_FUNCTION\", \"Hello\"), aux)\n\n def test_skips_already_visited_refs(self):\n bundle = {\n \"topics\": [{\"name\": \"T\", \"actions\": [\n {\"name\": \"A1\", \"invocationTarget\": \"FlowA\",\n \"invocationTargetType\": \"flow\"},\n ]}],\n }\n visited = parse_wave.empty_kind_sets()\n visited[\"FLOW\"].add(\"FlowA\")\n aux: set = set()\n _, new_refs = parse_wave.build_root_children(bundle, visited, aux)\n # FlowA already visited → not added to new_refs\n self.assertNotIn(\"FlowA\", new_refs[\"FLOW\"])\n\n def test_standard_action_does_not_pollute_new_refs(self):\n # STANDARD_ACTION is declared-only, must NOT\n # land in new_refs (would pollute _pending_fetches).\n bundle = {\n \"topics\": [{\"name\": \"T\", \"actions\": [\n {\"name\": \"A1\", \"invocationTarget\": \"createRecord\",\n \"invocationTargetType\": \"standardinvocableaction\"},\n ]}],\n }\n visited = parse_wave.empty_kind_sets()\n aux: set = set()\n _, new_refs = parse_wave.build_root_children(bundle, visited, aux)\n self.assertNotIn(\"createRecord\", new_refs[\"STANDARD_ACTION\"])\n\n def test_empty_bundle_returns_empty_children(self):\n children, new_refs = parse_wave.build_root_children(\n {}, parse_wave.empty_kind_sets(), set(),\n )\n self.assertEqual(children, [])\n for v in new_refs.values():\n self.assertEqual(v, set())\n\n\n# -----------------------------------------------------------------------------\n# main() — happy path + finalize-cap branch\n# -----------------------------------------------------------------------------\n\n\ndef _setup_workdir(tmp: Path, *, bundle: dict | None = None) -> Path:\n \"\"\"Plant a WORK_DIR with _bundle_parsed.json + minimal sf_meta layout.\"\"\"\n work_dir = tmp / \"work\"\n work_dir.mkdir()\n bundle = bundle or {\n \"plannerName\": \"MyPlanner\",\n \"plannerType\": \"Atlas__Reasoning\",\n \"generation\": \"nga\",\n \"topics\": [{\"name\": \"Greetings\", \"actions\": [\n {\"name\": \"SayHi\", \"invocationTarget\": \"GreetingsFlow\",\n \"invocationTargetType\": \"flow\"},\n ]}],\n }\n (work_dir / \"_bundle_parsed.json\").write_text(json.dumps(bundle))\n # Empty sf_meta is fine — harvest_waves returns empty when missing.\n return work_dir\n\n\ndef _run_main(*, work_dir: Path, argv_extra: list[str] | None = None,\n env_extra: dict | None = None) -> int:\n \"\"\"Invoke parse_wave.main with controlled env + argv.\"\"\"\n saved_env = dict(os.environ)\n saved_argv = list(parse_wave.sys.argv)\n try:\n os.environ.update({\n \"WORK_DIR\": str(work_dir),\n \"AGENT_API_NAME\": \"DemoAgent\",\n \"AGENT_VERSION\": \"v3\",\n \"BOT_ID\": \"0Xx000000000ABC\",\n \"BOT_MASTER_LABEL\": \"Demo Agent\",\n \"VERSION_AUTO_PICKED\": \"false\",\n })\n if env_extra:\n os.environ.update(env_extra)\n parse_wave.sys.argv = [\"parse_wave.py\", *(argv_extra or [])]\n return parse_wave.main()\n finally:\n os.environ.clear()\n os.environ.update(saved_env)\n parse_wave.sys.argv = saved_argv\n\n\nclass MainHappyPathTests(unittest.TestCase):\n\n def test_main_writes_declared_action_tree_json(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = _setup_workdir(tmp)\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n rc = _run_main(work_dir=work_dir)\n self.assertEqual(rc, 0)\n tree_path = work_dir / \"declared_action_tree.json\"\n self.assertTrue(tree_path.is_file())\n tree = json.loads(tree_path.read_text())\n # Tree has the expected shape\n self.assertEqual(tree[\"_schema_version\"], \"3.1\")\n self.assertEqual(tree[\"agent\"][\"api_name\"], \"DemoAgent\")\n self.assertEqual(tree[\"root\"][\"kind\"], \"BOT_DEFINITION\")\n # One topic became a root child\n kinds = [c[\"kind\"] for c in tree[\"root\"][\"children\"]]\n self.assertIn(\"TOPIC\", kinds)\n\n def test_main_records_pending_for_unfetched_flow(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = _setup_workdir(tmp)\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n _run_main(work_dir=work_dir)\n tree = json.loads(\n (work_dir / \"declared_action_tree.json\").read_text()\n )\n # GreetingsFlow not on disk → in _pending_fetches.FLOW\n self.assertIn(\"GreetingsFlow\", tree[\"_pending_fetches\"][\"FLOW\"])\n self.assertTrue(tree[\"_partial\"])\n # _partial_reason populated on first run when pending refs exist\n # but no depth cap was tripped.\n self.assertEqual(tree[\"_partial_reason\"], \"pending-refs\")\n\n def test_main_writes_pending_refs_reason_when_initial_reason_is_none(self):\n # init_tree seeds `_partial_reason=None`. The writer's elif branch\n # must populate it with \"pending-refs\" when any pending bucket is\n # non-empty and the depth cap did not trip — a setdefault against\n # `None` is a no-op, which is the bug this guards.\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = _setup_workdir(tmp)\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n _run_main(work_dir=work_dir)\n tree = json.loads(\n (work_dir / \"declared_action_tree.json\").read_text()\n )\n # Sanity: pending exists, depth cap was NOT tripped.\n self.assertIn(\"GreetingsFlow\", tree[\"_pending_fetches\"][\"FLOW\"])\n # The fix: a None-valued reason gets promoted to \"pending-refs\".\n self.assertEqual(tree[\"_partial_reason\"], \"pending-refs\")\n\n def test_main_preserves_existing_partial_reason_when_pending_refs(self):\n # init_tree primes _partial_reason=None; setdefault used to be a\n # no-op against that None and left the reason blank. Today the\n # writer treats None as \"unset\" and fills in \"pending-refs\".\n # When a prior reason exists (e.g. \"max-depth-cap\" from a previous\n # wave), the writer must NOT overwrite it.\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = _setup_workdir(tmp)\n # Plant a tree on disk that mimics a previously-suspended run:\n # _partial_reason=\"max-depth-cap\" and empty root children, so\n # main() enters the fresh-tree branch and re-collects bundle\n # refs (GreetingsFlow pending) yet sees the prior reason.\n tree_path = work_dir / \"declared_action_tree.json\"\n tree_path.write_text(json.dumps({\n \"_schema_version\": \"3.1\",\n \"agent\": {\"api_name\": \"DemoAgent\", \"version\": \"v3\"},\n \"root\": {\"kind\": \"BOT_DEFINITION\", \"api_name\": \"DemoAgent\",\n \"children\": []},\n \"_partial\": True,\n \"_partial_reason\": \"max-depth-cap\",\n \"_pending_fetches\": {k: [] for k in parse_wave.BFS_KINDS},\n \"_unresolved\": [],\n \"_visited\": [],\n }))\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n _run_main(work_dir=work_dir)\n tree2 = json.loads(tree_path.read_text())\n # GreetingsFlow is pending → any_pending=True → elif branch runs.\n self.assertIn(\"GreetingsFlow\", tree2[\"_pending_fetches\"][\"FLOW\"])\n # Existing more-specific reason is preserved.\n self.assertEqual(tree2[\"_partial_reason\"], \"max-depth-cap\")\n\n def test_main_returns_one_when_work_dir_env_missing(self):\n saved_env = dict(os.environ)\n try:\n for k in (\"WORK_DIR\", \"AGENT_API_NAME\", \"AGENT_VERSION\"):\n os.environ.pop(k, None)\n with mock.patch.object(parse_wave.sys, \"argv\", [\"parse_wave.py\"]):\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n rc = parse_wave.main()\n finally:\n os.environ.clear()\n os.environ.update(saved_env)\n self.assertEqual(rc, 1)\n\n def test_main_returns_one_when_bundle_missing(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = tmp / \"work\"\n work_dir.mkdir()\n # No _bundle_parsed.json\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n rc = _run_main(work_dir=work_dir)\n self.assertEqual(rc, 1)\n\n def test_main_reparses_existing_tree(self):\n # Two-pass: first run creates the tree, second re-uses it.\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = _setup_workdir(tmp)\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n _run_main(work_dir=work_dir)\n # Second invocation should load + augment without crashing\n rc = _run_main(work_dir=work_dir)\n self.assertEqual(rc, 0)\n\n\nclass MainFinalizeCapTests(unittest.TestCase):\n\n def test_finalize_cap_drains_pending_into_unresolved(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = _setup_workdir(tmp)\n # First run produces a tree with pending FLOW=GreetingsFlow.\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n _run_main(work_dir=work_dir)\n # --finalize-cap drains pending → unresolved\n rc = _run_main(\n work_dir=work_dir, argv_extra=[\"--finalize-cap\"],\n )\n self.assertEqual(rc, 0)\n tree = json.loads(\n (work_dir / \"declared_action_tree.json\").read_text()\n )\n # All buckets emptied\n for kind in parse_wave.BFS_KINDS:\n self.assertEqual(tree[\"_pending_fetches\"][kind], [])\n # GreetingsFlow now in _unresolved\n api_names = [u[\"api_name\"] for u in tree[\"_unresolved\"]]\n self.assertIn(\"GreetingsFlow\", api_names)\n\n def test_finalize_cap_with_no_existing_tree_is_noop(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n work_dir = tmp / \"work\"\n work_dir.mkdir()\n with mock.patch.object(parse_wave.sys, \"stderr\"):\n rc = _run_main(\n work_dir=work_dir, argv_extra=[\"--finalize-cap\"],\n )\n # No tree file → noop, exit 0.\n self.assertEqual(rc, 0)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":16756,"content_sha256":"9fde97eee469dd1e50b15dd54f48a2f570ac3d7228716b07e7f44250f67cb129"},{"filename":"scripts/tests/test_parse_wave.py","content":"\"\"\"Tests for parse_wave BFS + inflate uses (kind, canonical_name)\ntuple-keyed visited tracking. A flat per-name visited set would silently\ndrop the second of two same-named different-kind nodes (Flow Foo + Apex Foo),\nand would infinite-loop / depth-cap on self-cycles without annotation.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nfrom . import _bootstrap # noqa: F401\n\nimport parse_wave # type: ignore\n\n\nclass BfsStepTupleKeyingTests(unittest.TestCase):\n \"\"\"Same-name, different-kind refs must not collide.\"\"\"\n\n def test_flow_and_apex_same_name_are_distinct(self):\n pending = {k: set() for k in parse_wave._BFS_KINDS}\n visited = {k: set() for k in parse_wave._BFS_KINDS}\n new_refs = {\"FLOW\": {\"Foo\"}, \"APEX\": {\"Foo\"}}\n\n merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)\n\n self.assertIn(\"Foo\", merged[\"FLOW\"])\n self.assertIn(\"Foo\", merged[\"APEX\"])\n self.assertEqual(cycles, [])\n\n def test_already_visited_ref_skipped_and_recorded_as_cycle(self):\n pending = {k: set() for k in parse_wave._BFS_KINDS}\n visited = {k: set() for k in parse_wave._BFS_KINDS}\n visited[\"FLOW\"] = {\"Foo\"} # simulate prior wave visit\n new_refs = {\"FLOW\": {\"Foo\", \"Bar\"}}\n\n merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)\n\n self.assertNotIn(\"Foo\", merged[\"FLOW\"]) # filtered out\n self.assertIn(\"Bar\", merged[\"FLOW\"])\n self.assertIn((\"FLOW\", \"Foo\"), cycles)\n\n def test_cross_type_cycle_is_distinct(self):\n \"\"\"Flow Foo visited does not mask Apex Foo on the same wave.\"\"\"\n pending = {k: set() for k in parse_wave._BFS_KINDS}\n visited = {k: set() for k in parse_wave._BFS_KINDS}\n visited[\"FLOW\"] = {\"Foo\"}\n new_refs = {\"FLOW\": {\"Foo\"}, \"APEX\": {\"Foo\"}}\n\n merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)\n\n self.assertEqual(merged[\"FLOW\"], set()) # Foo is visited\n self.assertEqual(merged[\"APEX\"], {\"Foo\"}) # distinct kind → keeps\n self.assertIn((\"FLOW\", \"Foo\"), cycles)\n self.assertNotIn((\"APEX\", \"Foo\"), cycles)\n\n def test_empty_initial_pending_terminates(self):\n pending = {k: set() for k in parse_wave._BFS_KINDS}\n visited = {k: set() for k in parse_wave._BFS_KINDS}\n merged, cycles = parse_wave.bfs_step(pending, visited, {})\n self.assertEqual(cycles, [])\n for k in parse_wave._BFS_KINDS:\n self.assertEqual(merged[k], set())\n\n def test_unknown_kind_raises(self):\n \"\"\"unknown BFS kinds raise ValueError — bfs_step is an\n internal API and a typo in a caller is a programming error, not a\n runtime condition we can silently paper over. Silent-drop produced\n false confidence: the ref was never fetched and no log mentioned it.\n \"\"\"\n pending = {k: set() for k in parse_wave._BFS_KINDS}\n visited = {k: set() for k in parse_wave._BFS_KINDS}\n new_refs = {\"NOT_A_KIND\": {\"X\"}}\n\n with self.assertRaises(ValueError) as ctx:\n parse_wave.bfs_step(pending, visited, new_refs)\n\n self.assertIn(\"unknown BFS kind\", ctx.exception.args[0])\n\n\nclass InflateCycleDetectionTests(unittest.TestCase):\n \"\"\"`inflate_flow_leaf` must terminate on self-cycles and annotate\n `_cycle_back_to` on the repeat node.\"\"\"\n\n def test_self_cycle_flow_a_calls_subflow_a(self):\n \"\"\"Flow A → subflow A — one real node + a stub child with cycle tag.\"\"\"\n flow_children = {\n \"A\": [\n {\"kind\": \"FLOW\", \"element_name\": \"recurse\", \"api_name\": \"A\"},\n ],\n }\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n\n parse_wave.inflate_flow_leaf(leaf, flow_children)\n\n self.assertEqual(len(leaf[\"children\"]), 1)\n child = leaf[\"children\"][0]\n self.assertEqual(child[\"kind\"], \"FLOW\")\n self.assertEqual(child[\"api_name\"], \"A\")\n self.assertEqual(child.get(\"_cycle_back_to\"), \"FLOW:A\")\n # Must NOT have expanded children on the cycle stub.\n self.assertEqual(child.get(\"children\", []), [])\n\n def test_cross_type_cycle_flow_a_to_apex_b_to_flow_a(self):\n \"\"\"Flow A → Apex B (leaf) → nothing (Apex doesn't recurse in inflate).\n\n The inflate layer only descends through FLOW→FLOW. A cycle through\n Apex would be caught at the BFS layer (bfs_step), not here. This\n test confirms the inflate layer doesn't misclassify a FLOW→APEX\n edge as a cycle and doesn't over-recurse on it.\n \"\"\"\n flow_children = {\n \"A\": [\n {\"kind\": \"APEX\", \"element_name\": \"callApex\", \"api_name\": \"B\"},\n ],\n }\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n\n parse_wave.inflate_flow_leaf(leaf, flow_children)\n\n self.assertEqual(len(leaf[\"children\"]), 1)\n self.assertEqual(leaf[\"children\"][0][\"kind\"], \"APEX\")\n self.assertNotIn(\"_cycle_back_to\", leaf[\"children\"][0])\n\n def test_three_layer_linear_graph_fully_expanded(self):\n \"\"\"A → B → C — each Flow visited once, no false cycle tags.\"\"\"\n flow_children = {\n \"A\": [{\"kind\": \"FLOW\", \"element_name\": \"callB\", \"api_name\": \"B\"}],\n \"B\": [{\"kind\": \"FLOW\", \"element_name\": \"callC\", \"api_name\": \"C\"}],\n \"C\": [],\n }\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n\n parse_wave.inflate_flow_leaf(leaf, flow_children)\n\n # A has one child: B\n self.assertEqual(len(leaf[\"children\"]), 1)\n b = leaf[\"children\"][0]\n self.assertEqual(b[\"api_name\"], \"B\")\n self.assertNotIn(\"_cycle_back_to\", b)\n # B has one child: C\n self.assertEqual(len(b[\"children\"]), 1)\n c = b[\"children\"][0]\n self.assertEqual(c[\"api_name\"], \"C\")\n self.assertNotIn(\"_cycle_back_to\", c)\n # C has no children (empty list in flow_children)\n self.assertEqual(c.get(\"children\", []), [])\n\n def test_sibling_flows_sharing_target_not_cross_contaminated(self):\n \"\"\"If Flow X is called by two different siblings, both sibling\n branches must fully expand X — not prune the second as a cycle.\n\n Frozenset path-visited semantics protect this: siblings don't see\n each other's descent. A mutable shared set would cause the second\n sibling to treat X as a cycle.\n \"\"\"\n flow_children = {\n \"Root\": [\n {\"kind\": \"FLOW\", \"element_name\": \"a\", \"api_name\": \"Sib1\"},\n {\"kind\": \"FLOW\", \"element_name\": \"b\", \"api_name\": \"Sib2\"},\n ],\n \"Sib1\": [{\"kind\": \"FLOW\", \"element_name\": \"shared\", \"api_name\": \"Shared\"}],\n \"Sib2\": [{\"kind\": \"FLOW\", \"element_name\": \"shared\", \"api_name\": \"Shared\"}],\n \"Shared\": [],\n }\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"Root\", \"children\": []}\n\n parse_wave.inflate_flow_leaf(leaf, flow_children)\n\n self.assertEqual(len(leaf[\"children\"]), 2)\n for sib in leaf[\"children\"]:\n # Each sibling must have Shared as a fully-expanded child —\n # NOT a cycle stub.\n self.assertEqual(len(sib[\"children\"]), 1)\n shared = sib[\"children\"][0]\n self.assertEqual(shared[\"api_name\"], \"Shared\")\n self.assertNotIn(\"_cycle_back_to\", shared)\n\n def test_empty_flow_children_returns_immediately(self):\n \"\"\"No data for this leaf → preserve existing children (no-op).\"\"\"\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"X\", \"children\": [{\"kind\": \"APEX\", \"api_name\": \"Y\"}]}\n parse_wave.inflate_flow_leaf(leaf, {}) # empty map\n # Preserved unchanged\n self.assertEqual(leaf[\"children\"], [{\"kind\": \"APEX\", \"api_name\": \"Y\"}])\n\n def test_non_flow_leaf_ignored(self):\n \"\"\"Only FLOW kind is inflated.\"\"\"\n leaf = {\"kind\": \"APEX\", \"api_name\": \"X\"}\n parse_wave.inflate_flow_leaf(leaf, {\"X\": [{\"kind\": \"FLOW\", \"api_name\": \"Z\"}]})\n self.assertNotIn(\"children\", leaf)\n\n\nclass CycleKeyTests(unittest.TestCase):\n \"\"\"Unit test for the tuple-key helper — safety net on schema drift.\"\"\"\n\n def test_tuple_shape(self):\n self.assertEqual(\n parse_wave._cycle_key({\"kind\": \"FLOW\", \"api_name\": \"Foo\"}),\n (\"FLOW\", \"Foo\"),\n )\n\n def test_missing_kind_or_name_safe(self):\n self.assertEqual(parse_wave._cycle_key({}), (\"\", \"\"))\n\n\ndef _linear_flow_chain(names: list[str]) -> dict:\n \"\"\"Build a flow_children graph: FLOW[i] → FLOW[i+1], last flow has no kids.\n\n Returns the `flow_children` mapping that inflate_flow_leaf consumes.\n \"\"\"\n graph: dict = {}\n for i, n in enumerate(names):\n if i + 1 \u003c len(names):\n graph[n] = [\n {\"kind\": \"FLOW\", \"element_name\": f\"call_{names[i+1]}\",\n \"api_name\": names[i+1]},\n ]\n else:\n graph[n] = []\n return graph\n\n\nclass DepthCapPartialTests(unittest.TestCase):\n \"\"\"`MAX_BFS_DEPTH` is a defensive guard,\n not a functional chain-depth limit. Fixtures here push past the cap\n to prove it still terminates and still populates `pending_out` when\n it trips. Cycle semantics live in per-branch ancestor tracking and\n are covered by `test_per_branch_visited.py`.\n \"\"\"\n\n def _deep_linear_names(self, n: int) -> list[str]:\n return [f\"F{i}\" for i in range(1, n + 1)]\n\n def test_chain_under_cap_fully_expanded(self):\n \"\"\"A linear chain shorter than `MAX_BFS_DEPTH` fully expands with\n no pending. Prior to the revision this used a 5-flow chain; with\n a defensive cap of 20, 5 is trivially under-cap but the shape of\n the assertion is unchanged.\n \"\"\"\n names = [\"F1\", \"F2\", \"F3\", \"F4\", \"F5\"]\n graph = _linear_flow_chain(names)\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"F1\", \"children\": []}\n pending: dict[str, set[str]] = {k: set() for k in parse_wave._BFS_KINDS}\n\n parse_wave.inflate_flow_leaf(leaf, graph, pending_out=pending)\n\n node = leaf\n for expected in names[1:]:\n kids = node.get(\"children\", [])\n self.assertEqual(len(kids), 1, f\"expected one child under {node['api_name']}\")\n self.assertEqual(kids[0][\"api_name\"], expected)\n node = kids[0]\n self.assertEqual(node.get(\"children\", []), [])\n\n for k in parse_wave._BFS_KINDS:\n self.assertEqual(pending[k], set())\n\n def test_chain_at_exact_cap_trips_and_records_pending(self):\n \"\"\"A linear chain of MAX_BFS_DEPTH+1 unique flows trips the\n defensive cap: the last flow must NOT be expanded AND must land\n in `pending_out[\"FLOW\"]`. This is the only functional invariant\n of the cap after the revision.\n \"\"\"\n n = parse_wave.MAX_BFS_DEPTH + 1\n names = self._deep_linear_names(n)\n graph = _linear_flow_chain(names)\n leaf = {\"kind\": \"FLOW\", \"api_name\": names[0], \"children\": []}\n pending: dict[str, set[str]] = {k: set() for k in parse_wave._BFS_KINDS}\n\n parse_wave.inflate_flow_leaf(leaf, graph, pending_out=pending)\n\n # Walk down to the flow at `MAX_BFS_DEPTH - 1` index (last one\n # before the cap trips on its child). All prior flows must have\n # been fully expanded.\n node = leaf\n for expected in names[1:parse_wave.MAX_BFS_DEPTH]:\n kids = node.get(\"children\", [])\n self.assertEqual(len(kids), 1)\n self.assertEqual(kids[0][\"api_name\"], expected)\n node = kids[0]\n\n # The last flow in the chain (the (MAX_BFS_DEPTH+1)-th) must be\n # in pending_out.\n self.assertIn(names[-1], pending[\"FLOW\"])\n\n def test_deep_chain_with_tail_self_loop_terminates_via_cycle_detection(self):\n \"\"\"Long chain with a self-loop at the tail terminates via\n per-branch cycle detection (the revised primitive), NOT the\n defensive cap. The self-loop flow appears once with children\n expanded, its self-reference annotated `_cycle_back_to`.\n \"\"\"\n # Chain of 7 unique flows, each pointing to the next, then the\n # last one points at itself — 7 is well under the cap of 20 so\n # this proves cycle detection, not depth, terminates the walk.\n names = [\"F1\", \"F2\", \"F3\", \"F4\", \"F5\", \"F6\", \"F7\"]\n graph = _linear_flow_chain(names)\n # Rewrite F7 to self-loop instead of empty.\n graph[\"F7\"] = [{\"kind\": \"FLOW\", \"api_name\": \"F7\", \"element_name\": \"self\"}]\n\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"F1\", \"children\": []}\n pending: dict[str, set[str]] = {k: set() for k in parse_wave._BFS_KINDS}\n\n # Must not hang / recurse infinitely.\n parse_wave.inflate_flow_leaf(leaf, graph, pending_out=pending)\n\n # Walk to F7.\n node = leaf\n for expected in names[1:]:\n node = node[\"children\"][0]\n self.assertEqual(node[\"api_name\"], expected)\n\n # F7 has ONE child — the cycle-annotated self-reference.\n f7_kids = node.get(\"children\", [])\n self.assertEqual(len(f7_kids), 1)\n self.assertEqual(f7_kids[0][\"api_name\"], \"F7\")\n self.assertEqual(f7_kids[0][\"_cycle_back_to\"], \"FLOW:F7\")\n self.assertEqual(\n f7_kids[0][\"_truncated\"],\n {\"reason\": \"cycle\", \"target\": \"FLOW:F7\"},\n )\n\n # Nothing in pending — cycle is annotated, not pended.\n self.assertEqual(pending[\"FLOW\"], set())\n\n def test_chain_under_cap_with_legacy_pending_none(self):\n \"\"\"Callers pre-dating pass no `pending_out`. An under-cap\n chain still expands fully — no crash on `None`, no spurious\n truncation. The pre-revision form of this test asserted cap=5\n behavior; now it asserts that a short chain expands cleanly when\n the cap plays no role.\n \"\"\"\n names = [\"F1\", \"F2\", \"F3\", \"F4\", \"F5\", \"F6\"]\n graph = _linear_flow_chain(names)\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"F1\", \"children\": []}\n\n # No pending_out — legacy call shape. Must not crash.\n parse_wave.inflate_flow_leaf(leaf, graph)\n\n # All flows expanded to the terminal leaf.\n node = leaf\n for expected in names[1:]:\n kids = node.get(\"children\", [])\n self.assertEqual(len(kids), 1, f\"{node['api_name']} should have one child\")\n self.assertEqual(kids[0][\"api_name\"], expected)\n node = kids[0]\n self.assertEqual(node.get(\"children\", []), [])\n\n def test_partial_and_unresolved_coexist(self):\n \"\"\"_unresolved + _partial-reason must both be reported.\n\n Simulate: finalize_cap drains some pending into _unresolved, but\n depth-cap already set _partial_reason=max-depth-cap. Finalize must\n NOT clobber the depth-cap reason.\n \"\"\"\n tree = {\n \"_partial\": True,\n \"_partial_reason\": \"max-depth-cap\",\n \"_pending_fetches\": {\n \"FLOW\": [\"LateFlow\"],\n \"APEX\": [],\n \"PROMPT_TEMPLATE\": [],\n \"STANDARD_ACTION\": [],\n },\n \"_unresolved\": [{\"kind\": \"APEX\", \"api_name\": \"PriorUnresolved\",\n \"reason\": \"resolve_invocation_target failed\"}],\n }\n\n parse_wave.finalize_cap(tree)\n\n # Pending drained into unresolved.\n self.assertEqual(tree[\"_pending_fetches\"][\"FLOW\"], [])\n self.assertEqual(len(tree[\"_unresolved\"]), 2)\n # Depth-cap reason preserved (NOT overwritten by max-wave-depth).\n self.assertEqual(tree[\"_partial_reason\"], \"max-depth-cap\")\n self.assertTrue(tree[\"_partial\"])\n\n\nclass MaxBfsDepthConstantTests(unittest.TestCase):\n \"\"\"`MAX_BFS_DEPTH` is a defensive guard,\n not a functional chain-depth limit. The constant must match\n `scripts/config.py::MAX_BFS_DEPTH` exactly (duplicated intentionally\n — see comment there on the \"no intra-skill imports\" convention).\n \"\"\"\n\n def test_max_bfs_depth_matches_config(self):\n \"\"\"Cross-module consistency. If someone bumps one literal they\n must bump the other.\"\"\"\n import config # type: ignore\n self.assertEqual(parse_wave.MAX_BFS_DEPTH, config.MAX_BFS_DEPTH)\n\n def test_max_bfs_depth_is_defensive_not_functional(self):\n \"\"\"The cap should be comfortably larger than any realistic\n production flow-chain depth, so that per-branch cycle detection\n — not the cap — terminates the walk in practice. `>= 15` is a\n loose sanity bound; if someone quietly tightens it below 15\n they're probably reintroducing the shared-utility-flow bug.\"\"\"\n self.assertGreaterEqual(parse_wave.MAX_BFS_DEPTH, 15)\n\n def test_legacy_alias_tracks_new_constant(self):\n \"\"\"MAX_INFLATE_DEPTH is retained as an alias.\"\"\"\n self.assertEqual(parse_wave.MAX_INFLATE_DEPTH, parse_wave.MAX_BFS_DEPTH)\n\n\nclass PublicSymbolPromotionTests(unittest.TestCase):\n \"\"\"`_BFS_KINDS` and `_empty_kind_sets` were\n promoted to public names (`BFS_KINDS`, `empty_kind_sets`) so the\n in-process `main.py` orchestrator doesn't have to reach across the\n leading-underscore boundary that closed for `redact_text`.\n The underscore forms remain as deprecated aliases for one more minor\n version.\n \"\"\"\n\n def test_public_bfs_kinds_importable(self):\n \"\"\"`parse_wave.BFS_KINDS` exists, is a tuple, and carries the\n expected four tokens in the same order as before promotion.\n \"\"\"\n self.assertTrue(hasattr(parse_wave, \"BFS_KINDS\"))\n self.assertIsInstance(parse_wave.BFS_KINDS, tuple)\n self.assertEqual(\n parse_wave.BFS_KINDS,\n (\"FLOW\", \"APEX\", \"PROMPT_TEMPLATE\", \"STANDARD_ACTION\"),\n )\n\n def test_public_empty_kind_sets_importable_and_callable(self):\n \"\"\"`parse_wave.empty_kind_sets()` returns a fresh {kind: set()}\n mapping keyed by every BFS_KINDS entry. Each call yields an\n independent dict — callers must not share buckets across waves.\n \"\"\"\n self.assertTrue(hasattr(parse_wave, \"empty_kind_sets\"))\n d1 = parse_wave.empty_kind_sets()\n d2 = parse_wave.empty_kind_sets()\n self.assertEqual(set(d1.keys()), set(parse_wave.BFS_KINDS))\n for kind in parse_wave.BFS_KINDS:\n self.assertEqual(d1[kind], set())\n # Independence: mutating one must not affect the other.\n d1[\"FLOW\"].add(\"X\")\n self.assertEqual(d2[\"FLOW\"], set())\n\n def test_underscore_aliases_still_work_for_backcompat(self):\n \"\"\"Legacy `_BFS_KINDS` / `_empty_kind_sets` imports continue to\n resolve. They are deprecated but not yet removed — existing\n tests and any external callers must keep working through the\n migration window.\n \"\"\"\n self.assertTrue(hasattr(parse_wave, \"_BFS_KINDS\"))\n self.assertTrue(hasattr(parse_wave, \"_empty_kind_sets\"))\n # Same object (alias, not a copy) so equality holds structurally.\n self.assertIs(parse_wave._BFS_KINDS, parse_wave.BFS_KINDS)\n self.assertIs(parse_wave._empty_kind_sets, parse_wave.empty_kind_sets)\n\n\nclass FetchableKindsTests(unittest.TestCase):\n \"\"\"STANDARD_ACTION is declared-only, never fetched.\n Must stay out of `_pending_fetches` even though it remains a BFS kind\n for tree counts + visited dedup.\"\"\"\n\n def test_fetchable_kinds_excludes_standard_action(self):\n \"\"\"FETCHABLE_KINDS = (FLOW, APEX, PROMPT_TEMPLATE). STANDARD_ACTION\n must be in BFS_KINDS (tree bookkeeping) but NOT in\n FETCHABLE_KINDS (body-fetch eligibility).\"\"\"\n self.assertIn(\"STANDARD_ACTION\", parse_wave.BFS_KINDS)\n self.assertNotIn(\"STANDARD_ACTION\", parse_wave.FETCHABLE_KINDS)\n self.assertEqual(\n parse_wave.FETCHABLE_KINDS,\n (\"FLOW\", \"APEX\", \"PROMPT_TEMPLATE\"),\n )\n\n def test_build_root_children_skips_standard_action_refs(self):\n \"\"\"A GenAiFunction whose unwrap is STANDARD_ACTION must NOT land\n in new_refs. Before this fix, `streamKnowledgeSearch` landed in\n `new_refs[\"STANDARD_ACTION\"]` → merged into pending → never\n visited → surfaced in _pending_fetches (pollution).\"\"\"\n bundle = {\n \"topics\": [{\n \"name\": \"T\",\n \"actions\": [{\n \"name\": \"A1\",\n \"invocationTarget\": \"streamKnowledgeSearch\",\n \"invocationTargetType\": \"standardInvocableAction\",\n }],\n }],\n \"plannerActions\": [],\n }\n visited = parse_wave.empty_kind_sets()\n aux_visited: set = set()\n children, new_refs = parse_wave.build_root_children(\n bundle, visited, aux_visited,\n )\n # Children list is built normally — tree still has the leaf.\n self.assertEqual(len(children), 1)\n # But new_refs[\"STANDARD_ACTION\"] is empty → nothing to pend on.\n self.assertEqual(new_refs[\"STANDARD_ACTION\"], set())\n self.assertEqual(new_refs[\"FLOW\"], set())\n self.assertEqual(new_refs[\"APEX\"], set())\n self.assertEqual(new_refs[\"PROMPT_TEMPLATE\"], set())\n\n def test_build_root_children_still_captures_flow_apex_prompt_refs(self):\n \"\"\"Positive control — fetchable kinds still accumulate into\n new_refs. The FOLLOWUP-2 gate is on STANDARD_ACTION only.\"\"\"\n bundle = {\n \"topics\": [{\n \"name\": \"T\",\n \"actions\": [\n {\"name\": \"F1\", \"invocationTarget\": \"FlowA\",\n \"invocationTargetType\": \"flow\"},\n {\"name\": \"A1\", \"invocationTarget\": \"ApexA\",\n \"invocationTargetType\": \"apex\"},\n {\"name\": \"P1\", \"invocationTarget\": \"PromptA\",\n \"invocationTargetType\": \"generatePromptResponse\"},\n ],\n }],\n \"plannerActions\": [],\n }\n visited = parse_wave.empty_kind_sets()\n aux_visited: set = set()\n _, new_refs = parse_wave.build_root_children(\n bundle, visited, aux_visited,\n )\n self.assertIn(\"FlowA\", new_refs[\"FLOW\"])\n self.assertIn(\"ApexA\", new_refs[\"APEX\"])\n self.assertIn(\"PromptA\", new_refs[\"PROMPT_TEMPLATE\"])\n self.assertEqual(new_refs[\"STANDARD_ACTION\"], set())\n\n\nclass UnifiedTruncationAnnotationTests(unittest.TestCase):\n \"\"\"both cycle and max-depth code paths annotate truncated\n nodes with the unified `_truncated = {reason, target}` sub-object.\n `_cycle_back_to` is preserved as a deprecated alias (backcompat).\"\"\"\n\n def test_truncation_constants_are_exposed(self):\n \"\"\"Public constants let consumers match on strings without\n duplicating the literal values.\"\"\"\n self.assertEqual(parse_wave.TRUNCATION_CYCLE, \"cycle\")\n self.assertEqual(parse_wave.TRUNCATION_MAX_DEPTH, \"max-depth\")\n self.assertIn(\n parse_wave.TRUNCATION_CYCLE, parse_wave.TRUNCATION_REASONS,\n )\n self.assertIn(\n parse_wave.TRUNCATION_MAX_DEPTH, parse_wave.TRUNCATION_REASONS,\n )\n\n def test_cycle_emits_unified_truncated_and_legacy_alias(self):\n \"\"\"Cycle annotations carry BOTH the unified `_truncated` and the\n legacy `_cycle_back_to` string (same target). Consumers that\n haven't migrated still work; new consumers read one field.\"\"\"\n flow_children = {\n # A simple A → A self-cycle inside one expansion.\n \"A\": [{\"kind\": \"FLOW\", \"api_name\": \"A\", \"element_name\": \"loop\"}],\n }\n leaf = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n parse_wave.inflate_flow_leaf(leaf, flow_children)\n\n # Find the cycle-annotated child (second encounter of A).\n kids = leaf.get(\"children\") or []\n self.assertEqual(len(kids), 1)\n cycle_node = kids[0]\n\n # Unified annotation present.\n self.assertIn(\"_truncated\", cycle_node)\n self.assertEqual(\n cycle_node[\"_truncated\"],\n {\"reason\": \"cycle\", \"target\": \"FLOW:A\"},\n )\n\n # Legacy annotation still present for backcompat.\n self.assertEqual(cycle_node[\"_cycle_back_to\"], \"FLOW:A\")\n\n def test_depth_cap_emits_unified_truncated_on_leaf(self):\n \"\"\"When MAX_BFS_DEPTH trips, the truncated FLOW leaf picks up\n `_truncated = {reason: 'max-depth', target: 'FLOW:\u003cname>'}`.\n Before the leaf carried no per-node signal at all — only\n the tree-level `_partial_reason` and the `pending_out`\n accumulator identified it.\n\n 2026-05-03: since the cap was bumped from 5 to 20 (defensive\n guard, not functional limit), the chain here is built\n programmatically at `MAX_BFS_DEPTH + 1` unique flows so the\n test tracks the constant instead of hard-coding chain length.\n \"\"\"\n n = parse_wave.MAX_BFS_DEPTH + 1 # one past the cap\n names = [f\"F{i}\" for i in range(n)]\n flow_children: dict[str, list[dict]] = {}\n for i, name in enumerate(names):\n if i + 1 \u003c n:\n flow_children[name] = [{\n \"kind\": \"FLOW\",\n \"api_name\": names[i + 1],\n \"element_name\": \"sub\",\n }]\n else:\n flow_children[name] = []\n\n root = {\"kind\": \"FLOW\", \"api_name\": names[0], \"children\": []}\n pending_out = parse_wave.empty_kind_sets()\n parse_wave.inflate_flow_leaf(root, flow_children, pending_out=pending_out)\n\n # Walk down the chain to the terminal (unreached) flow.\n def walk(n, name):\n if n.get(\"api_name\") == name:\n return n\n for c in n.get(\"children\") or []:\n found = walk(c, name)\n if found:\n return found\n return None\n\n tail = walk(root, names[-1])\n self.assertIsNotNone(tail, f\"{names[-1]} should appear as a leaf in the tree\")\n\n # Unified annotation on the depth-capped leaf.\n self.assertIn(\"_truncated\", tail)\n self.assertEqual(\n tail[\"_truncated\"],\n {\"reason\": \"max-depth\", \"target\": f\"FLOW:{names[-1]}\"},\n )\n\n # The capped leaf should NOT also carry _cycle_back_to — this\n # is a depth-cap, not a cycle.\n self.assertNotIn(\"_cycle_back_to\", tail)\n\n # pending_out still picks up the unreached name — existing\n # contract preserved.\n self.assertIn(names[-1], pending_out[\"FLOW\"])\n\n def test_non_flow_leaf_depth_cap_no_annotation(self):\n \"\"\"The cap is scoped to FLOW leaves. Non-FLOW leaves at\n cap depth are just no-op returns — no spurious `_truncated`.\"\"\"\n flow_children = {\n \"X\": [{\"kind\": \"APEX\", \"api_name\": \"SomeApex\"}],\n }\n # Build an APEX leaf that we try to \"inflate\" at depth = MAX.\n # inflate_flow_leaf with a non-FLOW leaf at depth >= MAX does\n # nothing and emits no annotation.\n leaf = {\"kind\": \"APEX\", \"api_name\": \"SomeApex\"}\n pending_out = parse_wave.empty_kind_sets()\n parse_wave.inflate_flow_leaf(\n leaf, flow_children,\n depth=parse_wave.MAX_BFS_DEPTH,\n pending_out=pending_out,\n )\n self.assertNotIn(\"_truncated\", leaf)\n self.assertEqual(pending_out[\"FLOW\"], set())\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":27501,"content_sha256":"12423a0f1e955b4c12c5fcd328794120de69b728cdb49ccacc6a5af85fd3ee5d"},{"filename":"scripts/tests/test_per_branch_visited.py","content":"\"\"\"2026-05-03 revision: `inflate_flow_leaf` uses a per-branch ancestor\npath set (`visited_in_path`) for cycle detection. The defensive\n`MAX_BFS_DEPTH` cap is preserved as a last-resort termination guard\nbut is no longer the primitive that prevents runaway recursion.\n\nThe bug this regression suite prevents: shared utility flows such as\n`handleFlowFault` appear on every real Agentforce flow's fault path.\nUnder the old `MAX_BFS_DEPTH = 5` behaviour the utility showed up in\n`_pending_fetches[\"FLOW\"]` with `PARTIAL_REASON=max-depth-cap` on any\nmoderately nested tree (e.g. `AGNT_Baz_Qux →\nhandleFlowFault`), even though `handleFlowFault` was trivially\nexpandable. Per-branch path-set semantics fix this: the same flow on\ntwo sibling branches is NOT a cycle, only an ancestor-chain recurrence\nis.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nfrom . import _bootstrap # noqa: F401\n\nimport parse_wave # type: ignore\n\n\ndef _find_all(node: dict, api_name: str) -> list[dict]:\n \"\"\"Collect every node in `node`'s subtree whose `api_name` matches.\"\"\"\n found: list[dict] = []\n if node.get(\"api_name\") == api_name:\n found.append(node)\n for c in node.get(\"children\", []) or []:\n found.extend(_find_all(c, api_name))\n return found\n\n\nclass SharedUtilityFlowExpansionTests(unittest.TestCase):\n \"\"\"Direct repro of the real-org `handleFlowFault` bug.\n\n Parent flow has two sibling children, each of which invokes the\n same utility flow. Both utility instances must expand fully.\n \"\"\"\n\n def test_shared_utility_flow_expands_on_every_branch(self):\n # parent -> [childA, childB]; childA -> utility; childB -> utility\n # utility itself calls a leaf apex (so we can assert `utility`\n # got fully inflated with a child, not just a stub).\n flow_children = {\n \"parent\": [\n {\"kind\": \"FLOW\", \"api_name\": \"childA\", \"element_name\": \"call_A\"},\n {\"kind\": \"FLOW\", \"api_name\": \"childB\", \"element_name\": \"call_B\"},\n ],\n \"childA\": [\n {\"kind\": \"FLOW\", \"api_name\": \"utility\", \"element_name\": \"call_util\"},\n ],\n \"childB\": [\n {\"kind\": \"FLOW\", \"api_name\": \"utility\", \"element_name\": \"call_util\"},\n ],\n \"utility\": [\n {\"kind\": \"APEX\", \"api_name\": \"XCSF_FlowFaultMessage\", \"element_name\": \"log\"},\n ],\n }\n root = {\"kind\": \"FLOW\", \"api_name\": \"parent\", \"children\": []}\n pending = parse_wave.empty_kind_sets()\n\n parse_wave.inflate_flow_leaf(root, flow_children, pending_out=pending)\n\n utilities = _find_all(root, \"utility\")\n self.assertEqual(\n len(utilities), 2,\n \"utility should appear once on each sibling branch\",\n )\n\n # Both utility instances must have expanded children (the apex\n # leaf). The regression shape was: first branch expanded, second\n # either in `_pending_fetches` or emitted as an empty-children\n # stub due to depth-cap or false-cycle pruning.\n for i, u in enumerate(utilities):\n kids = u.get(\"children\", [])\n self.assertEqual(\n len(kids), 1,\n f\"utility instance {i} should have 1 expanded child, got {kids}\",\n )\n self.assertEqual(kids[0][\"api_name\"], \"XCSF_FlowFaultMessage\")\n self.assertNotIn(\"_truncated\", u)\n self.assertNotIn(\"_cycle_back_to\", u)\n\n # Critically: nothing pending. Shared-utility expansion must not\n # leak into `_pending_fetches`.\n self.assertEqual(pending[\"FLOW\"], set())\n\n\nclass TrueCycleDetectionTests(unittest.TestCase):\n \"\"\"Per-branch path-set must still catch genuine cycles.\"\"\"\n\n def test_true_cycle_detected(self):\n \"\"\"A -> B -> A: second A is annotated, not recursed into, and\n not surfaced in `_pending_fetches`.\"\"\"\n flow_children = {\n \"A\": [{\"kind\": \"FLOW\", \"api_name\": \"B\", \"element_name\": \"call_B\"}],\n \"B\": [{\"kind\": \"FLOW\", \"api_name\": \"A\", \"element_name\": \"call_A\"}],\n }\n root = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n pending = parse_wave.empty_kind_sets()\n\n parse_wave.inflate_flow_leaf(root, flow_children, pending_out=pending)\n\n # A -> B -> A(cycle)\n b = root[\"children\"][0]\n self.assertEqual(b[\"api_name\"], \"B\")\n a_cycle = b[\"children\"][0]\n self.assertEqual(a_cycle[\"api_name\"], \"A\")\n self.assertEqual(\n a_cycle[\"_truncated\"],\n {\"reason\": \"cycle\", \"target\": \"FLOW:A\"},\n )\n self.assertEqual(a_cycle[\"_cycle_back_to\"], \"FLOW:A\")\n\n # Cycles live in the tree, not the pending accumulator.\n self.assertEqual(pending[\"FLOW\"], set())\n\n def test_self_recursive_flow(self):\n \"\"\"A -> A: direct self-cycle is annotated on first recurrence.\"\"\"\n flow_children = {\n \"A\": [{\"kind\": \"FLOW\", \"api_name\": \"A\", \"element_name\": \"self_call\"}],\n }\n root = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n pending = parse_wave.empty_kind_sets()\n\n parse_wave.inflate_flow_leaf(root, flow_children, pending_out=pending)\n\n kids = root.get(\"children\", [])\n self.assertEqual(len(kids), 1)\n self.assertEqual(kids[0][\"api_name\"], \"A\")\n self.assertEqual(\n kids[0][\"_truncated\"],\n {\"reason\": \"cycle\", \"target\": \"FLOW:A\"},\n )\n self.assertEqual(pending[\"FLOW\"], set())\n\n\nclass DeepNonCyclicChainTests(unittest.TestCase):\n \"\"\"Linear chains longer than the old `MAX_BFS_DEPTH = 5` must\n expand fully under the revised semantics. Previously they tripped\n `PARTIAL_REASON=max-depth-cap`; now they expand cleanly.\n \"\"\"\n\n def test_deep_non_cyclic_chain_fully_expands(self):\n # A -> B -> C -> D -> E -> F -> G (7 unique flows, depth 6\n # from the root). Old cap of 5 would have truncated at F;\n # new cap of 20 doesn't care.\n names = list(\"ABCDEFG\")\n flow_children: dict = {}\n for i, n in enumerate(names):\n if i + 1 \u003c len(names):\n flow_children[n] = [{\n \"kind\": \"FLOW\",\n \"api_name\": names[i + 1],\n \"element_name\": f\"call_{names[i + 1]}\",\n }]\n else:\n flow_children[n] = []\n\n root = {\"kind\": \"FLOW\", \"api_name\": \"A\", \"children\": []}\n pending = parse_wave.empty_kind_sets()\n parse_wave.inflate_flow_leaf(root, flow_children, pending_out=pending)\n\n node = root\n for expected in names[1:]:\n kids = node.get(\"children\", [])\n self.assertEqual(\n len(kids), 1,\n f\"{node['api_name']} should have expanded child {expected}\",\n )\n self.assertEqual(kids[0][\"api_name\"], expected)\n self.assertNotIn(\"_truncated\", kids[0])\n node = kids[0]\n\n # Terminal leaf has no children and carries no truncation\n # annotation — the cap was never relevant.\n self.assertEqual(node.get(\"children\", []), [])\n self.assertNotIn(\"_truncated\", node)\n self.assertEqual(pending[\"FLOW\"], set())\n\n\nclass DefensiveCapStillTerminatesTests(unittest.TestCase):\n \"\"\"Pathological chain longer than the defensive cap must still\n terminate cleanly. This proves the safety net still works even\n though per-branch cycle detection does the real work in practice.\n \"\"\"\n\n def test_defensive_cap_still_terminates(self):\n # 25 unique flows, linear — longer than the defensive cap of 20.\n # Either the tree fully expands (if someone lifts the cap\n # further) OR it caps at MAX_BFS_DEPTH and annotates the\n # unreached flow. The test asserts termination and one of\n # those two well-defined shapes.\n n = 25\n names = [f\"F{i:02d}\" for i in range(n)]\n flow_children: dict = {}\n for i, name in enumerate(names):\n if i + 1 \u003c n:\n flow_children[name] = [{\n \"kind\": \"FLOW\",\n \"api_name\": names[i + 1],\n \"element_name\": \"sub\",\n }]\n else:\n flow_children[name] = []\n\n root = {\"kind\": \"FLOW\", \"api_name\": names[0], \"children\": []}\n pending = parse_wave.empty_kind_sets()\n\n # Primary assertion: this call terminates and does not recurse\n # past Python's default recursion limit. If the defensive cap\n # regresses, this test will hang or raise RecursionError.\n parse_wave.inflate_flow_leaf(root, flow_children, pending_out=pending)\n\n # Collect every flow that appears in the tree.\n def all_api_names(node: dict) -> set[str]:\n out = {node.get(\"api_name\")}\n for c in node.get(\"children\", []) or []:\n out.update(all_api_names(c))\n return out - {None}\n\n present = all_api_names(root)\n\n # At least the first MAX_BFS_DEPTH flows must be present. The\n # tail may or may not appear depending on where the cap trips.\n for i in range(parse_wave.MAX_BFS_DEPTH):\n self.assertIn(\n names[i], present,\n f\"{names[i]} (index {i}) should be expanded — \"\n f\"it's below MAX_BFS_DEPTH={parse_wave.MAX_BFS_DEPTH}\",\n )\n\n # If any flow is pending, it must be a real descendant (not a\n # fabricated name) and must carry the max-depth annotation\n # somewhere in the tree.\n if pending[\"FLOW\"]:\n for pending_name in pending[\"FLOW\"]:\n self.assertIn(pending_name, names)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9777,"content_sha256":"74164859ee815b1a94a055986468b1a083233f701328f8cb1cd3faafc66db4ee"},{"filename":"scripts/tests/test_probe_channels.py","content":"\"\"\"Tests for channel probe TTL (7d), mandatory-field gate,\nruntime invalidate + re-probe.\n\nEvery test mocks `sf_cli.run_sf` — no real org calls. Cache dir is\nredirected into a tempdir via monkey-patching `config.PROBE_CACHE_ROOT`\nto keep tests hermetic.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport stat\nimport sys\nimport tempfile\nimport time\nimport unittest\nimport unittest.mock as mock\nfrom pathlib import Path\n\nfrom . import _bootstrap # noqa: F401\n\nimport config # type: ignore\nimport probe_channels # type: ignore\n\n\ndef _fake_describe_ok(fields: list[str]) -> dict:\n \"\"\"Shape the sf_cli.run_sf output: success envelope wrapping a fields list.\"\"\"\n return {\n \"status\": 0,\n \"result\": {\n \"fields\": [{\"name\": f, \"queryable\": True} for f in fields],\n },\n }\n\n\n# A \"universal\" success payload: every mandatory field for every sObject\n# present. Individual tests override for specific sObjects.\ndef _all_fields_ok_run_sf(recipe: str, **params: str) -> dict:\n sobject = params[\"SOBJECT\"]\n mandatory = probe_channels.MANDATORY_FIELDS.get(sobject, set())\n # Add a couple of bonus fields so queryable_fields isn't identical to\n # mandatory_missing-inverse by construction.\n extra = {\"CreatedDate\", \"LastModifiedDate\"}\n return _fake_describe_ok(sorted(mandatory | extra))\n\n\nclass ProbeChannelsCacheTests(unittest.TestCase):\n \"\"\"Cache hit / miss / TTL semantics.\"\"\"\n\n def setUp(self) -> None:\n self._tmp = tempfile.TemporaryDirectory()\n self.addCleanup(self._tmp.cleanup)\n self._orig_root = config.PROBE_CACHE_ROOT\n config.PROBE_CACHE_ROOT = Path(self._tmp.name) / \"_channel_probe\"\n\n def tearDown(self) -> None:\n config.PROBE_CACHE_ROOT = self._orig_root\n\n def test_cache_hit_within_ttl_no_network(self):\n \"\"\"A fresh cache file + force_refresh=False → no run_sf calls.\"\"\"\n cache_dir = config.build_probe_cache_dir(\"00D000000000ABC\", \"v60.0\")\n cache_dir.mkdir(parents=True, exist_ok=True)\n cache_file = cache_dir / \"channels.json\"\n payload = {\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": time.time(), # right now → fresh\n \"status\": \"OK\",\n \"channels\": {\"BotDefinition\": {\"queryable_fields\": [\"Id\"],\n \"mandatory_missing\": [],\n \"describe_error\": None}},\n }\n cache_file.write_text(json.dumps(payload))\n\n with mock.patch.object(probe_channels, \"run_sf\") as m:\n out = probe_channels.probe_channels(\n \"myorg\", \"00D000000000ABC\", \"v60.0\", force_refresh=False,\n )\n self.assertEqual(out[\"status\"], \"OK\")\n m.assert_not_called()\n\n def test_cache_stale_triggers_reprobe(self):\n \"\"\"Cached file older than TTL → re-run describes.\"\"\"\n cache_dir = config.build_probe_cache_dir(\"00D000000000ABC\", \"v60.0\")\n cache_dir.mkdir(parents=True, exist_ok=True)\n cache_file = cache_dir / \"channels.json\"\n stale = {\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": time.time() - (8 * 86400), # 8 days old\n \"status\": \"OK\",\n \"channels\": {},\n }\n cache_file.write_text(json.dumps(stale))\n\n with mock.patch.object(probe_channels, \"run_sf\",\n side_effect=_all_fields_ok_run_sf) as m:\n out = probe_channels.probe_channels(\n \"myorg\", \"00D000000000ABC\", \"v60.0\", force_refresh=False,\n )\n self.assertEqual(out[\"status\"], \"OK\")\n # One call per sObject.\n self.assertEqual(m.call_count, len(probe_channels.ALL_SOBJECTS))\n\n def test_force_refresh_always_reprobes(self):\n \"\"\"force_refresh=True bypasses a fresh cache.\"\"\"\n cache_dir = config.build_probe_cache_dir(\"00D000000000ABC\", \"v60.0\")\n cache_dir.mkdir(parents=True, exist_ok=True)\n cache_file = cache_dir / \"channels.json\"\n fresh = {\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": time.time(),\n \"status\": \"OK\",\n \"channels\": {},\n }\n cache_file.write_text(json.dumps(fresh))\n\n with mock.patch.object(probe_channels, \"run_sf\",\n side_effect=_all_fields_ok_run_sf) as m:\n probe_channels.probe_channels(\n \"myorg\", \"00D000000000ABC\", \"v60.0\", force_refresh=True,\n )\n self.assertEqual(m.call_count, len(probe_channels.ALL_SOBJECTS))\n\n\nclass InvalidateAndReprobeTests(unittest.TestCase):\n \"\"\"runtime escape valve when SOQL returns INVALID_FIELD.\"\"\"\n\n def setUp(self) -> None:\n self._tmp = tempfile.TemporaryDirectory()\n self.addCleanup(self._tmp.cleanup)\n self._orig_root = config.PROBE_CACHE_ROOT\n config.PROBE_CACHE_ROOT = Path(self._tmp.name) / \"_channel_probe\"\n\n def tearDown(self) -> None:\n config.PROBE_CACHE_ROOT = self._orig_root\n\n def test_invalidate_deletes_cache_and_reruns(self):\n cache_dir = config.build_probe_cache_dir(\"00D000000000ABC\", \"v60.0\")\n cache_dir.mkdir(parents=True, exist_ok=True)\n cache_file = cache_dir / \"channels.json\"\n cache_file.write_text(json.dumps({\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": time.time(),\n \"status\": \"OK\",\n \"channels\": {},\n }))\n\n with mock.patch.object(probe_channels, \"run_sf\",\n side_effect=_all_fields_ok_run_sf) as m:\n out = probe_channels.invalidate_and_reprobe(\n \"myorg\", \"00D000000000ABC\", \"v60.0\",\n )\n\n self.assertEqual(out[\"status\"], \"OK\")\n self.assertTrue(cache_file.is_file(), \"fresh cache must be rewritten\")\n self.assertEqual(m.call_count, len(probe_channels.ALL_SOBJECTS))\n\n def test_invalidate_tolerates_missing_cache(self):\n \"\"\"Cold-path invalidate (no file yet) must not crash.\"\"\"\n with mock.patch.object(probe_channels, \"run_sf\",\n side_effect=_all_fields_ok_run_sf):\n out = probe_channels.invalidate_and_reprobe(\n \"myorg\", \"00D000000000ABC\", \"v60.0\",\n )\n self.assertEqual(out[\"status\"], \"OK\")\n\n\nclass MandatoryFieldGateTests(unittest.TestCase):\n \"\"\"Missing mandatory field → status flips to PROBE_FAILED.\"\"\"\n\n def setUp(self) -> None:\n self._tmp = tempfile.TemporaryDirectory()\n self.addCleanup(self._tmp.cleanup)\n self._orig_root = config.PROBE_CACHE_ROOT\n config.PROBE_CACHE_ROOT = Path(self._tmp.name) / \"_channel_probe\"\n\n def tearDown(self) -> None:\n config.PROBE_CACHE_ROOT = self._orig_root\n\n def test_missing_invocation_target_flags_probe_failed(self):\n \"\"\"SF release drops `InvocationTarget` on\n GenAiFunctionDefinition → probe must surface PROBE_FAILED.\n \"\"\"\n\n def mock_run_sf(recipe: str, **params: str) -> dict:\n sobject = params[\"SOBJECT\"]\n mandatory = set(probe_channels.MANDATORY_FIELDS.get(sobject, set()))\n if sobject == \"GenAiFunctionDefinition\":\n # Simulate the schema drift: drop InvocationTarget.\n mandatory.discard(\"InvocationTarget\")\n return _fake_describe_ok(sorted(mandatory))\n\n with mock.patch.object(probe_channels, \"run_sf\", side_effect=mock_run_sf):\n out = probe_channels.probe_channels(\n \"myorg\", \"00D000000000ABC\", \"v60.0\", force_refresh=True,\n )\n\n self.assertEqual(out[\"status\"], \"PROBE_FAILED\")\n entry = out[\"channels\"][\"GenAiFunctionDefinition\"]\n self.assertIn(\"InvocationTarget\", entry[\"mandatory_missing\"])\n # Other sObjects unaffected.\n self.assertEqual(\n out[\"channels\"][\"BotDefinition\"][\"mandatory_missing\"], [],\n )\n\n\nclass CacheFilePermissionsTests(unittest.TestCase):\n \"\"\"cached channels.json must be 0o600 (owner RW only).\"\"\"\n\n def setUp(self) -> None:\n self._tmp = tempfile.TemporaryDirectory()\n self.addCleanup(self._tmp.cleanup)\n self._orig_root = config.PROBE_CACHE_ROOT\n config.PROBE_CACHE_ROOT = Path(self._tmp.name) / \"_channel_probe\"\n\n def tearDown(self) -> None:\n config.PROBE_CACHE_ROOT = self._orig_root\n\n def test_cache_file_is_mode_0o600(self):\n with mock.patch.object(probe_channels, \"run_sf\",\n side_effect=_all_fields_ok_run_sf):\n probe_channels.probe_channels(\n \"myorg\", \"00D000000000ABC\", \"v60.0\", force_refresh=True,\n )\n cache_file = (\n config.build_probe_cache_dir(\"00D000000000ABC\", \"v60.0\")\n / \"channels.json\"\n )\n self.assertTrue(cache_file.is_file())\n mode = stat.S_IMODE(cache_file.stat().st_mode)\n self.assertEqual(\n mode, 0o600,\n f\"cache file mode must be 0o600, got 0o{mode:03o} \",\n )\n\n\nclass CacheFreshnessBoundsTests(unittest.TestCase):\n \"\"\"`_is_cache_fresh` must reject future timestamps as stale.\n\n A `_built_at_utc` in the future is a symptom of clock skew (NTP\n drift after suspend, container clock jump) or cache tampering —\n either way, we treat it as untrustworthy and force a reprobe.\n \"\"\"\n\n def setUp(self) -> None:\n self._tmp = tempfile.TemporaryDirectory()\n self.addCleanup(self._tmp.cleanup)\n\n def test_future_timestamp_is_not_fresh(self):\n # Year 3000 → unambiguously future.\n future_epoch = 32503680000.0\n cache_file = Path(self._tmp.name) / \"channels.json\"\n cache_file.write_text(json.dumps({\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": future_epoch,\n \"status\": \"OK\",\n \"channels\": {},\n }))\n self.assertFalse(\n probe_channels._is_cache_fresh(cache_file, ttl_days=7),\n \"future _built_at_utc must be rejected as stale\",\n )\n\n def test_present_timestamp_is_fresh(self):\n cache_file = Path(self._tmp.name) / \"channels.json\"\n cache_file.write_text(json.dumps({\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": time.time(),\n \"status\": \"OK\",\n \"channels\": {},\n }))\n self.assertTrue(probe_channels._is_cache_fresh(cache_file, ttl_days=7))\n\n\nclass WriteCacheConcurrencyTests(unittest.TestCase):\n \"\"\"concurrent _write_cache calls must not collide on tmp names.\n\n The prior design used `path.with_suffix(path.suffix + \".tmp\")` — a\n deterministic name shared by every concurrent writer. This test\n fires N threads that all write the same target path; after the dust\n settles there must be exactly one final file, no leftover `.tmp`\n files, and the contents must be valid JSON from ONE of the writers\n (any of them is acceptable — what matters is integrity).\n \"\"\"\n\n def setUp(self) -> None:\n self._tmp = tempfile.TemporaryDirectory()\n self.addCleanup(self._tmp.cleanup)\n\n def test_concurrent_writes_no_collision_no_corruption(self):\n import threading\n\n target_dir = Path(self._tmp.name) / \"probe\"\n target_path = target_dir / \"channels.json\"\n\n N = 8\n errors: list[BaseException] = []\n barrier = threading.Barrier(N)\n\n def worker(i: int) -> None:\n payload = {\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": float(i + 1),\n \"status\": \"OK\",\n \"channels\": {\"writer\": {\"queryable_fields\": [f\"w{i}\"]}},\n }\n barrier.wait() # maximise contention\n try:\n probe_channels._write_cache(target_path, payload)\n except BaseException as e: # noqa: BLE001 — thread boundary\n errors.append(e)\n\n threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)]\n for t in threads:\n t.start()\n for t in threads:\n t.join(timeout=5.0)\n\n self.assertEqual(errors, [], f\"worker errors: {errors}\")\n self.assertTrue(target_path.is_file())\n\n # No stray tmp files left behind.\n leftover = list(target_dir.glob(\".channels.*.tmp\"))\n self.assertEqual(\n leftover, [],\n f\"concurrent writes left tmp leftovers: {leftover}\",\n )\n\n # Final file is parseable JSON (not a half-written corruption).\n parsed = json.loads(target_path.read_text())\n # One of the N payloads won the race; its _built_at_utc is in [1, N].\n self.assertIn(parsed[\"_built_at_utc\"], [float(i + 1) for i in range(N)])\n\n def test_parent_dir_permissions_are_0o700(self):\n \"\"\"parent dir is normalized to 0o700 after write.\n\n Even if the directory pre-existed with looser permissions, a\n successful _write_cache leaves it at 0o700.\n \"\"\"\n target_path = Path(self._tmp.name) / \"probe\" / \"channels.json\"\n target_path.parent.mkdir(parents=True, exist_ok=True)\n # Deliberately widen perms to prove _write_cache narrows them.\n os.chmod(target_path.parent, 0o755)\n\n probe_channels._write_cache(target_path, {\n \"_schema\": \"channels/1\",\n \"_built_at_utc\": time.time(),\n \"status\": \"OK\",\n \"channels\": {},\n })\n\n mode = stat.S_IMODE(target_path.parent.stat().st_mode)\n self.assertEqual(\n mode, 0o700,\n f\"parent dir mode must be 0o700, got 0o{mode:03o}\",\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13621,"content_sha256":"ac7013b9a03d943a47f03171679d4cc97454ecd501517eb99b03ac3ba74f0f9d"},{"filename":"scripts/tests/test_probe_cli_recipes.py","content":"\"\"\"structural validation of the describe CLI recipes.\n\nBefore these recipes shipped, `scripts/probe_channels.py::_describe_sobject`\ncalled `run_sf(\"describe_sobject\", ...)` / `run_sf(\"describe_tooling_sobject\",\n...)` against YAMLs that didn't exist on disk. Every unit test mocked\n`run_sf`, so the suite stayed green — but the first real invocation would\nhave raised `SfCliError: recipe not found`. This module closes that gap\nby loading each YAML via `sf_cli._load_recipe` and asserting the schema\n`sf_cli.run_sf` expects.\n\nDeliberately does NOT invoke `sf sobject describe` — that needs a live\norg. Structural validation only; the wire-level contract is covered by\nthe existing probe_channels integration path once the skill ships.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\nimport unittest.mock as mock\nfrom pathlib import Path\n\nfrom . import _bootstrap # noqa: F401\n\nimport sf_cli # type: ignore\n\n# SKILL_ROOT is now file-relative (config.py uses\n# Path(__file__).resolve().parent.parent), so config.CLI_DIR auto-resolves\n# to the repo's assets/cli/ under test. _REPO_CLI_DIR is kept for tests\n# that compare paths or pass them as explicit args.\n_REPO_CLI_DIR = (\n Path(__file__).resolve().parent.parent.parent / \"assets\" / \"cli\"\n)\n\n\nclass DescribeRecipeStructureTests(unittest.TestCase):\n \"\"\"Both describe recipes must parse + conform to the run_sf schema.\"\"\"\n\n def setUp(self) -> None:\n # patch the module-level CLI_DIR binding inside sf_cli so\n # _load_recipe reads from the repo's assets/cli/. `sf_cli`\n # imported `CLI_DIR` by name at module load, so we must patch\n # the *local* reference in sf_cli — patching `config.CLI_DIR`\n # alone would leave the stale import behind.\n self._patch = mock.patch.object(sf_cli, \"CLI_DIR\", _REPO_CLI_DIR)\n self._patch.start()\n self.addCleanup(self._patch.stop)\n\n def _load(self, name: str) -> dict:\n # _load_recipe is the same function run_sf uses at runtime, so if\n # it parses here it will parse in production. yaml.safe_load + the\n # mapping-shape guard both run inside _load_recipe .\n return sf_cli._load_recipe(name)\n\n # ---- parsing + mapping shape -----------------------------------------\n\n def test_describe_sobject_parses(self):\n recipe = self._load(\"describe_sobject\")\n self.assertIsInstance(recipe, dict)\n self.assertEqual(recipe.get(\"name\"), \"describe_sobject\")\n\n def test_describe_tooling_sobject_parses(self):\n recipe = self._load(\"describe_tooling_sobject\")\n self.assertIsInstance(recipe, dict)\n self.assertEqual(recipe.get(\"name\"), \"describe_tooling_sobject\")\n\n # ---- required_params: a list, covers what probe_channels passes -------\n\n def test_required_params_are_list_of_strings(self):\n for name in (\"describe_sobject\", \"describe_tooling_sobject\"):\n with self.subTest(recipe=name):\n recipe = self._load(name)\n required = recipe.get(\"required_params\")\n self.assertIsInstance(required, list)\n self.assertTrue(\n all(isinstance(p, str) for p in required),\n f\"{name}: required_params must be list[str]\",\n )\n # probe_channels._describe_sobject passes exactly these two.\n # If the recipe drops either, run_sf will SfCliError before\n # the subprocess call — so assert both are present.\n self.assertIn(\"ORG_ALIAS\", required)\n self.assertIn(\"SOBJECT\", required)\n\n # ---- argv: a list of strings with both placeholders referenced --------\n\n def test_argv_is_list_of_strings(self):\n for name in (\"describe_sobject\", \"describe_tooling_sobject\"):\n with self.subTest(recipe=name):\n recipe = self._load(name)\n argv = recipe.get(\"argv\")\n self.assertIsInstance(argv, list)\n self.assertGreater(len(argv), 0)\n self.assertTrue(\n all(isinstance(e, str) for e in argv),\n f\"{name}: argv must be list[str]\",\n )\n\n def test_argv_references_both_required_placeholders(self):\n \"\"\"Every key in required_params must appear as `{{KEY}}` somewhere\n in argv — otherwise the substitution is a no-op and the caller's\n input never reaches the CLI. Defensive sanity check, not a\n functional run.\n \"\"\"\n for name in (\"describe_sobject\", \"describe_tooling_sobject\"):\n with self.subTest(recipe=name):\n recipe = self._load(name)\n argv_joined = \" \".join(recipe[\"argv\"])\n for key in recipe.get(\"required_params\") or []:\n self.assertIn(\n f\"{{{{{key}}}}}\", argv_joined,\n f\"{name}: required param {key} not referenced in argv\",\n )\n\n # ---- success_check contract -----------------------------------------\n\n def test_success_check_is_stdout_json_status_zero(self):\n \"\"\"run_sf's success-detection code path (in sf_cli.run_sf) assumes\n `stdout_json_status_zero`. Any other value silently changes the\n pass/fail contract, so pin it.\n \"\"\"\n for name in (\"describe_sobject\", \"describe_tooling_sobject\"):\n with self.subTest(recipe=name):\n recipe = self._load(name)\n self.assertEqual(\n recipe.get(\"success_check\"), \"stdout_json_status_zero\",\n )\n\n # ---- timeout is a positive int ---------------------------------------\n\n def test_timeout_is_positive_int(self):\n for name in (\"describe_sobject\", \"describe_tooling_sobject\"):\n with self.subTest(recipe=name):\n recipe = self._load(name)\n t = recipe.get(\"timeout_seconds\")\n self.assertIsInstance(t, int)\n self.assertGreater(t, 0)\n\n # ---- auth_required_stderr_patterns -----------------------------------\n\n def test_auth_patterns_cover_the_known_failures(self):\n \"\"\"sf CLI surfaces `NoOrgAuthenticationError` for a never-logged-in\n alias and `AuthInfoError` for a stale/revoked session. Both must\n classify as AuthRequired, not a generic SfCliError, so the skill\n can prompt the user to re-login instead of aborting.\n \"\"\"\n for name in (\"describe_sobject\", \"describe_tooling_sobject\"):\n with self.subTest(recipe=name):\n recipe = self._load(name)\n patterns = recipe.get(\"auth_required_stderr_patterns\") or []\n self.assertIn(\"NoOrgAuthenticationError\", patterns)\n self.assertIn(\"AuthInfoError\", patterns)\n\n # ---- tooling recipe has --use-tooling-api; data-API recipe does NOT ---\n\n def test_tooling_recipe_uses_tooling_api_flag(self):\n recipe = self._load(\"describe_tooling_sobject\")\n self.assertIn(\"--use-tooling-api\", recipe[\"argv\"])\n\n def test_data_api_recipe_does_not_use_tooling_api_flag(self):\n \"\"\"Separating the two recipes rather than toggling via a param\n was a deliberate design call (call sites stay explicit). Verify\n the separation hasn't regressed into a single merged recipe.\n \"\"\"\n recipe = self._load(\"describe_sobject\")\n self.assertNotIn(\"--use-tooling-api\", recipe[\"argv\"])\n\n # ---- run_sf missing-param surfacing runs end-to-end through recipe ----\n\n def test_run_sf_raises_cleanly_when_required_param_absent(self):\n \"\"\"This exercises the full load_recipe → required_params check\n path inside run_sf, with a REAL (on-disk) recipe rather than a\n mock. If the YAML parses but required_params is malformed, this\n would surface as something other than SfCliError with a clear\n 'missing required params' message.\n \"\"\"\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n # Deliberately omit SOBJECT — should raise at the required-\n # params check, well before subprocess.run. Pass ORG_ALIAS so\n # we isolate the failure to the missing param.\n sf_cli.run_sf(\"describe_sobject\", ORG_ALIAS=\"whatever\")\n self.assertIn(\"missing required params\", str(ctx.exception))\n self.assertIn(\"SOBJECT\", str(ctx.exception))\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8445,"content_sha256":"9b14ed67877e5d79c20bad9d72b873f5840907504bf695186668ce4bfa28ec3e"},{"filename":"scripts/tests/test_render_architecture.py","content":"\"\"\"Tests for render_architecture.render + load_mermaid.\n\nP2.2-1: exercise every per-generation branch (classic ReAct, classic\nSequential, NGA ConcurrentMultiAgent, search/BYOP placeholder), the\n_partial / _unresolved / cycle surfaces, and the node-cap behaviour.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport render_architecture # type: ignore\n\n# SKILL_ROOT is now file-relative (config.py uses\n# Path(__file__).resolve().parent.parent), so config.MERMAID_DIR auto-\n# resolves to the repo's assets/mermaid/ under test. We still construct\n# _REPO_MERMAID_DIR explicitly for tests that compare paths or pass them\n# as args; render_architecture's own MERMAID_DIR captures the right path\n# at module import time.\n_REPO_MERMAID_DIR = (\n Path(__file__).resolve().parent.parent.parent / \"assets\" / \"mermaid\"\n)\n\n\ndef _classic_react_tree() -> dict:\n \"\"\"MyAgent v5 shape — classic ReAct, 2 topics, a handful of\n children to keep the fixture inline-readable.\"\"\"\n return {\n \"_schema_version\": \"3.0\",\n \"agent\": {\n \"api_name\": \"MyAgent\",\n \"version\": \"v5\",\n \"master_label\": \"Xero support AI\",\n \"description\": \"External-facing service agent.\",\n \"agent_type\": \"EinsteinAgentKind\",\n \"type\": \"ExternalCopilot\",\n \"agent_template\": \"SvcCopilotTmpl__EinsteinAgentKind\",\n \"bot_source\": \"None\",\n \"generation\": \"classic\",\n \"planner_name\": \"MyAgent_v2_v3_v4_v5\",\n \"planner_type\": \"AiCopilot__ReAct\",\n \"bot_id\": \"0XxXx00000000FdKAI\",\n },\n \"root\": {\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": \"MyAgent\",\n \"children\": [\n {\n \"kind\": \"TOPIC\",\n \"api_name\": \"Customer_Q_A\",\n \"children\": [\n {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"Find_Articles\",\n \"unwraps_to\": {\"kind\": \"FLOW\",\n \"api_name\": \"Find_Public_Articles\"},\n \"children\": [\n {\n \"kind\": \"FLOW\",\n \"api_name\": \"Find_Public_Articles\",\n \"children\": [\n {\"kind\": \"APEX\",\n \"api_name\": \"KnowledgeRetriever\"},\n ],\n },\n ],\n },\n ],\n },\n {\n \"kind\": \"TOPIC\",\n \"api_name\": \"Escalation\",\n \"children\": [\n {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"Escalate\",\n \"unwraps_to\": {\"kind\": \"APEX\",\n \"api_name\": \"EscalateAction\"},\n \"children\": [\n {\"kind\": \"APEX\", \"api_name\": \"EscalateAction\"},\n ],\n },\n ],\n },\n ],\n },\n \"node_count\": 8,\n \"depth\": 4,\n \"_partial\": False,\n \"_pending_fetches\": {\"Flow\": [], \"ApexClass\": [], \"GenAiPromptTemplate\": []},\n \"_unresolved\": [],\n \"_kind_counts\": {\n \"BOT_DEFINITION\": 1,\n \"TOPIC\": 2,\n \"GEN_AI_FUNCTION\": 2,\n \"FLOW\": 1,\n \"APEX\": 2,\n },\n }\n\n\ndef _nga_tree() -> dict:\n t = _classic_react_tree()\n t[\"agent\"][\"generation\"] = \"nga\"\n t[\"agent\"][\"planner_name\"] = \"Atlas__ConcurrentMultiAgentOrchestration\"\n t[\"agent\"][\"planner_type\"] = \"Atlas__ConcurrentMultiAgentOrchestration\"\n return t\n\n\ndef _sequential_tree() -> dict:\n \"\"\"Zero-topic Sequential planner (classic).\"\"\"\n return {\n \"_schema_version\": \"3.0\",\n \"agent\": {\n \"api_name\": \"KAMAgent\",\n \"version\": \"v5\",\n \"generation\": \"classic\",\n \"planner_name\": \"KAM_Planner\",\n \"planner_type\": \"AiCopilot__SequentialPlannerIntentClassifier\",\n },\n \"root\": {\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": \"KAMAgent\",\n \"children\": [\n {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"SalesPlay\",\n \"children\": [],\n },\n ],\n },\n \"node_count\": 2,\n \"depth\": 1,\n \"_partial\": False,\n \"_pending_fetches\": {},\n \"_unresolved\": [],\n \"_kind_counts\": {\"BOT_DEFINITION\": 1, \"GEN_AI_FUNCTION\": 1},\n }\n\n\ndef _write_tree(tmp: Path, tree: dict) -> Path:\n p = tmp / \"metadata_tree.json\"\n p.write_text(json.dumps(tree))\n return p\n\n\nclass _RenderTestBase(unittest.TestCase):\n \"\"\"Shared setup: patch MERMAID_DIR at the renderer binding + tmp dir.\n\n Every test that invokes `render_architecture.render` or\n `render_architecture.load_mermaid` patches MERMAID_DIR to point at the\n repo's `assets/mermaid/`. With file-relative SKILL_ROOT this is now\n coincident with the natural resolution, but the explicit patch keeps\n each TestCase deterministic and isolated from any sys.path drift.\n \"\"\"\n\n def setUp(self) -> None:\n self._patch = mock.patch.object(\n render_architecture, \"MERMAID_DIR\", _REPO_MERMAID_DIR,\n )\n self._patch.start()\n self.addCleanup(self._patch.stop)\n self._tmp = tempfile.TemporaryDirectory()\n self.addCleanup(self._tmp.cleanup)\n self.tmp = Path(self._tmp.name)\n\n\nclass RenderClassicReActTests(_RenderTestBase):\n def test_all_eight_sections_present(self):\n tree_path = _write_tree(self.tmp, _classic_react_tree())\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n # Section headers 2..8 all present; H1 covers section 1.\n # Invocation sequence (formerly section 3) was removed from the\n # default pipeline in 2026-05 — the heuristic was not trusted.\n # Planner state machine (formerly section 6) was also removed in\n # 2026-05. Both `_render_invocation_sequence` and\n # `_render_planner_state` stay callable for a future re-enable;\n # see `test_nga_invocation_sequence_has_orchestrator_lane` and\n # `test_nga_state_diagram_has_par_and_block`.\n self.assertIn(\"# Architecture\", text)\n for heading in (\n \"## 2. Anatomy summary\",\n \"## 3. Action tree\",\n \"## 4. Topic anatomy\",\n \"## 5. Action catalog\",\n \"## 6. Data flow / context propagation\",\n \"## 7. Flow / Apex / Prompt catalogs\",\n \"## 8. Unresolved refs + artifact pointers\",\n ):\n self.assertIn(heading, text)\n # Explicit regression guard: the retired sections must NOT appear.\n self.assertNotIn(\"## 3. Invocation sequence\", text)\n self.assertNotIn(\"Invocation sequence\", text)\n self.assertNotIn(\"Planner state machine\", text)\n\n def test_two_mermaid_diagrams_rendered(self):\n tree_path = _write_tree(self.tmp, _classic_react_tree())\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n # each diagram-kind keyword must appear as a bare\n # non-comment line inside exactly one fenced block. Keywords in\n # prose inside `%%` comments are now harmless (Mermaid parses\n # them as comments at render time), so we don't policework the\n # comment contents — we only require a real keyword line.\n # sequenceDiagram was removed from the default pipeline in 2026-05\n # along with the Invocation sequence section; stateDiagram-v2\n # was removed the same cycle along with Planner state machine.\n import re\n blocks = re.findall(r\"```mermaid\\n(.*?)\\n```\", text, re.DOTALL)\n keywords = {\"flowchart TB\", \"flowchart LR\"}\n seen_keywords: set[str] = set()\n for b in blocks:\n for ln in b.splitlines():\n stripped = ln.strip()\n if stripped.startswith(\"%%\"):\n continue\n if stripped in keywords:\n seen_keywords.add(stripped)\n self.assertEqual(seen_keywords, keywords)\n # Regression: sequenceDiagram and stateDiagram-v2 must NOT appear\n # in the default render.\n for b in blocks:\n for ln in b.splitlines():\n stripped = ln.strip()\n if stripped.startswith(\"%%\"):\n continue\n self.assertNotEqual(stripped, \"sequenceDiagram\")\n self.assertNotEqual(stripped, \"stateDiagram-v2\")\n # Every block must be fully substituted — no stray `{{PARAM}}`\n # tokens left behind. removed the `{{...}}` from\n # comment headers precisely to make this invariant hold.\n for b in blocks:\n self.assertNotIn(\"{{\", b)\n self.assertNotIn(\"}}\", b)\n # No dependency graph when _unresolved is empty.\n self.assertNotIn(\"## Dependency graph\", text)\n\n def test_react_state_machine_not_in_default_render(self):\n # Planner state machine was retired from the default pipeline in\n # 2026-05. Thought/Action/Observation states are emitted by\n # `_render_planner_state` which is still tested directly but not\n # wired into `render`.\n tree_path = _write_tree(self.tmp, _classic_react_tree())\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertNotIn(\"Thought\", text)\n self.assertNotIn(\"Action --> Observation\", text)\n\n\nclass RenderNgaTests(_RenderTestBase):\n def test_nga_state_diagram_has_par_and_block(self):\n # `_render_planner_state` was removed from the default render\n # pipeline in 2026-05, but the function + mermaid template are\n # retained so the feature can be re-enabled by uncommenting one\n # line in `render`. This test exercises the function directly to\n # keep the NGA par/and-block behaviour covered.\n tree = _nga_tree()\n rendered = render_architecture._render_planner_state(\n tree[\"agent\"], \"nga\",\n dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),\n )\n # par/and block marker is the `--` separator inside an `Orchestration`\n # state nest. Also confirm the lanes differ from ReAct.\n self.assertIn(\"state Orchestration\", rendered)\n self.assertIn(\"--\", rendered)\n self.assertIn(\"SubAgentA\", rendered)\n self.assertIn(\"SubAgentB\", rendered)\n # ReAct-specific states must NOT appear.\n self.assertNotIn(\"Thought\", rendered)\n\n def test_nga_invocation_sequence_has_orchestrator_lane(self):\n # `_render_invocation_sequence` was removed from the default\n # render pipeline in 2026-05 (heuristic distrusted), but the\n # function + mermaid template are retained so the feature can\n # be re-enabled by uncommenting one line in `render`. This test\n # exercises the function directly to keep the behaviour covered.\n tree = _nga_tree()\n walker = render_architecture._TreeWalker(tree)\n walker.walk()\n rendered = render_architecture._render_invocation_sequence(\n tree, tree[\"agent\"], walker,\n dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),\n )\n self.assertIn(\"participant Orchestrator\", rendered)\n self.assertIn(\"participant SubAgent\", rendered)\n\n\nclass RenderSequentialTests(_RenderTestBase):\n def test_sequential_zero_topic_produces_empty_topic_section(self):\n tree_path = _write_tree(self.tmp, _sequential_tree())\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertIn(\"_No topics defined\", text)\n # Sequential state machine uses Classify -> Execute, but the\n # Planner state machine section was retired from the default\n # pipeline in 2026-05 — exercise the helper directly instead.\n rendered = render_architecture._render_planner_state(\n _sequential_tree()[\"agent\"], \"classic\",\n dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),\n )\n self.assertIn(\"Classify --> Execute\", rendered)\n\n def test_sequential_does_not_emit_react_states(self):\n tree_path = _write_tree(self.tmp, _sequential_tree())\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertNotIn(\"Thought\", text)\n self.assertNotIn(\"Orchestration\", text)\n\n\nclass RenderPartialTreeTests(_RenderTestBase):\n def test_partial_true_emits_health_callout(self):\n tree = _classic_react_tree()\n tree[\"_partial\"] = True\n tree[\"_partial_reason\"] = \"flow-metadata-timeout\"\n tree[\"_pending_fetches\"] = {\"FLOW\": [\"Foo\", \"Bar\"], \"APEX\": []}\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertIn(\"**Health: PARTIAL.**\", text)\n self.assertIn(\"flow-metadata-timeout\", text)\n self.assertIn(\"Pending fetches: 2\", text)\n\n def test_missing_planner_name_emits_warn(self):\n tree = _classic_react_tree()\n tree[\"agent\"][\"planner_name\"] = None\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertIn(\"`planner_name` missing\", text)\n\n\nclass RenderUnresolvedAndCyclesTests(_RenderTestBase):\n def test_unresolved_renders_dependency_graph(self):\n tree = _classic_react_tree()\n tree[\"_unresolved\"] = [\n {\"kind\": \"FLOW\", \"api_name\": \"Missing_Flow\",\n \"reason\": \"not-in-org\"},\n ]\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertIn(\"## Dependency graph\", text)\n self.assertIn(\"Missing_Flow\", text)\n # Section 8 renders the unresolved row in its table.\n self.assertIn(\"not-in-org\", text)\n\n def test_cycle_annotation_renders_dotted_back_edge(self):\n tree = _classic_react_tree()\n # Inject a _cycle_back_to annotation on the Find_Public_Articles flow.\n tree[\"root\"][\"children\"][0][\"children\"][0][\"children\"][0][\n \"_cycle_back_to\"] = \"Find_Public_Articles\"\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n # Dotted back-edge syntax `-.->` plus the `cycle_back_to:` label.\n self.assertIn(\"-.->\", text)\n self.assertIn(\"cycle_back_to\", text)\n\n\nclass RenderNodeCapTests(_RenderTestBase):\n def _large_tree(self, n_actions: int) -> dict:\n tree = _classic_react_tree()\n # Overwrite the second topic with a fan-out of n_actions children.\n big_topic = {\n \"kind\": \"TOPIC\",\n \"api_name\": \"Mega_Topic\",\n \"children\": [\n {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": f\"Action_{i:03d}\",\n \"children\": [\n {\"kind\": \"APEX\", \"api_name\": f\"ApexClass_{i:03d}\"},\n ],\n }\n for i in range(n_actions)\n ],\n }\n tree[\"root\"][\"children\"] = [big_topic]\n tree[\"_kind_counts\"] = {\n \"BOT_DEFINITION\": 1,\n \"TOPIC\": 1,\n \"GEN_AI_FUNCTION\": n_actions,\n \"APEX\": n_actions,\n }\n return tree\n\n def test_flowchart_cap_exceeded_emits_placeholder(self):\n # 250 actions + 250 apex + 1 topic + 1 bot = 502 nodes > 200 cap.\n tree = self._large_tree(250)\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertIn(\"[diagram truncated: flowchart\", text)\n self.assertIn(\"exceed cap of 200\", text)\n # Top-5 fan-out line present.\n self.assertIn(\"Top 5 nodes by fan-out\", text)\n # Mega_Topic shows up as a top fan-out node.\n self.assertIn(\"Mega_Topic\", text)\n\n def test_react_30_topics_does_not_false_trip_sequence_cap(self):\n # pre-fix, msg_count was computed as\n # `2 * len(topics) + len(actions) + 2`, which for 30 topics\n # yields 62+ > cap=60. But ReAct only samples topics[:5], so\n # the rendered sequence has ~14 messages — nowhere near the\n # cap. Assert the diagram renders, no truncation placeholder.\n # 2026-05: `_render_invocation_sequence` is no longer wired\n # into `render`; invoke it directly so the cap math stays\n # covered.\n tree = _classic_react_tree()\n topics = [\n {\n \"kind\": \"TOPIC\",\n \"api_name\": f\"Topic_{i:02d}\",\n \"children\": [\n {\"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": f\"Action_{i:02d}\"},\n ],\n }\n for i in range(30)\n ]\n tree[\"root\"][\"children\"] = topics\n tree[\"_kind_counts\"] = {\n \"BOT_DEFINITION\": 1, \"TOPIC\": 30, \"GEN_AI_FUNCTION\": 30,\n }\n walker = render_architecture._TreeWalker(tree)\n walker.walk()\n rendered = render_architecture._render_invocation_sequence(\n tree, tree[\"agent\"], walker,\n dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),\n )\n self.assertNotIn(\"[diagram truncated: sequenceDiagram\", rendered)\n self.assertIn(\"sequenceDiagram\", rendered)\n # Sampling at :5 means exactly 5 topics show up in the messages.\n # Topic_00..Topic_04 must appear; Topic_05 must not.\n self.assertIn(\"Topic_04\", rendered)\n self.assertNotIn(\"Topic_05\", rendered)\n\n def test_sequence_cap_trips_on_actually_rendered_overflow(self):\n # construct a scenario where the rendered message\n # list truly exceeds the cap. We use a tiny cap (5) rather than\n # fabricating 60+ NGA messages — the test's intent is \"cap math\n # is evaluated against the rendered list\", not \"cap=60 is\n # specifically correct\".\n # 2026-05: invoke `_render_invocation_sequence` directly since\n # the section was retired from the default pipeline.\n tree = _classic_react_tree()\n walker = render_architecture._TreeWalker(tree)\n walker.walk()\n caps = dict(render_architecture.DEFAULT_MAX_MERMAID_NODES)\n caps[\"sequenceDiagram\"] = 3\n rendered = render_architecture._render_invocation_sequence(\n tree, tree[\"agent\"], walker, caps,\n )\n self.assertIn(\"[diagram truncated: sequenceDiagram\", rendered)\n self.assertIn(\"exceed cap of 3\", rendered)\n\n\nclass RenderLoadMermaidTests(_RenderTestBase):\n def test_nested_placeholder_in_value_logs_warning(self):\n with self.assertLogs(render_architecture.logger, level=\"WARNING\") as cm:\n out = render_architecture.load_mermaid(\n \"invocation_sequence\",\n PARTICIPANTS=\"participant {{NESTED}}\",\n MESSAGES=\"User->>+Planner: x\",\n )\n self.assertTrue(any(\"nested placeholder\" in m for m in cm.output))\n # Still renders — the nested token is left as-is (no second pass).\n self.assertIn(\"{{NESTED}}\", out)\n\n def test_missing_template_raises_filenotfounderror_without_path(self):\n with self.assertRaises(FileNotFoundError) as ctx:\n render_architecture.load_mermaid(\"nonexistent_template_zzz\")\n msg = str(ctx.exception)\n self.assertIn(\"nonexistent_template_zzz\", msg)\n # Hygiene: absolute SKILL_ROOT path must not bleed through.\n self.assertNotIn(\"/assets/mermaid\", msg)\n\n def test_traversal_name_rejected_without_absolute_path_leak(self):\n # The hygiene contract is that the absolute MERMAID_DIR path\n # must not appear in the error text — echoing the caller's\n # own input back (which here happens to contain `/etc/passwd`)\n # is fine; that's information the caller already had.\n with self.assertRaises(FileNotFoundError) as ctx:\n render_architecture.load_mermaid(\"../../../etc/passwd\")\n msg = str(ctx.exception)\n self.assertNotIn(str(_REPO_MERMAID_DIR), msg)\n # Also must not leak the absolute install path. Asserting the user's\n # home directory doesn't appear is a stricter, runtime-agnostic check\n # than naming any specific install root (.claude / .vibe /\n # plugin-specific layouts).\n self.assertNotIn(str(Path.home()), msg)\n\n def test_non_string_param_raises_typeerror(self):\n with self.assertRaises(TypeError):\n render_architecture.load_mermaid(\n \"invocation_sequence\",\n PARTICIPANTS=None, # type: ignore[arg-type]\n MESSAGES=\"x\",\n )\n\n def test_leading_comment_block_preserved_but_not_substituted(self):\n # the `%%` header comments in the shipped templates\n # are kept in rendered output (Mermaid ignores `%%` lines at\n # render time). The prior _strip_leading_comment_block workaround\n # was a misdiagnosis — the real bug was templates documenting\n # placeholders as `{{NAME}}` inside `%%` comments, causing\n # load_mermaid's str.replace to substitute them.\n out = render_architecture.load_mermaid(\n \"action_tree\", SUBGRAPHS=\"SG_INJECTED\", EDGES=\"EDGES_INJECTED\",\n )\n # Header comments survive.\n self.assertTrue(out.startswith(\"%%\"))\n # `%%` lines must not have been corrupted by substitution.\n for ln in out.splitlines():\n if ln.startswith(\"%%\"):\n self.assertNotIn(\"SG_INJECTED\", ln)\n self.assertNotIn(\"EDGES_INJECTED\", ln)\n # Diagram-kind keyword still present on its own bare line.\n self.assertIn(\"\\nflowchart TB\", \"\\n\" + out)\n # Placeholders in the body were substituted exactly once.\n self.assertIn(\"SG_INJECTED\", out)\n self.assertIn(\"EDGES_INJECTED\", out)\n\n\nclass RenderEmptyAndSearchTests(_RenderTestBase):\n def test_empty_bot_definition_renders_without_crash(self):\n tree = {\n \"_schema_version\": \"3.0\",\n \"agent\": {\n \"api_name\": \"Empty\",\n \"version\": \"v1\",\n \"generation\": \"classic\",\n \"planner_type\": \"AiCopilot__ReAct\",\n \"planner_name\": \"Empty_Planner\",\n },\n \"root\": {\"kind\": \"BOT_DEFINITION\", \"api_name\": \"Empty\",\n \"children\": []},\n \"node_count\": 1,\n \"depth\": 0,\n \"_partial\": False,\n \"_unresolved\": [],\n \"_kind_counts\": {\"BOT_DEFINITION\": 1},\n }\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n self.assertIn(\"_No topics defined\", text)\n self.assertIn(\"_No actions declared._\", text)\n self.assertIn(\"_No backing artifacts in tree._\", text)\n\n def test_search_generation_skips_state_diagram(self):\n # Planner state machine was retired from the default render in\n # 2026-05. Exercise `_render_planner_state` directly to keep the\n # search-generation prose-placeholder branch covered.\n tree = _classic_react_tree()\n tree[\"agent\"][\"generation\"] = \"search\"\n tree[\"agent\"][\"planner_type\"] = \"custom_search_Apex\"\n rendered = render_architecture._render_planner_state(\n tree[\"agent\"], \"search\",\n dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),\n )\n self.assertIn(\"Custom planner\", rendered)\n self.assertNotIn(\"stateDiagram-v2\", rendered)\n\n def test_byop_generation_skips_state_diagram(self):\n # Same 2026-05 retirement — exercise the helper directly.\n tree = _classic_react_tree()\n tree[\"agent\"][\"generation\"] = \"byop\"\n rendered = render_architecture._render_planner_state(\n tree[\"agent\"], \"byop\",\n dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),\n )\n self.assertIn(\"Custom planner\", rendered)\n\n\nclass RenderPromptCatalogTests(_RenderTestBase):\n \"\"\"Section-7 'Prompt templates' sub-section shape.\n\n Flows and Apex classes each get an H4 per entry with a fenced\n signature block. Prompts were historically rendered as a bare bullet\n list (just names) which left the reader with no indication of what\n each prompt does. We now mirror the flow/apex shape: H4 heading,\n optional `Type:` line, optional signature fence, `_Details not\n captured._` fallback when the walker has no metadata on the node.\n Wave B doesn't yet stamp prompt signatures — the renderer just has\n to be ready for when it does.\"\"\"\n\n def _tree_with_prompts(self, prompt_nodes: list[dict]) -> dict:\n \"\"\"Wrap two prompt nodes under a TOPIC -> GEN_AI_FUNCTION so the\n walker indexes them into `walker.prompts`.\"\"\"\n tree = _classic_react_tree()\n tree[\"root\"][\"children\"].append({\n \"kind\": \"TOPIC\",\n \"api_name\": \"PromptTopic\",\n \"children\": [\n {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"UsePrompts\",\n \"children\": prompt_nodes,\n },\n ],\n })\n return tree\n\n def test_prompt_h4_per_entry_with_type_and_fallback(self):\n r\"\"\"Two prompts: one with `prompt_type` set, one without. Both\n get H4 headings. The typed one shows ``Type: `flex```; the\n bare one shows `_Details not captured._`.\"\"\"\n tree = self._tree_with_prompts([\n {\n \"kind\": \"PROMPT_TEMPLATE\",\n \"api_name\": \"DraftReply\",\n \"prompt_type\": \"flex\",\n \"children\": [],\n },\n {\n \"kind\": \"PROMPT_TEMPLATE\",\n \"api_name\": \"SummarizeThread\",\n \"children\": [],\n },\n ])\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n\n self.assertIn(\"### Prompt templates\", text)\n # Both prompts render as H4, not as bare bullets.\n self.assertIn(\"#### `DraftReply`\", text)\n self.assertIn(\"#### `SummarizeThread`\", text)\n self.assertNotIn(\"- `DraftReply`\", text)\n self.assertNotIn(\"- `SummarizeThread`\", text)\n # Typed prompt surfaces its Type line.\n self.assertIn(\"- Type: `flex`\", text)\n # Untyped prompt (no signature either) falls back honestly.\n # Locate the SummarizeThread block and confirm its body.\n import re\n m = re.search(\n r\"#### `SummarizeThread`\\n\\n(.*?)(?:\\n#### |\\n### |\\n## |\\Z)\",\n text, re.DOTALL,\n )\n self.assertIsNotNone(m, \"SummarizeThread prompt block not found\")\n self.assertIn(\"_Details not captured._\", m.group(1))\n\n def test_prompt_with_signature_renders_fenced_block(self):\n \"\"\"When a prompt node carries a `signature` (future Wave B\n stamp), the renderer emits it inside a fenced code block, same\n shape used for flows/apex.\"\"\"\n tree = self._tree_with_prompts([\n {\n \"kind\": \"PROMPT_TEMPLATE\",\n \"api_name\": \"ClassifyIntent\",\n \"prompt_type\": \"genAiPromptTemplate\",\n \"signature\": \"in: userUtterance: String | out: intent: String\",\n \"children\": [],\n },\n ])\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n\n self.assertIn(\"#### `ClassifyIntent`\", text)\n self.assertIn(\"- Type: `genAiPromptTemplate`\", text)\n # Fenced signature block present.\n self.assertIn(\n \"```\\nin: userUtterance: String | out: intent: String\\n```\",\n text,\n )\n # No fallback when we have either type or sig.\n import re\n m = re.search(\n r\"#### `ClassifyIntent`\\n\\n(.*?)(?:\\n#### |\\n### |\\n## |\\Z)\",\n text, re.DOTALL,\n )\n self.assertIsNotNone(m)\n self.assertNotIn(\"_Details not captured._\", m.group(1))\n\n\nclass RenderPromptTemplateBodyTests(_RenderTestBase):\n \"\"\"Gap C (2026-05-05): prompt template bodies retrieved via\n `retrieve_prompt_templates` are stamped onto PROMPT_TEMPLATE leaves\n as `master_label`, `content`, `inputs`, `_body_available`. The\n renderer emits:\n - `_Label: \u003cmaster_label>_` blurb\n - `**Inputs**:` bulleted list\n - fenced `text` code block for the content\n - `_Body not retrieved._` when `_body_available` is False and no\n other details are available\n \"\"\"\n\n def _tree_with_prompts(self, prompt_nodes: list[dict]) -> dict:\n tree = _classic_react_tree()\n tree[\"root\"][\"children\"].append({\n \"kind\": \"TOPIC\",\n \"api_name\": \"PromptTopic\",\n \"children\": [\n {\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"UsePrompts\",\n \"children\": prompt_nodes,\n },\n ],\n })\n return tree\n\n def test_body_rendered_with_label_inputs_and_fenced_content(self):\n tree = self._tree_with_prompts([\n {\n \"kind\": \"PROMPT_TEMPLATE\",\n \"api_name\": \"AGNT_US_Q_A_with_Site_Scraping\",\n \"master_label\": \"AGNT - US Q&A with Site Scraping\",\n \"content\": \"# ROLE & OBJECTIVE\\nAnswer the customer question.\",\n \"inputs\": [\n {\"name\": \"Query\", \"dataType\": \"String\"},\n {\"name\": \"Site\", \"dataType\": \"String\"},\n ],\n \"_body_available\": True,\n \"children\": [],\n },\n ])\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n\n self.assertIn(\n \"#### `AGNT_US_Q_A_with_Site_Scraping`\", text,\n )\n self.assertIn(\"_Label: AGNT - US Q&A with Site Scraping_\", text)\n self.assertIn(\"**Inputs**:\", text)\n self.assertIn(\"- `Query`: `String`\", text)\n self.assertIn(\"- `Site`: `String`\", text)\n # Fenced content block uses `text` language hint.\n self.assertIn(\n \"```text\\n# ROLE & OBJECTIVE\\nAnswer the customer question.\\n```\",\n text,\n )\n self.assertNotIn(\"_Details not captured._\", text)\n self.assertNotIn(\"_Body not retrieved._\", text)\n\n def test_body_unavailable_falls_back_honestly(self):\n tree = self._tree_with_prompts([\n {\n \"kind\": \"PROMPT_TEMPLATE\",\n \"api_name\": \"MissingTpl\",\n \"_body_available\": False,\n \"children\": [],\n },\n ])\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n\n self.assertIn(\"#### `MissingTpl`\", text)\n import re\n m = re.search(\n r\"#### `MissingTpl`\\n\\n(.*?)(?:\\n#### |\\n### |\\n## |\\Z)\",\n text, re.DOTALL,\n )\n self.assertIsNotNone(m)\n block = m.group(1)\n self.assertIn(\"_Body not retrieved._\", block)\n # No content fence; no Inputs block.\n self.assertNotIn(\"```text\", block)\n self.assertNotIn(\"**Inputs**:\", block)\n\n def test_inputs_without_datatype_still_render(self):\n \"\"\"Older templates may omit \u003cdataType> on \u003cinputs>. Render the\n name alone rather than crashing or dropping the input.\"\"\"\n tree = self._tree_with_prompts([\n {\n \"kind\": \"PROMPT_TEMPLATE\",\n \"api_name\": \"LegacyTpl\",\n \"content\": \"body\",\n \"inputs\": [{\"name\": \"Query\"}],\n \"_body_available\": True,\n \"children\": [],\n },\n ])\n tree_path = _write_tree(self.tmp, tree)\n out = self.tmp / \"architecture.md\"\n render_architecture.render(tree_path, out)\n text = out.read_text()\n\n self.assertIn(\"**Inputs**:\", text)\n self.assertIn(\"- `Query`\", text)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":33522,"content_sha256":"3f27dc872917c8f2320a8de027bfe019853cbeabb89e83eeec7d2c77000ee874"},{"filename":"scripts/tests/test_resolve_bot.py","content":"\"\"\"Tests for ``resolve_bot`` — Bot + version resolution helpers + main flow.\n\nCovers the small pure helpers (``scrub``, ``natural_key``,\n``emit_error_block``) plus a happy-path ``main`` end-to-end with\n``subprocess.run`` mocked.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport unittest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom types import SimpleNamespace\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport resolve_bot # type: ignore\n\n\n# -----------------------------------------------------------------------------\n# scrub\n# -----------------------------------------------------------------------------\n\n\nclass ScrubTests(unittest.TestCase):\n\n def test_strips_dangerous_shell_chars(self):\n # ` $ \" \\\\ \\r \\t \\0 \\n all stripped; safe chars kept verbatim.\n self.assertEqual(\n resolve_bot.scrub(\"hello`bad$\\\"\" + \"\\n\" + \"world\"),\n \"hellobadworld\",\n )\n\n def test_strips_carriage_return_and_tab(self):\n self.assertEqual(resolve_bot.scrub(\"a\\tb\\rc\"), \"abc\")\n\n def test_returns_empty_string_for_none(self):\n self.assertEqual(resolve_bot.scrub(None), \"\")\n\n def test_coerces_non_strings(self):\n self.assertEqual(resolve_bot.scrub(42), \"42\")\n\n def test_passes_safe_chars_through(self):\n self.assertEqual(resolve_bot.scrub(\"Customer_Support_Agent\"),\n \"Customer_Support_Agent\")\n\n\n# -----------------------------------------------------------------------------\n# natural_key — version sorting\n# -----------------------------------------------------------------------------\n\n\nclass NaturalKeyTests(unittest.TestCase):\n\n def test_sorts_v10_after_v9(self):\n keys = [\"v9\", \"v10\", \"v2\", \"v1\"]\n keys.sort(key=resolve_bot.natural_key, reverse=True)\n self.assertEqual(keys, [\"v10\", \"v9\", \"v2\", \"v1\"])\n\n def test_handles_none_and_empty_string(self):\n # Both should be safe (empty list)\n self.assertEqual(resolve_bot.natural_key(\"\"), [\"\"])\n self.assertEqual(resolve_bot.natural_key(None), [\"\"])\n\n\n# -----------------------------------------------------------------------------\n# emit_error_block — terminal RESULT block + sys.exit\n# -----------------------------------------------------------------------------\n\n\nclass EmitErrorBlockTests(unittest.TestCase):\n\n def test_emits_result_block_and_exits_one(self):\n old_env = dict(os.environ)\n os.environ[\"AGENT_API_NAME\"] = \"MyAgent\"\n os.environ[\"ORG_ID_15\"] = \"00D000000000000\"\n os.environ[\"ORG_ID_18\"] = \"00D000000000000EAA\"\n os.environ.pop(\"ERROR_TEE\", None)\n try:\n with mock.patch.object(resolve_bot.sys, \"stdout\") as out:\n with self.assertRaises(SystemExit) as ctx:\n resolve_bot.emit_error_block(\n \"AGENT_NOT_FOUND\", \"details here\", {\"AVAILABLE_BOTS\": \"A,B\"},\n )\n self.assertEqual(ctx.exception.code, 1)\n written = \"\".join(c.args[0] for c in out.write.call_args_list)\n self.assertIn(\"STATUS=AGENT_NOT_FOUND\", written)\n self.assertIn(\"ERROR_DETAIL=details here\", written)\n self.assertIn(\"AGENT_API_NAME=MyAgent\", written)\n self.assertIn(\"AVAILABLE_BOTS=A,B\", written)\n finally:\n os.environ.clear()\n os.environ.update(old_env)\n\n def test_writes_error_tee_when_env_set(self):\n with TemporaryDirectory() as t:\n tee_path = Path(t) / \"subdir\" / \"tee.txt\"\n old_env = dict(os.environ)\n os.environ[\"ERROR_TEE\"] = str(tee_path)\n os.environ[\"AGENT_API_NAME\"] = \"MyAgent\"\n os.environ[\"ORG_ID_15\"] = \"x\"\n os.environ[\"ORG_ID_18\"] = \"y\"\n try:\n with mock.patch.object(resolve_bot.sys, \"stdout\"):\n with self.assertRaises(SystemExit):\n resolve_bot.emit_error_block(\"X\", \"y\", {})\n # tee_path got the same content\n self.assertTrue(tee_path.is_file())\n self.assertIn(\"STATUS=X\", tee_path.read_text())\n finally:\n os.environ.clear()\n os.environ.update(old_env)\n\n\n# -----------------------------------------------------------------------------\n# main — happy path with mocked sf data query\n# -----------------------------------------------------------------------------\n\n\ndef _versions_payload() -> str:\n return json.dumps({\n \"result\": {\n \"records\": [\n {\"DeveloperName\": \"v3\", \"Status\": \"Active\",\n \"BotDefinitionId\": \"0Xx000000000ABC\",\n \"BotDefinition\": {\"DeveloperName\": \"MyAgent\",\n \"MasterLabel\": \"My Agent\"}},\n {\"DeveloperName\": \"v2\", \"Status\": \"Inactive\",\n \"BotDefinitionId\": \"0Xx000000000ABC\",\n \"BotDefinition\": {\"DeveloperName\": \"MyAgent\",\n \"MasterLabel\": \"My Agent\"}},\n ],\n },\n })\n\n\ndef _bot_def_payload() -> str:\n return json.dumps({\n \"result\": {\n \"records\": [\n {\"DeveloperName\": \"MyAgent\", \"MasterLabel\": \"My Agent\",\n \"Description\": \"demo\", \"AgentType\": \"Internal\",\n \"Type\": \"AiCopilot\", \"AgentTemplate\": \"T\",\n \"BotSource\": \"AgentforceAgentCopilot\"},\n ],\n },\n })\n\n\nclass MainTests(unittest.TestCase):\n\n def _run_main(self, *, agent_version: str = \"\") -> tuple[int, str]:\n old_env = dict(os.environ)\n with TemporaryDirectory() as t:\n tmp = Path(t)\n os.environ[\"ORG_ALIAS\"] = \"my-org\"\n os.environ[\"AGENT_API_NAME\"] = \"MyAgent\"\n os.environ[\"WORK_DIR\"] = str(tmp)\n os.environ[\"AGENT_VERSION\"] = agent_version\n\n calls: list = []\n\n def fake_run(argv, **kw):\n # Two distinct sf data queries: BotVersion then BotDefinition\n calls.append(argv)\n if \"BotVersion\" in argv[-1]:\n return SimpleNamespace(returncode=0, stdout=_versions_payload(), stderr=\"\")\n if \"BotDefinition\" in argv[-1]:\n return SimpleNamespace(returncode=0, stdout=_bot_def_payload(), stderr=\"\")\n return SimpleNamespace(returncode=0, stdout=\"{}\", stderr=\"\")\n\n stdout_buf: list[str] = []\n\n try:\n with mock.patch.object(resolve_bot.subprocess, \"run\", side_effect=fake_run):\n with mock.patch.object(resolve_bot.sys, \"stdout\") as out:\n out.write = lambda s: stdout_buf.append(s)\n rc = resolve_bot.main()\n finally:\n os.environ.clear()\n os.environ.update(old_env)\n return rc, \"\".join(stdout_buf)\n\n def test_main_succeeds_with_auto_pick(self):\n rc, written = self._run_main()\n self.assertEqual(rc, 0)\n # v3 is Active and lexically highest → auto-picked\n self.assertIn(\"AGENT_VERSION=v3\", written)\n self.assertIn(\"VERSION_AUTO_PICKED=true\", written)\n self.assertIn(\"BOT_FOUND=true\", written)\n self.assertIn(\"BOT_ID=0Xx000000000ABC\", written)\n\n def test_main_succeeds_with_explicit_version_match(self):\n rc, written = self._run_main(agent_version=\"v2\")\n self.assertEqual(rc, 0)\n self.assertIn(\"AGENT_VERSION=v2\", written)\n self.assertIn(\"VERSION_AUTO_PICKED=false\", written)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":7650,"content_sha256":"16b75fcdd5eb8bbaa17746f81d882c9b5a38268b6391a3acf9b0e7f1d8dd0ad4"},{"filename":"scripts/tests/test_resolve_creds.py","content":"\"\"\"Tests for ``main._resolve_creds`` fail-fast token retrieval\n(forcedotcom/cli#3560).\n\nThe access token must come ONLY from\n``sf org auth show-access-token --json --no-prompt`` via the\n``show_access_token`` recipe. ``sf org display`` supplies instanceUrl\nonly; any ``accessToken`` it carries is never used.\n\nWe mock ``run_sf`` (not subprocess) since these tests target the\norchestration logic in ``main.py``, not the recipe loader. The recipe\nloader, subprocess env, and capability preflight have their own\ncoverage in ``test_sf_cli.py``.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport main # type: ignore\nfrom sf_cli import AuthRequired, SfCliError # type: ignore\n\n\nREDACTED_TOKEN = \"[REDACTED] Use 'sf org auth show-access-token' to view\"\n\n\ndef _display_payload(*, instance_url=\"https://example.salesforce.com\",\n access_token=\"TOKEN_FROM_DISPLAY\"):\n return {\"result\": {\n \"instanceUrl\": instance_url,\n \"accessToken\": access_token,\n }}\n\n\ndef _show_token_payload(*, access_token=\"TOKEN_FROM_SHOW\"):\n return {\"result\": {\"accessToken\": access_token}}\n\n\nclass ResolveCredsTests(unittest.TestCase):\n \"\"\"Cover every branch of fail-fast token retrieval.\"\"\"\n\n def setUp(self):\n patcher = mock.patch.object(\n main, \"assert_show_access_token_capability\", return_value=None,\n )\n patcher.start()\n self.addCleanup(patcher.stop)\n\n def _route(self, *, primary_payload=None, primary_exc=None,\n display_payload=None):\n \"\"\"Build a ``run_sf`` side-effect that dispatches by recipe name.\"\"\"\n display = display_payload or _display_payload()\n primary = primary_payload or _show_token_payload()\n\n def fake_run_sf(name, **params):\n if name == \"org_display\":\n return display\n if name == \"show_access_token\":\n if primary_exc is not None:\n raise primary_exc\n return primary\n raise AssertionError(f\"unexpected recipe: {name}\")\n\n return fake_run_sf\n\n def test_primary_path_returns_show_token(self):\n \"\"\"Happy path — dedicated command returns a clean token.\"\"\"\n with mock.patch.object(main, \"run_sf\", side_effect=self._route()):\n url, token = main._resolve_creds(\"my-org\")\n self.assertEqual(url, \"https://example.salesforce.com\")\n self.assertEqual(token, \"TOKEN_FROM_SHOW\")\n\n def test_primary_unknown_raises_authrequired(self):\n \"\"\"show-access-token failing is terminal — the display payload's\n accessToken (non-empty here) must NOT be used as a fallback.\"\"\"\n with mock.patch.object(\n main, \"run_sf\",\n side_effect=self._route(\n primary_exc=SfCliError(\"sf CLI 'show_access_token' failed\"),\n ),\n ):\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"show-access-token\", str(ctx.exception))\n self.assertNotIn(\"TOKEN_FROM_DISPLAY\", str(ctx.exception))\n\n def test_primary_auth_required_propagates_as_authrequired(self):\n with mock.patch.object(\n main, \"run_sf\",\n side_effect=self._route(\n primary_exc=AuthRequired(\"NoOrgAuthenticationError\"),\n ),\n ):\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"show-access-token\", str(ctx.exception))\n\n def test_primary_returns_redacted_token_raises(self):\n \"\"\"Dedicated command runs cleanly but returns the placeholder\n string — fail fast rather than handing it to Tooling/REST callers\n (which would 401 with INVALID_AUTH_HEADER).\"\"\"\n with mock.patch.object(\n main, \"run_sf\",\n side_effect=self._route(\n primary_payload=_show_token_payload(access_token=REDACTED_TOKEN),\n ),\n ):\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"usable access token\", str(ctx.exception))\n\n def test_primary_returns_empty_token_raises(self):\n with mock.patch.object(\n main, \"run_sf\",\n side_effect=self._route(\n primary_payload=_show_token_payload(access_token=\"\"),\n ),\n ):\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"usable access token\", str(ctx.exception))\n\n def test_missing_instance_url_raises_authrequired(self):\n \"\"\"display returning empty instanceUrl is a hard failure — no\n amount of token-juggling helps if we can't talk to the org.\"\"\"\n with mock.patch.object(\n main, \"run_sf\",\n side_effect=self._route(\n display_payload=_display_payload(instance_url=\"\"),\n ),\n ):\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"instanceUrl\", str(ctx.exception))\n\n def test_primary_path_recipe_name_is_show_access_token(self):\n \"\"\"Tripwire — the primary call MUST go through the\n ``show_access_token`` recipe. Without it the patch silently\n regresses to reading a (redacted) token from ``sf org display``.\"\"\"\n recipes_called: list[str] = []\n\n def fake_run_sf(name, **params):\n recipes_called.append(name)\n if name == \"org_display\":\n return _display_payload()\n if name == \"show_access_token\":\n return _show_token_payload()\n raise AssertionError(f\"unexpected recipe: {name}\")\n\n with mock.patch.object(main, \"run_sf\", side_effect=fake_run_sf):\n main._resolve_creds(\"my-org\")\n\n self.assertIn(\"show_access_token\", recipes_called)\n # And the alias was passed through:\n # (We don't capture params here, but the recipe loader enforces\n # required_params at run_sf time — see test_sf_cli.py.)\n\n def test_capability_preflight_failure_raises_authrequired(self):\n \"\"\"Missing ``sf org auth show-access-token`` fails fast with the\n preflight's upgrade message, before any token call is made.\"\"\"\n recipes_called: list[str] = []\n\n def fake_run_sf(name, **params):\n recipes_called.append(name)\n return self._route()(name, **params)\n\n with mock.patch.object(\n main, \"assert_show_access_token_capability\",\n side_effect=SfCliError(\n \"sf CLI is missing required command \"\n \"'sf org auth show-access-token'\"\n ),\n ), mock.patch.object(main, \"run_sf\", side_effect=fake_run_sf):\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"missing required command\", str(ctx.exception))\n # Preflight runs first: neither `sf org display` nor the token\n # command may be invoked when it fails.\n self.assertEqual(recipes_called, [])\n\n def test_preflight_runs_before_org_display(self):\n calls: list[str] = []\n\n def fake_run_sf(name, **params):\n calls.append(name)\n return self._route()(name, **params)\n\n with mock.patch.object(\n main, \"assert_show_access_token_capability\",\n side_effect=lambda: calls.append(\"preflight\"),\n ), mock.patch.object(main, \"run_sf\", side_effect=fake_run_sf):\n main._resolve_creds(\"my-org\")\n self.assertEqual(calls, [\"preflight\", \"org_display\", \"show_access_token\"])\n\n\nclass ResolveCredsPreflightCacheTests(unittest.TestCase):\n \"\"\"Exercise the REAL preflight (subprocess mocked) through\n ``_resolve_creds``: argv order, once-per-process success caching, and\n no caching of failures.\"\"\"\n\n PREFLIGHT_ARGV = [\"sf\", \"org\", \"auth\", \"show-access-token\", \"--help\"]\n REAL_HELP = \"USAGE\\n $ sf org auth show-access-token -o \u003cvalue>\\n\"\n\n def setUp(self):\n import sf_cli # type: ignore\n self.sf_cli = sf_cli\n sf_cli._show_access_token_capability_ok = False\n self.addCleanup(\n setattr, sf_cli, \"_show_access_token_capability_ok\", False,\n )\n\n def _patches(self, calls, preflight_effects):\n \"\"\"Patch subprocess (preflight) + run_sf (recipes) into one ordered\n ``calls`` log. ``preflight_effects`` is consumed per preflight spawn.\"\"\"\n from types import SimpleNamespace\n effects = list(preflight_effects)\n\n def fake_subprocess_run(argv, **kwargs):\n calls.append(list(argv))\n effect = effects.pop(0)\n if isinstance(effect, BaseException):\n raise effect\n if effect == \"no-signature\":\n # Zero exit but not the command's help (unknown command).\n return SimpleNamespace(\n returncode=0, stdout=\"USAGE\\n $ sf [COMMAND]\\n\", stderr=\"\",\n )\n return SimpleNamespace(\n returncode=0, stdout=self.REAL_HELP, stderr=\"\",\n )\n\n def fake_run_sf(name, **params):\n calls.append(name)\n if name == \"org_display\":\n return _display_payload()\n if name == \"show_access_token\":\n return _show_token_payload()\n raise AssertionError(f\"unexpected recipe: {name}\")\n\n return (\n mock.patch.object(\n self.sf_cli.subprocess, \"run\", side_effect=fake_subprocess_run,\n ),\n mock.patch.object(main, \"run_sf\", side_effect=fake_run_sf),\n )\n\n def test_preflight_argv_is_first_sf_call(self):\n calls: list = []\n p1, p2 = self._patches(calls, [\"ok\"])\n with p1, p2:\n main._resolve_creds(\"my-org\")\n self.assertEqual(calls[0], self.PREFLIGHT_ARGV)\n self.assertEqual(calls[1:], [\"org_display\", \"show_access_token\"])\n\n def test_preflight_runs_once_across_two_resolves(self):\n calls: list = []\n p1, p2 = self._patches(calls, [\"ok\"])\n with p1, p2:\n main._resolve_creds(\"my-org\")\n main._resolve_creds(\"my-org\")\n self.assertEqual(calls.count(self.PREFLIGHT_ARGV), 1)\n self.assertEqual(calls.count(\"org_display\"), 2)\n\n def test_missing_sf_fails_preflight_and_skips_display(self):\n calls: list = []\n p1, p2 = self._patches(calls, [FileNotFoundError(\"sf\")])\n with p1, p2:\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"not found on PATH\", str(ctx.exception))\n self.assertNotIn(\"org_display\", calls)\n\n def test_failed_preflight_is_retried_on_next_resolve(self):\n import subprocess as _subprocess\n err = _subprocess.CalledProcessError(\n returncode=1, cmd=self.PREFLIGHT_ARGV,\n stderr=\"show-access-token is not a sf command\",\n )\n calls: list = []\n p1, p2 = self._patches(calls, [err, \"ok\"])\n with p1, p2:\n with self.assertRaises(AuthRequired):\n main._resolve_creds(\"my-org\")\n self.assertNotIn(\"org_display\", calls)\n url, token = main._resolve_creds(\"my-org\")\n self.assertEqual(token, \"TOKEN_FROM_SHOW\")\n self.assertEqual(calls.count(self.PREFLIGHT_ARGV), 2)\n\n def test_zero_exit_without_help_signature_fails_and_is_retried(self):\n calls: list = []\n p1, p2 = self._patches(calls, [\"no-signature\", \"ok\"])\n with p1, p2:\n with self.assertRaises(AuthRequired) as ctx:\n main._resolve_creds(\"my-org\")\n self.assertIn(\"missing required command\", str(ctx.exception))\n self.assertNotIn(\"org_display\", calls)\n self.assertFalse(self.sf_cli._show_access_token_capability_ok)\n url, token = main._resolve_creds(\"my-org\")\n self.assertEqual(token, \"TOKEN_FROM_SHOW\")\n self.assertEqual(calls.count(self.PREFLIGHT_ARGV), 2)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12244,"content_sha256":"7d345fc8438abd18a19ebe75dafbd8fe1890598fde7cd48bafa651482a1dc80d"},{"filename":"scripts/tests/test_resolve_invocation_target.py","content":"\"\"\"Tests for resolve_invocation_target.resolve_target_id never raises,\nand `resolve_or_unresolved` records unknown prefixes + invalid shapes into\n`_unresolved[]` so the wave orchestrator can keep running when Salesforce\nships a new NGA target type.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nfrom . import _bootstrap # noqa: F401\n\nimport resolve_invocation_target as rit # type: ignore\n\n\n# A valid-shape Salesforce Id is 15 or 18 alphanumeric chars. Prefixes below\n# are chosen to avoid collision with _PREFIX_MAP so they exercise the\n# \"unknown prefix\" path.\n_APEX_ID_15 = \"01p0000000ABC12\"\n_APEX_ID_18 = \"01p0000000ABC12AAA\"\n_FLOW_DEF_ID_15 = \"3000000000XYZ99\"\n_FLOW_VER_ID_15 = \"3010000000XYZ99\"\n_PROMPT_ID_15 = \"0hf0000000QRS77\"\n_UNKNOWN_PREFIX_ID_15 = \"0aN000000000XYZ\"\n\n\nclass KnownPrefixTests(unittest.TestCase):\n def test_apex_prefix(self):\n self.assertEqual(rit.resolve_target_id(_APEX_ID_15), (\"apex\", \"tooling_soql\"))\n\n def test_flow_definition_prefix(self):\n self.assertEqual(\n rit.resolve_target_id(_FLOW_DEF_ID_15),\n (\"flow_definition\", \"tooling_soql\"),\n )\n\n def test_flow_version_prefix(self):\n self.assertEqual(\n rit.resolve_target_id(_FLOW_VER_ID_15),\n (\"flow_version\", \"tooling_soql\"),\n )\n\n def test_prompt_template_prefix(self):\n self.assertEqual(\n rit.resolve_target_id(_PROMPT_ID_15),\n (\"prompt_template\", \"retrieve_required\"),\n )\n\n def test_eighteen_char_id_works(self):\n self.assertEqual(\n rit.resolve_target_id(_APEX_ID_18),\n (\"apex\", \"tooling_soql\"),\n )\n\n\nclass UnknownPrefixTests(unittest.TestCase):\n def test_resolve_target_id_never_raises(self):\n # Unknown prefix → (\"unknown\", \"skip\"), no exception.\n self.assertEqual(\n rit.resolve_target_id(_UNKNOWN_PREFIX_ID_15),\n (\"unknown\", \"skip\"),\n )\n\n def test_resolve_or_unresolved_records_unknown_prefix(self):\n unresolved: list[dict] = []\n result = rit.resolve_or_unresolved(_UNKNOWN_PREFIX_ID_15, unresolved)\n self.assertEqual(result, (\"unknown\", \"skip\"))\n self.assertEqual(len(unresolved), 1)\n entry = unresolved[0]\n self.assertEqual(entry[\"id\"], _UNKNOWN_PREFIX_ID_15)\n self.assertEqual(entry[\"reason\"], \"unknown-id-prefix:0aN\")\n\n def test_known_prefix_does_not_touch_unresolved(self):\n unresolved: list[dict] = []\n result = rit.resolve_or_unresolved(_APEX_ID_15, unresolved)\n self.assertEqual(result, (\"apex\", \"tooling_soql\"))\n self.assertEqual(unresolved, [])\n\n\nclass InvalidShapeTests(unittest.TestCase):\n def test_empty_string(self):\n unresolved: list[dict] = []\n result = rit.resolve_or_unresolved(\"\", unresolved)\n self.assertEqual(result, (\"unknown\", \"skip\"))\n self.assertEqual(len(unresolved), 1)\n self.assertEqual(unresolved[0][\"reason\"], \"invalid-id-format\")\n self.assertEqual(unresolved[0][\"id\"], \"\")\n\n def test_none_coerced_via_str(self):\n unresolved: list[dict] = []\n result = rit.resolve_or_unresolved(None, unresolved)\n self.assertEqual(result, (\"unknown\", \"skip\"))\n self.assertEqual(len(unresolved), 1)\n self.assertEqual(unresolved[0][\"reason\"], \"invalid-id-format\")\n self.assertEqual(unresolved[0][\"id\"], \"None\")\n\n def test_wrong_length_is_invalid(self):\n # A 10-char id-like string is not 15 or 18 — invalid shape, not\n # an unknown prefix.\n unresolved: list[dict] = []\n rit.resolve_or_unresolved(\"01pABC1234\", unresolved)\n self.assertEqual(len(unresolved), 1)\n self.assertEqual(unresolved[0][\"reason\"], \"invalid-id-format\")\n\n def test_bad_chars_rejected(self):\n # 15-char length but contains punctuation — not an Id shape.\n unresolved: list[dict] = []\n rit.resolve_or_unresolved(\"01p!!!!!!!!!!99\", unresolved)\n self.assertEqual(len(unresolved), 1)\n self.assertEqual(unresolved[0][\"reason\"], \"invalid-id-format\")\n\n def test_short_string_is_invalid(self):\n unresolved: list[dict] = []\n rit.resolve_or_unresolved(\"ab\", unresolved)\n self.assertEqual(len(unresolved), 1)\n self.assertEqual(unresolved[0][\"reason\"], \"invalid-id-format\")\n\n\nclass RegisteredPrefixesTests(unittest.TestCase):\n def test_exact_membership(self):\n self.assertEqual(\n rit.REGISTERED_PREFIXES,\n frozenset({\"01p\", \"300\", \"301\", \"0hf\"}),\n )\n\n def test_is_frozenset(self):\n \"\"\"Immutable so callers can't accidentally corrupt the table.\"\"\"\n self.assertIsInstance(rit.REGISTERED_PREFIXES, frozenset)\n\n\nclass ResolveTargetIdRobustnessTests(unittest.TestCase):\n \"\"\"`resolve_target_id` is the lower-level helper — it must also never\n raise, even on garbage input.\"\"\"\n\n def test_none_input(self):\n self.assertEqual(rit.resolve_target_id(None), (\"unknown\", \"skip\")) # type: ignore[arg-type]\n\n def test_empty_string(self):\n self.assertEqual(rit.resolve_target_id(\"\"), (\"unknown\", \"skip\"))\n\n def test_too_short(self):\n self.assertEqual(rit.resolve_target_id(\"ab\"), (\"unknown\", \"skip\"))\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5300,"content_sha256":"89d618ef6dd6c118f073289feb703b12e9e8f0561aa78bfa62b5f9a0ca8ae677"},{"filename":"scripts/tests/test_rest_client.py","content":"\"\"\"Tests for (cross-host redirect strips Authorization) and\n (redact_error scrubs bearer tokens).\n\nadds: `retry_on_401` decorator + `tooling_query` / `data_query`\nhelpers. Tests cover both the HTTP-401 and the INVALID_SESSION_ID-in-body\ncode paths, as well as the non-auth-error propagation guarantee.\n\"\"\"\nfrom __future__ import annotations\n\nimport email.message\nimport email.utils\nimport io\nimport json\nimport time\nimport unittest\nimport unittest.mock as mock\nimport urllib.error\nimport urllib.request\n\nfrom . import _bootstrap # noqa: F401\n\nimport rest_client # type: ignore\n\n\nclass RedirectHeaderStripTests(unittest.TestCase):\n \"\"\"Authorization must be dropped on cross-host redirect.\"\"\"\n\n def setUp(self) -> None:\n self.handler = rest_client.StripAuthOnCrossHostRedirect()\n # Minimal stand-in for the `fp` argument — the handler ignores it\n # except when constructing HTTPError on unsupported-scheme paths.\n self.fp = io.BytesIO(b\"\")\n # Headers are a Message-like; handler reads nothing from it for\n # the 302 path. An empty dict-like suffices.\n self.msg = {}\n\n def _make_request(self, url: str, auth: str = \"Bearer tok123\"):\n req = urllib.request.Request(url)\n if auth:\n req.add_header(\"Authorization\", auth)\n return req\n\n def _has_authorization(self, req) -> bool:\n for k in list(req.headers.keys()) + list(req.unredirected_hdrs.keys()):\n if k.lower() == \"authorization\":\n return True\n return False\n\n def test_same_host_redirect_preserves_authorization(self):\n req = self._make_request(\"https://na1.salesforce.com/services/data/v60.0/query\")\n new_req = self.handler.redirect_request(\n req, self.fp, 302, \"Found\", self.msg,\n \"https://na1.salesforce.com/services/data/v60.0/query?page=2\",\n )\n self.assertIsNotNone(new_req)\n self.assertTrue(\n self._has_authorization(new_req),\n \"same-host redirect must preserve Authorization\",\n )\n\n def test_cross_host_redirect_strips_authorization(self):\n req = self._make_request(\"https://na1.salesforce.com/services/data/v60.0/query\")\n new_req = self.handler.redirect_request(\n req, self.fp, 302, \"Found\", self.msg,\n \"https://attacker.example.com/steal\",\n )\n self.assertIsNotNone(new_req)\n self.assertFalse(\n self._has_authorization(new_req),\n \"cross-host redirect must strip Authorization \",\n )\n\n def test_downgrade_to_http_cross_host_strips(self):\n \"\"\"A redirect from HTTPS→HTTP on a different host is the classic\n MITM bait. Authorization must not follow.\"\"\"\n req = self._make_request(\"https://na1.salesforce.com/services/data/v60.0/query\")\n new_req = self.handler.redirect_request(\n req, self.fp, 302, \"Found\", self.msg,\n \"http://attacker.example.com/\",\n )\n self.assertIsNotNone(new_req)\n self.assertFalse(self._has_authorization(new_req))\n\n def test_case_insensitive_host_match(self):\n \"\"\"DNS is case-insensitive — `Foo.com` and `foo.com` are same host.\"\"\"\n req = self._make_request(\"https://Foo.Salesforce.com/path\")\n new_req = self.handler.redirect_request(\n req, self.fp, 302, \"Found\", self.msg,\n \"https://foo.salesforce.com/other\",\n )\n self.assertIsNotNone(new_req)\n self.assertTrue(self._has_authorization(new_req))\n\n def test_build_opener_wires_the_handler(self):\n opener = rest_client.build_opener()\n self.assertIsInstance(opener, urllib.request.OpenerDirector)\n installed = [\n h for h in opener.handlers\n if isinstance(h, rest_client.StripAuthOnCrossHostRedirect)\n ]\n self.assertEqual(\n len(installed), 1,\n \"build_opener must install exactly one StripAuthOnCrossHostRedirect\",\n )\n\n\nclass RedactErrorTests(unittest.TestCase):\n \"\"\"redact_error must scrub bearer tokens from exception strings.\"\"\"\n\n def test_bearer_token_redacted(self):\n # Scanner-inert placeholder — redactor only cares about the\n # `Authorization: Bearer ` prefix; the value is opaque `\\S+`.\n raw = \"Authorization: Bearer TESTONLY_HEADER.TESTONLY_PAYLOAD.TESTONLY_SIG\"\n exc = RuntimeError(raw)\n out = rest_client.redact_error(exc)\n self.assertIn(\"\u003credacted>\", out)\n self.assertNotIn(\"TESTONLY_HEADER\", out)\n self.assertNotIn(\"TESTONLY_PAYLOAD\", out)\n\n def test_bearer_token_case_insensitive(self):\n exc = RuntimeError(\"authorization: BEARER TESTONLY_BEARER_VALUE\")\n out = rest_client.redact_error(exc)\n self.assertNotIn(\"TESTONLY_BEARER_VALUE\", out)\n\n def test_bare_bearer_token_redacted(self):\n # No `Authorization:` prefix — e.g. curl-style traces or CLI stderr.\n for raw in (\n \"request failed: Bearer TESTONLY_BARE_VALUE\",\n \"headers={'auth': 'bearer TESTONLY_DICT_VALUE'}\",\n ):\n with self.subTest(raw=raw):\n out = rest_client.redact_text(raw)\n self.assertIn(\"\u003credacted>\", out)\n self.assertNotIn(\"TESTONLY_BARE_VALUE\", out)\n self.assertNotIn(\"TESTONLY_DICT_VALUE\", out)\n\n def test_auth_header_not_double_redacted(self):\n # The auth-header rule runs first; the bare rule must not double-mangle it.\n out = rest_client.redact_text(\"Authorization: Bearer TESTONLY_HDR\")\n self.assertEqual(out, \"Authorization: Bearer \u003credacted>\")\n\n def test_access_token_querystring_redacted(self):\n exc = RuntimeError(\"POST /oauth/token accessToken=TESTONLY_TOKEN&foo=1\")\n out = rest_client.redact_error(exc)\n self.assertNotIn(\"TESTONLY_TOKEN\", out)\n self.assertIn(\"\u003credacted>\", out)\n # The non-sensitive tail is preserved — handy for debugging.\n self.assertIn(\"foo=1\", out)\n\n def test_access_token_snake_case_querystring_redacted(self):\n exc = RuntimeError(\"access_token=TESTONLY_SNAKE_TOKEN&other=ok\")\n out = rest_client.redact_error(exc)\n self.assertNotIn(\"TESTONLY_SNAKE_TOKEN\", out)\n self.assertIn(\"other=ok\", out)\n\n def test_access_token_json_redacted(self):\n exc = RuntimeError('{\"accessToken\":\"TESTONLY_JSON_TOKEN\",\"status\":0}')\n out = rest_client.redact_error(exc)\n self.assertNotIn(\"TESTONLY_JSON_TOKEN\", out)\n self.assertIn(\"\u003credacted>\", out)\n # Non-sensitive JSON preserved.\n self.assertIn(\"status\", out)\n\n def test_access_token_json_snake_case_redacted(self):\n exc = RuntimeError('{\"access_token\":\"tok.ABC\"}')\n out = rest_client.redact_error(exc)\n self.assertNotIn(\"tok.ABC\", out)\n\n def test_output_includes_exception_type(self):\n exc = ValueError(\"boring message\")\n out = rest_client.redact_error(exc)\n self.assertTrue(out.startswith(\"ValueError:\"))\n\n def test_empty_exception_safe(self):\n exc = RuntimeError(\"\")\n out = rest_client.redact_error(exc)\n self.assertTrue(out.startswith(\"RuntimeError:\"))\n\n def test_plain_message_unchanged(self):\n exc = RuntimeError(\"connection refused\")\n out = rest_client.redact_error(exc)\n self.assertIn(\"connection refused\", out)\n\n\nclass RedactTextPublicSymbolTests(unittest.TestCase):\n \"\"\"`redact_text` is the public symbol; `_redact_text` stays as\n a deprecated alias for backwards compatibility.\n \"\"\"\n\n def test_redact_text_importable_at_module_top(self):\n \"\"\"Public name must be reachable via a plain `from` import — this\n is what sf_cli now relies on at module-top import time.\n \"\"\"\n from rest_client import redact_text # noqa: F401\n self.assertTrue(callable(redact_text))\n\n def test_redact_text_same_behavior_as_alias(self):\n \"\"\"The deprecated alias must be bound to the same function object\n so behavior drift is impossible.\n \"\"\"\n self.assertIs(rest_client._redact_text, rest_client.redact_text)\n\n def test_underscore_alias_still_works(self):\n \"\"\"Backwards-compat: any lingering `from rest_client import\n _redact_text` must keep functioning until the alias is removed\n in a follow-up batch.\n \"\"\"\n from rest_client import _redact_text # noqa: F401\n out = _redact_text(\"Authorization: Bearer tokXYZ\")\n self.assertIn(\"\u003credacted>\", out)\n self.assertNotIn(\"tokXYZ\", out)\n\n def test_public_redact_text_scrubs_bearer(self):\n out = rest_client.redact_text(\"Authorization: Bearer secrettok\")\n self.assertNotIn(\"secrettok\", out)\n self.assertIn(\"\u003credacted>\", out)\n\n\ndef _make_http_error(code: int, body: bytes = b\"\") -> urllib.error.HTTPError:\n \"\"\"Build a minimal HTTPError carrying `code` + `body`.\n\n We don't need a real socket — the decorator only reads `.code` and\n (optionally) the stringified form. Body is passed for parity with\n the wire.\n \"\"\"\n return urllib.error.HTTPError(\n url=\"https://example.salesforce.com/x\",\n code=code,\n msg=\"error\",\n hdrs={}, # type: ignore[arg-type]\n fp=io.BytesIO(body),\n )\n\n\nclass RetryOn401Tests(unittest.TestCase):\n \"\"\"decorator refreshes the token on 401 and retries once.\"\"\"\n\n def test_401_then_success_calls_refresh_once(self):\n \"\"\"Wrapped fn raises 401 first, succeeds on retry.\n\n refresh_fn MUST be called exactly once; wrapped fn returns normally.\n \"\"\"\n call_count = {\"n\": 0}\n\n def wrapped():\n call_count[\"n\"] += 1\n if call_count[\"n\"] == 1:\n raise _make_http_error(401)\n return {\"ok\": True}\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n decorated = rest_client.retry_on_401(refresh)(wrapped)\n result = decorated()\n\n self.assertEqual(result, {\"ok\": True})\n self.assertEqual(len(refresh_calls), 1, \"refresh_fn must be called exactly once\")\n self.assertEqual(call_count[\"n\"], 2)\n\n def test_401_twice_reraises_with_original_context(self):\n \"\"\"Second 401 → original 401 context surfaces (wrapped as RestClientError).\n\n refresh_fn still called only once (no infinite retry).\n \"\"\"\n\n def wrapped():\n raise _make_http_error(401, body=b\"session bad\")\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n decorated = rest_client.retry_on_401(refresh)(wrapped)\n with self.assertRaises(rest_client.RestClientError) as ctx:\n decorated()\n\n self.assertIn(\"auth error after refresh\", str(ctx.exception))\n self.assertEqual(len(refresh_calls), 1, \"refresh_fn must be called only once\")\n\n def test_invalid_session_body_triggers_refresh(self):\n \"\"\"INVALID_SESSION_ID signalled via body (HTTP 200) → refresh + retry.\"\"\"\n call_count = {\"n\": 0}\n\n def wrapped():\n call_count[\"n\"] += 1\n if call_count[\"n\"] == 1:\n raise rest_client._InvalidSessionSignal(\"body had INVALID_SESSION_ID\")\n return {\"ok\": True}\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n decorated = rest_client.retry_on_401(refresh)(wrapped)\n result = decorated()\n\n self.assertEqual(result, {\"ok\": True})\n self.assertEqual(len(refresh_calls), 1)\n\n def test_non_auth_http_error_propagates_without_refresh(self):\n \"\"\"500 → NO refresh, error propagates as-is.\"\"\"\n\n def wrapped():\n raise _make_http_error(500)\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n decorated = rest_client.retry_on_401(refresh)(wrapped)\n with self.assertRaises(urllib.error.HTTPError) as ctx:\n decorated()\n\n self.assertEqual(ctx.exception.code, 500)\n self.assertEqual(len(refresh_calls), 0, \"500 must NOT trigger refresh\")\n\n def test_retry_error_messages_go_through_redact_error(self):\n \"\"\"A token baked into the original 401 body must not survive in the\n RestClientError that gets re-raised on the second 401.\n \"\"\"\n\n def wrapped():\n # The HTTPError's str form echoes url + code. We bake a bearer\n # token into the msg to exercise the redact path on the\n # re-raise.\n err = urllib.error.HTTPError(\n url=\"https://example.salesforce.com\",\n code=401,\n msg=\"Authorization: Bearer TESTONLY_HTTPERROR_TOKEN\",\n hdrs={}, # type: ignore[arg-type]\n fp=io.BytesIO(b\"\"),\n )\n raise err\n\n def refresh():\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n decorated = rest_client.retry_on_401(refresh)(wrapped)\n with self.assertRaises(rest_client.RestClientError) as ctx:\n decorated()\n msg = str(ctx.exception)\n self.assertNotIn(\"TESTONLY_HTTPERROR_TOKEN\", msg)\n # The redaction sentinel must appear somewhere — proof the\n # substitution actually ran.\n self.assertIn(\"\u003credacted>\", msg)\n\n\nclass QueryHelperTests(unittest.TestCase):\n \"\"\"tooling_query + data_query — happy paths with mocked opener.\"\"\"\n\n def _install_mock_opener(self, response_body: bytes):\n \"\"\"Monkeypatch build_opener to return an opener whose .open returns\n a context manager yielding an object with .read() == response_body.\n \"\"\"\n fake_resp = mock.MagicMock()\n fake_resp.read.return_value = response_body\n fake_resp.__enter__ = mock.MagicMock(return_value=fake_resp)\n fake_resp.__exit__ = mock.MagicMock(return_value=False)\n\n fake_opener = mock.MagicMock()\n fake_opener.open = mock.MagicMock(return_value=fake_resp)\n return mock.patch.object(\n rest_client, \"build_opener\", return_value=fake_opener\n )\n\n def test_tooling_query_happy_path(self):\n body = json.dumps({\"records\": [{\"Id\": \"01p000000000001\"}]}).encode(\"utf-8\")\n with self._install_mock_opener(body):\n out = rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM ApexClass\",\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n self.assertEqual(out[\"records\"][0][\"Id\"], \"01p000000000001\")\n\n def test_data_query_happy_path(self):\n body = json.dumps({\"totalSize\": 0, \"records\": []}).encode(\"utf-8\")\n with self._install_mock_opener(body):\n out = rest_client.data_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM Account LIMIT 1\",\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n self.assertEqual(out[\"totalSize\"], 0)\n\n def test_tooling_query_apex_body_with_marker_does_not_trigger_refresh(self):\n \"\"\"BUGFIX 2026-05-03: 200 OK Tooling response where ApexClass.Body\n references `INVALID_SESSION_ID` in source must pass through\n unchanged — no refresh, no retry, no error wrapping.\"\"\"\n good_body = json.dumps({\n \"size\": 1,\n \"totalSize\": 1,\n \"done\": True,\n \"queryLocator\": None,\n \"entityTypeName\": \"ApexClass\",\n \"records\": [\n {\n \"attributes\": {\"type\": \"ApexClass\"},\n \"Id\": \"01pUv000003a1YaIAI\",\n \"Name\": \"XCSF_FlowFaultMessage\",\n \"Body\": \"// if (code == 'INVALID_SESSION_ID') rethrow;\",\n }\n ],\n }).encode(\"utf-8\")\n\n open_count = {\"n\": 0}\n\n def fake_open(req):\n open_count[\"n\"] += 1\n resp = mock.MagicMock()\n resp.read.return_value = good_body\n resp.__enter__ = mock.MagicMock(return_value=resp)\n resp.__exit__ = mock.MagicMock(return_value=False)\n return resp\n\n fake_opener = mock.MagicMock()\n fake_opener.open = fake_open\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n with mock.patch.object(rest_client, \"build_opener\", return_value=fake_opener):\n out = rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id, Name, Body FROM ApexClass WHERE Id IN (...)\",\n api_version=\"v66.0\",\n on_401_refresh=refresh,\n )\n\n self.assertEqual(out[\"totalSize\"], 1)\n self.assertEqual(out[\"records\"][0][\"Name\"], \"XCSF_FlowFaultMessage\")\n self.assertEqual(open_count[\"n\"], 1,\n \"exactly one HTTP call — no spurious retry\")\n self.assertEqual(refresh_calls, [],\n \"refresh must not fire on a 200 OK success payload\")\n\n def test_tooling_query_invalid_session_in_body_triggers_refresh(self):\n \"\"\"HTTP 200 with INVALID_SESSION_ID body → refresh closure invoked.\"\"\"\n bad_body = json.dumps([\n {\"errorCode\": \"INVALID_SESSION_ID\", \"message\": \"Session expired\"}\n ]).encode(\"utf-8\")\n good_body = json.dumps({\"records\": []}).encode(\"utf-8\")\n\n # Two-shot opener: first call returns bad_body, second returns good.\n responses = [bad_body, good_body]\n\n def fake_open(req):\n resp = mock.MagicMock()\n resp.read.return_value = responses.pop(0)\n resp.__enter__ = mock.MagicMock(return_value=resp)\n resp.__exit__ = mock.MagicMock(return_value=False)\n return resp\n\n fake_opener = mock.MagicMock()\n fake_opener.open = fake_open\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n with mock.patch.object(rest_client, \"build_opener\", return_value=fake_opener):\n out = rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM ApexClass\",\n api_version=\"v60.0\",\n on_401_refresh=refresh,\n )\n\n self.assertEqual(out, {\"records\": []})\n self.assertEqual(len(refresh_calls), 1)\n\n\nclass RetryOn401CredentialRefreshIntegrationTests(unittest.TestCase):\n \"\"\"Verify the retry carries the NEW token, not the stale one.\n\n The previous design looked correct under unit tests (refresh_fn called\n once, retry returns success) but was broken end-to-end: the closure\n around `(instance_url, token)` was captured at decoration time, so\n `refresh_fn`'s return value was thrown away and the retry saw the\n SAME stale credentials. Any real 401 (bad token) would hit the retry\n with the same bad token and 401 again.\n\n This test exercises a full tooling_query through a mock opener that\n 401s for the stale token and succeeds for the refreshed token. A\n fix that only changes decorator bookkeeping (without threading the\n refresh into the retry's actual HTTP call) will fail this test.\n \"\"\"\n\n def test_retry_on_401_uses_refreshed_credentials(self):\n # creds cell — a single-slot list mutated by refresh_fn.\n creds_cell: list = [(\"https://example.salesforce.com\", \"bad_token\")]\n\n def creds_provider():\n return creds_cell[0]\n\n def refresh_fn():\n creds_cell[0] = (\"https://example.salesforce.com\", \"good_token\")\n return creds_cell[0]\n\n # Tracks the Authorization header seen on every request — this is\n # what proves whether the retry carried the refreshed token or not.\n auth_headers_seen: list[str] = []\n\n def fake_open(req):\n # urllib normalizes header names on add_header; read whichever\n # case lands.\n auth = None\n for k, v in req.headers.items():\n if k.lower() == \"authorization\":\n auth = v\n break\n auth_headers_seen.append(auth or \"\")\n\n if auth == \"Bearer bad_token\":\n raise urllib.error.HTTPError(\n url=req.full_url,\n code=401,\n msg=\"Unauthorized\",\n hdrs={}, # type: ignore[arg-type]\n fp=io.BytesIO(b\"\"),\n )\n # Good token path — return a tiny success JSON.\n resp = mock.MagicMock()\n resp.read.return_value = json.dumps({\"records\": []}).encode(\"utf-8\")\n resp.__enter__ = mock.MagicMock(return_value=resp)\n resp.__exit__ = mock.MagicMock(return_value=False)\n return resp\n\n fake_opener = mock.MagicMock()\n fake_opener.open = fake_open\n\n with mock.patch.object(rest_client, \"build_opener\", return_value=fake_opener):\n out = rest_client.tooling_query(\n creds_provider,\n \"SELECT Id FROM ApexClass\",\n api_version=\"v60.0\",\n on_401_refresh=refresh_fn,\n )\n\n self.assertEqual(out, {\"records\": []})\n self.assertEqual(\n len(auth_headers_seen), 2,\n f\"expected 2 attempts (401 + retry), got {len(auth_headers_seen)}\",\n )\n self.assertEqual(\n auth_headers_seen[0], \"Bearer bad_token\",\n \"first attempt should have used the stale token\",\n )\n self.assertEqual(\n auth_headers_seen[1], \"Bearer good_token\",\n \"retry MUST use the refreshed token — this is the bug\",\n )\n\n\nclass TransientHttpWithRefreshIntegrationTests(unittest.TestCase):\n \"\"\"End-to-end stack test through `tooling_query`.\n\n Drives the real decorator chain baked into `tooling_query`: first\n response 429 (transient retry), second response 401 (refresh + retry),\n third response 200. Proves that the stacking in the production\n helper — NOT just the raw decorators — gives the intended behavior.\n \"\"\"\n\n def test_tooling_query_429_then_401_then_success(self):\n responses = [\n (\"429\", None),\n (\"401\", None),\n (\"ok\", json.dumps({\"records\": [{\"Id\": \"a\"}]}).encode(\"utf-8\")),\n ]\n creds_cell = [(\"https://example.salesforce.com\", \"bad_token\")]\n refresh_calls = []\n\n def creds_provider():\n return creds_cell[0]\n\n def refresh():\n refresh_calls.append(1)\n creds_cell[0] = (\"https://example.salesforce.com\", \"good_token\")\n return creds_cell[0]\n\n def fake_open(req):\n kind, body = responses.pop(0)\n if kind == \"429\":\n exc = urllib.error.HTTPError(\n url=req.full_url,\n code=429,\n msg=\"rate limited\",\n hdrs=email.message.Message(),\n fp=io.BytesIO(b\"\"),\n )\n exc.headers[\"Retry-After\"] = \"0\" # 0 → base_delay wins, but sleep is mocked\n raise exc\n if kind == \"401\":\n raise urllib.error.HTTPError(\n url=req.full_url,\n code=401,\n msg=\"unauthorized\",\n hdrs={}, # type: ignore[arg-type]\n fp=io.BytesIO(b\"\"),\n )\n resp = mock.MagicMock()\n resp.read.return_value = body\n resp.__enter__ = mock.MagicMock(return_value=resp)\n resp.__exit__ = mock.MagicMock(return_value=False)\n return resp\n\n fake_opener = mock.MagicMock()\n fake_opener.open = fake_open\n\n with mock.patch.object(rest_client, \"build_opener\", return_value=fake_opener), \\\n mock.patch.object(rest_client.time, \"sleep\"):\n out = rest_client.tooling_query(\n creds_provider,\n \"SELECT Id FROM ApexClass\",\n api_version=\"v60.0\",\n on_401_refresh=refresh,\n )\n\n self.assertEqual(out[\"records\"][0][\"Id\"], \"a\")\n self.assertEqual(len(refresh_calls), 1,\n \"exactly one refresh — 429 must NOT trigger the refresh\")\n self.assertEqual(responses, [], \"all three canned responses consumed\")\n\n\nclass AcceptEncodingAndForbiddenInvalidSessionTests(unittest.TestCase):\n \"\"\"Accept-Encoding: identity + 403+INVALID_SESSION_ID handling.\"\"\"\n\n def test_query_sends_accept_encoding_identity(self):\n \"\"\"The outbound request carries `Accept-Encoding: identity` so no\n gzip body ever lands on the body-inspection path.\"\"\"\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n\n captured: list = []\n\n def fake_open(req):\n captured.append(req)\n resp = mock.MagicMock()\n resp.read.return_value = body\n resp.__enter__ = mock.MagicMock(return_value=resp)\n resp.__exit__ = mock.MagicMock(return_value=False)\n return resp\n\n fake_opener = mock.MagicMock()\n fake_opener.open = fake_open\n\n with mock.patch.object(rest_client, \"build_opener\", return_value=fake_opener):\n rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM ApexClass\",\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n\n self.assertEqual(len(captured), 1)\n req = captured[0]\n # urllib stores add_header'd names via .capitalize(); read any case.\n accept_enc = None\n for k, v in req.headers.items():\n if k.lower() == \"accept-encoding\":\n accept_enc = v\n break\n self.assertEqual(accept_enc, \"identity\",\n \"Accept-Encoding must be 'identity'\")\n\n def test_403_invalid_session_triggers_refresh(self):\n \"\"\"HTTP 403 + INVALID_SESSION_ID body is treated as refreshable.\"\"\"\n\n call_count = {\"n\": 0}\n\n def wrapped():\n call_count[\"n\"] += 1\n if call_count[\"n\"] == 1:\n # 403 with INVALID_SESSION_ID body → should refresh.\n raise urllib.error.HTTPError(\n url=\"https://example.salesforce.com/x\",\n code=403,\n msg=\"Forbidden\",\n hdrs={}, # type: ignore[arg-type]\n fp=io.BytesIO(\n b'[{\"errorCode\":\"INVALID_SESSION_ID\",\"message\":\"Session expired\"}]'\n ),\n )\n return {\"ok\": True}\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n decorated = rest_client.retry_on_401(refresh)(wrapped)\n result = decorated()\n\n self.assertEqual(result, {\"ok\": True})\n self.assertEqual(len(refresh_calls), 1,\n \"403+INVALID_SESSION_ID must trigger refresh\")\n\n def test_403_non_invalid_session_does_not_trigger_refresh(self):\n \"\"\"HTTP 403 with some OTHER errorCode propagates unchanged.\"\"\"\n\n def wrapped():\n raise urllib.error.HTTPError(\n url=\"https://example.salesforce.com/x\",\n code=403,\n msg=\"Forbidden\",\n hdrs={}, # type: ignore[arg-type]\n fp=io.BytesIO(\n b'[{\"errorCode\":\"INSUFFICIENT_ACCESS\",\"message\":\"Denied\"}]'\n ),\n )\n\n refresh_calls = []\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new_tok\")\n\n decorated = rest_client.retry_on_401(refresh)(wrapped)\n with self.assertRaises(urllib.error.HTTPError) as ctx:\n decorated()\n\n self.assertEqual(ctx.exception.code, 403)\n self.assertEqual(len(refresh_calls), 0,\n \"non-auth 403 must NOT burn a refresh\")\n\n\nclass RetryOnTransientHttpTests(unittest.TestCase):\n \"\"\"429/503 → exponential-backoff retry up to max_retries.\"\"\"\n\n def test_429_then_success_retries_once(self):\n calls = {\"n\": 0}\n\n def fn():\n calls[\"n\"] += 1\n if calls[\"n\"] == 1:\n raise _make_http_error(429)\n return {\"ok\": True}\n\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http()(fn)\n out = decorated()\n\n self.assertEqual(out, {\"ok\": True})\n self.assertEqual(calls[\"n\"], 2)\n slept.assert_called_once()\n # First retry: base_delay * 2**0 = 1.0\n self.assertAlmostEqual(slept.call_args[0][0], 1.0)\n\n def test_503_three_times_propagates_httperror(self):\n \"\"\"With max_retries=3, total attempts = 4. Four 503s must surface\n the HTTPError after exactly 3 sleeps.\"\"\"\n def fn():\n raise _make_http_error(503)\n\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http(max_retries=3)(fn)\n with self.assertRaises(urllib.error.HTTPError) as ctx:\n decorated()\n\n self.assertEqual(ctx.exception.code, 503)\n self.assertEqual(slept.call_count, 3,\n \"3 sleeps for 3 retries before the final failure\")\n\n def test_retry_after_header_honored_when_larger(self):\n \"\"\"Retry-After: 10 overrides the 1s exponential schedule.\"\"\"\n def fn():\n exc = urllib.error.HTTPError(\n url=\"https://example.salesforce.com/x\",\n code=429,\n msg=\"rate limited\",\n hdrs=email.message.Message(),\n fp=io.BytesIO(b\"\"),\n )\n exc.headers[\"Retry-After\"] = \"10\"\n raise exc\n\n # fn always raises → exhaust retries; we just need to observe the\n # first sleep value.\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http(max_retries=1)(fn)\n with self.assertRaises(urllib.error.HTTPError):\n decorated()\n\n self.assertEqual(slept.call_count, 1)\n self.assertAlmostEqual(slept.call_args_list[0][0][0], 10.0,\n msg=\"Retry-After (10) must win over base_delay (1)\")\n\n def test_retry_after_smaller_than_base_loses(self):\n \"\"\"Retry-After: 0.5 with base_delay=1.0 → sleep 1.0 (base_delay wins).\"\"\"\n def fn():\n exc = urllib.error.HTTPError(\n url=\"https://example.salesforce.com/x\",\n code=429,\n msg=\"rate limited\",\n hdrs=email.message.Message(),\n fp=io.BytesIO(b\"\"),\n )\n exc.headers[\"Retry-After\"] = \"0.5\"\n raise exc\n\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http(max_retries=1)(fn)\n with self.assertRaises(urllib.error.HTTPError):\n decorated()\n\n self.assertAlmostEqual(slept.call_args_list[0][0][0], 1.0)\n\n def test_500_propagates_without_sleep(self):\n \"\"\"Non-429/503 HTTP errors are out of scope — no retry, no sleep.\"\"\"\n def fn():\n raise _make_http_error(500)\n\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http()(fn)\n with self.assertRaises(urllib.error.HTTPError) as ctx:\n decorated()\n\n self.assertEqual(ctx.exception.code, 500)\n slept.assert_not_called()\n\n def test_401_propagates_without_sleep(self):\n \"\"\"401 is retry_on_401's job — this decorator must pass it through.\"\"\"\n def fn():\n raise _make_http_error(401)\n\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http()(fn)\n with self.assertRaises(urllib.error.HTTPError) as ctx:\n decorated()\n\n self.assertEqual(ctx.exception.code, 401)\n slept.assert_not_called()\n\n def test_stacks_correctly_with_retry_on_401(self):\n \"\"\"Integration: 429 → transient retry → 401 → refresh retry → success.\n\n This verifies the documented ordering in `tooling_query` /\n `data_query`: transient-HTTP is inner, 401-refresh is outer. If\n the layering were reversed, the refresh would fire on the 429.\n \"\"\"\n sequence = [\"429\", \"401\", \"ok\"]\n refresh_calls = []\n\n def fn():\n code = sequence.pop(0)\n if code == \"429\":\n raise _make_http_error(429)\n if code == \"401\":\n raise _make_http_error(401)\n return {\"ok\": True}\n\n def refresh():\n refresh_calls.append(1)\n return (\"https://example.salesforce.com\", \"new\")\n\n with mock.patch.object(rest_client.time, \"sleep\"):\n # Inner: transient retry. Outer: 401 refresh. Matches the\n # stack in tooling_query / data_query.\n decorated = rest_client.retry_on_401(refresh)(\n rest_client.retry_on_transient_http()(fn)\n )\n out = decorated()\n\n self.assertEqual(out, {\"ok\": True})\n self.assertEqual(len(refresh_calls), 1,\n \"exactly one refresh — 429 must not burn a refresh\")\n self.assertEqual(sequence, [], \"all three responses consumed\")\n\n def test_retry_after_http_date_parses(self):\n \"\"\"HTTP-date form of Retry-After is accepted.\n\n We pass an RFC 7231 date roughly 5 seconds in the future. The\n parsed delta should be >= 0 and, since base_delay=1.0, the delta\n wins whenever it exceeds 1s. We accept any value >= 1.0 to avoid\n flakiness on slow test machines.\n \"\"\"\n future_ts = time.time() + 30 # 30s out, plenty of slack\n http_date = email.utils.formatdate(future_ts, usegmt=True)\n\n def fn():\n exc = urllib.error.HTTPError(\n url=\"https://example.salesforce.com/x\",\n code=503,\n msg=\"unavailable\",\n hdrs=email.message.Message(),\n fp=io.BytesIO(b\"\"),\n )\n exc.headers[\"Retry-After\"] = http_date\n raise exc\n\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http(max_retries=1)(fn)\n with self.assertRaises(urllib.error.HTTPError):\n decorated()\n\n self.assertEqual(slept.call_count, 1)\n delay = slept.call_args_list[0][0][0]\n # Sanity: must be larger than base_delay (1.0) and no bigger than\n # the original offset (30) plus generous test-timing slack.\n self.assertGreaterEqual(delay, 1.0)\n self.assertLessEqual(delay, 35.0)\n\n def test_malformed_retry_after_falls_back_to_base(self):\n \"\"\"`Retry-After: not-a-date` → base_delay wins.\"\"\"\n def fn():\n exc = urllib.error.HTTPError(\n url=\"https://example.salesforce.com/x\",\n code=429,\n msg=\"rate limited\",\n hdrs=email.message.Message(),\n fp=io.BytesIO(b\"\"),\n )\n exc.headers[\"Retry-After\"] = \"not-a-date\"\n raise exc\n\n with mock.patch.object(rest_client.time, \"sleep\") as slept:\n decorated = rest_client.retry_on_transient_http(max_retries=1, base_delay=2.0)(fn)\n with self.assertRaises(urllib.error.HTTPError):\n decorated()\n\n self.assertAlmostEqual(slept.call_args_list[0][0][0], 2.0,\n msg=\"malformed Retry-After falls back to base_delay\")\n\n\nclass InvalidSessionDetectionTests(unittest.TestCase):\n \"\"\"Unit coverage for the body-inspection helper.\"\"\"\n\n def test_list_with_invalid_session_detected(self):\n body = [{\"errorCode\": \"INVALID_SESSION_ID\", \"message\": \"foo\"}]\n self.assertTrue(rest_client._body_indicates_invalid_session(body))\n\n def test_dict_without_invalid_session_not_detected(self):\n body = {\"records\": [{\"Id\": \"a\"}]}\n self.assertFalse(rest_client._body_indicates_invalid_session(body))\n\n def test_none_safe(self):\n self.assertFalse(rest_client._body_indicates_invalid_session(None))\n\n def test_string_with_marker(self):\n self.assertTrue(rest_client._body_indicates_invalid_session(\n \"...INVALID_SESSION_ID...\"\n ))\n\n # --- BUGFIX 2026-05-03: false positive on ApexClass.Body substring -----\n # Regression: _body_indicates_invalid_session used to walk ALL dict\n # values and substring-match. A 200 OK Tooling Query response whose\n # records carried Apex source referencing the string INVALID_SESSION_ID\n # (e.g. `XCSF_FlowFaultMessage`, `SkillRulesMatchAction`) was\n # misclassified as a session error, triggering spurious Wave B retries\n # and a bogus `INVALID_SESSION_ID after refresh` entry in `_unresolved`.\n\n def test_success_body_with_apex_source_substring_not_detected(self):\n \"\"\"200 OK Tooling response whose ApexClass.Body mentions\n INVALID_SESSION_ID in source code must NOT be flagged.\"\"\"\n apex_source = (\n \"public class XCSF_FlowFaultMessage {\\n\"\n \" // Handles INVALID_SESSION_ID by rethrowing a typed error.\\n\"\n \" public static void raise(String code) {\\n\"\n \" if (code == 'INVALID_SESSION_ID') {\\n\"\n \" throw new HandledException('session expired');\\n\"\n \" }\\n\"\n \" }\\n\"\n \"}\"\n )\n body = {\n \"size\": 2,\n \"totalSize\": 2,\n \"done\": True,\n \"queryLocator\": None,\n \"entityTypeName\": \"ApexClass\",\n \"records\": [\n {\n \"attributes\": {\n \"type\": \"ApexClass\",\n \"url\": \"/services/data/v66.0/tooling/sobjects/ApexClass/01pUv0\",\n },\n \"Id\": \"01pUv000003a1YaIAI\",\n \"Name\": \"XCSF_FlowFaultMessage\",\n \"Body\": apex_source,\n },\n {\n \"attributes\": {\"type\": \"ApexClass\"},\n \"Id\": \"01pUv000003a1YbIAI\",\n \"Name\": \"SkillRulesMatchAction\",\n \"Body\": \"// references INVALID_SESSION_ID constant\",\n },\n ],\n }\n self.assertFalse(\n rest_client._body_indicates_invalid_session(body),\n \"success payload with INVALID_SESSION_ID inside ApexClass.Body \"\n \"must not be misclassified as an auth failure\",\n )\n\n def test_error_envelope_under_errors_key_still_detected(self):\n \"\"\"Dict-wrapped error envelope under a known key still flags.\"\"\"\n body = {\n \"errors\": [\n {\"errorCode\": \"INVALID_SESSION_ID\", \"message\": \"Session expired\"}\n ]\n }\n self.assertTrue(rest_client._body_indicates_invalid_session(body))\n\n def test_success_body_with_marker_in_data_field_not_detected(self):\n \"\"\"A top-level data field whose string value mentions the marker\n must not trigger — only error-envelope keys are walked.\"\"\"\n body = {\"records\": [{\"Id\": \"a\", \"Description\": \"handles INVALID_SESSION_ID\"}]}\n self.assertFalse(rest_client._body_indicates_invalid_session(body))\n\n\nclass QueryWireShapeTests(unittest.TestCase):\n \"\"\"`_query_once` must send GET with a urlencoded `q=`\n querystring. The prior POST-with-JSON-body shape returned HTTP 405\n on every real-org run — three reference fixtures failed\n end-to-end. These tests pin the wire shape so a regression back to\n POST would break CI before landing.\n \"\"\"\n\n def _install_capturing_opener(self, captured: list, response_body: bytes):\n def fake_open(req):\n captured.append(req)\n resp = mock.MagicMock()\n resp.read.return_value = response_body\n resp.__enter__ = mock.MagicMock(return_value=resp)\n resp.__exit__ = mock.MagicMock(return_value=False)\n return resp\n\n fake_opener = mock.MagicMock()\n fake_opener.open = fake_open\n return mock.patch.object(\n rest_client, \"build_opener\", return_value=fake_opener\n )\n\n def test_data_query_sends_get_with_urlencoded_querystring(self):\n captured: list = []\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n soql = \"SELECT Id FROM BotDefinition WHERE DeveloperName = 'Foo'\"\n\n with self._install_capturing_opener(captured, body):\n rest_client.data_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n soql,\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n\n self.assertEqual(len(captured), 1)\n req = captured[0]\n # method must be GET. Previously POST.\n self.assertEqual(req.get_method(), \"GET\",\n \"REST Query endpoint is GET-only; POST returns 405\")\n # GET has no body.\n self.assertIsNone(req.data,\n \"GET requests must carry no body\")\n # URL must carry the urlencoded querystring.\n self.assertIn(\"/services/data/v60.0/query/?q=\", req.full_url)\n # urllib.parse.urlencode produces `+` for spaces — decoding\n # should round-trip the SOQL exactly.\n import urllib.parse\n parsed = urllib.parse.urlparse(req.full_url)\n qs = urllib.parse.parse_qs(parsed.query)\n self.assertEqual(qs.get(\"q\"), [soql],\n \"urlencoded `q` must round-trip the SOQL exactly\")\n\n def test_tooling_query_sends_get_to_tooling_path(self):\n captured: list = []\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n soql = \"SELECT Id, DeveloperName FROM BotVersion\"\n\n with self._install_capturing_opener(captured, body):\n rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n soql,\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n\n self.assertEqual(len(captured), 1)\n req = captured[0]\n self.assertEqual(req.get_method(), \"GET\")\n self.assertIsNone(req.data)\n # Tooling path is distinct from Data API path.\n self.assertIn(\"/services/data/v60.0/tooling/query/?q=\", req.full_url)\n\n def test_query_does_not_send_content_type_header(self):\n \"\"\"GET has no body — Content-Type is inappropriate and was\n removed alongside the method change. Some edge proxies complain\n about Content-Type on a bodyless GET; we just drop it.\"\"\"\n captured: list = []\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n\n with self._install_capturing_opener(captured, body):\n rest_client.data_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM Account LIMIT 1\",\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n\n req = captured[0]\n content_type = None\n for k, v in req.headers.items():\n if k.lower() == \"content-type\":\n content_type = v\n break\n self.assertIsNone(content_type,\n \"GET requests must not carry Content-Type\")\n\n def test_soql_with_special_chars_is_urlencoded(self):\n \"\"\"SOQL string literals contain single quotes, commas, spaces,\n parentheses. urlencode must escape them all; a naive concat\n would break either the URL parse or the SOQL semantics.\"\"\"\n captured: list = []\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n # Exercise the full SOQL character classes that routinely appear.\n soql = (\n \"SELECT Id, Name FROM ApexClass \"\n \"WHERE Name IN ('Foo', 'Bar Baz') \"\n \"AND NamespacePrefix IS NULL LIMIT 200\"\n )\n\n with self._install_capturing_opener(captured, body):\n rest_client.data_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n soql,\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n\n req = captured[0]\n import urllib.parse\n parsed = urllib.parse.urlparse(req.full_url)\n qs = urllib.parse.parse_qs(parsed.query)\n self.assertEqual(qs.get(\"q\"), [soql])\n # Spaces MUST be escaped — a raw space in a URL is malformed.\n self.assertNotIn(\" \", parsed.query,\n \"querystring must contain no literal spaces\")\n\n\nclass ApiVersionRequiredKwargTests(unittest.TestCase):\n \"\"\"`api_version` is a REQUIRED keyword-only\n argument on `tooling_query` + `data_query`.\n\n The prior design hardcoded `v60.0`. Real orgs (my-org-alias,\n my-perf-org-alias) run on v66 and expose fields v60 does not — confirmed\n empirically: `BotDefinition.Description` resolves on v66, raises\n `INVALID_FIELD` on v60 for the same org. Making `api_version`\n required surfaces a missed call-site as a TypeError at call time,\n not a silent regression back to a stale pinned version.\n \"\"\"\n\n def test_tooling_query_requires_api_version(self):\n with self.assertRaises(TypeError) as ctx:\n rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM ApexClass\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n self.assertIn(\"api_version\", str(ctx.exception))\n\n def test_data_query_requires_api_version(self):\n with self.assertRaises(TypeError) as ctx:\n rest_client.data_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM Account LIMIT 1\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n self.assertIn(\"api_version\", str(ctx.exception))\n\n\nclass ApiVersionUrlSubstitutionTests(unittest.TestCase):\n \"\"\"the `api_version` kwarg lands in the URL path\n unchanged. A caller passing `v66.0` must produce\n `/services/data/v66.0/...` in the outbound Request, NOT the old\n hardcoded `v60.0`.\n\n Regression shield: if a future refactor re-pins the version string,\n these tests fail at the wire-shape assertion even when unit-level\n behavior of `tooling_query` still looks correct.\n \"\"\"\n\n def _install_capturing_opener(self, captured: list, response_body: bytes):\n def fake_open(req):\n captured.append(req)\n resp = mock.MagicMock()\n resp.read.return_value = response_body\n resp.__enter__ = mock.MagicMock(return_value=resp)\n resp.__exit__ = mock.MagicMock(return_value=False)\n return resp\n\n fake_opener = mock.MagicMock()\n fake_opener.open = fake_open\n return mock.patch.object(\n rest_client, \"build_opener\", return_value=fake_opener\n )\n\n def test_tooling_query_v66_in_url(self):\n captured: list = []\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n with self._install_capturing_opener(captured, body):\n rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM ApexClass\",\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n self.assertEqual(len(captured), 1)\n url = captured[0].full_url\n self.assertIn(\"/services/data/v66.0/tooling/query/?q=\", url)\n # And the old hardcoded version must NOT appear — otherwise the\n # substitution silently failed.\n self.assertNotIn(\"v60.0\", url)\n\n def test_data_query_v66_in_url(self):\n captured: list = []\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n with self._install_capturing_opener(captured, body):\n rest_client.data_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM BotDefinition LIMIT 1\",\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n url = captured[0].full_url\n self.assertIn(\"/services/data/v66.0/query/?q=\", url)\n self.assertNotIn(\"v60.0\", url)\n\n def test_different_api_version_per_call(self):\n \"\"\"Two calls with different `api_version` land on distinct URLs —\n proves the value is NOT cached in module-level state between\n invocations.\"\"\"\n captured: list = []\n body = json.dumps({\"records\": []}).encode(\"utf-8\")\n with self._install_capturing_opener(captured, body):\n rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM ApexClass\",\n api_version=\"v60.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n rest_client.tooling_query(\n rest_client.static_creds(\"https://example.salesforce.com\", \"tok\"),\n \"SELECT Id FROM ApexClass\",\n api_version=\"v66.0\",\n on_401_refresh=lambda: (\"https://example.salesforce.com\", \"new\"),\n )\n self.assertEqual(len(captured), 2)\n self.assertIn(\"/services/data/v60.0/tooling/query/\", captured[0].full_url)\n self.assertIn(\"/services/data/v66.0/tooling/query/\", captured[1].full_url)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":50961,"content_sha256":"57913ff6f5995aaf42d2a4f388fe2a0550952488a2af0abc6a2f95afa7e66366"},{"filename":"scripts/tests/test_runtime_override.py","content":"\"\"\"Tests for the --data-dir / --cache-dir override mechanism.\n\nThree things must be true after ``--data-dir \u003cpath>`` is parsed:\n\n1. ``paths.DATA_ROOT`` points at the override.\n2. ``config.DATA_ROOT`` points at the override (re-export).\n3. ``main.DATA_ROOT`` points at the override (the local ``from config import\n DATA_ROOT`` snapshot at module top).\n\nIf any of the three is stale, callers reading ``DATA_ROOT`` from that module\nsee the default path. ``main.py:2221`` writes ``str(DATA_ROOT)`` into the\n``.emit_ctx.json`` file consumed by ``emit_result.py`` — getting that wrong\nmeans the RESULT block points users at the wrong directory.\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport config # type: ignore\nimport main # type: ignore\nfrom _shared import paths, runtime # type: ignore\n\n\ndef _reset_overrides() -> None:\n \"\"\"Clear runtime overrides so the test starts from a known state.\"\"\"\n runtime.set_data_root_override(None)\n runtime.set_cache_root_override(None)\n # Re-resolve all three namespace levels back to defaults.\n default_data = runtime.resolve_data_root(\"investigating-agentforce-architecture\")\n default_cache = runtime.resolve_cache_root(\"investigating-agentforce-architecture\")\n paths.DATA_ROOT = default_data\n paths.CACHE_ROOT = default_cache\n config.DATA_ROOT = default_data\n config.CACHE_ROOT = default_cache\n config.PROBE_CACHE_ROOT = default_cache / \"_channel_probe\"\n main.DATA_ROOT = default_data\n\n\ndef test_default_paths_under_vibe() -> None:\n \"\"\"No overrides → all 3 namespaces point at ~/.vibe/.\"\"\"\n _reset_overrides()\n expected_data = Path.home() / \".vibe\" / \"data\" / \"investigating-agentforce-architecture\"\n expected_cache = Path.home() / \".vibe\" / \"cache\" / \"investigating-agentforce-architecture\"\n assert paths.DATA_ROOT == expected_data\n assert config.DATA_ROOT == expected_data\n assert main.DATA_ROOT == expected_data\n assert paths.CACHE_ROOT == expected_cache\n assert config.CACHE_ROOT == expected_cache\n\n\ndef test_data_dir_override_rebinds_three_namespaces(tmp_path: Path) -> None:\n \"\"\"--data-dir /tmp/x → all 3 namespaces (paths, config, main) update.\"\"\"\n _reset_overrides()\n override = tmp_path / \"custom-data\"\n args = main.parse_args([\n \"--org-alias\", \"myorg\",\n \"--agent\", \"MyAgent\",\n \"--work-dir\", str(tmp_path / \"work\"),\n \"--data-dir\", str(override),\n ])\n main._apply_path_overrides(args)\n # All three namespace levels must equal the override path.\n assert paths.DATA_ROOT == override, \"paths.DATA_ROOT not rebound\"\n assert config.DATA_ROOT == override, \"config.DATA_ROOT not rebound\"\n assert main.DATA_ROOT == override, \"main.DATA_ROOT not rebound\"\n _reset_overrides()\n\n\ndef test_cache_dir_override_rebinds_paths_and_config(tmp_path: Path) -> None:\n \"\"\"--cache-dir /tmp/y → paths.CACHE_ROOT, config.CACHE_ROOT, and PROBE_CACHE_ROOT update.\"\"\"\n _reset_overrides()\n override = tmp_path / \"custom-cache\"\n args = main.parse_args([\n \"--org-alias\", \"myorg\",\n \"--agent\", \"MyAgent\",\n \"--work-dir\", str(tmp_path / \"work\"),\n \"--cache-dir\", str(override),\n ])\n main._apply_path_overrides(args)\n assert paths.CACHE_ROOT == override\n assert config.CACHE_ROOT == override\n assert config.PROBE_CACHE_ROOT == override / \"_channel_probe\"\n _reset_overrides()\n\n\ndef test_no_override_leaves_defaults_intact(tmp_path: Path) -> None:\n \"\"\"Running with no override flags must not mutate anything.\"\"\"\n _reset_overrides()\n expected_data = Path.home() / \".vibe\" / \"data\" / \"investigating-agentforce-architecture\"\n args = main.parse_args([\n \"--org-alias\", \"myorg\",\n \"--agent\", \"MyAgent\",\n \"--work-dir\", str(tmp_path / \"work\"),\n ])\n main._apply_path_overrides(args)\n assert paths.DATA_ROOT == expected_data\n assert config.DATA_ROOT == expected_data\n assert main.DATA_ROOT == expected_data\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4026,"content_sha256":"9015c54372f1228016a055747ac79e02257459d988f64891729b7f7dd1e7dff1"},{"filename":"scripts/tests/test_sf_cli.py","content":"\"\"\"Tests for sf_cli loads YAML with safe_load only.\n\nAlso covers redaction boundary for stderr content via the local\n_redact_subprocess_stderr helper.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nimport yaml\n\nfrom . import _bootstrap # noqa: F401\n\nimport sf_cli # type: ignore\n\n\nclass YamlSafeLoadTests(unittest.TestCase):\n \"\"\"the bound loader must be identity-equal to yaml.safe_load.\"\"\"\n\n def test_loader_is_yaml_safe_load(self):\n self.assertIs(\n sf_cli._SAFE_LOADER,\n yaml.safe_load,\n \"sf_cli must use yaml.safe_load, not yaml.load \",\n )\n\n def test_module_docstring_mentions_safe_load(self):\n \"\"\"The security contract lives in the docstring; reviewers who\n grep for `yaml.load` in this module should find only the banned\n reference plus the explanation.\"\"\"\n self.assertIn(\"yaml.safe_load\", sf_cli.__doc__ or \"\")\n self.assertIn(\"yaml.load is banned\", sf_cli.__doc__ or \"\")\n\n def test_safe_load_rejects_python_object_construction(self):\n \"\"\"Sanity check that safe_load actually differs from load —\n an attacker-controlled `!!python/object` tag must raise.\"\"\"\n malicious = \"!!python/object/apply:os.system ['echo pwned']\"\n with self.assertRaises(yaml.YAMLError):\n sf_cli._SAFE_LOADER(malicious)\n\n\nclass StderrRedactionTests(unittest.TestCase):\n \"\"\"subprocess stderr is redacted before reaching log/exception text.\"\"\"\n\n def test_bearer_token_in_stderr_redacted(self):\n stderr = \"Error: API call failed\\nAuthorization: Bearer TESTONLY_STDERR_TOKEN\\n\"\n safe = sf_cli._redact_subprocess_stderr(stderr)\n self.assertNotIn(\"TESTONLY_STDERR_TOKEN\", safe)\n self.assertIn(\"\u003credacted>\", safe)\n\n def test_empty_stderr_returns_empty(self):\n self.assertEqual(sf_cli._redact_subprocess_stderr(\"\"), \"\")\n self.assertEqual(sf_cli._redact_subprocess_stderr(None), \"\")\n\n\nclass ModuleTopImportTests(unittest.TestCase):\n \"\"\"`redact_text` is imported at sf_cli module top. If anyone\n re-introduces a lazy `from rest_client import _redact_text` inside\n `_redact_subprocess_stderr`, these tests fail.\n \"\"\"\n\n def test_redact_text_bound_at_module_top(self):\n self.assertTrue(hasattr(sf_cli, \"redact_text\"))\n # And it's the actual public function, not a stub.\n import rest_client as _rc\n self.assertIs(sf_cli.redact_text, _rc.redact_text)\n\n def test_subprocess_stderr_redaction_does_not_import_private(self):\n \"\"\"Behavioral proof: redaction works without any lazy import. If\n rest_client.redact_text were renamed and nobody updated sf_cli's\n module-top import, the module would fail to load — an obvious,\n loud failure rather than a silent redaction skip at runtime.\n \"\"\"\n stderr = \"Authorization: Bearer TOKEN_MUST_NOT_SURVIVE\"\n out = sf_cli._redact_subprocess_stderr(stderr)\n self.assertNotIn(\"TOKEN_MUST_NOT_SURVIVE\", out)\n self.assertIn(\"\u003credacted>\", out)\n\n\nclass StderrAuthLinePrefixTests(unittest.TestCase):\n \"\"\"auth patterns only match on lines starting with Error:/Warning:.\n\n Tightens `_stderr_matches_auth` to line-anchored scanning so an\n embedded substring in prose (or a stray Node ESM warning that\n doesn't begin with Error:/Warning:) doesn't false-trigger\n AuthRequired.\n \"\"\"\n\n PATTERNS = (\"NoOrgAuthenticationError\", \"NamedOrgNotFoundError\")\n\n def test_error_line_with_pattern_matches(self):\n stderr = \"Error: NoOrgAuthenticationError — no org logged in\\n\"\n self.assertTrue(sf_cli._stderr_matches_auth(stderr, self.PATTERNS))\n\n def test_warning_line_with_pattern_matches(self):\n \"\"\"Warning-prefixed lines are ALSO in scope per the tightened rule.\n\n Some sf CLI plugins emit auth issues as Warning-level diagnostics\n before the hard Error; we don't want to miss those.\n \"\"\"\n stderr = \"Warning: NoOrgAuthenticationError — auth cache stale\\n\"\n self.assertTrue(sf_cli._stderr_matches_auth(stderr, self.PATTERNS))\n\n def test_embedded_prose_does_not_match(self):\n \"\"\"Pattern embedded mid-line without Error:/Warning: prefix → NO match.\n\n This is the behavior change: previous substring match would\n have false-positived here.\n \"\"\"\n stderr = \"see docs for NoOrgAuthenticationError troubleshooting\\n\"\n self.assertFalse(sf_cli._stderr_matches_auth(stderr, self.PATTERNS))\n\n def test_empty_stderr_no_match(self):\n self.assertFalse(sf_cli._stderr_matches_auth(\"\", self.PATTERNS))\n self.assertFalse(sf_cli._stderr_matches_auth(None, self.PATTERNS))\n\n def test_multiline_stderr_pattern_on_third_line_matches(self):\n stderr = (\n \"Warning: @gthoppae/sf-cli-plugin-data360 is a linked ESM module\\n\"\n \"debug: config loaded\\n\"\n \"Error: NoOrgAuthenticationError — no default org\\n\"\n )\n self.assertTrue(sf_cli._stderr_matches_auth(stderr, self.PATTERNS))\n\n def test_esm_warning_without_auth_pattern_no_match(self):\n \"\"\"The exact warning shape we're trying NOT to false-positive on.\n\n If a future sf CLI plugin name happened to contain an auth pattern\n substring (unlikely but possible), the line-prefix rule alone\n wouldn't save us. For now, this canonical ESM warning contains no\n auth pattern, so it MUST NOT match.\n \"\"\"\n stderr = (\n \"Warning: @gthoppae/sf-cli-plugin-data360 is a linked ESM module\\n\"\n \"and will not reload without CLI restart\\n\"\n )\n self.assertFalse(sf_cli._stderr_matches_auth(stderr, self.PATTERNS))\n\n def test_other_prefix_with_pattern_does_not_match(self):\n \"\"\"Lines that don't start with Error:/Warning: — even with a pattern\n substring — are filtered out.\"\"\"\n stderr = \"debug: NoOrgAuthenticationError transient\\n\"\n self.assertFalse(sf_cli._stderr_matches_auth(stderr, self.PATTERNS))\n\n\nclass RunSfTests(unittest.TestCase):\n \"\"\"Cover ``run_sf`` end-to-end with subprocess mocked.\n\n Uses the real ``org_display`` recipe shipped under ``assets/cli/`` so\n the recipe load + arg substitution + auth-pattern config exercise\n real code paths.\n \"\"\"\n\n from types import SimpleNamespace as _Namespace\n\n def _patch_subprocess(self, *, returncode: int = 0,\n stdout: str = '{\"status\":0,\"result\":{}}',\n stderr: str = \"\",\n raise_exc: BaseException | None = None):\n from unittest import mock\n if raise_exc is not None:\n return mock.patch.object(sf_cli.subprocess, \"run\", side_effect=raise_exc)\n cp = self._Namespace(returncode=returncode, stdout=stdout, stderr=stderr)\n return mock.patch.object(sf_cli.subprocess, \"run\", return_value=cp)\n\n def test_returns_parsed_stdout_on_success(self):\n with self._patch_subprocess():\n data = sf_cli.run_sf(\"org_display\", ORG_ALIAS=\"my-org\")\n self.assertEqual(data, {\"status\": 0, \"result\": {}})\n\n def test_raises_when_required_param_missing(self):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.run_sf(\"org_display\") # missing ORG_ALIAS\n self.assertIn(\"missing required params\", str(ctx.exception))\n self.assertIn(\"ORG_ALIAS\", str(ctx.exception))\n\n def test_classifies_auth_failure_via_stderr_pattern(self):\n # org_display recipe has NoOrgAuthenticationError in its\n # auth_required_stderr_patterns list.\n with self._patch_subprocess(\n returncode=1,\n stdout='{\"status\":1}',\n stderr=\"Error: NoOrgAuthenticationError — no org\\n\",\n ):\n with self.assertRaises(sf_cli.AuthRequired):\n sf_cli.run_sf(\"org_display\", ORG_ALIAS=\"my-org\")\n\n def test_raises_sfcli_error_on_nonauth_failure(self):\n with self._patch_subprocess(\n returncode=1,\n stdout='{\"status\":1}',\n stderr=\"Error: SomethingElseBlewUp\\n\",\n ):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.run_sf(\"org_display\", ORG_ALIAS=\"my-org\")\n self.assertIn(\"exit=1\", str(ctx.exception))\n\n def test_raises_sfcli_error_on_subprocess_timeout(self):\n import subprocess as _subprocess\n with self._patch_subprocess(\n raise_exc=_subprocess.TimeoutExpired(cmd=[\"sf\"], timeout=1),\n ):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.run_sf(\"org_display\", ORG_ALIAS=\"my-org\")\n self.assertIn(\"timed out\", str(ctx.exception))\n\n def test_raises_sfcli_error_when_binary_missing(self):\n # FileNotFoundError = sf binary not on PATH.\n with self._patch_subprocess(raise_exc=FileNotFoundError(\"sf not found\")):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.run_sf(\"org_display\", ORG_ALIAS=\"my-org\")\n self.assertIn(\"invocation failed\", str(ctx.exception))\n\n def test_does_not_inject_show_secrets_env(self):\n \"\"\"Tripwire: run_sf must not rely on SF_TEMP_SHOW_SECRETS.\n\n sf CLI removes that env var (forcedotcom/cli#3560); tokens come\n only from ``sf org auth show-access-token``.\n \"\"\"\n from unittest import mock\n captured = {}\n\n def fake_run(argv, **kwargs):\n captured[\"env\"] = kwargs.get(\"env\")\n captured[\"argv\"] = argv\n return self._Namespace(\n returncode=0, stdout='{\"status\":0,\"result\":{}}', stderr=\"\",\n )\n\n with mock.patch.object(sf_cli.subprocess, \"run\", side_effect=fake_run):\n sf_cli.run_sf(\"org_display\", ORG_ALIAS=\"my-org\")\n\n env = captured[\"env\"] or {}\n self.assertNotIn(\"SF_TEMP_SHOW_SECRETS\", env)\n\n def test_org_display_recipe_includes_verbose_flag(self):\n \"\"\"Tripwire for the W-22582511 ``--verbose`` fix.\n\n Without ``--verbose``, some sf CLI builds omit fields that callers\n expect in ``--json`` output.\n \"\"\"\n from unittest import mock\n captured = {}\n\n def fake_run(argv, **kwargs):\n captured[\"argv\"] = argv\n return self._Namespace(\n returncode=0, stdout='{\"status\":0,\"result\":{}}', stderr=\"\",\n )\n\n with mock.patch.object(sf_cli.subprocess, \"run\", side_effect=fake_run):\n sf_cli.run_sf(\"org_display\", ORG_ALIAS=\"my-org\")\n\n self.assertIn(\"--verbose\", captured[\"argv\"])\n\n\nclass ShowAccessTokenCapabilityTests(unittest.TestCase):\n \"\"\"Preflight for the required ``sf org auth show-access-token`` command.\"\"\"\n\n # Shape of real ``--help`` output (sf CLI 2.150.6): the command id is\n # printed in the USAGE line.\n REAL_HELP = (\n \"Show an access token for an org.\\n\\nUSAGE\\n\"\n \" $ sf org auth show-access-token -o \u003cvalue> [--json]\\n\"\n )\n\n def setUp(self):\n # The preflight caches success process-wide; isolate every test.\n sf_cli._show_access_token_capability_ok = False\n self.addCleanup(\n setattr, sf_cli, \"_show_access_token_capability_ok\", False,\n )\n\n def test_capability_check_passes_when_help_available(self):\n from types import SimpleNamespace\n from unittest import mock\n\n cp = SimpleNamespace(returncode=0, stdout=self.REAL_HELP, stderr=\"\")\n with mock.patch.object(sf_cli.subprocess, \"run\", return_value=cp) as run:\n sf_cli.assert_show_access_token_capability()\n argv = run.call_args.args[0]\n self.assertEqual(argv, [\"sf\", \"org\", \"auth\", \"show-access-token\", \"--help\"])\n\n def test_capability_check_raises_clear_error_when_missing(self):\n import subprocess as _subprocess\n from unittest import mock\n\n err = _subprocess.CalledProcessError(\n returncode=1,\n cmd=[\"sf\", \"org\", \"auth\", \"show-access-token\", \"--help\"],\n stderr=\"show-access-token is not a sf command\",\n )\n with mock.patch.object(sf_cli.subprocess, \"run\", side_effect=err):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.assert_show_access_token_capability()\n self.assertIn(\"missing required command\", str(ctx.exception))\n self.assertIn(\"sf org auth show-access-token\", str(ctx.exception))\n\n def test_capability_check_raises_when_sf_not_on_path(self):\n from unittest import mock\n\n with mock.patch.object(\n sf_cli.subprocess, \"run\", side_effect=FileNotFoundError(\"sf\"),\n ):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.assert_show_access_token_capability()\n self.assertIn(\"not found on PATH\", str(ctx.exception))\n\n def test_successful_preflight_is_cached_for_process(self):\n from types import SimpleNamespace\n from unittest import mock\n\n cp = SimpleNamespace(returncode=0, stdout=self.REAL_HELP, stderr=\"\")\n with mock.patch.object(sf_cli.subprocess, \"run\", return_value=cp) as run:\n sf_cli.assert_show_access_token_capability()\n sf_cli.assert_show_access_token_capability()\n self.assertEqual(run.call_count, 1)\n\n def test_failed_preflight_is_not_cached(self):\n import subprocess as _subprocess\n from types import SimpleNamespace\n from unittest import mock\n\n err = _subprocess.CalledProcessError(\n returncode=1,\n cmd=[\"sf\", \"org\", \"auth\", \"show-access-token\", \"--help\"],\n stderr=\"show-access-token is not a sf command\",\n )\n ok = SimpleNamespace(returncode=0, stdout=self.REAL_HELP, stderr=\"\")\n with mock.patch.object(\n sf_cli.subprocess, \"run\", side_effect=[err, ok],\n ) as run:\n with self.assertRaises(sf_cli.SfCliError):\n sf_cli.assert_show_access_token_capability()\n # Retried (not cached) and now succeeds.\n sf_cli.assert_show_access_token_capability()\n # Success is cached — no third spawn.\n sf_cli.assert_show_access_token_capability()\n self.assertEqual(run.call_count, 2)\n\n def test_zero_exit_without_help_signature_fails_and_is_not_cached(self):\n \"\"\"A CLI that answers an unknown command with exit 0 (e.g. printing\n top-level help) must NOT be treated as capable.\"\"\"\n from types import SimpleNamespace\n from unittest import mock\n\n bogus = SimpleNamespace(\n returncode=0,\n stdout=\"\\x1b[1mUSAGE\\x1b[22m\\n $ sf [COMMAND]\\n\\nTOPICS\\n org\\n\",\n stderr=\"Authorization: Bearer SECRET_TOKEN_X\",\n )\n ok = SimpleNamespace(returncode=0, stdout=self.REAL_HELP, stderr=\"\")\n with mock.patch.object(\n sf_cli.subprocess, \"run\", side_effect=[bogus, ok],\n ) as run:\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.assert_show_access_token_capability()\n msg = str(ctx.exception)\n self.assertIn(\"missing required command\", msg)\n self.assertNotIn(\"SECRET_TOKEN_X\", msg)\n self.assertFalse(sf_cli._show_access_token_capability_ok)\n # Next call re-probes and now succeeds.\n sf_cli.assert_show_access_token_capability()\n self.assertEqual(run.call_count, 2)\n self.assertTrue(sf_cli._show_access_token_capability_ok)\n\n def test_zero_exit_with_empty_output_fails(self):\n from types import SimpleNamespace\n from unittest import mock\n\n cp = SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n with mock.patch.object(sf_cli.subprocess, \"run\", return_value=cp):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.assert_show_access_token_capability()\n self.assertIn(\"lacked the command signature\", str(ctx.exception))\n self.assertFalse(sf_cli._show_access_token_capability_ok)\n\n def test_zero_exit_unknown_command_notice_echoing_name_fails(self):\n \"\"\"Unknown-command notices contain the bare command name; only the\n USAGE form (`$ sf org auth show-access-token`) may pass.\"\"\"\n from types import SimpleNamespace\n from unittest import mock\n\n for out in (\n \"Error: Command org:auth:show-access-token not found.\",\n \"Warning: show-access-token is not a sf command.\",\n \"Did you mean org auth show-access-token? Run sf help for options.\",\n ):\n with self.subTest(out=out):\n sf_cli._show_access_token_capability_ok = False\n cp = SimpleNamespace(returncode=0, stdout=\"\", stderr=out)\n with mock.patch.object(sf_cli.subprocess, \"run\", return_value=cp):\n with self.assertRaises(sf_cli.SfCliError) as ctx:\n sf_cli.assert_show_access_token_capability()\n self.assertIn(\"missing required command\", str(ctx.exception))\n self.assertFalse(sf_cli._show_access_token_capability_ok)\n\n def test_ansi_colored_real_help_passes(self):\n \"\"\"Real help with ANSI styling still matches the signature.\"\"\"\n from types import SimpleNamespace\n from unittest import mock\n\n cp = SimpleNamespace(\n returncode=0,\n stdout=\"\\x1b[1mUSAGE\\x1b[22m\\n $ sf org auth \"\n \"show-access-token -o \u003cvalue>\\n\",\n stderr=\"\",\n )\n with mock.patch.object(sf_cli.subprocess, \"run\", return_value=cp):\n sf_cli.assert_show_access_token_capability()\n self.assertTrue(sf_cli._show_access_token_capability_ok)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17733,"content_sha256":"dc452751636de69a83aa399bb37e3508374b370a88428120101acf52527f3d50"},{"filename":"scripts/tests/test_signature_stamping.py","content":"\"\"\"Tests for Section-7 signature stamping.\n\nPreviously, every flow and every Apex class in the rendered architecture\nreport's Section 7 showed `_Signature not captured._` because Wave B\nfetched `SymbolTable` + `Flow.Metadata` but we never projected that data\nonto the tree nodes the renderer reads. These tests pin the three\nhelpers that close the gap:\n\n * `_derive_apex_signature` — SymbolTable → one-line method signature\n * `_derive_flow_signature` — Flow.Metadata → `in:/out:` param list\n * `_stamp_signatures` — walks the tree and writes `node[\"signature\"]`\n\nThe renderer at `render_architecture.py` reads `node.get(\"signature\")`\nalready; no renderer change was required.\n\"\"\"\nfrom __future__ import annotations\n\nimport unittest\n\nfrom . import _bootstrap # noqa: F401\n\nimport main # type: ignore\n\n\n# ---------------------------------------------------------------------------\n# _derive_apex_signature\n# ---------------------------------------------------------------------------\n\n\nclass DeriveApexSignatureTests(unittest.TestCase):\n def test_invocable_method_is_preferred(self):\n \"\"\"@InvocableMethod is what Flow / Agentforce actually call. If\n present, it MUST be picked over any other method — otherwise the\n rendered signature points at a private helper instead of the\n public entry point.\"\"\"\n apex_row = {\n \"Name\": \"OrderLookup\",\n \"SymbolTable\": {\n \"methods\": [\n {\n \"name\": \"helper\",\n \"returnType\": \"void\",\n \"modifiers\": [\"private\"],\n \"parameters\": [{\"name\": \"x\", \"type\": \"String\"}],\n \"annotations\": [],\n },\n {\n \"name\": \"run\",\n \"returnType\": \"List\u003cResult>\",\n \"modifiers\": [\"public\", \"static\"],\n \"parameters\": [\n {\"name\": \"input\", \"type\": \"List\u003cRequest>\"},\n ],\n \"annotations\": [{\"name\": \"InvocableMethod\"}],\n },\n ],\n },\n }\n sig = main._derive_apex_signature(apex_row)\n self.assertEqual(sig, \"public static List\u003cResult> run(List\u003cRequest> input)\")\n\n def test_no_invocable_falls_back_to_global(self):\n \"\"\"Without `@InvocableMethod`, a `global` method wins over a\n `private` helper.\"\"\"\n apex_row = {\n \"Name\": \"Utility\",\n \"SymbolTable\": {\n \"methods\": [\n {\n \"name\": \"privateHelper\",\n \"returnType\": \"void\",\n \"modifiers\": [\"private\"],\n \"parameters\": [],\n \"annotations\": [],\n },\n {\n \"name\": \"publicEntry\",\n \"returnType\": \"String\",\n \"modifiers\": [\"global\"],\n \"parameters\": [{\"name\": \"id\", \"type\": \"Id\"}],\n \"annotations\": [],\n },\n ],\n },\n }\n sig = main._derive_apex_signature(apex_row)\n self.assertEqual(sig, \"global String publicEntry(Id id)\")\n\n def test_empty_symbol_table_returns_none(self):\n \"\"\"`SymbolTable = null` is the common case for classes the org\n couldn't compile a symbol table for. Must not raise, must return\n None so the renderer falls back to the `_Signature not captured._`\n placeholder.\"\"\"\n self.assertIsNone(\n main._derive_apex_signature({\"Name\": \"X\", \"SymbolTable\": None})\n )\n self.assertIsNone(\n main._derive_apex_signature({\"Name\": \"X\", \"SymbolTable\": {}})\n )\n self.assertIsNone(\n main._derive_apex_signature(\n {\"Name\": \"X\", \"SymbolTable\": {\"methods\": []}}\n )\n )\n\n def test_no_candidate_methods_returns_none(self):\n \"\"\"A class whose only method is private AND not invocable has no\n sensible public signature to surface; return None rather than\n expose internal detail.\"\"\"\n apex_row = {\n \"Name\": \"PrivateOnly\",\n \"SymbolTable\": {\n \"methods\": [\n {\n \"name\": \"hidden\",\n \"returnType\": \"void\",\n \"modifiers\": [\"private\"],\n \"parameters\": [],\n \"annotations\": [],\n },\n ],\n },\n }\n self.assertIsNone(main._derive_apex_signature(apex_row))\n\n\n# ---------------------------------------------------------------------------\n# _derive_flow_signature\n# ---------------------------------------------------------------------------\n\n\nclass DeriveFlowSignatureTests(unittest.TestCase):\n def test_input_and_output_both_present(self):\n \"\"\"Both sides rendered with ` | ` separator.\n\n Flow.Metadata exposes a flat `variables[]` list; each item carries\n boolean `isInput` / `isOutput` flags. We partition the list by\n those flags rather than reading the non-existent\n `inputParameters` / `outputParameters` fields.\n \"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\"name\": \"caseId\", \"dataType\": \"String\",\n \"isInput\": True, \"isOutput\": False},\n {\"name\": \"priority\", \"dataType\": \"String\",\n \"isInput\": True, \"isOutput\": False},\n {\"name\": \"result\", \"dataType\": \"String\",\n \"isInput\": False, \"isOutput\": True},\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(\n sig,\n \"in: caseId: String, priority: String | out: result: String\",\n )\n\n def test_only_input_side(self):\n \"\"\"outputs empty → only `in:` side rendered, no trailing ` | `.\"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\"name\": \"caseId\", \"dataType\": \"String\",\n \"isInput\": True, \"isOutput\": False},\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(sig, \"in: caseId: String\")\n\n def test_no_parameters_returns_none(self):\n \"\"\"No variables at all → None (render falls back to placeholder).\"\"\"\n record = {\"Metadata\": {\"variables\": []}}\n self.assertIsNone(main._derive_flow_signature(record))\n self.assertIsNone(main._derive_flow_signature({\"Metadata\": {}}))\n self.assertIsNone(main._derive_flow_signature({}))\n\n def test_collection_type_rendered_as_list(self):\n \"\"\"`isCollection: True` on a variable → `List\u003cDataType>` (and for\n sObject variables, the `objectType` feeds the inner type rather\n than the literal `SObject`; case-insensitive match on\n `dataType`).\"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\n \"name\": \"ids\",\n \"dataType\": \"String\",\n \"isCollection\": True,\n \"isInput\": True,\n \"isOutput\": False,\n },\n {\n \"name\": \"cases\",\n \"dataType\": \"sObject\",\n \"objectType\": \"Case\",\n \"isCollection\": True,\n \"isInput\": True,\n \"isOutput\": False,\n },\n {\n \"name\": \"result\",\n \"dataType\": \"SObject\", # upper-case variant\n \"objectType\": \"Account\",\n \"isInput\": False,\n \"isOutput\": True,\n },\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(\n sig,\n \"in: ids: List\u003cString>, cases: List\u003cCase> | out: result: Account\",\n )\n\n def test_round_trip_variable_appears_on_both_sides(self):\n \"\"\"A variable with `isInput: True` AND `isOutput: True` is a\n round-trip — flows use this for parameters the caller passes in\n and the flow mutates back out. It must appear on BOTH sides of\n the rendered signature.\"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\"name\": \"payload\", \"dataType\": \"String\",\n \"isInput\": True, \"isOutput\": True},\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(\n sig,\n \"in: payload: String | out: payload: String\",\n )\n\n def test_derive_flow_signature_local_variables_ignored(self):\n \"\"\"A variable with `isInput: False` AND `isOutput: False` is a\n flow-local — it exists only inside the flow's execution and is\n not part of its public surface. It must not appear in either\n side of the signature.\"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\"name\": \"scratch\", \"dataType\": \"String\",\n \"isInput\": False, \"isOutput\": False},\n {\"name\": \"caseId\", \"dataType\": \"String\",\n \"isInput\": True, \"isOutput\": False},\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(sig, \"in: caseId: String\")\n\n def test_derive_flow_signature_handlefaultfault_shape(self):\n \"\"\"Real-org fixture modeled on `handleFlowFault` from the\n my-org-alias org: 8 variables → 5 inputs, 3 outputs. Pins the\n exact expected signature string end-to-end so that any future\n regression in partitioning, rendering, or ordering surfaces\n immediately.\"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\"name\": \"alwaysLogError\", \"dataType\": \"Boolean\",\n \"isCollection\": False, \"isInput\": True,\n \"isOutput\": False, \"objectType\": None},\n {\"name\": \"errorCode\", \"dataType\": \"String\",\n \"isCollection\": False, \"isInput\": False,\n \"isOutput\": True, \"objectType\": None},\n {\"name\": \"errorMessage\", \"dataType\": \"String\",\n \"isCollection\": False, \"isInput\": False,\n \"isOutput\": True, \"objectType\": None},\n {\"name\": \"flowFaultMessage\", \"dataType\": \"String\",\n \"isCollection\": False, \"isInput\": True,\n \"isOutput\": False, \"objectType\": None},\n {\"name\": \"flowName\", \"dataType\": \"String\",\n \"isCollection\": False, \"isInput\": True,\n \"isOutput\": False, \"objectType\": None},\n {\"name\": \"flowObject\", \"dataType\": \"String\",\n \"isCollection\": False, \"isInput\": True,\n \"isOutput\": False, \"objectType\": None},\n {\"name\": \"flowSection\", \"dataType\": \"String\",\n \"isCollection\": False, \"isInput\": True,\n \"isOutput\": False, \"objectType\": None},\n {\"name\": \"isFatal\", \"dataType\": \"Boolean\",\n \"isCollection\": False, \"isInput\": False,\n \"isOutput\": True, \"objectType\": None},\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(\n sig,\n \"in: alwaysLogError: Boolean, flowFaultMessage: String, \"\n \"flowName: String, flowObject: String, flowSection: String | \"\n \"out: errorCode: String, errorMessage: String, isFatal: Boolean\",\n )\n\n def test_derive_flow_signature_apex_type_uses_apexclass(self):\n \"\"\"Apex-typed variables (e.g. Flow invocable action return rows)\n carry the concrete class name in `apexClass`, not `dataType`\n (which is the generic literal `Apex`). The signature MUST render\n the concrete class so authors can trace the type back to real\n code — `List\u003cApex>` is useless.\"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\n \"name\": \"OrgList\",\n \"dataType\": \"Apex\",\n \"apexClass\": \"CX_OrgSelector.Org\",\n \"isCollection\": True,\n \"isInput\": False,\n \"isOutput\": True,\n },\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(sig, \"out: OrgList: List\u003cCX_OrgSelector.Org>\")\n\n def test_derive_flow_signature_apex_type_without_apexclass_falls_through(self):\n \"\"\"Defensive fallback: if `apexClass` is missing/blank on an\n Apex-typed variable we render the literal `Apex` so the surface\n still reports *something* — losing the signature entirely would\n regress today's behavior.\"\"\"\n record = {\n \"Metadata\": {\n \"variables\": [\n {\n \"name\": \"rows\",\n \"dataType\": \"Apex\",\n \"apexClass\": None,\n \"isCollection\": True,\n \"isInput\": False,\n \"isOutput\": True,\n },\n ],\n },\n }\n sig = main._derive_flow_signature(record)\n self.assertEqual(sig, \"out: rows: List\u003cApex>\")\n\n def test_bare_metadata_dict_accepted(self):\n \"\"\"Test convenience: callers can pass the `Metadata` dict\n directly (what they'd get from `record[\"Metadata\"]`) without\n wrapping it in the outer Tooling row. Exercised by several\n sibling tests; pin the contract explicitly.\"\"\"\n bare = {\n \"variables\": [\n {\"name\": \"caseId\", \"dataType\": \"String\",\n \"isInput\": True, \"isOutput\": False},\n ],\n }\n sig = main._derive_flow_signature(bare)\n self.assertEqual(sig, \"in: caseId: String\")\n\n\n# ---------------------------------------------------------------------------\n# _stamp_signatures — walks tree, writes node[\"signature\"]\n# ---------------------------------------------------------------------------\n\n\nclass StampSignaturesTests(unittest.TestCase):\n def test_stamp_applies_to_tree(self):\n \"\"\"APEX + FLOW nodes whose api_name matches the sigs dicts get\n `node[\"signature\"]` populated; unmatched nodes are left alone.\"\"\"\n tree_root = {\n \"kind\": \"AGENT\",\n \"api_name\": \"MyAgent\",\n \"children\": [\n {\n \"kind\": \"APEX\",\n \"api_name\": \"OrderLookup\",\n \"children\": [],\n },\n {\n \"kind\": \"APEX\",\n \"api_name\": \"UnmatchedApex\",\n \"children\": [],\n },\n {\n \"kind\": \"FLOW\",\n \"api_name\": \"LookupOrder\",\n \"children\": [\n {\n \"kind\": \"APEX\",\n \"api_name\": \"OrderLookup\",\n \"children\": [],\n },\n ],\n },\n ],\n }\n apex_sigs = {\n \"OrderLookup\": \"public static List\u003cResult> run(List\u003cRequest> input)\",\n }\n flow_sigs = {\n \"LookupOrder\": \"in: caseId: String | out: result: String\",\n }\n\n main._stamp_signatures(tree_root, apex_sigs, flow_sigs)\n\n self.assertEqual(\n tree_root[\"children\"][0][\"signature\"],\n \"public static List\u003cResult> run(List\u003cRequest> input)\",\n )\n # Unmatched Apex stays bare.\n self.assertNotIn(\"signature\", tree_root[\"children\"][1])\n # Flow node gets its signature.\n self.assertEqual(\n tree_root[\"children\"][2][\"signature\"],\n \"in: caseId: String | out: result: String\",\n )\n # Apex nested under the flow also stamped.\n self.assertEqual(\n tree_root[\"children\"][2][\"children\"][0][\"signature\"],\n \"public static List\u003cResult> run(List\u003cRequest> input)\",\n )\n # Root itself (neither APEX nor FLOW) is untouched.\n self.assertNotIn(\"signature\", tree_root)\n\n def test_stamp_preserves_shared_utility_across_branches(self):\n \"\"\"Shared utility flows (e.g. handleFlowFault referenced from N\n places) must get the SAME signature stamped on every occurrence —\n they reference the same FlowDefinition / ApexClass by name.\"\"\"\n tree_root = {\n \"kind\": \"AGENT\",\n \"api_name\": \"Agent\",\n \"children\": [\n {\n \"kind\": \"FLOW\",\n \"api_name\": \"BranchA\",\n \"children\": [\n {\n \"kind\": \"FLOW\",\n \"api_name\": \"handleFlowFault\",\n \"children\": [],\n },\n ],\n },\n {\n \"kind\": \"FLOW\",\n \"api_name\": \"BranchB\",\n \"children\": [\n {\n \"kind\": \"FLOW\",\n \"api_name\": \"handleFlowFault\",\n \"children\": [],\n },\n ],\n },\n ],\n }\n flow_sigs = {\"handleFlowFault\": \"in: fault: String\"}\n main._stamp_signatures(tree_root, {}, flow_sigs)\n\n stamp_a = tree_root[\"children\"][0][\"children\"][0][\"signature\"]\n stamp_b = tree_root[\"children\"][1][\"children\"][0][\"signature\"]\n self.assertEqual(stamp_a, \"in: fault: String\")\n self.assertEqual(stamp_b, \"in: fault: String\")\n self.assertEqual(stamp_a, stamp_b)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":18469,"content_sha256":"864752b6a3072e5d52f69f9e0650feaac9aec94dafa5ec96b10e353ea87bcdd5"},{"filename":"scripts/tests/test_skill_md_pipeline_block.py","content":"\"\"\"Run SKILL.md's \"## Pipeline invocation\" bash block under bash and zsh.\n\nThe block is the contract the agent executes verbatim, so these tests extract\nit from SKILL.md (no copy to drift) and run it under ``env -i`` against:\n\n* a stub SKILL_ROOT whose scripts/main.py records its argv, emit_result.py\n prints a RESULT marker, and _shared/fs_guard.py always passes;\n* a stub ``sf`` on PATH that answers ``config get target-org --json`` with a\n canned payload (or fails).\n\nScenarios: ARGUMENTS unset with ARG_AGENT preset (default org used), flags in\nARGUMENTS, flags overriding preset ARG_*, nothing resolvable (usage, no\npipeline run), and SKILL_ROOT discovery when the scripts are staged under\n``\u003ccandidate>/artifacts/`` (the ADK eval staging layout).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport shutil\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nimport unittest\nfrom pathlib import Path\n\n_SKILL_NAME = \"agentforce-architecture-analyze\"\n\n\ndef _find_skill_md() -> Path:\n # contributed-skills: \u003cskill>/scripts/tests/ -> \u003cskill>/SKILL.md\n # outputs/unified-sors: \u003cskill>/artifacts/scripts/tests/ -> \u003cskill>/SKILL.md\n for parent in Path(__file__).resolve().parents:\n candidate = parent / \"SKILL.md\"\n if candidate.is_file():\n return candidate\n raise FileNotFoundError(\"SKILL.md not found above \" + __file__)\n\n\ndef _extract_pipeline_block(skill_md: str) -> str:\n section = skill_md.split(\"## Pipeline invocation\", 1)[1]\n match = re.search(r\"```bash\\n(.*?)\\n```\", section, re.S)\n if match is None:\n raise ValueError(\"no ```bash block under '## Pipeline invocation'\")\n return match.group(1) + \"\\n\"\n\n\ndef _write_exec(path: Path, text: str) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(text)\n path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)\n\n\n_RECORDER = \"\"\"\\\nimport json, os, sys\nwith open({record!r}, \"a\") as fh:\n fh.write(json.dumps({{\"script\": {name!r}, \"path\": os.path.realpath(__file__),\n \"argv\": sys.argv[1:]}}) + \"\\\\n\")\n{tail}\n\"\"\"\n\n_SF_STUB = \"\"\"#!/bin/sh\n# Canned `sf config get target-org --json`; behaviour chosen by SF_STUB_MODE.\nif [ \"$1 $2 $3\" != \"config get target-org\" ]; then\n echo \"unexpected sf call: $*\" >&2; exit 3\nfi\ncase \"${SF_STUB_MODE:-set}\" in\n set) cat \u003c\u003c'JSON'\n{\n \"status\": 0,\n \"result\": [\n {\n \"name\": \"target-org\",\n \"location\": \"Local\",\n \"value\": \"default-org\",\n \"success\": true\n }\n ],\n \"warnings\": []\n}\nJSON\n ;;\n unset) printf '%s\\\\n' '{\"status\":0,\"result\":[{\"name\":\"target-org\",\"success\":true}],\"warnings\":[]}' ;;\n fail) echo '{\"status\":1,\"name\":\"Error\"}'; exit 1 ;;\nesac\n\"\"\"\n\n\nclass PipelineBlockTest(unittest.TestCase):\n SHELLS = [s for s in (\"bash\", \"zsh\") if shutil.which(s)]\n\n @classmethod\n def setUpClass(cls) -> None:\n cls.block = _extract_pipeline_block(_find_skill_md().read_text())\n\n def setUp(self) -> None:\n self.tmp = Path(tempfile.mkdtemp(prefix=\"arch-block-test-\"))\n self.addCleanup(shutil.rmtree, self.tmp, True)\n self.record = self.tmp / \"calls.jsonl\"\n self.root = self.tmp / \"skill\"\n self._make_stub_scripts(self.root)\n self.bin = self.tmp / \"bin\"\n _write_exec(self.bin / \"sf\", _SF_STUB)\n (self.bin / \"python3\").symlink_to(sys.executable)\n self.script = self.tmp / \"block.sh\"\n self.script.write_text(self.block)\n self.home = self.tmp / \"home\"\n self.home.mkdir()\n\n # -- helpers ---------------------------------------------------------\n\n def _make_stub_scripts(self, root: Path) -> Path:\n \"\"\"Recorder stubs for main.py / emit_result.py / _shared/fs_guard.py\n under ``root/scripts``; returns that scripts dir.\"\"\"\n scripts = root / \"scripts\"\n (scripts / \"_shared\").mkdir(parents=True)\n (scripts / \"main.py\").write_text(\n _RECORDER.format(record=str(self.record), name=\"main\", tail=\"\")\n )\n (scripts / \"emit_result.py\").write_text(\n _RECORDER.format(\n record=str(self.record), name=\"emit\",\n tail='print(\"=== RESULT ===\")',\n )\n )\n (scripts / \"_shared\" / \"fs_guard.py\").write_text(\n _RECORDER.format(record=str(self.record), name=\"fs_guard\", tail=\"\")\n )\n return scripts\n\n def _run(self, shell: str, extra_env: dict[str, str], with_sf: bool = True,\n skill_root: str | None = None):\n if self.record.exists():\n self.record.unlink()\n bin_dir = self.bin\n if not with_sf:\n bin_dir = self.tmp / \"bin-nosf\"\n bin_dir.mkdir(exist_ok=True)\n if not (bin_dir / \"python3\").exists():\n (bin_dir / \"python3\").symlink_to(sys.executable)\n env = {\n \"PATH\": f\"{bin_dir}:/usr/bin:/bin\",\n \"HOME\": str(self.home),\n **extra_env,\n }\n if skill_root is None:\n env[\"SKILL_ROOT\"] = str(self.root)\n elif skill_root:\n env[\"SKILL_ROOT\"] = skill_root\n # skill_root=\"\" → SKILL_ROOT unset, so the install-root probe runs.\n cmd = [\"env\", \"-i\", *(f\"{k}={v}\" for k, v in env.items()),\n shell, str(self.script)]\n proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)\n calls = []\n if self.record.exists():\n calls = [json.loads(l) for l in self.record.read_text().splitlines()]\n for call in calls:\n if call[\"script\"] == \"main\" and \"--work-dir\" in call[\"argv\"]:\n wd = call[\"argv\"][call[\"argv\"].index(\"--work-dir\") + 1]\n shutil.rmtree(wd, ignore_errors=True)\n return proc, calls\n\n def _main_argv(self, calls):\n mains = [c[\"argv\"] for c in calls if c[\"script\"] == \"main\"]\n self.assertEqual(len(mains), 1, calls)\n return mains[0]\n\n def _opt(self, argv, flag):\n self.assertIn(flag, argv)\n return argv[argv.index(flag) + 1]\n\n # -- scenarios -------------------------------------------------------\n\n def test_shells_available(self) -> None:\n self.assertIn(\"bash\", self.SHELLS)\n\n def test_a_arguments_unset_preset_agent_uses_default_org(self) -> None:\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(shell, {\"ARG_AGENT\": \"Support_Agent\"})\n self.assertEqual(proc.returncode, 0, proc.stderr)\n argv = self._main_argv(calls)\n self.assertEqual(self._opt(argv, \"--org-alias\"), \"default-org\")\n self.assertEqual(self._opt(argv, \"--agent\"), \"Support_Agent\")\n self.assertTrue(proc.stdout.rstrip().endswith(\"=== RESULT ===\"))\n\n def test_b_flags_in_arguments(self) -> None:\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(\n shell, {\"ARGUMENTS\": \"--org o1 --agent a1 --version=v3 --force\"}\n )\n self.assertEqual(proc.returncode, 0, proc.stderr)\n argv = self._main_argv(calls)\n self.assertEqual(self._opt(argv, \"--org-alias\"), \"o1\")\n self.assertEqual(self._opt(argv, \"--agent\"), \"a1\")\n self.assertEqual(self._opt(argv, \"--version\"), \"v3\")\n self.assertIn(\"--force\", argv)\n\n def test_c_flags_override_preset_env(self) -> None:\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(shell, {\n \"ARGUMENTS\": \"document * the agent --org=o2 --agent a2\",\n \"ARG_ORG\": \"env-org\",\n \"ARG_AGENT\": \"EnvAgent\",\n })\n self.assertEqual(proc.returncode, 0, proc.stderr)\n argv = self._main_argv(calls)\n self.assertEqual(self._opt(argv, \"--org-alias\"), \"o2\")\n self.assertEqual(self._opt(argv, \"--agent\"), \"a2\")\n\n def test_preset_org_skips_default_lookup(self) -> None:\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(shell, {\n \"ARG_ORG\": \"env-org\", \"ARG_AGENT\": \"EnvAgent\",\n \"SF_STUB_MODE\": \"fail\",\n })\n self.assertEqual(proc.returncode, 0, proc.stderr)\n argv = self._main_argv(calls)\n self.assertEqual(self._opt(argv, \"--org-alias\"), \"env-org\")\n\n def test_d_nothing_resolvable_prints_usage(self) -> None:\n cases = [\n (\"unset\", {\"SF_STUB_MODE\": \"unset\"}, True),\n (\"sf-fails\", {\"SF_STUB_MODE\": \"fail\"}, True),\n (\"no-sf\", {}, False),\n (\"agent-only-no-default\", {\"SF_STUB_MODE\": \"unset\", \"ARG_AGENT\": \"A\"}, True),\n ]\n for shell in self.SHELLS:\n for label, env, with_sf in cases:\n with self.subTest(shell=shell, case=label):\n proc, calls = self._run(shell, env, with_sf=with_sf)\n self.assertNotEqual(proc.returncode, 0)\n self.assertIn(\"Which agent should I document\", proc.stderr)\n self.assertEqual(\n [c for c in calls if c[\"script\"] == \"main\"], []\n )\n\n # -- SKILL_ROOT discovery: ADK eval artifacts/ staging layout ----------\n\n def _assert_all_from(self, calls, scripts_dir: Path) -> None:\n \"\"\"main, emit_result and fs_guard all ran from ``scripts_dir``.\"\"\"\n real = scripts_dir.resolve()\n self.assertEqual({c[\"script\"] for c in calls}, {\"main\", \"emit\", \"fs_guard\"})\n for call in calls:\n self.assertTrue(\n Path(call[\"path\"]).is_relative_to(real), (call[\"path\"], real)\n )\n\n def test_e_scripts_only_under_artifacts_of_exported_root(self) -> None:\n staged = self.tmp / \"staged\" / _SKILL_NAME\n scripts = self._make_stub_scripts(staged / \"artifacts\")\n (staged / \"SKILL.md\").write_text(\"# staged\\n\")\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(\n shell, {\"ARG_AGENT\": \"A\", \"ARG_ORG\": \"o\"},\n skill_root=str(staged),\n )\n self.assertEqual(proc.returncode, 0, proc.stderr)\n self._assert_all_from(calls, scripts)\n self._main_argv(calls)\n\n def test_f_artifacts_found_via_install_root_probe(self) -> None:\n # SKILL_ROOT unset: the candidate comes from the $HOME/.claude/skills probe.\n staged = self.home / \".claude\" / \"skills\" / _SKILL_NAME\n scripts = self._make_stub_scripts(staged / \"artifacts\")\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(\n shell, {\"ARG_AGENT\": \"A\", \"ARG_ORG\": \"o\"}, skill_root=\"\",\n )\n self.assertEqual(proc.returncode, 0, proc.stderr)\n self._assert_all_from(calls, scripts)\n\n def test_g_candidate_root_preferred_over_its_artifacts(self) -> None:\n root_scripts = self.root / \"scripts\"\n self._make_stub_scripts(self.root / \"artifacts\")\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(shell, {\"ARG_AGENT\": \"A\", \"ARG_ORG\": \"o\"})\n self.assertEqual(proc.returncode, 0, proc.stderr)\n self._assert_all_from(calls, root_scripts)\n\n def test_h_earlier_candidate_artifacts_beats_later_candidate_root(self) -> None:\n # Candidate order is unchanged: exported SKILL_ROOT (artifacts layout)\n # wins over a later install root that has scripts/ at its top level.\n staged = self.tmp / \"staged\" / _SKILL_NAME\n scripts = self._make_stub_scripts(staged / \"artifacts\")\n self._make_stub_scripts(self.home / \".claude\" / \"skills\" / _SKILL_NAME)\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(\n shell, {\"ARG_AGENT\": \"A\", \"ARG_ORG\": \"o\"},\n skill_root=str(staged),\n )\n self.assertEqual(proc.returncode, 0, proc.stderr)\n self._assert_all_from(calls, scripts)\n\n def test_i_no_scripts_anywhere_reports_guard_message(self) -> None:\n empty = self.tmp / \"empty\" / _SKILL_NAME\n (empty / \"artifacts\").mkdir(parents=True)\n for shell in self.SHELLS:\n with self.subTest(shell=shell):\n proc, calls = self._run(\n shell, {\"ARG_AGENT\": \"A\", \"ARG_ORG\": \"o\"},\n skill_root=str(empty),\n )\n self.assertEqual(proc.returncode, 1)\n self.assertIn(f\"{_SKILL_NAME}: scripts not found\", proc.stderr)\n self.assertEqual(calls, [])\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13046,"content_sha256":"ae35040970c0782dcff5ebcbe011de78b2b9406f0cf8e37ead1ecb593b0936c4"},{"filename":"scripts/tests/test_soql_loader.py","content":"\"\"\"Tests for load_soql revalidates every substituted string.\"\"\"\nfrom __future__ import annotations\n\nimport tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\n\nclass LoadSoqlValidationTests(unittest.TestCase):\n \"\"\"every param must be revalidated at the substitution boundary.\"\"\"\n\n def setUp(self) -> None:\n # Use a throwaway SOQL_DIR populated per test so we don't depend on\n # the shipped assets having a specific template name.\n self._tmpdir = tempfile.TemporaryDirectory()\n self.soql_dir = Path(self._tmpdir.name)\n # Patch config.SOQL_DIR BEFORE importing soql_loader so the module\n # constant reflects the tmpdir. Because soql_loader binds SOQL_DIR\n # at import, we re-import it fresh under the patch.\n self._patch = mock.patch(\"config.SOQL_DIR\", self.soql_dir)\n self._patch.start()\n # Force a fresh import so load_soql reads the patched SOQL_DIR.\n import importlib\n import soql_loader # type: ignore\n importlib.reload(soql_loader)\n self.soql_loader = soql_loader\n\n def tearDown(self) -> None:\n self._patch.stop()\n self._tmpdir.cleanup()\n\n def _write_template(self, name: str, body: str) -> None:\n (self.soql_dir / f\"{name}.soql\").write_text(body)\n\n # ---- happy path -------------------------------------------------------\n\n def test_valid_param_substitutes(self):\n self._write_template(\n \"bot_lookup\",\n \"SELECT Id FROM BotDefinition WHERE DeveloperName = '{{NAME}}'\",\n )\n out = self.soql_loader.load_soql(\"bot_lookup\", NAME=\"MyAgent\")\n self.assertEqual(\n out,\n \"SELECT Id FROM BotDefinition WHERE DeveloperName = 'MyAgent'\",\n )\n\n def test_multiple_valid_params(self):\n self._write_template(\n \"lookup\",\n \"SELECT Id FROM Obj WHERE A='{{A}}' AND B='{{B}}'\",\n )\n out = self.soql_loader.load_soql(\"lookup\", A=\"Foo\", B=\"Bar_v2\")\n self.assertIn(\"A='Foo'\", out)\n self.assertIn(\"B='Bar_v2'\", out)\n\n # ---- injection attempts must raise ------------------------------------\n\n def test_injection_quote_or_clause_raises(self):\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name='{{NAME}}'\")\n with self.assertRaises(self.soql_loader.SoqlParamError) as ctx:\n self.soql_loader.load_soql(\"q\", NAME=\"x' OR Id!=null--\")\n self.assertEqual(ctx.exception.key, \"NAME\")\n\n def test_injection_drop_table_raises(self):\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name='{{NAME}}'\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", NAME=\"'; DROP TABLE x;--\")\n\n def test_injection_or_1eq1_raises(self):\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name='{{NAME}}'\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", NAME=\"x OR 1=1\")\n\n def test_whitespace_rejected(self):\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name='{{NAME}}'\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", NAME=\"x OR y\") # space\n\n def test_dash_rejected(self):\n # Common SOQL-injection payload prefix.\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name='{{NAME}}'\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", NAME=\"x-y\")\n\n # ---- type errors ------------------------------------------------------\n\n def test_non_string_value_raises(self):\n self._write_template(\"q\", \"SELECT {{N}}\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", N=42)\n\n def test_none_value_raises(self):\n self._write_template(\"q\", \"SELECT {{N}}\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", N=None)\n\n def test_empty_string_raises(self):\n self._write_template(\"q\", \"SELECT {{N}}\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", N=\"\")\n\n # ---- single-pass substitution guarantee -------------------------------\n\n def test_value_containing_other_placeholder_does_not_retrigger(self):\n \"\"\"A valid value that contains `{{OTHER}}` must NOT trigger a\n second substitution pass on OTHER. str.replace is single-pass\n by contract; we assert that the validator's regex prevents any\n value from containing `{`, `}`, or whitespace in the first place\n — so the injection path is closed at validation, not at\n substitution.\n \"\"\"\n self._write_template(\"q\", \"SELECT {{A}} AND {{B}}\")\n # Reject values that carry placeholder syntax — the regex forbids\n # `{` and `}` (they're not in [A-Za-z0-9_]).\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"q\", A=\"{{B}}\", B=\"safe\")\n\n def test_raw_replace_is_single_pass(self):\n \"\"\"Belt-and-braces: even if a value slipped through (it cannot,\n per the regex), Python's str.replace does not recursively scan\n the output. Simulate by bypassing the validator and confirming\n str.replace behavior directly.\n \"\"\"\n template = \"SELECT {{A}} AND {{B}}\"\n # Substitute A first with a value that \"looks like\" the B\n # placeholder. str.replace for B should then replace the\n # template's own `{{B}}` — but A's embedded `{{B}}` should stay\n # (Python replaces left-to-right in a single pass; the output\n # of the A replacement is the NEW string and B's pass runs on\n # that new string). The assertion below therefore confirms\n # that B's replacement hits BOTH occurrences (the original and\n # the one inside A's substituted value) — demonstrating that\n # str.replace is NOT recursive on the ORIGINAL template alone,\n # but IS a single linear scan of the full post-A string.\n step1 = template.replace(\"{{A}}\", \"{{B}}\")\n step2 = step1.replace(\"{{B}}\", \"replaced\")\n # Both occurrences of `{{B}}` in step1 are replaced in a single\n # left-to-right pass. This is the property tested for.\n self.assertEqual(step2, \"SELECT replaced AND replaced\")\n # The safety net: if a caller EVER skipped revalidation and\n # allowed a `{{X}}` to reach substitution, the above behavior\n # could matter — which is PRECISELY why revalidates at\n # the boundary. The regex denies `{`, `}`, whitespace, making\n # this scenario unreachable in production.\n\n\nclass LoadSoqlNameValidationTests(unittest.TestCase):\n \"\"\"the `name` argument is validated before any filesystem access.\n\n Without this, a caller that sources `name` from data (config file, user\n argument, discovered string) could read arbitrary files via traversal\n (`../../../etc/passwd`). The regex gate closes that before `read_text()`.\n \"\"\"\n\n def setUp(self) -> None:\n self._tmpdir = tempfile.TemporaryDirectory()\n self.soql_dir = Path(self._tmpdir.name)\n self._patch = mock.patch(\"config.SOQL_DIR\", self.soql_dir)\n self._patch.start()\n import importlib\n import soql_loader # type: ignore\n importlib.reload(soql_loader)\n self.soql_loader = soql_loader\n\n def tearDown(self) -> None:\n self._patch.stop()\n self._tmpdir.cleanup()\n\n # ---- traversal attempts must raise before any file read ---------------\n\n def test_parent_traversal_raises(self):\n \"\"\"`../../../etc/passwd` must be caught by validation, not by the\n filesystem. The error must be SoqlParamError (clearly labeled) —\n NOT a bare FileNotFoundError or a ValidationError with raw path.\n \"\"\"\n with self.assertRaises(self.soql_loader.SoqlParamError) as ctx:\n self.soql_loader.load_soql(\"../../../etc/passwd\")\n self.assertEqual(ctx.exception.key, \"soql_template_name\")\n # The absolute SOQL_DIR must NOT appear in the surfaced message.\n self.assertNotIn(str(self.soql_dir), str(ctx.exception))\n\n def test_dotdot_alone_raises(self):\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"..\")\n\n def test_slash_in_name_raises(self):\n \"\"\"`/` is not in [A-Za-z0-9_], so `plugins/by_planner` must be\n caught at validation — never reach the filesystem.\"\"\"\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"plugins/by_planner\")\n\n def test_backslash_in_name_raises(self):\n \"\"\"Windows-style separators — belt and braces.\"\"\"\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"plugins\\\\by_planner\")\n\n def test_absolute_path_raises(self):\n \"\"\"A caller passing an absolute path (a different traversal variant)\n must still hit validation, not a false-negative file read.\"\"\"\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"/etc/passwd\")\n\n def test_empty_name_raises(self):\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"\")\n\n def test_none_name_raises(self):\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(None) # type: ignore[arg-type]\n\n def test_whitespace_in_name_raises(self):\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"bot lookup\")\n\n def test_traversal_never_reads_filesystem(self):\n \"\"\"Validation runs before any I/O — verify read_text is never\n called when the name is invalid. If the order ever flips, a bad\n name could still leak a FileNotFoundError with raw path info.\n \"\"\"\n with mock.patch.object(Path, \"read_text\") as mock_read:\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql(\"../../../evil\")\n mock_read.assert_not_called()\n\n\nclass LoadSoqlTemplateNotFoundTests(unittest.TestCase):\n \"\"\"FileNotFoundError is translated into SoqlTemplateNotFound\n whose message is free of filesystem-path leakage.\n \"\"\"\n\n def setUp(self) -> None:\n self._tmpdir = tempfile.TemporaryDirectory()\n self.soql_dir = Path(self._tmpdir.name)\n self._patch = mock.patch(\"config.SOQL_DIR\", self.soql_dir)\n self._patch.start()\n import importlib\n import soql_loader # type: ignore\n importlib.reload(soql_loader)\n self.soql_loader = soql_loader\n\n def tearDown(self) -> None:\n self._patch.stop()\n self._tmpdir.cleanup()\n\n def test_missing_template_raises_custom_exception(self):\n with self.assertRaises(self.soql_loader.SoqlTemplateNotFound) as ctx:\n self.soql_loader.load_soql(\"nonexistent_template\")\n self.assertEqual(ctx.exception.name, \"nonexistent_template\")\n\n def test_missing_template_message_excludes_soql_dir(self):\n \"\"\"The SOQL_DIR absolute path must NOT appear in the surfaced error\n string — information-disclosure hygiene. Attackers don't need to know\n where the skill install lives on disk.\n \"\"\"\n with self.assertRaises(self.soql_loader.SoqlTemplateNotFound) as ctx:\n self.soql_loader.load_soql(\"nonexistent_template\")\n msg = str(ctx.exception)\n self.assertNotIn(str(self.soql_dir), msg)\n self.assertNotIn(\".soql\", msg)\n # Template name IS allowed in the message — that's the triage signal.\n self.assertIn(\"nonexistent_template\", msg)\n\n def test_missing_template_does_not_leak_via_cause_chain(self):\n \"\"\"`raise ... from None` is load-bearing: without it, the\n FileNotFoundError (with its raw `filename` attribute) would be\n reachable via `exception.__cause__`. Verify the chain is severed.\n \"\"\"\n try:\n self.soql_loader.load_soql(\"nonexistent_template\")\n except self.soql_loader.SoqlTemplateNotFound as e:\n # `from None` sets __cause__ = None AND __suppress_context__ = True.\n # Either alone suppresses traceback rendering of the underlying\n # FileNotFoundError.\n self.assertIsNone(e.__cause__)\n self.assertTrue(e.__suppress_context__)\n else:\n self.fail(\"expected SoqlTemplateNotFound\")\n\n def test_not_found_exception_is_distinct_from_file_not_found(self):\n \"\"\"Callers should be able to tell 'template missing' apart from\n 'permission denied / I/O error' at the except-clause layer.\n \"\"\"\n self.assertFalse(\n issubclass(\n self.soql_loader.SoqlTemplateNotFound,\n FileNotFoundError,\n ),\n \"SoqlTemplateNotFound must not subclass FileNotFoundError\",\n )\n\n def test_valid_name_with_params_unchanged(self):\n \"\"\"Regression: must not break the happy path.\"\"\"\n (self.soql_dir / \"lookup.soql\").write_text(\n \"SELECT Id FROM X WHERE Name = '{{NAME}}'\"\n )\n out = self.soql_loader.load_soql(\"lookup\", NAME=\"MyAgent\")\n self.assertEqual(\n out,\n \"SELECT Id FROM X WHERE Name = 'MyAgent'\",\n )\n\n\nclass LoadSoqlInListParamTests(unittest.TestCase):\n \"\"\"`load_soql_in` renders `WHERE X IN (...)` list placeholders.\n\n Same validation surface as `load_soql` — every list element passes\n through `fs_guard.validate_api_name`. Empty lists fail fast (SOQL\n `WHERE X IN ()` is invalid). Dedup + sort are load-bearing for\n stable cache keys.\n \"\"\"\n\n def setUp(self) -> None:\n self._tmpdir = tempfile.TemporaryDirectory()\n self.soql_dir = Path(self._tmpdir.name)\n self._patch = mock.patch(\"config.SOQL_DIR\", self.soql_dir)\n self._patch.start()\n import importlib\n import soql_loader # type: ignore\n importlib.reload(soql_loader)\n self.soql_loader = soql_loader\n\n def tearDown(self) -> None:\n self._patch.stop()\n self._tmpdir.cleanup()\n\n def _write_template(self, name: str, body: str) -> None:\n (self.soql_dir / f\"{name}.soql\").write_text(body)\n\n # ---- happy path ------------------------------------------------------\n\n def test_list_params_render_single_quoted_comma_joined(self):\n self._write_template(\n \"apex_by_names\",\n \"SELECT Id FROM ApexClass WHERE Name IN ({{NAMES_LIST}})\",\n )\n out = self.soql_loader.load_soql_in(\n \"apex_by_names\",\n list_params={\"NAMES_LIST\": [\"ClassA\", \"ClassB\"]},\n )\n self.assertIn(\"WHERE Name IN ('ClassA','ClassB')\", out)\n\n def test_mixed_string_and_list_params(self):\n self._write_template(\n \"functions_q\",\n \"SELECT Id FROM GenAiFunctionDefinition \"\n \"WHERE PlannerId = '{{PLANNER_ID}}' OR PluginId IN ({{PLUGIN_IDS}})\",\n )\n out = self.soql_loader.load_soql_in(\n \"functions_q\",\n string_params={\"PLANNER_ID\": \"X\"},\n list_params={\"PLUGIN_IDS\": [\"P1\", \"P2\"]},\n )\n self.assertIn(\"PlannerId = 'X'\", out)\n self.assertIn(\"PluginId IN ('P1','P2')\", out)\n\n def test_string_params_optional(self):\n \"\"\"string_params defaults to None — list_params alone should work.\"\"\"\n self._write_template(\n \"flow_by_names\",\n \"SELECT Id FROM FlowDefinition WHERE DeveloperName IN ({{NAMES_LIST}})\",\n )\n out = self.soql_loader.load_soql_in(\n \"flow_by_names\",\n list_params={\"NAMES_LIST\": [\"Flow_A\"]},\n )\n self.assertIn(\"IN ('Flow_A')\", out)\n\n # ---- validation: list elements must match api_name regex ---------------\n\n def test_injection_in_list_element_raises_with_list_key(self):\n \"\"\"A SOQL injection attempt inside a list element must raise\n SoqlParamError whose `key` is the LIST key (not the element\n index or a synthetic name) — so the caller can log / mark\n `_unresolved[NAMES_LIST]` at the upstream boundary.\n \"\"\"\n self._write_template(\n \"q\", \"SELECT Id FROM ApexClass WHERE Name IN ({{NAMES_LIST}})\",\n )\n with self.assertRaises(self.soql_loader.SoqlParamError) as ctx:\n self.soql_loader.load_soql_in(\n \"q\",\n list_params={\"NAMES_LIST\": [\"ClassA\", \"ClassB'; DROP TABLE x;--\"]},\n )\n self.assertEqual(ctx.exception.key, \"NAMES_LIST\")\n\n def test_non_string_element_raises(self):\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Id IN ({{IDS}})\")\n with self.assertRaises(self.soql_loader.SoqlParamError) as ctx:\n self.soql_loader.load_soql_in(\n \"q\", list_params={\"IDS\": [\"ClassA\", 42]},\n )\n self.assertEqual(ctx.exception.key, \"IDS\")\n\n def test_whitespace_in_list_element_raises(self):\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Id IN ({{IDS}})\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n self.soql_loader.load_soql_in(\n \"q\", list_params={\"IDS\": [\"ClassA\", \"x OR 1=1\"]},\n )\n\n # ---- empty list fails fast --------------------------------------------\n\n def test_empty_list_raises(self):\n \"\"\"SOQL `WHERE X IN ()` is a syntax error; fail at the loader,\n not at the CLI. The reason string must mention empty so the\n `_unresolved` bucket can be tagged distinctly from injection.\n \"\"\"\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Id IN ({{IDS}})\")\n with self.assertRaises(self.soql_loader.SoqlParamError) as ctx:\n self.soql_loader.load_soql_in(\"q\", list_params={\"IDS\": []})\n self.assertEqual(ctx.exception.key, \"IDS\")\n self.assertIn(\"empty\", ctx.exception.reason.lower())\n\n def test_list_params_not_a_list_raises(self):\n \"\"\"Defensive: a dict or string passed in `list_params[KEY]`\n must be rejected — silently iterating a string would produce\n one-char-per-element SOQL, which is worse than an explicit error.\n \"\"\"\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Id IN ({{IDS}})\")\n with self.assertRaises(self.soql_loader.SoqlParamError):\n # type: ignore[arg-type]\n self.soql_loader.load_soql_in(\"q\", list_params={\"IDS\": \"notalist\"})\n\n # ---- dedupe + deterministic order -------------------------------------\n\n def test_dedupe_eliminates_duplicates(self):\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name IN ({{NS}})\")\n out = self.soql_loader.load_soql_in(\n \"q\", list_params={\"NS\": [\"ClassA\", \"ClassA\", \"ClassB\"]},\n )\n self.assertEqual(out.count(\"'ClassA'\"), 1)\n self.assertEqual(out.count(\"'ClassB'\"), 1)\n\n def test_output_is_sorted_for_deterministic_order(self):\n \"\"\"Stable cache-key requirement: input order MUST NOT affect\n output. `sorted(set(...))` lands ClassA before ClassB regardless\n of input order.\n \"\"\"\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name IN ({{NS}})\")\n out1 = self.soql_loader.load_soql_in(\n \"q\", list_params={\"NS\": [\"ClassB\", \"ClassA\"]},\n )\n out2 = self.soql_loader.load_soql_in(\n \"q\", list_params={\"NS\": [\"ClassA\", \"ClassB\"]},\n )\n self.assertEqual(out1, out2)\n # And the order is alphabetical, not input-dependent.\n self.assertLess(out1.index(\"'ClassA'\"), out1.index(\"'ClassB'\"))\n\n # ---- scalar validation path shared with load_soql ---------------------\n\n def test_scalar_injection_still_raises(self):\n \"\"\"string_params go through the same validator as `load_soql` —\n no shortcut. A SOQL-injection attempt in a scalar must still\n surface SoqlParamError.\n \"\"\"\n self._write_template(\n \"q\",\n \"SELECT Id FROM X WHERE P = '{{PID}}' OR Q IN ({{LIST}})\",\n )\n with self.assertRaises(self.soql_loader.SoqlParamError) as ctx:\n self.soql_loader.load_soql_in(\n \"q\",\n string_params={\"PID\": \"x' OR Id!=null--\"},\n list_params={\"LIST\": [\"A\"]},\n )\n self.assertEqual(ctx.exception.key, \"PID\")\n\n # ---- template-name validation reused ----------------------------------\n\n def test_template_traversal_raises(self):\n with self.assertRaises(self.soql_loader.SoqlParamError) as ctx:\n self.soql_loader.load_soql_in(\n \"../../../evil\", list_params={\"IDS\": [\"A\"]},\n )\n self.assertEqual(ctx.exception.key, \"soql_template_name\")\n\n def test_missing_template_raises_template_not_found(self):\n with self.assertRaises(self.soql_loader.SoqlTemplateNotFound):\n self.soql_loader.load_soql_in(\n \"nonexistent_for_in\", list_params={\"IDS\": [\"A\"]},\n )\n\n # ---- existing load_soql unchanged -------------------------------------\n\n def test_load_soql_signature_untouched(self):\n \"\"\"contract: `load_soql(name, **params)` keeps its original\n signature — no kwargs-only, no extra params. A caller that still\n uses the old form must keep working.\n \"\"\"\n self._write_template(\"q\", \"SELECT Id FROM X WHERE Name = '{{NAME}}'\")\n out = self.soql_loader.load_soql(\"q\", NAME=\"Foo\")\n self.assertIn(\"'Foo'\", out)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":21823,"content_sha256":"81b6d67f348a795891bf3951bd63dd8d6b598e3b96a9197e59c4cce1567559a9"},{"filename":"scripts/tests/test_summarize_tree.py","content":"\"\"\"Tests for ``summarize_tree`` — declared-action-tree → markdown summary.\n\nCovers:\n- ``render_tree`` recursive box-drawing renderer\n- ``main`` CLI orchestration via argv (tree_json + out_md + built_at)\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport unittest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom unittest import mock\n\nfrom . import _bootstrap # noqa: F401 — sys.path setup\n\nimport summarize_tree # type: ignore\n\n\n# -----------------------------------------------------------------------------\n# render_tree — pure dict → list[str]\n# -----------------------------------------------------------------------------\n\n\nclass RenderTreeTests(unittest.TestCase):\n\n def test_single_leaf_uses_last_connector(self):\n # is_last=True (default) → \"└── \"; no children → no recursion.\n out = summarize_tree.render_tree({\"kind\": \"TOPIC\", \"api_name\": \"T1\"})\n self.assertEqual(out, [\"└── [TOPIC] T1\"])\n\n def test_first_of_two_siblings_uses_branch_connector(self):\n # render at top-level isn't the natural use, but the function\n # accepts is_last=False → \"├── \".\n out = summarize_tree.render_tree(\n {\"kind\": \"TOPIC\", \"api_name\": \"T1\"}, is_last=False\n )\n self.assertEqual(out, [\"├── [TOPIC] T1\"])\n\n def test_bot_definition_with_version_includes_version_in_label(self):\n agent = {\"version\": \"v3\"}\n out = summarize_tree.render_tree(\n {\"kind\": \"BOT_DEFINITION\", \"api_name\": \"MyAgent\"},\n agent_=agent,\n )\n self.assertEqual(out, [\"└── [BOT_DEFINITION] MyAgent (v3)\"])\n\n def test_bot_definition_without_version_omits_version_suffix(self):\n out = summarize_tree.render_tree(\n {\"kind\": \"BOT_DEFINITION\", \"api_name\": \"MyAgent\"},\n agent_={},\n )\n self.assertEqual(out, [\"└── [BOT_DEFINITION] MyAgent\"])\n\n def test_gen_ai_function_includes_unwraps_arrow(self):\n out = summarize_tree.render_tree({\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"GAF1\",\n \"unwraps_to\": {\"kind\": \"FLOW\", \"api_name\": \"FlowA\"},\n })\n self.assertEqual(out, [\"└── [GEN_AI_FUNCTION] GAF1 → FLOW:FlowA\"])\n\n def test_gen_ai_function_without_unwraps_omits_arrow(self):\n out = summarize_tree.render_tree({\n \"kind\": \"GEN_AI_FUNCTION\",\n \"api_name\": \"GAF1\",\n })\n self.assertEqual(out, [\"└── [GEN_AI_FUNCTION] GAF1\"])\n\n def test_standard_action_uses_invocation_type_when_present(self):\n out = summarize_tree.render_tree({\n \"kind\": \"STANDARD_ACTION\",\n \"api_name\": \"SA1\",\n \"invocation_type\": \"standardinvocableaction\",\n })\n self.assertEqual(\n out, [\"└── [STANDARD_ACTION] SA1 (standardinvocableaction)\"]\n )\n\n def test_standard_action_falls_back_to_legacy_invocation_keys(self):\n # raw_invocation_type wins when invocation_type missing.\n with self.subTest(\"raw_invocation_type\"):\n out = summarize_tree.render_tree({\n \"kind\": \"STANDARD_ACTION\",\n \"api_name\": \"SA1\",\n \"raw_invocation_type\": \"legacyA\",\n })\n self.assertIn(\"legacyA\", out[0])\n with self.subTest(\"raw_action_type\"):\n out = summarize_tree.render_tree({\n \"kind\": \"UNKNOWN\",\n \"api_name\": \"U1\",\n \"raw_action_type\": \"legacyB\",\n })\n self.assertIn(\"legacyB\", out[0])\n\n def test_element_name_appended_for_non_bot_kinds(self):\n out = summarize_tree.render_tree({\n \"kind\": \"FLOW\",\n \"api_name\": \"FlowA\",\n \"element_name\": \"callX\",\n })\n self.assertEqual(out, [\"└── [FLOW] FlowA — element:callX\"])\n\n def test_element_name_skipped_on_bot_definition(self):\n out = summarize_tree.render_tree({\n \"kind\": \"BOT_DEFINITION\",\n \"api_name\": \"MyAgent\",\n \"element_name\": \"should-not-appear\",\n }, agent_={\"version\": \"v1\"})\n self.assertNotIn(\"should-not-appear\", out[0])\n\n def test_nested_children_recurse_with_correct_prefix(self):\n out = summarize_tree.render_tree({\n \"kind\": \"TOPIC\",\n \"api_name\": \"T1\",\n \"children\": [\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"A1\"},\n {\"kind\": \"STANDARD_ACTION\", \"api_name\": \"A2\"},\n ],\n })\n # Top-level (is_last=True) gets \" \" continuation prefix.\n self.assertEqual(out, [\n \"└── [TOPIC] T1\",\n \" ├── [STANDARD_ACTION] A1\",\n \" └── [STANDARD_ACTION] A2\",\n ])\n\n def test_branch_node_uses_pipe_continuation_for_children(self):\n # is_last=False at parent → \"│ \" continuation prefix.\n out = summarize_tree.render_tree({\n \"kind\": \"TOPIC\",\n \"api_name\": \"T1\",\n \"children\": [{\"kind\": \"APEX\", \"api_name\": \"A1\"}],\n }, is_last=False)\n self.assertEqual(out, [\n \"├── [TOPIC] T1\",\n \"│ └── [APEX] A1\",\n ])\n\n\n# -----------------------------------------------------------------------------\n# main — argv-based CLI\n# -----------------------------------------------------------------------------\n\n\ndef _minimal_tree() -> dict:\n return {\n \"agent\": {\n \"api_name\": \"MyAgent\", \"version\": \"v1\", \"bot_id\": \"0Xx000000000\",\n \"master_label\": \"My Agent\", \"generation\": \"nga\",\n \"planner_name\": \"MyPlanner\", \"planner_type\": \"Atlas__Reasoning\",\n \"_version_auto_picked\": False,\n },\n \"node_count\": 2,\n \"depth\": 1,\n \"_kind_counts\": {\"TOPIC\": 1, \"STANDARD_ACTION\": 1},\n \"_partial\": False,\n \"root\": {\n \"kind\": \"BOT_DEFINITION\", \"api_name\": \"MyAgent\",\n \"children\": [{\"kind\": \"TOPIC\", \"api_name\": \"Greetings\"}],\n },\n }\n\n\nclass MainTests(unittest.TestCase):\n\n def test_main_returns_one_when_argv_count_wrong(self):\n with mock.patch.object(summarize_tree, \"sys\") as fake_sys:\n fake_sys.argv = [\"summarize_tree.py\"] # missing args\n fake_sys.stderr.write = lambda *_a, **_kw: None\n rc = summarize_tree.main()\n self.assertEqual(rc, 1)\n\n def test_main_returns_one_when_tree_json_unreadable(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n with mock.patch.object(summarize_tree, \"sys\") as fake_sys:\n fake_sys.argv = [\n \"summarize_tree.py\",\n str(tmp / \"missing.json\"),\n str(tmp / \"out.md\"),\n \"2026-05-09T00:00:00Z\",\n ]\n fake_sys.stderr.write = lambda *_a, **_kw: None\n rc = summarize_tree.main()\n self.assertEqual(rc, 1)\n\n def test_main_writes_summary_md_with_expected_sections(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n tree_path = tmp / \"tree.json\"\n tree_path.write_text(json.dumps(_minimal_tree()))\n out_path = tmp / \"out.summary.md\"\n\n with mock.patch.object(summarize_tree, \"sys\") as fake_sys:\n fake_sys.argv = [\n \"summarize_tree.py\",\n str(tree_path), str(out_path),\n \"2026-05-09T00:00:00Z\",\n ]\n fake_sys.stderr.write = lambda *_a, **_kw: None\n rc = summarize_tree.main()\n\n self.assertEqual(rc, 0)\n content = out_path.read_text()\n self.assertIn(\"# MyAgent v1 — declared action tree\", content)\n self.assertIn(\"## Kind counts\", content)\n self.assertIn(\"## Declared action tree\", content)\n # tree section is wrapped in ``` fence\n self.assertIn(\"```\\n└── [BOT_DEFINITION] MyAgent (v1)\", content)\n # 2026-05-09 timestamp surfaced\n self.assertIn(\"2026-05-09T00:00:00Z\", content)\n\n def test_main_appends_unresolved_section_when_present(self):\n with TemporaryDirectory() as t:\n tmp = Path(t)\n tree = _minimal_tree()\n tree[\"_unresolved\"] = [\n {\"kind\": \"FLOW\", \"api_name\": \"Missing\", \"reason\": \"404\"},\n ]\n tree_path = tmp / \"tree.json\"\n tree_path.write_text(json.dumps(tree))\n out_path = tmp / \"out.summary.md\"\n\n with mock.patch.object(summarize_tree, \"sys\") as fake_sys:\n fake_sys.argv = [\n \"summarize_tree.py\",\n str(tree_path), str(out_path),\n \"2026-05-09T00:00:00Z\",\n ]\n fake_sys.stderr.write = lambda *_a, **_kw: None\n summarize_tree.main()\n\n content = out_path.read_text()\n self.assertIn(\"## Unresolved\", content)\n self.assertIn(\"`FLOW`/`Missing` — 404\", content)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9151,"content_sha256":"c8f46aaa3d8ee56860db3c457f016a86a6c6b9f233b852a1d86b3311fd65d3b3"},{"filename":"scripts/write_emit_ctx.py","content":"#!/usr/bin/env python3\n\"\"\"Write the `$WORK_DIR/.emit_ctx.json` context file for `emit_result.py`.\n\nThe agent's bash pipeline has ~10 places where it needs to populate this\ncontext file (one OK path + 6 error paths + cache-hit path). Previously this\nwas done with inline `python3 - \u003c\u003cPYCTX` heredocs; extracting it into one\nscript gives a stable allowlist footprint and a single source of truth for\nthe ctx-file shape.\n\nThis script reads every field from env vars the agent has already exported\nand writes the JSON atomically. Status-specific error messages come in via\n`STATUS` + `ERROR_DETAIL`; the rest of the fields are derived from the run's\nalready-exported state.\n\nUsage:\n STATUS=OK ERROR_DETAIL='' CACHE_HIT=false \\\\\n python3 write_emit_ctx.py\n\nInputs (env):\n WORK_DIR required — target dir for .emit_ctx.json\n AGENT_API_NAME required\n AGENT_VERSION required on success paths, may be empty on early fails\n VERSION_AUTO_PICKED 'true'/'false'; defaults to false\n AGENT_GENERATION 'classic'|'nga'|'unknown'; defaults to 'unknown'\n BOT_ID required on success paths, may be empty on early fails\n ORG_ID_15 required on success paths, may be empty on early fails\n ORG_ID_18 required on success paths, may be empty on early fails\n CACHE_HIT 'true'/'false'; defaults to false\n CACHED_AT_UTC ISO-8601 or empty\n NODE_COUNT integer as string; defaults to 0\n DEPTH integer as string; defaults to 0\n PARTIAL 'true'/'false'; defaults to false\n UNRESOLVED_COUNT integer as string; defaults to 0\n START_EPOCH float seconds since epoch\n DATA_DIR required — absolute path (may be org-less sentinel dir on early fail)\n CACHE_PATH optional — absolute path to cache dir (ends with /)\n OUTPUT_JSON_PATH optional — computed if empty on OK path\n OUTPUT_SUMMARY_PATH optional — computed if empty on OK path\n AVAILABLE_BOTS optional — csv, for AGENT_NOT_FOUND\n AVAILABLE_VERSIONS optional — csv, for AGENT_VERSION_NOT_FOUND\n STATUS required — one of the 7 enum values\n ERROR_DETAIL optional — human-readable error message\n\n (REMEDIATE) derived keys (read from tree JSON, not env):\n ctx[\"partial_reason\"] from tree[\"_partial_reason\"] (default \"\")\n ctx[\"pending_fetches_count\"] sum(len(v) for v in tree[\"_pending_fetches\"].values())\n (default 0)\n\nThe tree is read from `$WORK_DIR/declared_action_tree.json` — this is\nwhere parse_wave.py writes. If the file is missing / unreadable / malformed,\nboth keys default to empty / 0 (pre-remediation behavior). This keeps\nearly-abort paths (AGENT_NOT_FOUND / AUTH_REQUIRED / etc.) working —\nthey never produce a tree and rightly surface `partial_reason=\"\"` +\n`pending_fetches_count=0`.\n\nderived keys (read from data_dir, not env):\n ctx[\"architecture_path\"] absolute path to `architecture.md` when\n the renderer produced one, else \"\".\n ctx[\"render_failed\"] True iff a `architecture.md.error`\n sidecar exists (written by\n main._run_finalize on renderer exception).\n ctx[\"render_error_detail\"] first 200 chars of sidecar, scrubbed via\n rest_client.redact_text. Empty when\n render_failed is False. The redact pass\n is defensive — the sidecar shouldn't\n carry tokens, but exception messages\n can carry arbitrary org content.\n\nThe sidecar convention pairs with `architecture.md`: finalize writes the\n`.error` only on exception; emit_result's RESULT-block auto-promote to\nPARTIAL_OK relies on this boolean to flag \"tree OK, diagram skipped\".\n\nOutput:\n $WORK_DIR/.emit_ctx.json\n exit 0 on success, 1 on missing env / write failure\n\"\"\"\nimport json\nimport os\nimport pathlib\nimport re\nimport sys\n\n\n# local, dependency-free token scrub mirroring the patterns in\n# `scripts/rest_client.redact_text`. Keeping the redactor inline preserves\n# the scripts/ emit helpers (stdlib-only by policy) and\n# avoids adding a sys.path hop on every write_emit_ctx invocation. The two\n# implementations must stay in sync; the shared regex shapes are:\n# * Authorization: Bearer \u003ctoken>\n# * access(_)?Token=\u003cvalue> in query strings\n# * \"access(_)?Token\":\"\u003cvalue>\" in JSON payloads\n# If a new redaction pattern is added to rest_client.redact_text it MUST be\n# mirrored here — the sidecar surface is narrow (render_architecture\n# exception messages) so the risk of drift is low, but the dual-impl is a\n# known trade-off for decoupling.\n_TOOLS_AUTH_HEADER_RE = re.compile(\n r\"(Authorization:\\s*Bearer\\s+)[^\\s\\\"'\u003c>]+\",\n flags=re.IGNORECASE,\n)\n_TOOLS_ACCESS_TOKEN_QS_RE = re.compile(\n r\"(access[_]?token=)[^&\\s\\\"'\u003c>]+\",\n flags=re.IGNORECASE,\n)\n_TOOLS_ACCESS_TOKEN_JSON_RE = re.compile(\n r\"(\\\"access[_]?token\\\"\\s*:\\s*\\\")[^\\\"]*\",\n flags=re.IGNORECASE,\n)\n\n\ndef _redact(text: str) -> str:\n if not text:\n return text\n text = _TOOLS_AUTH_HEADER_RE.sub(r\"\\1\u003credacted>\", text)\n text = _TOOLS_ACCESS_TOKEN_QS_RE.sub(r\"\\1\u003credacted>\", text)\n text = _TOOLS_ACCESS_TOKEN_JSON_RE.sub(r'\\1\u003credacted>', text)\n return text\n\n\ndef _bool(v: str) -> bool:\n return (v or \"\").strip().lower() == \"true\"\n\n\ndef _int(v: str, default: int = 0) -> int:\n try:\n return int((v or \"\").strip() or default)\n except ValueError:\n return default\n\n\ndef _read_architecture_signals(\n data_dir: str,\n agent_api_name: str = \"\",\n agent_version: str = \"\",\n) -> tuple[str, bool, str]:\n \"\"\"surface architecture.md presence + render-failure sidecar.\n\n filename is now self-identifying —\n `{agent_api_name}_{agent_version}_architecture.md` (plus `.error`\n sidecar). Callers pass both identifiers through; when either is\n missing we fall back to the legacy bare `architecture.md` / `.error`\n names so early-abort paths that haven't resolved an agent yet still\n surface any stray files they produced.\n\n Returns `(architecture_path, render_failed, render_error_detail)`:\n\n * `architecture_path` — absolute str to the rendered architecture.md\n when present; `\"\"` otherwise. Empty also covers cache-hit paths\n where a *past* run's file may exist — see the inline note.\n * `render_failed` — True iff the `.error` sidecar exists (written by\n main._run_finalize on renderer exception).\n * `render_error_detail` — first 200 chars of the sidecar, stripped +\n scrubbed via `_redact`. Empty when `render_failed` is False.\n\n Tolerant of: missing dir, unreadable sidecar, binary content. A failure\n to read the sidecar degrades to an empty detail string rather than\n suppressing the render_failed flag — consumers still see the outage\n signal, just without the triage text.\n\n Cache-hit caveat: on a cache replay, the prior run's architecture.md\n may still be present. We intentionally surface its path anyway (the\n file *is* a product of this run's data_dir). Sidecar semantics are\n different — the sidecar only lands via finalize, so a cache replay\n cannot plant a stale `.error`.\n \"\"\"\n if not data_dir:\n return (\"\", False, \"\")\n dd = pathlib.Path(data_dir)\n if agent_api_name and agent_version:\n arch = dd / f\"{agent_api_name}_{agent_version}_architecture.md\"\n sidecar = dd / f\"{agent_api_name}_{agent_version}_architecture.md.error\"\n else:\n # Early-abort paths (no agent resolved) fall back to the legacy\n # bare name so any stray file still surfaces.\n arch = dd / \"architecture.md\"\n sidecar = dd / \"architecture.md.error\"\n\n arch_path = str(arch) if arch.is_file() else \"\"\n\n if not sidecar.is_file():\n return (arch_path, False, \"\")\n\n try:\n # read_text()'s default is utf-8 with strict errors; a sidecar\n # written by finalize is always ascii prose, but be defensive in\n # case future code changes widen the content set.\n raw = sidecar.read_text(errors=\"replace\")\n except OSError:\n return (arch_path, True, \"\")\n\n # Truncate to a single-line-ish detail string. The sidecar format is\n # `\"render_architecture failed: \u003cExcType>: \u003cmsg>\\n\"`; we strip + cap\n # at 200 chars to keep the RESULT block readable, then scrub.\n detail = raw.strip().splitlines()[0] if raw.strip() else \"\"\n detail = _redact(detail)[:200]\n return (arch_path, True, detail)\n\n\ndef _read_tree_partial_signals(work_dir: str) -> tuple[str, int]:\n \"\"\"extract `_partial_reason` + pending-fetches rollup from the tree.\n\n Reads `$WORK_DIR/declared_action_tree.json` (parse_wave's output) and\n returns `(partial_reason, pending_fetches_count)`.\n\n Tolerant of: missing file, unreadable file, malformed JSON, missing\n keys, wrong-typed keys. All of those degrade to `(\"\", 0)` — the\n pre-remediation defaults — so early-abort code paths (which never\n produce a tree) behave unchanged.\n\n This is intentionally defensive: write_emit_ctx runs on every exit\n path, including ones where the tree was never created. A raise here\n would turn a clean error status into a write failure.\n \"\"\"\n try:\n tree_path = pathlib.Path(work_dir) / \"declared_action_tree.json\"\n if not tree_path.is_file():\n return (\"\", 0)\n tree = json.loads(tree_path.read_text())\n except (OSError, json.JSONDecodeError):\n return (\"\", 0)\n\n if not isinstance(tree, dict):\n return (\"\", 0)\n\n reason_raw = tree.get(\"_partial_reason\")\n reason = reason_raw if isinstance(reason_raw, str) else \"\"\n\n pending = tree.get(\"_pending_fetches\")\n count = 0\n if isinstance(pending, dict):\n for v in pending.values():\n if isinstance(v, list):\n count += len(v)\n\n return (reason, count)\n\n\ndef main() -> int:\n try:\n work_dir = os.environ[\"WORK_DIR\"]\n data_dir = os.environ[\"DATA_DIR\"]\n status = os.environ[\"STATUS\"]\n except KeyError as e:\n sys.stderr.write(f\"write_emit_ctx.py: missing env {e}\\n\")\n return 1\n\n agent_api_name = os.environ.get(\"AGENT_API_NAME\", \"\")\n agent_version = os.environ.get(\"AGENT_VERSION\", \"\")\n org_id_15 = os.environ.get(\"ORG_ID_15\", \"\")\n\n # Default OUTPUT_*_PATH paths follow the agent-scoped naming convention.\n # Caller can override by exporting directly. If DATA_DIR already ends with\n # the per-agent suffix (because the agent bash composed it), don't re-append.\n #\n # summary.md dropped from the output contract — OUTPUT_SUMMARY_PATH\n # is kept in the RESULT block (empty string) for shape stability.\n default_json = \"\"\n if status in (\"OK\", \"PARTIAL_OK\") and org_id_15 and agent_api_name and agent_version:\n per_agent_suffix = f\"{org_id_15}/{agent_api_name}__{agent_version}\"\n dd = data_dir.rstrip(\"/\")\n base = dd if dd.endswith(per_agent_suffix) else f\"{dd}/{per_agent_suffix}\"\n default_json = f\"{base}/{agent_api_name}_{agent_version}_metadata_tree.json\"\n\n output_json = os.environ.get(\"OUTPUT_JSON_PATH\") or default_json\n output_summary = os.environ.get(\"OUTPUT_SUMMARY_PATH\") or \"\"\n\n try:\n start_epoch = float(os.environ.get(\"START_EPOCH\", \"0\") or \"0\")\n except ValueError:\n start_epoch = 0.0\n\n # derive partial_reason + pending_fetches_count from the tree\n # on disk rather than relying on the agent bash to export them. The\n # tree is the single source of truth — parse_wave wrote these fields;\n # having the bash re-export them would be a narrow, drift-prone\n # integration layer. Absent tree → default (\"\", 0).\n partial_reason, pending_fetches_count = _read_tree_partial_signals(work_dir)\n\n # derive architecture-output signals from the data_dir.\n # Parallel to the partial-signals plumbing above: data_dir is the\n # single source of truth for what finalize produced. A missing dir\n # (early-abort paths) degrades to empty fields and render_failed=False.\n architecture_path, render_failed, render_error_detail = (\n _read_architecture_signals(data_dir, agent_api_name, agent_version)\n )\n\n ctx = {\n \"status\": status,\n \"error_detail\": os.environ.get(\"ERROR_DETAIL\", \"\"),\n \"agent_api_name\": agent_api_name,\n \"agent_version\": agent_version,\n \"version_auto_picked\": _bool(os.environ.get(\"VERSION_AUTO_PICKED\", \"\")),\n \"agent_generation\": os.environ.get(\"AGENT_GENERATION\", \"\") or \"unknown\",\n \"bot_id\": os.environ.get(\"BOT_ID\", \"\"),\n \"org_id_15\": org_id_15,\n \"org_id_18\": os.environ.get(\"ORG_ID_18\", \"\"),\n \"cache_hit\": _bool(os.environ.get(\"CACHE_HIT\", \"\")),\n \"cached_at_utc\": os.environ.get(\"CACHED_AT_UTC\", \"\"),\n \"cache_path\": os.environ.get(\"CACHE_PATH\", \"\"),\n \"output_json_path\": output_json,\n \"output_summary_path\": output_summary,\n \"node_count\": _int(os.environ.get(\"NODE_COUNT\", \"0\")),\n \"depth\": _int(os.environ.get(\"DEPTH\", \"0\")),\n \"partial\": _bool(os.environ.get(\"PARTIAL\", \"\")),\n # plumbed from the tree, not env.\n \"partial_reason\": partial_reason,\n \"pending_fetches_count\": pending_fetches_count,\n \"unresolved_count\": _int(os.environ.get(\"UNRESOLVED_COUNT\", \"0\")),\n \"available_bots\": os.environ.get(\"AVAILABLE_BOTS\", \"\"),\n \"available_versions\": os.environ.get(\"AVAILABLE_VERSIONS\", \"\"),\n \"start_epoch\": start_epoch,\n \"data_dir\": data_dir,\n \"work_dir\": work_dir,\n # architecture-render outcome signals.\n \"architecture_path\": architecture_path,\n \"render_failed\": render_failed,\n \"render_error_detail\": render_error_detail,\n }\n\n out = pathlib.Path(work_dir) / \".emit_ctx.json\"\n try:\n out.parent.mkdir(parents=True, exist_ok=True)\n tmp = out.with_suffix(out.suffix + \".tmp\")\n tmp.write_text(json.dumps(ctx, indent=2))\n os.replace(tmp, out)\n except OSError as e:\n sys.stderr.write(f\"write_emit_ctx.py: write failed: {e}\\n\")\n return 1\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14404,"content_sha256":"08bb989dd0db7dd7de959b87911e92b0cd114f0945629a07f12ed70bdc952d02"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"agentforce-architecture-analyze — declared architecture snapshot","type":"text"}]},{"type":"paragraph","content":[{"text":"Design-time metadata tree for one Agentforce agent: planner → topics → actions → flows → Apex → prompts → NGA plugins. Reads declared metadata only — ","type":"text"},{"text":"BotDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"GenAiPlanner*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"GenAiPlugin*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"GenAiFunction*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Flow","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"ApexClass","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"GenAiPromptTemplate","type":"text","marks":[{"type":"code_inline"}]},{"text":". Does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" read runtime audit rows.","type":"text"}]},{"type":"paragraph","content":[{"text":"Runtime budget: 30–45s typical, ≤60s hard cap","type":"text","marks":[{"type":"strong"}]},{"text":" on reference fixtures. Sequential baseline would be 90–220s; parallel Tooling SOQL fan-out delivers a 3–5× speedup. Large bots with many flows scale approximately linearly — each flow metadata retrieve is one round-trip.","type":"text"}]},{"type":"paragraph","content":[{"text":"Runs ","type":"text"},{"text":"inline","type":"text","marks":[{"type":"strong"}]},{"text":" — no subagent. Every phase is deterministic file processing.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"If the user hasn't named an agent","type":"text"}]},{"type":"paragraph","content":[{"text":"Trigger condition: ","type":"text"},{"text":"$ARGUMENTS","type":"text","marks":[{"type":"code_inline"}]},{"text":" names no agent (no ","type":"text"},{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag and no exact ","type":"text"},{"text":"BotDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":" API name in the prose). Never invent an agent name. Instead, discover what the org actually contains — one read-only query, no pipeline yet. Use the ","type":"text"},{"text":"--org","type":"text","marks":[{"type":"code_inline"}]},{"text":" alias if given, otherwise the default target org (omit ","type":"text"},{"text":"--target-org","type":"text","marks":[{"type":"code_inline"}]},{"text":"):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"sf data query --query \"SELECT DeveloperName, MasterLabel FROM BotDefinition ORDER BY DeveloperName\" \\\n [--target-org \u003calias>] --json","type":"text"}]},{"type":"paragraph","content":[{"text":"If the user gave no org alias, the pipeline block defaults ","type":"text"},{"text":"--org","type":"text","marks":[{"type":"code_inline"}]},{"text":" to the CLI default target org itself (","type":"text"},{"text":"sf config get target-org --json","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":".result[0].value","type":"text","marks":[{"type":"code_inline"}]},{"text":") — you don't need to resolve it.","type":"text"}]},{"type":"paragraph","content":[{"text":"Then branch on the rows returned:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Zero agents","type":"text","marks":[{"type":"strong"}]},{"text":" — tell the user the org has no Agentforce agents and stop. Do not fabricate an agent or a tree.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Exactly one agent, or exactly one whose ","type":"text","marks":[{"type":"strong"}]},{"text":"DeveloperName","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"/","type":"text","marks":[{"type":"strong"}]},{"text":"MasterLabel","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" matches the user's description","type":"text","marks":[{"type":"strong"}]},{"text":" (e.g. \"support\", \"service\", \"sales\") — use it, say explicitly which agent you picked and why, then run the pipeline block below with ","type":"text"},{"text":"ARG_AGENT=\u003cDeveloperName>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (and ","type":"text"},{"text":"ARG_ORG=\u003calias>","type":"text","marks":[{"type":"code_inline"}]},{"text":" if the user named one) set as its first lines, e.g. ","type":"text"},{"text":"ARG_AGENT=Customer_Support_Agent","type":"text","marks":[{"type":"code_inline"}]},{"text":". The block reads ","type":"text"},{"text":"ARG_*","type":"text","marks":[{"type":"code_inline"}]},{"text":" from the environment; flags in ","type":"text"},{"text":"$ARGUMENTS","type":"text","marks":[{"type":"code_inline"}]},{"text":" still override.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Otherwise","type":"text","marks":[{"type":"strong"}]},{"text":" — print the block below, with the discovered agents listed as a numbered ","type":"text"},{"text":"DeveloperName — MasterLabel","type":"text","marks":[{"type":"code_inline"}]},{"text":" table under the first line, and wait for the user to pick.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Query fails","type":"text","marks":[{"type":"strong"}]},{"text":" (no default org, auth error) — print the block below without the list.","type":"text"}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Which agent should I document, and in which org?","type":"text"}]},{"type":"paragraph","content":[{"text":"I need:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Agent API name","type":"text","marks":[{"type":"strong"}]},{"text":" — the ","type":"text"},{"text":"DeveloperName","type":"text","marks":[{"type":"code_inline"}]},{"text":" of the ","type":"text"},{"text":"BotDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g. ","type":"text"},{"text":"MyAgent","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"MySalesAgent","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Not the label.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Org alias","type":"text","marks":[{"type":"strong"}]},{"text":" — for ","type":"text"},{"text":"sf","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI auth (the alias you configured with ","type":"text"},{"text":"sf org login","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Optional:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Version","type":"text","marks":[{"type":"strong"}]},{"text":" — an ","type":"text"},{"text":"agent_version_api_name","type":"text","marks":[{"type":"code_inline"}]},{"text":" like ","type":"text"},{"text":"v5","type":"text","marks":[{"type":"code_inline"}]},{"text":". If omitted, I'll resolve the active ","type":"text"},{"text":"BotVersion","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"--force","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — ignore cached tree; re-fetch everything.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"--reprobe","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — re-run the 7-day channel-probe cache (only needed after a Salesforce release).","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"I'll run the metadata pipeline inline. Artifacts land under ","type":"text"},{"text":"~/.vibe/data/agentforce-architecture-analyze/\u003corg_id15>/\u003cagent_api_name>__\u003cagent_version>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (overridable with ","type":"text"},{"text":"--data-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Pipeline invocation","type":"text"}]},{"type":"paragraph","content":[{"text":"Run this block once the agent is known — from ","type":"text"},{"text":"--agent \u003capi_name>","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"$ARGUMENTS","type":"text","marks":[{"type":"code_inline"}]},{"text":", or preset as ","type":"text"},{"text":"ARG_AGENT=\u003cDeveloperName>","type":"text","marks":[{"type":"code_inline"}]},{"text":" after discovery. ","type":"text"},{"text":"--org","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"ARG_ORG","type":"text","marks":[{"type":"code_inline"}]},{"text":") is optional; without it the CLI default target org is used. One ","type":"text"},{"text":"python3","type":"text","marks":[{"type":"code_inline"}]},{"text":" invocation drives the full pipeline. ","type":"text"},{"text":"main.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" writes ","type":"text"},{"text":".emit_ctx.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"emit_result.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" reads it and prints the final ","type":"text"},{"text":"=== RESULT ===","type":"text","marks":[{"type":"code_inline"}]},{"text":" block last to stdout.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"set -euo pipefail\n\n# zsh arrays are 1-indexed by default; bash arrays are 0-indexed.\n# This block uses 0-indexed semantics throughout (_args[$i] starting at i=0),\n# so under zsh + `set -u` the very first read of `_args[0]` would trip\n# `parameter not set`. KSH_ARRAYS makes zsh treat arrays as 0-indexed,\n# matching the bash shebang's expectation. No-op under bash.\n[ -n \"${ZSH_VERSION:-}\" ] && setopt KSH_ARRAYS\n\n# \u003cSKILL_DIR> = absolute path of this skill's own directory (the folder holding\n# this SKILL.md); substitute it from the skill path in context. Runtimes install\n# skills in different places, so also probe the known install roots and use the\n# first that actually contains this skill's scripts. An exported SKILL_ROOT wins.\n_skill=agentforce-architecture-analyze\nfor _c in \"${SKILL_ROOT:-}\" \"\u003cSKILL_DIR>\" \\\n \"${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/skills/$_skill}\" \\\n \"${VIBES_SKILLS_DIR:+$VIBES_SKILLS_DIR/$_skill}\" \\\n \"$HOME/.claude/skills/$_skill\" \"$PWD/.claude/skills/$_skill\" \\\n \"${PLUGIN_ROOT:-$HOME/.vibe/skills}/$_skill\"; do\n [ -n \"$_c\" ] || continue\n # Some stagers (ADK eval) nest the bundled files under \u003cskill>/artifacts/.\n for _r in \"$_c\" \"$_c/artifacts\"; do\n [ -f \"$_r/scripts/main.py\" ] && { SKILL_ROOT=\"$_r\"; break 2; }\n done\ndone\n[ -f \"${SKILL_ROOT:-}/scripts/main.py\" ] || { echo \"$_skill: scripts not found — set SKILL_ROOT to this skill's directory\" >&2; exit 1; }\n\n# Argument parser. Accepts both `--org foo` and `--org=foo`.\n# `$ARGUMENTS` is the raw user input Claude Code substitutes; it may be unset\n# or plain prose. Each ARG_* starts from the environment, so a value resolved\n# during discovery can be preset (e.g. `ARG_AGENT=MyAgent` on the first line);\n# flags in $ARGUMENTS still override.\nARG_ORG=\"${ARG_ORG:-}\"\nARG_AGENT=\"${ARG_AGENT:-}\"\nARG_VERSION=\"${ARG_VERSION:-}\"\nARG_FORCE=\"${ARG_FORCE:-}\"\nARG_REPROBE=\"${ARG_REPROBE:-}\"\nARG_PARALLELISM=\"${ARG_PARALLELISM:-}\"\nARG_MAX_MERMAID=\"${ARG_MAX_MERMAID:-}\"\n\n# Word-split $ARGUMENTS (zsh needs SH_WORD_SPLIT for that) with globbing off\n# so prose like `*` never expands to filenames.\n[ -n \"${ZSH_VERSION:-}\" ] && setopt SH_WORD_SPLIT\nset -f\n# shellcheck disable=SC2206\n_args=(${ARGUMENTS:-})\nset +f\ni=0\nwhile [ $i -lt ${#_args[@]} ]; do\n tok=\"${_args[$i]}\"\n case \"$tok\" in\n --org=*) ARG_ORG=\"${tok#--org=}\" ;;\n --org) i=$((i+1)); ARG_ORG=\"${_args[$i]:-}\" ;;\n --agent=*) ARG_AGENT=\"${tok#--agent=}\" ;;\n --agent) i=$((i+1)); ARG_AGENT=\"${_args[$i]:-}\" ;;\n --version=*) ARG_VERSION=\"${tok#--version=}\" ;;\n --version) i=$((i+1)); ARG_VERSION=\"${_args[$i]:-}\" ;;\n --parallelism=*) ARG_PARALLELISM=\"${tok#--parallelism=}\" ;;\n --parallelism) i=$((i+1)); ARG_PARALLELISM=\"${_args[$i]:-}\" ;;\n --max-mermaid-nodes=*) ARG_MAX_MERMAID=\"${tok#--max-mermaid-nodes=}\" ;;\n --max-mermaid-nodes) i=$((i+1)); ARG_MAX_MERMAID=\"${_args[$i]:-}\" ;;\n --force) ARG_FORCE=\"1\" ;;\n --reprobe) ARG_REPROBE=\"1\" ;;\n esac\n i=$((i+1))\ndone\n\n# No org named anywhere: fall back to the sf CLI default target org\n# (`sf config get target-org --json` -> .result[0].value). Extracted with sed\n# so the block needs neither jq nor an inline interpreter; any failure leaves\n# ARG_ORG empty and the usage block below fires.\nif [ -z \"$ARG_ORG\" ] && command -v sf >/dev/null 2>&1; then\n _cfg=\"$(sf config get target-org --json 2>/dev/null || true)\"\n _cfg=\"$(printf '%s\\n' \"$_cfg\" | sed -n 's/.*\"value\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' || true)\"\n ARG_ORG=\"${_cfg%%

agentforce-architecture-analyze — declared architecture snapshot Design-time metadata tree for one Agentforce agent: planner → topics → actions → flows → Apex → prompts → NGA plugins. Reads declared metadata only — , , , , , , . Does not read runtime audit rows. Runtime budget: 30–45s typical, ≤60s hard cap on reference fixtures. Sequential baseline would be 90–220s; parallel Tooling SOQL fan-out delivers a 3–5× speedup. Large bots with many flows scale approximately linearly — each flow metadata retrieve is one round-trip. Runs inline — no subagent. Every phase is deterministic file processi…

\\n'*}\"\nfi\n\n# Usage block if required flags missing. Agent reads stderr,\n# prints verbatim, and stops — does NOT pre-run main.py.\nif [ -z \"$ARG_ORG\" ] || [ -z \"$ARG_AGENT\" ]; then\n cat >&2 \u003c\u003c'USAGE'\n> Which agent should I document, and in which org?\n>\n> I need:\n> - **Agent API name** — the BotDefinition.DeveloperName (e.g. `MyAgent`)\n> - **Org alias** — for `sf` CLI auth (the alias you configured with `sf org login`);\n> only needed when no default target org is set (`sf config set target-org \u003calias>`)\n>\n> Optional flags:\n> - `--version v5` — pin a specific BotVersion (default: Active+highest)\n> - `--force` — bypass cache\n> - `--reprobe` — force channel-probe refresh\n> - `--parallelism N` — ThreadPoolExecutor size (default 5)\n> - `--max-mermaid-nodes N` — cap Mermaid node count (default 80)\nUSAGE\n exit 2\nfi\n\n# Fresh work dir per invocation. Epoch + random suffix avoids collisions\n# between concurrent runs on the same host.\nWORK_DIR=\"/tmp/agentforce-architecture-analyze-$(date +%s)-$RANDOM\"\nmkdir -p \"$WORK_DIR\"\n\n# Input validation at the boundary, BEFORE any python3 call.\n# fs_guard exits 1 and prints an INVALID_INPUT RESULT block on failure;\n# `|| exit 1` is mandatory — bare calls silently continue past failures.\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$ARG_AGENT\" agent_api_name api_name || exit 1\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$ARG_ORG\" org_alias not_empty || exit 1\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$WORK_DIR\" WORK_DIR symlink || exit 1\npython3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$WORK_DIR\" WORK_DIR owned || exit 1\nif [ -n \"$ARG_VERSION\" ]; then\n python3 \"$SKILL_ROOT/scripts/_shared/fs_guard.py\" \"$ARG_VERSION\" agent_version api_name || exit 1\nfi\n\n# Single python3 call drives all pipeline phases. main.py writes\n# `.emit_ctx.json` into $WORK_DIR — emit_result.py then renders the\n# RESULT block from that ctx. No subprocess-per-phase.\n_main_args=(--org-alias \"$ARG_ORG\" --agent \"$ARG_AGENT\" --work-dir \"$WORK_DIR\")\n[ -n \"$ARG_VERSION\" ] && _main_args+=(--version \"$ARG_VERSION\")\n[ -n \"$ARG_FORCE\" ] && _main_args+=(--force)\n[ -n \"$ARG_REPROBE\" ] && _main_args+=(--reprobe)\n[ -n \"$ARG_PARALLELISM\" ] && _main_args+=(--parallelism \"$ARG_PARALLELISM\")\n[ -n \"$ARG_MAX_MERMAID\" ] && _main_args+=(--max-mermaid-nodes \"$ARG_MAX_MERMAID\")\n\n# main.py returns nonzero on terminal failures; we DON'T short-circuit —\n# emit_result still publishes the failure RESULT block. `set -e` is\n# temporarily relaxed around this single call.\nset +e\npython3 \"$SKILL_ROOT/scripts/main.py\" \"${_main_args[@]}\"\n_rc=$?\nset -e\n\n# Final RESULT block is emit_result.py's stdout — MUST be the last thing\n# stdout sees. emit_result exits 0 on render success; the bash harness\n# propagates main.py's rc for the agent's exit status.\nWORK_DIR=\"$WORK_DIR\" python3 \"$SKILL_ROOT/scripts/emit_result.py\"\nexit \"$_rc\"","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Inputs","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Input","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Required","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"org_alias","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--org","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sf CLI default target org (","type":"text"},{"text":"sf config get target-org","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"agent_api_name","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"yes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"agent_version_api_name","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--version","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"active BotVersion","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"force_refresh","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--force","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false (honor cache)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"reprobe","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--reprobe","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false (honor 7-day channel-probe cache)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"parallelism","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--parallelism","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"max_mermaid_nodes","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--max-mermaid-nodes","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"80","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"data_dir","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--data-dir","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~/.vibe/data/agentforce-architecture-analyze","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cache_dir","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--cache-dir","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~/.vibe/cache/agentforce-architecture-analyze","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Outputs","type":"text"}]},{"type":"paragraph","content":[{"text":"All artifacts under ","type":"text"},{"text":"~/.vibe/data/agentforce-architecture-analyze/\u003corg_id15>/\u003cagent_api_name>__\u003cagent_version>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default; override with ","type":"text"},{"text":"--data-dir \u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":"):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"xml"},"content":[{"text":"\u003cagent>_\u003cver>_metadata_tree.json primary artifact — normalized planner/topic/action/flow/apex/prompt/plugin tree\n\u003cagent>_\u003cver>_architecture.md human-readable section-by-section rendering (H1 + 7 numbered sections, plus a conditional Dependency graph appendix). Mermaid diagrams are embedded inside the relevant sections (Action tree, Data flow, and Dependency graph)","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Pipeline — inline, no subagent","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"resolve_bot.py → BotDefinition + BotVersion + planner name lookup\nretrieve_planner.py → Metadata API zip retrieve for GenAiPlannerBundle (+ NGA plugins if present)\nparallel_retrieve.py → 6 parallel Tooling SOQL channels fan out from the planner id\n (resolved by the `planner_definition_by_agent_chain` seed query):\n - plugins_by_planner (GenAiPluginDefinition)\n - planner_bundle_functions (GenAiPlannerFunctionDef join)\n - functions_by_plugins (GenAiFunctionDefinition)\n - planner_attrs_by_parent_ids (GenAiPlannerAttrDefinition)\n - plugin_functions_by_plugin_ids (GenAiPluginFunctionDef join)\n - plugin_instructions_by_plugin_ids (GenAiPluginInstructionDef)\nparse_bundle.py → parse retrieved XML into normalized node shapes\nparse_wave.py → BFS expansion: flow/apex/prompt refs discovered in nodes\n → SOQL for Flow/Apex bodies (batched by id list)\n → Metadata retrieve ONLY for GenAiPromptTemplate (+ NGA external plugins conditionally)\nfinalize.py → merge waves into metadata_tree.json\nrender_architecture.py → \u003cagent>_\u003cver>_architecture.md + Mermaid invocation graph (capped at --max-mermaid-nodes)","type":"text"}]},{"type":"paragraph","content":[{"text":"Channel strategy — SOQL-first.","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tooling SOQL","type":"text","marks":[{"type":"strong"}]},{"text":" for every normalized tree node (planner, plugins, functions, plugin-functions, plugin-instructions, planner-functions, planner-attrs) — 6 parallel channels keyed on planner id, plus the ","type":"text"},{"text":"planner_definition_by_agent_chain","type":"text","marks":[{"type":"code_inline"}]},{"text":" seed query that resolves the planner id from the agent chain.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Data API SOQL","type":"text","marks":[{"type":"strong"}]},{"text":" for Flow (by id) and Apex (by id or name) bodies — batched.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Metadata retrieve","type":"text","marks":[{"type":"strong"}]},{"text":" only for two cases: (a) ","type":"text"},{"text":"GenAiPromptTemplate","type":"text","marks":[{"type":"code_inline"}]},{"text":" (prompt bodies aren't cleanly exposed via Tooling SOQL), and (b) NGA ","type":"text"},{"text":"external plugins","type":"text","marks":[{"type":"strong"}]},{"text":" when the planner is Native Generative Agent shape (skipped for classic ReAct).","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"This is where the 3–5× speedup comes from. A naive implementation would retrieve everything via Metadata API zips sequentially; parallel Tooling SOQL covers ~80% of the tree in a single fan-out.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Planner shapes — classic ReAct vs NGA","type":"text"}]},{"type":"paragraph","content":[{"text":"The skill normalizes two planner families into a single tree shape:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Shape","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GenAiPlannerDefinition.PlannerType","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvocationTarget style","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NGA plugins?","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Classic ReAct","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ReactAiPlannerV1","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"SequentialPlannerIntentClassifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" / etc.","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DeveloperName strings","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"no","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NGA","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ConcurrentMultiAgentOrchestration","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"AnthropicCompatibleV1","type":"text","marks":[{"type":"code_inline"}]},{"text":" / etc.","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sometimes 15/18-char Ids (ID-prefix routed)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"yes (external plugins via Metadata retrieve)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"The ID-prefix router in ","type":"text"},{"text":"resolve_invocation_target.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" distinguishes the two: NGA InvocationTargets that look like ids (","type":"text"},{"text":"01p…","type":"text","marks":[{"type":"code_inline"}]},{"text":" = ApexClass, ","type":"text"},{"text":"301…","type":"text","marks":[{"type":"code_inline"}]},{"text":" = Flow, etc.) get resolved via id-scoped SOQL; DeveloperName targets go through name-scoped SOQL. Unknown prefixes surface as ","type":"text"},{"text":"_unresolved[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"reason=\"unknown-id-prefix:\u003cprefix>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" — never silently dropped.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Caching","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tree cache","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"metadata_tree.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" is reused unless ","type":"text"},{"text":"--force","type":"text","marks":[{"type":"code_inline"}]},{"text":" is passed. Cache key includes the asset-hash of every ","type":"text"},{"text":".soql","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".mmd","type":"text","marks":[{"type":"code_inline"}]},{"text":" template bundled with the skill — bump a template, the cache busts automatically.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Channel probe cache","type":"text","marks":[{"type":"strong"}]},{"text":": 7-day TTL on the per-org ","type":"text"},{"text":"sf sobject describe","type":"text","marks":[{"type":"code_inline"}]},{"text":" results that validate every field name the SOQL assets reference. A Salesforce quarterly release that renames / removes a field triggers ","type":"text"},{"text":"status: PROBE_FAILED","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"--reprobe","type":"text","marks":[{"type":"code_inline"}]},{"text":" forces a refresh.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisites","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Required","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sf","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI (authenticated against the target org)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"yes — ","type":"text"},{"text":"sf org login web --alias \u003calias>","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the CLI must provide ","type":"text"},{"text":"sf org auth show-access-token","type":"text","marks":[{"type":"code_inline"}]},{"text":" (startup preflight enforces this)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Python 3.10+","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"yes","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference docs to load when needed","type":"text"}]},{"type":"paragraph","content":[{"text":"Do NOT load eagerly. Load when the user's question requires it:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/soql_fields.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — per-sObject field reference for the 13 sObjects this skill touches (2 Data API + 11 Tooling), with ","type":"text"},{"text":"[mandatory]","type":"text","marks":[{"type":"code_inline"}]},{"text":" vs ","type":"text"},{"text":"[optional]","type":"text","marks":[{"type":"code_inline"}]},{"text":" tags. Load when the user asks about a specific field, or when debugging an ","type":"text"},{"text":"INVALID_FIELD","type":"text","marks":[{"type":"code_inline"}]},{"text":" SOQL error.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/contract.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" — machine-readable schema for ","type":"text"},{"text":"metadata_tree.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". Load when writing downstream tooling that consumes the tree.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/architecture_sections.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — section-by-section structure of the rendered ","type":"text"},{"text":"\u003cagent>_\u003cver>_architecture.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Invariants worth knowing upfront","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pipeline is deterministic.","type":"text","marks":[{"type":"strong"}]},{"text":" Same ","type":"text"},{"text":"(org, agent, version)","type":"text","marks":[{"type":"code_inline"}]},{"text":" + static org metadata → byte-identical ","type":"text"},{"text":"\u003cagent>_\u003cver>_metadata_tree.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"\u003cagent>_\u003cver>_architecture.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Only manifest timestamps drift across re-runs.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Forward-only traversal.","type":"text","marks":[{"type":"strong"}]},{"text":" Every discovered ref goes forward from planner → children. No backward lookups.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Partial results are surfaced, not silenced.","type":"text","marks":[{"type":"strong"}]},{"text":" Any unresolved reference lands in ","type":"text"},{"text":"_unresolved[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"reason=...","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"STATUS=PARTIAL_OK","type":"text","marks":[{"type":"code_inline"}]},{"text":" if any channel failed; ","type":"text"},{"text":"STATUS=OK","type":"text","marks":[{"type":"code_inline"}]},{"text":" only on a clean run.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cycle detection is per-branch.","type":"text","marks":[{"type":"strong"}]},{"text":" Same flow visited along its own ancestor chain emits ","type":"text"},{"text":"_cycle_back_to:\u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead of recursing. A defensive ","type":"text"},{"text":"MAX_BFS_DEPTH=20","type":"text","marks":[{"type":"code_inline"}]},{"text":" guard backs the per-branch ancestor set; real-world agents bottom out well before either limit fires. (Earlier docs claimed a hard cap of 5; that was the historical limit and was abandoned because shared utility flows like ","type":"text"},{"text":"handleFlowFault","type":"text","marks":[{"type":"code_inline"}]},{"text":" tripped it on every nested tree — see ","type":"text"},{"text":"config.MAX_BFS_DEPTH","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the rationale.)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Child ordering is alphabetical by ","type":"text","marks":[{"type":"strong"}]},{"text":"api_name","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (case-insensitive).","type":"text","marks":[{"type":"strong"}]},{"text":" Topics come before non-topic plannerActions at the root level. Flow-actionCall order is NOT sorted — that's the flow author's execution sequence.","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"agentforce-architecture-analyze","author":"@skillopedia","source":{"stars":1139,"repo_name":"sf-skills","origin_url":"https://github.com/forcedotcom/sf-skills/blob/HEAD/skills/agentforce-architecture-analyze/SKILL.md","repo_owner":"forcedotcom","body_sha256":"1a28c6a9abcca38b1a07b75103f06069bfd8451572cad4b7b3b5182a1a240da0","cluster_key":"82941f315183c397c285d2c045df0a847d8575d69aef691f7e765b591b07c368","clean_bundle":{"format":"clean-skill-bundle-v1","source":"forcedotcom/sf-skills/skills/agentforce-architecture-analyze/SKILL.md","text_bytes":1046801,"source_commit":"a8a18ce1e4ba12e69b4073eab98ce31bfbd8271e","skills_sh_rank":3897,"attachment_count":69,"text_attachments":69,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/agentforce-architecture-analyze/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"ai-agent-development","category_label":"AI"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"ai-agent-development","metadata":{"domains":["Agentforce"],"version":"1.0","cliTools":[{"tool":["sf"],"semver":">=2.136.8"},{"tool":["python3"],"semver":">=3.10.0"}],"minApiVersion":"64.0","relatedSkills":["agentforce-d360-analyze"]},"import_tag":"skills-sh-top20k-2026-11","description":"Declared architecture snapshot for one Agentforce agent: planner, topics, actions, flows, Apex, prompt templates, and NGA plugins. Renders a human-readable architecture document and Mermaid invocation graph from design-time metadata (not runtime audit rows). TRIGGER when user asks to describe, diagram, inventory, audit, document, or diff (e.g. v3 vs v5) the architecture / action tree / topic structure / tool inventory of a specific agent by agent API name in a specific org. DO NOT TRIGGER for runtime session traces, conversation transcripts, generation timings, or gateway audit chains — this skill reads design-time metadata only (use agentforce-d360-analyze for session traces)."}},"renderedAt":1793661649755}

agentforce-architecture-analyze — declared architecture snapshot Design-time metadata tree for one Agentforce agent: planner → topics → actions → flows → Apex → prompts → NGA plugins. Reads declared metadata only — , , , , , , . Does not read runtime audit rows. Runtime budget: 30–45s typical, ≤60s hard cap on reference fixtures. Sequential baseline would be 90–220s; parallel Tooling SOQL fan-out delivers a 3–5× speedup. Large bots with many flows scale approximately linearly — each flow metadata retrieve is one round-trip. Runs inline — no subagent. Every phase is deterministic file processi…