diff --git a/.gitignore b/.gitignore index 7e031029ea..5c07c3cefa 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ requests.txt # Local build tools installed via Taskfiles build -.cursor \ No newline at end of file +.cursor diff --git a/frontend/bun.lock b/frontend/bun.lock index bcd8e27752..e17b2f2a11 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -4,7 +4,7 @@ "workspaces": { "": { "dependencies": { - "@a2a-js/sdk": "^0.3.10", + "@a2a-js/sdk": "^0.3.13", "@autoform/react": "^4.0.0", "@autoform/zod": "^5.0.0", "@buf/redpandadata_ai-gateway.bufbuild_es": "^2.11.0-20260313141452-dbbaece03f76.1", @@ -41,7 +41,7 @@ "@tanstack/zod-adapter": "^1.158.0", "@types/prismjs": "^1.26.5", "@xyflow/react": "^12.9.2", - "ai": "^5.0.101", + "ai": "^6.0.168", "array-move": "^4.0.0", "chakra-react-select": "5.0.5", "class-variance-authority": "^0.7.1", @@ -89,7 +89,7 @@ "shiki": "^3.15.0", "sonner": "^2.0.7", "stacktrace-js": "^2.0.2", - "streamdown": "^1.4.0", + "streamdown": "^2.5.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", "tokenlens": "^1.3.1", @@ -174,15 +174,15 @@ "yaml": "^2.8.3", }, "packages": { - "@a2a-js/sdk": ["@a2a-js/sdk@0.3.10", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-t6w5ctnwJkSOMRl6M9rn95C1FTHCPqixxMR0yWXtzhZXEnF6mF1NAK0CfKlG3cz+tcwTxkmn287QZC3t9XPgrA=="], + "@a2a-js/sdk": ["@a2a-js/sdk@0.3.13", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A=="], "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.15", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-i1YVKzC1dg9LGvt+GthhD7NlRhz9J4+ZRj3KELU14IZ/MHPsOBiFeEoCCIDLR+3tqT8/+5nIsK3eZ7DFRfMfdw=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], - "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -366,15 +366,15 @@ "@chakra-ui/utils": ["@chakra-ui/utils@2.0.15", "", { "dependencies": { "@types/lodash.mergewith": "4.6.7", "css-box-model": "1.2.1", "framesync": "6.1.2", "lodash.mergewith": "4.6.2" } }, "sha512-El4+jL0WSaYYs+rJbuYFDbjmfCcfGDmRY95GO4xwzit6YAPZBLcR65rOEwLps+XWluZTy1xdMrusg/hW0c1aAA=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], - "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="], - "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + "@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], - "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], @@ -664,7 +664,7 @@ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], - "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], "@milkdown/components": ["@milkdown/components@7.18.0", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@milkdown/core": "7.18.0", "@milkdown/ctx": "7.18.0", "@milkdown/exception": "7.18.0", "@milkdown/plugin-tooltip": "7.18.0", "@milkdown/preset-commonmark": "7.18.0", "@milkdown/preset-gfm": "7.18.0", "@milkdown/prose": "7.18.0", "@milkdown/transformer": "7.18.0", "@milkdown/utils": "7.18.0", "@types/lodash-es": "^4.17.12", "clsx": "^2.0.0", "dompurify": "^3.2.5", "lodash-es": "^4.17.21", "nanoid": "^5.0.9", "unist-util-visit": "^5.0.0", "vue": "^3.5.20" }, "peerDependencies": { "@codemirror/language": "^6", "@codemirror/state": "^6", "@codemirror/view": "^6" } }, "sha512-Zu/GMqy1byyxul/+/RWcpe02b7luhtW1SfTYNFZnaWPvIap5M9vG7pFeQNRqJe5cbfKI+bvW8Ubyb5BG2kb9Ug=="], @@ -1558,7 +1558,9 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], @@ -1668,7 +1670,7 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ai": ["ai@5.0.101", "", { "dependencies": { "@ai-sdk/gateway": "2.0.15", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/P4fgs2PGYTBaZi192YkPikOudsl9vccA65F7J7LvoNTOoP5kh1yAsJPsKAy6FXU32bAngai7ft1UDyC3u7z5g=="], + "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -1852,9 +1854,9 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], + "chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], - "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + "chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -2054,7 +2056,7 @@ "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -2270,7 +2272,7 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], @@ -2426,28 +2428,20 @@ "hast": ["hast@1.0.0", "", {}, "sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA=="], - "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], - - "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], - - "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], - "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], - "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], @@ -2658,7 +2652,7 @@ "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], - "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + "langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="], "launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="], @@ -2826,7 +2820,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.12.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g=="], + "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], @@ -3298,12 +3292,12 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "rehype-harden": ["rehype-harden@1.1.5", "", {}, "sha512-JrtBj5BVd/5vf3H3/blyJatXJbzQfRT9pJBmjafbTaPouQCAKxHwRyCc7dle9BXQKxv4z1OzZylz/tNamoiG3A=="], - - "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + "rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="], "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="], "remark-emoji": ["remark-emoji@5.0.2", "", { "dependencies": { "@types/mdast": "^4.0.4", "emoticon": "^4.0.1", "mdast-util-find-and-replace": "^3.0.1", "node-emoji": "^2.1.3", "unified": "^11.0.4" } }, "sha512-IyIqGELcyK5AVdLFafoiNww+Eaw/F+rGrNSXoKucjo95uL267zrddgxGM83GN1wFIb68pyDuAsY3m5t2Cav1pQ=="], @@ -3320,6 +3314,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -3532,7 +3528,7 @@ "stream-http": ["stream-http@3.2.0", "", { "dependencies": { "builtin-status-codes": "^3.0.0", "inherits": "^2.0.4", "readable-stream": "^3.6.0", "xtend": "^4.0.2" } }, "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A=="], - "streamdown": ["streamdown@1.4.0", "", { "dependencies": { "clsx": "^2.1.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "react-markdown": "^10.1.0", "rehype-harden": "^1.1.5", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-ylhDSQ4HpK5/nAH9v7OgIIdGJxlJB2HoYrYkJNGrO8lMpnWuKUcrz/A8xAMwA6eILA27469vIavcOTjmxctrKg=="], + "streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="], "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], @@ -3686,8 +3682,6 @@ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - "unist-util-generated": ["unist-util-generated@2.0.1", "", {}, "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -3880,6 +3874,8 @@ "@a2a-js/sdk/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/core/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -4010,8 +4006,6 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "@module-federation/bridge-react-webpack-plugin/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], @@ -4344,8 +4338,6 @@ "global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], - "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -4370,8 +4362,6 @@ "knip/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], - "langium/vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], - "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lower-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -4504,9 +4494,7 @@ "stream-http/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], - - "streamdown/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -4642,8 +4630,6 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@modelcontextprotocol/sdk/express/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], - "@module-federation/node/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "@redpanda-data/ui/chakra-react-select/react-select": ["react-select@5.8.3", "", { "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.8.1", "@floating-ui/dom": "^1.0.1", "@types/react-transition-group": "^4.4.0", "memoize-one": "^6.0.0", "prop-types": "^15.6.0", "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.1.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-lVswnIq8/iTj1db7XCG74M/3fbGB6ZaluCzvwPGT5ZOjCdL/k0CLWhEK0vCBLuU5bHTEf6Gj8jtSvi+3v+tO1w=="], @@ -4790,8 +4776,6 @@ "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "hpack.js/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -4934,8 +4918,6 @@ "@chakra-ui/react-utils/@chakra-ui/utils/framesync/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@modelcontextprotocol/sdk/express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "@module-federation/node/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "@module-federation/node/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/frontend/package.json b/frontend/package.json index 03b0c5201d..20c1049e66 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,7 +64,7 @@ "immutable": "^5.1.5" }, "dependencies": { - "@a2a-js/sdk": "^0.3.10", + "@a2a-js/sdk": "^0.3.13", "@autoform/react": "^4.0.0", "@autoform/zod": "^5.0.0", "@buf/redpandadata_ai-gateway.bufbuild_es": "^2.11.0-20260313141452-dbbaece03f76.1", @@ -101,7 +101,7 @@ "@tanstack/zod-adapter": "^1.158.0", "@types/prismjs": "^1.26.5", "@xyflow/react": "^12.9.2", - "ai": "^5.0.101", + "ai": "^6.0.168", "array-move": "^4.0.0", "chakra-react-select": "5.0.5", "class-variance-authority": "^0.7.1", @@ -149,7 +149,7 @@ "shiki": "^3.15.0", "sonner": "^2.0.7", "stacktrace-js": "^2.0.2", - "streamdown": "^1.4.0", + "streamdown": "^2.5.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", "tokenlens": "^1.3.1", diff --git a/frontend/src/__tests__/browser-test-utils.tsx b/frontend/src/__tests__/browser-test-utils.tsx index f589814014..770bdd6418 100644 --- a/frontend/src/__tests__/browser-test-utils.tsx +++ b/frontend/src/__tests__/browser-test-utils.tsx @@ -53,3 +53,25 @@ export function ScreenshotFrame({ children, width = 1200 }: { children: React.Re ); } + +/** + * Default directory (relative to a browser test file) where PR showcase + * screenshots are written. Mirrors the ADP UI convention of keeping + * documentation screenshots in a single repository-level folder rather + * than scattering them next to the tests. + */ +export const PR_SCREENSHOT_DIR = '../../../docs/pr-screenshots'; + +/** + * Capture a PNG of the stable `ScreenshotFrame` wrapper for use in PR + * documentation. Use this for showcase / documentation screenshots that + * are not compared against a baseline. For visual regression assertions + * prefer Vitest's `expect(locator).toMatchScreenshot(...)` instead. + */ +export async function captureScreenshotFrame( + locator: { screenshot: (opts: { path: string }) => Promise }, + name: string, + dir = PR_SCREENSHOT_DIR +): Promise { + await locator.screenshot({ path: `${dir}/${name}.png` }); +} diff --git a/frontend/src/components/ai-elements/context.browser.test.tsx b/frontend/src/components/ai-elements/context.browser.test.tsx new file mode 100644 index 0000000000..87119f9584 --- /dev/null +++ b/frontend/src/components/ai-elements/context.browser.test.tsx @@ -0,0 +1,134 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { LanguageModelUsage } from 'ai'; +import { useRef, useState, useEffect, type ReactNode } from 'react'; +import { afterEach, describe, test } from 'vitest'; +import { page } from 'vitest/browser'; +import { cleanup, render } from 'vitest-browser-react'; + +import { captureScreenshotFrame, ScreenshotFrame } from '../../__tests__/browser-test-utils'; +import { + Context, + ContextCacheUsage, + ContextContent, + ContextContentBody, + ContextContentFooter, + ContextContentHeader, + ContextInputUsage, + ContextOutputUsage, + ContextReasoningUsage, + ContextTrigger, +} from './context'; + +afterEach(() => { + cleanup(); +}); + +// Render the HoverCardContent portal inside the screenshot frame so the whole +// card (trigger + popup body) is captured by a single element screenshot. +const PortalWithinFrame = ({ + children, + open, +}: { + children: (container: Element | undefined) => ReactNode; + open: boolean; +}) => { + const ref = useRef(null); + const [container, setContainer] = useState(undefined); + useEffect(() => { + if (ref.current) { + setContainer(ref.current); + } + }, []); + return ( +
+ {open ? children(container) : children(undefined)} +
+ ); +}; + +const ContextPanel = ({ + usedTokens, + maxTokens, + usage, + modelId, +}: { + usedTokens: number; + maxTokens: number; + usage?: LanguageModelUsage; + modelId?: string; +}) => ( + +
+ + {(container) => ( + + + + + + + + + + + + + + )} + +
+
+); + +const shot = (name: string) => + captureScreenshotFrame(page.getByTestId('screenshot-frame'), name); + +describe('Context hover-card screenshots', () => { + test('zero tokens (guards hide sub-rows)', async () => { + render(); + await shot('context-zero-tokens'); + }); + + test('populated usage with all sub-objects', async () => { + render( + + ); + await shot('context-populated'); + }); +}); diff --git a/frontend/src/components/ai-elements/context.test.tsx b/frontend/src/components/ai-elements/context.test.tsx new file mode 100644 index 0000000000..9384fddeac --- /dev/null +++ b/frontend/src/components/ai-elements/context.test.tsx @@ -0,0 +1,199 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { render, screen } from '@testing-library/react'; +import type { LanguageModelUsage } from 'ai'; +import { describe, expect, test } from 'vitest'; + +import { + Context, + ContextContent, + ContextContentFooter, + ContextContentHeader, + ContextInputUsage, + ContextOutputUsage, + ContextTrigger, +} from './context'; + +// --------------------------------------------------------------------------- +// Zero-token / edge-case guards for ContextContentHeader +// --------------------------------------------------------------------------- + +describe('ContextContentHeader', () => { + test('renders 0% and 0 / 0 when both used and max tokens are zero', () => { + // This is the degenerate case that shows up when a conversation hasn't + // emitted any usage events yet. It must not render NaN, Infinity, or + // throw a divide-by-zero. + render( + + + + + + + ); + + // The trigger's percentage label should be 0% (Intl formats NaN as "NaN%" + // which is a regression signal for a divide-by-zero). + const triggerPct = screen.getByRole('button'); + expect(triggerPct.textContent ?? '').not.toMatch(/NaN|Infinity/); + }); + + test('renders a sane percentage when usage is partial', () => { + render( + + + + ); + const trigger = screen.getByRole('button'); + expect(trigger.textContent ?? '').toContain('25%'); + }); + + // Defensive: guard against every non-finite / invalid input the backend + // could send us. In all of these cases the rendered label must collapse to + // 0% rather than NaN%, Infinity%, or a negative percentage. + test.each<[string, number, number]>([ + ['maxTokens is NaN', 100, Number.NaN], + ['maxTokens is Infinity', 100, Number.POSITIVE_INFINITY], + ['maxTokens is -Infinity', 100, Number.NEGATIVE_INFINITY], + ['maxTokens is negative', 100, -1], + ['usedTokens is NaN', Number.NaN, 100], + ['usedTokens is Infinity', Number.POSITIVE_INFINITY, 100], + ['usedTokens is negative', -50, 100], + ])('renders 0%% and never NaN/Infinity when %s', (_label, usedTokens, maxTokens) => { + render( + + + + ); + const trigger = screen.getByRole('button'); + const text = trigger.textContent ?? ''; + expect(text).not.toMatch(/NaN|Infinity/); + expect(text).toContain('0%'); + }); +}); + +// --------------------------------------------------------------------------- +// ContextInputUsage / ContextOutputUsage zero-token suppression +// --------------------------------------------------------------------------- + +describe('ContextInputUsage / ContextOutputUsage zero-token guard', () => { + const zeroUsage: LanguageModelUsage = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }; + + test('ContextInputUsage renders nothing when inputTokens is 0', () => { + const { container } = render( + + + + + + ); + // The component short-circuits to `null` for zero input tokens; the + // HoverCard content is closed by default, so the input row never renders. + expect(container.querySelector('[data-slot="input-usage"]')).toBeNull(); + // Explicitly, no "Input" label should leak out. + expect(screen.queryByText('Input')).toBeNull(); + }); + + test('ContextOutputUsage renders nothing when outputTokens is 0', () => { + render( + + + + + + ); + expect(screen.queryByText('Output')).toBeNull(); + }); + + test('ContextContentFooter renders $0.00 when no modelId is provided', () => { + // Without a modelId we can't look up per-token pricing; the footer should + // fall back to $0.00 (not NaN) even when usage is zero. + render( + + + + + + ); + // Footer is inside a closed hover card by default, so we just assert no + // NaN / Infinity leaks out of the subtree. + const pretty = document.body.textContent ?? ''; + expect(pretty).not.toMatch(/NaN|Infinity/); + }); +}); + +// --------------------------------------------------------------------------- +// Memoisation of provider value +// --------------------------------------------------------------------------- + +describe('Context provider memoisation', () => { + test('provider value identity is stable across re-renders when props do not change', () => { + // We read the internal React context by swapping in a probe that captures + // the value reference seen on each render. Because Context's state + // container is module-private, we instead assert stability indirectly: + // the `useContextUsage`-style consumer would re-memoise its derived work + // from the stable value — we observe that here by checking the identity + // of the usage object we pass through remains referentially stable and + // that consumer renders the same count of text nodes. + + const usage: LanguageModelUsage = { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }; + + const { rerender, container } = render( + + + + ); + const firstTriggerText = container.textContent; + // Re-render with the exact same object references — no changes. + rerender( + + + + ); + expect(container.textContent).toBe(firstTriggerText); + }); + + test('changing usage props produces a new derived output', () => { + const usageA: LanguageModelUsage = { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }; + const usageB: LanguageModelUsage = { + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + }; + + const { rerender, container } = render( + + + + ); + const first = container.textContent ?? ''; + rerender( + + + + ); + const second = container.textContent ?? ''; + expect(first).not.toBe(second); + }); +}); diff --git a/frontend/src/components/ai-elements/context.tsx b/frontend/src/components/ai-elements/context.tsx index b252772a9d..4b17f44d63 100644 --- a/frontend/src/components/ai-elements/context.tsx +++ b/frontend/src/components/ai-elements/context.tsx @@ -9,7 +9,7 @@ import { import { Progress } from "components/redpanda-ui/components/progress"; import { cn } from "components/redpanda-ui/lib/utils"; import type { LanguageModelUsage } from "ai"; -import { type ComponentProps, createContext, useContext } from "react"; +import { type ComponentProps, createContext, useContext, useMemo } from "react"; import { getUsage } from "tokenlens"; const PERCENT_MAX = 100; @@ -18,6 +18,26 @@ const ICON_VIEWBOX = 24; const ICON_CENTER = 12; const ICON_STROKE_WIDTH = 2; +/** + * Compute `usedTokens / maxTokens` defensively. Guards against: + * - `maxTokens` of `0` (cold-start before any usage has been observed), + * - `NaN` / non-finite inputs (e.g. partial usage payloads), + * - negative inputs (malformed backend response). + * In all edge cases we return `0` so the UI renders `0%` rather than `NaN%`, + * `-Infinity`, or `Infinity`. + */ +const safeUsedPercent = (usedTokens: number, maxTokens: number): number => { + if ( + !Number.isFinite(maxTokens) || + !Number.isFinite(usedTokens) || + maxTokens <= 0 || + usedTokens < 0 + ) { + return 0; + } + return usedTokens / maxTokens; +}; + type ModelId = string; type ContextSchema = { @@ -46,27 +66,28 @@ export const Context = ({ maxTokens, usage, modelId, - children, ...props -}: ContextProps) => ( - - - {children} - - -); +}: ContextProps) => { + // Memoise the context value so consumers only re-render when one of the + // usage fields actually changes, not on every render of the parent. + const contextValue = useMemo( + () => ({ maxTokens, modelId, usage, usedTokens }), + [maxTokens, modelId, usage, usedTokens] + ); + + return ( + + + + ); +}; const ContextIcon = () => { const { usedTokens, maxTokens } = useContextValue(); const circumference = 2 * Math.PI * ICON_RADIUS; - const usedPercent = usedTokens / maxTokens; + // Guard against divide-by-zero, NaN, Infinity and negative inputs so the + // SVG dash-offset stays finite regardless of what the backend reports. + const usedPercent = safeUsedPercent(usedTokens, maxTokens); const dashOffset = circumference * (1 - usedPercent); return ( @@ -108,7 +129,10 @@ export type ContextTriggerProps = ComponentProps; export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => { const { usedTokens, maxTokens } = useContextValue(); - const usedPercent = usedTokens / maxTokens; + // Guard against divide-by-zero, NaN, Infinity and negative inputs during + // cold-start rendering or malformed usage payloads. Without this, the + // trigger label can render as "NaN%". + const usedPercent = safeUsedPercent(usedTokens, maxTokens); const renderedPercent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1, @@ -148,7 +172,9 @@ export const ContextContentHeader = ({ ...props }: ContextContentHeaderProps) => { const { usedTokens, maxTokens } = useContextValue(); - const usedPercent = usedTokens / maxTokens; + // Guard against divide-by-zero, NaN, Infinity and negative inputs so the + // hover-card never renders "NaN%". + const usedPercent = safeUsedPercent(usedTokens, maxTokens); const displayPct = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1, @@ -245,6 +271,10 @@ export const ContextInputUsage = ({ return children; } + if (!inputTokens) { + return null; + } + const inputCost = modelId ? getUsage({ modelId, @@ -281,6 +311,10 @@ export const ContextOutputUsage = ({ return children; } + if (!outputTokens) { + return null; + } + const outputCost = modelId ? getUsage({ modelId, diff --git a/frontend/src/components/ai-elements/conversation.browser.test.tsx b/frontend/src/components/ai-elements/conversation.browser.test.tsx new file mode 100644 index 0000000000..68e532839c --- /dev/null +++ b/frontend/src/components/ai-elements/conversation.browser.test.tsx @@ -0,0 +1,92 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { UIMessage } from 'ai'; +import { afterEach, describe, test } from 'vitest'; +import { page } from 'vitest/browser'; +import { cleanup, render } from 'vitest-browser-react'; + +import { captureScreenshotFrame, ScreenshotFrame } from '../../__tests__/browser-test-utils'; +import { ConversationDownload } from './conversation'; + +afterEach(() => { + cleanup(); +}); + +const sampleMessages: UIMessage[] = [ + { + id: 'm1', + role: 'user', + parts: [{ type: 'text', text: 'Summarise the last 24h of topic activity.' }], + }, + { + id: 'm2', + role: 'assistant', + parts: [ + { + type: 'text', + text: 'Produced 1.2M messages across 14 topics; peak throughput at 09:42 UTC.', + }, + ], + }, +]; + +// The real ConversationDownload is positioned absolute. For the screenshot we +// frame it inside a relative container so the trigger is shown alongside a +// short transcript. +const DownloadPanel = () => ( + +
+
+

+ User: Summarise the last 24h of topic activity. +

+

+ Assistant: Produced 1.2M messages across 14 topics; peak + throughput at 09:42 UTC. +

+
+ +
+
+); + +const shot = (name: string) => + captureScreenshotFrame(page.getByTestId('screenshot-frame'), name); + +describe('ConversationDownload screenshots', () => { + test('idle trigger rendered next to a short transcript', async () => { + render(); + await shot('conversation-download-idle'); + }); + + test('hover state (focus-ring / post-hover styling)', async () => { + render(); + // Hover shows the secondary-ghost button focus styling. We intentionally + // don't click — click triggers a real blob download which creates flake + // in headless Chromium. + await page.getByLabelText('Download conversation').hover(); + await shot('conversation-download-hover'); + }); +}); diff --git a/frontend/src/components/ai-elements/conversation.test.tsx b/frontend/src/components/ai-elements/conversation.test.tsx new file mode 100644 index 0000000000..ecf9a9450f --- /dev/null +++ b/frontend/src/components/ai-elements/conversation.test.tsx @@ -0,0 +1,81 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { UIMessage } from 'ai'; +import { describe, expect, test } from 'vitest'; + +import { messagesToMarkdown } from './conversation'; + +const makeMessage = ( + role: UIMessage['role'], + text: string +): UIMessage => ({ + id: `msg-${role}-${text}`, + role, + parts: [{ type: 'text', text }], +}); + +describe('messagesToMarkdown', () => { + test('formats a single user message with a capitalised role label', () => { + const md = messagesToMarkdown([makeMessage('user', 'hello')]); + expect(md).toBe('**User:** hello'); + }); + + test('formats assistant message and joins multiple messages with blank lines', () => { + const md = messagesToMarkdown([ + makeMessage('user', 'ping'), + makeMessage('assistant', 'pong'), + ]); + expect(md).toBe('**User:** ping\n\n**Assistant:** pong'); + }); + + test('concatenates multiple text parts within a single message', () => { + const message: UIMessage = { + id: 'm1', + role: 'assistant', + parts: [ + { type: 'text', text: 'part-a ' }, + { type: 'text', text: 'part-b' }, + ], + }; + const md = messagesToMarkdown([message]); + expect(md).toBe('**Assistant:** part-a part-b'); + }); + + test('ignores non-text message parts', () => { + const message: UIMessage = { + id: 'm1', + role: 'assistant', + parts: [ + { type: 'text', text: 'visible' }, + { type: 'step-start' }, + ], + }; + const md = messagesToMarkdown([message]); + expect(md).toBe('**Assistant:** visible'); + }); + + test('uses a caller-supplied formatter when given', () => { + const md = messagesToMarkdown( + [makeMessage('user', 'hi'), makeMessage('assistant', 'yo')], + (msg, i) => + `${i + 1}. <${msg.role}> ${msg.parts + .filter((p) => p.type === 'text') + .map((p) => p.text) + .join('')}` + ); + expect(md).toBe('1. hi\n\n2. yo'); + }); + + test('returns an empty string for an empty message list', () => { + expect(messagesToMarkdown([])).toBe(''); + }); +}); diff --git a/frontend/src/components/ai-elements/conversation.tsx b/frontend/src/components/ai-elements/conversation.tsx index 123807f22e..a87dffa598 100644 --- a/frontend/src/components/ai-elements/conversation.tsx +++ b/frontend/src/components/ai-elements/conversation.tsx @@ -2,7 +2,8 @@ import { Button } from "components/redpanda-ui/components/button"; import { cn } from "components/redpanda-ui/lib/utils"; -import { ArrowDownIcon } from "lucide-react"; +import type { UIMessage } from "ai"; +import { ArrowDownIcon, DownloadIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { useCallback } from "react"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; @@ -97,3 +98,67 @@ export const ConversationScrollButton = ({ ) ); }; + +const getMessageText = (message: UIMessage): string => + message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + +export type ConversationDownloadProps = Omit< + ComponentProps, + "onClick" +> & { + messages: UIMessage[]; + filename?: string; + formatMessage?: (message: UIMessage, index: number) => string; +}; + +const defaultFormatMessage = (message: UIMessage): string => { + const roleLabel = + message.role.charAt(0).toUpperCase() + message.role.slice(1); + return `**${roleLabel}:** ${getMessageText(message)}`; +}; + +export const messagesToMarkdown = ( + messages: UIMessage[], + formatMessage: ( + message: UIMessage, + index: number + ) => string = defaultFormatMessage +): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n"); + +export const ConversationDownload = ({ + messages, + filename = "conversation.md", + formatMessage = defaultFormatMessage, + className, + children, + ...props +}: ConversationDownloadProps) => { + const handleDownload = useCallback(() => { + const markdown = messagesToMarkdown(messages, formatMessage); + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, [messages, filename, formatMessage]); + + return ( + + ); +}; diff --git a/frontend/src/components/ai-elements/image.tsx b/frontend/src/components/ai-elements/image.tsx index be30d0e332..2aa2873df9 100644 --- a/frontend/src/components/ai-elements/image.tsx +++ b/frontend/src/components/ai-elements/image.tsx @@ -6,9 +6,20 @@ export type ImageProps = Experimental_GeneratedImage & { alt?: string; }; +/** + * `Experimental_GeneratedImage` from the `ai` package ships both a `base64` + * string and a `uint8Array` byte buffer containing the same image bytes — they + * are two representations of one value. Browsers render images via + * `data:;base64,` URIs, so the `base64` field covers every case + * the `uint8Array` would. Pulling `uint8Array` into a discarded underscore + * variable keeps it out of `...props` (it would otherwise land on the `` + * DOM element and trigger a React warning) while silencing the unused-var + * lint rule. If we ever need raw bytes (e.g. to feed a `Blob` for download), + * rename to `uint8Array` and handle it explicitly. + */ export const Image = ({ base64, - uint8Array, + uint8Array: _uint8Array, mediaType, ...props }: ImageProps) => ( diff --git a/frontend/src/components/ai-elements/response.test.tsx b/frontend/src/components/ai-elements/response.test.tsx new file mode 100644 index 0000000000..1f04b118a5 --- /dev/null +++ b/frontend/src/components/ai-elements/response.test.tsx @@ -0,0 +1,73 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { render } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { Response } from './response'; + +// --------------------------------------------------------------------------- +// Regression guards for the streamdown v2 bump. v2 replaced several remark / +// rehype internals for CJK and KaTeX support — we assert that basic markdown +// still renders, that fenced code blocks survive the pipeline, and that the +// memoised wrapper bails out on identical children. +// --------------------------------------------------------------------------- + +describe('Response', () => { + test('renders a plain paragraph from basic markdown', () => { + const { container } = render(hello world); + // streamdown v2 should still produce a paragraph for bare text input. + const paragraph = container.querySelector('p'); + expect(paragraph).not.toBeNull(); + expect((paragraph?.textContent ?? '').trim()).toBe('hello world'); + }); + + test('renders bold markdown with the text preserved', () => { + const { container } = render(**bold text**); + // streamdown v2's streaming pipeline may defer wrapping until the + // marker closes; we mainly want to guarantee the raw text survives the + // sanitiser / highlighter roundtrip without losing characters. + expect((container.textContent ?? '').includes('bold text')).toBe(true); + }); + + test('renders fenced code blocks inside a
', () => {
+    const { container } = render(
+      {'```ts\nconst x = 1;\n```'}
+    );
+    // The syntax-highlighting pipeline must still produce a 

+    // structure; streamdown v2 swaps internals but the public shape should
+    // hold.
+    const pre = container.querySelector('pre');
+    const code = container.querySelector('pre code');
+    expect(pre).not.toBeNull();
+    expect(code).not.toBeNull();
+    expect((code?.textContent ?? '').includes('const x = 1')).toBe(true);
+  });
+
+  test('memo bails out and preserves DOM identity when children unchanged', () => {
+    const { container, rerender } = render(stable content);
+    const first = container.firstElementChild;
+    rerender(stable content);
+    const second = container.firstElementChild;
+    // The custom propsAreEqual in `response.tsx` compares `children`; when it
+    // matches, React skips the re-render and the DOM node stays referentially
+    // identical.
+    expect(second).toBe(first);
+  });
+
+  test('memo re-renders when children change', () => {
+    const { container, rerender } = render(first);
+    rerender(second);
+    // Content must update when `children` changes, otherwise streaming output
+    // would freeze on the first chunk.
+    expect((container.textContent ?? '').includes('second')).toBe(true);
+  });
+});
diff --git a/frontend/src/components/ai-elements/shimmer.browser.test.tsx b/frontend/src/components/ai-elements/shimmer.browser.test.tsx
new file mode 100644
index 0000000000..4815aa3be9
--- /dev/null
+++ b/frontend/src/components/ai-elements/shimmer.browser.test.tsx
@@ -0,0 +1,37 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { afterEach, describe, test } from 'vitest';
+import { page } from 'vitest/browser';
+import { cleanup, render } from 'vitest-browser-react';
+
+import { captureScreenshotFrame, ScreenshotFrame } from '../../__tests__/browser-test-utils';
+import { Shimmer } from './shimmer';
+
+afterEach(() => {
+  cleanup();
+});
+
+const shot = (name: string) =>
+  captureScreenshotFrame(page.getByTestId('screenshot-frame'), name);
+
+describe('Shimmer screenshots', () => {
+  test('loading shimmer — frozen frame (reduced motion disables the animation)', async () => {
+    render(
+      
+        
+ Generating response… +
+
+ ); + await shot('shimmer-loading'); + }); +}); diff --git a/frontend/src/components/ai-elements/shimmer.test.tsx b/frontend/src/components/ai-elements/shimmer.test.tsx new file mode 100644 index 0000000000..173f230285 --- /dev/null +++ b/frontend/src/components/ai-elements/shimmer.test.tsx @@ -0,0 +1,81 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { Shimmer } from './shimmer'; + +// --------------------------------------------------------------------------- +// These tests guard the module-level motion-component cache introduced when +// bumping streamdown/ai-elements. Regression here would be silent — rendering +// would start creating a fresh motion component on every render, eventually +// tearing down its animation frame — so we assert observable behaviour: +// multiple renders with the same `as` succeed, different `as` values each +// render correctly, and the `memo()` wrapper preserves the text content +// across re-renders with identical props. +// --------------------------------------------------------------------------- + +describe('Shimmer', () => { + test('renders the provided text inside a

by default', () => { + render(Loading); + const node = screen.getByText('Loading'); + // Default `as` is "p" — verify the cached motion component wraps in a + // paragraph so we don't accidentally regress the DOM shape consumers + // rely on for styling. + expect(node.tagName).toBe('P'); + }); + + test('renders with a non-default element via `as`', () => { + render( + + Streaming + + ); + const node = screen.getByText('Streaming'); + expect(node.tagName).toBe('SPAN'); + expect(node.className).toContain('my-shimmer'); + }); + + test('re-rendering with identical props leaves DOM node untouched (memo bails out)', () => { + const { rerender, container } = render(Hello); + const firstNode = container.firstElementChild; + rerender(Hello); + const secondNode = container.firstElementChild; + // `memo` must return the same React element, so the DOM node reference + // is preserved across re-renders. + expect(secondNode).toBe(firstNode); + }); + + test('re-rendering with the same `as` across two instances does not throw', () => { + // The module-level cache is reused across instances. Render two Shimmer + // instances with `as="span"` and confirm both appear — if the cache + // were incorrectly keyed, the second render could blow up or lose the + // content. + render( + <> + one + two + + ); + expect(screen.getByText('one').tagName).toBe('SPAN'); + expect(screen.getByText('two').tagName).toBe('SPAN'); + }); + + test('switching `as` between renders swaps element type', () => { + // Different `as` values should produce distinct cached motion + // components. When `as` changes, the DOM tag must change too. + const { rerender, container } = render(content); + expect(container.firstElementChild?.tagName).toBe('P'); + rerender(content); + expect(container.firstElementChild?.tagName).toBe('SPAN'); + }); +}); diff --git a/frontend/src/components/ai-elements/shimmer.tsx b/frontend/src/components/ai-elements/shimmer.tsx index 32f223c020..a91d204a3d 100644 --- a/frontend/src/components/ai-elements/shimmer.tsx +++ b/frontend/src/components/ai-elements/shimmer.tsx @@ -1,14 +1,27 @@ "use client"; import { cn } from "components/redpanda-ui/lib/utils"; +import type { MotionProps } from "motion/react"; import { motion } from "motion/react"; -import { - type CSSProperties, - type ElementType, - type JSX, - memo, - useMemo, -} from "react"; +import type { CSSProperties, ElementType, JSX } from "react"; +import { memo, useMemo } from "react"; + +type MotionHTMLProps = MotionProps & Record; + +// Cache motion components at module level to avoid creating during render +const motionComponentCache = new Map< + keyof JSX.IntrinsicElements, + React.ComponentType +>(); + +const getMotionComponent = (element: keyof JSX.IntrinsicElements) => { + let component = motionComponentCache.get(element); + if (!component) { + component = motion.create(element); + motionComponentCache.set(element, component); + } + return component; +}; export type TextShimmerProps = { children: string; @@ -25,7 +38,7 @@ const ShimmerComponent = ({ duration = 2, spread = 2, }: TextShimmerProps) => { - const MotionComponent = motion.create( + const MotionComponent = getMotionComponent( Component as keyof JSX.IntrinsicElements ); @@ -51,9 +64,9 @@ const ShimmerComponent = ({ } as CSSProperties } transition={{ - repeat: Number.POSITIVE_INFINITY, duration, ease: "linear", + repeat: Number.POSITIVE_INFINITY, }} > {children} diff --git a/frontend/src/components/ai-elements/tool.browser.test.tsx b/frontend/src/components/ai-elements/tool.browser.test.tsx new file mode 100644 index 0000000000..faabfe4af7 --- /dev/null +++ b/frontend/src/components/ai-elements/tool.browser.test.tsx @@ -0,0 +1,172 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { ReactNode } from 'react'; +import { afterEach, describe, test } from 'vitest'; +import { page } from 'vitest/browser'; +import { cleanup, render } from 'vitest-browser-react'; + +import { captureScreenshotFrame, ScreenshotFrame } from '../../__tests__/browser-test-utils'; +import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from './tool'; + +afterEach(() => { + cleanup(); +}); + +// Force the collapsible open so the inner input/output panels are visible in +// the screenshots. `Tool` is a Radix Collapsible; we control it via `open`. +const OpenTool = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const shot = (name: string) => + captureScreenshotFrame(page.getByTestId('screenshot-frame'), name); + +describe('Tool card screenshots', () => { + test('input-streaming', async () => { + render( + + + + + + + ); + await shot('tool-input-streaming'); + }); + + test('input-available', async () => { + render( + + + + + + + ); + await shot('tool-input-available'); + }); + + test('output-available', async () => { + render( + + + + + + + + ); + await shot('tool-output-available'); + }); + + test('output-error', async () => { + render( + + + + + + + + ); + await shot('tool-output-error'); + }); + + test('approval-requested (newly adopted)', async () => { + render( + + + + + + + ); + await shot('tool-approval-requested'); + }); + + test('approval-responded (newly adopted)', async () => { + render( + + + + + + + ); + await shot('tool-approval-responded'); + }); + + test('output-denied (newly adopted)', async () => { + render( + + + + + + + + ); + await shot('tool-output-denied'); + }); + + test('dynamic-tool with runtime toolName override (newly adopted)', async () => { + render( + + + + + + + + ); + await shot('tool-dynamic-toolname'); + }); +}); diff --git a/frontend/src/components/ai-elements/tool.test.tsx b/frontend/src/components/ai-elements/tool.test.tsx new file mode 100644 index 0000000000..3c6999b565 --- /dev/null +++ b/frontend/src/components/ai-elements/tool.test.tsx @@ -0,0 +1,337 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from './tool'; + +describe('ToolHeader', () => { + test('renders static tool with name derived from type', () => { + render( + + + + ); + expect(screen.getByText('get-weather')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + test('renders dynamic tool using the provided toolName prop', () => { + render( + + + + ); + // The dynamic tool's runtime name should be shown verbatim, not derived + // from the constant `dynamic-tool` type. + expect(screen.getByText('mcp_list_topics')).toBeInTheDocument(); + expect(screen.getByText('Working')).toBeInTheDocument(); + }); + + test('shows "Awaiting Approval" badge for approval-requested state', () => { + render( + + + + ); + expect(screen.getByText('Awaiting Approval')).toBeInTheDocument(); + }); + + test('shows "Responded" badge for approval-responded state', () => { + render( + + + + ); + expect(screen.getByText('Responded')).toBeInTheDocument(); + }); + + test('shows "Denied" badge for output-denied state', () => { + render( + + + + ); + expect(screen.getByText('Denied')).toBeInTheDocument(); + }); + + test('explicit title overrides derived name', () => { + render( + + + + ); + expect(screen.getByText('Custom Title')).toBeInTheDocument(); + expect(screen.queryByText('get-weather')).not.toBeInTheDocument(); + }); + + test('renders duration when output is available', () => { + render( + + + + ); + expect(screen.getByText('1.23s')).toBeInTheDocument(); + }); + + test('renders sub-second duration as ms', () => { + render( + + + + ); + expect(screen.getByText('850ms')).toBeInTheDocument(); + }); + + test('renders toolCallId when provided', () => { + render( + + + + ); + expect(screen.getByText('call-xyz-123')).toBeInTheDocument(); + }); + + test('copy button title uses derived name for static tools', () => { + render( + + + + ); + // Regression guard: previously used `toolName` which is undefined for + // static tools, producing "Copy: undefined". + expect(screen.getByTitle('Copy: get-weather (call-xyz-123)')).toBeInTheDocument(); + }); + + test('copy button title uses toolName for dynamic tools', () => { + render( + + + + ); + expect(screen.getByTitle('Copy: mcp_list_topics')).toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // Additional state-coverage cases (added under a2a/mcp/ai-elements bump) + // These exercise the approval flow beyond the happy path and guard against + // regression of badge rendering for error / streaming states. + // --------------------------------------------------------------------------- + + test('shows "Pending" badge for input-streaming state', () => { + render( + + + + ); + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + + test('shows "Error" badge and duration for output-error state', () => { + render( + + + + ); + expect(screen.getByText('Error')).toBeInTheDocument(); + // Duration should render for both output-available and output-error — gating + // on only one would hide execution time when users most need it. + expect(screen.getByText('500ms')).toBeInTheDocument(); + }); + + test('suppresses duration for in-flight states even when durationMs is set', () => { + render( + + + + ); + // Duration should not leak into the header for in-flight (`input-available`) + // tool executions — showing a duration mid-flight would be misleading. + expect(screen.queryByText('2.35s')).not.toBeInTheDocument(); + }); + + test('approval-requested → output-denied transition swaps badges correctly', () => { + const { rerender } = render( + + + + ); + expect(screen.getByText('Awaiting Approval')).toBeInTheDocument(); + + rerender( + + + + ); + // After denial, the approval-requested badge must be gone and the denied + // state must surface — otherwise users might not realise the tool was + // cancelled. + expect(screen.queryByText('Awaiting Approval')).not.toBeInTheDocument(); + expect(screen.getByText('Denied')).toBeInTheDocument(); + }); + + test('renders no badge for an unknown tool state', () => { + // `getStatusBadge` returns null for anything outside the known set. The + // header should still render the tool name without throwing. We narrow + // via `unknown` cast to avoid polluting this guard test with a wider + // type-escape hatch. + const unknownState = 'stale-unknown' as unknown as 'output-available'; + render( + + + + ); + expect(screen.getByText('get-weather')).toBeInTheDocument(); + // None of the known status labels should have leaked in. + for (const label of [ + 'Completed', + 'Working', + 'Error', + 'Pending', + 'Awaiting Approval', + 'Responded', + 'Denied', + ]) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + }); +}); + +describe('ToolInput / ToolOutput', () => { + test('ToolInput renders nothing for an empty object', () => { + const { container } = render( + + + + + + ); + // An empty tool input should collapse away rather than render a bare + // "Parameters" section that confuses readers. + expect(container.querySelector('h4')).toBeNull(); + }); + + test('ToolOutput renders the parsed JSON output for objects', () => { + const { container } = render( + + + + + + ); + // The object output is pretty-printed and the code-block syntax + // highlighter splits tokens across spans, so we assert on the concatenated + // textContent of the rendered region. + const text = container.textContent ?? ''; + expect(text).toContain('"topics"'); + expect(text).toContain('"a"'); + // The label should identify this as a result, not an error. + expect(screen.getByText('Result')).toBeInTheDocument(); + }); + + test('ToolOutput renders errorText when provided', () => { + const { container } = render( + + + + + + ); + // Heading becomes "Error" and the body contains the error text. + expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Error'); + expect(container.textContent ?? '').toContain('boom'); + }); + + test('ToolOutput applies destructive background only in the error state', () => { + const { container: errorContainer } = render( + + + + + + ); + // Regression guard: the error branch previously collided with the + // success branch on `bg-muted/50`, so errors were visually + // indistinguishable from successful tool output. + expect(errorContainer.querySelector('.bg-destructive\\/10')).not.toBeNull(); + + const { container: okContainer } = render( + + + + + + ); + expect(okContainer.querySelector('.bg-destructive\\/10')).toBeNull(); + expect(okContainer.querySelector('.bg-muted\\/50')).not.toBeNull(); + }); +}); diff --git a/frontend/src/components/ai-elements/tool.tsx b/frontend/src/components/ai-elements/tool.tsx index a36ec16e8e..9adf0dbfe8 100644 --- a/frontend/src/components/ai-elements/tool.tsx +++ b/frontend/src/components/ai-elements/tool.tsx @@ -9,12 +9,14 @@ import { import { CopyButton } from "components/redpanda-ui/components/copy-button"; import { Text } from "components/redpanda-ui/components/typography"; import { cn } from "components/redpanda-ui/lib/utils"; -import type { ToolUIPart } from "ai"; +import type { DynamicToolUIPart, ToolUIPart } from "ai"; import { CheckIcon, ChevronDownIcon, ClockIcon, LoaderIcon, + ShieldAlertIcon, + ShieldCheckIcon, WrenchIcon, XIcon, } from "lucide-react"; @@ -23,6 +25,16 @@ import { isValidElement } from "react"; import { deepParseJson } from "utils/json-utils"; import { CodeBlock } from "./code-block"; +/** + * Union of static and dynamic tool UI parts. + * + * Static (typed) tools come from `UITools`; dynamic tools (e.g. provider-executed + * MCP tools resolved at runtime) have `type: 'dynamic-tool'` and expose a + * `toolName` field. Callers that handle MCP tools should accept `ToolPart` + * rather than `ToolUIPart` to cover both shapes. + */ +export type ToolPart = ToolUIPart | DynamicToolUIPart; + export type ToolProps = ComponentProps; export const Tool = ({ className, ...props }: ToolProps) => ( @@ -34,14 +46,19 @@ export const Tool = ({ className, ...props }: ToolProps) => ( export type ToolHeaderProps = { title?: string; - type: ToolUIPart["type"]; - state: ToolUIPart["state"]; className?: string; toolCallId?: string; durationMs?: number; -}; +} & ( + | { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never } + | { + type: DynamicToolUIPart["type"]; + state: DynamicToolUIPart["state"]; + toolName: string; + } +); -const getStatusBadge = (status: ToolUIPart["state"]) => { +const getStatusBadge = (status: ToolPart["state"]) => { if (status === "output-available") { return ( @@ -82,6 +99,36 @@ const getStatusBadge = (status: ToolUIPart["state"]) => { ); } + if (status === "approval-requested") { + return ( + + + + Awaiting Approval + + + ); + } + if (status === "approval-responded") { + return ( + + + + Responded + + + ); + } + if (status === "output-denied") { + return ( + + + + Denied + + + ); + } return null; }; @@ -92,10 +139,16 @@ export const ToolHeader = ({ state, toolCallId, durationMs, + toolName, ...props }: ToolHeaderProps) => { - const toolName = title ?? type.split("-").slice(1).join("-"); - const textToCopy = toolCallId ? `${toolName} (${toolCallId})` : toolName; + // For dynamic tools (e.g. MCP tools resolved at runtime), use the provided + // `toolName` directly. For static tools the name is encoded in the part + // `type`, e.g. `tool-get-weather` → `get-weather`. + const derivedName = + type === "dynamic-tool" ? (toolName ?? "") : type.split("-").slice(1).join("-"); + const displayName = title ?? derivedName; + const textToCopy = toolCallId ? `${displayName} (${toolCallId})` : displayName; const formatDuration = (ms: number): string => { if (ms < 1000) { @@ -116,7 +169,7 @@ export const ToolHeader = ({

- {toolName} + {displayName} {getStatusBadge(state)} {durationMs !== undefined && (state === 'output-available' || state === 'output-error') && ( @@ -137,7 +190,7 @@ export const ToolHeader = ({ size="icon" className="size-7" onClick={(e) => e.stopPropagation()} - title={toolCallId ? `Copy: ${toolName} (${toolCallId})` : `Copy: ${toolName}`} + title={toolCallId ? `Copy: ${displayName} (${toolCallId})` : `Copy: ${displayName}`} />
@@ -160,7 +213,7 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => ( ); export type ToolInputProps = ComponentProps<"div"> & { - input: ToolUIPart["input"]; + input: ToolPart["input"]; }; export const ToolInput = ({ className, input, ...props }: ToolInputProps) => { @@ -185,8 +238,8 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => { }; export type ToolOutputProps = ComponentProps<"div"> & { - output: ToolUIPart["output"]; - errorText: ToolUIPart["errorText"]; + output: ToolPart["output"]; + errorText: ToolPart["errorText"]; }; export const ToolOutput = ({ @@ -221,7 +274,7 @@ export const ToolOutput = ({

{errorText ? "Error" : "Result"}

-
+
{errorText && } {hasOutput && Output}
diff --git a/frontend/src/components/pages/agents/details/a2a/a2a-chat-language-model.test.ts b/frontend/src/components/pages/agents/details/a2a/a2a-chat-language-model.test.ts new file mode 100644 index 0000000000..910fe07799 --- /dev/null +++ b/frontend/src/components/pages/agents/details/a2a/a2a-chat-language-model.test.ts @@ -0,0 +1,162 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { Message, Task, TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent } from '@a2a-js/sdk'; +import { describe, expect, test } from 'vitest'; + +import { getResponseMetadata, mapFinishReason } from './a2a-chat-language-model'; + +// --------------------------------------------------------------------------- +// Regression guards for the A2A → AI SDK v6 adapter. We assert on the pure +// helpers that shape the `LanguageModel` contract — `mapFinishReason` decides +// which finish reason the v6 stream emits when the task terminates, and +// `getResponseMetadata` fills the `response-metadata` stream part. Both are +// load-bearing for `useChat` downstream, which inspects finish reason to +// decide whether to surface errors vs normal completion. +// --------------------------------------------------------------------------- + +const mkStatusUpdate = (state: TaskState, timestamp?: string): TaskStatusUpdateEvent => ({ + kind: 'status-update', + contextId: 'ctx-1', + taskId: 'task-1', + final: true, + status: { + state, + timestamp, + }, +}); + +describe('mapFinishReason', () => { + test('maps completed → stop', () => { + expect(mapFinishReason(mkStatusUpdate('completed'))).toBe('stop'); + }); + + test('maps input-required → stop (awaiting further user input, not an error)', () => { + expect(mapFinishReason(mkStatusUpdate('input-required'))).toBe('stop'); + }); + + test('maps submitted → stop', () => { + expect(mapFinishReason(mkStatusUpdate('submitted'))).toBe('stop'); + }); + + test('maps failed → error', () => { + expect(mapFinishReason(mkStatusUpdate('failed'))).toBe('error'); + }); + + test('maps rejected → error', () => { + // Rejected by upstream policy / guard — surfaces as an error so the + // UI can render the reason. + expect(mapFinishReason(mkStatusUpdate('rejected'))).toBe('error'); + }); + + test('maps auth-required → error', () => { + // Auth-required is treated as an error path by the adapter so the caller + // can prompt re-authentication rather than silently finishing. + expect(mapFinishReason(mkStatusUpdate('auth-required'))).toBe('error'); + }); + + test('maps canceled → other', () => { + expect(mapFinishReason(mkStatusUpdate('canceled'))).toBe('other'); + }); + + test('maps working → unknown', () => { + // `working` should never actually arrive as a final status, but if it + // does the adapter falls back to `unknown` rather than pretending the + // run completed. + expect(mapFinishReason(mkStatusUpdate('working'))).toBe('unknown'); + }); + + test('maps unknown → unknown', () => { + expect(mapFinishReason(mkStatusUpdate('unknown'))).toBe('unknown'); + }); + + // Defensive: the backend may evolve `TaskState` with new enum values. The + // adapter's contract is that any value outside the known set falls back + // to `'unknown'` so the stream doesn't pretend the run completed + // successfully. We pin this behaviour here so a drive-by edit of + // `mapFinishReason` doesn't silently change it. + test.each<[string]>([ + ['in-progress'], + ['queued'], + ['mystery-state'], + [''], + ])('unknown enum value "%s" falls back to "unknown"', (state) => { + // Cast via `unknown` — this value is deliberately outside the TaskState + // union so we have to bypass the compile-time check. + expect(mapFinishReason(mkStatusUpdate(state as unknown as TaskState))).toBe('unknown'); + }); +}); + +describe('getResponseMetadata', () => { + test('derives id, timestamp from a Task event', () => { + const task: Task = { + kind: 'task', + id: 'task-42', + contextId: 'ctx-1', + status: { + state: 'completed', + timestamp: '2025-04-18T12:00:00.000Z', + }, + }; + const meta = getResponseMetadata(task); + expect(meta).toMatchObject({ + id: 'task-42', + modelId: undefined, + }); + expect(meta.timestamp).toBeInstanceOf(Date); + expect((meta.timestamp as Date).toISOString()).toBe('2025-04-18T12:00:00.000Z'); + }); + + test('uses messageId for a Message event', () => { + const message: Message = { + kind: 'message', + messageId: 'msg-9', + role: 'agent', + parts: [{ kind: 'text', text: 'hi' }], + }; + expect(getResponseMetadata(message)).toEqual({ + id: 'msg-9', + modelId: undefined, + timestamp: undefined, + }); + }); + + test('uses taskId for status-update events', () => { + const event = mkStatusUpdate('working', '2025-04-18T12:30:00.000Z'); + const meta = getResponseMetadata(event); + expect(meta.id).toBe('task-1'); + expect(meta.timestamp).toBeInstanceOf(Date); + }); + + test('uses taskId for artifact-update events (timestamp always undefined)', () => { + const event: TaskArtifactUpdateEvent = { + kind: 'artifact-update', + contextId: 'ctx-1', + taskId: 'task-7', + artifact: { + artifactId: 'a-1', + parts: [{ kind: 'text', text: 'x' }], + }, + }; + expect(getResponseMetadata(event)).toEqual({ + id: 'task-7', + modelId: undefined, + timestamp: undefined, + }); + }); + + test('status-update without a timestamp yields timestamp undefined', () => { + // If the agent omits a timestamp, adapter must not crash (new Date(undefined) + // would produce an invalid Date). Instead `undefined` should propagate. + const event = mkStatusUpdate('working'); + expect(getResponseMetadata(event).timestamp).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/pages/agents/details/a2a/a2a-chat-language-model.tsx b/frontend/src/components/pages/agents/details/a2a/a2a-chat-language-model.tsx index 352345778c..45a2a9da04 100644 --- a/frontend/src/components/pages/agents/details/a2a/a2a-chat-language-model.tsx +++ b/frontend/src/components/pages/agents/details/a2a/a2a-chat-language-model.tsx @@ -24,6 +24,8 @@ import { import { convertAsyncIteratorToReadableStream, generateId, IdGenerator } from '@ai-sdk/provider-utils'; import { getAgentCardUrls } from 'utils/ai-agent.utils'; +import { a2aEventToV2StreamParts, finalizeStream, initialStreamMapperState } from './a2a-stream-mapper'; + /** * Try multiple agent card URLs in order until one succeeds. * Tries agent-card.json first, then falls back to agent.json @@ -146,24 +148,63 @@ function isErrorResponse( return 'error' in response; } +/** + * The subset of `A2AClient` methods that `chooseA2ASourceStream` needs. Kept + * structural so unit tests can supply fakes without constructing a real + * client (which hits the network via `fromCardUrl`). + */ +export type A2ATransport = { + getAgentCard: () => Promise<{ capabilities: { streaming?: boolean } }>; + sendMessage: (params: MessageSendParams) => Promise; + sendMessageStream: (params: MessageSendParams) => AsyncIterable; +}; + +/** + * Select the source stream for an A2A exchange: + * - streaming-capable agents consume `sendMessageStream` directly + * - non-streaming agents receive a single blocking `sendMessage` whose + * successful `result` is replayed as a one-event ReadableStream + * + * The branches are mutually exclusive; the previous implementation fell + * through and double-dispatched the prompt. + */ +export async function chooseA2ASourceStream( + client: A2ATransport, + streamParams: MessageSendParams +): Promise> { + const card = await client.getAgentCard(); + + if (card.capabilities.streaming) { + const iterable = client.sendMessageStream(streamParams); + return convertAsyncIteratorToReadableStream(iterable[Symbol.asyncIterator]()); + } + + const response = await client.sendMessage(streamParams); + + if ('error' in response) { + const err = (response as { error: { message: string } }).error; + throw new Error(`A2A sendMessage failed: ${err.message}`); + } + + const { result } = response as SendMessageSuccessResponse; + return new ReadableStream({ + start(controller) { + controller.enqueue(result); + controller.close(); + }, + }); +} + class A2aChatLanguageModel implements LanguageModelV2 { readonly specificationVersion = 'v2'; readonly provider: string; readonly modelId: string; - // @ts-ignore part of A2A adapter for AI SDK private readonly config: A2aChatConfig; - - constructor( - modelId: string, - // @ts-ignore part of A2A adapter for AI SDK - settings: A2aChatSettings, - config: A2aChatConfig, - ) { + constructor(modelId: string, _settings: A2aChatSettings, config: A2aChatConfig) { this.provider = config.provider; this.modelId = modelId; this.config = config; - // Initialize with settings and config } // Convert AI SDK prompt to provider format @@ -292,82 +333,33 @@ class A2aChatLanguageModel implements LanguageModelV2 { const streamParams: MessageSendParams = { message }; - const clientCard = await client.getAgentCard(); - - let simulatedStream = null; - - if (!clientCard.capabilities.streaming) { - const nonStreamingResponse = await client.sendMessage(streamParams); - - if ("result" in nonStreamingResponse) { - // task or message - simulatedStream = new ReadableStream({ - start(controller) { - controller.enqueue(nonStreamingResponse.result); - controller.close(); - }, - }); - } - - if ("error" in nonStreamingResponse) { - // FIXME: error - } - } - - // Use the `sendMessageStream` method. - const response = client.sendMessageStream(streamParams); - let isFirstChunk = true; - const activeTextIds = new Set(); - let finishReason: LanguageModelV2FinishReason = 'unknown'; + const sourceStream = await chooseA2ASourceStream(client, streamParams); + let state = initialStreamMapperState(); return { - stream: (simulatedStream || convertAsyncIteratorToReadableStream(response)).pipeThrough( + stream: sourceStream.pipeThrough( new TransformStream< A2AStreamEventData, LanguageModelV2StreamPart >({ start(controller) { + // Emitted at the call site rather than in the reducer because the + // `warnings` array is scoped to this invocation of `doStream`. controller.enqueue({ type: 'stream-start', warnings }); }, transform(event, controller) { - // Emit raw chunk if requested (before anything else) - if (options.includeRawChunks) { - controller.enqueue({ type: 'raw', rawValue: event }); - } - - if (isFirstChunk) { - isFirstChunk = false; - - controller.enqueue({ - type: 'response-metadata', - ...getResponseMetadata(event), - }); - } - - // Handle only artifact-update and task state changes - if (event.kind === 'status-update') { - if (event.final) { - finishReason = mapFinishReason(event) - } + const { parts, state: next } = a2aEventToV2StreamParts(event, state, { + includeRawChunks: options.includeRawChunks, + }); + for (const part of parts) { + controller.enqueue(part); } - // Artifact-update events are handled as raw events, not converted to text-delta + state = next; }, flush(controller) { - activeTextIds.forEach((activeTextId) => { - controller.enqueue({ type: 'text-end', id: activeTextId }); - }) - - controller.enqueue({ - type: 'finish', - finishReason, - usage: { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - }, - }); + controller.enqueue(finalizeStream(state)); }, }), ), diff --git a/frontend/src/components/pages/agents/details/a2a/a2a-choose-source-stream.test.ts b/frontend/src/components/pages/agents/details/a2a/a2a-choose-source-stream.test.ts new file mode 100644 index 0000000000..31f67fa988 --- /dev/null +++ b/frontend/src/components/pages/agents/details/a2a/a2a-choose-source-stream.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { MessageSendParams, SendMessageResponse } from '@a2a-js/sdk'; +import { describe, expect, test, vi } from 'vitest'; + +import { type A2ATransport, chooseA2ASourceStream } from './a2a-chat-language-model'; + +// Top-level regex literal — biome's `useTopLevelRegex` rule flags inline +// regexes inside test bodies because they are recompiled on every call. +const SEND_MESSAGE_FAILED_PATTERN = /A2A sendMessage failed: upstream down/; + +// Regression guards for the doStream transport-selection fix: the branches +// for streaming-capable vs. non-streaming agents must be mutually exclusive, +// and sendMessage errors must propagate instead of silently falling through +// to a second sendMessageStream dispatch. + +const PARAMS: MessageSendParams = { + message: { + role: 'user', + parts: [{ kind: 'text', text: 'hi' }], + messageId: 'm1', + kind: 'message', + }, +}; + +async function collect(stream: ReadableStream): Promise { + const out: T[] = []; + const reader = stream.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (value !== undefined) { + out.push(value); + } + } + return out; +} + +describe('chooseA2ASourceStream', () => { + test('streaming-capable agent: only sendMessageStream is called', async () => { + const sendMessage = vi.fn(); + // biome-ignore lint/suspicious/useAwait: generator must be `async function*` to be an AsyncIterable + async function* streamGen() { + yield { kind: 'task', id: 't1' } as never; + } + const sendMessageStream = vi.fn(() => streamGen()); + const client: A2ATransport = { + getAgentCard: async () => ({ capabilities: { streaming: true } }), + sendMessage, + sendMessageStream, + }; + + const stream = await chooseA2ASourceStream(client, PARAMS); + await collect(stream); + + expect(sendMessageStream).toHaveBeenCalledTimes(1); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + test('non-streaming agent: sendMessage fires once, result is replayed as single-event stream', async () => { + const result = { kind: 'task', id: 't2', contextId: 'c1', status: { state: 'completed' } }; + const sendMessage = vi.fn(async () => ({ result }) as unknown as SendMessageResponse); + const sendMessageStream = vi.fn(); + const client: A2ATransport = { + getAgentCard: async () => ({ capabilities: { streaming: false } }), + sendMessage, + sendMessageStream, + }; + + const stream = await chooseA2ASourceStream(client, PARAMS); + const events = await collect(stream); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessageStream).not.toHaveBeenCalled(); + expect(events).toEqual([result]); + }); + + test('non-streaming agent: sendMessage error surfaces instead of falling through to stream', async () => { + const sendMessage = vi.fn( + async () => + ({ + error: { code: -32_603, message: 'upstream down' }, + }) as unknown as SendMessageResponse + ); + const sendMessageStream = vi.fn(); + const client: A2ATransport = { + getAgentCard: async () => ({ capabilities: { streaming: false } }), + sendMessage, + sendMessageStream, + }; + + await expect(chooseA2ASourceStream(client, PARAMS)).rejects.toThrow(SEND_MESSAGE_FAILED_PATTERN); + expect(sendMessageStream).not.toHaveBeenCalled(); + }); + + test('undefined streaming capability is treated as non-streaming', async () => { + const result = { kind: 'message', messageId: 'm-out' }; + const sendMessage = vi.fn(async () => ({ result }) as unknown as SendMessageResponse); + const sendMessageStream = vi.fn(); + const client: A2ATransport = { + getAgentCard: async () => ({ capabilities: {} }), + sendMessage, + sendMessageStream, + }; + + await collect(await chooseA2ASourceStream(client, PARAMS)); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessageStream).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/pages/agents/details/a2a/a2a-stream-mapper.test.ts b/frontend/src/components/pages/agents/details/a2a/a2a-stream-mapper.test.ts new file mode 100644 index 0000000000..84de3577eb --- /dev/null +++ b/frontend/src/components/pages/agents/details/a2a/a2a-stream-mapper.test.ts @@ -0,0 +1,270 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { Message, Task, TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent } from '@a2a-js/sdk'; +import type { LanguageModelV2FinishReason } from '@ai-sdk/provider'; +import { UnsupportedFunctionalityError } from '@ai-sdk/provider'; +import { describe, expect, test } from 'vitest'; + +import { + a2aEventToV2StreamParts, + finalizeStream, + initialStreamMapperState, + isKnownA2AEvent, +} from './a2a-stream-mapper'; + +// --------------------------------------------------------------------------- +// These tests lock down the pure reducer that drives the A2A doStream +// TransformStream. The reducer is the seam we use to make the AI SDK v6 +// stream contract testable without standing up a full streaming pipeline — so +// every TaskState branch that `mapFinishReason` recognises gets a dedicated +// test here. +// --------------------------------------------------------------------------- + +const mkStatusUpdate = (state: TaskState, final = true): TaskStatusUpdateEvent => ({ + kind: 'status-update', + contextId: 'ctx-1', + taskId: 'task-1', + final, + status: { state }, +}); + +const mkTask = (id = 'task-1'): Task => ({ + kind: 'task', + id, + contextId: 'ctx-1', + status: { state: 'working' }, +}); + +const mkArtifactUpdate = (): TaskArtifactUpdateEvent => ({ + kind: 'artifact-update', + contextId: 'ctx-1', + taskId: 'task-1', + artifact: { + artifactId: 'a-1', + parts: [{ kind: 'text', text: 'artifact body' }], + }, +}); + +const mkMessage = (): Message => ({ + kind: 'message', + messageId: 'msg-1', + role: 'agent', + parts: [{ kind: 'text', text: 'hi' }], +}); + +describe('a2aEventToV2StreamParts — first chunk', () => { + test('emits response-metadata part on the first event', () => { + const { parts, state } = a2aEventToV2StreamParts(mkTask(), initialStreamMapperState(), {}); + + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ type: 'response-metadata', id: 'task-1' }); + expect(state.isFirstChunk).toBe(false); + }); + + test('does not re-emit response-metadata on subsequent events', () => { + const first = a2aEventToV2StreamParts(mkTask(), initialStreamMapperState(), {}); + const second = a2aEventToV2StreamParts(mkArtifactUpdate(), first.state, {}); + + expect(second.parts.some((p) => p.type === 'response-metadata')).toBe(false); + }); +}); + +describe('a2aEventToV2StreamParts — includeRawChunks', () => { + test('prepends a raw part when includeRawChunks is true', () => { + const event = mkTask(); + const { parts } = a2aEventToV2StreamParts(event, initialStreamMapperState(), { + includeRawChunks: true, + }); + + expect(parts[0]).toEqual({ type: 'raw', rawValue: event }); + // response-metadata still comes right after on the first chunk + expect(parts[1]).toMatchObject({ type: 'response-metadata' }); + }); + + test('omits the raw part when includeRawChunks is false', () => { + const { parts } = a2aEventToV2StreamParts(mkTask(), initialStreamMapperState(), { + includeRawChunks: false, + }); + + expect(parts.some((p) => p.type === 'raw')).toBe(false); + }); + + test('omits the raw part when includeRawChunks is omitted', () => { + const { parts } = a2aEventToV2StreamParts(mkTask(), initialStreamMapperState(), {}); + + expect(parts.some((p) => p.type === 'raw')).toBe(false); + }); +}); + +describe('a2aEventToV2StreamParts — status-update → finishReason', () => { + // After the first-chunk response-metadata path is out of the way, each + // terminal status-update should translate to the expected finish reason. + // We prime the state as `isFirstChunk: false` so the table focuses on the + // finish-reason mapping rather than the metadata emission. + const primedState = (): ReturnType => ({ + isFirstChunk: false, + finishReason: 'unknown', + }); + + const cases: [TaskState, LanguageModelV2FinishReason][] = [ + ['submitted', 'stop'], + ['working', 'unknown'], + ['input-required', 'stop'], + ['completed', 'stop'], + ['canceled', 'other'], + ['failed', 'error'], + ['rejected', 'error'], + ['auth-required', 'error'], + ]; + + test.each(cases)('terminal %s maps to finishReason %s', (taskState, expected) => { + const { state } = a2aEventToV2StreamParts(mkStatusUpdate(taskState), primedState(), {}); + expect(state.finishReason).toBe(expected); + }); + + test('non-final status-update does not change finishReason', () => { + const start = primedState(); + const { state } = a2aEventToV2StreamParts(mkStatusUpdate('completed', false), start, {}); + expect(state.finishReason).toBe('unknown'); + }); +}); + +describe('a2aEventToV2StreamParts — non-status events do not change finishReason', () => { + const primedState = (): ReturnType => ({ + isFirstChunk: false, + finishReason: 'unknown', + }); + + test('Task event (kind: task) leaves finishReason alone', () => { + const { state } = a2aEventToV2StreamParts(mkTask(), primedState(), {}); + expect(state.finishReason).toBe('unknown'); + }); + + test('TaskArtifactUpdateEvent leaves finishReason alone', () => { + const { state } = a2aEventToV2StreamParts(mkArtifactUpdate(), primedState(), {}); + expect(state.finishReason).toBe('unknown'); + }); + + test('Message event leaves finishReason alone', () => { + const { state } = a2aEventToV2StreamParts(mkMessage(), primedState(), {}); + expect(state.finishReason).toBe('unknown'); + }); +}); + +describe('finalizeStream', () => { + test('emits a finish part carrying the current finishReason', () => { + const part = finalizeStream({ isFirstChunk: false, finishReason: 'stop' }); + + expect(part).toEqual({ + type: 'finish', + finishReason: 'stop', + usage: { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + }, + }); + }); + + test('emits undefined usage counts regardless of state', () => { + const part = finalizeStream({ isFirstChunk: true, finishReason: 'unknown' }); + + // Usage is always undefined — the A2A protocol surfaces token counts + // through a separate metadata path, not through the v2 finish part. + if (part.type !== 'finish') { + throw new Error('expected finish part'); + } + expect(part.usage.inputTokens).toBeUndefined(); + expect(part.usage.outputTokens).toBeUndefined(); + expect(part.usage.totalTokens).toBeUndefined(); + }); +}); + +describe('a2aEventToV2StreamParts — initial state', () => { + test('initialStreamMapperState starts with isFirstChunk=true and finishReason=unknown', () => { + expect(initialStreamMapperState()).toEqual({ + isFirstChunk: true, + finishReason: 'unknown', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Defensive guards for backward compatibility with the previous AI agent +// backend. If a legacy or future server emits a stream event whose `kind` we +// don't understand, the adapter must fail loudly with an +// `UnsupportedFunctionalityError` rather than silently drop the event. +// Silent drops look like a stalled stream from the user's perspective. +// --------------------------------------------------------------------------- + +describe('isKnownA2AEvent', () => { + test.each([['task'], ['message'], ['status-update'], ['artifact-update']])('accepts known kind "%s"', (kind) => { + expect(isKnownA2AEvent({ kind })).toBe(true); + }); + + test.each([ + ['null', null], + ['undefined', undefined], + ['number', 42], + ['string', 'task'], + ['array', ['task']], + ['object missing kind', {}], + ['unknown kind string', { kind: 'mystery' }], + ['empty-string kind', { kind: '' }], + ['non-string kind', { kind: 42 }], + ])('rejects %s', (_label, value) => { + expect(isKnownA2AEvent(value)).toBe(false); + }); +}); + +describe('a2aEventToV2StreamParts — malformed input', () => { + test('throws UnsupportedFunctionalityError for an unknown event kind', () => { + const malformed = { kind: 'mystery-update', taskId: 'x' }; + expect(() => + a2aEventToV2StreamParts( + // Bypass the compile-time guard — this is exactly the shape a + // legacy/newer backend could emit at runtime. + malformed as unknown as Parameters[0], + initialStreamMapperState(), + {} + ) + ).toThrow(UnsupportedFunctionalityError); + }); + + test('throws UnsupportedFunctionalityError for a missing kind field', () => { + expect(() => + a2aEventToV2StreamParts( + { taskId: 'x' } as unknown as Parameters[0], + initialStreamMapperState(), + {} + ) + ).toThrow(UnsupportedFunctionalityError); + }); + + test('error identifies the unknown kind for easier debugging', () => { + const malformed = { kind: 'legacy-tool-event' }; + try { + a2aEventToV2StreamParts( + malformed as unknown as Parameters[0], + initialStreamMapperState(), + {} + ); + throw new Error('expected throw'); + } catch (e) { + expect(e).toBeInstanceOf(UnsupportedFunctionalityError); + // `functionality` carries the offending kind so on-call engineers can + // grep logs for the culprit event shape. + const ufe = e as UnsupportedFunctionalityError; + expect(ufe.functionality).toContain('legacy-tool-event'); + } + }); +}); diff --git a/frontend/src/components/pages/agents/details/a2a/a2a-stream-mapper.ts b/frontend/src/components/pages/agents/details/a2a/a2a-stream-mapper.ts new file mode 100644 index 0000000000..b0837841a1 --- /dev/null +++ b/frontend/src/components/pages/agents/details/a2a/a2a-stream-mapper.ts @@ -0,0 +1,146 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { Message, Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '@a2a-js/sdk'; +import type { LanguageModelV2FinishReason, LanguageModelV2StreamPart } from '@ai-sdk/provider'; +import { UnsupportedFunctionalityError } from '@ai-sdk/provider'; + +import { getResponseMetadata, mapFinishReason } from './a2a-chat-language-model'; + +/** + * Exhaustive list of A2A stream event kinds we understand. If the backend + * ever emits something outside this set we fail loudly rather than silently + * drop the event — silent drops during a streaming chat look like the model + * stalled and are very hard to diagnose in production. + */ +const KNOWN_A2A_EVENT_KINDS = new Set(['task', 'message', 'status-update', 'artifact-update']); + +/** + * Runtime shape-check guarding the adapter against legacy/unknown backend + * events. Exported for direct testing. + */ +export function isKnownA2AEvent(event: unknown): event is A2AStreamEventData { + if (!event || typeof event !== 'object') { + return false; + } + const kind = (event as { kind?: unknown }).kind; + return typeof kind === 'string' && KNOWN_A2A_EVENT_KINDS.has(kind); +} + +/** + * Pure-reducer state threaded through `a2aEventToV2StreamParts` on each event. + * + * The state captures cross-event bookkeeping that the AI SDK v6 stream protocol + * requires us to track: + * - `isFirstChunk` — the spec requires exactly one `response-metadata` part, + * and it must be emitted from the first event we see. + * - `finishReason` — terminal `status-update` events mutate this; `flush` + * reads it back into the trailing `finish` part. + */ +export type StreamMapperState = { + isFirstChunk: boolean; + finishReason: LanguageModelV2FinishReason; +}; + +export type A2AStreamEventData = Task | Message | TaskStatusUpdateEvent | TaskArtifactUpdateEvent; + +/** + * Build the initial state for `a2aEventToV2StreamParts`. + * + * `finishReason` starts as `'unknown'` — it is only changed when a terminal + * `status-update` event arrives. If the stream ends without a terminal event + * (network drop, cancel, etc.) we want the emitted `finish` part to carry + * `'unknown'` rather than pretending the run completed. + */ +export const initialStreamMapperState = (): StreamMapperState => ({ + isFirstChunk: true, + finishReason: 'unknown', +}); + +/** + * Pure per-event translator from A2A SDK stream events to AI SDK v6 stream + * parts. + * + * Contract: + * - Returns the parts to enqueue for this event plus the next state. + * - Never side-effects; the caller owns the controller. + * - Mirrors `doStream`'s original inline behavior exactly — the adapter + * call-site is the *only* place this should be wired up. + * + * Ordering note: if `includeRawChunks` is set we emit the `raw` part *before* + * any other parts for that event. On the first chunk the order is therefore + * `raw`, `response-metadata`. The AI SDK expects `response-metadata` to arrive + * before any content parts but allows `raw` parts to be interleaved freely, so + * putting `raw` first preserves the original ordering while keeping callers + * free to dedupe on `response-metadata`. + */ +export function a2aEventToV2StreamParts( + event: A2AStreamEventData, + state: StreamMapperState, + options: { includeRawChunks?: boolean } +): { parts: LanguageModelV2StreamPart[]; state: StreamMapperState } { + // Defensive: if a legacy backend emits an event the adapter doesn't know + // about, fail loudly via `UnsupportedFunctionalityError` rather than + // silently dropping it. A silent drop manifests as a stalled chat with no + // error surfaced to the user — extremely hard to diagnose in production. + // The AI SDK surfaces this error through the stream so `useChat` can + // render it as a message error. + if (!isKnownA2AEvent(event)) { + throw new UnsupportedFunctionalityError({ + functionality: `a2a stream event kind "${String((event as { kind?: unknown })?.kind)}"`, + message: + 'Unknown A2A stream event kind. The backend returned an event the adapter does not recognise; this usually indicates a newer protocol version on the backend than the frontend supports.', + }); + } + + const parts: LanguageModelV2StreamPart[] = []; + let nextState = state; + + // Emit raw chunk first so callers can observe the untouched SDK event. + if (options.includeRawChunks) { + parts.push({ type: 'raw', rawValue: event }); + } + + if (nextState.isFirstChunk) { + nextState = { ...nextState, isFirstChunk: false }; + parts.push({ + type: 'response-metadata', + ...getResponseMetadata(event), + }); + } + + // Only terminal status-update events change the finish reason. Task, + // artifact-update, and message events leave it alone. + if (event.kind === 'status-update' && event.final) { + nextState = { ...nextState, finishReason: mapFinishReason(event) }; + } + + return { parts, state: nextState }; +} + +/** + * Build the trailing `finish` stream part from the final mapper state. + * + * Usage values are always `undefined` because the A2A protocol does not + * surface token counts on the stream close event. Token accounting flows + * through a separate out-of-band metadata path. + */ +export function finalizeStream(state: StreamMapperState): LanguageModelV2StreamPart { + return { + type: 'finish', + finishReason: state.finishReason, + usage: { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + }, + }; +} diff --git a/frontend/src/components/pages/agents/details/a2a/chat/components/message-blocks/a2a-error-block.tsx b/frontend/src/components/pages/agents/details/a2a/chat/components/message-blocks/a2a-error-block.tsx index 3466b32d41..1afff4a58f 100644 --- a/frontend/src/components/pages/agents/details/a2a/chat/components/message-blocks/a2a-error-block.tsx +++ b/frontend/src/components/pages/agents/details/a2a/chat/components/message-blocks/a2a-error-block.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2025 Redpanda Data, Inc. + * Copyright 2026 Redpanda Data, Inc. * * Use of this software is governed by the Business Source License * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md @@ -14,42 +14,16 @@ import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/comp import { Text } from 'components/redpanda-ui/components/typography'; import { AlertCircleIcon } from 'lucide-react'; +import { lookupErrorMeta, type ParsedError } from '../../utils/parse-a2a-error'; + type A2AErrorBlockProps = { - error: JSONRPCError; + // Accept both the raw JSON-RPC error and the parser's enriched variant so + // legacy call-sites keep working. If a bare `JSONRPCError` comes in we + // derive the title/hint inline via the shared `lookupErrorMeta` table. + error: JSONRPCError | ParsedError; timestamp: Date; }; -/** - * Map JSON-RPC error codes to human-readable names - */ -/** - * Map JSON-RPC/A2A error codes to human-readable names - * Based on a2a-go/a2a/errors.go codeToError mapping - */ -const getErrorCodeName = (code: number): string => { - const errorCodes: Record = { - // Standard JSON-RPC 2.0 errors - [-32_700]: 'Parse Error', - [-32_600]: 'Invalid Request', - [-32_601]: 'Method Not Found', - [-32_602]: 'Invalid Params', - [-32_603]: 'Internal Error', - [-32_000]: 'Server Error', - // A2A-specific errors - [-32_001]: 'Task Not Found', - [-32_002]: 'Task Not Cancelable', - [-32_003]: 'Push Notifications Not Supported', - [-32_004]: 'Unsupported Operation', - [-32_005]: 'Content Type Not Supported', - [-32_006]: 'Invalid Agent Response', - [-32_007]: 'Authenticated Extended Card Not Configured', - [-32_008]: 'Authentication Failed', - [-32_009]: 'Forbidden', - }; - - return errorCodes[code] || `Error ${code}`; -}; - /** * A2A Error Block - displays JSON-RPC errors with full details */ @@ -61,31 +35,45 @@ export const A2AErrorBlock = ({ error, timestamp }: A2AErrorBlockProps) => { fractionalSecondDigits: 3, }); - const errorCodeName = getErrorCodeName(error.code); - const hasData = error.data && Object.keys(error.data).length > 0; + // Prefer the title/hint that the parser already resolved; fall back to the + // shared `lookupErrorMeta` so legacy callers that pass a raw + // `JSONRPCError` still render the same human-readable headline. + const parsed: ParsedError = + 'title' in error + ? (error as ParsedError) + : { ...(error as JSONRPCError), ...lookupErrorMeta((error as JSONRPCError).code) }; + const hasData = parsed.data && Object.keys(parsed.data).length > 0; return ( } variant="destructive"> - {errorCodeName} + {parsed.title}
- {error.message} + {parsed.message} + {parsed.hint ? ( + + {parsed.hint} + + ) : null} +
code: - {error.code} + {parsed.code}
message: - {error.message} + {parsed.message}
{Boolean(hasData) && (
data: -
{JSON.stringify(error.data, null, 2)}
+
+                  {JSON.stringify(parsed.data, null, 2)}
+                
)}
diff --git a/frontend/src/components/pages/agents/details/a2a/chat/hooks/event-handlers.ts b/frontend/src/components/pages/agents/details/a2a/chat/hooks/event-handlers.ts index 97199b16c7..ca65343199 100644 --- a/frontend/src/components/pages/agents/details/a2a/chat/hooks/event-handlers.ts +++ b/frontend/src/components/pages/agents/details/a2a/chat/hooks/event-handlers.ts @@ -420,20 +420,3 @@ export const handleArtifactUpdateEvent = ( }); onMessageUpdate(updatedMessage); }; - -/** - * Handle text-delta event to accumulate streaming text - * NOTE: Text-delta is now only for artifacts (protocol compliant) - * Regular messages come via status-update events with message.parts - */ -export const handleTextDeltaEvent = ( - _textDelta: string, - _state: StreamingState, - _assistantMessage: ChatMessage, - _onMessageUpdate: (message: ChatMessage) => void -): void => { - // Text-delta events are deprecated for regular messages - // They are only used for artifact streaming now (handled separately) - // If we receive text-delta, it's likely duplicate artifact content - // Skip processing to avoid duplicate text blocks -}; diff --git a/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-context-usage.test.tsx b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-context-usage.test.tsx new file mode 100644 index 0000000000..fad83eb0e4 --- /dev/null +++ b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-context-usage.test.tsx @@ -0,0 +1,92 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { renderHook } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { useContextUsage } from './use-context-usage'; +import type { UsageMetadata } from '../types'; + +const makeUsage = (overrides: Partial = {}): UsageMetadata => ({ + cumulativeInputTokens: 0, + cumulativeOutputTokens: 0, + cumulativeReasoningTokens: 0, + cumulativeCachedTokens: 0, + ...overrides, +}); + +describe('useContextUsage', () => { + test('maps cumulative counts onto legacy top-level fields', () => { + const { result } = renderHook(() => + useContextUsage( + makeUsage({ + cumulativeInputTokens: 100, + cumulativeOutputTokens: 50, + cumulativeReasoningTokens: 25, + cumulativeCachedTokens: 10, + }) + ) + ); + + expect(result.current.inputTokens).toBe(100); + expect(result.current.outputTokens).toBe(50); + expect(result.current.reasoningTokens).toBe(25); + expect(result.current.cachedInputTokens).toBe(10); + expect(result.current.totalTokens).toBe(150); + }); + + test('populates ai v6 inputTokenDetails / outputTokenDetails sub-objects', () => { + const { result } = renderHook(() => + useContextUsage( + makeUsage({ + cumulativeInputTokens: 80, + cumulativeOutputTokens: 20, + cumulativeReasoningTokens: 7, + cumulativeCachedTokens: 3, + }) + ) + ); + + // AI SDK v6 moved detail breakdowns into nested objects; we populate them + // so callers reading from the new shape see consistent values. + expect(result.current.inputTokenDetails).toEqual({ + noCacheTokens: undefined, + cacheReadTokens: 3, + cacheWriteTokens: undefined, + }); + expect(result.current.outputTokenDetails).toEqual({ + textTokens: undefined, + reasoningTokens: 7, + }); + }); + + test('returns a stable object across renders when inputs do not change', () => { + const usage = makeUsage({ cumulativeInputTokens: 5 }); + const { result, rerender } = renderHook((u: UsageMetadata) => useContextUsage(u), { + initialProps: usage, + }); + const firstResult = result.current; + rerender(usage); + expect(result.current).toBe(firstResult); + }); + + test('recomputes when any cumulative token count changes', () => { + let usage = makeUsage({ cumulativeInputTokens: 5 }); + const { result, rerender } = renderHook((u: UsageMetadata) => useContextUsage(u), { + initialProps: usage, + }); + const firstResult = result.current; + usage = makeUsage({ cumulativeInputTokens: 7 }); + rerender(usage); + expect(result.current).not.toBe(firstResult); + expect(result.current.inputTokens).toBe(7); + }); +}); diff --git a/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-context-usage.ts b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-context-usage.ts index 99da0551d5..93f26b8c4a 100644 --- a/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-context-usage.ts +++ b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-context-usage.ts @@ -9,22 +9,39 @@ * by the Apache License, Version 2.0 */ +import type { LanguageModelUsage } from 'ai'; import { useMemo } from 'react'; import type { UsageMetadata } from '../types'; /** * Hook to transform cumulative usage metadata into context usage format - * for display in the Context component + * for display in the Context component. + * + * Note: ai v6 expanded `LanguageModelUsage` with `inputTokenDetails` and + * `outputTokenDetails` sub-objects. The legacy top-level `reasoningTokens` + * and `cachedInputTokens` fields remain (deprecated) for backwards compat and + * are what the shadcn/ai-elements Context component reads today, but we also + * populate the new sub-objects so callers that upgrade later get consistent + * values. */ -export function useContextUsage(usage: UsageMetadata) { +export function useContextUsage(usage: UsageMetadata): LanguageModelUsage { return useMemo( () => ({ inputTokens: usage.cumulativeInputTokens, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: usage.cumulativeCachedTokens, + cacheWriteTokens: undefined, + }, outputTokens: usage.cumulativeOutputTokens, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: usage.cumulativeReasoningTokens, + }, + totalTokens: usage.cumulativeInputTokens + usage.cumulativeOutputTokens, reasoningTokens: usage.cumulativeReasoningTokens, cachedInputTokens: usage.cumulativeCachedTokens, - totalTokens: usage.cumulativeInputTokens + usage.cumulativeOutputTokens, }), [ usage.cumulativeInputTokens, diff --git a/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.test.ts b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.test.ts index 10b37660ed..86fd50af03 100644 --- a/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.test.ts +++ b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.test.ts @@ -12,9 +12,10 @@ import type { TaskState, TaskStatusUpdateEvent } from '@a2a-js/sdk'; import { afterEach, beforeEach, describe, expect, vi } from 'vitest'; -import { parseA2AError, streamMessage } from './use-message-streaming'; +import { streamMessage } from './use-message-streaming'; import type { ContentBlock } from '../types'; import { updateMessage } from '../utils/database-operations'; +import { parseA2AError } from '../utils/parse-a2a-error'; // --------------------------------------------------------------------------- // Module mocks @@ -878,8 +879,9 @@ describe('streamMessage - SSE reconnection via tasks/resubscribe', () => { // `finalizeMessage failed after recovery: ...` via console.error at the // catch site. Silence that expected negative-path log for this test only; // the vi.restoreAllMocks() in the describe's afterEach restores it. - // biome-ignore lint/suspicious/noConsole: scoped suppression of expected negative-path log - vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => { + // Swallow expected negative-path log to keep test output clean. + }); const TASK_ID = 'task-finalize-fail'; const onMessageUpdate = vi.fn(); diff --git a/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.ts b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.ts index 6272a37710..665d351753 100644 --- a/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.ts +++ b/frontend/src/components/pages/agents/details/a2a/chat/hooks/use-message-streaming.ts @@ -9,7 +9,7 @@ * by the Apache License, Version 2.0 */ -import type { JSONRPCError, Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '@a2a-js/sdk'; +import type { Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '@a2a-js/sdk'; import { streamText } from 'ai'; import { config } from 'config'; @@ -18,84 +18,17 @@ import { handleResponseMetadataEvent, handleStatusUpdateEvent, handleTaskEvent, - handleTextDeltaEvent, } from './event-handlers'; import { buildMessageWithContentBlocks, closeActiveTextBlock } from './message-builder'; -import type { ResponseMetadataEvent, StreamChunk, StreamingState, TextDeltaEvent } from './streaming-types'; +import type { ResponseMetadataEvent, StreamChunk, StreamingState } from './streaming-types'; import { a2a } from '../../a2a-provider'; import type { ChatMessage, ContentBlock } from '../types'; import { createA2AClient } from '../utils/a2a-client'; import { saveMessage, updateMessage } from '../utils/database-operations'; import { createAssistantMessage } from '../utils/message-converter'; +import { parseA2AError } from '../utils/parse-a2a-error'; import { resolveStaleToolBlocks } from '../utils/task-to-content-blocks'; -/** - * Regex patterns for parsing JSON-RPC error details from error messages. - * - * Why regex? The a2a-js SDK throws plain Error objects with formatted strings - * instead of structured error objects. The SDK has access to the structured - * JSON-RPC error (code, message, data) but serializes it into the error message: - * - * // a2a-js/src/client/transports/json_rpc_transport.ts - * if ('error' in a2aStreamResponse) { - * const err = a2aStreamResponse.error; - * throw new Error( - * `SSE event contained an error: ${err.message} (Code: ${err.code}) Data: ${JSON.stringify(err.data || {})}` - * ); - * } - * - * Until the SDK exposes structured error data, we parse it back out. - */ -const JSON_RPC_CODE_REGEX = /\(Code:\s*(-?\d+)\)/i; -const JSON_RPC_DATA_REGEX = /Data:\s*(\{[^}]*\})/i; -const JSON_RPC_MESSAGE_REGEX = /error:\s*([^(]+)\s*\(Code:/i; -const ERROR_PREFIX_STREAMING_REGEX = /^Error during streaming[^:]*:\s*/i; -const ERROR_PREFIX_SSE_REGEX = /^SSE event contained an error:\s*/i; -const ERROR_SUFFIX_CODE_REGEX = /\s*\(Code:\s*-?\d+\).*$/i; - -/** - * Parse A2A/JSON-RPC error details from an error message string. - */ -export const parseA2AError = (error: unknown): JSONRPCError => { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Try to parse JSON-RPC error from the error message - // Format: "SSE event contained an error: (Code: ) Data: (code: )" - const jsonRpcMatch = errorMessage.match(JSON_RPC_CODE_REGEX); - const dataMatch = errorMessage.match(JSON_RPC_DATA_REGEX); - const messageMatch = errorMessage.match(JSON_RPC_MESSAGE_REGEX); - - // Extract just the core error message without wrapper text - let message = errorMessage; - if (messageMatch?.[1]) { - message = messageMatch[1].trim(); - } else { - // Remove common prefixes - message = message - .replace(ERROR_PREFIX_STREAMING_REGEX, '') - .replace(ERROR_PREFIX_SSE_REGEX, '') - .replace(ERROR_SUFFIX_CODE_REGEX, '') - .trim(); - } - - const code = jsonRpcMatch?.[1] ? Number.parseInt(jsonRpcMatch[1], 10) : -1; - - let data: Record | undefined; - if (dataMatch?.[1]) { - try { - data = JSON.parse(dataMatch[1]); - } catch { - // Invalid JSON in data field - } - } - - return { - code, - message: message || 'Unknown error', - data, - }; -}; - type StreamMessageParams = { prompt: string; agentId: string; @@ -393,14 +326,10 @@ export const streamMessage = async ({ handleArtifactUpdateEvent(event as TaskArtifactUpdateEvent, state, assistantMessage, onMessageUpdate); } } - continue; - } - - // Handle text-delta events - if (streamChunk.type === 'text-delta') { - const textDelta = streamChunk as TextDeltaEvent; - handleTextDeltaEvent(textDelta.text, state, assistantMessage, onMessageUpdate); } + // text-delta chunks are emitted as raw events routed above; no separate + // handling is needed because the A2A protocol carries text through + // status-update.message.parts and artifact-update. } // Close any active text block before finalizing diff --git a/frontend/src/components/pages/agents/details/a2a/chat/utils/parse-a2a-error.test.ts b/frontend/src/components/pages/agents/details/a2a/chat/utils/parse-a2a-error.test.ts new file mode 100644 index 0000000000..8a5cc7416b --- /dev/null +++ b/frontend/src/components/pages/agents/details/a2a/chat/utils/parse-a2a-error.test.ts @@ -0,0 +1,226 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, test } from 'vitest'; + +import { lookupErrorMeta, parseA2AError } from './parse-a2a-error'; + +// Top-level regex literal — biome's `useTopLevelRegex` rule flags inline +// regexes inside test bodies because they are recompiled on every call. +const METHOD_NOT_FOUND_HINT_PATTERN = /A2A|MCP|capabilities/i; + +// --------------------------------------------------------------------------- +// Table-driven coverage of the regex-based A2A error parser. The parser has +// to tolerate a grab-bag of formats produced by the a2a-js SDK's +// stringified JSON-RPC errors, plus bare Error/string/unknown inputs. Each +// row below pins down one of those shapes; the existing hook-level suite in +// use-message-streaming.test.ts exercises the parser through the full +// streamMessage error path. +// --------------------------------------------------------------------------- + +type Row = { + name: string; + input: unknown; + expected: { + code: number; + message: string; + data?: Record; + }; +}; + +const rows: Row[] = [ + { + name: 'SSE event-wrapped error with structured code + data', + input: new Error('SSE event contained an error: Connection reset (Code: -1) Data: {}'), + expected: { code: -1, message: 'Connection reset', data: {} }, + }, + { + name: 'JSON-RPC streaming error with data payload', + input: new Error('Error during streaming for task-abc: network timeout (Code: 500) Data: {"detail":"timeout"}'), + expected: { code: 500, message: 'network timeout', data: { detail: 'timeout' } }, + }, + { + // Regression guard: the old `[^}]*` stopped at the first `}` so nested + // data got truncated and JSON.parse threw silently. + name: 'Data payload containing a nested object is captured in full', + input: new Error( + 'SSE event contained an error: validation failed (Code: -32602) Data: {"field":{"reason":"expired","after":1700000000}}' + ), + expected: { + code: -32_602, + message: 'validation failed', + data: { field: { reason: 'expired', after: 1_700_000_000 } }, + }, + }, + { + name: 'error without Code: falls back to -1 and preserves raw message', + input: 'something completely unexpected', + expected: { code: -1, message: 'something completely unexpected' }, + }, + { + name: 'invalid JSON in Data: leaves data undefined (preserved legacy behavior)', + input: new Error('SSE event contained an error: Bad (Code: -1) Data: {not-json}'), + // data is undefined because JSON.parse throws and the catch swallows. + expected: { code: -1, message: 'Bad', data: undefined }, + }, + { + name: 'empty string → Unknown error sentinel', + input: '', + expected: { code: -1, message: 'Unknown error' }, + }, + { + name: 'numeric non-Error input → best-effort stringification', + input: 42, + expected: { code: -1, message: '42' }, + }, + { + name: 'object non-Error input → best-effort stringification', + // Confirms parseA2AError accepts any `unknown` via String(...) without + // blowing up. The default Object.prototype.toString is what we get. + input: { foo: 'bar' }, + expected: { code: -1, message: '[object Object]' }, + }, + { + name: 'streaming prefix stripped when there is no Code:', + input: new Error('Error during streaming for task-xyz: connection refused'), + expected: { code: -1, message: 'connection refused' }, + }, + { + name: 'SSE prefix stripped when there is no Code:', + input: new Error('SSE event contained an error: Connection refused'), + expected: { code: -1, message: 'Connection refused' }, + }, +]; + +describe('parseA2AError (table-driven)', () => { + test.each(rows)('$name', ({ input, expected }) => { + const result = parseA2AError(input); + + expect(result.code).toBe(expected.code); + expect(result.message).toBe(expected.message); + + if ('data' in expected) { + expect(result.data).toEqual(expected.data); + } + }); +}); + +describe('parseA2AError — edge cases not easily expressed as a table row', () => { + test('positive Code values are parsed as-is', () => { + const result = parseA2AError(new Error('SSE event contained an error: boom (Code: 32000) Data: {}')); + expect(result.code).toBe(32_000); + }); + + test('negative JSON-RPC codes survive the regex', () => { + const result = parseA2AError( + new Error('SSE event contained an error: auth required (Code: -32001) Data: {"scope":"read"}') + ); + expect(result.code).toBe(-32_001); + expect(result.data).toEqual({ scope: 'read' }); + }); + + test('Error instance without any structure passes through unchanged', () => { + const result = parseA2AError(new Error('boom')); + expect(result.code).toBe(-1); + expect(result.message).toBe('boom'); + expect(result.data).toBeUndefined(); + // Sentinel -1 code resolves to the generic "Error" title with no hint. + expect(result.title).toBe('Error'); + expect(result.hint).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Well-known JSON-RPC / A2A / MCP code → (title, hint) lookup coverage. +// One row per code we claim to understand. If a new code is added to the +// lookup table, add a row here too — otherwise `title` defaults to the +// synthetic `Error ` fallback. +// --------------------------------------------------------------------------- + +type CodeRow = { + code: number; + title: string; + // Hint is optional — we only assert it exists and is non-empty because the + // exact wording is UI-tunable. Use `false` to assert hint is absent. + expectHint?: string | false; +}; + +const codeRows: CodeRow[] = [ + // JSON-RPC 2.0 standard errors — also reused by MCP. + { code: -32_700, title: 'Parse Error', expectHint: 'JSON' }, + { code: -32_600, title: 'Invalid Request', expectHint: 'JSON-RPC' }, + { code: -32_601, title: 'Method Not Found', expectHint: 'capabilities' }, + { code: -32_602, title: 'Invalid Params', expectHint: 'data' }, + { code: -32_603, title: 'Internal Error', expectHint: 'Retry' }, + { code: -32_000, title: 'Server Error', expectHint: 'Retry' }, + // A2A protocol extensions. + { code: -32_001, title: 'Task Not Found', expectHint: 'task' }, + { code: -32_002, title: 'Task Not Cancelable' }, + { code: -32_003, title: 'Push Notifications Not Supported' }, + { code: -32_004, title: 'Unsupported Operation', expectHint: 'A2A' }, + { code: -32_005, title: 'Content Type Not Supported' }, + { code: -32_006, title: 'Invalid Agent Response' }, + { code: -32_007, title: 'Authenticated Extended Card Not Configured' }, + { code: -32_008, title: 'Authentication Failed', expectHint: 'Re-authenticate' }, + { code: -32_009, title: 'Forbidden', expectHint: 'agent owner' }, +]; + +describe('lookupErrorMeta — well-known code coverage', () => { + test.each(codeRows)('code $code → "$title"', ({ code, title, expectHint }) => { + const meta = lookupErrorMeta(code); + expect(meta.title).toBe(title); + if (expectHint === false) { + expect(meta.hint).toBeUndefined(); + } else if (expectHint) { + expect(meta.hint ?? '').toContain(expectHint); + } else { + // Any non-false / non-string expectHint means "hint should exist". + expect(meta.hint).toBeDefined(); + } + }); + + test('unknown implementation-defined server code (-32050) is surfaced with the code inline', () => { + const meta = lookupErrorMeta(-32_050); + expect(meta.title).toBe('Server Error -32050'); + expect(meta.hint).toBeDefined(); + }); + + test('generic fallback for completely foreign code', () => { + const meta = lookupErrorMeta(42); + expect(meta.title).toBe('Error 42'); + expect(meta.hint).toBeUndefined(); + }); + + test('sentinel -1 (parser could not extract a code) falls back to "Error"', () => { + const meta = lookupErrorMeta(-1); + expect(meta.title).toBe('Error'); + expect(meta.hint).toBeUndefined(); + }); +}); + +describe('parseA2AError — title + hint are surfaced end-to-end', () => { + test('method-not-found (-32601) surfaces the hint in the parsed result', () => { + const result = parseA2AError(new Error('SSE event contained an error: method unknown (Code: -32601) Data: {}')); + expect(result.code).toBe(-32_601); + expect(result.title).toBe('Method Not Found'); + expect(result.hint).toBeDefined(); + expect(result.hint ?? '').toMatch(METHOD_NOT_FOUND_HINT_PATTERN); + }); + + test('MCP-style Internal Error (-32603) surfaces a retry hint', () => { + const result = parseA2AError( + new Error('SSE event contained an error: tool execution failed (Code: -32603) Data: {"tool":"delete_topic"}') + ); + expect(result.code).toBe(-32_603); + expect(result.title).toBe('Internal Error'); + expect(result.hint).toBeDefined(); + }); +}); diff --git a/frontend/src/components/pages/agents/details/a2a/chat/utils/parse-a2a-error.ts b/frontend/src/components/pages/agents/details/a2a/chat/utils/parse-a2a-error.ts new file mode 100644 index 0000000000..c9165ae0f3 --- /dev/null +++ b/frontend/src/components/pages/agents/details/a2a/chat/utils/parse-a2a-error.ts @@ -0,0 +1,211 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { JSONRPCError } from '@a2a-js/sdk'; + +/** + * Regex patterns for parsing JSON-RPC error details from error messages. + * + * Why regex? The a2a-js SDK throws plain Error objects with formatted strings + * instead of structured error objects. The SDK has access to the structured + * JSON-RPC error (code, message, data) but serializes it into the error message: + * + * // a2a-js/src/client/transports/json_rpc_transport.ts + * if ('error' in a2aStreamResponse) { + * const err = a2aStreamResponse.error; + * throw new Error( + * `SSE event contained an error: ${err.message} (Code: ${err.code}) Data: ${JSON.stringify(err.data || {})}` + * ); + * } + * + * Until the SDK exposes structured error data, we parse it back out. + */ +export const JSON_RPC_CODE_REGEX = /\(Code:\s*(-?\d+)\)/i; +// Matches greedily to end-of-string so nested objects inside `Data:` are +// captured in full — the SDK always serializes `Data: ${JSON.stringify(...)}` +// at the tail of the error message, so the final `}` is reliable. +export const JSON_RPC_DATA_REGEX = /Data:\s*(\{.*\})\s*$/is; +export const JSON_RPC_MESSAGE_REGEX = /error:\s*([^(]+)\s*\(Code:/i; +export const ERROR_PREFIX_STREAMING_REGEX = /^Error during streaming[^:]*:\s*/i; +export const ERROR_PREFIX_SSE_REGEX = /^SSE event contained an error:\s*/i; +export const ERROR_SUFFIX_CODE_REGEX = /\s*\(Code:\s*-?\d+\).*$/i; + +/** + * Human-readable metadata for a parsed A2A / MCP / JSON-RPC error. + * + * - `title` — short noun phrase safe to surface in a headline / alert title. + * - `hint` — one-sentence remediation tip. Points the user at what to try + * next (check credentials, retry, update the agent, etc). May be undefined + * when we don't have a sharper suggestion than the raw server message. + */ +export type ParsedError = JSONRPCError & { + title: string; + hint?: string; +}; + +type CodeMeta = { title: string; hint?: string }; + +/** + * Well-known JSON-RPC 2.0, A2A-protocol and MCP error codes mapped to a + * human-readable title + actionable hint. + * + * Sources: + * - JSON-RPC 2.0 standard errors: https://www.jsonrpc.org/specification#error_object + * - A2A protocol extensions: a2a-go/a2a/errors.go + * - MCP errors: @modelcontextprotocol/sdk/types.js (ErrorCode enum) + * + * The MCP SDK reuses the JSON-RPC 2.0 standard codes plus `-32002` (Resource + * Not Found) for its resource URI lookups. MCP tool-execution failures are + * typically surfaced as `InternalError` (`-32603`) with structured `data`. + */ +const ERROR_CODE_TABLE: Record = { + // ---- JSON-RPC 2.0 standard errors ---- + [-32_700]: { + title: 'Parse Error', + hint: 'The agent sent a payload that is not valid JSON. Retry — if it persists the backend is likely unreachable or misconfigured.', + }, + [-32_600]: { + title: 'Invalid Request', + hint: 'The request does not conform to JSON-RPC 2.0. This is usually a client/SDK version mismatch — check that the frontend and backend are on compatible A2A versions.', + }, + [-32_601]: { + title: 'Method Not Found', + hint: "The agent does not expose this method. Confirm the agent's capabilities advertise the A2A/MCP operation you tried to invoke.", + }, + [-32_602]: { + title: 'Invalid Params', + hint: 'The request arguments are rejected by the agent. Inspect the `data` field for a field-level reason.', + }, + [-32_603]: { + title: 'Internal Error', + hint: "The agent failed while handling the request. Retry — if it persists, check the agent's server logs.", + }, + [-32_000]: { + title: 'Server Error', + hint: 'The agent returned an implementation-defined server error. Retry; if it persists, the agent owner should be notified.', + }, + // ---- A2A protocol extensions ---- + [-32_001]: { + title: 'Task Not Found', + hint: "The task id has expired or was never created. Start a new conversation to re-seed the agent's task state.", + }, + [-32_002]: { + title: 'Task Not Cancelable', + hint: 'This task has already completed or is in a terminal state — cancel does not apply.', + }, + [-32_003]: { + title: 'Push Notifications Not Supported', + hint: "The agent's capabilities do not advertise push notifications. Check the agent card.", + }, + [-32_004]: { + title: 'Unsupported Operation', + hint: 'The agent does not support the A2A/MCP operation you invoked. Check the agent supports A2A and MCP.', + }, + [-32_005]: { + title: 'Content Type Not Supported', + hint: 'Switch the input content type (for example, drop binary parts) and retry.', + }, + [-32_006]: { + title: 'Invalid Agent Response', + hint: 'The agent returned a response that does not conform to A2A. This is typically an agent-side bug.', + }, + [-32_007]: { + title: 'Authenticated Extended Card Not Configured', + hint: 'The agent is configured without an authenticated extended card. Contact the agent owner to enable it.', + }, + [-32_008]: { + title: 'Authentication Failed', + hint: 'Your credentials were rejected. Re-authenticate and try again.', + }, + [-32_009]: { + title: 'Forbidden', + hint: 'You are authenticated but not authorised for this agent. Contact the agent owner to grant access.', + }, +}; + +/** + * Look up a known title + hint for the given JSON-RPC / A2A / MCP error code. + * Returns a synthetic title of the form "Error " for codes in the + * JSON-RPC implementation-defined server range (-32099 to -32000) without a + * specific mapping. For anything else, returns the fallback title "Error". + */ +export function lookupErrorMeta(code: number): CodeMeta { + const known = ERROR_CODE_TABLE[code]; + if (known) { + return known; + } + // JSON-RPC reserves -32099..-32000 for server-defined errors. Surface the + // code so operators can correlate with server logs. + if (code >= -32_099 && code <= -32_000) { + return { + title: `Server Error ${code}`, + hint: 'The agent returned an implementation-defined server error. Retry; if it persists, the agent owner should be notified.', + }; + } + if (code === -1) { + // Sentinel we use when the regex could not parse a code out of the + // stringified SDK error. + return { title: 'Error' }; + } + return { title: `Error ${code}` }; +} + +/** + * Parse A2A/JSON-RPC/MCP error details from an error message string. + * + * Returns a `ParsedError` with: + * - `code`, `message`, `data` — raw JSON-RPC fields (or sentinels when absent) + * - `title` — human-readable noun phrase for headline display + * - `hint` — optional remediation tip the UI can render below the message + */ +export const parseA2AError = (error: unknown): ParsedError => { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Try to parse JSON-RPC error from the error message + // Format: "SSE event contained an error: (Code: ) Data: (code: )" + const jsonRpcMatch = errorMessage.match(JSON_RPC_CODE_REGEX); + const dataMatch = errorMessage.match(JSON_RPC_DATA_REGEX); + const messageMatch = errorMessage.match(JSON_RPC_MESSAGE_REGEX); + + // Extract just the core error message without wrapper text + let message = errorMessage; + if (messageMatch?.[1]) { + message = messageMatch[1].trim(); + } else { + // Remove common prefixes + message = message + .replace(ERROR_PREFIX_STREAMING_REGEX, '') + .replace(ERROR_PREFIX_SSE_REGEX, '') + .replace(ERROR_SUFFIX_CODE_REGEX, '') + .trim(); + } + + const code = jsonRpcMatch?.[1] ? Number.parseInt(jsonRpcMatch[1], 10) : -1; + + let data: Record | undefined; + if (dataMatch?.[1]) { + try { + data = JSON.parse(dataMatch[1]); + } catch { + // Invalid JSON in data field + } + } + + const { title, hint } = lookupErrorMeta(code); + + return { + code, + message: message || 'Unknown error', + data, + title, + hint, + }; +}; diff --git a/frontend/src/components/pages/mcp-servers/details/__screenshots__/remote-mcp-inspector-tab.browser.test.tsx/mcp-streaming-inspector-chromium-darwin.png b/frontend/src/components/pages/mcp-servers/details/__screenshots__/remote-mcp-inspector-tab.browser.test.tsx/mcp-streaming-inspector-chromium-darwin.png deleted file mode 100644 index c041c9a273..0000000000 Binary files a/frontend/src/components/pages/mcp-servers/details/__screenshots__/remote-mcp-inspector-tab.browser.test.tsx/mcp-streaming-inspector-chromium-darwin.png and /dev/null differ diff --git a/frontend/src/components/pages/mcp-servers/details/remote-mcp-inspector-tab.test.tsx b/frontend/src/components/pages/mcp-servers/details/remote-mcp-inspector-tab.test.tsx index 059992a32c..d041e1aeea 100644 --- a/frontend/src/components/pages/mcp-servers/details/remote-mcp-inspector-tab.test.tsx +++ b/frontend/src/components/pages/mcp-servers/details/remote-mcp-inspector-tab.test.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2025 Redpanda Data, Inc. + * Copyright 2026 Redpanda Data, Inc. * * Use of this software is governed by the Business Source License * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md @@ -21,6 +21,7 @@ import { import { getMCPServer, listMCPServers } from 'protogen/redpanda/api/dataplane/v1/mcp-MCPServerService_connectquery'; import { ListTopicsResponseSchema } from 'protogen/redpanda/api/dataplane/v1/topic_pb'; import { listTopics } from 'protogen/redpanda/api/dataplane/v1/topic-TopicService_connectquery'; +import { act } from 'react'; import { renderWithFileRoutes, screen, waitFor } from 'test-utils'; vi.mock('config', () => ({ @@ -275,7 +276,9 @@ describe('RemoteMCPInspectorTab — streaming progress UI', () => { await screen.findByText('halfway'); await waitFor(() => expect(onprogressHandoff).toBeDefined()); - onprogressHandoff?.({ progress: 50, total: 100 }); + await act(async () => { + onprogressHandoff?.({ progress: 50, total: 100 }); + }); await waitFor(() => { const bar = screen.queryByTestId('mcp-tool-progress-bar'); @@ -300,7 +303,9 @@ describe('RemoteMCPInspectorTab — streaming progress UI', () => { await waitFor(() => expect(onprogressHandoff).toBeDefined()); // > 100% - onprogressHandoff?.({ progress: 200, total: 100 }); + await act(async () => { + onprogressHandoff?.({ progress: 200, total: 100 }); + }); await waitFor(() => { const bar = screen.queryByTestId('mcp-tool-progress-bar'); expect(bar).toBeTruthy(); @@ -311,7 +316,9 @@ describe('RemoteMCPInspectorTab — streaming progress UI', () => { }); // < 0% - onprogressHandoff?.({ progress: -5, total: 10 }); + await act(async () => { + onprogressHandoff?.({ progress: -5, total: 10 }); + }); await waitFor(() => { const bar = screen.queryByTestId('mcp-tool-progress-bar'); const value = bar?.getAttribute('data-value'); @@ -319,7 +326,9 @@ describe('RemoteMCPInspectorTab — streaming progress UI', () => { }); // NaN (total = 0 → division by zero NaN handled as undefined) - onprogressHandoff?.({ progress: 5, total: 0 }); + await act(async () => { + onprogressHandoff?.({ progress: 5, total: 0 }); + }); await waitFor(() => { const bar = screen.queryByTestId('mcp-tool-progress-bar'); expect(bar).toBeTruthy(); diff --git a/frontend/src/react-query/api/mcp-oauth-provider.test.ts b/frontend/src/react-query/api/mcp-oauth-provider.test.ts index 377b2c1857..73bbc0a784 100644 --- a/frontend/src/react-query/api/mcp-oauth-provider.test.ts +++ b/frontend/src/react-query/api/mcp-oauth-provider.test.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025 Redpanda Data, Inc. + * Copyright 2026 Redpanda Data, Inc. * * Use of this software is governed by the Business Source License * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md diff --git a/frontend/src/react-query/api/mcp-oauth-provider.ts b/frontend/src/react-query/api/mcp-oauth-provider.ts index 8a61e27bd6..ffe563ad39 100644 --- a/frontend/src/react-query/api/mcp-oauth-provider.ts +++ b/frontend/src/react-query/api/mcp-oauth-provider.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025 Redpanda Data, Inc. + * Copyright 2026 Redpanda Data, Inc. * * Use of this software is governed by the Business Source License * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md diff --git a/frontend/src/react-query/api/remote-mcp.test.tsx b/frontend/src/react-query/api/remote-mcp.test.tsx index e45265c03e..dc5beb53a5 100644 --- a/frontend/src/react-query/api/remote-mcp.test.tsx +++ b/frontend/src/react-query/api/remote-mcp.test.tsx @@ -676,14 +676,16 @@ describe('useStreamMCPServerToolMutation — capability fallback', () => { const { result } = renderHook(() => useStreamMCPServerToolMutation(), { wrapper }); const start = Date.now(); - await expect( - result.current.mutateAsync({ - serverUrl: 'https://example.test/mcp', - toolName: 'my-tool', - parameters: {}, - streamTimeoutMs: 50, - }) - ).rejects.toBeTruthy(); + await act(async () => { + await expect( + result.current.mutateAsync({ + serverUrl: 'https://example.test/mcp', + toolName: 'my-tool', + parameters: {}, + streamTimeoutMs: 50, + }) + ).rejects.toBeTruthy(); + }); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(500); @@ -703,14 +705,16 @@ describe('useStreamMCPServerToolMutation — timeout & watchdog', () => { }); const { result } = renderHook(() => useStreamMCPServerToolMutation(), { wrapper }); - await expect( - result.current.mutateAsync({ - serverUrl: 'https://example.test/mcp', - toolName: 'my-tool', - parameters: {}, - streamTimeoutMs: 50, - }) - ).rejects.toThrow(STREAM_TIMEOUT_50MS_REGEX); + await act(async () => { + await expect( + result.current.mutateAsync({ + serverUrl: 'https://example.test/mcp', + toolName: 'my-tool', + parameters: {}, + streamTimeoutMs: 50, + }) + ).rejects.toThrow(STREAM_TIMEOUT_50MS_REGEX); + }); }); test('timeout path aborts the SDK signal so upstream fetches are cancelled', async () => { @@ -722,14 +726,16 @@ describe('useStreamMCPServerToolMutation — timeout & watchdog', () => { const { result } = renderHook(() => useStreamMCPServerToolMutation(), { wrapper }); const abortStates: boolean[] = []; - const promise = result.current.mutateAsync({ - serverUrl: 'https://example.test/mcp', - toolName: 'my-tool', - parameters: {}, - streamTimeoutMs: 30, - }); + await act(async () => { + const promise = result.current.mutateAsync({ + serverUrl: 'https://example.test/mcp', + toolName: 'my-tool', + parameters: {}, + streamTimeoutMs: 30, + }); - await expect(promise).rejects.toThrow(STREAM_TIMED_OUT_REGEX); + await expect(promise).rejects.toThrow(STREAM_TIMED_OUT_REGEX); + }); abortStates.push(lastStreamOptions?.signal?.aborted ?? false); expect(abortStates).toEqual([true]); }); @@ -810,32 +816,34 @@ describe('useStreamMCPServerToolMutation — concurrency', () => { const controllerA = new AbortController(); const controllerB = new AbortController(); - streamHangForever = true; - const aPromise = resultA.current.mutateAsync({ - serverUrl: 'https://example.test/mcp', - toolName: 'tool-a', - parameters: {}, - signal: controllerA.signal, - streamTimeoutMs: 10_000, - }); - // Let the first call enter the stream. - await new Promise((r) => setTimeout(r, 10)); + await act(async () => { + streamHangForever = true; + const aPromise = resultA.current.mutateAsync({ + serverUrl: 'https://example.test/mcp', + toolName: 'tool-a', + parameters: {}, + signal: controllerA.signal, + streamTimeoutMs: 10_000, + }); + // Let the first call enter the stream. + await new Promise((r) => setTimeout(r, 10)); - streamHangForever = false; - streamMessages = [{ type: 'result', result: { content: [{ type: 'text', text: 'b-done' }] } }]; - const bPromise = resultB.current.mutateAsync({ - serverUrl: 'https://example.test/mcp', - toolName: 'tool-b', - parameters: {}, - signal: controllerB.signal, - }); + streamHangForever = false; + streamMessages = [{ type: 'result', result: { content: [{ type: 'text', text: 'b-done' }] } }]; + const bPromise = resultB.current.mutateAsync({ + serverUrl: 'https://example.test/mcp', + toolName: 'tool-b', + parameters: {}, + signal: controllerB.signal, + }); - const b = await bPromise; - expect(b).toEqual({ content: [{ type: 'text', text: 'b-done' }] }); - expect(controllerB.signal.aborted).toBe(false); + const b = await bPromise; + expect(b).toEqual({ content: [{ type: 'text', text: 'b-done' }] }); + expect(controllerB.signal.aborted).toBe(false); - controllerA.abort(); - await expect(aPromise).rejects.toBeTruthy(); + controllerA.abort(); + await expect(aPromise).rejects.toBeTruthy(); + }); // B was never aborted by A's cancellation. expect(controllerB.signal.aborted).toBe(false); }); @@ -954,19 +962,21 @@ describe('useStreamMCPServerToolMutation — client lifecycle (close in finally) const { result } = renderHook(() => useStreamMCPServerToolMutation(), { wrapper }); const controller = new AbortController(); - const promise = result.current.mutateAsync({ - serverUrl: 'https://example.test/mcp', - toolName: 'my-tool', - parameters: {}, - signal: controller.signal, - streamTimeoutMs: 10_000, - }); + await act(async () => { + const promise = result.current.mutateAsync({ + serverUrl: 'https://example.test/mcp', + toolName: 'my-tool', + parameters: {}, + signal: controller.signal, + streamTimeoutMs: 10_000, + }); - // Let the mutation enter the streaming path before aborting. - await new Promise((r) => setTimeout(r, 10)); - controller.abort(); + // Let the mutation enter the streaming path before aborting. + await new Promise((r) => setTimeout(r, 10)); + controller.abort(); - await expect(promise).rejects.toBeTruthy(); + await expect(promise).rejects.toBeTruthy(); + }); expect(createdClients[0].close).toHaveBeenCalledTimes(1); expect(toastErrorMock).not.toHaveBeenCalled();