Skip to content

Commit 5bfcf8e

Browse files
committed
test(aws-strands): read the documents that keep drifting
The truth pass on the four Strands documents corrected roughly fifty false claims, in four recurring classes: counted quantities stated in prose, quoted signatures, example-table claims about which hooks a demo uses, and claims about what a package exports. The one class that did not drift is the one class the repository already had a lever for. Six levers, each in the existing test file for its subject. Every value is derived, from the document by parsing a structured region of it and from the source by enumerating it, so none of them carries an expected count or name list beside the test. - The TypeScript README route table against the DEMOS registry, by set and by length, mirroring the Python route-table test that held. - The README's runner and script numerals against the files that carry a standalone runner and the package.json scripts that point at one, plus the named complement so the sentence and the numeral cannot disagree. - Named imports in every ts fence against the real entry namespaces, and the install fence against this package's non-optional peers and the installed Strands SDK's own, with both fence numerals derived from those manifests. - The ARCHITECTURE packaging-surface block against __all__ three ways: the name set, the block size and the prose numeral, so editing the sentence alone cannot satisfy it. - Every signature the documents quote against inspect.signature, as ordered sequences, with the surface taken from __all__. - Each example-table row's identifiers against the primitives the demo actually uses, by AST walk, set-equal so both directions are covered. The example-table lever found two rows wrong by omission, which is the direction a one-way check misses, and both are corrected here: predictive_state_updates.py also uses state_from_args and state_context_builder, and a2ui_recovery.py sets StrandsAgentConfig.a2ui. Every lever was driven red before green, including the cases a weaker test would pass: a duplicated row that is set-equal but the wrong length, a stripped runner whose numeral stays correct, a prose numeral edited to match a shortened block, and a parameter reorder a set comparison accepts.
1 parent 8f8389d commit 5bfcf8e

5 files changed

Lines changed: 966 additions & 19 deletions

File tree

integrations/aws-strands/ARCHITECTURE.md

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -309,23 +309,23 @@ Behaviors the Python adapter does not currently implement, added to match TypeSc
309309

310310
The repository includes fifteen runnable FastAPI apps that showcase different features. Each example builds a Strands SDK agent, or a `Graph` of them, wraps it with `StrandsAgent`, and exposes it via `create_strands_app`. `server/settings.py` holds the route table and `server/__init__.py` mounts each app as a sub-application of the dojo:
311311

312-
| Module | Focus | Relevant Configuration |
313-
| ----------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
314-
| `agentic_chat.py` | Baseline text generation with a frontend-only `change_background` tool. | No custom config; demonstrates automatic text streaming and frontend tool short-circuiting. |
315-
| `agentic_chat_reasoning.py` | Reasoning/thinking event streaming with extended thinking models. | No custom config; demonstrates REASONING\_\* event emission. Pins a reasoning-capable model mode in `model_factory.py`. |
316-
| `agentic_chat_citations.py` | Answers carrying the sources they came from. | No custom config; asks for OpenAI's Responses API with the built-in `web_search` tool, which is honoured only on `MODEL_PROVIDER=openai`. |
317-
| `agentic_chat_multimodal.py` | Multimodal image/document analysis with vision-capable model. | No custom config; demonstrates automatic multimodal content conversion. |
318-
| `backend_tool_rendering.py` | Backend-executed tools (`render_chart`, `get_weather`). | Shows how tool results become `ToolCallResultEvent`s and can be rendered directly in the UI. |
319-
| `shared_state.py` | Collaborative recipe editor that streams server-side state. | Uses `state_context_builder` and `state_from_args` to keep the UI's recipe object synchronized. |
320-
| `agentic_generative_ui.py` | Predictive and reactive state updates for generative UI surfaces. | Demonstrates `PredictStateMapping` alongside `state_context_builder` and `state_from_result`. |
321-
| `predictive_state_updates.py` | Document editor painted from a frontend tool's streaming arguments. | A `PredictStateMapping` on `write_document` projects the streaming args into `state.document` before the result arrives. |
322-
| `tool_based_generative_ui.py` | Frontend-rendered tool (`generate_haiku`) auto-registered as a proxy. | No custom config; exercises the `TOOL_CALL_*` stream the dojo's page consumes. |
323-
| `human_in_the_loop.py` | Human-in-the-loop confirmation flow with frontend tools. | Explicitly configures `generate_task_steps` with `continue_after_frontend_call=False`; the shared frontend remains unchanged. |
324-
| `interrupt.py` | A backend tool pauses itself mid-body to ask the user for a time. | No custom config; the tool calls `tool_context.interrupt(...)` and reads the user's choice back from that same call on resume. |
325-
| `multi_agent.py` | A `Graph` of agents, streamed as steps. | Built with `GraphBuilder`; the adapter detects the orchestrator and drives its stream rather than cloning a per-thread agent. |
326-
| `a2ui_dynamic_schema.py` | A2UI surfaces composed on the fly. | Sets `StrandsAgentConfig.a2ui` to name the catalog; `generate_a2ui` is auto-injected rather than wired here. |
327-
| `a2ui_fixed_schema.py` | A2UI from fixed-layout backend tools. | Backend tools return an `a2ui_operations` envelope directly, so nothing is auto-injected. |
328-
| `a2ui_recovery.py` | A2UI validate-and-retry recovery loop. | Same auto-injection as the dynamic demo; the injected tool validates each surface and retries up to three attempts before failing. |
312+
| Module | Focus | Relevant Configuration |
313+
| ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314+
| `agentic_chat.py` | Baseline text generation with a frontend-only `change_background` tool. | No custom config; demonstrates automatic text streaming and frontend tool short-circuiting. |
315+
| `agentic_chat_reasoning.py` | Reasoning/thinking event streaming with extended thinking models. | No custom config; demonstrates REASONING\_\* event emission. Pins a reasoning-capable model mode in `model_factory.py`. |
316+
| `agentic_chat_citations.py` | Answers carrying the sources they came from. | No custom config; asks for OpenAI's Responses API with the built-in `web_search` tool, which is honoured only on `MODEL_PROVIDER=openai`. |
317+
| `agentic_chat_multimodal.py` | Multimodal image/document analysis with vision-capable model. | No custom config; demonstrates automatic multimodal content conversion. |
318+
| `backend_tool_rendering.py` | Backend-executed tools (`render_chart`, `get_weather`). | Shows how tool results become `ToolCallResultEvent`s and can be rendered directly in the UI. |
319+
| `shared_state.py` | Collaborative recipe editor that streams server-side state. | Uses `state_context_builder` and `state_from_args` to keep the UI's recipe object synchronized. |
320+
| `agentic_generative_ui.py` | Predictive and reactive state updates for generative UI surfaces. | Demonstrates `PredictStateMapping` alongside `state_context_builder` and `state_from_result`. |
321+
| `predictive_state_updates.py` | Document editor painted from a frontend tool's streaming arguments. | A `PredictStateMapping` on `write_document` projects the streaming args into `state.document` before the result arrives; `state_from_args` then publishes the finished document as authoritative state, and `state_context_builder` feeds that document back into the prompt so edits stay incremental. |
322+
| `tool_based_generative_ui.py` | Frontend-rendered tool (`generate_haiku`) auto-registered as a proxy. | No custom config; exercises the `TOOL_CALL_*` stream the dojo's page consumes. |
323+
| `human_in_the_loop.py` | Human-in-the-loop confirmation flow with frontend tools. | Explicitly configures `generate_task_steps` with `continue_after_frontend_call=False`; the shared frontend remains unchanged. |
324+
| `interrupt.py` | A backend tool pauses itself mid-body to ask the user for a time. | No custom config; the tool calls `tool_context.interrupt(...)` and reads the user's choice back from that same call on resume. |
325+
| `multi_agent.py` | A `Graph` of agents, streamed as steps. | Built with `GraphBuilder`; the adapter detects the orchestrator and drives its stream rather than cloning a per-thread agent. |
326+
| `a2ui_dynamic_schema.py` | A2UI surfaces composed on the fly. | Sets `StrandsAgentConfig.a2ui` to name the catalog; `generate_a2ui` is auto-injected rather than wired here. |
327+
| `a2ui_fixed_schema.py` | A2UI from fixed-layout backend tools. | Backend tools return an `a2ui_operations` envelope directly, so nothing is auto-injected. |
328+
| `a2ui_recovery.py` | A2UI validate-and-retry recovery loop. | Sets `StrandsAgentConfig.a2ui` the way the dynamic demo does; the auto-injected tool validates each surface and retries up to three attempts before failing. |
329329

330330
### TypeScript (`typescript/examples/server/api/*.ts`)
331331

integrations/aws-strands/python/tests/test_docs_contract.py

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""The README's load-bearing claims, checked against the code.
1+
"""The load-bearing claims of README.md and ARCHITECTURE.md, checked against the code.
22
33
Review of this package repeatedly found documentation that named a constant the
44
adapter does not emit, or a shape it does not produce. Prose drifts because
@@ -12,14 +12,19 @@
1212

1313
from __future__ import annotations
1414

15+
import inspect
1516
import re
1617
from pathlib import Path
1718

18-
from ag_ui_strands import INTERRUPT_CANCELLED
19+
import ag_ui_strands
20+
from ag_ui_strands import INTERRUPT_CANCELLED, __all__
1921

2022
_ROOT = Path(__file__).resolve().parent.parent
2123
README = (_ROOT / "README.md").read_text()
2224
SOURCE = (_ROOT / "src" / "ag_ui_strands" / "agent.py").read_text()
25+
ARCHITECTURE = (_ROOT.parent / "ARCHITECTURE.md").read_text()
26+
27+
_PACKAGING_HEADING = "### Packaging Surface"
2328

2429

2530
def test_the_readme_never_names_a_near_miss_of_a_real_error_code():
@@ -96,3 +101,183 @@ def test_the_readme_documents_every_approval_metadata_key_published():
96101
assert undocumented == [], (
97102
f"the README does not document published approval metadata keys: {undocumented}"
98103
)
104+
105+
106+
def _packaging_surface_region() -> str:
107+
"""The Packaging Surface subsection of ARCHITECTURE.md, heading to next heading."""
108+
start = ARCHITECTURE.find(_PACKAGING_HEADING)
109+
assert start != -1, (
110+
f"the packaging-surface heading {_PACKAGING_HEADING!r} moved or was reworded"
111+
)
112+
end = ARCHITECTURE.find("\n### ", start + 1)
113+
return ARCHITECTURE[start:] if end == -1 else ARCHITECTURE[start:end]
114+
115+
116+
def _packaging_surface_names() -> list[str]:
117+
"""Every name listed in the grouped fence, in document order.
118+
119+
The fence lines are either ``label<gap>Name / Name /`` or an indented
120+
continuation carrying bare names, so a label is whatever precedes the first
121+
run of two or more spaces on an unindented line.
122+
"""
123+
region = _packaging_surface_region()
124+
fence = re.search(r"```\n(.*?)```", region, re.DOTALL)
125+
assert fence is not None, "the packaging-surface group listing is no longer fenced"
126+
127+
names: list[str] = []
128+
for line in fence.group(1).splitlines():
129+
if not line.strip():
130+
continue
131+
if line[0].isspace():
132+
listed = line
133+
else:
134+
parts = re.split(r"\s{2,}", line.strip(), maxsplit=1)
135+
assert len(parts) == 2, f"packaging-surface line carries no names: {line!r}"
136+
listed = parts[1]
137+
names.extend(name.strip() for name in listed.split("/") if name.strip())
138+
139+
assert names, "the packaging-surface fence parsed to no names"
140+
return names
141+
142+
143+
def test_architecture_lists_exactly_the_names_the_package_exports():
144+
"""Set, block size and prose numeral, all three against ``__all__``.
145+
146+
The audited drift was a fence listing 8 of 32 names beside a prose numeral
147+
that was wrong on its own. Asserting only the set would let the numeral rot;
148+
asserting only the numeral would let someone satisfy the test by editing one
149+
digit while the fence stays short. Nothing here is a literal name list, so
150+
adding an export fails until the fence and the sentence both catch up.
151+
"""
152+
listed = _packaging_surface_names()
153+
exported = list(__all__)
154+
155+
assert set(listed) == set(exported), (
156+
"the packaging-surface fence and `__all__` disagree: "
157+
f"only in the document {sorted(set(listed) - set(exported))}, "
158+
f"only in `__all__` {sorted(set(exported) - set(listed))}"
159+
)
160+
assert len(listed) == len(exported), (
161+
f"the fence lists {len(listed)} names but `__all__` carries {len(exported)}"
162+
)
163+
164+
prose = re.search(
165+
r"`__all__` is the exact surface and currently carries (\d+) names",
166+
_packaging_surface_region(),
167+
)
168+
assert prose is not None, (
169+
"the sentence stating how many names `__all__` carries moved or was reworded"
170+
)
171+
claimed = int(prose.group(1))
172+
assert claimed == len(listed) == len(exported), (
173+
f"the prose claims {claimed} names, the fence lists {len(listed)}, "
174+
f"`__all__` carries {len(exported)}"
175+
)
176+
177+
178+
def _code_parameter_names() -> dict[str, list[str]]:
179+
"""Declared parameter names of every callable in ``__all__``, in order.
180+
181+
Varargs are dropped because a document spells them inconsistently and they
182+
say nothing about the call a reader will write. Protocol classes reduce to
183+
nothing here and are simply never matched by a documented span.
184+
"""
185+
names: dict[str, list[str]] = {}
186+
for name in __all__:
187+
obj = getattr(ag_ui_strands, name)
188+
if not callable(obj):
189+
continue
190+
try:
191+
signature = inspect.signature(obj)
192+
except (TypeError, ValueError):
193+
continue
194+
names[name] = [
195+
parameter.name
196+
for parameter in signature.parameters.values()
197+
if parameter.kind not in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD)
198+
]
199+
return names
200+
201+
202+
def _split_top_level(text: str) -> list[str]:
203+
"""Split on commas outside brackets and quotes, so defaults stay intact."""
204+
parts: list[str] = []
205+
current: list[str] = []
206+
depth = 0
207+
quote = ""
208+
for char in text:
209+
if quote:
210+
current.append(char)
211+
if char == quote:
212+
quote = ""
213+
continue
214+
if char in "\"'":
215+
quote = char
216+
elif char in "([{":
217+
depth += 1
218+
elif char in ")]}":
219+
depth -= 1
220+
elif char == "," and depth == 0:
221+
parts.append("".join(current))
222+
current = []
223+
continue
224+
current.append(char)
225+
parts.append("".join(current))
226+
return [part.strip() for part in parts if part.strip()]
227+
228+
229+
def _documented_parameter_names(arguments: str) -> list[str]:
230+
names = []
231+
for argument in _split_top_level(arguments):
232+
if argument in {"*", "/"} or argument.startswith("*"):
233+
continue
234+
names.append(argument.split("=", 1)[0].split(":", 1)[0].strip())
235+
return names
236+
237+
238+
# A documented signature is a code span that opens a list item. Both looser
239+
# rules admit an example call, which legitimately passes a variable or a chosen
240+
# value where the signature names a parameter, so reading one as a signature
241+
# would fail against correct prose: dropping the list marker admits a sentence
242+
# that happens to start with a call span, and dropping the line-start anchor
243+
# admits a call quoted mid-sentence. A genuine signature written some other way
244+
# is not silently skipped either, because the set of callables this finds is
245+
# asserted below.
246+
_SIGNATURE_SPAN = re.compile(
247+
r"^[ \t]*[-*+][ \t]+`([A-Za-z_][A-Za-z0-9_]*)\((.*?)\)`",
248+
re.MULTILINE,
249+
)
250+
251+
# Which callables the documents currently quote a signature for. Pinned so a
252+
# signature quietly vanishing from the prose fails here instead of shrinking the
253+
# scan to nothing. Parameter names are never pinned; those come from the code.
254+
DOCUMENTED_SIGNATURES = {"add_strands_fastapi_endpoint", "create_strands_app"}
255+
256+
257+
def test_every_quoted_signature_matches_the_parameters_the_code_declares():
258+
"""Ordered names, because a reader passes positional arguments by position.
259+
260+
The audited drift was a quoted signature missing ``invocation_state_provider``
261+
in two documents while the parameter had existed for releases. Comparing
262+
sets rather than sequences would have caught that one and missed a reorder,
263+
which misleads a reader in exactly the same way.
264+
"""
265+
declared = _code_parameter_names()
266+
found: dict[str, set[str]] = {}
267+
268+
for label, document in (("ARCHITECTURE.md", ARCHITECTURE), ("README.md", README)):
269+
for match in _SIGNATURE_SPAN.finditer(document):
270+
name, arguments = match.group(1), match.group(2)
271+
if name not in declared:
272+
continue
273+
found.setdefault(name, set()).add(label)
274+
documented = _documented_parameter_names(arguments)
275+
assert documented == declared[name], (
276+
f"{label} documents {name} as {documented} but the code declares "
277+
f"{declared[name]}"
278+
)
279+
280+
assert set(found) == DOCUMENTED_SIGNATURES, (
281+
f"the documents quote signatures for {sorted(found)}, expected "
282+
f"{sorted(DOCUMENTED_SIGNATURES)}"
283+
)

0 commit comments

Comments
 (0)