Skip to content

test(conformance): add agent SDK parser suites and run them in the Python SDK - #2713

Merged
nan-yu merged 17 commits into
a2ui-project:mainfrom
nan-yu:agent-conformance
Sep 21, 2026
Merged

nan-yu merged 17 commits into
a2ui-project:mainfrom
nan-yu:agent-conformance

Conversation

@nan-yu

@nan-yu nan-yu commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds conformance suites for the agent SDK blueprint's Parser interface, runs
them against the Python agent SDK, and fixes the defects the runner exposed that
could be fixed without changing what the suites say.

The suites are written against the interface in
blueprints/modules/a2ui_agent.blueprint.md rather than against any one SDK, so
they describe what an implementation of that interface owes a caller. Where the
Python SDK disagrees with a suite, the case is recorded as a known gap with a
one-line reason rather than weakened or deleted.

Changes

Conformance suites

New suites under conformance/agent/, one per format, named after the Parser
call each exercises:

  • express/compiler.yaml, express/decompiler.yaml,
    express/response_parser.yaml, express/prompt_generator.yaml
  • direct_json/compiler.yaml, direct_json/decompiler.yaml,
    direct_json/response_parser.yaml, direct_json/prompt_generator.yaml,
    direct_json/response_streaming.yaml
  • request_processor.yaml, covering catalog resolution and message processing

Case actions are named after the interface methods they call: compile,
decompile, wrap, unwrap, parse_response. Express has no streaming suite
because the format does not stream.

The three existing suites are written against the SDKs' current interface rather
than the blueprint's, so they move to conformance/agent/legacy/ and stay there
until the SDKs move over. Nothing in them changed.

Catalog fixtures move to conformance/test_data/catalogs/ and examples to
conformance/test_data/examples/. Cases name a fixture by path instead of
inlining it, so the same catalog can be shared and diffed. New fixtures cover a
forms catalog, a catalog with a property that refuses data binding, a catalog
with no id, and a malformed catalog.

conformance_schema.json gains the new actions, the expect_present assertion
for cases that pin a field's presence rather than its value, and the argument
shapes the new suites use. All catalog fixtures $ref into common_types.json
rather than redefining protocol common types.

Python agent SDK

tests/conformance/test_conformance.py runs the compiler, decompiler and
response parser suites: 101 cases, of which 69 pass, 31 are recorded as known
gaps, and 1 has no API to call at all. Each gap carries a one-line reason and is
an xfail(strict=True), so fixing one fails the build until its entry is
removed.

Seven Express unit tests are removed. Each was deleted only because every
mechanism it exercised is now pinned by a conformance case that passes today; a
test whose conformance counterpart is a known gap was kept, because it is then
the only executing coverage of that path.

Fixes the runner exposed

A2uiCompilationError descended from Exception, outside the A2uiError
hierarchy the rest of the SDK raises, so a caller could not ask whether a block
was rejected because it could not be read or because the catalog refused what it
named. It now descends from A2uiError and gains two subclasses that also
descend from A2uiParseError and A2uiValidationError. Telling the two apart
needs the compiler to keep them apart, so the Express errors are grouped under
ExpressParseError and ExpressValidationError, and the failures that were
raised as bare ValueErrors join whichever group they belong to.

Parser.parse_response caught everything the compiler raised and re-raised it
as A2uiCompilationError, which discarded the category Direct JSON's reader had
already set. An A2uiError now travels out unchanged, carrying the parts read
before it.

ExpressUndefinedChildError passed an undefined help_message to its base
class and would have raised NameError on construction. Nothing constructs it.

specification/proposals/express/a2ui_express.md showed a standalone function
call compiling to a top-level functionCallId and callFunction, which no
schema in specification/v1_0/json admits. Corrected to the
callRendererFunction envelope.

Impact and risks

An Express compile failure now raises a ValueError subclass where it
previously raised a bare Exception subclass. Existing except Exception and
except A2uiCompilationError handlers are unaffected; the change only widens
what else will catch it.

The known gaps are strict xfails by intent. Fixing any one of them fails the
build until the entry is removed from KNOWN_GAPS, which keeps the list honest.

prompt_generator.yaml and request_processor.yaml are not yet wired into any
runner. The Kotlin harness's three updated path constants in ConformanceTest.kt
follow the fixture moves but were not run.

No renderer, Swift, or web behaviour changes.

Testing

  • agent_sdks/python/a2ui_agent: uv run pytest tests/ gives 662 passed,
    1 skipped, 31 xfailed.
  • agent_sdks/python/a2ui_core: uv run pytest gives 210 passed.
  • Repository root: uv run pytest conformance/tests/ gives 25 passed, which
    validates every suite against conformance_schema.json.
  • ./scripts/fix_format.sh --check passes.
  • Each of the nine commits was checked out in turn and both suites run at that
    point, so the branch bisects cleanly.

Covers the agent SDK described in blueprints/modules/a2ui_agent.blueprint.md:
catalog providers, catalog resolution, catalog transformers, the request
processor, skills, and the two inference formats. Every case is written against
v1.0.

agent/ holds the suites no format decides for itself. agent/direct_json/ and
agent/express/ hold the rest, written in each format's own notation, so an
implementer adding a format can run one folder and know when they are done.
Streaming is the exception: the Express parser does not implement parse_chunk,
so only direct_json has a streaming suite.

The suites for the earlier agent interface move to agent/legacy/, with the
Python and Kotlin harness paths updated. They stay until those SDKs move to the
blueprint interface. Catalog fixtures move to test_data/catalogs/, and prompt
examples are fixture files under test_data/examples/ referenced by path.

conformance_schema.json gains the vocabulary these suites need, and
conformance/README.md documents how to write cases in it. An agent case states
no protocol version of its own: the version comes from the catalog document, or
from what a provider is constructed with.

The blueprint leaves several questions open, and the suites answer them rather
than leave them to each implementation. Each answer is argued in the header of
the file that relies on it, so a reader can see what was decided and why.
The Express proposal showed a standalone call compiling to a top level
functionCallId beside a callFunction. agent_to_renderer.json has no such
message: CallRendererFunctionMessage nests both inside callRendererFunction,
and marks catalogId required on the call. A payload in the shape the proposal
showed fails validation.

Both compilers implement the proposal faithfully, so both emit an invalid
message. The normative specification/v1_0/docs/a2ui_protocol.md had the nesting
right all along, which is why this went unnoticed. Fixing the proposal first so
the compilers have something correct to follow.
The vocabulary said unwrap_response, wrap_response, compile_payload and
decompile_payload. The blueprint's Parser declares unwrap, wrap, compile and
decompile. parse_response and parse_chunk already matched, so four of the six
names differed from the method each one tests, for no reason beyond how they
were first written.

Renames the actions and the test definitions that go with them, so a reader
moving between a case and the interface it exercises does not have to translate.

The three remaining agent actions cannot follow: the action enum is flat across
core/, agent/ and extensions/, and load, generate_prompt and transform are
either taken or say nothing on their own. They keep their qualified names.

test_unwrap_response_without_tags_is_one_text_part keeps its name. That is
English rather than the old action, and its Express counterpart is spelled the
same way.
response_parser.yaml held five different calls in one file, and the Express one
had grown to 74 cases behind a 135 line header covering all of them. Splits each
format's suite into three, one per call:

  response_parser.yaml  where a payload begins and ends: unwrap, wrap,
                        parse_response
  compiler.yaml         what a payload means
  decompiler.yaml       how messages are written back

The blueprint declares all five on one Parser interface, so this is three files
for one interface. They are split by call because each has rules of its own, and
the Express compilation rules alone run to a page. Each header was rewritten
rather than copied, so a rule sits with the cases that depend on it and there is
no second copy to drift: compiler.yaml owns the error categories, the Express id
scheme, the grammar facts and the positional argument order, and decompiler.yaml
owns only the two rules that belong to its direction.

Also ports the behaviour the Python Express unit tests covered and the suites
did not. 30 cases: the notation the grammar admits that no case exercised
(comments, semicolons, trailing commas, a statement spanning lines, a bare $, a
string holding an equals sign), two surfaces in one block, several checks in one
list, seven compile errors, and seven decompile cases below the message level,
where coverage was thinnest.

Two behaviours the unit tests assert were deliberately not ported. ?and([...])
is a Python extension the Express specification does not describe, so
test_compile_express_several_checks_in_one_list covers the spec-backed list form
instead. An unregistered function compiling rather than failing contradicts
catalog validation, so the suite rules the other way.

conformance_schema.json gains expect_present for a value the format must supply
but the protocol does not dictate, such as the message on a check written
without one. Pinning the wording would freeze one implementation's English into
the protocol.

Case count is unchanged across the split: 322 agent cases before, 322 after.
The runner loaded only the legacy suites, so agent/express/ and
agent/direct_json/ were inert YAML. Wires compiler.yaml and decompiler.yaml for
both formats: 67 cases, 45 passing, 21 xfail, 1 skipped. Direct JSON passes all
ten of its cases.

The adapter is thin, because this SDK already implements the blueprint Parser:
load the catalog a case names from conformance/test_data/, build the format's
parser, call compile or decompile. Two things the suites leave to a harness and
this one supplies: the v1.0 validation experiment, without which no v1.0 catalog
can build a validator, and the surface id, which the suites fix as
default_surface and ExpressParser defaults to main.

A case this SDK does not satisfy is marked xfail(strict=True) with the reason,
so fixing the implementation fails the marker rather than passing quietly. No
case was weakened to make it pass. All 21 are Express, and five defects account
for them:

  - Three catalog violations compile clean. A component the catalog does not
    declare is dropped, leaving an empty surface with no diagnostic.
  - A2uiCompilationError descends from Exception rather than A2uiError, so no
    error category can match it. Eight cases.
  - Four decompiler defects: a standalone updateDataModel writes no surface
    line, callRendererFunction decompiles to the empty string, an updateComponents
    block cannot be recompiled, and two spellings its own compiler rejects.
  - Inline component ids are a counter rather than parent plus property.
  - An Event with no context compiles context: {} rather than omitting it.

One case is skipped rather than marked: a parser holds a single catalog, so a
block targeting a second one by id has no API to run through.
Adds the third pair, response_parser.yaml for both formats: 34 cases, 15
passing and 19 marked. The runner now carries 101 conformance cases, 60 passing,
40 xfail, 1 skipped.

One test function dispatches on the call a case names, since the file holds
three: unwrap into ordered raw parts, wrap parts back into a response, and
parse_response for both at once. The unwrap and wrap cases carry no catalog,
because neither call consults one, so they get the simplified fixture and never
read it.

Five defects account for the 19:

  - A part carries text and payload together. The suites fix one or the other
    per part, so a response of text, block, text, block, text unwraps to three
    parts here where five are expected. Four cases, evenly split between the
    formats, which makes it a disagreement about the protocol rather than a bug
    in one parser.
  - wrap is wrap_decompiled_blocks and takes raw strings rather than parts, so
    it has nowhere to put a text part and always writes a tagged block. Seven
    cases. The harness calls it with the raw blocks a case names and lets the
    text go missing, rather than assembling the response itself, which would be
    testing the harness.
  - Direct JSON unwrap raises where the suites return parts: no tags, an empty
    response, and an unterminated block. Four cases. The third matters most,
    since raising throws away the partial block a streaming agent needs.
  - parse_response catches the compiler's A2uiParseError and re-raises it as
    A2uiCompilationError, so the category is lost at the entry point an agent
    actually calls. The direct JSON compiler raises the right category and the
    compiler suite passes on the same input.
  - parse_response takes no wrapped argument, so a response known to carry no
    sentinel tags cannot be handed to the compiler whole.
Seven tests whose every assertion is made by a conformance case that passes
today:

  test_format_string_and_actions           nested_function_call,
                                           event_action_with_context
  test_map_variable_inlining               map_variable_is_inlined_where_it_is_used
  test_skipped_and_omitted_arguments       placeholder_skips_an_optional_argument,
                                           trailing_optional_arguments_may_be_omitted
  test_semicolons_and_trailing_commas_     semicolons_and_trailing_commas,
    and_line_continuation                  statement_spans_several_lines
  test_compilation_delete_surface_kwargs   delete_surface_with_a_keyword_argument
  test_compilation_multi_surface           two_surfaces_in_one_block
  test_decompile_delete_surface            decompile_express_delete_surface

Nothing stopped being executed: each replacement is a case that passes rather
than one marked xfail.

That rule is what keeps the rest. A test whose conformance counterpart is xfail
is the only executing coverage of that path until the defect is fixed, so it
stays; the strict marker will say when it can go. So do the tests of what
conformance deliberately does not fix: error classes and their attributes, the
wording of a synthesised check message, thread safety, catalog polymorphism,
CatalogSchemaHelper internals, and v0.9 and v0.9.1 targeting, which the suites
do not cover at all.

Both module docstrings now say where the coverage moved, so it is not added back
here.
A compile failure left the SDK as A2uiCompilationError, which descended
from Exception and so sat outside the A2uiError hierarchy every other
failure in the SDK belongs to. A caller could not ask whether a block was
rejected because it could not be read or because the catalog refused what
it named, and the eight conformance cases that draw exactly that line had
no class to match.

A2uiCompilationError now descends from A2uiError, and gains two
subclasses that also descend from A2uiParseError and A2uiValidationError.
Choosing between them needs the compiler to keep the two apart, so the
Express errors are grouped under ExpressParseError and
ExpressValidationError, and the failures that were raised as bare
ValueErrors join whichever group they belong to: a value outside an enum
and a standalone call the target version has no message for are
validation failures, while a block the grammar rejects and a malformed
_template call are parse failures. The parser then picks the class by
type test rather than by reading the message.

Also fixes a NameError in ExpressUndefinedChildError, which referenced an
undefined help_message and so could never be constructed.
parse_response caught everything the compiler raised and, unless it was
already an A2uiCompilationError, re-raised it as one. Direct JSON raises
A2uiParseError from its own reader, so a response whose payload could not
be read reached the caller as an uncategorised compilation error and the
conformance case asking for a parse error had nothing to match.

An A2uiError now travels out as it came, carrying the parts read before
it, and only an exception from outside the hierarchy is wrapped.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive set of conformance test suites for the A2UI agent SDK, covering catalog providers, resolution, transformers, and format-specific (Direct JSON and Express) compilers, decompilers, prompt generators, and response parsers. To support this, the Python SDK's Express compiler and parser are updated to raise more specific error types (ExpressParseError and ExpressValidationError), which map to new A2uiCompilationParseError and A2uiCompilationValidationError exceptions. Additionally, legacy tests are moved or refactored, and documentation is updated to reflect the new test structure and correct RPC message schemas. Feedback on the changes suggests using a 'version at least' check (e.g., >=) instead of strict membership checks when verifying protocol versions for feature availability to ensure forward compatibility.

@nan-yu
nan-yu marked this pull request as ready for review September 21, 2026 20:22
Comment thread agent_sdks/python/a2ui_agent/tests/conformance/test_conformance.py
Comment thread agent_sdks/python/a2ui_agent/src/a2ui/parser/errors.py
Comment thread agent_sdks/python/a2ui_agent/src/a2ui/parser/errors.py
Comment thread conformance/README.md Outdated
- Port SemVer utilities and tests from v1_0 branch into a2ui_core
- Add A2uiProtocolVersion / ProtocolVersion enum in a2ui_core.schema
- Export semver utilities from a2ui.core.common
- Use is_at_least_version with ProtocolVersion.V1_0 in ExpressCompiler
…ed output

- Add delete_pointer helper to remove JSON Pointer paths from compiled payload
- Strip expect_present pointers after presence assertion so assert compiled == test_case["expect"] is verified
- Remove unused conformanceDir and test_data directory scans from jsonDirs
- Remove unused SIMPLIFIED_CATALOG_V09 constant and dead schemaMappings entries
- Accept details: list[A2uiErrorDetail] | None in A2uiCompilationError.__init__
- Forward details to super().__init__(message, details=details)
- Pass error details when wrapping compilation exceptions in ExpressParser
…Error

- Remove A2uiCompileError from a2ui_core in Python, Dart, Swift, and blueprints
- Keep A2uiCompilationError in agent SDK and map to CompilationError category
- Update category enum in conformance_schema.json to CompilationError
- Remove outdated CompileError documentation in conformance/README.md and compiler headers
- Update CHANGELOGs for python a2ui_core, python a2ui_agent, and dart a2ui_core
@nan-yu
nan-yu merged commit 33a750d into a2ui-project:main Sep 21, 2026
40 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in A2UI Sep 21, 2026
@nan-yu
nan-yu deleted the agent-conformance branch September 21, 2026 23:36
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.

2 participants