This directory holds a deterministic, non-agentic end-to-end test of the
ado-aw execute (Stage 3) safe-output executor.
The agentic smoke suite in tests/safe-outputs/ exercises
the full Stage 1 → 2 → 3 pipeline shape, but it drives Stage 3 by having an
LLM agent (Stage 1) emit the safe output — so a failure can be the model's
fault and the suite is inherently flaky. It also only runs a small number of
omnibus pipelines rather than one per tool.
This suite removes the LLM from the loop. For every ADO-write safe output it:
- sets up preconditions deterministically via the ADO REST API,
- crafts the executor's
safe_outputs.ndjsoninput directly (fixed literal values), - runs the real
ado-aw executebinary (built from the checkout), - asserts the effect via the ADO REST API,
- cleans up every object it created,
and, on any failure, files a GitHub issue on the configured issue repository
and fails the build. AgentPlayground currently uses
jamesadevine/ado-aw-issues because a canonical-repository credential is not
available.
| File | Purpose |
|---|---|
azure-pipelines.yml |
Hand-authored ADO pipeline (daily schedule on main + path-filtered PR validation + manual). Builds ado-aw, builds the harness, runs it against AgentPlayground. |
README.md |
This file. |
The harness itself lives in
scripts/ado-script/src/executor-e2e/.
It is a test-only ado-script bundle: it is built by npm run build:executor-e2e to scripts/ado-script/test-bin/executor-e2e.js (a
gitignored, non-root path) and is deliberately excluded from the released
ado-script.zip (the release glob only packages ado-script/*.js, and the
executor-e2e dir is listed in NON_BUNDLE_DIRS in
src/__tests__/bundle-coverage.test.ts).
The default TypeScript suite also passes proposals generated by every
create-pull-request scenario (including create-then-update and both reviewer
variants) through a freshly built ado-aw execute --dry-run. It uses
synthetic setup state and the live payload builders, without running remote
setup or requiring a reviewer identity or credentials.
npm --prefix scripts/ado-script test -- create-pull-request-scenarios.test.tsThis test requires Cargo/Rust and the workspace's npm dependencies. A missing toolchain or a failed build fails the test rather than silently skipping it. The contract covers Rust deserialization and ordered execution records, not live temporary-ID resolution, patch application, or reviewer assignment. Negative cases check the former overlong temporary ID in both producer and consumer proposals, a missing internal ID, and a malformed reviewers payload. Temporary files are removed after each test. The ado-script CI workflow runs this suite when either the TypeScript scenario code or the relevant Rust schemas, validators, or executor change.
All deterministically-assertable ADO-write safe outputs plus the flagship
create-pull-request, and the four signal-only tools:
- Signals:
noop,missing-tool,missing-data,report-incomplete(no ADO write path; assert that the executor emits the expected status) - Conclusion work-item filing:
conclusion-noop,conclusion-missing-tool,conclusion-missing-dataandconclusion-report-as-work-item-false— see Conclusion scenarios below - Work items:
create-work-item,assign-work-item,update-work-item,comment-on-work-item,link-work-items,upload-workitem-attachment, plus two rendering-fidelity scenarios (see Rendering fidelity below) - Wiki:
create-wiki-page,update-wiki-page - PR:
add-pr-comment,reply-to-pr-comment,resolve-pr-thread,submit-pr-review,update-pr - Git:
create-branch,create-git-tag - Cross-org Git (optional infrastructure):
create-branch-cross-org,create-git-tag-cross-org, andcreate-pull-request-cross-org - Build:
add-build-tag,queue-build,upload-build-attachment,upload-pipeline-artifact - Flagship:
create-pull-requestcovers both a named additional checkout at<BUILD_SOURCESDIRECTORY>/<alias>andrepository: selfbeneath a non-Git multi-checkout root, with compiler-owned self repository identity taking precedence over trigger-scopedBUILD_REPOSITORY_*values. Theselfscenario supplies onlyADO_AW_SELF_REPOSITORY_NAME— matching what the compiler emits — so it also proves the executor resolves a repository from its name alone. - GitHub issues: the complete GitHub issue safe-output family, including
ordinary comment creation,
hide-github-issue-comment, and a combinedcomment-on-github-issuescenario withhide-older-comments: true. The combined scenario uses the configured PAT to prove actor matching, minimization, replacement creation, and result reporting. GitHub App mint-to-executor identity wiring is covered by deterministic bundle, compiler, and Rust integration tests rather than new live App credentials in this pipeline. These are the only scenarios that assert against GitHub rather than ADO — see GitHub issue scenarios below.
Excluded (out of scope): none of the currently shipped safe outputs.
The standalone create-work-item scenario writes the deterministic internal
ID #aw_wicreate; the assign-work-item-temporary-id-handoff scenario stages
another create-work-item ahead of assign-work-item in one executor
invocation and resolves it through #aw_wiassign. Both explicit IDs emulate
the values generated and persisted by the MCP because this harness writes
internal executor NDJSON directly rather than agent-supplied parameters. The
handoff verifies System.AssignedTo and deletes the scratch item. It uses
E2E_WORK_ITEM_ASSIGNEE when configured,
otherwise BUILD_REQUESTEDFOREMAIL; it skips when neither provides an
assignable identity.
create-work-item is the only safe output whose body goes through the Markdown
sanitizer (src/sanitize/markdown.rs), and its stored description is what a
human actually reads in the work item. Two scenarios pin that rendering:
| Scenario id | Work item type | Field |
|---|---|---|
create-work-item-rendering |
Task |
System.Description |
create-work-item-rendering-bug |
Bug |
Microsoft.VSTS.TCM.ReproSteps |
Both propose the same unsanitized corpus and assert, in order, that the stored field:
- contains no denied construct (
<script,onerror=,<iframe, or ajavascript:URL) after Azure DevOps applies its own server-side sanitization, including inside fenced code; - is recorded with
multilineFieldsFormat: Markdown— when the organization does not surface that on read, the scenario logs a note and the patch stays pinned by the executor unit tests insrc/safe_outputs/create_work_item.rs; - equals the observed Azure DevOps normalization golden byte for byte.
The corpus and both goldens live in one place —
scripts/ado-script/src/executor-e2e/scenarios/markdown-rendering-corpus.json
— imported by the harness and include_str!d by the Rust golden test in
src/sanitize/markdown.rs. expected pins what the ado-aw sanitizer sends;
ado_expected pins what Azure DevOps returns after its additional
normalization. A deliberate change to either boundary is explicit in the same
fixture.
The Bug scenario skips (rather than fails) when the project does not define the
Bug work item type.
The checked-in pipeline resolves E2E_WORK_ITEM_ASSIGNEE from a same-named
definition/queue-time variable first, then falls back to
Build.RequestedForEmail.
The two live create-PR → add-reviewers scenarios resolve
EXECUTOR_E2E_REVIEWER the same way: a definition/queue-time override takes
precedence, then Build.RequestedForEmail is used. The reviewer must resolve
to exactly one Azure DevOps identity during setup; otherwise each scenario
skips before creating remote state.
create-pull-request-add-reviewerssubmits the resolved identity GUID through both the allowlist and safe-output payload, exercising productionidentityIdsverification.create-pull-request-add-reviewers-generalsubmits the configured email/name unchanged through both surfaces, exercising productionsearchFilter=Generalexact matching. It skips when the configured value is itself a GUID.
Each scenario uses its own temporary ID, source branch, patch, and PR; each request contains exactly one reviewer. Both verify the structured executor result and live PR membership against the setup-resolved identity GUID, and retain failure-safe PR/branch/checkout cleanup.
Coverage note. The signal scenarios (
noop,missing-tool,missing-data,report-incomplete) were previously exercised only by now-deleted per-tool agentic smoke pipelines. Adding them here closes the coverage gap while keeping the test deterministic.
The signal safe-outputs have no ADO write path of their own: ado-aw execute
only records them in safe-outputs-executed.ndjson. Their user-visible effect
is produced one job later by the Conclusion job, which reads that manifest
and files (or appends to) an Azure DevOps work item per signal — see
docs/conclusion.md.
These scenarios extend the harness past Stage 3: after ado-aw execute
succeeds, the runner's postExecute phase runs the real compiled
conclusion.js against the manifest that run just wrote, with the same flat
AW_* env contract the compiler emits. The work item is then asserted (and
deleted) through the ADO REST API.
| Scenario id | Signal | What it proves |
|---|---|---|
conclusion-noop |
noop |
a work item is created with the configured title, type and tags, and its description carries the rendered noop report plus the conclusion stats block |
conclusion-missing-tool |
missing-tool |
same for missing-tool, including the reported tool name; a second conclusion run over the same manifest appends one comment instead of filing a duplicate (title deduplication) |
conclusion-missing-data |
missing-data |
same for missing-data, including the reported data type and reason |
conclusion-report-as-work-item-false |
noop |
the per-tool report-as-work-item: false opt-out files nothing |
Each scenario uses a title unique to the build
([ado-aw-e2e conclusion] ado-aw-det-<buildId>-<scenario>) so concurrent runs
never dedup into each other's work item, and deletes it in cleanup.
The bundle is a build artifact, not a checked-in file: the scenarios read its
path from EXECUTOR_E2E_CONCLUSION_BUNDLE and skip when that is unset or
points at a missing file. The pipeline builds it with npm run build:conclusion alongside the harness.
create-github-issue and set-github-issue-type had zero runtime
coverage before these scenarios: their only proof was a wiremock unit test,
and the last thing exercising create-github-issue end to end
(smoke-failure-reporter) was removed by the smoke-suite rework.
| Scenario id | Tool | What it proves |
|---|---|---|
create-github-issue |
create-github-issue |
final title is title-prefix + the agent title; the body carries the agent text and the <!-- ado-aw --> traceability footer; config-injected static labels merge with allowed agent labels |
create-github-issue-label-denied |
create-github-issue |
allowed-labels is default-deny — an agent label outside the allowlist is rejected and no issue is filed |
set-github-issue-type |
set-github-issue-type |
a native issue type is applied to an existing issue |
set-github-issue-type-clear |
set-github-issue-type |
the documented issue_type: "" clear operation |
create-github-issue-temporary-id-handoff |
set-github-issue-type (with create-github-issue staged ahead of it) |
the same-run temporary_id handoff |
create-github-issue may mint a temporary_id, and
set-github-issue-type.issue_number accepts either a real number or that id.
The registry backing this (ExecutionContext::resolved_github_issues) is an
in-process Arc<Mutex<HashMap<…>>> that is never persisted, so the handoff is
only observable inside a single ado-aw execute invocation.
That matches production — a SafeOutputs job runs one ado-aw execute over the
whole safe_outputs.ndjson — but every other scenario here runs one entry per
invocation. The handoff scenario therefore uses the harness's priorEntries
hook (see Scenario.priorEntries in scripts/ado-script/src/executor-e2e/scenario.ts)
to stage create-github-issue as an extra NDJSON line ahead of its own, in the
same executor process. The assertion is that the set-github-issue-type record
reports the issue number create-github-issue actually filed.
Why this can't be split across jobs. Because the registry is per-process, putting
require-approvalon only one of the two tools would split Stage 3 into twoado-aw executeprocesses (SafeOutputsandSafeOutputs_Reviewed), and atemporary_idminted in one could not resolve in the other. The compiler rejects that configuration up front —validate_github_issue_outputs_configinsrc/compile/common.rsrequires both tools to have the same effectiverequire-approvalsetting, so the section-level default and a per-tool override are both accounted for.
Adding this coverage immediately found a real defect in Stage 3 config
handling, now fixed in ExecutionContext::get_tool_config
(src/safe_outputs/result.rs). It is recorded here because it is exactly the
class of bug the wiremock unit tests structurally could not see.
Stage 3 injects synthetic staged and require-approval keys into every
tool config (src/main.rs for --source; src/compile/custom_tools.rs for the
compiler-generated --resolved-config that production uses).
CreateGithubIssueConfig and SetGithubIssueTypeConfig are the only
safe-output configs declared #[serde(deny_unknown_fields)], and neither
declares a staged field — so deserialization failed, get_tool_config
swallowed the error via .ok().unwrap_or_default(), and the operator config
was silently replaced with Default::default().
Observable effects: target-repo ignored (Stage 3 failed outright on
non-GitHub-backed ADO builds), title-prefix never applied, static
labels/assignees dropped, allowed-labels emptied so default-deny rejected
every agent label, require-temporary-id unenforced, and
set-github-issue-type.allowed never gating anything — the last of which
failed open.
The unit tests missed it because they build an ExecutionContext directly with
a config map that has no staged key, i.e. a shape that never occurs in
production. The fix strips both orchestration keys and logs a warning instead of
silently defaulting; result.rs carries regression tests asserting an operator
config survives the injected keys.
Note that create-github-issue-label-denied deliberately matches only the
labels not in allowed-labels message. The alternative message
(no allowed-labels configured) is precisely what the executor emitted when the
config was dropped, so accepting both would have let the scenario pass either
way — the failure mode this suite exists to prevent.
GitHub has no REST endpoint to delete an issue. These scenarios are the only
ones in the suite that cannot tear down completely: cleanup() closes each
issue as not_planned instead.
Every scratch issue title embeds the standard ado-aw-det-$(Build.BuildId)-<id>
marker, so anything a cleanup misses is findable with a single search on the
scratch repository. Cleanup also does not depend solely on state captured in
assert() — when the executor filed an issue but the record came back
non-succeeded, assert() never runs, so cleanup falls back to an exact-title
search on that marker.
Because issues accumulate (closed, never deleted), point these scenarios at a scratch repository, not a canonical one.
| Variable | Meaning |
|---|---|
EXECUTOR_E2E_GITHUB_TOKEN |
Reused from failure-issue filing. It must now also carry Issues: write on the scratch repository, because these scenarios create, mutate, and close issues. |
EXECUTOR_E2E_SCENARIO_ISSUE_REPO |
Optional. owner/repo for scratch issues; falls back to EXECUTOR_E2E_ISSUE_REPO. Set it to keep scenario issues away from the failure-report repository. |
E2E_GITHUB_ISSUE_TYPE |
Optional. Forces a native issue-type name for environments where the token cannot read org metadata but the type is known to exist. |
EXECUTOR_E2E_CROSS_ORG_ORGANIZATION |
Target Azure DevOps organization name. |
EXECUTOR_E2E_CROSS_ORG_PROJECT |
Target project containing the scratch repository. |
EXECUTOR_E2E_CROSS_ORG_REPOSITORY |
Scratch repository used for branch, tag, and PR writes. |
EXECUTOR_E2E_CROSS_ORG_ENDPOINT |
Azure DevOps WIF service-connection name used by the source fixture. |
EXECUTOR_E2E_CROSS_ORG_TOKEN |
Secret, short-lived Entra bearer minted from that connection. |
These scenarios are registered but skip unless all five variables above are present. The target must be in the same Entra tenant, and the service connection's identity must be added to both Azure DevOps organizations with Read, Contribute, Create branch/tag, and Create pull request permissions on the scratch repository.
The harness:
- uses Bearer authentication for target-org setup/assert/cleanup;
- renders
repos.organization, the checkoutendpoint, and expandedpermissions.writewithconnection-type: azureDevOps; - runs the real Stage 3 executor with the target token;
- creates and deletes a branch and annotated tag;
- clones the target repository, generates a patch, creates and abandons a PR, deletes its source branch, and removes the local checkout.
The registered AgentPlayground pipeline does not currently mint this token:
creating or authorizing the Azure DevOps WIF service connection, adding its
identity to the target organization, and granting repository ACLs are
administrative side effects that must be provisioned separately. After that,
add an AzureCLI@3 token-mint step and map its secret output to
EXECUTOR_E2E_CROSS_ORG_TOKEN; the scenarios then become mandatory rather
than skipped.
There is deliberately no default repo for these scenarios: when neither
variable is set they skip rather than filing scratch issues onto
githubnext/ado-aw.
Some scenarios need optional infrastructure and skip (rather than fail) when it is not available:
queue-build— needs a target pipeline id inE2E_QUEUE_PIPELINE_ID.- The four
conclusion-*scenarios — need a compiledconclusion.jsinEXECUTOR_E2E_CONCLUSION_BUNDLE. create-wiki-page/update-wiki-page— need a wiki in the project. The harness auto-discovers the first wiki; setE2E_WIKI_NAMEto force one. When no wiki exists, both skip.add-build-tag,upload-build-attachment,upload-pipeline-artifact— need a real current build (BUILD_BUILDID); they skip when run outside a pipeline.- All five GitHub issue scenarios — need
EXECUTOR_E2E_GITHUB_TOKENand a scratch repo (EXECUTOR_E2E_SCENARIO_ISSUE_REPOorEXECUTOR_E2E_ISSUE_REPO). They also skip when the token authenticates but cannot write issues on that repo, with the harness's auth diagnosis attached to the skip reason. set-github-issue-typeand, on the named-type path, the handoff — need a native issue type to exist. Issue types are an organisation-level construct (GET /orgs/{org}/issue-types) with no user-account equivalent, so a user-owned scratch repo can never expose one and these skip permanently there. The handoff scenario stays runnable by falling back to the documentedissue_type: ""clear operation;set-github-issue-type-clearskips only if GitHub rejects the clear outright.
Every object a scenario creates is prefixed
ado-aw-det-$(Build.BuildId)-<tool>. Cleanup runs unconditionally after each
scenario; the smoke-suite janitor (which prunes ado-aw-* artifacts) is the
backstop for anything a cleanup misses.
You need a write-capable ADO token (PAT) and a checkout-built binary:
cargo build --release --bin ado-aw
cd scripts/ado-script && npm ci && npm run build:executor-e2e && npm run build:conclusion && cd ../..
export SYSTEM_COLLECTIONURI="https://dev.azure.com/msazuresphere/"
export SYSTEM_TEAMPROJECT="AgentPlayground"
export SYSTEM_ACCESSTOKEN="<write-capable-PAT>"
export EXECUTOR_E2E_ADO_AW_BIN="$PWD/target/release/ado-aw"
export EXECUTOR_E2E_ADO_REPO="agent-definitions"
# Enables the conclusion work-item scenarios (they skip when unset):
export EXECUTOR_E2E_CONCLUSION_BUNDLE="$PWD/scripts/ado-script/conclusion.js"
# Optional:
# export EXECUTOR_E2E_GITHUB_TOKEN="<fine-grained PAT: Issues rw on jamesadevine/ado-aw-issues>"
# export EXECUTOR_E2E_ISSUE_REPO="jamesadevine/ado-aw-issues"
# Optional: keep GitHub issue scenario scratch issues out of the failure-report repo
# export EXECUTOR_E2E_SCENARIO_ISSUE_REPO="<owner>/<scratch-repo>"
# Optional: force a native issue-type name (org-owned repos only)
# export E2E_GITHUB_ISSUE_TYPE="Bug"
# export E2E_QUEUE_PIPELINE_ID="<noop-target pipeline id>"
# export E2E_WORK_ITEM_ASSIGNEE="<ADO user email, UPN, or display name>"
# Optional timeout tuning (milliseconds) for slow environments:
# export EXECUTOR_E2E_REST_TIMEOUT_MS=30000 # per ADO REST call (default 30000)
# export EXECUTOR_E2E_EXECUTE_TIMEOUT_MS=600000 # per `ado-aw execute` run (default 600000)
# export EXECUTOR_E2E_GIT_TIMEOUT_MS=300000 # per git subprocess call (default 300000)
node scripts/ado-script/test-bin/executor-e2e.jsBuild-scoped scenarios (add-build-tag, uploads) skip locally because there is
no current build. The harness exits non-zero if any scenario fails.
In https://dev.azure.com/msazuresphere/AgentPlayground:
Current registration: definition
2550in\executor-e2e, withE2E_QUEUE_PIPELINE_IDpointing at thequeue-targetdefinition registered fromqueue-target.yml.
-
Register the pipeline. New pipeline → GitHub through the
githubnextservice connection → existing YAML →tests/executor-e2e/azure-pipelines.yml. Place it in a\executor-e2efolder and skip the first run until variables are configured. In the live pull-request trigger settings, disable builds from forks and disable fork access to secrets/full tokens. Definition2550is audited bytests/smoke/trigger-policy.json. -
Grant the principal behind
agent-playground-writewrite access on theagent-definitionsrepo (Contribute, Create branch, Contribute to PRs) and on Build (add tags). The YAML maps its AAD token toSYSTEM_ACCESSTOKENthroughSC_WRITE_TOKEN. Seedocs/safe-output-permissions.mdif Stage 3 hits 401/403. -
Set the GitHub PAT secret on this pipeline only:
ado-aw secrets set EXECUTOR_E2E_GITHUB_TOKEN ` --org msazuresphere --project AgentPlayground ` --definition-ids <executor-e2e-pipeline-id> ` --value <fine-grained-pat-Issues-rw-on-jamesadevine/ado-aw-issues>
Do not place this token in a shared variable group.
The GitHub issue scenarios reuse this same token, so it needs Issues: write on the scratch repository — not just enough to file a failure report. When it can authenticate but not write, those scenarios skip with a diagnosis rather than failing the build.
-
Set
EXECUTOR_E2E_ISSUE_REPO=jamesadevine/ado-aw-issues. Confirm the target repository hasexecutor-e2e-failureandpipeline-failurelabels. (Optional) SetEXECUTOR_E2E_SCENARIO_ISSUE_REPOto a separate scratch repository so the GitHub issue scenarios do not accumulate closed issues alongside the failure reports.set-github-issue-typeand the named-type half of the handoff need a native issue type, which is an organisation-level construct. On a user-owned repo such asjamesadevine/ado-aw-issuesthey will skip on every run; pointEXECUTOR_E2E_SCENARIO_ISSUE_REPOat an org-owned repo with issue types defined to enable them. -
Set
E2E_QUEUE_PIPELINE_IDto thequeue-targetdefinition ID (registerqueue-target.ymlif it does not exist yet). It is a permanent, trigger-free, non-agentic pipeline that exists only to be queued, so this scenario no longer depends on the smoke suite's registration lifecycle. (Optional) SetE2E_WIKI_NAMEto enable the wiki scenarios. -
Trigger one manual run to seed the schedule.