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,117 @@
1
+ // Internal validator state and the API each check module sees.
2
+ // Everything here is engine-internal; the public surface lives in src/types.ts.
3
+
4
+ import type { CanonicalFeature } from "../../types.js"
5
+
6
+ /** Emit a diagnostic for rule `id`; params fill the catalog messageTemplate. */
7
+ export type EmitFn = (
8
+ ruleId: string,
9
+ params: Record<string, unknown>,
10
+ extra?: {
11
+ eventIndex?: number
12
+ pointer?: string
13
+ relatedEventIndex?: number
14
+ /** Instance-level severity floor (e.g. draft META downgrades AGUI503). */
15
+ severity?: "error" | "warning" | "info"
16
+ /** Instance-level spec link override (e.g. draft docs page). */
17
+ specUrl?: string
18
+ /** Appended to the formatted message (e.g. casing hints). */
19
+ messageSuffix?: string
20
+ },
21
+ ) => void
22
+
23
+ export interface OpenToolCall {
24
+ startIndex: number
25
+ args: string
26
+ sawArgs: boolean
27
+ }
28
+
29
+ export interface RunState {
30
+ /** null while the run is implicit (stream opened without RUN_STARTED). */
31
+ runId: string | null
32
+ threadId: string | null
33
+ startIndex: number
34
+ implicit: boolean
35
+ terminal: { type: string; index: number } | null
36
+
37
+ openMessages: Map<string, { startIndex: number }>
38
+ /** messageId -> index of the event that closed it. */
39
+ closedMessages: Map<string, number>
40
+ /** Every message id observed (starts, chunks, snapshots, tool results). */
41
+ knownMessageIds: Set<string>
42
+
43
+ openToolCalls: Map<string, OpenToolCall>
44
+ closedToolCalls: Map<string, number>
45
+ /** Ids also known from MESSAGES_SNAPSHOT history. */
46
+ knownToolCallIds: Set<string>
47
+
48
+ /** stepName -> re-entrant open count (SQ-10) and first-open index. */
49
+ openSteps: Map<string, { count: number; firstIndex: number }>
50
+
51
+ openReasoningBlocks: Map<string, number>
52
+ openReasoningMessages: Map<string, number>
53
+
54
+ state: {
55
+ /** True once a STATE_SNAPSHOT established an observable base (SQ-1). */
56
+ known: boolean
57
+ value: unknown
58
+ deltasSinceSnapshot: number
59
+ snapshotSeen: boolean
60
+ agui301Fired: boolean
61
+ }
62
+
63
+ /** Implicit streams opened by *_CHUNK events; closed by any other event. */
64
+ textChunk: { messageId: string; startIndex: number } | null
65
+ toolChunk: { toolCallId: string; startIndex: number; args: string; sawArgs: boolean } | null
66
+ reasoningChunk: { messageId: string; startIndex: number } | null
67
+ }
68
+
69
+ export function newRunState(init: {
70
+ runId: string | null
71
+ threadId: string | null
72
+ startIndex: number
73
+ implicit: boolean
74
+ }): RunState {
75
+ return {
76
+ ...init,
77
+ terminal: null,
78
+ openMessages: new Map(),
79
+ closedMessages: new Map(),
80
+ knownMessageIds: new Set(),
81
+ openToolCalls: new Map(),
82
+ closedToolCalls: new Map(),
83
+ knownToolCallIds: new Set(),
84
+ openSteps: new Map(),
85
+ openReasoningBlocks: new Map(),
86
+ openReasoningMessages: new Map(),
87
+ state: { known: false, value: undefined, deltasSinceSnapshot: 0, snapshotSeen: false, agui301Fired: false },
88
+ textChunk: null,
89
+ toolChunk: null,
90
+ reasoningChunk: null,
91
+ }
92
+ }
93
+
94
+ export interface StreamState {
95
+ eventCount: number
96
+ sawTimestamp: boolean
97
+ anySnapshot: boolean
98
+ agui001Fired: boolean
99
+ features: Set<CanonicalFeature>
100
+ }
101
+
102
+ /** What a per-event check handler receives. */
103
+ export interface CheckApi {
104
+ index: number
105
+ type: string
106
+ event: Record<string, unknown>
107
+ run: RunState
108
+ stream: StreamState
109
+ emit: EmitFn
110
+ feature: (f: CanonicalFeature) => void
111
+ }
112
+
113
+ /** Reads a field only if it is a string (schema problems already reported). */
114
+ export function str(event: Record<string, unknown>, field: string): string | undefined {
115
+ const v = event[field]
116
+ return typeof v === "string" ? v : undefined
117
+ }
@@ -0,0 +1,59 @@
1
+ // Lifecycle rules: AGUI001–AGUI008. Run open/terminate logic lives in the
2
+ // engine (src/index.ts) because it owns run-scope resets; this module holds
3
+ // the step pairing and the RUN_FINISHED id-stability check.
4
+
5
+ import type { CheckApi, RunState, EmitFn } from "./context.js"
6
+ import { str } from "./context.js"
7
+
8
+ export function handleStepEvent(api: CheckApi): void {
9
+ const { type, event, run, emit, index } = api
10
+ const stepName = str(event, "stepName")
11
+ if (stepName === undefined) return
12
+
13
+ if (type === "STEP_STARTED") {
14
+ const open = run.openSteps.get(stepName)
15
+ if (open !== undefined) {
16
+ open.count += 1 // re-entrant same-name steps are tolerated (SQ-10)
17
+ } else {
18
+ run.openSteps.set(stepName, { count: 1, firstIndex: index })
19
+ }
20
+ return
21
+ }
22
+
23
+ if (type === "STEP_FINISHED") {
24
+ const open = run.openSteps.get(stepName)
25
+ if (open === undefined) {
26
+ emit("AGUI006", { stepName }, { pointer: "/stepName" })
27
+ return
28
+ }
29
+ open.count -= 1
30
+ if (open.count === 0) run.openSteps.delete(stepName)
31
+ }
32
+ }
33
+
34
+ /** AGUI008 — RUN_FINISHED must carry the ids RUN_STARTED established. */
35
+ export function checkRunIdStability(api: CheckApi): void {
36
+ const { event, run, emit } = api
37
+ if (run.implicit) return // no RUN_STARTED to compare against
38
+ for (const field of ["threadId", "runId"] as const) {
39
+ const actual = str(api.event, field)
40
+ const expected = field === "threadId" ? run.threadId : run.runId
41
+ if (actual !== undefined && expected !== null && actual !== expected) {
42
+ emit("AGUI008", { field, actual, expected }, {
43
+ pointer: `/${field}`,
44
+ relatedEventIndex: run.startIndex,
45
+ })
46
+ }
47
+ }
48
+ void event
49
+ }
50
+
51
+ /** AGUI007 — open steps when the run reaches a clean end. */
52
+ export function endOfRunSteps(run: RunState, emit: EmitFn, atIndex: number): void {
53
+ for (const [stepName, open] of run.openSteps) {
54
+ emit("AGUI007", { stepName }, {
55
+ eventIndex: atIndex,
56
+ relatedEventIndex: open.firstIndex,
57
+ })
58
+ }
59
+ }
@@ -0,0 +1,97 @@
1
+ // Reasoning rules: AGUI401–AGUI402, plus REASONING_MESSAGE_CHUNK handling.
2
+ //
3
+ // Per SQ-4: block-level nesting (REASONING_MESSAGE_* inside
4
+ // REASONING_START/END) is only described as "a typical flow" by the docs, so
5
+ // it is NOT enforced. AGUI401 enforces the message-level pairing, mirroring
6
+ // the text message rules; AGUI402 reports unterminated starts at warning.
7
+
8
+ import type { CheckApi, RunState, EmitFn } from "./context.js"
9
+ import { str } from "./context.js"
10
+
11
+ export function handleReasoningEvent(api: CheckApi): void {
12
+ const { type, event, run, emit, index } = api
13
+
14
+ switch (type) {
15
+ case "REASONING_START": {
16
+ const id = str(event, "messageId")
17
+ if (id !== undefined) run.openReasoningBlocks.set(id, index)
18
+ return
19
+ }
20
+
21
+ case "REASONING_END": {
22
+ const id = str(event, "messageId")
23
+ if (id !== undefined) run.openReasoningBlocks.delete(id)
24
+ return
25
+ }
26
+
27
+ case "REASONING_MESSAGE_START": {
28
+ const id = str(event, "messageId")
29
+ if (id !== undefined) run.openReasoningMessages.set(id, index)
30
+ return
31
+ }
32
+
33
+ case "REASONING_MESSAGE_CONTENT": {
34
+ const id = str(event, "messageId")
35
+ if (id === undefined) return
36
+ const openChunk = run.reasoningChunk !== null && run.reasoningChunk.messageId === id
37
+ if (!run.openReasoningMessages.has(id) && !openChunk) {
38
+ emit("AGUI401", { messageId: id }, { pointer: "/messageId" })
39
+ }
40
+ return
41
+ }
42
+
43
+ case "REASONING_MESSAGE_END": {
44
+ const id = str(event, "messageId")
45
+ if (id !== undefined) run.openReasoningMessages.delete(id)
46
+ return
47
+ }
48
+
49
+ case "REASONING_MESSAGE_CHUNK": {
50
+ const id = str(event, "messageId")
51
+ if (id === undefined) {
52
+ if (run.reasoningChunk === null) {
53
+ emit("AGUI504", { type, detail: "first REASONING_MESSAGE_CHUNK must include messageId" }, {
54
+ pointer: "/messageId",
55
+ })
56
+ return
57
+ }
58
+ if (event.delta === "") run.reasoningChunk = null // documented implicit close
59
+ return
60
+ }
61
+ if (run.reasoningChunk !== null && run.reasoningChunk.messageId !== id) {
62
+ run.reasoningChunk = null
63
+ }
64
+ if (event.delta === "") {
65
+ run.reasoningChunk = null // empty delta implicitly closes the message
66
+ return
67
+ }
68
+ if (run.reasoningChunk === null) {
69
+ run.reasoningChunk = { messageId: id, startIndex: index }
70
+ }
71
+ return
72
+ }
73
+
74
+ // REASONING_ENCRYPTED_VALUE: schema-checked only; no ordering rules cited.
75
+ }
76
+ }
77
+
78
+ /** Chunked reasoning also closes on any non-reasoning event (documented). */
79
+ export function closeReasoningChunk(run: RunState): void {
80
+ run.reasoningChunk = null
81
+ }
82
+
83
+ /** AGUI402 — unterminated reasoning at a clean run end. */
84
+ export function endOfRunReasoning(run: RunState, emit: EmitFn, atIndex: number): void {
85
+ for (const [id, startIndex] of run.openReasoningBlocks) {
86
+ emit("AGUI402", { startType: "REASONING_START", messageId: id }, {
87
+ eventIndex: atIndex,
88
+ relatedEventIndex: startIndex,
89
+ })
90
+ }
91
+ for (const [id, startIndex] of run.openReasoningMessages) {
92
+ emit("AGUI402", { startType: "REASONING_MESSAGE_START", messageId: id }, {
93
+ eventIndex: atIndex,
94
+ relatedEventIndex: startIndex,
95
+ })
96
+ }
97
+ }
@@ -0,0 +1,72 @@
1
+ // State rules: AGUI301–AGUI305, plus MESSAGES_SNAPSHOT history harvesting.
2
+
3
+ import { applyPatch, validatePatchShape } from "../../protocol/jsonpatch.js"
4
+ import type { CheckApi } from "./context.js"
5
+
6
+ export function handleStateEvent(api: CheckApi): void {
7
+ const { type, event, run, stream, emit } = api
8
+
9
+ switch (type) {
10
+ case "STATE_SNAPSHOT": {
11
+ api.feature("shared-state")
12
+ if (run.state.deltasSinceSnapshot > 0) {
13
+ emit("AGUI304", { deltaCount: run.state.deltasSinceSnapshot }, {})
14
+ }
15
+ run.state.known = true
16
+ run.state.value = event.snapshot
17
+ run.state.snapshotSeen = true
18
+ run.state.deltasSinceSnapshot = 0
19
+ stream.anySnapshot = true
20
+ return
21
+ }
22
+
23
+ case "STATE_DELTA": {
24
+ api.feature("shared-state")
25
+ const delta = event.delta
26
+ if (!Array.isArray(delta)) return // AGUI504 already reported the kind mismatch
27
+ const shape = validatePatchShape(delta)
28
+ if (shape !== null) {
29
+ emit("AGUI303", { error: shape.error }, { pointer: `/delta${shape.pointer}` })
30
+ return
31
+ }
32
+ if (!run.state.snapshotSeen && !run.state.agui301Fired) {
33
+ // The base may be seeded out-of-band via RunAgentInput.state (SQ-1),
34
+ // so this is informational, and patch application is not judged until
35
+ // a snapshot establishes an observable base.
36
+ emit("AGUI301", {}, {})
37
+ run.state.agui301Fired = true
38
+ }
39
+ if (run.state.known) {
40
+ const applied = applyPatch(run.state.value, delta)
41
+ if (applied.ok) {
42
+ run.state.value = applied.result
43
+ } else {
44
+ emit("AGUI302", { error: applied.error }, { pointer: `/delta/${applied.opIndex}` })
45
+ }
46
+ }
47
+ run.state.deltasSinceSnapshot += 1
48
+ return
49
+ }
50
+
51
+ case "MESSAGES_SNAPSHOT": {
52
+ // Snapshots can carry history from before this capture: harvest ids so
53
+ // reference checks (AGUI207/AGUI208) don't false-positive on them.
54
+ const messages = event.messages
55
+ if (!Array.isArray(messages)) return
56
+ for (const message of messages) {
57
+ if (typeof message !== "object" || message === null) continue
58
+ const m = message as Record<string, unknown>
59
+ if (typeof m.id === "string") run.knownMessageIds.add(m.id)
60
+ if (Array.isArray(m.toolCalls)) {
61
+ for (const call of m.toolCalls) {
62
+ if (typeof call === "object" && call !== null) {
63
+ const id = (call as Record<string, unknown>).id
64
+ if (typeof id === "string") run.knownToolCallIds.add(id)
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,109 @@
1
+ // Text message rules: AGUI101–AGUI106, plus TEXT_MESSAGE_CHUNK stream handling.
2
+
3
+ import type { CheckApi, RunState, EmitFn } from "./context.js"
4
+ import { str } from "./context.js"
5
+
6
+ export function handleTextEvent(api: CheckApi): void {
7
+ const { type, event, run, emit, index } = api
8
+ api.feature("agentic-chat")
9
+
10
+ switch (type) {
11
+ case "TEXT_MESSAGE_START": {
12
+ const id = str(event, "messageId")
13
+ if (id === undefined) return
14
+ if (run.openMessages.has(id)) {
15
+ emit("AGUI106", { messageId: id }, {
16
+ pointer: "/messageId",
17
+ relatedEventIndex: run.openMessages.get(id)!.startIndex,
18
+ })
19
+ return
20
+ }
21
+ if (run.closedMessages.has(id)) {
22
+ emit("AGUI104", { messageId: id }, {
23
+ pointer: "/messageId",
24
+ relatedEventIndex: run.closedMessages.get(id)!,
25
+ })
26
+ return
27
+ }
28
+ run.openMessages.set(id, { startIndex: index })
29
+ run.knownMessageIds.add(id)
30
+ return
31
+ }
32
+
33
+ case "TEXT_MESSAGE_CONTENT": {
34
+ const id = str(event, "messageId")
35
+ if (id === undefined) return
36
+ const open = run.openMessages.get(id)
37
+ if (open === undefined) {
38
+ const closedAt = run.closedMessages.get(id)
39
+ emit("AGUI101", { messageId: id }, {
40
+ pointer: "/messageId",
41
+ ...(closedAt !== undefined ? { relatedEventIndex: closedAt } : {}),
42
+ })
43
+ return
44
+ }
45
+ if (event.delta === "") {
46
+ emit("AGUI105", { messageId: id }, { pointer: "/delta", relatedEventIndex: open.startIndex })
47
+ }
48
+ return
49
+ }
50
+
51
+ case "TEXT_MESSAGE_END": {
52
+ const id = str(event, "messageId")
53
+ if (id === undefined) return
54
+ if (!run.openMessages.has(id)) {
55
+ emit("AGUI102", { messageId: id }, { pointer: "/messageId" })
56
+ return
57
+ }
58
+ run.openMessages.delete(id)
59
+ run.closedMessages.set(id, index)
60
+ return
61
+ }
62
+
63
+ case "TEXT_MESSAGE_CHUNK": {
64
+ const id = str(event, "messageId")
65
+ if (id === undefined) {
66
+ // Continuation chunks may omit messageId; the first chunk must not
67
+ // (docs: "First chunk for a message must include messageId").
68
+ if (run.textChunk === null) {
69
+ emit("AGUI504", { type, detail: "first TEXT_MESSAGE_CHUNK for a message must include messageId" }, {
70
+ pointer: "/messageId",
71
+ })
72
+ }
73
+ return
74
+ }
75
+ // Mixing chunk and explicit forms on one id is undefined behaviour
76
+ // (SQ-9): tolerated, treated as content for the open message.
77
+ if (run.openMessages.has(id)) return
78
+ if (run.textChunk !== null && run.textChunk.messageId === id) return
79
+ closeTextChunk(run, index)
80
+ if (run.closedMessages.has(id)) {
81
+ emit("AGUI104", { messageId: id }, {
82
+ pointer: "/messageId",
83
+ relatedEventIndex: run.closedMessages.get(id)!,
84
+ })
85
+ return
86
+ }
87
+ run.textChunk = { messageId: id, startIndex: index }
88
+ run.knownMessageIds.add(id)
89
+ return
90
+ }
91
+ }
92
+ }
93
+
94
+ /** Chunk streams close implicitly on the next non-chunk event. */
95
+ export function closeTextChunk(run: RunState, atIndex: number): void {
96
+ if (run.textChunk === null) return
97
+ run.closedMessages.set(run.textChunk.messageId, atIndex)
98
+ run.textChunk = null
99
+ }
100
+
101
+ /** AGUI103 — open messages when the run reaches a clean end. */
102
+ export function endOfRunText(run: RunState, emit: EmitFn, atIndex: number): void {
103
+ for (const [id, open] of run.openMessages) {
104
+ emit("AGUI103", { messageId: id }, {
105
+ eventIndex: atIndex,
106
+ relatedEventIndex: open.startIndex,
107
+ })
108
+ }
109
+ }
@@ -0,0 +1,167 @@
1
+ // Tool call rules: AGUI201–AGUI208, plus TOOL_CALL_CHUNK stream handling.
2
+
3
+ import type { CheckApi, RunState, EmitFn } from "./context.js"
4
+ import { str } from "./context.js"
5
+
6
+ export function handleToolCallEvent(api: CheckApi): void {
7
+ const { type, event, run, emit, index } = api
8
+ api.feature("backend-tool-rendering")
9
+
10
+ switch (type) {
11
+ case "TOOL_CALL_START": {
12
+ const id = str(event, "toolCallId")
13
+ if (id === undefined) return
14
+ if (run.openToolCalls.has(id) || run.closedToolCalls.has(id)) {
15
+ const related = run.openToolCalls.get(id)?.startIndex ?? run.closedToolCalls.get(id)!
16
+ emit("AGUI205", { toolCallId: id }, { pointer: "/toolCallId", relatedEventIndex: related })
17
+ return
18
+ }
19
+ run.openToolCalls.set(id, { startIndex: index, args: "", sawArgs: false })
20
+ const parent = str(event, "parentMessageId")
21
+ if (parent !== undefined && !run.knownMessageIds.has(parent)) {
22
+ emit("AGUI208", { parentMessageId: parent }, { pointer: "/parentMessageId" })
23
+ }
24
+ return
25
+ }
26
+
27
+ case "TOOL_CALL_ARGS": {
28
+ const id = str(event, "toolCallId")
29
+ if (id === undefined) return
30
+ const open = run.openToolCalls.get(id)
31
+ if (open === undefined) {
32
+ emit("AGUI201", { toolCallId: id }, { pointer: "/toolCallId" })
33
+ return
34
+ }
35
+ const delta = str(event, "delta")
36
+ if (delta !== undefined) {
37
+ open.args += delta
38
+ open.sawArgs = true
39
+ }
40
+ return
41
+ }
42
+
43
+ case "TOOL_CALL_END": {
44
+ const id = str(event, "toolCallId")
45
+ if (id === undefined) return
46
+ const open = run.openToolCalls.get(id)
47
+ if (open === undefined) {
48
+ emit("AGUI202", { toolCallId: id }, { pointer: "/toolCallId" })
49
+ return
50
+ }
51
+ run.openToolCalls.delete(id)
52
+ run.closedToolCalls.set(id, index)
53
+ checkArgsJson(id, open, emit, index)
54
+ return
55
+ }
56
+
57
+ case "TOOL_CALL_RESULT": {
58
+ const id = str(event, "toolCallId")
59
+ if (id !== undefined) {
60
+ const open = run.openToolCalls.get(id)
61
+ if (open !== undefined) {
62
+ emit("AGUI206", { toolCallId: id }, {
63
+ pointer: "/toolCallId",
64
+ relatedEventIndex: open.startIndex,
65
+ })
66
+ } else if (!run.closedToolCalls.has(id) && !run.knownToolCallIds.has(id)) {
67
+ emit("AGUI207", { toolCallId: id }, { pointer: "/toolCallId" })
68
+ }
69
+ }
70
+ // The result is itself a tool message in the conversation.
71
+ const messageId = str(event, "messageId")
72
+ if (messageId !== undefined) run.knownMessageIds.add(messageId)
73
+ return
74
+ }
75
+
76
+ case "TOOL_CALL_CHUNK": {
77
+ const id = str(event, "toolCallId")
78
+ const delta = str(event, "delta")
79
+ if (id === undefined) {
80
+ if (run.toolChunk === null) {
81
+ emit("AGUI504", { type, detail: "first TOOL_CALL_CHUNK for a tool call must include toolCallId and toolCallName" }, {
82
+ pointer: "/toolCallId",
83
+ })
84
+ return
85
+ }
86
+ if (delta !== undefined) {
87
+ run.toolChunk.args += delta
88
+ run.toolChunk.sawArgs = true
89
+ }
90
+ return
91
+ }
92
+ // SQ-9: chunk continuing an explicitly-opened call — treat as args.
93
+ const openExplicit = run.openToolCalls.get(id)
94
+ if (openExplicit !== undefined) {
95
+ if (delta !== undefined) {
96
+ openExplicit.args += delta
97
+ openExplicit.sawArgs = true
98
+ }
99
+ return
100
+ }
101
+ if (run.toolChunk !== null && run.toolChunk.toolCallId === id) {
102
+ if (delta !== undefined) {
103
+ run.toolChunk.args += delta
104
+ run.toolChunk.sawArgs = true
105
+ }
106
+ return
107
+ }
108
+ closeToolChunk(run, emit, index)
109
+ if (run.closedToolCalls.has(id)) {
110
+ emit("AGUI205", { toolCallId: id }, {
111
+ pointer: "/toolCallId",
112
+ relatedEventIndex: run.closedToolCalls.get(id)!,
113
+ })
114
+ return
115
+ }
116
+ if (str(event, "toolCallName") === undefined) {
117
+ emit("AGUI504", { type, detail: "first TOOL_CALL_CHUNK for a tool call must include toolCallName" }, {
118
+ pointer: "/toolCallName",
119
+ })
120
+ }
121
+ run.toolChunk = {
122
+ toolCallId: id,
123
+ startIndex: index,
124
+ args: delta ?? "",
125
+ sawArgs: delta !== undefined && delta.length > 0,
126
+ }
127
+ return
128
+ }
129
+ }
130
+ }
131
+
132
+ function checkArgsJson(
133
+ id: string,
134
+ call: { startIndex: number; args: string; sawArgs: boolean },
135
+ emit: EmitFn,
136
+ atIndex: number,
137
+ ): void {
138
+ // A call that streamed no args at all is fine (a no-argument tool).
139
+ if (!call.sawArgs || call.args.length === 0) return
140
+ try {
141
+ JSON.parse(call.args)
142
+ } catch (e) {
143
+ emit("AGUI204", { toolCallId: id, error: e instanceof Error ? e.message : String(e) }, {
144
+ eventIndex: atIndex,
145
+ relatedEventIndex: call.startIndex,
146
+ })
147
+ }
148
+ }
149
+
150
+ /** Chunk streams close implicitly on the next non-chunk event. */
151
+ export function closeToolChunk(run: RunState, emit: EmitFn, atIndex: number): void {
152
+ if (run.toolChunk === null) return
153
+ const { toolCallId, startIndex, args, sawArgs } = run.toolChunk
154
+ run.toolChunk = null
155
+ run.closedToolCalls.set(toolCallId, atIndex)
156
+ checkArgsJson(toolCallId, { startIndex, args, sawArgs }, emit, atIndex)
157
+ }
158
+
159
+ /** AGUI203 — open tool calls when the run reaches a clean end. */
160
+ export function endOfRunToolCalls(run: RunState, emit: EmitFn, atIndex: number): void {
161
+ for (const [id, open] of run.openToolCalls) {
162
+ emit("AGUI203", { toolCallId: id }, {
163
+ eventIndex: atIndex,
164
+ relatedEventIndex: open.startIndex,
165
+ })
166
+ }
167
+ }
@@ -0,0 +1,17 @@
1
+ // Transport rules (AGUI501, AGUI505–AGUI508) are only checkable against a
2
+ // live connection: SSE framing, Content-Type, keepalive timing, flush
3
+ // behaviour, abnormal EOF. The core cannot observe any of that from a parsed
4
+ // event sequence, so it reports these rules as skipped — never silently —
5
+ // and the transport layer (ag-ui-validate/transport, M5) evaluates them.
6
+ //
7
+ // AGUI502/503/504 are transport-adjacent but *are* checkable here (the core
8
+ // accepts raw JSON strings), so they live in the engine, not this list.
9
+
10
+ import { CATALOG } from "../catalog.js"
11
+
12
+ export const TRANSPORT_SKIP_REASON =
13
+ "transport-layer rule; only checkable against a live connection (validated by ag-ui-validate/transport)"
14
+
15
+ export const TRANSPORT_RULE_IDS: readonly string[] = CATALOG.rules
16
+ .filter((r) => r.checkedIn === "transport")
17
+ .map((r) => r.id)