feat(agent): 持久化Trace并支持SSE恢复

This commit is contained in:
2026-09-01 00:39:37 +08:00
parent 8da75d4420
commit 3cb197aafe
20 changed files with 949 additions and 70 deletions
+22
View File
@@ -143,6 +143,10 @@ export type AgentEventType =
| 'PermissionRequired'
| 'Usage'
| 'Citation'
| 'ModelCallStarted'
| 'ModelCallCompleted'
| 'ModelCallFailed'
| 'PermissionResolved'
| 'RunCompleted'
| 'RunFailed'
| 'RunCancelled'
@@ -155,6 +159,24 @@ export interface AgentEvent {
timestamp: string
}
export interface AgentTraceSummary {
model_calls: number
tool_calls: number
duration_ms: number
token_usage: number
errors: number
}
export interface AgentTraceResponse {
run_id: string
status: AgentRunStatus
items: AgentEvent[]
next_sequence: number
has_more: boolean
summary: AgentTraceSummary
config_snapshot: Record<string, unknown>
}
export interface ToolCall {
tool_call_id: string
name: string
+8
View File
@@ -18,6 +18,10 @@ const eventLabels: Record<AgentEventType, string> = {
PermissionRequired: '请求权限',
Usage: '用量统计',
Citation: '引用来源',
ModelCallStarted: '模型调用开始',
ModelCallCompleted: '模型调用完成',
ModelCallFailed: '模型调用失败',
PermissionResolved: '权限已处理',
RunCompleted: '运行完成',
RunFailed: '运行失败',
RunCancelled: '运行取消',
@@ -86,6 +90,10 @@ const detailLabels: Record<string, string> = {
total_tokens: '令牌总数',
status: '状态',
duration_ms: '耗时(毫秒)',
model_call_id: '模型调用 ID',
parent_model_call_id: '上级模型调用 ID',
finish_reason: '结束原因',
decision: '授权决定',
}
export function runStatusLabel(status?: AgentRunStatus): string {
+12 -3
View File
@@ -1,6 +1,6 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
function toAgentRun(run: ApiAgentRun): AgentRun {
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
@@ -54,6 +54,13 @@ export async function cancelAgentRun(runId: string): Promise<OperationResponse>
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
}
export async function getAgentTrace(
runId: string,
params?: { after_sequence?: number; limit?: number },
): Promise<AgentTraceResponse> {
return apiClient.get(`/api/agent/runs/${runId}/trace`, { params })
}
export async function listTools(): Promise<ToolDefinition[]> {
const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
return response.items
@@ -66,12 +73,14 @@ export function streamAgentEvents(
onError?: (error: Error) => void
onDone?: () => void
onOpen?: () => void
}
},
afterSequence = -1,
): SseClient {
// 将通用 SSE 包装成领域事件,Store 无需了解传输层 envelope。
const client = new SseClient({
url: `/api/agent/runs/${runId}/events`,
url: `/api/agent/runs/${runId}/events?after_sequence=${afterSequence}`,
method: 'GET',
lastEventId: afterSequence >= 0 ? String(afterSequence) : undefined,
onEvent: (eventName, data) => {
handlers.onEvent?.({
event: eventName as AgentEvent['event'],
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SseClient } from './sseClient'
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('SseClient resumable event transport', () => {
it('sends Last-Event-ID and exposes the returned SSE id', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
'id: 3\nevent: ModelCallCompleted\ndata: {"sequence":3,"data":{"duration_ms":12}}\n\n',
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
),
)
vi.stubGlobal('fetch', fetchMock)
const received = vi.fn()
const client = new SseClient({
url: '/api/agent/runs/run-1/events?after_sequence=2',
method: 'GET',
lastEventId: '2',
onEvent: received,
})
await client.connect()
expect(fetchMock).toHaveBeenCalledWith(
'/api/agent/runs/run-1/events?after_sequence=2',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({ 'Last-Event-ID': '2' }),
}),
)
expect(received).toHaveBeenCalledWith(
'ModelCallCompleted',
{ sequence: 3, data: { duration_ms: 12 } },
'3',
)
})
})
+16 -4
View File
@@ -1,12 +1,17 @@
import { resolveApiUrl } from './apiClient'
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
export type SseEventHandler = (
event: string,
data: Record<string, unknown>,
eventId?: string,
) => void
export interface SseClientOptions {
url: string
method?: string
body?: unknown
token?: string
lastEventId?: string
onEvent?: SseEventHandler
onError?: (error: Error) => void
onOpen?: () => void
@@ -26,7 +31,7 @@ export class SseClient {
}
async connect() {
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
const { url, method = 'POST', body, token, lastEventId, onEvent, onError, onOpen, onDone } = this.options
try {
const headers: Record<string, string> = {
@@ -38,6 +43,9 @@ export class SseClient {
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
if (lastEventId !== undefined) {
headers['Last-Event-ID'] = lastEventId
}
const resp = await fetch(resolveApiUrl(url), {
method,
@@ -57,17 +65,19 @@ export class SseClient {
// 一个 UTF-8 字符或 SSE 行可能横跨多个网络分片,必须累积后再按空行派发。
const decoder = new TextDecoder('utf-8')
let eventName = 'message'
let eventId: string | undefined
let dataLines: string[] = []
let doneNotified = false
const dispatchEvent = () => {
if (!dataLines.length) {
eventName = 'message'
eventId = undefined
return
}
try {
const data = JSON.parse(dataLines.join('\n')) as Record<string, unknown>
onEvent?.(eventName, data)
onEvent?.(eventName, data, eventId)
if (!doneNotified && ['Done', 'RunCompleted', 'RunFailed', 'RunCancelled'].includes(eventName)) {
doneNotified = true
onDone?.()
@@ -76,6 +86,7 @@ export class SseClient {
onError?.(error instanceof Error ? error : new Error('Malformed SSE data'))
}
eventName = 'message'
eventId = undefined
dataLines = []
}
@@ -87,6 +98,7 @@ export class SseClient {
let fieldValue = separator === -1 ? '' : line.slice(separator + 1)
if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1)
if (field === 'event') eventName = fieldValue
if (field === 'id') eventId = fieldValue
if (field === 'data') dataLines.push(fieldValue)
}
@@ -118,7 +130,7 @@ export class SseClient {
this.controller.abort()
}
// TODO(streaming): Agent 事件持久化后,增加 Last-Event-ID 与指数退避重连。
// TODO(streaming): 桌面网络策略确定后,在 Store 层增加有上限的指数退避重连。
isConnected() {
return this.connected