diff --git a/.gitignore b/.gitignore
index 073122f4c..a6e080d7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
.vscode/
.idea/
+node_modules/
+.next/
*.out
*.log
coverage.out
@@ -24,3 +26,4 @@ CLAUDE.md
*test*.py
*.db
.codex
+pnpm-lock.yaml
diff --git a/.resource/images/examples/agui-copilotkit.png b/.resource/images/examples/agui-copilotkit.png
new file mode 100644
index 000000000..b177d275d
--- /dev/null
+++ b/.resource/images/examples/agui-copilotkit.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8e11382990a08da9d2a66f121244530ac3eb3a18664c0d8866f668229976d02e
+size 93710
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index bee2193d5..645fc9a00 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -24,6 +24,7 @@ nav:
- Planner: planner.md
- Event: event.md
- Debug: debugserver.md
+ - AG-UI: agui.md
- Observability: observability.md
- A2A: a2a.md
- Ecosystem: ecosystem.md
@@ -61,6 +62,7 @@ plugins:
- Graph: graph.md
- Event: event.md
- 调试: debugserver.md
+ - AG-UI: agui.md
- 可观测: observability.md
- A2A: a2a.md
- 生态: ecosystem.md
diff --git a/docs/mkdocs/assets/img/agui/copilotkit.png b/docs/mkdocs/assets/img/agui/copilotkit.png
new file mode 100644
index 000000000..b177d275d
--- /dev/null
+++ b/docs/mkdocs/assets/img/agui/copilotkit.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8e11382990a08da9d2a66f121244530ac3eb3a18664c0d8866f668229976d02e
+size 93710
diff --git a/docs/mkdocs/en/agui.md b/docs/mkdocs/en/agui.md
new file mode 100644
index 000000000..472813230
--- /dev/null
+++ b/docs/mkdocs/en/agui.md
@@ -0,0 +1,131 @@
+# AG-UI Guide
+
+The AG-UI (Agent-User Interaction) protocol is maintained by the open-source [AG-UI Protocol](https://github.com/ag-ui-protocol/ag-ui) project. It enables agents built in different languages, frameworks, and execution environments to deliver their runtime outputs to user interfaces through a unified event stream. The protocol tolerates loosely matched payloads and supports transports such as SSE and WebSocket.
+
+`tRPC-Agent-Go` ships with native AG-UI integration. It provides an SSE server implementation by default, while also allowing you to swap in a custom `service.Service` to use transports like WebSocket and to extend the event translation logic.
+
+## Getting Started
+
+Assuming you already have an agent, you can expose it via the AG-UI protocol with just a few lines of code:
+
+```go
+import (
+ "net/http"
+
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+)
+
+// Create the agent.
+agent := newAgent()
+// Build the Runner that will execute the agent.
+runner := runner.NewRunner(agent.Info().Name, agent)
+// Create the AG-UI server and mount it on an HTTP route.
+server, err := agui.New(runner, agui.WithPath("/agui"))
+if err != nil {
+ log.Fatalf("create agui server failed: %v", err)
+}
+// Start the HTTP listener.
+if err := http.ListenAndServe("127.0.0.1:8080", server.Handler()); err != nil {
+ log.Fatalf("server stopped with error: %v", err)
+}
+```
+
+A complete version of this example lives in [examples/agui/server/default](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/server/default).
+
+For an in-depth guide to Runners, refer to the [runner](./runner.md) documentation.
+
+On the client side you can pair the server with frameworks that understand the AG-UI protocol, such as [CopilotKit](https://github.com/CopilotKit/CopilotKit). It provides React/Next.js components with built-in SSE subscriptions. The sample at [examples/agui/client/copilotkit](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/client/copilotkit) builds a web UI that communicates with the agent through AG-UI, as shown below.
+
+
+
+## Advanced Usage
+
+### Custom transport
+
+The AG-UI specification does not enforce a transport. The framework uses SSE by default, but you can implement the `service.Service` interface to switch to WebSocket or any other transport:
+
+```go
+import (
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+)
+
+type wsService struct{}
+
+func (s *wsService) Handler() http.Handler { /* Register WebSocket and stream events. */ }
+
+runner := runner.NewRunner(agent.Info().Name, agent)
+server, _ := agui.New(runner, agui.WithService(&wsService{}))
+```
+
+### Custom translator
+
+`translator.New` converts internal events into the standard AG-UI events. To enrich the stream while keeping the default behaviour, implement `translator.Translator` and use the AG-UI `Custom` event type to carry extra data:
+
+```go
+import (
+ aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ agentevent "trpc.group/trpc-go/trpc-agent-go/event"
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/translator"
+)
+
+type customTranslator struct {
+ inner translator.Translator
+}
+
+func (t *customTranslator) Translate(evt *agentevent.Event) ([]aguievents.Event, error) {
+ out, err := t.inner.Translate(evt)
+ if err != nil {
+ return nil, err
+ }
+ if payload := buildCustomPayload(evt); payload != nil {
+ out = append(out, aguievents.NewCustomEvent("trace.metadata", aguievents.WithValue(payload)))
+ }
+ return out, nil
+}
+
+func buildCustomPayload(evt *agentevent.Event) map[string]any {
+ if evt == nil || evt.Response == nil {
+ return nil
+ }
+ return map[string]any{
+ "object": evt.Response.Object,
+ "timestamp": evt.Response.Timestamp,
+ }
+}
+
+factory := func(input *adapter.RunAgentInput) translator.Translator {
+ return &customTranslator{inner: translator.New(input.ThreadID, input.RunID)}
+}
+
+runner := runner.NewRunner(agent.Info().Name, agent)
+server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithTranslatorFactory(factory)))
+```
+
+### Custom `UserIDResolver`
+
+By default every request maps to the fixed user ID `"user"`. Implement a custom `UserIDResolver` if you need to derive the user from the `RunAgentInput`:
+
+```go
+import (
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+)
+
+resolver := func(ctx context.Context, input *adapter.RunAgentInput) (string, error) {
+ if user, ok := input.ForwardedProps["userId"].(string); ok && user != "" {
+ return user, nil
+ }
+ return "anonymous", nil
+}
+
+runner := runner.NewRunner(agent.Info().Name, agent)
+server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithUserIDResolver(resolver)))
+```
diff --git a/docs/mkdocs/zh/agui.md b/docs/mkdocs/zh/agui.md
new file mode 100644
index 000000000..6e03fabf9
--- /dev/null
+++ b/docs/mkdocs/zh/agui.md
@@ -0,0 +1,130 @@
+# AG-UI 使用指南
+
+AG-UI(Agent-User Interaction)协议由开源社区 [AG-UI Protocol](https://github.com/ag-ui-protocol/ag-ui) 维护,旨在让不同语言、不同框架、不同执行环境的 Agent,都能够通过统一的事件流把执行过程中产生的内容传递给用户界面,允许松散的事件格式匹配,支持 SSE 和 WebSocket 等多种通信协议。
+
+`tRPC-Agent-Go` 接入了 AG-UI 协议,默认提供 SSE 服务端实现,也支持通过自定义 `service.Service` 切换到 WebSocket 等通信协议,并扩展事件翻译逻辑。
+
+## 快速上手
+
+假设你已实现一个 Agent,可以按如下方式接入 AG-UI 协议并启动服务:
+
+```go
+import (
+ "net/http"
+
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+)
+
+// 创建 Agent
+agent := newAgent()
+// 创建 Runner
+runner := runner.NewRunner(agent.Info().Name, agent)
+// 创建 AG-UI 服务,指定 HTTP 路由
+server, err := agui.New(runner, agui.WithPath("/agui"))
+if err != nil {
+ log.Fatalf("create agui server failed: %v", err)
+}
+// 启动 HTTP 服务
+if err := http.ListenAndServe("127.0.0.1:8080", server.Handler()); err != nil {
+ log.Fatalf("server stopped with error: %v", err)
+}
+```
+
+完整代码示例参见 [examples/agui/server/default](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/server/default)。
+
+Runner 全面的使用方法参见 [runner](./runner.md)。
+
+在前端侧,可以配合 [CopilotKit](https://github.com/CopilotKit/CopilotKit) 等支持 AG-UI 协议的客户端框架,它提供 React/Next.js 组件并内置 SSE 订阅能力。[examples/agui/client/copilotkit](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/client/copilotkit) 使用 CopilotKit 搭建了 Web UI 界面,通过 AG-UI 协议与 Agent 通信,效果如下图所示。
+
+
+
+## 进阶用法
+
+### 自定义通信协议
+
+AG-UI 协议未强制规定通信协议,框架使用 SSE 作为 AG-UI 的默认通信协议,如果希望改用 WebSocket 等其他协议,可以实现 `service.Service` 接口:
+
+```go
+import (
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+)
+
+type wsService struct{}
+
+func (s *wsService) Handler() http.Handler { /* 注册 WebSocket 并写入事件 */ }
+
+runner := runner.NewRunner(agent.Info().Name, agent)
+server, _ := agui.New(runner, agui.WithService(&wsService{}))
+```
+
+### 自定义 Translator
+
+默认的 `translator.New` 会把内部事件翻译成协议里定义的标准事件集。若想在保留默认行为的基础上追加自定义信息,可以实现 `translator.Translator` 接口,并借助 AG-UI 的 `Custom` 事件类型携带扩展数据:
+
+```go
+import (
+ aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/translator"
+)
+
+type customTranslator struct {
+ inner translator.Translator
+}
+
+func (t *customTranslator) Translate(event *event.Event) ([]aguievents.Event, error) {
+ out, err := t.inner.Translate(event)
+ if err != nil {
+ return nil, err
+ }
+ if payload := buildCustomPayload(event); payload != nil {
+ out = append(out, aguievents.NewCustomEvent("trace.metadata", aguievents.WithValue(payload)))
+ }
+ return out, nil
+}
+
+func buildCustomPayload(event *event.Event) map[string]any {
+ if event == nil || event.Response == nil {
+ return nil
+ }
+ return map[string]any{
+ "object": event.Response.Object,
+ "timestamp": event.Response.Timestamp,
+ }
+}
+
+factory := func(input *adapter.RunAgentInput) translator.Translator {
+ return &customTranslator{inner: translator.New(input.ThreadID, input.RunID)}
+}
+
+runner := runner.NewRunner(agent.Info().Name, agent)
+server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithTranslatorFactory(factory)))
+```
+
+### 自定义 `UserIDResolver`
+
+默认所有请求都会归到固定的 `"user"` 用户 ID,可以通过自定义 `UserIDResolver` 从 `RunAgentInput` 中提取 `UserID`:
+
+```go
+import (
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+)
+
+resolver := func(ctx context.Context, input *adapter.RunAgentInput) (string, error) {
+ if user, ok := input.ForwardedProps["userId"].(string); ok && user != "" {
+ return user, nil
+ }
+ return "anonymous", nil
+}
+
+runner := runner.NewRunner(agent.Info().Name, agent)
+server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithUserIDResolver(resolver)))
+```
diff --git a/examples/agui/README.md b/examples/agui/README.md
new file mode 100644
index 000000000..12a66ccea
--- /dev/null
+++ b/examples/agui/README.md
@@ -0,0 +1,26 @@
+# AG-UI Examples
+
+This folder collects runnable demos that showcase how to integrate the `tRPC-Agent-Go` AG-UI server and various clients.
+
+- [`client/`](client/) – Client-side samples.
+- [`server/`](server/) – Server-side samples.
+
+## Quick Start
+
+1. Start the default AG-UI server:
+
+```bash
+go run ./server/default
+```
+
+2. In another terminal start the CopilotKit client:
+
+```bash
+cd ./client/copilotkit
+pnpm install
+pnpm dev
+```
+
+3. Ask a question such as `Calculate 2*(10+11)` and watch the live event stream in the terminal. A full transcript example is documented in [`client/copilotkit/README.md`](client/copilotkit/README.md).
+
+See the individual README files under `client/` and `server/` for more background and configuration options.
diff --git a/examples/agui/client/README.md b/examples/agui/client/README.md
new file mode 100644
index 000000000..daa8bb79d
--- /dev/null
+++ b/examples/agui/client/README.md
@@ -0,0 +1,8 @@
+# AG-UI Clients
+
+Runnable client front-ends that consume the AG-UI SSE stream exposed by the example servers.
+
+## Available Clients
+
+- [copilotkit/](copilotkit/) – Next.js web chat built with CopilotKit that renders AG-UI responses in the browser.
+- [raw/](raw/) – Minimal Go terminal client that prints every SSE event for inspection.
diff --git a/examples/agui/client/copilotkit/README.md b/examples/agui/client/copilotkit/README.md
new file mode 100644
index 000000000..25a3053fc
--- /dev/null
+++ b/examples/agui/client/copilotkit/README.md
@@ -0,0 +1,18 @@
+# CopilotKit Front-End for the AG-UI Server
+
+This example shows how to pair the Go-based AG-UI server with a React front-end built on [CopilotKit](https://docs.copilotkit.ai/). The UI streams Server-Sent Events from the AG-UI endpoint using the `@ag-ui/client` HTTP agent and renders an assistant sidebar provided by CopilotKit.
+
+## Start the CopilotKit client
+
+```bash
+pnpm install # or npm install
+pnpm dev # or npm run dev
+```
+
+Available environment variables before `pnpm dev`:
+
+- `AG_UI_ENDPOINT`: override the AG-UI endpoint URL (defaults to `http://127.0.0.1:8080/agui`).
+
+Open `http://localhost:3000` and start chatting with the full-screen assistant UI. The input shows the placeholder `Calculate 2*(10+11)`, first explain the idea, then calculate, and finally give the conclusion.`—press Enter to run that scenario or type your own request. Tool calls and their results appear inline inside the chat transcript.
+
+
\ No newline at end of file
diff --git a/examples/agui/client/copilotkit/app/api/copilotkit/route.ts b/examples/agui/client/copilotkit/app/api/copilotkit/route.ts
new file mode 100644
index 000000000..40ab972db
--- /dev/null
+++ b/examples/agui/client/copilotkit/app/api/copilotkit/route.ts
@@ -0,0 +1,40 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+import { NextRequest } from "next/server";
+import {
+ CopilotRuntime,
+ ExperimentalEmptyAdapter,
+ copilotRuntimeNextJSAppRouterEndpoint,
+} from "@copilotkit/runtime";
+import { HttpAgent } from "@ag-ui/client";
+
+const runtime = new CopilotRuntime({
+ agents: {
+ "agui-demo": new HttpAgent({
+ agentId: "agui-demo",
+ description: "AG-UI agent hosted by the Go evaluation server",
+ threadId: "demo-thread",
+ url: process.env.AG_UI_ENDPOINT ?? "http://127.0.0.1:8080/agui",
+ headers: process.env.AG_UI_TOKEN
+ ? { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }
+ : undefined,
+ }),
+ },
+});
+
+const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
+ runtime,
+ serviceAdapter: new ExperimentalEmptyAdapter(),
+ endpoint: "/api/copilotkit",
+});
+
+export async function POST(request: NextRequest) {
+ return handleRequest(request);
+}
diff --git a/examples/agui/client/copilotkit/app/globals.css b/examples/agui/client/copilotkit/app/globals.css
new file mode 100644
index 000000000..ca45f4f74
--- /dev/null
+++ b/examples/agui/client/copilotkit/app/globals.css
@@ -0,0 +1,197 @@
+/*
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+*/
+
+:root {
+ color-scheme: dark;
+ --copilot-kit-background-color: #141414;
+ --copilot-kit-primary-color: #024a76;
+ --copilot-kit-contrast-color: #ffffff;
+ --copilot-kit-secondary-contrast-color: #ffffff;
+ --copilot-kit-separator-color: rgba(255, 255, 255, 0.12);
+}
+
+html,
+body {
+ margin: 0;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #141414;
+ color: #ffffff;
+ min-height: 100vh;
+ height: 100%;
+ overflow: hidden;
+}
+
+main.agui-chat {
+ min-height: 100vh;
+ height: 100vh;
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+.agui-chat__panel {
+ flex: 1;
+ margin: 0;
+ background: #141414;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ min-height: 100vh;
+}
+
+.copilotKitChat,
+.copilotKitMessages,
+.copilotKitMessagesContainer,
+.copilotKitMessagesFooter {
+ background: #141414 !important;
+}
+
+.tool-message {
+ margin: 0.75rem 0 0.75rem 1.5rem;
+ padding: 0.75rem 0.95rem;
+ border-radius: 12px;
+ background: #303030;
+ border-left: 3px solid #4c6f8e;
+ color: #ffffff;
+ font-size: 0.95rem;
+ display: inline-block;
+ max-width: min(680px, 80%);
+ width: fit-content;
+}
+
+.tool-message__label {
+ display: block;
+ font-size: 0.85rem;
+ letter-spacing: 0.01em;
+ text-transform: uppercase;
+ color: rgba(129, 212, 250, 0.9);
+ margin-bottom: 0.5rem;
+}
+
+.tool-message__body {
+ margin: 0;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+ color: #bae6fd;
+ background: transparent;
+}
+
+@media (max-width: 768px) {
+ .agui-chat__panel {
+ border-radius: 0;
+ border-width: 0;
+ }
+
+ .tool-message {
+ margin: 0.75rem 0;
+ max-width: 100%;
+ }
+}
+
+
+.copilotKitMessage {
+ color: #ffffff;
+ padding: 0.9rem 1.1rem;
+ border-radius: 18px;
+ max-width: min(720px, 85%);
+ line-height: 1.6;
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.22);
+}
+
+.agui-chat .copilotKitMessage.copilotKitUserMessage {
+ background: #024a76;
+ margin-left: auto;
+ margin-right: 1.5rem;
+ color: #ffffff;
+}
+
+.agui-chat .copilotKitMessage.copilotKitAssistantMessage {
+ background: #303030;
+ margin-right: auto;
+ margin-left: 1.5rem;
+ padding: 1rem 1.2rem;
+ max-width: min(760px, 90%);
+ color: #ffffff;
+}
+
+.agui-chat .copilotKitMessage.copilotKitAssistantMessage * {
+ color: inherit;
+}
+
+.agui-chat .copilotKitMessageControls {
+ margin-top: 0.75rem;
+ display: flex;
+ gap: 0.35rem;
+}
+
+.agui-chat .copilotKitMessageControlButton {
+ background: rgba(255, 255, 255, 0.1);
+ border: none;
+ border-radius: 999px;
+ color: #ffffff;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.agui-chat .copilotKitMessageControlButton:hover {
+ background: rgba(255, 255, 255, 0.22);
+}
+
+.copilotKitInputContainer {
+ background: #141414;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ padding: 1rem 1.5rem;
+ display: flex;
+ justify-content: center;
+}
+
+.copilotKitInput {
+ background: #1d1d1d;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 18px;
+ padding: 0.5rem 0.75rem;
+ display: flex;
+ align-items: stretch;
+ gap: 0.75rem;
+ width: min(720px, 95%);
+ box-sizing: border-box;
+}
+
+.copilotKitInput textarea {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: #ffffff;
+ font-size: 0.95rem;
+ line-height: 1.6;
+ font-family: inherit;
+ padding: 0.35rem 0.1rem;
+ align-self: stretch;
+ min-height: 1.8rem;
+}
+
+.copilotKitInput textarea::placeholder {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.tool-message__label {
+ color: #ffffff;
+}
+
+.tool-message__body {
+ color: #ffffff;
+}
diff --git a/examples/agui/client/copilotkit/app/layout.tsx b/examples/agui/client/copilotkit/app/layout.tsx
new file mode 100644
index 000000000..2b576c711
--- /dev/null
+++ b/examples/agui/client/copilotkit/app/layout.tsx
@@ -0,0 +1,35 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+import type { Metadata } from "next";
+import { CopilotKit } from "@copilotkit/react-core";
+
+import "@copilotkit/react-ui/styles.css";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "AG-UI CopilotKit Demo",
+ description: "Minimal CopilotKit front-end that streams AG-UI events from a Go agent server.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/examples/agui/client/copilotkit/app/page.tsx b/examples/agui/client/copilotkit/app/page.tsx
new file mode 100644
index 000000000..afa4a1ff1
--- /dev/null
+++ b/examples/agui/client/copilotkit/app/page.tsx
@@ -0,0 +1,275 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+"use client";
+
+import { Fragment, useLayoutEffect, useRef, useState } from "react";
+import type { InputProps, RenderMessageProps } from "@copilotkit/react-ui";
+import {
+ AssistantMessage as DefaultAssistantMessage,
+ CopilotChat,
+ ImageRenderer as DefaultImageRenderer,
+ UserMessage as DefaultUserMessage,
+ useChatContext,
+} from "@copilotkit/react-ui";
+
+const DEFAULT_PROMPT = "Calculate 2*(10+11), first explain the idea, then calculate, and finally give the conclusion.";
+
+const PromptInput = ({
+ inProgress,
+ onSend,
+ isVisible = false,
+ onStop,
+ hideStopButton = false,
+}: InputProps) => {
+ const context = useChatContext();
+ const textareaRef = useRef(null);
+ const [text, setText] = useState("");
+ const [isComposing, setIsComposing] = useState(false);
+
+ const adjustHeight = () => {
+ const textarea = textareaRef.current;
+ if (!textarea) {
+ return;
+ }
+ const styles = window.getComputedStyle(textarea);
+ const lineHeight = parseFloat(styles.lineHeight || "20");
+ const paddingTop = parseFloat(styles.paddingTop || "0");
+ const paddingBottom = parseFloat(styles.paddingBottom || "0");
+ const baseHeight = lineHeight + paddingTop + paddingBottom;
+
+ textarea.style.height = "auto";
+ const value = textarea.value;
+ if (value.trim() === "") {
+ textarea.style.height = `${baseHeight}px`;
+ textarea.style.overflowY = "hidden";
+ return;
+ }
+
+ textarea.style.height = `${Math.max(textarea.scrollHeight, baseHeight)}px`;
+ textarea.style.overflowY = "auto";
+ };
+
+ useLayoutEffect(() => {
+ adjustHeight();
+ }, [text]);
+
+ useLayoutEffect(() => {
+ adjustHeight();
+ }, [isVisible]);
+
+ useLayoutEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.focus();
+ // ensure consistent initial height after focus
+ adjustHeight();
+ }
+ }, []);
+
+ const handleDivClick = (event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.closest("button")) return;
+ if (target.tagName === "TEXTAREA") return;
+ textareaRef.current?.focus();
+ };
+
+ const send = () => {
+ if (inProgress) {
+ return;
+ }
+ const trimmed = text.trim();
+ const payload = trimmed.length > 0 ? text : DEFAULT_PROMPT;
+ onSend(payload);
+ setText("");
+ textareaRef.current?.focus();
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter" && !event.shiftKey && !isComposing) {
+ event.preventDefault();
+ if (inProgress && !hideStopButton) {
+ onStop?.();
+ } else {
+ send();
+ }
+ }
+ };
+
+ return (
+
+ );
+};
+
+function formatStructuredContent(value: unknown): string {
+ if (value === null || value === undefined) {
+ return "(empty)";
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (trimmed === "") {
+ return "(empty)";
+ }
+ try {
+ const maybeJson = JSON.parse(trimmed);
+ return typeof maybeJson === "string"
+ ? maybeJson
+ : JSON.stringify(maybeJson, null, 2);
+ } catch {
+ return trimmed;
+ }
+ }
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+function renderToolBlock({
+ id,
+ name,
+ label,
+ body,
+}: {
+ id: string;
+ name: string;
+ label: string;
+ body: unknown;
+}) {
+ const content = formatStructuredContent(body);
+ return (
+
+
{label || name}
+
{content}
+
+ );
+}
+
+const ToolAwareRenderMessage = ({
+ message,
+ inProgress,
+ index,
+ isCurrentMessage,
+ onRegenerate,
+ onCopy,
+ onThumbsUp,
+ onThumbsDown,
+ markdownTagRenderers,
+ AssistantMessage = DefaultAssistantMessage,
+ UserMessage = DefaultUserMessage,
+ ImageRenderer = DefaultImageRenderer,
+}: RenderMessageProps) => {
+ const messageType = (message as any)?.type;
+
+ if (messageType === "ActionExecutionMessage") {
+ const actionName = (message as any)?.name ?? "Tool call";
+ const args = (message as any)?.arguments ?? {};
+ return renderToolBlock({
+ id: String((message as any)?.id ?? `${index}-tool-call`),
+ name: actionName,
+ label: actionName,
+ body: args,
+ });
+ }
+
+ if (messageType === "ResultMessage" || message.role === "tool") {
+ const actionName = (message as any)?.actionName ?? (message as any)?.name ?? "Tool result";
+ const body =
+ (message as any)?.result !== undefined ? (message as any)?.result : (message as any)?.content;
+ return renderToolBlock({
+ id: String((message as any)?.id ?? `${index}-tool-result`),
+ name: actionName,
+ label: actionName,
+ body,
+ });
+ }
+
+ if (message.role === "assistant") {
+ const messageId = String(message.id ?? index);
+ const toolCalls = Array.isArray((message as any)?.toolCalls)
+ ? ((message as any)?.toolCalls as any[])
+ : [];
+
+ return (
+
+ onRegenerate?.(String(message.id)) : undefined}
+ onCopy={onCopy}
+ onThumbsUp={onThumbsUp}
+ onThumbsDown={onThumbsDown}
+ markdownTagRenderers={markdownTagRenderers}
+ ImageRenderer={ImageRenderer}
+ />
+ {toolCalls.map((call, callIndex) => {
+ const identifier = String(call?.id ?? `${messageId}-call-${callIndex}`);
+ const callName = call?.function?.name ?? call?.name ?? "Tool call";
+ const callArgs = call?.function?.arguments ?? call?.arguments ?? {};
+ return renderToolBlock({
+ id: identifier,
+ name: callName,
+ label: callName,
+ body: callArgs,
+ });
+ })}
+
+ );
+ }
+
+ if (message.role === "user") {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+export default function Home() {
+ return (
+
+
+
+ );
+}
+
+export { ToolAwareRenderMessage };
diff --git a/examples/agui/client/copilotkit/next.config.mjs b/examples/agui/client/copilotkit/next.config.mjs
new file mode 100644
index 000000000..fbd62a525
--- /dev/null
+++ b/examples/agui/client/copilotkit/next.config.mjs
@@ -0,0 +1,19 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ experimental: {
+ serverActions: {
+ bodySizeLimit: "2mb",
+ },
+ },
+};
+
+export default nextConfig;
diff --git a/examples/agui/client/copilotkit/package.json b/examples/agui/client/copilotkit/package.json
new file mode 100644
index 000000000..f615df54e
--- /dev/null
+++ b/examples/agui/client/copilotkit/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "agui-copilotkit-ui",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@ag-ui/client": "^0.0.38",
+ "@copilotkit/react-core": "^1.1.3",
+ "@copilotkit/react-ui": "^1.1.3",
+ "@copilotkit/runtime": "^1.1.3",
+ "next": "^14.2.5",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.9",
+ "@types/react": "^18.3.5",
+ "@types/react-dom": "^18.3.0",
+ "eslint": "^8.57.0",
+ "eslint-config-next": "^14.2.5",
+ "typescript": "^5.5.4"
+ }
+}
diff --git a/examples/agui/client/copilotkit/tsconfig.json b/examples/agui/client/copilotkit/tsconfig.json
new file mode 100644
index 000000000..8c6856be0
--- /dev/null
+++ b/examples/agui/client/copilotkit/tsconfig.json
@@ -0,0 +1,40 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "types": [
+ "node"
+ ],
+ "baseUrl": ".",
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/examples/agui/client/raw/README.md b/examples/agui/client/raw/README.md
new file mode 100644
index 000000000..c1e7c4eda
--- /dev/null
+++ b/examples/agui/client/raw/README.md
@@ -0,0 +1,35 @@
+# Raw AG-UI SSE Client
+
+This minimal terminal client shows how to consume AG-UI events without any UI framework. It opens an SSE stream, parses each frame with the community Go SDK, and prints the events as they arrive so you can watch the agent think step by step.
+
+## Run the Client
+
+From `examples/agui`:
+
+```bash
+go run .
+```
+
+Pass `--endpoint` to target a different server URL. Prompts are read interactively from standard input (Ctrl+D exits, or type `quit`).
+
+## Sample Output
+
+Submitting `calculate 1.2+3.5` produces output similar to the following (IDs truncated for brevity):
+
+```text
+Simple AG-UI client. Endpoint: http://127.0.0.1:8080/agui
+Type your prompt and press Enter (Ctrl+D to exit).
+You> calculate 1.2+3.5
+Agent> [RUN_STARTED]
+Agent> [TEXT_MESSAGE_START]
+Agent> [TEXT_MESSAGE_CONTENT] I'll calculate 1.2 + 3.5 for you.
+Agent> [TEXT_MESSAGE_END]
+Agent> [TOOL_CALL_START] tool call 'calculator' started, id: call_00_rwe3...
+Agent> [TOOL_CALL_ARGS] tool args: {"a": 1.2, "b": 3.5, "operation": "add"}
+Agent> [TOOL_CALL_END] tool call completed, id: call_00_rwe3...
+Agent> [TOOL_CALL_RESULT] tool result: {"result":4.7}
+Agent> [TEXT_MESSAGE_START]
+Agent> [TEXT_MESSAGE_CONTENT] The result of 1.2 + 3.5 is **4.7**.
+Agent> [TEXT_MESSAGE_END]
+Agent> [RUN_FINISHED]
+```
diff --git a/examples/agui/client/raw/main.go b/examples/agui/client/raw/main.go
new file mode 100644
index 000000000..b570654e9
--- /dev/null
+++ b/examples/agui/client/raw/main.go
@@ -0,0 +1,176 @@
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package main
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/client/sse"
+ "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ "github.com/sirupsen/logrus"
+)
+
+const (
+ defaultEndpoint = "http://127.0.0.1:8080/agui"
+ requestTimeout = 2 * time.Minute
+ connectTimeout = 30 * time.Second
+ readTimeout = 5 * time.Minute
+ streamBufferSize = 100
+ stdinBufferInitial = 64 * 1024
+ stdinBufferMax = 1 << 20
+)
+
+func main() {
+ endpoint := flag.String("endpoint", defaultEndpoint, "AG-UI SSE endpoint")
+ flag.Parse()
+
+ if err := runInteractive(*endpoint); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func runInteractive(endpoint string) error {
+ scanner := bufio.NewScanner(os.Stdin)
+ scanner.Buffer(make([]byte, stdinBufferInitial), stdinBufferMax)
+
+ fmt.Printf("Simple AG-UI client. Endpoint: %s\n", endpoint)
+ fmt.Println("Type your prompt and press Enter (Ctrl+D to exit).")
+
+ for {
+ fmt.Print("You> ")
+ if !scanner.Scan() {
+ if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) {
+ return fmt.Errorf("read input: %w", err)
+ }
+ fmt.Println()
+ return nil
+ }
+ prompt := strings.TrimSpace(scanner.Text())
+ if prompt == "" {
+ continue
+ }
+ if strings.EqualFold(prompt, "quit") || strings.EqualFold(prompt, "exit") {
+ return nil
+ }
+ if err := streamConversation(endpoint, prompt); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ }
+ }
+}
+
+func streamConversation(endpoint, prompt string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
+ defer cancel()
+
+ client := newSSEClient(endpoint)
+ defer client.Close()
+
+ payload := map[string]any{
+ "threadId": "demo-thread",
+ "runId": fmt.Sprintf("run-%d", time.Now().UnixNano()),
+ "messages": []map[string]any{{"role": "user", "content": prompt}},
+ }
+
+ frames, errCh, err := client.Stream(sse.StreamOptions{Context: ctx, Payload: payload})
+ if err != nil {
+ return fmt.Errorf("start SSE stream: %w", err)
+ }
+
+ printed := false
+
+ for frames != nil || errCh != nil {
+ select {
+ case frame, ok := <-frames:
+ if !ok {
+ frames = nil
+ continue
+ }
+ evt, err := events.EventFromJSON(frame.Data)
+ if err != nil {
+ return fmt.Errorf("parse event: %w", err)
+ }
+ lines := formatEvent(evt)
+ if len(lines) == 0 {
+ continue
+ }
+ printed = true
+ for _, line := range lines {
+ fmt.Println(line)
+ }
+ case err, ok := <-errCh:
+ if !ok {
+ errCh = nil
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("stream error: %w", err)
+ }
+ case <-ctx.Done():
+ return fmt.Errorf("stream timeout: %w", ctx.Err())
+ }
+ }
+
+ if !printed {
+ fmt.Println("Agent> (no response)")
+ }
+ fmt.Println()
+ return nil
+}
+
+func newSSEClient(endpoint string) *sse.Client {
+ logger := logrus.New()
+ logger.SetLevel(logrus.WarnLevel)
+
+ return sse.NewClient(sse.Config{
+ Endpoint: endpoint,
+ ConnectTimeout: connectTimeout,
+ ReadTimeout: readTimeout,
+ BufferSize: streamBufferSize,
+ Logger: logger,
+ })
+}
+
+func formatEvent(evt events.Event) []string {
+ label := fmt.Sprintf("[%s]", evt.Type())
+ switch e := evt.(type) {
+ case *events.RunStartedEvent:
+ return []string{fmt.Sprintf("Agent> %s", label)}
+ case *events.RunFinishedEvent:
+ return []string{fmt.Sprintf("Agent> %s", label)}
+ case *events.RunErrorEvent:
+ return []string{fmt.Sprintf("Agent> %s: %s", label, e.Message)}
+ case *events.TextMessageStartEvent:
+ return []string{fmt.Sprintf("Agent> %s", label)}
+ case *events.TextMessageContentEvent:
+ if strings.TrimSpace(e.Delta) == "" {
+ return nil
+ }
+ return []string{fmt.Sprintf("Agent> %s %s", label, e.Delta)}
+ case *events.TextMessageEndEvent:
+ return []string{fmt.Sprintf("Agent> %s", label)}
+ case *events.ToolCallStartEvent:
+ return []string{fmt.Sprintf("Agent> %s tool call '%s' started, id: %s", label, e.ToolCallName, e.ToolCallID)}
+ case *events.ToolCallArgsEvent:
+ return []string{fmt.Sprintf("Agent> %s tool args: %s", label, e.Delta)}
+ case *events.ToolCallEndEvent:
+ return []string{fmt.Sprintf("Agent> %s tool call completed, id: %s", label, e.ToolCallID)}
+ case *events.ToolCallResultEvent:
+ return []string{fmt.Sprintf("Agent> %s tool result: %s", label, e.Content)}
+ default:
+ return []string{fmt.Sprintf("Agent> %s", label)}
+ }
+}
diff --git a/examples/agui/go.mod b/examples/agui/go.mod
new file mode 100644
index 000000000..8e6ff4acd
--- /dev/null
+++ b/examples/agui/go.mod
@@ -0,0 +1,49 @@
+module trpc.group/trpc-go/trpc-agent-go/examples/agui
+
+go 1.24.4
+
+replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8
+
+replace trpc.group/trpc-go/trpc-agent-go => ../../
+
+replace trpc.group/trpc-go/trpc-agent-go/server/agui => ../../server/agui
+
+require (
+ github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-00010101000000-000000000000
+ github.com/sirupsen/logrus v1.9.3
+ trpc.group/trpc-go/trpc-agent-go v0.2.0
+ trpc.group/trpc-go/trpc-agent-go/server/agui v0.0.0-00010101000000-000000000000
+)
+
+require (
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
+ github.com/openai/openai-go v1.12.0 // indirect
+ github.com/panjf2000/ants/v2 v2.9.0 // indirect
+ github.com/tidwall/gjson v1.14.4 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ go.opentelemetry.io/otel v1.29.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 // indirect
+ go.opentelemetry.io/otel/metric v1.29.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.29.0 // indirect
+ go.opentelemetry.io/otel/trace v1.29.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.3.1 // indirect
+ go.uber.org/multierr v1.10.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ golang.org/x/net v0.34.0 // indirect
+ golang.org/x/sync v0.11.0 // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect
+ google.golang.org/grpc v1.65.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
+ trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 // indirect
+)
diff --git a/examples/agui/go.sum b/examples/agui/go.sum
new file mode 100644
index 000000000..84a5ab764
--- /dev/null
+++ b/examples/agui/go.sum
@@ -0,0 +1,91 @@
+github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8 h1:3ZcB9AXuWmx/hFCptPv/DcY+zmwkruRTJREXSQjBHhM=
+github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
+github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
+github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
+github.com/panjf2000/ants/v2 v2.9.0 h1:SztCLkVxBRigbg+vt0S5QvF5vxAbxbKt09/YfAJ0tEo=
+github.com/panjf2000/ants/v2 v2.9.0/go.mod h1:7ZxyxsqE4vvW0M7LSD8aI3cKwgFhBHbxnlN8mDqHa1I=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 h1:nSiV3s7wiCam610XcLbYOmMfJxB9gO4uK3Xgv5gmTgg=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0/go.mod h1:hKn/e/Nmd19/x1gvIHwtOwVWM+VhuITSWip3JUDghj0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 h1:JAv0Jwtl01UFiyWZEMiJZBiTlv5A50zNs8lsthXqIio=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0/go.mod h1:QNKLmUEAq2QUbPQUfvw4fmv0bgbK7UlOSFCnXyfvSNc=
+go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
+go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
+go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo=
+go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
+go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
+go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd h1:BBOTEWLuuEGQy9n1y9MhVJ9Qt0BDu21X8qZs71/uPZo=
+google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:fO8wJzT2zbQbAjbIoos1285VfEIYKDDY+Dt+WpTkh6g=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
+google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
+google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 h1:Als3R0+WZSm+bkDVkt5ATElgRixuGRY7iBSEJXBq2XM=
+trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333/go.mod h1:Gtytau9Uoc3oPo/dpHvKit+tQn9Qlk5XFG1RiZTGqfk=
diff --git a/examples/agui/server/README.md b/examples/agui/server/README.md
new file mode 100644
index 000000000..71a67ab74
--- /dev/null
+++ b/examples/agui/server/README.md
@@ -0,0 +1,8 @@
+# AG-UI Servers
+
+This directory shows AG-UI servers that can talk to the AG-UI client examples.
+
+## Available Servers
+
+- [`default/`](default/) – Minimal AG-UI server that wires the `tRPC-Agent-Go` runner.
+
diff --git a/examples/agui/server/default/README.md b/examples/agui/server/default/README.md
new file mode 100644
index 000000000..03cb9ae2f
--- /dev/null
+++ b/examples/agui/server/default/README.md
@@ -0,0 +1,20 @@
+# Default AG-UI Server
+
+This example exposes a minimal AG-UI SSE endpoint backed by the `tRPC-Agent-Go` runner.
+
+It is intended to be used alongside the [Copilotkit client](../../client/copilotkit/).
+
+## Run
+
+From the `examples/agui` module:
+
+```bash
+# Start the server on http://localhost:8080/agui
+go run .
+```
+
+The server prints startup logs showing the bound address.
+
+```
+2025-09-26T10:28:46+08:00 INFO default/main.go:60 AG-UI: serving agent "agui-agent" on http://127.0.0.1:8080/agui
+```
diff --git a/examples/agui/server/default/main.go b/examples/agui/server/default/main.go
new file mode 100644
index 000000000..ab46eae16
--- /dev/null
+++ b/examples/agui/server/default/main.go
@@ -0,0 +1,103 @@
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "math"
+ "net/http"
+
+ "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
+ "trpc.group/trpc-go/trpc-agent-go/log"
+ "trpc.group/trpc-go/trpc-agent-go/model"
+ "trpc.group/trpc-go/trpc-agent-go/model/openai"
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui"
+ "trpc.group/trpc-go/trpc-agent-go/tool"
+ "trpc.group/trpc-go/trpc-agent-go/tool/function"
+)
+
+var (
+ modelName = flag.String("model", "deepseek-chat", "Model to use")
+ isStream = flag.Bool("stream", true, "Whether to stream the response")
+ address = flag.String("address", "127.0.0.1:8080", "Listen address")
+ path = flag.String("path", "/agui", "HTTP path")
+)
+
+func main() {
+ flag.Parse()
+ modelInstance := openai.New(*modelName)
+ generationConfig := model.GenerationConfig{
+ MaxTokens: intPtr(512),
+ Temperature: floatPtr(0.7),
+ Stream: *isStream,
+ }
+ calculatorTool := function.NewFunctionTool(
+ calculator,
+ function.WithName("calculator"),
+ function.WithDescription("A calculator tool, you can use it to calculate the result of the operation. "+
+ "a is the first number, b is the second number, "+
+ "the operation can be add, subtract, multiply, divide, power."),
+ )
+ agent := llmagent.New(
+ "agui-agent",
+ llmagent.WithTools([]tool.Tool{calculatorTool}),
+ llmagent.WithModel(modelInstance),
+ llmagent.WithGenerationConfig(generationConfig),
+ llmagent.WithInstruction("You are a helpful assistant."),
+ )
+ runner := runner.NewRunner(agent.Info().Name, agent)
+ server, err := agui.New(runner, agui.WithPath(*path))
+ if err != nil {
+ log.Fatalf("failed to create AG-UI server: %v", err)
+ }
+ log.Infof("AG-UI: serving agent %q on http://%s%s", agent.Info().Name, *address, *path)
+ if err := http.ListenAndServe(*address, server.Handler()); err != nil {
+ log.Fatalf("server stopped with error: %v", err)
+ }
+}
+
+func calculator(ctx context.Context, args calculatorArgs) (calculatorResult, error) {
+ var result float64
+ switch args.Operation {
+ case "add", "+":
+ result = args.A + args.B
+ case "subtract", "-":
+ result = args.A - args.B
+ case "multiply", "*":
+ result = args.A * args.B
+ case "divide", "/":
+ result = args.A / args.B
+ case "power", "^":
+ result = math.Pow(args.A, args.B)
+ default:
+ return calculatorResult{Result: 0}, fmt.Errorf("invalid operation: %s", args.Operation)
+ }
+ return calculatorResult{Result: result}, nil
+}
+
+type calculatorArgs struct {
+ Operation string `json:"operation" description:"add, subtract, multiply, divide, power"`
+ A float64 `json:"a" description:"First number"`
+ B float64 `json:"b" description:"Second number"`
+}
+
+type calculatorResult struct {
+ Result float64 `json:"result"`
+}
+
+func intPtr(i int) *int {
+ return &i
+}
+
+func floatPtr(f float64) *float64 {
+ return &f
+}
diff --git a/server/agui/adapter/adapter.go b/server/agui/adapter/adapter.go
new file mode 100644
index 000000000..f66373308
--- /dev/null
+++ b/server/agui/adapter/adapter.go
@@ -0,0 +1,29 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+// Package adapter provides the adapter for the AG-UI SDK.
+package adapter
+
+import (
+ "trpc.group/trpc-go/trpc-agent-go/model"
+)
+
+// RunAgentInput represents the parameters for an AG-UI run request.
+type RunAgentInput struct {
+ // ThreadID is the ID of the conversation thread, which is the session ID.
+ ThreadID string `json:"threadId"`
+ // RunID is the ID of the current run, which is the invocation ID.
+ RunID string `json:"runId"`
+ // Messages is the list of messages in the conversation.
+ Messages []model.Message `json:"messages"`
+ // State is the session state of the agent.
+ State map[string]any `json:"state"`
+ // ForwardedProps is the custom properties forwarded to the agent.
+ ForwardedProps map[string]any `json:"forwardedProps"`
+}
diff --git a/server/agui/adapter/adapter_test.go b/server/agui/adapter/adapter_test.go
new file mode 100644
index 000000000..3d6c8f6c9
--- /dev/null
+++ b/server/agui/adapter/adapter_test.go
@@ -0,0 +1,84 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package adapter_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "trpc.group/trpc-go/trpc-agent-go/model"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+)
+
+func TestRunAgentInputJSONUnmarshal(t *testing.T) {
+ raw := `{
+ "threadId": "thread-123",
+ "runId": "run-456",
+ "messages": [
+ {"role": "user", "content": "hi there"}
+ ],
+ "state": {"cursor": 1, "flags": ["a", "b"]},
+ "forwardedProps": {"userId": "alice", "metadata": {"traceId": "trace-01"}}
+ }`
+
+ var input adapter.RunAgentInput
+ assert.NoError(t, json.Unmarshal([]byte(raw), &input))
+
+ assert.Equal(t, "thread-123", input.ThreadID)
+ assert.Equal(t, "run-456", input.RunID)
+ assert.Len(t, input.Messages, 1)
+ assert.Equal(t, model.RoleUser, input.Messages[0].Role)
+ assert.Equal(t, "hi there", input.Messages[0].Content)
+
+ assert.Equal(t, map[string]any{"cursor": float64(1), "flags": []any{"a", "b"}}, input.State)
+ assert.Equal(t, "alice", input.ForwardedProps["userId"])
+
+ metadata, ok := input.ForwardedProps["metadata"].(map[string]any)
+ assert.True(t, ok)
+ assert.Equal(t, "trace-01", metadata["traceId"])
+}
+
+func TestRunAgentInputJSONMarshal(t *testing.T) {
+ input := adapter.RunAgentInput{
+ ThreadID: "thread-xyz",
+ RunID: "run-999",
+ Messages: []model.Message{{Role: model.RoleAssistant, Content: "result"}},
+ State: map[string]any{"step": 2},
+ ForwardedProps: map[string]any{
+ "userId": "bob",
+ "tags": []string{"x", "y"},
+ },
+ }
+
+ data, err := json.Marshal(input)
+ assert.NoError(t, err)
+
+ var decoded map[string]any
+ assert.NoError(t, json.Unmarshal(data, &decoded))
+
+ assert.Equal(t, "thread-xyz", decoded["threadId"])
+ assert.Equal(t, "run-999", decoded["runId"])
+
+ msgs, ok := decoded["messages"].([]any)
+ assert.True(t, ok)
+ assert.Len(t, msgs, 1)
+ first, ok := msgs[0].(map[string]any)
+ assert.True(t, ok)
+ assert.Equal(t, "assistant", first["role"])
+ assert.Equal(t, "result", first["content"])
+
+ assert.Equal(t, map[string]any{"step": float64(2)}, decoded["state"])
+
+ props, ok := decoded["forwardedProps"].(map[string]any)
+ assert.True(t, ok)
+ assert.Equal(t, "bob", props["userId"])
+ assert.ElementsMatch(t, []any{"x", "y"}, props["tags"].([]any))
+}
diff --git a/server/agui/agui.go b/server/agui/agui.go
new file mode 100644
index 000000000..c9e3e7a5b
--- /dev/null
+++ b/server/agui/agui.go
@@ -0,0 +1,56 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+// Package agui provides the ability to communicate with the front end through the AG-UI protocol.
+package agui
+
+import (
+ "errors"
+ "net/http"
+
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ irunner "trpc.group/trpc-go/trpc-agent-go/server/agui/internal/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/service"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/service/sse"
+)
+
+// DefaultNewService is the default function to create a new service.
+var DefaultNewService = sse.New
+
+// Server provides AG-UI server.
+type Server struct {
+ path string
+ runner runner.Runner
+ service service.Service
+ handler http.Handler
+}
+
+// New creates a AG-UI server instance.
+func New(runner runner.Runner, opt ...Option) (*Server, error) {
+ if runner == nil {
+ return nil, errors.New("agui: runner must not be nil")
+ }
+ opts := newOptions(opt...)
+ aguiService := opts.service
+ if aguiService == nil {
+ aguiRunner := irunner.New(runner, opts.aguiRunnerOptions...)
+ aguiService = DefaultNewService(aguiRunner, service.WithPath(opts.path))
+ }
+ return &Server{
+ path: opts.path,
+ runner: runner,
+ service: aguiService,
+ handler: aguiService.Handler(),
+ }, nil
+}
+
+// Handler returns the http.Handler serving AG-UI requests.
+func (s *Server) Handler() http.Handler {
+ return s.handler
+}
diff --git a/server/agui/agui_test.go b/server/agui/agui_test.go
new file mode 100644
index 000000000..e4eb663e2
--- /dev/null
+++ b/server/agui/agui_test.go
@@ -0,0 +1,172 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package agui
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "trpc.group/trpc-go/trpc-agent-go/agent"
+ "trpc.group/trpc-go/trpc-agent-go/event"
+ "trpc.group/trpc-go/trpc-agent-go/model"
+ "trpc.group/trpc-go/trpc-agent-go/runner"
+ irunner "trpc.group/trpc-go/trpc-agent-go/server/agui/internal/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/service"
+ "trpc.group/trpc-go/trpc-agent-go/tool"
+)
+
+func TestNewNilRunner(t *testing.T) {
+ srv, err := New(nil)
+ assert.Nil(t, srv)
+ assert.EqualError(t, err, "agui: runner must not be nil")
+}
+
+func TestNewWithProvidedService(t *testing.T) {
+ called := false
+ original := DefaultNewService
+ DefaultNewService = func(irunner.Runner, ...service.Option) service.Service {
+ called = true
+ return nil
+ }
+ t.Cleanup(func() { DefaultNewService = original })
+
+ handler := http.NewServeMux()
+ fakeSvc := &stubService{handler: handler}
+ r := &stubRunner{}
+
+ srv, err := New(r, WithService(fakeSvc), WithPath("/custom"))
+ assert.NoError(t, err)
+ assert.NotNil(t, srv)
+ assert.False(t, called)
+ assert.Same(t, fakeSvc, srv.service)
+ assert.Equal(t, "/custom", srv.path)
+ assert.Same(t, r, srv.runner)
+ assert.Same(t, handler, srv.Handler())
+}
+
+func TestNewCreatesDefaultService(t *testing.T) {
+ original := DefaultNewService
+ var capturedPath string
+ fakeHandler := http.NewServeMux()
+ DefaultNewService = func(_ irunner.Runner, opts ...service.Option) service.Service {
+ var svcOpts service.Options
+ for _, opt := range opts {
+ opt(&svcOpts)
+ }
+ capturedPath = svcOpts.Path
+ return &stubService{handler: fakeHandler}
+ }
+ t.Cleanup(func() { DefaultNewService = original })
+
+ r := &stubRunner{}
+ srv, err := New(r, WithPath("/agui/custom"))
+ assert.NoError(t, err)
+ assert.NotNil(t, srv)
+ assert.Equal(t, "/agui/custom", srv.path)
+ assert.Equal(t, "/agui/custom", capturedPath)
+ assert.Same(t, r, srv.runner)
+ assert.Same(t, fakeHandler, srv.Handler())
+}
+
+func TestEndToEndServerSendsSSEEvents(t *testing.T) {
+ agent := &mockAgent{info: agent.Info{Name: "demo"}}
+ r := runner.NewRunner(agent.Info().Name, agent)
+ srv, err := New(r, WithPath("/agui"))
+ assert.NoError(t, err)
+
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+
+ payload := `{"threadId":"thread-1","runId":"run-42","messages":[{"role":"user","content":"hi there"}]}`
+ req, err := http.NewRequest(http.MethodPost, ts.URL+"/agui", strings.NewReader(payload))
+ assert.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ assert.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ body, err := io.ReadAll(resp.Body)
+ assert.NoError(t, err)
+ bodyStr := string(body)
+
+ assert.Contains(t, bodyStr, `"type":"RUN_STARTED"`)
+ assert.Contains(t, bodyStr, `"type":"TEXT_MESSAGE_START"`)
+ assert.Contains(t, bodyStr, `"type":"TEXT_MESSAGE_CONTENT"`)
+ assert.Contains(t, bodyStr, `"type":"TEXT_MESSAGE_END"`)
+ assert.Contains(t, bodyStr, `"type":"RUN_FINISHED"`)
+
+ assert.Equal(t, 1, agent.runCalls)
+ assert.NotNil(t, agent.lastInvocation)
+ assert.Equal(t, "hi there", agent.lastInvocation.Message.Content)
+ assert.Equal(t, model.RoleUser, agent.lastInvocation.Message.Role)
+}
+
+type stubRunner struct{}
+
+func (r *stubRunner) Run(context.Context, string, string, model.Message, ...agent.RunOption) (<-chan *event.Event, error) {
+ ch := make(chan *event.Event)
+ close(ch)
+ return ch, nil
+}
+
+type stubService struct {
+ handler http.Handler
+}
+
+func (s *stubService) Handler() http.Handler { return s.handler }
+
+type mockAgent struct {
+ info agent.Info
+ runCalls int
+ lastInvocation *agent.Invocation
+}
+
+func (a *mockAgent) Run(ctx context.Context, invocation *agent.Invocation) (<-chan *event.Event, error) {
+ a.runCalls++
+ a.lastInvocation = invocation
+ ch := make(chan *event.Event, 2)
+ go func() {
+ defer close(ch)
+ chunk := &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletionChunk,
+ IsPartial: true,
+ Choices: []model.Choice{{
+ Delta: model.Message{Role: model.RoleAssistant, Content: "hello"},
+ }},
+ }
+ final := &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletion,
+ Done: true,
+ Choices: []model.Choice{{
+ Message: model.Message{Role: model.RoleAssistant},
+ }},
+ }
+ ch <- event.NewResponseEvent(invocation.InvocationID, invocation.AgentName, chunk)
+ ch <- event.NewResponseEvent(invocation.InvocationID, invocation.AgentName, final)
+ }()
+ return ch, nil
+}
+
+func (a *mockAgent) Tools() []tool.Tool { return nil }
+
+func (a *mockAgent) Info() agent.Info { return a.info }
+
+func (a *mockAgent) SubAgents() []agent.Agent { return nil }
+
+func (a *mockAgent) FindSubAgent(string) agent.Agent { return nil }
diff --git a/server/agui/go.mod b/server/agui/go.mod
new file mode 100644
index 000000000..e78dcbd29
--- /dev/null
+++ b/server/agui/go.mod
@@ -0,0 +1,41 @@
+module trpc.group/trpc-go/trpc-agent-go/server/agui
+
+go 1.24.4
+
+replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8
+
+require (
+ github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-00010101000000-000000000000
+ github.com/stretchr/testify v1.10.0
+ trpc.group/trpc-go/trpc-agent-go v0.2.0
+)
+
+require (
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ go.opentelemetry.io/otel v1.29.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 // indirect
+ go.opentelemetry.io/otel/metric v1.29.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.29.0 // indirect
+ go.opentelemetry.io/otel/trace v1.29.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.3.1 // indirect
+ go.uber.org/multierr v1.10.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ golang.org/x/net v0.34.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect
+ google.golang.org/grpc v1.65.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 // indirect
+)
diff --git a/server/agui/go.sum b/server/agui/go.sum
new file mode 100644
index 000000000..ab0b1bde2
--- /dev/null
+++ b/server/agui/go.sum
@@ -0,0 +1,79 @@
+github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8 h1:3ZcB9AXuWmx/hFCptPv/DcY+zmwkruRTJREXSQjBHhM=
+github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 h1:nSiV3s7wiCam610XcLbYOmMfJxB9gO4uK3Xgv5gmTgg=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0/go.mod h1:hKn/e/Nmd19/x1gvIHwtOwVWM+VhuITSWip3JUDghj0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 h1:JAv0Jwtl01UFiyWZEMiJZBiTlv5A50zNs8lsthXqIio=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0/go.mod h1:QNKLmUEAq2QUbPQUfvw4fmv0bgbK7UlOSFCnXyfvSNc=
+go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
+go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
+go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo=
+go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
+go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
+go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd h1:BBOTEWLuuEGQy9n1y9MhVJ9Qt0BDu21X8qZs71/uPZo=
+google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:fO8wJzT2zbQbAjbIoos1285VfEIYKDDY+Dt+WpTkh6g=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
+google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
+google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 h1:Als3R0+WZSm+bkDVkt5ATElgRixuGRY7iBSEJXBq2XM=
+trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333/go.mod h1:Gtytau9Uoc3oPo/dpHvKit+tQn9Qlk5XFG1RiZTGqfk=
+trpc.group/trpc-go/trpc-agent-go v0.2.0 h1:XDuv+s783DbFPhzM4vgM6yjPw6W76FfMlcy0a/9jR9U=
+trpc.group/trpc-go/trpc-agent-go v0.2.0/go.mod h1:8jAphCIcoi0LE7X3J4mxVQr29LEu+zT3oQr0xJ+hub4=
diff --git a/server/agui/internal/runner/runner.go b/server/agui/internal/runner/runner.go
new file mode 100644
index 000000000..835301dd0
--- /dev/null
+++ b/server/agui/internal/runner/runner.go
@@ -0,0 +1,99 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+// Package runner wraps a trpc-agent-go runner and translates it to AG-UI events.
+package runner
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ "trpc.group/trpc-go/trpc-agent-go/model"
+ trunner "trpc.group/trpc-go/trpc-agent-go/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+)
+
+// Runner executes AG-UI runs and emits AG-UI events.
+type Runner interface {
+ // Run starts processing one AG-UI run request and returns a channel of AG-UI events.
+ Run(ctx context.Context, runAgentInput *adapter.RunAgentInput) (<-chan aguievents.Event, error)
+}
+
+// New wraps a trpc-agent-go runner with AG-UI specific translation logic.
+func New(r trunner.Runner, opt ...aguirunner.Option) Runner {
+ opts := aguirunner.NewOptions(opt...)
+ run := &runner{
+ runner: r,
+ translatorFactory: opts.TranslatorFactory,
+ userIDResolver: opts.UserIDResolver,
+ }
+ return run
+}
+
+// runner is the default implementation of the Runner.
+type runner struct {
+ runner trunner.Runner
+ translatorFactory aguirunner.TranslatorFactory
+ userIDResolver aguirunner.UserIDResolver
+}
+
+// Run starts processing one AG-UI run request and returns a channel of AG-UI events.
+func (r *runner) Run(ctx context.Context, runAgentInput *adapter.RunAgentInput) (<-chan aguievents.Event, error) {
+ if r.runner == nil {
+ return nil, errors.New("agui: runner is nil")
+ }
+ if runAgentInput == nil {
+ return nil, errors.New("agui: run input cannot be nil")
+ }
+ events := make(chan aguievents.Event)
+ go r.run(ctx, runAgentInput, events)
+ return events, nil
+}
+
+func (r *runner) run(ctx context.Context, runAgentInput *adapter.RunAgentInput, events chan<- aguievents.Event) {
+ defer close(events)
+ translator := r.translatorFactory(runAgentInput)
+ events <- aguievents.NewRunStartedEvent(runAgentInput.ThreadID, runAgentInput.RunID)
+ if len(runAgentInput.Messages) == 0 {
+ events <- aguievents.NewRunErrorEvent("no messages provided", aguievents.WithRunID(runAgentInput.RunID))
+ return
+ }
+ userID, err := r.userIDResolver(ctx, runAgentInput)
+ if err != nil {
+ events <- aguievents.NewRunErrorEvent(fmt.Sprintf("resolve user ID: %v", err),
+ aguievents.WithRunID(runAgentInput.RunID))
+ return
+ }
+ userMessage := runAgentInput.Messages[len(runAgentInput.Messages)-1]
+ if userMessage.Role != model.RoleUser {
+ events <- aguievents.NewRunErrorEvent("last message is not a user message",
+ aguievents.WithRunID(runAgentInput.RunID))
+ return
+ }
+ ch, err := r.runner.Run(ctx, userID, runAgentInput.ThreadID, userMessage)
+ if err != nil {
+ events <- aguievents.NewRunErrorEvent(fmt.Sprintf("run agent: %v", err),
+ aguievents.WithRunID(runAgentInput.RunID))
+ return
+ }
+ for event := range ch {
+ aguiEvents, err := translator.Translate(event)
+ if err != nil {
+ events <- aguievents.NewRunErrorEvent(fmt.Sprintf("translate event: %v", err),
+ aguievents.WithRunID(runAgentInput.RunID))
+ return
+ }
+ for _, aguiEvent := range aguiEvents {
+ events <- aguiEvent
+ }
+ }
+}
diff --git a/server/agui/internal/runner/runner_test.go b/server/agui/internal/runner/runner_test.go
new file mode 100644
index 000000000..6e1714004
--- /dev/null
+++ b/server/agui/internal/runner/runner_test.go
@@ -0,0 +1,272 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package runner
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ "github.com/stretchr/testify/assert"
+ "trpc.group/trpc-go/trpc-agent-go/agent"
+ agentevent "trpc.group/trpc-go/trpc-agent-go/event"
+ "trpc.group/trpc-go/trpc-agent-go/model"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/translator"
+ aguitranslator "trpc.group/trpc-go/trpc-agent-go/server/agui/translator"
+)
+
+func TestNew(t *testing.T) {
+ r := New(nil)
+ assert.NotNil(t, r)
+ runner, ok := r.(*runner)
+ assert.True(t, ok)
+
+ trans := runner.translatorFactory(&adapter.RunAgentInput{ThreadID: "thread", RunID: "run"})
+ assert.NotNil(t, trans)
+ assert.IsType(t, translator.New("", ""), trans)
+
+ userID, err := runner.userIDResolver(context.Background(),
+ &adapter.RunAgentInput{ThreadID: "thread", RunID: "run"})
+ assert.NoError(t, err)
+ assert.Equal(t, "user", userID)
+}
+
+func TestRunValidatesInput(t *testing.T) {
+ r := &runner{}
+ ch, err := r.Run(context.Background(), nil)
+ assert.Nil(t, ch)
+ assert.Error(t, err)
+
+ r.runner = &fakeRunner{}
+ ch, err = r.Run(context.Background(), nil)
+ assert.Nil(t, ch)
+ assert.Error(t, err)
+}
+
+func TestRunNoMessages(t *testing.T) {
+ underlying := &fakeRunner{}
+ fakeTrans := &fakeTranslator{}
+ r := &runner{
+ runner: underlying,
+ translatorFactory: func(*adapter.RunAgentInput) aguitranslator.Translator { return fakeTrans },
+ userIDResolver: aguirunner.NewOptions().UserIDResolver,
+ }
+
+ input := &adapter.RunAgentInput{ThreadID: "thread", RunID: "run"}
+ eventsCh, err := r.Run(context.Background(), input)
+ assert.NoError(t, err)
+
+ evts := collectEvents(t, eventsCh)
+ assert.Len(t, evts, 2)
+ assert.IsType(t, (*aguievents.RunStartedEvent)(nil), evts[0])
+ _, ok := evts[1].(*aguievents.RunErrorEvent)
+ assert.True(t, ok)
+ assert.Equal(t, 0, underlying.calls)
+}
+
+func TestRunUserIDResolverError(t *testing.T) {
+ underlying := &fakeRunner{}
+ fakeTrans := &fakeTranslator{}
+ r := &runner{
+ runner: underlying,
+ translatorFactory: func(*adapter.RunAgentInput) aguitranslator.Translator { return fakeTrans },
+ userIDResolver: func(context.Context, *adapter.RunAgentInput) (string, error) {
+ return "", errors.New("boom")
+ },
+ }
+
+ input := &adapter.RunAgentInput{
+ ThreadID: "thread",
+ RunID: "run",
+ Messages: []model.Message{{Role: model.RoleUser, Content: "hi"}},
+ }
+ eventsCh, err := r.Run(context.Background(), input)
+ assert.NoError(t, err)
+ evts := collectEvents(t, eventsCh)
+ assert.Len(t, evts, 2)
+ _, ok := evts[1].(*aguievents.RunErrorEvent)
+ assert.True(t, ok)
+ assert.Equal(t, 0, underlying.calls)
+}
+
+func TestRunLastMessageNotUser(t *testing.T) {
+ underlying := &fakeRunner{}
+ fakeTrans := &fakeTranslator{}
+ r := &runner{
+ runner: underlying,
+ translatorFactory: func(*adapter.RunAgentInput) aguitranslator.Translator { return fakeTrans },
+ userIDResolver: aguirunner.NewOptions().UserIDResolver,
+ }
+
+ input := &adapter.RunAgentInput{
+ ThreadID: "thread",
+ RunID: "run",
+ Messages: []model.Message{{Role: model.RoleAssistant, Content: "bot"}},
+ }
+ eventsCh, err := r.Run(context.Background(), input)
+ assert.NoError(t, err)
+
+ evts := collectEvents(t, eventsCh)
+ assert.Len(t, evts, 2)
+ _, ok := evts[1].(*aguievents.RunErrorEvent)
+ assert.True(t, ok)
+ assert.Equal(t, 0, underlying.calls)
+}
+
+func TestRunUnderlyingRunnerError(t *testing.T) {
+ underlying := &fakeRunner{}
+ underlying.run = func(ctx context.Context, userID, sessionID string, message model.Message,
+ _ ...agent.RunOption) (<-chan *agentevent.Event, error) {
+ return nil, errors.New("fail")
+ }
+ fakeTrans := &fakeTranslator{}
+ r := &runner{
+ runner: underlying,
+ translatorFactory: func(*adapter.RunAgentInput) aguitranslator.Translator { return fakeTrans },
+ userIDResolver: aguirunner.NewOptions().UserIDResolver,
+ }
+
+ input := &adapter.RunAgentInput{
+ ThreadID: "thread",
+ RunID: "run",
+ Messages: []model.Message{{Role: model.RoleUser, Content: "hi"}},
+ }
+ eventsCh, err := r.Run(context.Background(), input)
+ assert.NoError(t, err)
+ evts := collectEvents(t, eventsCh)
+ assert.Len(t, evts, 2)
+ _, ok := evts[1].(*aguievents.RunErrorEvent)
+ assert.True(t, ok)
+ assert.Equal(t, 1, underlying.calls)
+}
+
+func TestRunTranslateError(t *testing.T) {
+ fakeTrans := &fakeTranslator{err: errors.New("bad event")}
+ eventsCh := make(chan *agentevent.Event, 1)
+ eventsCh <- &agentevent.Event{}
+ close(eventsCh)
+
+ underlying := &fakeRunner{}
+ underlying.run = func(ctx context.Context, userID, sessionID string, message model.Message, _ ...agent.RunOption) (<-chan *agentevent.Event, error) {
+ return eventsCh, nil
+ }
+
+ r := &runner{
+ runner: underlying,
+ translatorFactory: func(*adapter.RunAgentInput) aguitranslator.Translator {
+ return fakeTrans
+ },
+ userIDResolver: aguirunner.NewOptions().UserIDResolver,
+ }
+ input := &adapter.RunAgentInput{
+ ThreadID: "thread",
+ RunID: "run",
+ Messages: []model.Message{{Role: model.RoleUser, Content: "hi"}},
+ }
+ aguiCh, err := r.Run(context.Background(), input)
+ assert.NoError(t, err)
+ evts := collectEvents(t, aguiCh)
+ assert.Len(t, evts, 2)
+ _, ok := evts[1].(*aguievents.RunErrorEvent)
+ assert.True(t, ok)
+}
+
+func TestRunNormal(t *testing.T) {
+ fakeTrans := &fakeTranslator{events: [][]aguievents.Event{
+ {aguievents.NewTextMessageStartEvent("msg-1")},
+ {aguievents.NewTextMessageEndEvent("msg-1"), aguievents.NewRunFinishedEvent("thread", "run")},
+ }}
+
+ underlying := &fakeRunner{}
+ underlying.run = func(ctx context.Context, userID, sessionID string, message model.Message, _ ...agent.RunOption) (<-chan *agentevent.Event, error) {
+ assert.Equal(t, "user-123", userID)
+ assert.Equal(t, "thread", sessionID)
+ ch := make(chan *agentevent.Event, 2)
+ ch <- &agentevent.Event{}
+ ch <- &agentevent.Event{}
+ close(ch)
+ return ch, nil
+ }
+ r := &runner{
+ runner: underlying,
+ translatorFactory: func(*adapter.RunAgentInput) aguitranslator.Translator { return fakeTrans },
+ userIDResolver: func(context.Context, *adapter.RunAgentInput) (string, error) {
+ return "user-123", nil
+ },
+ }
+
+ input := &adapter.RunAgentInput{
+ ThreadID: "thread",
+ RunID: "run",
+ Messages: []model.Message{{Role: model.RoleUser, Content: "hi"}},
+ }
+
+ aguiCh, err := r.Run(context.Background(), input)
+ if !assert.NoError(t, err) {
+ return
+ }
+ evts := collectEvents(t, aguiCh)
+ assert.Len(t, evts, 4)
+ assert.IsType(t, (*aguievents.RunStartedEvent)(nil), evts[0])
+ assert.IsType(t, (*aguievents.TextMessageStartEvent)(nil), evts[1])
+ assert.IsType(t, (*aguievents.TextMessageEndEvent)(nil), evts[2])
+ assert.IsType(t, (*aguievents.RunFinishedEvent)(nil), evts[3])
+ assert.Equal(t, 1, underlying.calls)
+}
+
+type fakeTranslator struct {
+ events [][]aguievents.Event
+ err error
+}
+
+func (f *fakeTranslator) Translate(evt *agentevent.Event) ([]aguievents.Event, error) {
+ if f.err != nil {
+ return nil, f.err
+ }
+ if len(f.events) == 0 {
+ return nil, nil
+ }
+ out := f.events[0]
+ f.events = f.events[1:]
+ return out, nil
+}
+
+type fakeRunner struct {
+ run func(ctx context.Context, userID, sessionID string, message model.Message, opts ...agent.RunOption) (<-chan *agentevent.Event, error)
+ calls int
+}
+
+func (f *fakeRunner) Run(ctx context.Context, userID, sessionID string, message model.Message, opts ...agent.RunOption) (<-chan *agentevent.Event, error) {
+ f.calls++
+ if f.run != nil {
+ return f.run(ctx, userID, sessionID, message, opts...)
+ }
+ return nil, nil
+}
+
+func collectEvents(t *testing.T, ch <-chan aguievents.Event) []aguievents.Event {
+ t.Helper()
+ var out []aguievents.Event
+ for {
+ select {
+ case evt, ok := <-ch:
+ if !ok {
+ return out
+ }
+ out = append(out, evt)
+ case <-time.After(time.Second):
+ t.Fatalf("timeout collecting events")
+ }
+ }
+}
diff --git a/server/agui/options.go b/server/agui/options.go
new file mode 100644
index 000000000..987b51af9
--- /dev/null
+++ b/server/agui/options.go
@@ -0,0 +1,55 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package agui
+
+import (
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/service"
+)
+
+// options holds the options for the AG-UI server.
+type options struct {
+ path string
+ service service.Service
+ aguiRunnerOptions []aguirunner.Option
+}
+
+// newOptions creates a new options instance.
+func newOptions(opt ...Option) *options {
+ opts := &options{}
+ for _, o := range opt {
+ o(opts)
+ }
+ return opts
+}
+
+// Option is a function that configures the options.
+type Option func(*options)
+
+// WithPath sets the path for service listening.
+func WithPath(path string) Option {
+ return func(o *options) {
+ o.path = path
+ }
+}
+
+// WithService sets the service.
+func WithService(s service.Service) Option {
+ return func(o *options) {
+ o.service = s
+ }
+}
+
+// WithAGUIRunnerOptions sets the AG-UI runner options.
+func WithAGUIRunnerOptions(aguiRunnerOpts ...aguirunner.Option) Option {
+ return func(o *options) {
+ o.aguiRunnerOptions = append(o.aguiRunnerOptions, aguiRunnerOpts...)
+ }
+}
diff --git a/server/agui/options_test.go b/server/agui/options_test.go
new file mode 100644
index 000000000..0c69ddc20
--- /dev/null
+++ b/server/agui/options_test.go
@@ -0,0 +1,54 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package agui
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner"
+)
+
+func TestNewOptionsDefaults(t *testing.T) {
+ opts := newOptions()
+ assert.Equal(t, "", opts.path)
+ assert.Nil(t, opts.service)
+ assert.Empty(t, opts.aguiRunnerOptions)
+}
+
+func TestOptionMutators(t *testing.T) {
+ handler := http.NewServeMux()
+ svc := &stubService{handler: handler}
+ var aguiOpt aguirunner.Option
+
+ opts := newOptions(
+ WithPath("/custom"),
+ WithService(svc),
+ WithAGUIRunnerOptions(aguiOpt),
+ )
+
+ assert.Equal(t, "/custom", opts.path)
+ assert.Same(t, svc, opts.service)
+ assert.Equal(t, []aguirunner.Option{aguiOpt}, opts.aguiRunnerOptions)
+}
+
+func TestOptionAppends(t *testing.T) {
+ var (
+ aguiOpt1 aguirunner.Option
+ aguiOpt2 aguirunner.Option
+ )
+ opts := newOptions()
+
+ WithAGUIRunnerOptions(aguiOpt1)(opts)
+ WithAGUIRunnerOptions(aguiOpt2)(opts)
+
+ assert.Equal(t, []aguirunner.Option{aguiOpt1, aguiOpt2}, opts.aguiRunnerOptions)
+}
diff --git a/server/agui/runner/options.go b/server/agui/runner/options.go
new file mode 100644
index 000000000..18502abfb
--- /dev/null
+++ b/server/agui/runner/options.go
@@ -0,0 +1,68 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package runner
+
+import (
+ "context"
+
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/translator"
+)
+
+// Options holds the options for the runner.
+type Options struct {
+ TranslatorFactory TranslatorFactory
+ UserIDResolver UserIDResolver
+}
+
+// NewOptions creates a new options instance.
+func NewOptions(opt ...Option) *Options {
+ opts := &Options{
+ UserIDResolver: defaultUserIDResolver,
+ TranslatorFactory: defaultTranslatorFactory,
+ }
+ for _, o := range opt {
+ o(opts)
+ }
+ return opts
+}
+
+// Option is a function that configures the options.
+type Option func(*Options)
+
+// UserIDResolver is a function that derives the user identifier for an AG-UI run.
+type UserIDResolver func(ctx context.Context, input *adapter.RunAgentInput) (string, error)
+
+// WithUserIDResolver sets the user ID resolver.
+func WithUserIDResolver(u UserIDResolver) Option {
+ return func(o *Options) {
+ o.UserIDResolver = u
+ }
+}
+
+// TranslatorFactory is a function that creates a translator for an AG-UI run.
+type TranslatorFactory func(input *adapter.RunAgentInput) translator.Translator
+
+// WithTranslatorFactory sets the translator factory.
+func WithTranslatorFactory(factory TranslatorFactory) Option {
+ return func(o *Options) {
+ o.TranslatorFactory = factory
+ }
+}
+
+// defaultUserIDResolver is the default user ID resolver.
+func defaultUserIDResolver(ctx context.Context, input *adapter.RunAgentInput) (string, error) {
+ return "user", nil
+}
+
+// defaultTranslatorFactory is the default translator factory.
+func defaultTranslatorFactory(input *adapter.RunAgentInput) translator.Translator {
+ return translator.New(input.ThreadID, input.RunID)
+}
diff --git a/server/agui/runner/options_test.go b/server/agui/runner/options_test.go
new file mode 100644
index 000000000..76cc750bc
--- /dev/null
+++ b/server/agui/runner/options_test.go
@@ -0,0 +1,66 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package runner
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/translator"
+)
+
+func TestNewOptionsDefaults(t *testing.T) {
+ opts := NewOptions()
+
+ assert.NotNil(t, opts.UserIDResolver)
+ userID, err := opts.UserIDResolver(context.Background(), &adapter.RunAgentInput{})
+ assert.NoError(t, err)
+ assert.Equal(t, "user", userID)
+
+ assert.NotNil(t, opts.TranslatorFactory)
+ input := &adapter.RunAgentInput{ThreadID: "thread-1", RunID: "run-1"}
+ tr := opts.TranslatorFactory(input)
+ assert.NotNil(t, tr)
+ assert.IsType(t, translator.New("", ""), tr)
+}
+
+func TestWithUserIDResolver(t *testing.T) {
+ wantErr := errors.New("resolver error")
+ called := false
+ customResolver := func(ctx context.Context, input *adapter.RunAgentInput) (string, error) {
+ called = true
+ return "custom", wantErr
+ }
+
+ opts := NewOptions(WithUserIDResolver(customResolver))
+
+ userID, err := opts.UserIDResolver(context.Background(), &adapter.RunAgentInput{})
+ assert.Equal(t, wantErr, err)
+ assert.Equal(t, "custom", userID)
+ assert.True(t, called)
+}
+
+func TestWithTranslatorFactory(t *testing.T) {
+ customTranslator := translator.New("custom-thread", "custom-run")
+ factoryCalled := false
+ opts := NewOptions(WithTranslatorFactory(func(input *adapter.RunAgentInput) translator.Translator {
+ factoryCalled = true
+ return customTranslator
+ }))
+
+ input := &adapter.RunAgentInput{ThreadID: "thread", RunID: "run"}
+ tr := opts.TranslatorFactory(input)
+
+ assert.True(t, factoryCalled)
+ assert.Equal(t, customTranslator, tr)
+}
diff --git a/server/agui/service/options.go b/server/agui/service/options.go
new file mode 100644
index 000000000..1457235fe
--- /dev/null
+++ b/server/agui/service/options.go
@@ -0,0 +1,40 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package service
+
+// defaultPath is the default path for the AG-UI service.
+const defaultPath = "/"
+
+// Options holds the options for an AG-UI transport implementation.
+type Options struct {
+ Path string // Path is the request URL path served by the handler.
+}
+
+// NewOptions creates a new options instance.
+func NewOptions(opt ...Option) *Options {
+ opts := &Options{}
+ for _, o := range opt {
+ o(opts)
+ }
+ if opts.Path == "" {
+ opts.Path = defaultPath
+ }
+ return opts
+}
+
+// Option is a function that configures the options.
+type Option func(*Options)
+
+// WithPath sets the request path.
+func WithPath(path string) Option {
+ return func(s *Options) {
+ s.Path = path
+ }
+}
diff --git a/server/agui/service/options_test.go b/server/agui/service/options_test.go
new file mode 100644
index 000000000..fc7a8dad4
--- /dev/null
+++ b/server/agui/service/options_test.go
@@ -0,0 +1,26 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package service
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWithPath(t *testing.T) {
+ // Test default path
+ opts := NewOptions()
+ assert.Equal(t, opts.Path, "/")
+
+ // Test with path
+ opts = NewOptions(WithPath("/sse"))
+ assert.Equal(t, opts.Path, "/sse")
+}
diff --git a/server/agui/service/service.go b/server/agui/service/service.go
new file mode 100644
index 000000000..c30f91695
--- /dev/null
+++ b/server/agui/service/service.go
@@ -0,0 +1,20 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+// Package service defines Service interface for AG-UI services.
+package service
+
+import "net/http"
+
+// Service represents the AG-UI service implementation.
+// Different transports (SSE, WebSocket, etc.) can return their own http.Handler,
+// which can be mounted to an existing HTTP router.
+type Service interface {
+ Handler() http.Handler
+}
diff --git a/server/agui/service/sse/sse.go b/server/agui/service/sse/sse.go
new file mode 100644
index 000000000..55dcdc9e0
--- /dev/null
+++ b/server/agui/service/sse/sse.go
@@ -0,0 +1,100 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+// Package sse provides SSE service implementation.
+package sse
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+
+ aguisse "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ runner "trpc.group/trpc-go/trpc-agent-go/server/agui/internal/runner"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/service"
+)
+
+// sse is a SSE service implementation.
+type sse struct {
+ path string
+ writer *aguisse.SSEWriter
+ runner runner.Runner
+ handler http.Handler
+}
+
+// New creates a new SSE service.
+func New(runner runner.Runner, opt ...service.Option) service.Service {
+ opts := service.NewOptions(opt...)
+ s := &sse{
+ path: opts.Path,
+ runner: runner,
+ writer: aguisse.NewSSEWriter(),
+ }
+ h := http.NewServeMux()
+ h.HandleFunc(s.path, s.handle)
+ s.handler = h
+ return s
+}
+
+// Handler returns an http.Handler that exposes the AG-UI SSE endpoint.
+func (s *sse) Handler() http.Handler {
+ return s.handler
+}
+
+// handle handles an AG-UI run request.
+func (s *sse) handle(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodOptions {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
+ if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
+ w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
+ }
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if r.Method != http.MethodPost {
+ w.Header().Set("Allow", http.MethodPost)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if s.runner == nil {
+ http.Error(w, "runner not configured", http.StatusInternalServerError)
+ return
+ }
+ runAgentInput, err := runAgentInputFromReader(r.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ eventsCh, err := s.runner.Run(r.Context(), runAgentInput)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ for event := range eventsCh {
+ if err := s.writer.WriteEvent(r.Context(), w, event); err != nil {
+ return
+ }
+ }
+}
+
+// runAgentInputFromReader parses an AG-UI run request payload from a reader.
+func runAgentInputFromReader(r io.Reader) (*adapter.RunAgentInput, error) {
+ var input adapter.RunAgentInput
+ dec := json.NewDecoder(r)
+ if err := dec.Decode(&input); err != nil {
+ return nil, err
+ }
+ return &input, nil
+}
diff --git a/server/agui/service/sse/sse_test.go b/server/agui/service/sse/sse_test.go
new file mode 100644
index 000000000..ab08b9c58
--- /dev/null
+++ b/server/agui/service/sse/sse_test.go
@@ -0,0 +1,217 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package sse
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ aguisse "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse"
+ "github.com/stretchr/testify/assert"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter"
+ "trpc.group/trpc-go/trpc-agent-go/server/agui/service"
+)
+
+func TestHandleRunnerNotConfigured(t *testing.T) {
+ srv := &sse{}
+ req := httptest.NewRequest(http.MethodPost, "/agui", strings.NewReader("{}"))
+ rr := httptest.NewRecorder()
+
+ srv.handle(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusInternalServerError, res.StatusCode)
+ assert.Contains(t, rr.Body.String(), "runner not configured")
+}
+
+func TestHandleInvalidJSON(t *testing.T) {
+ runner := &stubRunner{}
+ srv := &sse{runner: runner, writer: aguisse.NewSSEWriter()}
+ req := httptest.NewRequest(http.MethodPost, "/agui", strings.NewReader("{invalid"))
+ rr := httptest.NewRecorder()
+
+ srv.handle(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusBadRequest, res.StatusCode)
+ assert.Equal(t, 0, runner.calls)
+}
+
+func TestHandleRunnerError(t *testing.T) {
+ runner := &stubRunner{
+ runFn: func(ctx context.Context, input *adapter.RunAgentInput) (<-chan aguievents.Event, error) {
+ return nil, errors.New("boom")
+ },
+ }
+ srv := &sse{runner: runner, writer: aguisse.NewSSEWriter()}
+ payload := `{"threadId":"thread","runId":"run","messages":[{"role":"user","content":"hi"}]}`
+ req := httptest.NewRequest(http.MethodPost, "/agui", strings.NewReader(payload))
+ rr := httptest.NewRecorder()
+
+ srv.handle(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusInternalServerError, res.StatusCode)
+ assert.Equal(t, 1, runner.calls)
+}
+
+func TestHandleSuccess(t *testing.T) {
+ eventsCh := make(chan aguievents.Event)
+ go func() {
+ defer close(eventsCh)
+ eventsCh <- aguievents.NewRunStartedEvent("thread", "run")
+ eventsCh <- aguievents.NewTextMessageStartEvent("msg-1", aguievents.WithRole("assistant"))
+ }()
+
+ runner := &stubRunner{
+ runFn: func(ctx context.Context, input *adapter.RunAgentInput) (<-chan aguievents.Event, error) {
+ assert.Equal(t, "thread", input.ThreadID)
+ assert.Equal(t, "run", input.RunID)
+ return eventsCh, nil
+ },
+ }
+
+ srv := &sse{runner: runner, writer: aguisse.NewSSEWriter()}
+ payload := `{"threadId":"thread","runId":"run","messages":[{"role":"user","content":"hi"}]}`
+ req := httptest.NewRequest(http.MethodPost, "/agui", strings.NewReader(payload))
+ rr := httptest.NewRecorder()
+
+ srv.handle(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+ assert.Equal(t, "text/event-stream", res.Header.Get("Content-Type"))
+ assert.Equal(t, "no-cache", res.Header.Get("Cache-Control"))
+ assert.Equal(t, "keep-alive", res.Header.Get("Connection"))
+ assert.Equal(t, "*", res.Header.Get("Access-Control-Allow-Origin"))
+ body := rr.Body.String()
+ assert.Contains(t, body, `"type":"RUN_STARTED"`)
+ assert.Contains(t, body, `"type":"TEXT_MESSAGE_START"`)
+ assert.Equal(t, 1, runner.calls)
+}
+
+func TestHandleMethodNotAllowed(t *testing.T) {
+ runner := &stubRunner{}
+ srv := &sse{runner: runner, writer: aguisse.NewSSEWriter()}
+ req := httptest.NewRequest(http.MethodGet, "/agui", nil)
+ rr := httptest.NewRecorder()
+
+ srv.handle(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusMethodNotAllowed, res.StatusCode)
+ assert.Equal(t, http.MethodPost, res.Header.Get("Allow"))
+ assert.Equal(t, 0, runner.calls)
+}
+
+func TestHandlerDispatchesToConfiguredPath(t *testing.T) {
+ eventsCh := make(chan aguievents.Event)
+ go func() {
+ defer close(eventsCh)
+ }()
+
+ runner := &stubRunner{
+ runFn: func(ctx context.Context, input *adapter.RunAgentInput) (<-chan aguievents.Event, error) {
+ return eventsCh, nil
+ },
+ }
+
+ svc := New(runner, service.WithPath("/custom"))
+ handler := svc.Handler()
+ assert.NotNil(t, handler)
+
+ payload := `{"threadId":"thread","runId":"run","messages":[{"role":"user","content":"hi"}]}`
+ req := httptest.NewRequest(http.MethodPost, "/custom", strings.NewReader(payload))
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+ assert.Equal(t, 1, runner.calls)
+}
+
+func TestNewUsesDefaultPath(t *testing.T) {
+ eventsCh := make(chan aguievents.Event)
+ go func() {
+ close(eventsCh)
+ }()
+
+ runner := &stubRunner{
+ runFn: func(ctx context.Context, input *adapter.RunAgentInput) (<-chan aguievents.Event, error) {
+ return eventsCh, nil
+ },
+ }
+
+ svc := New(runner)
+ handler := svc.Handler()
+ assert.NotNil(t, handler)
+
+ payload := `{"threadId":"thread","runId":"run","messages":[{"role":"user","content":"hi"}]}`
+ req := httptest.NewRequest(http.MethodPost, "/agui", strings.NewReader(payload))
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+ assert.Equal(t, 1, runner.calls)
+}
+
+func TestHandleCORSPreflight(t *testing.T) {
+ srv := &sse{writer: aguisse.NewSSEWriter()}
+ req := httptest.NewRequest(http.MethodOptions, "/agui", nil)
+ req.Header.Set("Access-Control-Request-Headers", "Content-Type, Authorization")
+ rr := httptest.NewRecorder()
+
+ srv.handle(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ assert.Equal(t, http.StatusNoContent, res.StatusCode)
+ assert.Equal(t, "*", res.Header.Get("Access-Control-Allow-Origin"))
+ assert.Equal(t, "POST", res.Header.Get("Access-Control-Allow-Methods"))
+ assert.Equal(t, "Content-Type, Authorization", res.Header.Get("Access-Control-Allow-Headers"))
+}
+
+type stubRunner struct {
+ runFn func(ctx context.Context, input *adapter.RunAgentInput) (<-chan aguievents.Event, error)
+ calls int
+ lastInput *adapter.RunAgentInput
+}
+
+func (s *stubRunner) Run(ctx context.Context, input *adapter.RunAgentInput) (<-chan aguievents.Event, error) {
+ s.calls++
+ s.lastInput = input
+ if s.runFn != nil {
+ return s.runFn(ctx, input)
+ }
+ return nil, nil
+}
diff --git a/server/agui/translator/translator.go b/server/agui/translator/translator.go
new file mode 100644
index 000000000..32a80d8ca
--- /dev/null
+++ b/server/agui/translator/translator.go
@@ -0,0 +1,165 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+// Package translator translates trpc-agent-go events to AG-UI events.
+package translator
+
+import (
+ "errors"
+
+ aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ agentevent "trpc.group/trpc-go/trpc-agent-go/event"
+ "trpc.group/trpc-go/trpc-agent-go/model"
+)
+
+// Translator translates trpc-agent-go events to AG-UI events.
+type Translator interface {
+ // Translate translates a trpc-agent-go event to AG-UI events.
+ Translate(event *agentevent.Event) ([]aguievents.Event, error)
+}
+
+// New creates a new event translator.
+func New(threadID, runID string) Translator {
+ return &translator{
+ threadID: threadID,
+ runID: runID,
+ }
+}
+
+// translator is the default implementation of the Translator.
+type translator struct {
+ threadID string
+ runID string
+ lastMessageID string
+}
+
+// Translate translates one trpc-agent-go event into zero or more AG-UI events.
+func (t *translator) Translate(event *agentevent.Event) ([]aguievents.Event, error) {
+ if event == nil || event.Response == nil {
+ return nil, errors.New("event is nil")
+ }
+ rsp := event.Response
+ if rsp.Error != nil {
+ return []aguievents.Event{aguievents.NewRunErrorEvent(rsp.Error.Message, aguievents.WithRunID(t.runID))}, nil
+ }
+ events := []aguievents.Event{}
+ if rsp.Object == model.ObjectTypeChatCompletionChunk || rsp.Object == model.ObjectTypeChatCompletion {
+ textMessageEvents, err := t.textMessageEvent(rsp)
+ if err != nil {
+ return nil, err
+ }
+ events = append(events, textMessageEvents...)
+ }
+ if rsp.IsToolCallResponse() {
+ toolCallEvents, err := t.toolCallEvent(rsp)
+ if err != nil {
+ return nil, err
+ }
+ events = append(events, toolCallEvents...)
+ }
+ if rsp.IsToolResultResponse() {
+ toolResultEvents, err := t.toolResultEvent(rsp)
+ if err != nil {
+ return nil, err
+ }
+ events = append(events, toolResultEvents...)
+ }
+ if rsp.IsFinalResponse() {
+ events = append(events, aguievents.NewRunFinishedEvent(t.threadID, t.runID))
+ }
+ return events, nil
+}
+
+// textMessageEvent translates a text message trpc-agent-go event to AG-UI events.
+func (t *translator) textMessageEvent(rsp *model.Response) ([]aguievents.Event, error) {
+ if rsp == nil || len(rsp.Choices) == 0 {
+ return nil, nil
+ }
+ var events []aguievents.Event
+ // Different message ID means a new message.
+ if t.lastMessageID != rsp.ID {
+ t.lastMessageID = rsp.ID
+ switch rsp.Object {
+ case model.ObjectTypeChatCompletionChunk:
+ role := rsp.Choices[0].Delta.Role.String()
+ events = append(events, aguievents.NewTextMessageStartEvent(rsp.ID, aguievents.WithRole(role)))
+ case model.ObjectTypeChatCompletion:
+ if rsp.Choices[0].Message.Content == "" {
+ return nil, nil
+ }
+ role := rsp.Choices[0].Message.Role.String()
+ events = append(events,
+ aguievents.NewTextMessageStartEvent(rsp.ID, aguievents.WithRole(role)),
+ aguievents.NewTextMessageContentEvent(rsp.ID, rsp.Choices[0].Message.Content),
+ aguievents.NewTextMessageEndEvent(rsp.ID),
+ )
+ return events, nil
+ default:
+ return nil, errors.New("invalid response object")
+ }
+ }
+ // Streaming response.
+ switch rsp.Object {
+ // Streaming chunk.
+ case model.ObjectTypeChatCompletionChunk:
+ if rsp.Choices[0].Delta.Content != "" {
+ events = append(events, aguievents.NewTextMessageContentEvent(rsp.ID, rsp.Choices[0].Delta.Content))
+ }
+ // For streaming response, don't need to emit final completion event.
+ // It means the response is ended.
+ case model.ObjectTypeChatCompletion:
+ events = append(events, aguievents.NewTextMessageEndEvent(rsp.ID))
+ default:
+ return nil, errors.New("invalid response object")
+ }
+ return events, nil
+}
+
+// toolCallEvent translates a tool call trpc-agent-go event to AG-UI events.
+func (t *translator) toolCallEvent(rsp *model.Response) ([]aguievents.Event, error) {
+ var events []aguievents.Event
+ if rsp == nil || len(rsp.Choices) == 0 {
+ return events, nil
+ }
+ for _, choice := range rsp.Choices {
+ for _, toolCall := range choice.Message.ToolCalls {
+ // Tool Call Start Event.
+ startOpt := []aguievents.ToolCallStartOption{aguievents.WithParentMessageID(rsp.ID)}
+ toolCallStartEvent := aguievents.NewToolCallStartEvent(toolCall.ID, toolCall.Function.Name, startOpt...)
+ events = append(events, toolCallStartEvent)
+ // Tool Call Arguments Event.
+ toolCallArguments := formatToolCallArguments(toolCall.Function.Arguments)
+ if toolCallArguments != "" {
+ events = append(events, aguievents.NewToolCallArgsEvent(toolCall.ID, toolCallArguments))
+ }
+ }
+ }
+ t.lastMessageID = rsp.ID
+ return events, nil
+}
+
+// toolResultEvent translates a tool result trpc-agent-go event to AG-UI events.
+func (t *translator) toolResultEvent(rsp *model.Response) ([]aguievents.Event, error) {
+ var events []aguievents.Event
+ choice := rsp.Choices[0]
+ // Tool call end event.
+ events = append(events, aguievents.NewToolCallEndEvent(choice.Message.ToolID))
+ // Tool call result event.
+ events = append(events, aguievents.NewToolCallResultEvent(t.lastMessageID,
+ choice.Message.ToolID, choice.Message.Content))
+ return events, nil
+}
+
+// formatToolCallArguments formats a tool call arguments event to a string.
+func formatToolCallArguments(arguments []byte) string {
+ if len(arguments) == 0 {
+ return ""
+ }
+ return string(arguments)
+}
diff --git a/server/agui/translator/translator_test.go b/server/agui/translator/translator_test.go
new file mode 100644
index 000000000..3c266f0ee
--- /dev/null
+++ b/server/agui/translator/translator_test.go
@@ -0,0 +1,362 @@
+//
+// Tencent is pleased to support the open source community by making trpc-agent-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-agent-go is licensed under the Apache License Version 2.0.
+//
+//
+
+package translator
+
+import (
+ "testing"
+
+ aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events"
+ "github.com/stretchr/testify/assert"
+ agentevent "trpc.group/trpc-go/trpc-agent-go/event"
+ "trpc.group/trpc-go/trpc-agent-go/model"
+)
+
+func TestTranslateNilEvent(t *testing.T) {
+ translator := New("thread", "run")
+
+ _, err := translator.Translate(nil)
+ assert.Error(t, err)
+
+ _, err = translator.Translate(&agentevent.Event{})
+ assert.Error(t, err)
+}
+
+func TestTranslateErrorResponse(t *testing.T) {
+ translator := New("thread", "run")
+ rsp := &model.Response{Error: &model.ResponseError{Message: "boom"}}
+
+ events, err := translator.Translate(&agentevent.Event{Response: rsp})
+ assert.NoError(t, err)
+ assert.Len(t, events, 1)
+ runErr, ok := events[0].(*aguievents.RunErrorEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "boom", runErr.Message)
+ assert.Equal(t, "run", runErr.RunID())
+}
+
+func TestTextMessageEventStreamingAndCompletion(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+
+ firstChunk := &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletionChunk,
+ Choices: []model.Choice{{
+ Delta: model.Message{Role: model.RoleAssistant, Content: "Hello"},
+ }},
+ }
+ chunkEvents, err := translator.textMessageEvent(firstChunk)
+ assert.NoError(t, err)
+ assert.Len(t, chunkEvents, 2)
+ start, ok := chunkEvents[0].(*aguievents.TextMessageStartEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-1", start.MessageID)
+
+ completionRsp := &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletion,
+ Choices: []model.Choice{{
+ Message: model.Message{Role: model.RoleAssistant, Content: "Hello"},
+ }},
+ }
+ completionEvents, err := translator.textMessageEvent(completionRsp)
+ assert.NoError(t, err)
+ assert.Len(t, completionEvents, 1)
+ end, ok := completionEvents[0].(*aguievents.TextMessageEndEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-1", end.MessageID)
+}
+
+func TestTextMessageEventNonStream(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+
+ nonStreamRsp := &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletion,
+ Choices: []model.Choice{{
+ Message: model.Message{Role: model.RoleAssistant, Content: "Hello"},
+ }},
+ }
+
+ completionEvents, err := translator.textMessageEvent(nonStreamRsp)
+ assert.NoError(t, err)
+ assert.Len(t, completionEvents, 3)
+
+ start, ok := completionEvents[0].(*aguievents.TextMessageStartEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-1", start.MessageID)
+
+ content, ok := completionEvents[1].(*aguievents.TextMessageContentEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-1", content.MessageID)
+ assert.Equal(t, "Hello", content.Delta)
+
+ end, ok := completionEvents[2].(*aguievents.TextMessageEndEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-1", end.MessageID)
+}
+
+func TestTextMessageEventEmptyChatCompletionContent(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+ rsp := &model.Response{
+ ID: "final-empty",
+ Object: model.ObjectTypeChatCompletion,
+ Choices: []model.Choice{{Message: model.Message{Role: model.RoleAssistant}}},
+ }
+
+ events, err := translator.textMessageEvent(rsp)
+ assert.NoError(t, err)
+ assert.Empty(t, events)
+ assert.Equal(t, "final-empty", translator.lastMessageID)
+}
+
+func TestTextMessageEventInvalidObject(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+ rsp := &model.Response{ID: "bad", Object: "unknown", Choices: []model.Choice{{}}}
+
+ _, err := translator.textMessageEvent(rsp)
+ assert.Error(t, err)
+}
+
+func TestTextMessageEventEmptyResponse(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+ events, err := translator.textMessageEvent(nil)
+ assert.Empty(t, events)
+ assert.NoError(t, err)
+ events, err = translator.textMessageEvent(&model.Response{})
+ assert.Empty(t, events)
+ assert.NoError(t, err)
+}
+
+func TestToolCallAndResultEvents(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+ callRsp := &model.Response{
+ ID: "msg-tool",
+ Choices: []model.Choice{{
+ Message: model.Message{ToolCalls: []model.ToolCall{{
+ ID: "call-1",
+ Function: model.FunctionDefinitionParam{Name: "lookup", Arguments: []byte(`{"foo":"bar"}`)},
+ }}},
+ }},
+ }
+
+ callEvents, err := translator.toolCallEvent(callRsp)
+ assert.NoError(t, err)
+ assert.Len(t, callEvents, 2)
+ start, ok := callEvents[0].(*aguievents.ToolCallStartEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "call-1", start.ToolCallID)
+ assert.Equal(t, "lookup", start.ToolCallName)
+ assert.Equal(t, "msg-tool", *start.ParentMessageID)
+ args, ok := callEvents[1].(*aguievents.ToolCallArgsEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "call-1", args.ToolCallID)
+ assert.Equal(t, "{\"foo\":\"bar\"}", args.Delta)
+ assert.Equal(t, "msg-tool", translator.lastMessageID)
+
+ resultRsp := &model.Response{
+ Choices: []model.Choice{{
+ Message: model.Message{ToolID: "call-1", Content: "done"},
+ }},
+ }
+ resultEvents, err := translator.toolResultEvent(resultRsp)
+ assert.NoError(t, err)
+ assert.Len(t, resultEvents, 2)
+ end, ok := resultEvents[0].(*aguievents.ToolCallEndEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "call-1", end.ToolCallID)
+ res, ok := resultEvents[1].(*aguievents.ToolCallResultEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-tool", res.MessageID)
+ assert.Equal(t, "call-1", res.ToolCallID)
+ assert.Equal(t, "done", res.Content)
+}
+
+func TestTranslateToolCallResponseIncludesAllEvents(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+ rsp := &model.Response{
+ ID: "msg-tool",
+ Object: model.ObjectTypeChatCompletion,
+ Choices: []model.Choice{{
+ Message: model.Message{
+ ToolCalls: []model.ToolCall{{
+ ID: "tool-call",
+ Function: model.FunctionDefinitionParam{Name: "lookup", Arguments: []byte(`{"q":"foo"}`)},
+ }},
+ Content: "hello",
+ }},
+ },
+ }
+
+ events, err := translator.Translate(&agentevent.Event{Response: rsp})
+ assert.NoError(t, err)
+ assert.Len(t, events, 5)
+
+ start, ok := events[0].(*aguievents.TextMessageStartEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-tool", start.MessageID)
+
+ content, ok := events[1].(*aguievents.TextMessageContentEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-tool", content.MessageID)
+ assert.Equal(t, "hello", content.Delta)
+
+ end, ok := events[2].(*aguievents.TextMessageEndEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-tool", end.MessageID)
+
+ toolStart, ok := events[3].(*aguievents.ToolCallStartEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "tool-call", toolStart.ToolCallID)
+
+ args, ok := events[4].(*aguievents.ToolCallArgsEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "tool-call", args.ToolCallID)
+}
+
+func TestTranslateFinalResponse(t *testing.T) {
+ translator, ok := New("thread", "run").(*translator)
+ assert.True(t, ok)
+ rsp := &model.Response{
+ ID: "final",
+ Object: model.ObjectTypeChatCompletion,
+ Choices: []model.Choice{{
+ Message: model.Message{Role: model.RoleAssistant, Content: "done"},
+ }},
+ Done: true,
+ }
+
+ events, err := translator.Translate(&agentevent.Event{Response: rsp})
+ assert.NoError(t, err)
+ assert.Len(t, events, 4)
+
+ start, ok := events[0].(*aguievents.TextMessageStartEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "final", start.MessageID)
+
+ content, ok := events[1].(*aguievents.TextMessageContentEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "final", content.MessageID)
+ assert.Equal(t, "done", content.Delta)
+
+ end, ok := events[2].(*aguievents.TextMessageEndEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "final", end.MessageID)
+
+ finished, ok := events[3].(*aguievents.RunFinishedEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "thread", finished.ThreadID())
+ assert.Equal(t, "run", finished.RunID())
+}
+
+func TestTranslateToolResultResponse(t *testing.T) {
+ translator := New("thread", "run")
+
+ _, err := translator.Translate(&agentevent.Event{Response: &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletionChunk,
+ Choices: []model.Choice{{
+ Delta: model.Message{Role: model.RoleAssistant, Content: "partial"},
+ }},
+ }})
+ assert.NoError(t, err)
+
+ events, err := translator.Translate(&agentevent.Event{Response: &model.Response{
+ Choices: []model.Choice{{
+ Message: model.Message{ToolID: "tool-1", Content: "done"},
+ }},
+ }})
+ assert.NoError(t, err)
+ assert.Len(t, events, 2)
+ assert.IsType(t, (*aguievents.ToolCallEndEvent)(nil), events[0])
+ result, ok := events[1].(*aguievents.ToolCallResultEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-1", result.MessageID)
+ assert.Equal(t, "tool-1", result.ToolCallID)
+ assert.Equal(t, "done", result.Content)
+}
+
+func TestTranslateSequentialEvents(t *testing.T) {
+ translator := New("thread", "run")
+
+ chunkRsp := &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletion,
+ Choices: []model.Choice{{
+ Message: model.Message{Role: model.RoleAssistant, Content: "hi"},
+ }},
+ }
+ events, err := translator.Translate(&agentevent.Event{Response: chunkRsp})
+ assert.NoError(t, err)
+ assert.Len(t, events, 3)
+ assert.IsType(t, (*aguievents.TextMessageStartEvent)(nil), events[0])
+ assert.IsType(t, (*aguievents.TextMessageContentEvent)(nil), events[1])
+ assert.IsType(t, (*aguievents.TextMessageEndEvent)(nil), events[2])
+
+ toolCallRsp := &model.Response{
+ ID: "msg-1",
+ Object: model.ObjectTypeChatCompletionChunk,
+ Choices: []model.Choice{{
+ Message: model.Message{
+ ToolCalls: []model.ToolCall{{
+ ID: "call-1",
+ Function: model.FunctionDefinitionParam{Name: "lookup", Arguments: []byte(`{"q":"foo"}`)},
+ }},
+ },
+ }},
+ }
+ events, err = translator.Translate(&agentevent.Event{Response: toolCallRsp})
+ assert.NoError(t, err)
+ assert.Len(t, events, 2)
+ assert.IsType(t, (*aguievents.ToolCallStartEvent)(nil), events[0])
+ assert.IsType(t, (*aguievents.ToolCallArgsEvent)(nil), events[1])
+
+ toolResultRsp := &model.Response{
+ Choices: []model.Choice{{
+ Message: model.Message{ToolID: "call-1", Content: "success"},
+ }},
+ }
+ events, err = translator.Translate(&agentevent.Event{Response: toolResultRsp})
+ assert.NoError(t, err)
+ assert.Len(t, events, 2)
+ assert.IsType(t, (*aguievents.ToolCallEndEvent)(nil), events[0])
+ res, ok := events[1].(*aguievents.ToolCallResultEvent)
+ assert.True(t, ok)
+ assert.Equal(t, "msg-1", res.MessageID)
+ assert.Equal(t, "call-1", res.ToolCallID)
+
+ finalRsp := &model.Response{
+ ID: "msg-2",
+ Object: model.ObjectTypeChatCompletion,
+ Choices: []model.Choice{{
+ Message: model.Message{Role: model.RoleAssistant, Content: "done"},
+ }},
+ Done: true,
+ }
+ events, err = translator.Translate(&agentevent.Event{Response: finalRsp})
+ assert.NoError(t, err)
+ assert.Len(t, events, 4)
+ assert.IsType(t, (*aguievents.TextMessageStartEvent)(nil), events[0])
+ assert.IsType(t, (*aguievents.TextMessageContentEvent)(nil), events[1])
+ assert.IsType(t, (*aguievents.TextMessageEndEvent)(nil), events[2])
+ assert.IsType(t, (*aguievents.RunFinishedEvent)(nil), events[3])
+}
+
+func TestFormatToolCallArguments(t *testing.T) {
+ assert.Equal(t, "", formatToolCallArguments(nil))
+ assert.Equal(t, "", formatToolCallArguments([]byte{}))
+ assert.Equal(t, "{\"foo\":\"bar\"}", formatToolCallArguments([]byte(`{"foo":"bar"}`)))
+}