Skip to content

fix(agui): export the subagent types, mirror subagentRunId onto messages and interrupts, and stop the drift collector reporting clean when it cannot see - #393

Merged
contextablemark merged 21 commits into
mainfrom
fix/agui-subagent-drift
Aug 31, 2026
Merged

Conversation

@jpr5

@jpr5 jpr5 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #391, which merged at its pre-review head. The type mirror landed, but the review that was running against it had not reported yet — so main currently has the three SUBAGENT_* event types and no way for a consumer to import them. This carries the review fixes.

What was actually broken on main

  • git show origin/main:src/index.ts | grep -c Subagent0. The new event types are exported from neither barrel, so the headline benefit of fix(agui): mirror the canonical subagent lifecycle events and subagentRunId #391 is unreachable from outside the package.
  • AGUIMessage and AGUIInterrupt never got subagentRunId, though canonical declares it on both — types.ts:28 (BaseMessageSchema) and types.ts:224 (InterruptSchema).
  • The AG-UI drift CI lane ran a single hardcoded filename, so any drift guard added later would silently never run.
  • The report collector dropped any AG-UI failure it could not pattern-match, with no counter and no quarantine — a real assertion in the existing agui-schema.drift.ts produced exit 0 and conclusion: "clean". That is pre-existing and independent of fix(agui): mirror the canonical subagent lifecycle events and subagentRunId #391.

Changes

Public surface

  • Export AGUISubagentStartedEvent, AGUISubagentFinishedEvent, AGUISubagentErrorEvent, AGUISubagentFinishedOutcome from both barrels — src/index.ts and src/agui-stub.ts (the ./agui subpath). The second barrel was missing them too.
  • Mirror canonical optional subagentRunId onto AGUIMessage and AGUIInterrupt.

Guard against recurrence

  • src/__tests__/agui-barrel-exports.test.ts asserts every type declared in agui-types.ts is re-exported from both barrels. It parses with the TypeScript compiler API, not regexes, and records a name only when it is reachable under its declared name — so a renamed re-export (X as Y) cannot pass, by construction.

CI lane

  • The AG-UI drift lane and the collector now select by path prefix src/__tests__/drift/agui- with a documented naming contract, instead of one hardcoded filename. A future AG-UI guard is picked up automatically rather than orphaned. The lane still requires no provider API keys; the credit-burning drift leg is untouched.

Collector fail-closed

  • An AG-UI failure the collector cannot structurally interpret now quarantines (existing exit-5 lane, CollectResult {entries, quarantine} — the same shape the HTTP leg already returns) instead of vanishing. Per-assertion and ungated.
  • A failed assertion with empty failureMessages no longer reads as clean.
  • Unparseable AG-UI stdout on a zero-exit run now throws, matching the HTTP twin, instead of returning {testResults: []}.
  • classifyAgUiCheckout verifies the canonical types.ts actually exists rather than trusting a directory named ag-ui, so a stale clone reports as stale instead of as a git/network failure.

Evidence

Barrel exports — before:

error TS2724: '"./src/index.js"' has no exported member named 'AGUISubagentStartedEvent'

8 such errors across both barrels; clean after.

CI lane — with subagentRunId deleted from AGUIMessage, the old lane command still passed, exit 0. The guard was provably inert on the server. After: exit 1.

Collector — mutating agui-schema.drift.ts to produce an uninterpretable real failure:

before: entries 0 / quarantined 0 / exit 0 / conclusion "clean"
after:  QUARANTINE= 1 ... EXITCODE= 5 CONCLUSION= quarantine

Gates: tsc --noEmit, eslint, prettier --check, tsdown all exit 0; full suite 179 files / 5367 tests.

Known gaps — deliberate, not oversights

  • subagentRunId on AGUIMessage/AGUIInterrupt ships without a regression test. A non-event drift guard was written for this PR and withdrawn: three review rounds found fail-open defects in it (most seriously, it read .omit({subagentRunId: true}) — a schema removing the field — as declaring it, an idiom canonical already uses). Rather than a fourth patching round on a hand-rolled schema differ, it is deferred to the follow-up that rewrites the legacy agui-schema.drift.ts, so one differ gets built once. The field is correct as merged; nothing will catch its removal until then.
  • The SUBAGENT_* types are declarative only. No builder in agui-handler.ts emits them and no build option accepts subagentRunId, so aimock can type a subagent event but cannot emit one. The CHANGELOG says so explicitly rather than implying reach.

Follow-ups filed (not in scope here)

  • Rewrite agui-schema.drift.ts onto the TypeScript AST — same root cause as everything above; the barrel guard's readers are liftable.
  • The drift reporting layer: the base-report reuse path is dead for three independent reproduced reasons (bare node importing a .ts module, missing actions: read, and a --status=success filter that cannot fire on days main drifted), so every PR pays a full fresh live provider run; infrastructure failures are Slacked as "providers changed response formats"; and seven provider secrets sit in job-level env:, exposed to pnpm install lifecycle scripts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AvkmhXVLqSSEW6FvPQHSu5

jpr5 added 21 commits August 30, 2026 13:51
The subagent lifecycle types added alongside SUBAGENT_STARTED /
SUBAGENT_FINISHED / SUBAGENT_ERROR were declared in src/agui-types.ts but
never re-exported, so no consumer of the package root or of the "./agui"
subpath could name them. Export AGUISubagentStartedEvent,
AGUISubagentFinishedEvent, AGUISubagentErrorEvent and
AGUISubagentFinishedOutcome from both barrels, and add a public-surface
test that fails whenever any agui-types.ts type is missing from either.
`AGUIMessage` and `AGUIInterrupt` dropped the optional `subagentRunId` that
`@ag-ui/core` declares on `BaseMessageSchema`, the three standalone message
schemas, and `InterruptSchema`. A replayed message or approval request lost
the attribution that lets a client group it under the subagent that produced
it, and read as root-raised instead.

The existing AG-UI drift suite only walks `*EventSchema` against `AGUI*Event`
interfaces, so it could not see this. Adds `agui-nonevent-schema.drift.ts`,
which compares the canonical non-event schemas that carry `subagentRunId`
against the aimock interfaces that mirror them, plus a type-level guard.
Scoped to `subagentRunId`; broader non-event field drift is tracked separately.

Note: tsconfig.json excludes src/__tests__, so the type-level guard is not
covered by `pnpm build` and must be typechecked explicitly.
The barrel guard split export blocks on commas before stripping comments, so
a comma inside a comment dropped a real exported name and injected a bogus
one; and it recorded `X as Y` under `X`, so renaming a re-export defeated the
reachability check the guard exists to perform.

Read src/index.ts, src/agui-stub.ts and src/agui-types.ts with the TypeScript
parser instead. Comments are trivia and a rename is a distinct
propertyName/name pair, so both failure modes are handled by construction. A
name is counted as reachable only when its local and exported names match.
Star re-exports are recognised as publishing everything. The reader itself is
now unit-tested on the shapes that defeat a regex, and the non-empty positive
control against a total parse failure is retained.
…rroring

The branch adds four public type exports and mirrors canonical's optional
subagentRunId onto 24 events, AGUIMessage and AGUIInterrupt, with no
[Unreleased] entry — against the repo's own convention. Adds one Added
bullet matching the depth of the 1.39.0 AGUITokenUsage entry (the same
kind of change), and one Fixed bullet for the drift parser's trailing-
comment bug that made the report undercount.

States plainly that the new types are declarative only: agui-handler.ts
gained no builder, so aimock can type a subagent event but not emit one.
The `agui-schema-drift` job and `runAgUiDriftTests()` in the drift report
collector both ran a single hardcoded path,
`src/__tests__/drift/agui-schema.drift.ts`. The default vitest config only
includes `*.test.ts`, so `pnpm test` never picks up `*.drift.ts` either —
meaning `agui-nonevent-schema.drift.ts` was executed by no CI job at all: a
guard that could never fail server-side.

Both consumers now pass the vitest filename filter
`src/__tests__/drift/agui-` instead, so every current and future
`agui-*.drift.ts` guard runs. `vitest.config.drift.ts` already scopes
`include` to the drift-suffixed files, so the filter cannot pull in plain
`.test.ts` helpers from the same directory.

The lane still needs no provider API keys — the AG-UI drift files compare
`src/agui-types.ts` against the cloned canonical ag-ui repo only. The
`paths:` trigger entry is generalised to `agui-*.drift.ts` to match.
…pping

The availability check lived inside the `describe.skipIf` it was meant to
guard, so a missing ag-ui checkout skipped the assertion that would have
reported it and the file exited 0 while comparing nothing. The gate now
lives outside the skipped block: an absent or unreadable canonical checkout
fails, and the local escape hatch (AIMOCK_ALLOW_MISSING_AGUI_CHECKOUT=1) is
inert whenever CI is set, so no automated lane can reach a silent pass.

The mirror loop also `continue`d past any canonical schema that did not
declare `subagentRunId`, so an upstream rename -- the drift this file exists
to catch -- reported clean for the four message mirrors. The canonical side
is now asserted and a missing declaration is reported as drift.

Drop the type-level companion block: it asserted nothing at runtime and the
`tsc` command its comment documented was wired into no script and no CI
lane. Run the file in test-drift.yml's AG-UI job, which previously named
only the older agui-schema.drift.ts and so never executed this one.
# Conflicts:
#	.github/workflows/test-drift.yml
The non-event subagentRunId guard hand-parsed TypeScript with regexes and a
brace counter, and every way that reader failed was a fail-open failure:

- the brace counter ignored comments and strings, so a brace inside a comment
  ran the reader past the end of the schema it was reading;
- the canonical-side check matched subagentRunId against the whole nested
  body, so a nested occurrence masked a top-level removal;
- MIRRORS was a hardcoded list with no completeness check, so a new canonical
  schema carrying the field went silently uncompared.

It also pinned the aimock side to the literal text subagentRunId?: string;,
reporting false drift on a reformat, a readonly, or a widened type; and gated
its local escape hatch on CI === "true", so a runner setting CI=1 reached an
opt-out meant to be unreachable in CI.

Both sides are now read with ts.createSourceFile (syntax-only, no program),
following the pattern already established in agui-barrel-exports.test.ts.
Members are read structurally, so comments and strings are trivia and a nested
member is distinguishable from a top-level one by construction; the aimock
mirror is compared by name, optionality and type under a stated widening rule
(must admit absence/undefined and a bare string; extra union members are an
acceptable widening, narrowing is drift). The canonical side of MIRRORS is
derived and asserted equal to the hand-written list, so a new or renamed
canonical schema fails loudly. The CI gate now treats any non-negating value
as truthy, and the readers have direct unit tests.
…ailure

The AG-UI leg of the drift collector recognized exactly three failure-message
shapes and DROPPED everything else — no entry, no counter, no warning. A
genuinely failing AG-UI guard whose message matched none of them therefore
produced zero entries, exit 0 and conclusion "clean": the collector reported a
clean baseline for a run it could not see.

Every unrecognized FAILED AG-UI assertion is now returned as a QuarantineEntry,
reusing the collector's existing quarantine / exit-5 lane rather than inventing
a parallel scheme. Unlike the HTTP leg's entries.length === 0 gated rescue, this
is per-assertion and unconditional, so an uninterpretable failure survives a
mixed run in which other failures did parse.

ensureAgUiRepo also accepted any directory merely NAMED ag-ui. A stale or
partial checkout passed that check, the drift suites' describe.skipIf gates then
skipped everything, and the run was certified clean. classifyAgUiCheckout now
requires the canonical sdks/typescript/packages/core/src/types.ts and reports a
stale/incomplete checkout as exactly that — not as a git/network failure — which
routes to the AG-UI-skipped lane (exit 1) instead of clean.
…r fail-opens

The hand-rolled schema differ in `src/__tests__/drift/agui-nonevent-schema.drift.ts`
produced a fail-open bug in each of three consecutive review rounds. It is
withdrawn rather than patched a fourth time; the follow-up that rewrites the
legacy drift harness will build one differ properly, once. Its
`AGUI_NONEVENT_FAILURE` collector fixture goes with it — it was documented as a
verbatim vitest capture but carried 2 of 4 drift lines and the wrong frame, so
it could not be trusted in any form. The fail-closed test it anchored now runs
on `AGUI_UNPARSEABLE_SCHEMA_FAILURE`, a real capture from the surviving
`agui-schema.drift.ts`, which is the guard the invariant actually protects.

Two fail-opens that outlive the withdrawal are fixed:

- `collectAgUiDriftEntries` skipped a FAILED assertion whose `failureMessages`
  was empty, before it was even looked at — zero entries, exit 0,
  `conclusion: "clean"`. A failure with no message is the least interpretable
  failure there is, so it now takes the same quarantine lane (exit 5) as an
  unrecognized message. The test that PINNED the old behavior pinned a
  fail-open; it now pins the quarantine.
- A ZERO-exit AG-UI run whose stdout is not vitest JSON returned
  `{ testResults: [] }`, which the collector reads as "no failures", while the
  HTTP twin `runDriftTests()` throws on the identical condition. The AG-UI leg
  now throws too. The run/parse half is extracted as the exported
  `runAgUiVitest(exec)` so that contract is testable without spawning vitest.

The barrel-export guard, `subagentRunId` on `AGUIMessage`/`AGUIInterrupt`, the
CI lane's `agui-` path-prefix filter and naming contract, and the collector's
fail-closed invariant are all untouched. `subagentRunId` on the two non-event
types now ships with NO regression test — the CHANGELOG says so plainly rather
than implying a guard exists.
@pkg-pr-new

pkg-pr-new Bot commented Aug 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@393

commit: dc92c3d

@contextablemark contextablemark 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.

I dug through the collector edge cases and checked them against the documented gaps and CI paths. Nothing here should hold the merge — approved.

@contextablemark
contextablemark merged commit 9e55ff3 into main Aug 31, 2026
30 checks passed
@contextablemark
contextablemark deleted the fix/agui-subagent-drift branch August 31, 2026 01:09
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