Skip to content

Implement protocol tests: schema, validator, checker, and skill - #45

Merged
lwaldron merged 8 commits into
mainfrom
protocol-tests-tooling
Oct 23, 2026
Merged

lwaldron merged 8 commits into
mainfrom
protocol-tests-tooling

Conversation

@lwaldron

Copy link
Copy Markdown
Member

The tooling ADR 0016 (#43) describes. Closes #3.

  • test.yaml schema as Pydantic models; the validator applies it to tests/ under every protocol, so content-repository CI checks tests with no workflow change.
  • scripts/check_test_result.py: --shape prints the result shape (values and nested keys withheld, table headers shown unless withheld) and writes source.yaml; the plain call compares each test with a result subdirectory, writes run.yaml, and exits 0, 1, or 2 as the standard says.
  • skills/test-protocol/SKILL.md: the executor side.
  • Fixtures and 18 pytest cases: schema in memory, validator on disk, checker end to end in a throwaway git repository.
  • The ADR moves to Accepted and the proposed notice leaves the standard. One wording fix in the run.yaml example: result is pass, fail, or not_run.

Next: reformat the eight fixtures in waldronlab/agent-protocols to this schema, then run the skill on them for the first run record.

Drafted with Claude Code.

ADR 0016 is accepted; this is the tooling it describes.

- scripts/models.py: ProtocolTest and FileRef, the test.yaml schema.
- scripts/validate_protocol.py: validates every tests/<name>/ under a
  protocol, so CI in content repositories checks tests through the
  existing action.
- scripts/check_test_result.py: --shape prints the result shape with
  values and nested keys withheld and snapshots the source; the plain
  call compares results and writes run.yaml. It is the only thing that
  decides pass or fail.
- skills/test-protocol: the executor side of the result contract.
- Fixtures: valid/with-tests, eight invalid/test-* cases, and a worked
  example in the template. 18 new pytest cases.
- Docs: the proposed notice is gone, README and DEVELOPMENT.md updated.

Drafted with Claude Code.
Copilot AI balanced review requested due to automatic review settings October 20, 2026 18:22

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

False-pass cases, unchecked cached expectations, and validation and error-handling defects undermine reliable test results.

5 open issues
What changed in this PR

Implements ADR 0016’s protocol-testing framework, connecting test validation to existing CI and adding tools for checking agent execution.

Changes:

  • Adds test schemas, validation, result comparison, and run records.
  • Adds the test-protocol skill, template examples, and automated coverage.
  • Marks ADR 0016 accepted and updates contributor documentation.
File Description
tests/​test_protocol_tests.py Covers schema, validation, and checker behavior.
tests/​fixtures/​valid/​with-tests/​protocols/​example-protocol/​tests/​orientation/​test.yaml Defines diagnostic outcomes.
tests/​fixtures/​valid/​with-tests/​protocols/​example-protocol/​tests/​orientation/​retained.csv Supplies expected diagnostic output.
tests/​fixtures/​valid/​with-tests/​protocols/​example-protocol/​tests/​orientation/​input.csv Supplies transposed input.
tests/​fixtures/​valid/​with-tests/​protocols/​example-protocol/​tests/​basic/​test.yaml Defines exact comparisons.
tests/​fixtures/​valid/​with-tests/​protocols/​example-protocol/​tests/​basic/​retained.csv Supplies expected filtered output.
tests/​fixtures/​valid/​with-tests/​protocols/​example-protocol/​tests/​basic/​input.csv Supplies filtering input.
tests/​fixtures/​valid/​with-tests/​protocols/​example-protocol/​protocol.md Hosts valid test fixtures.
tests/​fixtures/​invalid/​test-stray-file/​protocols/​example-protocol/​tests/​stray/​test.yaml Supplies an otherwise valid test.
tests/​fixtures/​invalid/​test-stray-file/​protocols/​example-protocol/​tests/​README.md Exercises stray-file rejection.
tests/​fixtures/​invalid/​test-stray-file/​protocols/​example-protocol/​protocol.md Hosts stray-file fixture.
tests/​fixtures/​invalid/​test-stray-file/​expected.txt Specifies expected diagnostic.
tests/​fixtures/​invalid/​test-reserved-key/​protocols/​example-protocol/​tests/​reserved/​test.yaml Exercises reserved-key rejection.
tests/​fixtures/​invalid/​test-reserved-key/​protocols/​example-protocol/​protocol.md Hosts reserved-key fixture.
tests/​fixtures/​invalid/​test-reserved-key/​expected.txt Specifies expected diagnostic.
tests/​fixtures/​invalid/​test-non-csv-table/​protocols/​example-protocol/​tests/​tsv/​test.yaml Exercises unsupported table format.
tests/​fixtures/​invalid/​test-non-csv-table/​protocols/​example-protocol/​tests/​tsv/​out.tsv Supplies unsupported table.
tests/​fixtures/​invalid/​test-non-csv-table/​protocols/​example-protocol/​protocol.md Hosts table-format fixture.
tests/​fixtures/​invalid/​test-non-csv-table/​expected.txt Specifies expected diagnostic.
tests/​fixtures/​invalid/​test-no-evidence/​protocols/​example-protocol/​tests/​label-only/​test.yaml Exercises missing outcome evidence.
tests/​fixtures/​invalid/​test-no-evidence/​protocols/​example-protocol/​protocol.md Hosts missing-evidence fixture.
tests/​fixtures/​invalid/​test-no-evidence/​expected.txt Specifies expected diagnostic.
tests/​fixtures/​invalid/​test-name-mismatch/​protocols/​example-protocol/​tests/​wrong-dir/​test.yaml Exercises directory/name mismatch.
tests/​fixtures/​invalid/​test-name-mismatch/​protocols/​example-protocol/​protocol.md Hosts name-mismatch fixture.
tests/​fixtures/​invalid/​test-name-mismatch/​expected.txt Specifies expected diagnostic.
tests/​fixtures/​invalid/​test-missing-file/​protocols/​example-protocol/​tests/​missing/​test.yaml References an absent input.
tests/​fixtures/​invalid/​test-missing-file/​protocols/​example-protocol/​protocol.md Hosts missing-file fixture.
tests/​fixtures/​invalid/​test-missing-file/​expected.txt Specifies expected diagnostic.
tests/​fixtures/​invalid/​test-empty-expected/​protocols/​example-protocol/​tests/​empty/​test.yaml Exercises empty expectations.
tests/​fixtures/​invalid/​test-empty-expected/​protocols/​example-protocol/​protocol.md Hosts empty-expectations fixture.
tests/​fixtures/​invalid/​test-empty-expected/​expected.txt Specifies expected diagnostic.
tests/​fixtures/​invalid/​test-bad-file-ref/​protocols/​example-protocol/​tests/​both/​test.yaml Exercises conflicting file-reference forms.
tests/​fixtures/​invalid/​test-bad-file-ref/​protocols/​example-protocol/​tests/​both/​input.csv Supplies fixture input file.
tests/​fixtures/​invalid/​test-bad-file-ref/​protocols/​example-protocol/​protocol.md Hosts invalid-reference fixture.
tests/​fixtures/​invalid/​test-bad-file-ref/​expected.txt Specifies expected diagnostic.
template/​protocols/​example-protocol/​tests/​example-case/​test.yaml Demonstrates test configuration.
template/​protocols/​example-protocol/​tests/​example-case/​input.csv Supplies example input.
template/​protocols/​example-protocol/​tests/​example-case/​expected.csv Supplies example expected output.
template/​CONTRIBUTING.md Explains adding protocol tests.
skills/​test-protocol/​SKILL.md Defines executor workflow.
skills/​README.md Lists the testing skill.
scripts/​validate_protocol.py Validates protocol test directories.
scripts/​models.py Defines test schema models.
scripts/​check_test_result.py Generates shapes, compares results, and records runs.
README.md Describes implemented testing support.
PROTOCOL_STANDARD.md Removes proposed notice and clarifies run status.
docs/​adr/​README.md Removes ADR 0016’s proposed label.
docs/​adr/​0016-protocol-tests.md Marks the decision accepted.
DEVELOPMENT.md Documents test tooling and coverage.

Review effort: Balanced

Comment thread scripts/check_test_result.py Resolved
Comment thread scripts/check_test_result.py Resolved
Comment thread scripts/check_test_result.py Resolved
Comment thread scripts/models.py Resolved
Comment thread scripts/models.py Resolved
… exit 2 on malformed input

After Copilot review. Also stop treating a section whose parameter is
named path or url as a file slot: file detection starts at each
section's values.

Drafted with Claude Code.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved comparison, input-integrity, and contract-handling defects affect the reliability of test results and run records.

1 open issue
8 previously missed issues

In code that hasn't changed since last review

Medium severity Preserve nested file descriptors in the generated shape

scripts/​check_test_result.py:171

The schema accepts file references inside mappings and lists, and comparison recurses into them, but the shape drops their file format and header. For example, tables: {sample: {path: expected.csv}} becomes tables: {'<key>': 'mapping'}. An executor following the shape cannot discover the required CSV header without reading withheld expectations. Preserve nested file descriptors while masking data keys and values, or reject nested file references during validation.

Medium severity Check forbidden results paths before creating directories

scripts/​check_test_result.py:205

This creates the directory before checking whether its location is forbidden. Passing <protocol>/tests/results leaves an empty directory behind after rejection. Subsequent validation and --shape calls then fail because that directory has no test.yaml, even after choosing an external results directory. Check containment before creating anything.

Medium severity Validate CSV row widths before comparison

scripts/​check_test_result.py:244

Rows are never checked against the header width. Two files containing id,x followed by A incorrectly pass, while two containing A,1,2 raise an uncaught IndexError in compare_table and prevent the run record. Validate every row against the header width when reading a table. Treat malformed reference data as a contract error and malformed actual data as a per-test failure.

Medium severity Preserve numeric precision when comparing CSV values

scripts/​check_test_result.py:252

Parsing CSV cells as binary floats can produce false passes at zero tolerance: 9007199254740993 and 9007199254740992 become the same float before comparison. Preserve numeric precision through both parsing and comparison, while retaining tolerance handling and rejecting non-finite matches. Add a regression test for adjacent integers above the exact range of a float.

Medium severity Record empty actual CSVs as per-test failures

scripts/​check_test_result.py:264

A zero-byte executor CSV raises ContractError here, which escapes to main, returns exit 2, and prevents run.yaml from being written for any test. Invalid actual output should instead be recorded as a per-test failure, just like a missing result file. Catch table-format errors from the actual file separately, while keeping invalid reference data a contract error.

Medium severity Reject result symlinks resolving outside the results directory

scripts/​check_test_result.py:301

The lexical path check accepts a result file that is a symlink to an expected CSV outside the results directory. The checker then compares the expected file against itself and reports PASS without an independently produced output. Resolve the file path and require it to remain beneath the test's results subdirectory before reading it.

Medium severity Require UTC timestamps in run records

scripts/​check_test_result.py:410

Timestamp validation accepts date-only and timezone-free strings, and _stamp copies strings unchanged. As a result, both '2026-10-20' and a quoted timestamp with +02:00 can enter run.yaml even though the standard specifies UTC timestamps. Require timezone-aware UTC values for both strings and YAML datetime objects, or normalize timezone-aware strings as well as datetime objects before writing the record.

Medium severity Verify remote input digests before execution

skills/​test-protocol/​SKILL.md:64

Remote inputs are passed unchanged in the shape, and the checker's verified downloader only handles expected outputs. These steps never require verifying an input's SHA-256, so execution on changed remote bytes can still receive PASS. The standard requires a digest mismatch to fail the test. Download remote inputs outside the protocol directory, verify them before execution, and record a failed test rather than using mismatched bytes.

4 resolved since last review

Review effort: Balanced

File slots are direct values of a section; containment is checked
before the results directory is created; CSV rows must match the header
width, malformed expected tables are a contract error and malformed
result tables a per-test failure; cells compare as Decimals; result
files must resolve inside the result directory; timestamps must carry a
timezone and are written as UTC; --shape downloads and verifies remote
inputs before anything runs.

Drafted with Claude Code.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved checker issues can falsely pass results, miss source changes, and mishandle paths or contract errors.

1 open issue
6 previously missed issues

In code that hasn't changed since last review

Medium severity Malformed frontmatter bypasses contract-error handling

scripts/​check_test_result.py:85

Malformed protocol frontmatter bypasses the contract-error handling: extract_frontmatter() wraps YAML parsing errors in a plain Exception, which main() does not catch. Both commands then terminate with a traceback and exit status 1, reserved for failed comparisons, instead of status 2. Translate the parsing exception into ContractError here.

Medium severity Symlinked test directories bypass source change detection

scripts/​check_test_result.py:101

Both test loaders accept symlinked test directories, but rglob does not descend into them here. Changing test.yaml inside such a directory leaves the source digest unchanged, so checking can use different expectations from those present at --shape without reporting a source change. Reject symlinked test directories consistently, or include their contents in a cycle-safe snapshot traversal. Add a regression case that edits a file through the directory symlink.

Medium severity CSV reader accepts unterminated quoted fields

scripts/​check_test_result.py:264

The default CSV reader accepts unterminated quoted fields. For example, id,x\n001,"3 parses like the valid table id,x\n001,3\n, allowing a truncated result to receive PASS. Enable strict parsing so malformed result tables fail and malformed expected tables reach the existing contract-error handling.

Medium severity Mixed integer and float comparison loses precision

scripts/​check_test_result.py:385

At tolerance 0, expected 9007199254740993 incorrectly matches actual 9007199254740992.0: mixed integer/float subtraction rounds the expected integer before computing the difference. Convert the operands to Decimal before comparing, using the existing _within helper, so unequal counts cannot receive PASS. Add a regression case for mixed numeric types beyond float precision.

Medium severity Non-mapping source YAML causes uncaught AttributeError

scripts/​check_test_result.py:545

If source.yaml contains a non-empty YAML list or scalar, loading succeeds but source.get() raises an uncaught AttributeError. The CLI then exits 1, which denotes a failed comparison, instead of contract-error status 2. Require a mapping here, as load_executor does.

Medium severity Symlinked result directories allow stale outputs to pass

scripts/​check_test_result.py:560

A test's result directory can be a symlink to a previous run's directory. is_dir() follows it, and the file-level containment check trusts that external directory as its root, allowing old outputs to receive PASS in this run. Check each resolved test directory against results_dir before reading its results.

1 resolved since last review

Review effort: Balanced

Comment thread scripts/check_test_result.py Resolved
Malformed frontmatter and a non-mapping source.yaml are contract
errors; symlinks under the protocol directory or in result
subdirectories are refused; CSV parsing is strict; mixed int and float
values compare as Decimals; a downloaded input is handed over as an
absolute path.

Drafted with Claude Code.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved comparison, validation, and provenance issues can produce incorrect results or misleading run records.

No open issues 🎉

6 previously missed issues

In code that hasn't changed since last review

Medium severity Ignored inputs are incorrectly attributed to a committed revision

scripts/​check_test_result.py:132

git status omits ignored files, but compute_digest() includes every file under tests/. If a test uses an ignored, untracked input, the run records uncommitted: false and a commit that does not contain the executed data. Check for ignored files among the hashed sources before assigning a commit, and add a regression test for an ignored input.

Medium severity Decimal tolerance comparison rounds boundary differences incorrectly

scripts/​check_test_result.py:304

Decimal subtraction and abs() use the default 28-digit context, so this can accept a difference outside the tolerance. A CSV value of 1.0000000000000000000000000001e-12 compared with 0 passes with tolerance 1e-12 because the difference rounds down. Use exact arithmetic for finite comparisons and add a boundary regression test.

Medium severity Symlinked result manifests bypass result validation

scripts/​check_test_result.py:419

A real result directory can contain a result.yaml symlink to a previous run. Both is_file() and read_text() follow it, so scalar-only tests or diagnostic refusals can pass using stale results despite the directory-level symlink guard. Reject symlinked result manifests before reading them, and cover this alongside the directory-symlink test.

Medium severity HTTPS URL validation accepts invalid hostnames and ports

scripts/​models.py:187

The prefix check accepts malformed URLs such as https:///input.csv and https://example.org:invalid/input.csv. These pass CI's schema validation but cannot be downloaded when the test runs. Validate the hostname and port as well as the HTTPS scheme.

Medium severity URL query slashes corrupt remote filename extraction

scripts/​models.py:196

Splitting on / before removing the query misidentifies valid remote filenames when a query parameter contains a slash. For example, https://example.org/expected.csv?token=a/b gets the name b, so the schema rejects this CSV reference. Extract the filename from the parsed URL path instead, and add coverage for query parameters containing slashes.

Medium severity Unsupported YAML expectation types are not rejected

scripts/​models.py:230

Non-file expectations are not checked for types the checker supports. For example, YAML loads expected_outputs: {day: 2026-10-18} as a date; it passes validation, but compare_value() rejects even an identical result as unsupported. Recursively reject unsupported expectation values for both exact and diagnostic tests, so malformed tests fail CI rather than producing unavoidable execution failures. Authors can quote dates to compare them as strings.

1 resolved since last review

Review effort: Balanced

Ignored files under tests/ make a run uncommitted; tolerance
comparisons use exact rational arithmetic; a symlinked result.yaml
fails; URLs are parsed rather than prefix-matched; expectations may
only hold types the checker can compare.

Drafted with Claude Code.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved validation, source-provenance, and error-handling gaps affect checker reliability.

1 open issue
3 previously missed issues

In code that hasn't changed since last review

Medium severity Stray non-directory test entries are silently ignored

scripts/​check_test_result.py:65

Non-directory entries are silently ignored here, while validate_tests_dir() rejects them. For example, the new invalid/test-stray-file fixture can pass this loader and produce a passing run record when its expected result is supplied, despite failing CI validation. Reject non-hidden stray files consistently with the validator instead of skipping them.

Medium severity Symlinked tests root is not rejected

scripts/​check_test_result.py:104

The symlink check covers descendants but not tests/ itself. A symlinked root is traversed without any ordinary child being identified as a symlink. If Git tracks that root link, a clean checkout can therefore attribute external test data to a commit that does not contain it. Reject a symlinked tests_dir before traversal, as already done for protocol.md and nested entries.

Medium severity Invalid download URLs cause uncaught CLI errors

scripts/​check_test_result.py:267

A URL such as https://example.org/my input.csv passes FileRef validation but raises http.client.InvalidURL when the request is constructed. An unencoded Unicode path similarly raises UnicodeEncodeError. Neither is caught by main, so the CLI emits a traceback and exits 1, which is reserved for failed comparisons, instead of contract-error status 2. Translate these download errors into ContractError.

Review effort: Balanced

Comment thread scripts/models.py Resolved
…d errors are contract errors

Also stop a top-level key named path from shielding its siblings from
type validation.

Drafted with Claude Code.
It is an HTTPException, not a ValueError. The previous commit shipped
with this test failing because the pipeline hid the pytest exit status.

Drafted with Claude Code.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Download error handling and diagnostic validation defects can produce incorrect checker outcomes.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Reject overlapping expected outputs and absent keys

scripts/​models.py:327

An outcome with expected_outputs: {n: 1} and expected_absent: [n] passes schema validation, but the checker fails it whether n is present or absent. Reject overlapping keys here so contradictory test definitions are reported as invalid tests rather than executor failures.

1 resolved since last review

Review effort: Balanced

Comment thread scripts/check_test_result.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved checker and validation defects affect source attribution, answer withholding, and valid test execution.

No open issues 🎉

4 previously missed issues

In code that hasn't changed since last review

Medium severity Require the canonical tests directory for digest computation

scripts/​check_test_result.py:61

The CLI accepts <protocol>/alternate-tests, but compute_digest() always hashes <protocol>/tests. Changes to the selected tests after --shape therefore go undetected, and run.yaml can attribute modified expectations to the original snapshot. Require the canonical tests/ directory, checking its normalized name so valid . arguments are not rejected.

Medium severity Reject unterminated CSV headers with a ContractError

scripts/​check_test_result.py:180

An unterminated quote in the CSV header makes this non-strict reader absorb subsequent expected data rows into the header. --shape then exposes those answers to the executor instead of rejecting the malformed test. Use the strict table reader and translate its TableError into a ContractError before printing the shape.

Medium severity Normalize TESTS_DIR before deriving the protocol parent

scripts/​check_test_result.py:646

Passing . as TESTS_DIR from inside a protocol's tests/ directory fails: Path('.').parent is still ., so both commands look for protocol.md inside tests/ and exit 2. Normalize the argument before deriving its parent, while retaining the explicit rejection of a symlinked tests root.

Medium severity Reject symlinks in test roots and descendant files

scripts/​validate_protocol.py:68

CI accepts symlinked test directories and local files because these checks follow symlinks. For example, a symlinked tests/basic/input.csv passes validation, but the checker's compute_digest() rejects it, so --shape cannot start that CI-green test. Reject symlinks during validation too, including the tests/ root and all descendants.

1 resolved since last review

Review effort: Balanced

@lwaldron
lwaldron merged commit 2dcc635 into main Oct 23, 2026
3 checks passed
@lwaldron
lwaldron deleted the protocol-tests-tooling branch October 23, 2026 13:19
ohih8 pushed a commit to ohih8/agent-protocols that referenced this pull request Oct 27, 2026
…hema

Files are {path: ...}, diagnostic fixtures carry their evidence under
accepted_outcomes, and the ad hoc compare_* and intended_* keys are gone.
Expected values are unchanged. Validates against the test schema in
waldronlab/agent-protocol-standard#45.

Drafted with Claude Code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Formal Unit Testing and Benchmarks for Protocols

2 participants