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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/catalog-BglXBNbL.js +472 -0
- package/dist/catalog-BglXBNbL.js.map +1 -0
- package/dist/catalog-Ci9dqc1a.cjs +495 -0
- package/dist/catalog-Ci9dqc1a.cjs.map +1 -0
- package/dist/cli.js +2783 -0
- package/dist/cli.js.map +1 -0
- package/dist/index-Hmqj3r_r.d.cts +52 -0
- package/dist/index-oNG1kOp9.d.ts +52 -0
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/report.cjs +139 -0
- package/dist/report.cjs.map +1 -0
- package/dist/report.d.cts +85 -0
- package/dist/report.d.ts +85 -0
- package/dist/report.js +134 -0
- package/dist/report.js.map +1 -0
- package/dist/src-HmI-kxef.cjs +1596 -0
- package/dist/src-HmI-kxef.cjs.map +1 -0
- package/dist/src-rGZ2G4qA.js +1555 -0
- package/dist/src-rGZ2G4qA.js.map +1 -0
- package/dist/transport.cjs +329 -0
- package/dist/transport.cjs.map +1 -0
- package/dist/transport.d.cts +89 -0
- package/dist/transport.d.ts +89 -0
- package/dist/transport.js +323 -0
- package/dist/transport.js.map +1 -0
- package/dist/types-oH_QTnn2.d.cts +148 -0
- package/dist/types-oH_QTnn2.d.ts +148 -0
- package/dist/vitest.d.ts +28 -0
- package/dist/vitest.js +2089 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +127 -0
- package/src/cli-args.ts +202 -0
- package/src/cli.ts +147 -0
- package/src/index.ts +465 -0
- package/src/protocol/event-table.ts +316 -0
- package/src/protocol/jsonpatch.ts +220 -0
- package/src/report/index.ts +10 -0
- package/src/report/json.ts +20 -0
- package/src/report/junit.ts +56 -0
- package/src/report/pretty.ts +59 -0
- package/src/report/sarif.ts +109 -0
- package/src/rules/catalog.json +431 -0
- package/src/rules/catalog.ts +84 -0
- package/src/rules/checks/context.ts +117 -0
- package/src/rules/checks/lifecycle.ts +59 -0
- package/src/rules/checks/reasoning.ts +97 -0
- package/src/rules/checks/state.ts +72 -0
- package/src/rules/checks/text.ts +109 -0
- package/src/rules/checks/toolcalls.ts +167 -0
- package/src/rules/checks/transport.ts +17 -0
- package/src/transport/index.ts +331 -0
- package/src/transport/ndjson.ts +25 -0
- package/src/transport/sse.ts +126 -0
- package/src/types.ts +136 -0
- package/src/vitest/index.ts +19 -0
- 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
|
+
})
|