ag-ui-validate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/catalog-BglXBNbL.js +472 -0
  4. package/dist/catalog-BglXBNbL.js.map +1 -0
  5. package/dist/catalog-Ci9dqc1a.cjs +495 -0
  6. package/dist/catalog-Ci9dqc1a.cjs.map +1 -0
  7. package/dist/cli.js +2783 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/index-Hmqj3r_r.d.cts +52 -0
  10. package/dist/index-oNG1kOp9.d.ts +52 -0
  11. package/dist/index.cjs +14 -0
  12. package/dist/index.d.cts +3 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.js +3 -0
  15. package/dist/report.cjs +139 -0
  16. package/dist/report.cjs.map +1 -0
  17. package/dist/report.d.cts +85 -0
  18. package/dist/report.d.ts +85 -0
  19. package/dist/report.js +134 -0
  20. package/dist/report.js.map +1 -0
  21. package/dist/src-HmI-kxef.cjs +1596 -0
  22. package/dist/src-HmI-kxef.cjs.map +1 -0
  23. package/dist/src-rGZ2G4qA.js +1555 -0
  24. package/dist/src-rGZ2G4qA.js.map +1 -0
  25. package/dist/transport.cjs +329 -0
  26. package/dist/transport.cjs.map +1 -0
  27. package/dist/transport.d.cts +89 -0
  28. package/dist/transport.d.ts +89 -0
  29. package/dist/transport.js +323 -0
  30. package/dist/transport.js.map +1 -0
  31. package/dist/types-oH_QTnn2.d.cts +148 -0
  32. package/dist/types-oH_QTnn2.d.ts +148 -0
  33. package/dist/vitest.d.ts +28 -0
  34. package/dist/vitest.js +2089 -0
  35. package/dist/vitest.js.map +1 -0
  36. package/package.json +127 -0
  37. package/src/cli-args.ts +202 -0
  38. package/src/cli.ts +147 -0
  39. package/src/index.ts +465 -0
  40. package/src/protocol/event-table.ts +316 -0
  41. package/src/protocol/jsonpatch.ts +220 -0
  42. package/src/report/index.ts +10 -0
  43. package/src/report/json.ts +20 -0
  44. package/src/report/junit.ts +56 -0
  45. package/src/report/pretty.ts +59 -0
  46. package/src/report/sarif.ts +109 -0
  47. package/src/rules/catalog.json +431 -0
  48. package/src/rules/catalog.ts +84 -0
  49. package/src/rules/checks/context.ts +117 -0
  50. package/src/rules/checks/lifecycle.ts +59 -0
  51. package/src/rules/checks/reasoning.ts +97 -0
  52. package/src/rules/checks/state.ts +72 -0
  53. package/src/rules/checks/text.ts +109 -0
  54. package/src/rules/checks/toolcalls.ts +167 -0
  55. package/src/rules/checks/transport.ts +17 -0
  56. package/src/transport/index.ts +331 -0
  57. package/src/transport/ndjson.ts +25 -0
  58. package/src/transport/sse.ts +126 -0
  59. package/src/types.ts +136 -0
  60. package/src/vitest/index.ts +19 -0
  61. package/src/vitest/matcher.ts +77 -0
@@ -0,0 +1,331 @@
1
+ // ag-ui-validate/transport: SSE + NDJSON clients over the pure core.
2
+ // This is the I/O layer — fetch, streams, and clocks live here, never in the
3
+ // core. Uses only web-platform APIs (fetch, TextDecoder, AbortController) so
4
+ // it stays isomorphic: Node 20+, browsers, Deno, Workers.
5
+
6
+ import { createValidator } from "../index.js"
7
+ import type { Diagnostic, Report, ValidationLayer, ValidatorOptions } from "../index.js"
8
+ import { ndjsonLines } from "./ndjson.js"
9
+ import { sseItems } from "./sse.js"
10
+
11
+ export { ndjsonLines } from "./ndjson.js"
12
+ export { sseItems } from "./sse.js"
13
+ export type { SseItem, SseProblemCode } from "./sse.js"
14
+
15
+ const DEFAULT_KEEPALIVE_MS = 30_000
16
+
17
+ export interface TransportRequestInit {
18
+ method: string
19
+ headers: Record<string, string>
20
+ body?: string
21
+ signal?: AbortSignal
22
+ }
23
+
24
+ export interface TransportResponseLike {
25
+ status: number
26
+ headers: { get(name: string): string | null }
27
+ body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> | null
28
+ }
29
+
30
+ export type FetchLike = (url: string, init: TransportRequestInit) => Promise<TransportResponseLike>
31
+
32
+ /** Operational failure (unreachable endpoint, non-2xx, no body) — distinct
33
+ * from conformance findings, which are diagnostics. */
34
+ export class TransportError extends Error {
35
+ status?: number
36
+ constructor(message: string, status?: number) {
37
+ super(message)
38
+ this.name = "TransportError"
39
+ if (status !== undefined) this.status = status
40
+ }
41
+ }
42
+
43
+ export interface TransportOptions {
44
+ /** Options passed through to createValidator ("transport" layer is added). */
45
+ validator?: ValidatorOptions
46
+ /** Extra request headers (validateEndpoint only). */
47
+ headers?: Record<string, string>
48
+ /** RunAgentInput to POST; defaults to a minimal valid input with one user message. */
49
+ input?: unknown
50
+ method?: "POST" | "GET"
51
+ /** AGUI506 window; default 30 000 ms. */
52
+ keepaliveWindowMs?: number
53
+ /** Abort the request after this long (validateEndpoint only). */
54
+ timeoutMs?: number
55
+ /** Injectable fetch (tests, custom stacks). Default: globalThis.fetch. */
56
+ fetchImpl?: FetchLike
57
+ /** Injectable clock for keepalive/buffering measurement. Default: Date.now. */
58
+ now?: () => number
59
+ /**
60
+ * The body is a recording (file, stdin) rather than a live connection.
61
+ * Timing-based rules (AGUI506, AGUI507) and mid-stream disconnect detection
62
+ * (AGUI508) are meaningless for recordings; they are reported as skipped
63
+ * with a reason instead of risking false positives, and read failures become
64
+ * TransportErrors (tool failure) rather than AGUI508 findings.
65
+ */
66
+ recorded?: boolean
67
+ signal?: AbortSignal
68
+ /** Called after each fed event with the diagnostics it produced. */
69
+ onEvent?: (raw: string, diagnostics: Diagnostic[]) => void
70
+ /** Called for every diagnostic as soon as it is detected. */
71
+ onDiagnostic?: (diagnostic: Diagnostic) => void
72
+ }
73
+
74
+ export interface TransportResult {
75
+ report: Report
76
+ /** HTTP status; null when validating a bare body/recording. */
77
+ status: number | null
78
+ contentType: string | null
79
+ /** Events fed to the validator (SSE frames / NDJSON lines). */
80
+ eventCount: number
81
+ /** Present when the connection failed mid-stream (see AGUI508). */
82
+ transportError?: string
83
+ }
84
+
85
+ function randomId(prefix: string): string {
86
+ const c = globalThis.crypto
87
+ const suffix =
88
+ c !== undefined && "randomUUID" in c
89
+ ? c.randomUUID().slice(0, 8)
90
+ : Math.random().toString(36).slice(2, 10)
91
+ return `${prefix}_${suffix}`
92
+ }
93
+
94
+ /** The minimal valid RunAgentInput POSTed when none is supplied. */
95
+ export function defaultRunAgentInput(): Record<string, unknown> {
96
+ return {
97
+ threadId: randomId("thread"),
98
+ runId: randomId("run"),
99
+ state: {},
100
+ messages: [{ id: randomId("msg"), role: "user", content: "Hello! Please respond briefly." }],
101
+ tools: [],
102
+ context: [],
103
+ forwardedProps: {},
104
+ }
105
+ }
106
+
107
+ async function* iterateBody(
108
+ body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
109
+ ): AsyncGenerator<Uint8Array> {
110
+ if (Symbol.asyncIterator in body) {
111
+ yield* body as AsyncIterable<Uint8Array>
112
+ return
113
+ }
114
+ const reader = (body as ReadableStream<Uint8Array>).getReader()
115
+ try {
116
+ for (;;) {
117
+ const { done, value } = await reader.read()
118
+ if (done) return
119
+ if (value !== undefined) yield value
120
+ }
121
+ } finally {
122
+ reader.releaseLock()
123
+ }
124
+ }
125
+
126
+ /** Buffers up to the first line to guess SSE vs NDJSON, then replays. */
127
+ async function sniffFormat(
128
+ source: AsyncGenerator<Uint8Array>,
129
+ ): Promise<{ format: "sse" | "ndjson"; replay: AsyncGenerator<Uint8Array> }> {
130
+ const held: Uint8Array[] = []
131
+ const decoder = new TextDecoder()
132
+ let text = ""
133
+ while (!text.includes("\n") && text.length < 4096) {
134
+ const { done, value } = await source.next()
135
+ if (done) break
136
+ held.push(value)
137
+ text += decoder.decode(value, { stream: true })
138
+ }
139
+ const firstLine = (text.split("\n")[0] ?? "").trim()
140
+ const format = firstLine.startsWith("{") || firstLine.startsWith("[") ? "ndjson" : "sse"
141
+ async function* replay(): AsyncGenerator<Uint8Array> {
142
+ yield* held
143
+ for (;;) {
144
+ const { done, value } = await source.next()
145
+ if (done) return
146
+ yield value
147
+ }
148
+ }
149
+ return { format, replay: replay() }
150
+ }
151
+
152
+ export async function validateBody(
153
+ body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
154
+ contentType: string | null,
155
+ opts: TransportOptions = {},
156
+ ): Promise<TransportResult> {
157
+ const userLayers = opts.validator?.layers ?? []
158
+ const layers: ValidationLayer[] = [...new Set<ValidationLayer>([...userLayers, "core", "transport"])]
159
+ const v = createValidator({ ...(opts.validator ?? {}), layers })
160
+ const now = opts.now ?? Date.now
161
+ const keepaliveWindow = opts.keepaliveWindowMs ?? DEFAULT_KEEPALIVE_MS
162
+ const recorded = opts.recorded === true
163
+
164
+ const emitTransport: typeof v.emitExternal = (rule, params, extra) => {
165
+ const d = v.emitExternal(rule, params, extra)
166
+ if (d !== null) opts.onDiagnostic?.(d)
167
+ return d
168
+ }
169
+
170
+ // Byte-level tap: chunk arrival times drive AGUI506 (silent gaps) and
171
+ // AGUI507 (everything in one chunk = buffered, not flushed).
172
+ let chunkCount = 0
173
+ let maxGapMs = 0
174
+ let lastArrival: number | null = null
175
+ async function* tapped(): AsyncGenerator<Uint8Array> {
176
+ for await (const chunk of iterateBody(body)) {
177
+ const t = now()
178
+ if (lastArrival !== null) maxGapMs = Math.max(maxGapMs, t - lastArrival)
179
+ lastArrival = t
180
+ chunkCount += 1
181
+ yield chunk
182
+ }
183
+ }
184
+
185
+ const mime = contentType === null ? null : (contentType.split(";")[0] ?? "").trim().toLowerCase()
186
+ if (contentType === null) {
187
+ v.markSkipped("AGUI505", "no Content-Type header is available for this input")
188
+ } else if (mime !== "text/event-stream" && mime !== "application/x-ndjson") {
189
+ emitTransport("AGUI505", { contentType: mime === "" ? "(none)" : mime })
190
+ }
191
+ if (recorded) {
192
+ v.markSkipped("AGUI506", "keepalive timing is not meaningful for recorded input")
193
+ v.markSkipped("AGUI507", "chunk arrival timing is not meaningful for recorded input")
194
+ v.markSkipped(
195
+ "AGUI508",
196
+ "abnormal disconnects cannot be distinguished from end-of-capture in recorded input",
197
+ )
198
+ }
199
+
200
+ let format: "sse" | "ndjson"
201
+ let stream: AsyncGenerator<Uint8Array> = tapped()
202
+ if (mime === "text/event-stream") format = "sse"
203
+ else if (mime === "application/x-ndjson") format = "ndjson"
204
+ else ({ format, replay: stream } = await sniffFormat(stream))
205
+ if (format === "ndjson") {
206
+ v.markSkipped("AGUI501", "the stream is NDJSON; there is no SSE framing to check")
207
+ }
208
+
209
+ let eventCount = 0
210
+ let runOpen = false
211
+ let openRunId: string | null = null
212
+ const feedRaw = (raw: string): void => {
213
+ eventCount += 1
214
+ const diags = v.feed(raw)
215
+ try {
216
+ const parsed = JSON.parse(raw) as { type?: unknown; runId?: unknown }
217
+ if (parsed?.type === "RUN_STARTED") {
218
+ runOpen = true
219
+ openRunId = typeof parsed.runId === "string" ? parsed.runId : null
220
+ } else if (parsed?.type === "RUN_FINISHED" || parsed?.type === "RUN_ERROR") {
221
+ runOpen = false
222
+ }
223
+ } catch {
224
+ // unparseable payloads are already AGUI502 diagnostics from the core
225
+ }
226
+ opts.onEvent?.(raw, diags)
227
+ if (opts.onDiagnostic !== undefined) for (const d of diags) opts.onDiagnostic(d)
228
+ }
229
+
230
+ let transportError: string | undefined
231
+ try {
232
+ if (format === "sse") {
233
+ for await (const item of sseItems(stream)) {
234
+ if (item.kind === "event") feedRaw(item.data)
235
+ else if (item.kind === "problem") emitTransport("AGUI501", { detail: item.detail })
236
+ }
237
+ } else {
238
+ for await (const line of ndjsonLines(stream)) feedRaw(line)
239
+ }
240
+ } catch (e) {
241
+ if (recorded) {
242
+ // A read failure on a recording is a broken input, not an observation
243
+ // about the agent's transport behavior.
244
+ throw e instanceof TransportError
245
+ ? e
246
+ : new TransportError(
247
+ `failed to read recorded input: ${e instanceof Error ? e.message : String(e)}`,
248
+ )
249
+ }
250
+ transportError = e instanceof Error ? e.message : String(e)
251
+ if (runOpen) {
252
+ // The connection died mid-run. The core's finalize will additionally
253
+ // report the run (and any open streams) as unterminated — both are true
254
+ // statements about the observed stream.
255
+ emitTransport("AGUI508", { runId: openRunId ?? "(unknown)" })
256
+ }
257
+ }
258
+
259
+ if (!recorded) {
260
+ if (lastArrival !== null) maxGapMs = Math.max(maxGapMs, now() - lastArrival)
261
+ if (maxGapMs > keepaliveWindow) {
262
+ emitTransport("AGUI506", { seconds: Math.round(maxGapMs / 1000) })
263
+ }
264
+ if (chunkCount === 1 && eventCount >= 3) {
265
+ emitTransport("AGUI507", { detail: `entire body (${eventCount} events) arrived in a single chunk` })
266
+ }
267
+ }
268
+
269
+ const finalDiags = v.finalize()
270
+ if (opts.onDiagnostic !== undefined) for (const d of finalDiags) opts.onDiagnostic(d)
271
+
272
+ const result: TransportResult = {
273
+ report: v.report(),
274
+ status: null,
275
+ contentType,
276
+ eventCount,
277
+ }
278
+ if (transportError !== undefined) result.transportError = transportError
279
+ return result
280
+ }
281
+
282
+ export async function validateEndpoint(
283
+ url: string,
284
+ opts: TransportOptions = {},
285
+ ): Promise<TransportResult> {
286
+ const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as FetchLike | undefined)
287
+ if (fetchImpl === undefined) {
288
+ throw new TransportError("no fetch implementation available; pass fetchImpl")
289
+ }
290
+
291
+ const method = opts.method ?? "POST"
292
+ const headers: Record<string, string> = {
293
+ accept: "text/event-stream, application/x-ndjson",
294
+ ...(method === "POST" ? { "content-type": "application/json" } : {}),
295
+ ...(opts.headers ?? {}),
296
+ }
297
+
298
+ const controller = new AbortController()
299
+ const timers: ReturnType<typeof setTimeout>[] = []
300
+ if (opts.timeoutMs !== undefined) {
301
+ timers.push(
302
+ setTimeout(
303
+ () => controller.abort(new TransportError(`timed out after ${opts.timeoutMs}ms`)),
304
+ opts.timeoutMs,
305
+ ),
306
+ )
307
+ }
308
+ opts.signal?.addEventListener("abort", () => controller.abort(opts.signal?.reason), { once: true })
309
+
310
+ try {
311
+ let res: TransportResponseLike
312
+ try {
313
+ const init: TransportRequestInit = { method, headers, signal: controller.signal }
314
+ if (method === "POST") init.body = JSON.stringify(opts.input ?? defaultRunAgentInput())
315
+ res = await fetchImpl(url, init)
316
+ } catch (e) {
317
+ throw e instanceof TransportError
318
+ ? e
319
+ : new TransportError(`request failed: ${e instanceof Error ? e.message : String(e)}`)
320
+ }
321
+ if (res.status < 200 || res.status >= 300) {
322
+ throw new TransportError(`endpoint responded with HTTP ${res.status}`, res.status)
323
+ }
324
+ if (res.body === null) throw new TransportError("response has no body", res.status)
325
+ const contentType = res.headers.get("content-type") ?? ""
326
+ const result = await validateBody(res.body, contentType, opts)
327
+ return { ...result, status: res.status }
328
+ } finally {
329
+ for (const timer of timers) clearTimeout(timer)
330
+ }
331
+ }
@@ -0,0 +1,25 @@
1
+ // Incremental NDJSON (application/x-ndjson) line splitter. Yields non-empty
2
+ // lines with trailing CR stripped; the core decides whether they parse.
3
+
4
+ export async function* ndjsonLines(source: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
5
+ const decoder = new TextDecoder("utf-8")
6
+ let buffer = ""
7
+
8
+ function clean(line: string): string | null {
9
+ const trimmed = line.endsWith("\r") ? line.slice(0, -1) : line
10
+ return trimmed.trim() === "" ? null : trimmed
11
+ }
12
+
13
+ for await (const chunk of source) {
14
+ buffer += decoder.decode(chunk, { stream: true })
15
+ let i: number
16
+ while ((i = buffer.indexOf("\n")) !== -1) {
17
+ const line = clean(buffer.slice(0, i))
18
+ buffer = buffer.slice(i + 1)
19
+ if (line !== null) yield line
20
+ }
21
+ }
22
+ buffer += decoder.decode()
23
+ const last = clean(buffer)
24
+ if (last !== null) yield last
25
+ }
@@ -0,0 +1,126 @@
1
+ // WHATWG-compliant incremental SSE (text/event-stream) parser.
2
+ // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
3
+ //
4
+ // Beyond plain parsing it surfaces framing anomalies relevant to AGUI501:
5
+ // - json-line-without-data-prefix: a line that looks like a JSON payload but
6
+ // has no "data:" field prefix. Spec-wise it is an unknown field and gets
7
+ // silently dropped by every SSE client — the classic broken-server bug.
8
+ // - truncated-frame: the stream ended mid-frame (pending data never
9
+ // dispatched, or a partial final line).
10
+
11
+ export type SseProblemCode = "json-line-without-data-prefix" | "truncated-frame"
12
+
13
+ export type SseItem =
14
+ | { kind: "event"; data: string; event?: string; id?: string }
15
+ | { kind: "comment"; text: string }
16
+ | { kind: "problem"; code: SseProblemCode; detail: string }
17
+
18
+ const truncate = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n)}…` : s)
19
+
20
+ export async function* sseItems(source: AsyncIterable<Uint8Array>): AsyncGenerator<SseItem> {
21
+ const decoder = new TextDecoder("utf-8")
22
+ let buffer = ""
23
+ let sawFirstChars = false
24
+ let dataLines: string[] = []
25
+ let eventName = ""
26
+ let lastId: string | undefined
27
+
28
+ function handleLine(line: string): SseItem | null {
29
+ if (line === "") {
30
+ // Dispatch. Per spec, an empty data buffer dispatches nothing.
31
+ const data = dataLines.join("\n")
32
+ const hadData = dataLines.length > 0
33
+ dataLines = []
34
+ const name = eventName
35
+ eventName = ""
36
+ if (!hadData || data === "") return null
37
+ const item: SseItem = { kind: "event", data }
38
+ if (name !== "") item.event = name
39
+ if (lastId !== undefined) item.id = lastId
40
+ return item
41
+ }
42
+ if (line.startsWith(":")) return { kind: "comment", text: line.slice(1) }
43
+
44
+ const colon = line.indexOf(":")
45
+ const field = colon === -1 ? line : line.slice(0, colon)
46
+ let value = colon === -1 ? "" : line.slice(colon + 1)
47
+ if (value.startsWith(" ")) value = value.slice(1)
48
+
49
+ switch (field) {
50
+ case "data":
51
+ dataLines.push(value)
52
+ return null
53
+ case "event":
54
+ eventName = value
55
+ return null
56
+ case "id":
57
+ if (!value.includes("\0")) lastId = value
58
+ return null
59
+ case "retry":
60
+ return null
61
+ default: {
62
+ // Unknown field: ignored per spec — but a JSON-looking line is almost
63
+ // certainly a payload missing its "data:" prefix, silently lost.
64
+ const trimmed = line.trimStart()
65
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
66
+ return {
67
+ kind: "problem",
68
+ code: "json-line-without-data-prefix",
69
+ detail: `line '${truncate(trimmed, 60)}' looks like a JSON payload but lacks the 'data:' field prefix, so SSE clients silently drop it`,
70
+ }
71
+ }
72
+ return null
73
+ }
74
+ }
75
+ }
76
+
77
+ function* drainLines(eof: boolean): Generator<SseItem> {
78
+ for (;;) {
79
+ const nl = buffer.indexOf("\n")
80
+ const cr = buffer.indexOf("\r")
81
+ let end: number
82
+ let next: number
83
+ if (cr !== -1 && (nl === -1 || cr < nl)) {
84
+ // Hold back a trailing CR mid-stream: it may be a CRLF split across
85
+ // chunk boundaries.
86
+ if (cr === buffer.length - 1 && !eof) return
87
+ end = cr
88
+ next = buffer[cr + 1] === "\n" ? cr + 2 : cr + 1
89
+ } else if (nl !== -1) {
90
+ end = nl
91
+ next = nl + 1
92
+ } else {
93
+ return
94
+ }
95
+ const line = buffer.slice(0, end)
96
+ buffer = buffer.slice(next)
97
+ const item = handleLine(line)
98
+ if (item !== null) yield item
99
+ }
100
+ }
101
+
102
+ for await (const chunk of source) {
103
+ buffer += decoder.decode(chunk, { stream: true })
104
+ if (!sawFirstChars && buffer.length > 0) {
105
+ if (buffer.startsWith("")) buffer = buffer.slice(1)
106
+ sawFirstChars = true
107
+ }
108
+ yield* drainLines(false)
109
+ }
110
+ buffer += decoder.decode()
111
+ yield* drainLines(true)
112
+
113
+ if (buffer !== "") {
114
+ yield {
115
+ kind: "problem",
116
+ code: "truncated-frame",
117
+ detail: `stream ended mid-line: '${truncate(buffer, 60)}' (no trailing newline; the frame was never dispatched)`,
118
+ }
119
+ } else if (dataLines.length > 0) {
120
+ yield {
121
+ kind: "problem",
122
+ code: "truncated-frame",
123
+ detail: "stream ended with a pending frame that was never terminated by a blank line",
124
+ }
125
+ }
126
+ }
package/src/types.ts ADDED
@@ -0,0 +1,136 @@
1
+ import type { Severity, SeverityOrOff } from "./rules/catalog.js"
2
+
3
+ export type { Severity, SeverityOrOff }
4
+
5
+ /** A single conformance finding. */
6
+ export interface Diagnostic {
7
+ /** Rule ID, e.g. "AGUI203". */
8
+ rule: string
9
+ severity: Severity
10
+ /** Human-readable, includes the offending id/value. */
11
+ message: string
12
+ /**
13
+ * 0-based position in the stream of the event this diagnostic is about.
14
+ * Stream-level diagnostics (e.g. AGUI902) use -1.
15
+ */
16
+ eventIndex: number
17
+ /** The event's declared type, if parseable. */
18
+ eventType?: string
19
+ /** RFC 6901 JSON pointer into the event, e.g. "/toolCallId". */
20
+ pointer?: string
21
+ /** e.g. the unterminated TOOL_CALL_START this refers back to. */
22
+ relatedEventIndex?: number
23
+ /** Link to the governing spec section. Always populated. */
24
+ specUrl: string
25
+ }
26
+
27
+ /** The seven canonical AG-UI features (from the AG-UI Dojo). */
28
+ export type CanonicalFeature =
29
+ | "agentic-chat"
30
+ | "backend-tool-rendering"
31
+ | "human-in-the-loop"
32
+ | "agentic-generative-ui"
33
+ | "tool-based-generative-ui"
34
+ | "shared-state"
35
+ | "predictive-state-updates"
36
+
37
+ export const CANONICAL_FEATURES: readonly CanonicalFeature[] = [
38
+ "agentic-chat",
39
+ "backend-tool-rendering",
40
+ "human-in-the-loop",
41
+ "agentic-generative-ui",
42
+ "tool-based-generative-ui",
43
+ "shared-state",
44
+ "predictive-state-updates",
45
+ ]
46
+
47
+ /**
48
+ * Feature-matrix status. Capability discovery (getCapabilities()) is
49
+ * out-of-band and invisible to a passive stream observer, so this matrix is
50
+ * inferred from observed events; features whose exercise cannot be
51
+ * distinguished passively are "not-inferable". See docs/spec-questions.md SQ-13.
52
+ */
53
+ export type FeatureStatus = "exercised" | "not-exercised" | "not-inferable"
54
+
55
+ export type FeatureMatrix = Record<CanonicalFeature, FeatureStatus>
56
+
57
+ export interface Summary {
58
+ errors: number
59
+ warnings: number
60
+ info: number
61
+ }
62
+
63
+ export interface SkippedRule {
64
+ rule: string
65
+ reason: string
66
+ }
67
+
68
+ export interface Report {
69
+ diagnostics: Diagnostic[]
70
+ summary: Summary
71
+ features: FeatureMatrix
72
+ /**
73
+ * Rules that were not evaluated in this mode and why — e.g. transport rules
74
+ * when validating recorded input. Skips are reported, never silent.
75
+ */
76
+ skipped: SkippedRule[]
77
+ eventCount: number
78
+ /**
79
+ * Unexpected internal validator errors. The validator never throws on any
80
+ * input; if a check itself crashes, the message lands here instead.
81
+ */
82
+ internalErrors: string[]
83
+ }
84
+
85
+ export type ValidationLayer = "core" | "transport"
86
+
87
+ export interface ValidatorOptions {
88
+ /** Pins the rule set to a spec version. Only "0.x" exists today. */
89
+ spec?: "0.x"
90
+ /**
91
+ * Declared features. Enables feature-conditional rules (e.g. AGUI305 fires
92
+ * only when "shared-state" is declared).
93
+ */
94
+ features?: string[]
95
+ /** Per-rule severity overrides; "off" disables a rule. */
96
+ severityOverrides?: Record<string, SeverityOrOff>
97
+ /**
98
+ * Which rule layers are being evaluated. "core" is always on. Wrapping
99
+ * layers that check transport rules (via emitExternal) declare "transport"
100
+ * so those rules stop being reported as skipped. Default: ["core"].
101
+ */
102
+ layers?: ValidationLayer[]
103
+ }
104
+
105
+ export interface Validator {
106
+ /**
107
+ * Feed one event — a parsed object, or a raw JSON string (malformed JSON is
108
+ * a diagnostic, not an exception). Returns diagnostics detectable at this
109
+ * event, in stream order. Never throws.
110
+ */
111
+ feed(event: unknown): Diagnostic[]
112
+ /**
113
+ * End-of-stream checks (unterminated tool calls, missing RUN_FINISHED, …).
114
+ * Idempotent: second and later calls return []. Never throws.
115
+ */
116
+ finalize(): Diagnostic[]
117
+ /** Cumulative report over everything fed so far. Never throws. */
118
+ report(): Report
119
+ /**
120
+ * For wrapping layers (transport, CLI): report a layer-checked rule through
121
+ * the same catalog formatting, severity overrides, and summary as core
122
+ * diagnostics. Returns the diagnostic, or null when the rule is unknown
123
+ * (recorded in internalErrors) or overridden off. Never throws.
124
+ */
125
+ emitExternal(
126
+ rule: string,
127
+ params?: Record<string, unknown>,
128
+ extra?: { eventIndex?: number; pointer?: string; relatedEventIndex?: number },
129
+ ): Diagnostic | null
130
+ /**
131
+ * For wrapping layers: declare that a rule was NOT evaluated and why (e.g.
132
+ * timing rules on recorded input). The entry appears in report().skipped,
133
+ * replacing any layer-computed entry for the same rule. Never throws.
134
+ */
135
+ markSkipped(rule: string, reason: string): void
136
+ }
@@ -0,0 +1,19 @@
1
+ import { expect } from "vitest"
2
+ import { toBeValidAGUI } from "./matcher.js"
3
+ import type { ToBeValidAGUIOptions } from "./matcher.js"
4
+
5
+ export { toBeValidAGUI } from "./matcher.js"
6
+ export type { MatcherResult, ToBeValidAGUIOptions } from "./matcher.js"
7
+
8
+ declare module "vitest" {
9
+ // The type parameter must match vitest's own `interface Matchers<T = any>`.
10
+ interface Matchers<T = any> {
11
+ toBeValidAGUI(options?: ToBeValidAGUIOptions): T
12
+ }
13
+ }
14
+
15
+ expect.extend({
16
+ toBeValidAGUI(received: unknown, options?: ToBeValidAGUIOptions) {
17
+ return toBeValidAGUI(received, options)
18
+ },
19
+ })