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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The ag-ui-validate executable — the one deliberately Node-only file in src/.
|
|
3
|
+
// Everything it does is delegated: parsing to cli-args.ts, validation to the
|
|
4
|
+
// core + transport layers, output to the pure reporters. This file only wires
|
|
5
|
+
// process.argv/stdin/stdout/exit codes to those pieces.
|
|
6
|
+
import { createReadStream, readFileSync, writeFileSync } from "node:fs"
|
|
7
|
+
import process from "node:process"
|
|
8
|
+
import { decideExitCode, parseCliArgs, USAGE } from "./cli-args.js"
|
|
9
|
+
import type { CliConfig } from "./cli-args.js"
|
|
10
|
+
import { formatDiagnosticLine, formatReportSummary, toJsonReport, toJUnit, toSarif } from "./report/index.js"
|
|
11
|
+
import { TransportError, validateBody, validateEndpoint } from "./transport/index.js"
|
|
12
|
+
import type { TransportOptions, TransportResult } from "./transport/index.js"
|
|
13
|
+
import type { Diagnostic, ValidatorOptions } from "./types.js"
|
|
14
|
+
|
|
15
|
+
const TOOL_NAME = "ag-ui-validate"
|
|
16
|
+
|
|
17
|
+
function toolVersion(): string {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
20
|
+
version?: string
|
|
21
|
+
}
|
|
22
|
+
return pkg.version ?? "0.0.0"
|
|
23
|
+
} catch {
|
|
24
|
+
return "0.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function transportOptions(
|
|
29
|
+
config: CliConfig,
|
|
30
|
+
onDiagnostic: ((d: Diagnostic) => void) | undefined,
|
|
31
|
+
): TransportOptions {
|
|
32
|
+
const validator: ValidatorOptions = {}
|
|
33
|
+
if (config.features !== undefined) validator.features = config.features
|
|
34
|
+
if (Object.keys(config.severityOverrides).length > 0) {
|
|
35
|
+
validator.severityOverrides = config.severityOverrides
|
|
36
|
+
}
|
|
37
|
+
const opts: TransportOptions = { validator }
|
|
38
|
+
if (onDiagnostic !== undefined) opts.onDiagnostic = onDiagnostic
|
|
39
|
+
return opts
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main(): Promise<number> {
|
|
43
|
+
const parsed = parseCliArgs(process.argv.slice(2))
|
|
44
|
+
if (!parsed.ok) {
|
|
45
|
+
process.stderr.write(`error: ${parsed.error}\n\n${USAGE}`)
|
|
46
|
+
return 2
|
|
47
|
+
}
|
|
48
|
+
const config = parsed.config
|
|
49
|
+
if (config.help) {
|
|
50
|
+
process.stdout.write(USAGE)
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
const version = toolVersion()
|
|
54
|
+
if (config.version) {
|
|
55
|
+
process.stdout.write(`${TOOL_NAME} ${version}\n`)
|
|
56
|
+
return 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const color =
|
|
60
|
+
config.color ?? (process.stdout.isTTY === true && process.env.NO_COLOR === undefined)
|
|
61
|
+
const pretty = config.format === "pretty"
|
|
62
|
+
|
|
63
|
+
// Pretty mode streams findings as they are detected; machine formats emit
|
|
64
|
+
// one document at the end, so nothing else may touch stdout before it.
|
|
65
|
+
let printed = 0
|
|
66
|
+
const onDiagnostic = pretty
|
|
67
|
+
? (d: Diagnostic): void => {
|
|
68
|
+
printed += 1
|
|
69
|
+
process.stdout.write(`${formatDiagnosticLine(d, { color })}\n`)
|
|
70
|
+
}
|
|
71
|
+
: undefined
|
|
72
|
+
|
|
73
|
+
const isUrl = /^https?:\/\//i.test(config.target)
|
|
74
|
+
let result: TransportResult
|
|
75
|
+
let targetLabel: string
|
|
76
|
+
if (isUrl) {
|
|
77
|
+
targetLabel = config.target
|
|
78
|
+
const opts = transportOptions(config, onDiagnostic)
|
|
79
|
+
if (Object.keys(config.headers).length > 0) opts.headers = config.headers
|
|
80
|
+
if (config.timeoutMs !== undefined) opts.timeoutMs = config.timeoutMs
|
|
81
|
+
result = await validateEndpoint(config.target, opts)
|
|
82
|
+
} else if (config.target === "-") {
|
|
83
|
+
targetLabel = "stdin"
|
|
84
|
+
if (process.stdin.isTTY === true) {
|
|
85
|
+
process.stderr.write("error: stdin is a terminal — pipe a recording in, or pass a file path\n")
|
|
86
|
+
return 2
|
|
87
|
+
}
|
|
88
|
+
result = await validateBody(process.stdin, null, {
|
|
89
|
+
...transportOptions(config, onDiagnostic),
|
|
90
|
+
recorded: true,
|
|
91
|
+
})
|
|
92
|
+
} else {
|
|
93
|
+
targetLabel = config.target
|
|
94
|
+
result = await validateBody(createReadStream(config.target), null, {
|
|
95
|
+
...transportOptions(config, onDiagnostic),
|
|
96
|
+
recorded: true,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const report = result.report
|
|
101
|
+
// Line-based SARIF locations only make sense when events map 1:1 to lines.
|
|
102
|
+
const lineOriented = !isUrl && config.target !== "-" && /\.(jsonl|ndjson)$/i.test(config.target)
|
|
103
|
+
const sarifOptions = lineOriented
|
|
104
|
+
? { toolVersion: version, artifactUri: config.target }
|
|
105
|
+
: { toolVersion: version }
|
|
106
|
+
|
|
107
|
+
if (config.jsonFile !== undefined) {
|
|
108
|
+
const doc = toJsonReport(report, { tool: { name: TOOL_NAME, version }, target: targetLabel })
|
|
109
|
+
writeFileSync(config.jsonFile, `${JSON.stringify(doc, null, 2)}\n`)
|
|
110
|
+
}
|
|
111
|
+
if (config.sarifFile !== undefined) {
|
|
112
|
+
writeFileSync(config.sarifFile, `${JSON.stringify(toSarif(report, sarifOptions), null, 2)}\n`)
|
|
113
|
+
}
|
|
114
|
+
if (config.junitFile !== undefined) {
|
|
115
|
+
writeFileSync(config.junitFile, toJUnit(report, { name: targetLabel }))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (pretty) {
|
|
119
|
+
if (printed > 0) process.stdout.write("\n")
|
|
120
|
+
process.stdout.write(`${formatReportSummary(report, { color })}\n`)
|
|
121
|
+
} else if (config.format === "json") {
|
|
122
|
+
const doc = toJsonReport(report, { tool: { name: TOOL_NAME, version }, target: targetLabel })
|
|
123
|
+
process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`)
|
|
124
|
+
} else if (config.format === "sarif") {
|
|
125
|
+
process.stdout.write(`${JSON.stringify(toSarif(report, sarifOptions), null, 2)}\n`)
|
|
126
|
+
} else {
|
|
127
|
+
process.stdout.write(toJUnit(report, { name: targetLabel }))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return decideExitCode(report.summary, config.maxWarnings)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main().then(
|
|
134
|
+
(code) => {
|
|
135
|
+
process.exitCode = code
|
|
136
|
+
},
|
|
137
|
+
(e: unknown) => {
|
|
138
|
+
const message =
|
|
139
|
+
e instanceof TransportError
|
|
140
|
+
? e.message
|
|
141
|
+
: e instanceof Error
|
|
142
|
+
? (e.stack ?? e.message)
|
|
143
|
+
: String(e)
|
|
144
|
+
process.stderr.write(`error: ${message}\n`)
|
|
145
|
+
process.exitCode = 2
|
|
146
|
+
},
|
|
147
|
+
)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// ag-ui-validate core: a pure function over an AG-UI event sequence.
|
|
2
|
+
// Zero I/O, zero runtime dependencies, isomorphic. Transports, the CLI, and
|
|
3
|
+
// the Vitest matcher are thin wrappers over this module.
|
|
4
|
+
//
|
|
5
|
+
// Two invariants hold everywhere here:
|
|
6
|
+
// - the validator NEVER throws on input: broken input is its input;
|
|
7
|
+
// - every diagnostic cites the spec section that governs it (specUrl).
|
|
8
|
+
|
|
9
|
+
import { DRAFT_EVENT_TYPES, EVENT_TABLE, EVENT_TYPES, SDK_VERSION } from "./protocol/event-table.js"
|
|
10
|
+
import type { EventSpec } from "./protocol/event-table.js"
|
|
11
|
+
import { RULES, formatMessage } from "./rules/catalog.js"
|
|
12
|
+
import type { CheckApi, EmitFn, RunState, StreamState } from "./rules/checks/context.js"
|
|
13
|
+
import { newRunState, str } from "./rules/checks/context.js"
|
|
14
|
+
import { checkRunIdStability, endOfRunSteps, handleStepEvent } from "./rules/checks/lifecycle.js"
|
|
15
|
+
import { closeReasoningChunk, endOfRunReasoning, handleReasoningEvent } from "./rules/checks/reasoning.js"
|
|
16
|
+
import { handleStateEvent } from "./rules/checks/state.js"
|
|
17
|
+
import { closeTextChunk, endOfRunText, handleTextEvent } from "./rules/checks/text.js"
|
|
18
|
+
import { closeToolChunk, endOfRunToolCalls, handleToolCallEvent } from "./rules/checks/toolcalls.js"
|
|
19
|
+
import { TRANSPORT_RULE_IDS, TRANSPORT_SKIP_REASON } from "./rules/checks/transport.js"
|
|
20
|
+
import type {
|
|
21
|
+
CanonicalFeature,
|
|
22
|
+
Diagnostic,
|
|
23
|
+
FeatureMatrix,
|
|
24
|
+
Report,
|
|
25
|
+
Severity,
|
|
26
|
+
Validator,
|
|
27
|
+
ValidatorOptions,
|
|
28
|
+
} from "./types.js"
|
|
29
|
+
import { CANONICAL_FEATURES } from "./types.js"
|
|
30
|
+
|
|
31
|
+
export * from "./types.js"
|
|
32
|
+
export { CATALOG, RULES, formatMessage, validateCatalog } from "./rules/catalog.js"
|
|
33
|
+
export type { Catalog, RuleDefinition, Severity as RuleSeverity, SeverityOrOff } from "./rules/catalog.js"
|
|
34
|
+
export { EVENT_TABLE, EVENT_TYPES, SDK_VERSION } from "./protocol/event-table.js"
|
|
35
|
+
export type { EventCategory, EventSpec, FieldKind, FieldSpec } from "./protocol/event-table.js"
|
|
36
|
+
export { applyPatch, validatePatchShape } from "./protocol/jsonpatch.js"
|
|
37
|
+
|
|
38
|
+
const DRAFTS_META_URL = "https://docs.ag-ui.com/drafts/meta-events"
|
|
39
|
+
|
|
40
|
+
// Features whose exercise cannot be distinguished on a passive stream: both
|
|
41
|
+
// generative-UI features need knowledge of the frontend's tool/component
|
|
42
|
+
// registry, which is out-of-band (see SQ-13).
|
|
43
|
+
const NOT_INFERABLE: readonly CanonicalFeature[] = [
|
|
44
|
+
"agentic-generative-ui",
|
|
45
|
+
"tool-based-generative-ui",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/** "runStarted" → "RUN_STARTED": case-insensitive match against wire types. */
|
|
49
|
+
const CANONICAL_BY_SQUASHED: ReadonlyMap<string, string> = new Map(
|
|
50
|
+
EVENT_TYPES.map((t) => [t.replace(/_/g, "").toLowerCase(), t]),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
function describeValue(v: unknown): string {
|
|
54
|
+
if (v === null) return "null"
|
|
55
|
+
if (Array.isArray(v)) return "array"
|
|
56
|
+
return typeof v
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createValidator(opts: ValidatorOptions = {}): Validator {
|
|
60
|
+
const overrides = opts.severityOverrides ?? {}
|
|
61
|
+
const declaredFeatures = new Set(opts.features ?? [])
|
|
62
|
+
const layers = new Set(["core", ...(opts.layers ?? [])])
|
|
63
|
+
|
|
64
|
+
const diagnostics: Diagnostic[] = []
|
|
65
|
+
const internalErrors: string[] = []
|
|
66
|
+
const explicitSkips = new Map<string, string>()
|
|
67
|
+
const stream: StreamState = {
|
|
68
|
+
eventCount: 0,
|
|
69
|
+
sawTimestamp: false,
|
|
70
|
+
anySnapshot: false,
|
|
71
|
+
agui001Fired: false,
|
|
72
|
+
features: new Set(),
|
|
73
|
+
}
|
|
74
|
+
let run: RunState | null = null
|
|
75
|
+
let finalized = false
|
|
76
|
+
|
|
77
|
+
function mkEmit(batch: Diagnostic[], current: { index: number; type: string } | null): EmitFn {
|
|
78
|
+
return (ruleId, params, extra = {}) => {
|
|
79
|
+
const rule = RULES.get(ruleId)
|
|
80
|
+
if (rule === undefined) {
|
|
81
|
+
internalErrors.push(`emit() for unknown rule ${ruleId}`)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const override = overrides[ruleId]
|
|
85
|
+
if (override === "off") return
|
|
86
|
+
const severity: Severity = override ?? extra.severity ?? rule.severity
|
|
87
|
+
const aboutCurrentEvent = extra.eventIndex === undefined && current !== null
|
|
88
|
+
const diag: Diagnostic = {
|
|
89
|
+
rule: ruleId,
|
|
90
|
+
severity,
|
|
91
|
+
message: formatMessage(rule, params) + (extra.messageSuffix ?? ""),
|
|
92
|
+
eventIndex: extra.eventIndex ?? current?.index ?? -1,
|
|
93
|
+
specUrl: extra.specUrl ?? rule.specUrl,
|
|
94
|
+
}
|
|
95
|
+
if (aboutCurrentEvent) diag.eventType = current.type
|
|
96
|
+
if (extra.pointer !== undefined) diag.pointer = extra.pointer
|
|
97
|
+
if (extra.relatedEventIndex !== undefined) diag.relatedEventIndex = extra.relatedEventIndex
|
|
98
|
+
diagnostics.push(diag)
|
|
99
|
+
batch.push(diag)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateSchema(type: string, spec: EventSpec, ev: Record<string, unknown>, emit: EmitFn): void {
|
|
104
|
+
for (const [field, fs] of Object.entries(spec.fields)) {
|
|
105
|
+
const v = ev[field]
|
|
106
|
+
if (v === undefined) {
|
|
107
|
+
if (fs.required) {
|
|
108
|
+
emit("AGUI504", { type, detail: `missing required field '${field}' (${fs.kind})` }, {
|
|
109
|
+
pointer: `/${field}`,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
let kindOk = true
|
|
115
|
+
switch (fs.kind) {
|
|
116
|
+
case "string":
|
|
117
|
+
case "number":
|
|
118
|
+
case "boolean":
|
|
119
|
+
kindOk = typeof v === fs.kind
|
|
120
|
+
break
|
|
121
|
+
case "array":
|
|
122
|
+
kindOk = Array.isArray(v)
|
|
123
|
+
break
|
|
124
|
+
case "object":
|
|
125
|
+
kindOk = typeof v === "object" && v !== null && !Array.isArray(v)
|
|
126
|
+
break
|
|
127
|
+
case "any":
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
if (!kindOk) {
|
|
131
|
+
emit("AGUI504", { type, detail: `field '${field}' must be ${fs.kind === "array" ? "an" : "a"} ${fs.kind}, got ${describeValue(v)}` }, {
|
|
132
|
+
pointer: `/${field}`,
|
|
133
|
+
})
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (fs.enum !== undefined && typeof v === "string" && !fs.enum.includes(v)) {
|
|
137
|
+
emit("AGUI504", { type, detail: `field '${field}' must be one of ${fs.enum.join("|")}, got '${v}'` }, {
|
|
138
|
+
pointer: `/${field}`,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Chunk streams close implicitly on any event of a different type. */
|
|
145
|
+
function closeChunks(r: RunState, emit: EmitFn, atIndex: number, except?: string): void {
|
|
146
|
+
if (except !== "TEXT_MESSAGE_CHUNK") closeTextChunk(r, atIndex)
|
|
147
|
+
if (except !== "TOOL_CALL_CHUNK") closeToolChunk(r, emit, atIndex)
|
|
148
|
+
if (except !== "REASONING_MESSAGE_CHUNK") closeReasoningChunk(r)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Unterminated-at-run-end rules. Only on a *clean* end (RUN_FINISHED or a
|
|
152
|
+
* stream that just stops): after RUN_ERROR, open streams are expected
|
|
153
|
+
* debris of the failure, and flagging them would manufacture noise. */
|
|
154
|
+
function endOfRunChecks(r: RunState, emit: EmitFn, atIndex: number): void {
|
|
155
|
+
endOfRunText(r, emit, atIndex)
|
|
156
|
+
endOfRunToolCalls(r, emit, atIndex)
|
|
157
|
+
endOfRunSteps(r, emit, atIndex)
|
|
158
|
+
endOfRunReasoning(r, emit, atIndex)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Opens the implicit run scope for streams that never announced one. */
|
|
162
|
+
function ensureRun(index: number, type: string, emit: EmitFn): RunState {
|
|
163
|
+
if (run === null) {
|
|
164
|
+
if (!stream.agui001Fired) {
|
|
165
|
+
emit("AGUI001", { type }, {})
|
|
166
|
+
stream.agui001Fired = true
|
|
167
|
+
}
|
|
168
|
+
run = newRunState({ runId: null, threadId: null, startIndex: index, implicit: true })
|
|
169
|
+
}
|
|
170
|
+
return run
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function processEvent(input: unknown, batch: Diagnostic[]): void {
|
|
174
|
+
const index = stream.eventCount
|
|
175
|
+
stream.eventCount += 1
|
|
176
|
+
|
|
177
|
+
// 1. Parse. Malformed input is a diagnostic, never an exception.
|
|
178
|
+
let parsed: unknown = input
|
|
179
|
+
if (typeof input === "string") {
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(input)
|
|
182
|
+
} catch (e) {
|
|
183
|
+
mkEmit(batch, { index, type: "" })("AGUI502", {
|
|
184
|
+
error: e instanceof Error ? e.message : String(e),
|
|
185
|
+
})
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
190
|
+
mkEmit(batch, { index, type: "" })("AGUI502", {
|
|
191
|
+
error: `payload is ${describeValue(parsed)}, expected a JSON object`,
|
|
192
|
+
})
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const ev = parsed as Record<string, unknown>
|
|
196
|
+
|
|
197
|
+
// 2. The declared type is the key to everything else.
|
|
198
|
+
if (typeof ev.type !== "string") {
|
|
199
|
+
mkEmit(batch, { index, type: "" })("AGUI504", {
|
|
200
|
+
type: "(untyped)",
|
|
201
|
+
detail: "event has no string 'type' property",
|
|
202
|
+
}, { pointer: "/type" })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const type = ev.type
|
|
206
|
+
const emit = mkEmit(batch, { index, type })
|
|
207
|
+
|
|
208
|
+
// 3. Base-event fields.
|
|
209
|
+
if ("timestamp" in ev && ev.timestamp !== undefined) {
|
|
210
|
+
if (typeof ev.timestamp === "number") stream.sawTimestamp = true
|
|
211
|
+
else emit("AGUI504", { type, detail: `field 'timestamp' must be a number, got ${describeValue(ev.timestamp)}` }, { pointer: "/timestamp" })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 4. Unknown types: AGUI503 (documented drafts at info; casing hints for
|
|
215
|
+
// near-misses — SQ-7).
|
|
216
|
+
const spec = EVENT_TABLE[type]
|
|
217
|
+
if (spec === undefined) {
|
|
218
|
+
if (DRAFT_EVENT_TYPES.includes(type)) {
|
|
219
|
+
emit("AGUI503", { type, sdkVersion: SDK_VERSION }, {
|
|
220
|
+
severity: "info",
|
|
221
|
+
specUrl: DRAFTS_META_URL,
|
|
222
|
+
messageSuffix: " — documented draft event type, not yet in @ag-ui/core",
|
|
223
|
+
})
|
|
224
|
+
} else {
|
|
225
|
+
const canonical = CANONICAL_BY_SQUASHED.get(type.replace(/[_\-\s]/g, "").toLowerCase())
|
|
226
|
+
emit("AGUI503", { type, sdkVersion: SDK_VERSION }, {
|
|
227
|
+
...(canonical !== undefined
|
|
228
|
+
? { messageSuffix: ` — did you mean '${canonical}'? AG-UI wire types use SCREAMING_SNAKE_CASE` }
|
|
229
|
+
: {}),
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 5. Schema validation for the declared type (AGUI504).
|
|
236
|
+
validateSchema(type, spec, ev, emit)
|
|
237
|
+
|
|
238
|
+
// 6. Lifecycle position.
|
|
239
|
+
if (type === "RUN_STARTED") {
|
|
240
|
+
if (run === null) {
|
|
241
|
+
run = newRunState({
|
|
242
|
+
runId: str(ev, "runId") ?? null,
|
|
243
|
+
threadId: str(ev, "threadId") ?? null,
|
|
244
|
+
startIndex: index,
|
|
245
|
+
implicit: false,
|
|
246
|
+
})
|
|
247
|
+
} else if (run.terminal !== null) {
|
|
248
|
+
// A new run after a clean terminal: multi-run streams are legal
|
|
249
|
+
// (serialized logs branch via parentRunId — SQ-6). Fresh scope.
|
|
250
|
+
run = newRunState({
|
|
251
|
+
runId: str(ev, "runId") ?? null,
|
|
252
|
+
threadId: str(ev, "threadId") ?? null,
|
|
253
|
+
startIndex: index,
|
|
254
|
+
implicit: false,
|
|
255
|
+
})
|
|
256
|
+
} else if (run.implicit) {
|
|
257
|
+
// The stream opened without RUN_STARTED (AGUI001 already fired); the
|
|
258
|
+
// late start legitimizes the implicit scope rather than double-firing.
|
|
259
|
+
closeChunks(run, emit, index)
|
|
260
|
+
run.implicit = false
|
|
261
|
+
run.runId = str(ev, "runId") ?? null
|
|
262
|
+
run.threadId = str(ev, "threadId") ?? null
|
|
263
|
+
} else {
|
|
264
|
+
closeChunks(run, emit, index)
|
|
265
|
+
emit("AGUI002", { runId: str(ev, "runId") ?? "(missing)" }, {
|
|
266
|
+
relatedEventIndex: run.startIndex,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const r = ensureRun(index, type, emit)
|
|
273
|
+
|
|
274
|
+
// 7. Nothing may follow a terminal event (AGUI004/AGUI005).
|
|
275
|
+
if (r.terminal !== null) {
|
|
276
|
+
const terminalType = r.terminal.type
|
|
277
|
+
if ((type === "RUN_FINISHED" || type === "RUN_ERROR") && type !== terminalType) {
|
|
278
|
+
emit("AGUI005", { type, terminalType }, { relatedEventIndex: r.terminal.index })
|
|
279
|
+
} else {
|
|
280
|
+
emit("AGUI004", { type, terminalType }, { relatedEventIndex: r.terminal.index })
|
|
281
|
+
}
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
closeChunks(r, emit, index, type)
|
|
286
|
+
|
|
287
|
+
// 8. Terminal events.
|
|
288
|
+
if (type === "RUN_FINISHED" || type === "RUN_ERROR") {
|
|
289
|
+
if (type === "RUN_FINISHED") {
|
|
290
|
+
checkRunIdStability(api(index, type, ev, r, emit))
|
|
291
|
+
endOfRunChecks(r, emit, index)
|
|
292
|
+
const outcome = ev.outcome
|
|
293
|
+
if (typeof outcome === "object" && outcome !== null &&
|
|
294
|
+
(outcome as Record<string, unknown>).type === "interrupt") {
|
|
295
|
+
stream.features.add("human-in-the-loop")
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
r.terminal = { type, index }
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 9. Per-category checks.
|
|
303
|
+
const a = api(index, type, ev, r, emit)
|
|
304
|
+
switch (spec.category) {
|
|
305
|
+
case "lifecycle":
|
|
306
|
+
handleStepEvent(a) // STEP_STARTED / STEP_FINISHED
|
|
307
|
+
break
|
|
308
|
+
case "text":
|
|
309
|
+
handleTextEvent(a)
|
|
310
|
+
break
|
|
311
|
+
case "toolcall":
|
|
312
|
+
handleToolCallEvent(a)
|
|
313
|
+
break
|
|
314
|
+
case "state":
|
|
315
|
+
handleStateEvent(a)
|
|
316
|
+
break
|
|
317
|
+
case "reasoning":
|
|
318
|
+
handleReasoningEvent(a)
|
|
319
|
+
break
|
|
320
|
+
case "thinking":
|
|
321
|
+
// Deprecated but valid (SQ-8): schema-checked above, no ordering rules
|
|
322
|
+
// in the catalog yet — a "deprecated event used" rule is proposed
|
|
323
|
+
// upstream rather than invented here.
|
|
324
|
+
break
|
|
325
|
+
case "activity":
|
|
326
|
+
// No activity rules in the catalog yet.
|
|
327
|
+
break
|
|
328
|
+
case "special":
|
|
329
|
+
if (type === "RAW") {
|
|
330
|
+
const wrapped = ev.event
|
|
331
|
+
if (typeof wrapped === "object" && wrapped !== null) {
|
|
332
|
+
const wrappedType = (wrapped as Record<string, unknown>).type
|
|
333
|
+
if (typeof wrappedType === "string" && EVENT_TABLE[wrappedType] !== undefined) {
|
|
334
|
+
emit("AGUI901", { wrappedType }, { pointer: "/event/type" })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else if (type === "CUSTOM") {
|
|
338
|
+
const name = str(ev, "name")
|
|
339
|
+
if (name !== undefined) {
|
|
340
|
+
if (name === "PredictState") stream.features.add("predictive-state-updates")
|
|
341
|
+
if (!/[.:/]/.test(name)) emit("AGUI903", { name }, { pointer: "/name" })
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
break
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function api(
|
|
349
|
+
index: number,
|
|
350
|
+
type: string,
|
|
351
|
+
event: Record<string, unknown>,
|
|
352
|
+
r: RunState,
|
|
353
|
+
emit: EmitFn,
|
|
354
|
+
): CheckApi {
|
|
355
|
+
return {
|
|
356
|
+
index,
|
|
357
|
+
type,
|
|
358
|
+
event,
|
|
359
|
+
run: r,
|
|
360
|
+
stream,
|
|
361
|
+
emit,
|
|
362
|
+
feature: (f) => stream.features.add(f),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
feed(event: unknown): Diagnostic[] {
|
|
368
|
+
const batch: Diagnostic[] = []
|
|
369
|
+
try {
|
|
370
|
+
processEvent(event, batch)
|
|
371
|
+
} catch (e) {
|
|
372
|
+
internalErrors.push(`feed(event ${stream.eventCount - 1}): ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
|
|
373
|
+
}
|
|
374
|
+
return batch
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
finalize(): Diagnostic[] {
|
|
378
|
+
if (finalized) return []
|
|
379
|
+
finalized = true
|
|
380
|
+
const batch: Diagnostic[] = []
|
|
381
|
+
const emit = mkEmit(batch, null)
|
|
382
|
+
try {
|
|
383
|
+
if (run !== null && run.terminal === null) {
|
|
384
|
+
closeChunks(run, emit, -1)
|
|
385
|
+
emit("AGUI003", { runId: run.runId ?? "(unknown)" }, {
|
|
386
|
+
eventIndex: -1,
|
|
387
|
+
relatedEventIndex: run.startIndex,
|
|
388
|
+
})
|
|
389
|
+
endOfRunChecks(run, emit, -1)
|
|
390
|
+
}
|
|
391
|
+
if (stream.eventCount > 0 && !stream.sawTimestamp) {
|
|
392
|
+
emit("AGUI902", { eventCount: stream.eventCount }, { eventIndex: -1 })
|
|
393
|
+
}
|
|
394
|
+
if (declaredFeatures.has("shared-state") && !stream.anySnapshot) {
|
|
395
|
+
emit("AGUI305", {}, { eventIndex: -1 })
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {
|
|
398
|
+
internalErrors.push(`finalize(): ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
|
|
399
|
+
}
|
|
400
|
+
return batch
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
emitExternal(rule, params = {}, extra = {}): Diagnostic | null {
|
|
404
|
+
const batch: Diagnostic[] = []
|
|
405
|
+
try {
|
|
406
|
+
mkEmit(batch, null)(rule, params, extra)
|
|
407
|
+
} catch (e) {
|
|
408
|
+
internalErrors.push(`emitExternal(${rule}): ${e instanceof Error ? e.message : String(e)}`)
|
|
409
|
+
}
|
|
410
|
+
return batch[0] ?? null
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
markSkipped(rule, reason): void {
|
|
414
|
+
explicitSkips.set(String(rule), String(reason))
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
report(): Report {
|
|
418
|
+
const summary = { errors: 0, warnings: 0, info: 0 }
|
|
419
|
+
for (const d of diagnostics) {
|
|
420
|
+
if (d.severity === "error") summary.errors += 1
|
|
421
|
+
else if (d.severity === "warning") summary.warnings += 1
|
|
422
|
+
else summary.info += 1
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const features = {} as FeatureMatrix
|
|
426
|
+
for (const f of CANONICAL_FEATURES) {
|
|
427
|
+
features[f] = NOT_INFERABLE.includes(f)
|
|
428
|
+
? "not-inferable"
|
|
429
|
+
: stream.features.has(f)
|
|
430
|
+
? "exercised"
|
|
431
|
+
: "not-exercised"
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let skipped = layers.has("transport")
|
|
435
|
+
? []
|
|
436
|
+
: TRANSPORT_RULE_IDS
|
|
437
|
+
.filter((id) => overrides[id] !== "off")
|
|
438
|
+
.map((rule) => ({ rule, reason: TRANSPORT_SKIP_REASON }))
|
|
439
|
+
if (!declaredFeatures.has("shared-state") && overrides.AGUI305 !== "off") {
|
|
440
|
+
skipped.push({
|
|
441
|
+
rule: "AGUI305",
|
|
442
|
+
reason: "only evaluated when the 'shared-state' feature is declared via options.features",
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
for (const [rule, severity] of Object.entries(overrides)) {
|
|
446
|
+
if (severity === "off" && RULES.has(rule)) {
|
|
447
|
+
skipped.push({ rule, reason: "disabled by severityOverrides" })
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (explicitSkips.size > 0) {
|
|
451
|
+
skipped = skipped.filter((s) => !explicitSkips.has(s.rule))
|
|
452
|
+
for (const [rule, reason] of explicitSkips) skipped.push({ rule, reason })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
diagnostics: [...diagnostics],
|
|
457
|
+
summary,
|
|
458
|
+
features,
|
|
459
|
+
skipped,
|
|
460
|
+
eventCount: stream.eventCount,
|
|
461
|
+
internalErrors: [...internalErrors],
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
}
|