feat(agent): 持久化Trace并支持SSE恢复
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user