Skip to content

feat(core): add an optional error field to TOOL_CALL_RESULT - #2549

Open
mxmzb wants to merge 12 commits into
mainfrom
feat/tool-call-result-error
Open

feat(core): add an optional error field to TOOL_CALL_RESULT#2549
mxmzb wants to merge 12 commits into
mainfrom
feat/tool-call-result-error

Conversation

@mxmzb

@mxmzb mxmzb commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The gap

ToolMessage carries an optional error. ToolCallResultEvent does not.

A UI rendering a live stream therefore cannot show a failed tool call as failed. It has to wait for
the MESSAGES_SNAPSHOT that carries the finished ToolMessage — which arrives after the run, too
late to be useful for anything the user is watching happen.

Reported by S&P Global, who hit exactly this in their frontend integration.

What this adds

An optional error string on TOOL_CALL_RESULT, in TypeScript, Python and .NET — the event-side
twin of ToolMessage.error.

SDK Field
TypeScript error: z.string().optional() on ToolCallResultEventSchema
Python error: Optional[str] = None on ToolCallResultEvent
.NET public string? Error on ToolCallResultEvent, recorded in PublicAPI.Unshipped.txt

Plus @ag-ui/client threading the value onto the ToolMessage that defaultApplyEvents
accumulates — without which the message built from the stream and the one in the MESSAGES_SNAPSHOT
disagree about whether the call failed, which is the confusion the field exists to remove. There is
now a test that streams both and asserts they agree.

The contract

A tool call is reported as failed when error is a non-null string. The empty string counts — a
producer that sends "" chose to send it.

Test the type, not the truthiness, and not presence:

  • if (event.error) reads "" as a success.
  • Presence is not testable in two of the three SDKs. In Python and .NET an absent key and an
    explicit JSON null both land as the language's null, so the contract is stated on the value.

Each SDK page gives its own idiom and names the wrong one. A producer with no failure to report
omits the key; writing an explicit JSON null instead is not allowed.

Compatibility — this narrows the wire

Additive for a producer that never writes error. A tightening for one that does.

BaseEventSchema is .passthrough() and the Python models use extra="allow", so an error key
of any other shape previously survived. It is now typed:

  • TypeScript — EventSchemas.parse throws and the run ends. Also rejects an explicit null.
  • Python — ValidationError.
  • .NET — JsonException out of the SSE reader. (Previously .NET dropped such a key silently
    rather than carrying it, so its old behaviour differed from the other two.)

A producer already sending a structured error on this event must flatten it to a string or move it
to another key. This narrowing is the intended reading of the no-null-tolerance rule for fields
added since PNI-199, but it is a tightening, and the schema comments and docs now say so.

The null rule is enforced in TypeScript only. Zod rejects an explicit null; Python coerces it to
None and .NET deserializes it to null. That is pre-existing behaviour of every optional field on
those models — subagent_run_id and role behave identically — and this change neither introduces
nor fixes it. The guarantee is that no producer writes null, not that every SDK refuses one.

Coverage is not uniform. The community Go, Java, Kotlin and Dart SDKs have no error field on
this event, and Go's strict decoder (DisallowUnknownFields) rejects the key outright rather than
ignoring it — as it already does for the pre-existing subagentRunId.

Deliberately not in this PR

  • No protobuf. TOOL_CALL_RESULT is not in the proto schema at all — a documented parity
    boundary, tracked as PNI-214.
  • No producers. Nothing populates error yet, so an absent error still means the producer said
    nothing about failure. LangGraph, the MCP middleware and the Claude managed-agents integration all
    have an error signal today and drop it; wiring them is the follow-up that makes this useful.
  • No .NET consumer wiring. EventStreamConverter maps this event to a FunctionResultContent
    built from ToolCallId and Content only. On .NET the value is reachable off the event itself
    (or via ChatResponseUpdate.RawRepresentation); the XML doc and docs page say exactly that rather
    than promising a live-stream render. Mapping a string onto an Exception-shaped API is a design
    decision, not a mechanical one.
  • No community SDKs. They are already behind core on subagentRunId.

Review

Two full review rounds, 30 reviewer agents on an unbiased prompt, 9 isolated fix agents. What it
changed, beyond the wording above:

  • The @ag-ui/client guard narrows on the value's type, not on nullishness — the event reaching
    that reducer has not necessarily been parsed, and a serialized exception object is the natural
    shape a Python or LangChain producer emits. A non-string is dropped with a warning; silently
    discarding it would leave a ToolMessage byte-identical to a successful one.
  • The tests were substantially rewritten because they could not fail. Deleting the schema field
    originally produced one type error and left the shared cross-language fixture case fully green in
    two of three SDKs; it now produces twelve type errors and three runtime failures. The
    fixture harnesses assert that every expected wire key is a declared field, and each SDK asserts
    an exact case count so a deleted case is noticed.
  • Added the Compatibility parity asset the repo's own cross-SDK rule requires, generated by calling
    the TypeScript factory rather than hand-written.

Verification

  • TypeScript: 220 (core) + 42 (encoder) + 734 (client) passed; tsc --noEmit and lint clean
  • Python: 183 passed
  • .NET: 330 passed (AGUI.Abstractions.UnitTests)
  • TS ↔ .NET cross-language wire harness: 149 passed
  • Mutation, merged tree: deleting the field → 12 typecheck errors, 3 runtime failures

Pre-existing and unrelated, verified on a clean origin/main checkout: AGUI.Client.UnitTests and
AGUI.Hosting.AspNetCore.IntegrationTests do not compile (CS0122; SignAssembly is always true,
which kills the InternalsVisibleTo condition). Filed as follow-up.

Refs PNI-362.

@mxmzb
mxmzb requested a review from a team as a code owner August 26, 2026 20:15
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1788201581 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1788201581' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1788201581' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1788201581' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1788201581' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1788201581' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1788201581' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1788201581

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: f9caa8a

@pkg-pr-new

pkg-pr-new Bot commented Aug 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2549

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2549

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2549

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2549

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2549

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2549

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2549

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2549

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2549

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2549

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2549

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2549

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2549

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2549

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2549

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2549

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2549

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2549

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2549

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2549

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2549

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2549

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2549

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2549

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2549

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2549

commit: b10a831

@mxmzb
mxmzb force-pushed the feat/tool-call-result-error branch from 7ff441c to fca733a Compare August 26, 2026 23:04
mxmzb and others added 12 commits August 31, 2026 20:39
ToolMessage carries an optional `error`. ToolCallResultEvent does not, so a UI
rendering a live stream cannot show a failed tool call as failed — it has to wait
for the MESSAGES_SNAPSHOT that carries the finished message, which arrives after
the run and too late to be useful. Reported by S&P Global.

Adds the field to TypeScript, Python and .NET as the event-side twin of
ToolMessage.error, matching its naming, optionality and doc style. Purely
additive: an absent error behaves exactly as today, and the tests assert that a
stream produced before this field existed re-serializes byte-identically.

No null tolerance, matching every field added since PNI-199 — absent is the only
spelling and an explicit null fails the parse.

Two changes beyond the schema, because the schema edit alone leaves the field
incoherent:

- @ag-ui/client threads `error` onto the ToolMessage that defaultApplyEvents
  accumulates. Without it the message built from the stream and the one in the
  MESSAGES_SNAPSHOT disagree about whether the call failed.
- sdks/fixtures/null-omission.json gains tool_call_result_with_error, holding all
  three SDKs to the same wire text for the present case, as the existing
  tool_call_result_without_role already does for the absent one.

The TypeScript suite carries a compile-time assertion alongside the runtime ones.
BaseEventSchema passes unknown keys through, so deleting the field from the
schema leaves five of six runtime cases green — the value survives parsing as an
unrecognized key. Python has the same trap via extra="allow" and is guarded by
asserting on model_fields. Both verified by deleting the field and watching the
suites fail.

Out of scope, deliberately: protobuf (TOOL_CALL_RESULT is not in the schema at
all, tracked as PNI-214), producers actually populating the field, and the
community SDKs, which are already behind core on subagentRunId.

Refs PNI-362.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
`sdks/fixtures/README.md` requires the `stream` array to read as one
plausible stream of events, top to bottom, so a given id means one thing
for the whole file. `tool_call_result_with_error` broke that: it used
`messageId: "msg_3"` and `toolCallId: "tc_2"`, and both are re-bound
further down the same array.

- `msg_3` is bound by `messages_snapshot_with_sparse_messages` to a tool
  message carrying a SUCCESSFUL result (`{"hits":2}` for `tc_1`), so the
  same message id is both a timeout failure and a success.
- `tc_2` is first minted by `assistant_message_tool_call_without_encrypted_value`
  inside `msg_5`'s assistant `toolCalls` — i.e. the tool call is STARTED
  after the case that reports it timing out.

Rebind the case to `msg_7` / `tc_3`, the lowest numbers unused anywhere
else in the file. Both now occur in exactly one case, so nothing later
reinterprets them and the failure reads as its own tool call.

No other case is touched. All three fixture consumers stay green:
@ag-ui/encoder 40/40, python 180/180, AGUI.Abstractions.UnitTests 323/323.
The `error` field on TOOL_CALL_RESULT is a wire-shape change, and
.github/skills/agui-cross-sdk-parity/SKILL.md is explicit that a JSON-shape
change may not land without a Compatibility fixture. The parent commit shipped
the field in all three SDKs and the shared sdks/fixtures/null-omission.json
case, but tool-call-events.json — the .NET side's guard against TypeScript wire
drift — still only held the pre-error TOOL_CALL_RESULT shape. Nothing in the
.NET tree asserted that a TS payload carrying `error` deserializes with it.

The new entry is produced by the TypeScript reference, not hand-written:
createToolCallResultEvent from packages/core/src/event-factories.ts, serialized
the way @ag-ui/encoder's encodeSSE does. The same generator first re-parsed all
seven existing entries through EventSchemas and confirmed each is byte-identical
to what TS emits, so the file as a whole is still TS-producer output.

Appended, never inserted: ToolCallEventsCompatibilityTest indexes the array
positionally (_fixtures[0]..[6]), so inserting anywhere else would silently
shift every existing assertion onto the wrong fixture. There is no hard-coded
count; the one loop-based test enumerates the array.

The absent half gets an assertion too — the pre-error fixture must deserialize
with Error null rather than empty — mirroring the pairing the TypeScript and
Python suites already use.

Verified the asset actually bites, twice. Deleting the Error property (and its
PublicAPI.Unshipped.txt lines, which fail the build first) breaks the build at
ToolCallEventsCompatibilityTest.cs lines 84 and 96 — both new assertions.
Renaming [JsonPropertyName("error")] to "errorDetail", which still compiles,
fails ToolCallResult_WithError_DeserializesFromTypeScriptPayload at runtime with
Expected "SearchTimeout: ..." / Actual null — so the fixture catches wire drift,
not merely the property's existence.

324 tests pass in tests/AGUI.Abstractions.UnitTests on net10.0.

Refs PNI-362.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
The error field was added to the JS and Python event reference only,
leaving the language-agnostic protocol table and the .NET event table
listing a ToolCallResult that no longer matches the SDKs.

Add the field to both missing surfaces, and state the semantics the
schema alone does not carry: failure is signalled by presence rather
than truthiness (an empty string is preserved and still means failed),
content and error are independent, and absence does not yet prove
success because no producer populates the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
`defaultApplyEvents` receives events that have not necessarily been
through `EventSchemas.parse` — `event as ToolCallResultEvent` is an
assertion, not validation. The nullish guard `error != null` therefore
let a non-string `error` (e.g. `error: 42` from an unvalidated producer)
onto the accumulated `ToolMessage`, where it survived into `messages`
and only failed `RunAgentInputSchema` a full turn later with
"Expected string, received number" at `messages.N.error` — far from the
event that caused it.

Guard with `typeof error === "string"`, matching the `typeof x ===
"string"` convention already used elsewhere in this reducer, rather than
adding a validation layer. An empty string is still carried: `""` is a
value the producer deliberately sent, and dropping it would turn a badly
reported failure into a success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
Four claims in the comments, doc-comments and docs tables were false or
overstated. Wording only; no behaviour changes.

- Not purely additive. BaseEventSchema is `.passthrough()` and the Python
  models use extra="allow", so an `error` key of any shape used to survive
  parsing as an unrecognized key. Typing it as a string means a stream carrying
  `error: {code, message}` — or, in TypeScript, `error: null` — now fails the
  parse. The narrowing is intended; the comments now say it is a narrowing.
- "An explicit null fails the parse" holds in TypeScript only. Python coerces
  JSON null to None and .NET's `string? Error` deserializes it to null, as they
  do for every optional field on those models (`subagent_run_id` and `role`
  included). Scoped the claim rather than restating it universally.
- "Byte-identically / key-for-key" overstated the assertions. `toEqual` and
  Python dict equality ignore key order, and `toEqual` also ignores
  undefined-valued keys. Weakened the wording to "the same keys and values",
  keeping the assertions as they are: key order is a serializer detail, not
  part of the wire contract.
- "Absent means the call succeeded" overclaimed. Nothing populates the field
  yet, so absence still covers real failures. The .NET XML doc additionally
  promised a consumer could render the failure from the live stream, but
  EventStreamConverter never reads Error — corrected to say the value is
  reachable only off the event itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
… is broken

The tests added with the field did not test what they claimed. Measured by
mutation, not by reading:

* Deleting `error: z.string().optional()` from ToolCallResultEventSchema left
  8 of the 9 new core cases green. BaseEventSchema is `.passthrough()`, so the
  value survives `.parse()` as an unrecognized key. Of the file's three
  compile-time annotations only one — `const read: string | undefined` — fired
  under `tsc`, and the comment credited all three.
* The shared fixture case `tool_call_result_with_error` passed with the field
  deleted in BOTH TypeScript (passthrough) and Python (`extra="allow"`). Only
  .NET was a genuine harness, because System.Text.Json drops unknown keys.
* The client assertion used `(toolMessage as any).error` and
  `Object.keys(toolMessage as object)`; both discard the ToolMessage narrowing,
  so removing `error` from ToolMessage in @ag-ui/core still typechecked and
  left the suite green. `as any` is also banned by the repo test policy.
* `defaultApplyEvents` holds the only falsy-sensitive guard (`error != null`)
  and nothing pinned it: mutating it to `error &&` kept all four suites green
  while silently dropping a reported failure. Core, Python and .NET each pin
  the empty-string case; the client did not.
* .NET `Deserialize_RoundTripsError` round-tripped through its own serializer,
  so it passed under a renamed wire key. `Serialize_OmitsError_WhenNull` never
  referenced `Error` at all, so its assertion was inevitable.

What now carries each claim:

* core: a runtime assertion on `ToolCallResultEventSchema.shape.error` — the
  TypeScript analogue of Python's `model_fields` check, and the only runtime
  fact a passthrough schema cannot fake — plus an annotated `string | undefined`
  local in every case, so each one fails `tsc` when the field is deleted.
  Deleting it now yields 12 typecheck errors and 2 failing cases, against 1
  before. The stale comment is corrected, and it records that the exported
  `ToolCallResultEventProps` alias cannot carry a guard at all: `EventProps` is
  `Omit<z.input<Schema>, "type">`, and `Omit` over a passthrough input type
  collapses to a bare index signature.
* fixtures: both the TypeScript and Python harnesses now assert that every key
  a case expects on the wire is a DECLARED field of the resolved variant — Zod
  shape and `model_fields` respectively — so a case can no longer pass on a
  field the SDK does not have. This covers all 36 cases, not just the new one.
* client: type-predicate narrowing to ToolMessage instead of `as any`,
  annotated reads, a shared helper for the three cases, and a new empty-string
  case that fails under the `error &&` mutation.
* dotnet: `Deserialize_RoundTripsError` asserts the "error" key on the
  serialized JSON before round-tripping, and `Serialize_OmitsError_WhenNull`
  pairs the omission with a populated sibling. Both now fail under a renamed
  wire key, where both passed before.

No production source changed; the runtime behaviour is untouched.

Honest limitation: a passthrough schema genuinely cannot reject an unknown key
at runtime, so most individual core parse-and-read assertions still cannot fail
on their own. The schema-shape assertion and the per-case compile-time
annotations carry them, and the comment now says exactly that instead of
implying the runtime asserts do the work.

Refs PNI-362.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
The note claimed the preceding case pins the absent half of the contract.
That case tests round-trip fidelity for an event carrying no error at all,
not null omission, so the claim overstated it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
The eight places that stated this field's contract did not agree with each
other, and the rule they stated could not be implemented in two of the three
SDKs.

Failure is now defined on the VALUE, not on presence: a call is reported as
failed when `error` is a non-null string, empty string included. Presence is
not a testable predicate — Python and .NET deserialize an absent key and an
explicit JSON null to the same null, and TypeScript rejects the null outright —
so `event.error !== undefined` was unimplementable advice that also disagreed
with the shipped client, which guards on `typeof error === "string"`. Each SDK
page now gives its own language's idiom; the protocol page states it neutrally.

Also documents, on the user-facing pages, that typing this key NARROWS the
wire: an `error` of another shape used to be ignored (carried through as an
unrecognized key in TypeScript and Python, dropped in .NET) and is now a hard
parse failure that ends the run, so a producer already sending a structured
`error` must change.

Corrects "every SDK preserves it" — the community Go, Java, Kotlin and Dart
SDKs have no such field, and Go's strict decoder rejects the key outright.

Drops the "no producer populates it yet" census from all seven places it had
spread to (three code comments, one XML doc that ships to NuGet IntelliSense,
three doc pages) in favour of one non-rotting statement on the protocol page.

Resolves the contradiction between the .NET docs page and the XML doc, and
stops the XML doc describing AGUI.Client behaviour from inside
AGUI.Abstractions: the type doc now states only what is true of the type, and
the consumer guidance lives on the docs page.

Fixes the fixture note's claim that TypeScript runs a reflection-driven sweep
over every optional field — it does not; its null-omission coverage comes from
`JSON.stringify` dropping `undefined`.

No runtime behaviour changes and no test files touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
…merge artifacts

Adds the two tests the branch was missing: the reducer must warn when it
drops a non-string `error`, and the streamed ToolMessage must agree with
a later MESSAGES_SNAPSHOT about whether the call failed — the invariant
the branch exists for, previously asserted only in a comment.

Clears three merge artifacts: the verbatim duplicate of the empty-string
test that re-inlined the setup `toolMessageFromResult` exists to remove;
the comment claiming the reducer "guards on `error != null`" (replaced in
8df405598) and that this is "the only case in any SDK" (the Python SDK
pins the same spelling); and the two `Object.keys(toolMessage as object)`
casts that threw away the narrowing this file calls load-bearing. The
type-predicate-and-throw narrowing is now a shared `expectToolMessage`
helper used everywhere. Fixes the helper JSDoc, which described a
`result` wire field that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
…ons able to fail

Every item below was reproduced as a mutation first, fixed, then re-mutated to
show the assertion now fails.

- The fixture harnesses' declared-field guards were top-level only. Deleting the
  nested ToolMessage.tool_call_id or RunAgentInput.forwarded_props left the whole
  Python null-omission file green. Python's walk is now recursive, following the
  validated model tree and stopping at opaque JSON payloads. TypeScript needs no
  recursion — .passthrough() is used once, on BaseEventSchema, so a nested
  undeclared key is stripped and already fails the round-trip — so its docstring
  now says that, and a new test pins the asymmetry instead of asserting it in
  prose.
- The `cases.length > 15` floor could not notice a deletion: removing the
  tool_call_result_with_error case outright left all three suites green. All
  three now pin the count exactly.
- Python pinned neither behaviour its own source comment advertises: widening the
  annotation to Optional[Any] left all 181 tests green. Adds tests for a rejected
  non-string error and for an explicit null validating and re-emitting as
  omission.
- .NET pinned neither either. Adds the observed behaviour for both: an explicit
  null reads back as absent and is dropped on re-serialization, and a non-string
  error is refused by the reader with a JsonException naming $.error.
- Two assertions in the .NET compatibility test could not fail.
  Assert.Equal(string.Empty, typed.Content) passed with Content's JSON property
  renamed, so a control now feeds the same fixture a sentinel; the all-fixtures
  loop asserted only Type, so it now compares every property the fixture carries.
  Positional _fixtures[N] indexing is untouched.
- Two core test comments each claimed to be "the one" runtime case that fails on
  deletion. Measured: exactly two fail. Both comments now say so and name each
  other.
- The shared fixture's tool_call_result_with_error case used tc_3, which no
  TOOL_CALL_START opened. Adds the opening case ahead of it.
- The Python module docstring said "Three tests" while indexing three of four
  classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
The `typeof error === "string"` guard discarded a non-string `error` in
silence, leaving a ToolMessage byte-identical to the one a successful
call produces — a reported failure read back as a success with nothing
in the logs. Warn on the drop, in the same shape every other
malformed-input path in this reducer uses.

The comment justifying the guard was also false: it claimed a non-string
would "only fail `RunAgentInputSchema` a full turn later", but no
production code parses that schema anywhere. The real consequence is
that the value reaches every consumer of `agent.messages` unchallenged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
@mxmzb
mxmzb force-pushed the feat/tool-call-result-error branch from fca733a to b10a831 Compare August 31, 2026 18:39
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.

1 participant