实现Debug EML实时Trace调试链路

This commit is contained in:
andy
2026-07-12 14:59:03 +08:00
parent eee37315a0
commit 8d8fae670a
20 changed files with 1571 additions and 76 deletions

View File

@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService'
import {
DebugEmlUploadError,
uploadDebugEmlSuperAgentRun,
uploadDebugEmlSuperAgentRunStream,
} from '@/services/debugEmlService'
import { setUnauthorizedHandler } from '@/services/httpClient'
const jsonHeaders = {
@@ -19,6 +23,24 @@ function mockJsonResponse(payload: unknown, status = 201): Response {
} as Response
}
function mockSseResponse(chunks: string[], status = 200): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
},
})
return new Response(stream, {
status,
headers: {
'Content-Type': 'text/event-stream',
},
})
}
describe('debugEmlService', () => {
beforeEach(() => {
vi.restoreAllMocks()
@@ -155,4 +177,100 @@ describe('debugEmlService', () => {
expect(unauthorizedHandler).toHaveBeenCalledOnce()
})
it('streams debug stages, public SuperAgent trace, and final result from backend SSE', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
mockSseResponse([
'event: debug_stage\n',
'data: {"debug_run_id":"90003","status":"PARSING_EML","safe_summary":"阶段:解析 EML 邮件。"}\n\n',
'event: superagent_trace\n',
'data: {"event":"reasoning.summary","run_id":"run-stream-1","text":"正在分析 Debug 邮件。","ts":"2026-07-11T10:00:01Z"}\n\n',
'event: superagent_trace\n',
'data: {"event":"tool.call.started","run_id":"run-stream-1","tool_name":"th_hotel_query_case_context","input_summary":"{\\"group_code\\":\\"G001\\"}"}\n\n',
'event: superagent_result\n',
'data: {"debug_run_id":"90003","source_message_id":"30003","uploaded_media":[],"superagent_session_id":"session-stream-1","superagent_run_id":"run-stream-1","superagent_raw_answer":"{\\"route_code\\":\\"S10\\"}","superagent_parsed_json":{"route_code":"S10"},"superagent_trace_events":[{"event":"reasoning.summary","text":"正在分析 Debug 邮件。"}],"warnings":[],"status":"SUPERAGENT_SUCCEEDED"}\n\n',
'event: done\n',
'data: {"status":"done"}\n\n',
]),
)
const stages: unknown[] = []
const traces: unknown[] = []
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
const result = await uploadDebugEmlSuperAgentRunStream(
{
hotelId: 'HOTEL-TEST',
debugUploadKey: 'manual-debug-key',
runLabel: 'stream-smoke',
file,
},
{
onStage: (stage) => stages.push(stage),
onTrace: (trace) => traces.push(trace),
},
)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('/api/system/debug/eml-superagent-runs/stream')
expect(init).toMatchObject({
method: 'POST',
headers: {
Accept: 'text/event-stream',
'X-TH-Hotel-Debug-Upload-Key': 'manual-debug-key',
},
})
expect(stages).toMatchObject([
{
debug_run_id: '90003',
status: 'PARSING_EML',
safe_summary: '阶段:解析 EML 邮件。',
},
])
expect(traces).toMatchObject([
{
event: 'reasoning.summary',
run_id: 'run-stream-1',
text: '正在分析 Debug 邮件。',
},
{
event: 'tool.call.started',
tool_name: 'th_hotel_query_case_context',
input_summary: '{"group_code":"G001"}',
},
])
expect(result.superagent_run_id).toBe('run-stream-1')
expect(result.superagent_parsed_json).toEqual({ route_code: 'S10' })
expect(result.superagent_trace_events).toMatchObject([
{
event: 'reasoning.summary',
text: '正在分析 Debug 邮件。',
},
])
})
it('throws a typed error when the backend SSE stream emits debug_error', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
mockSseResponse([
'event: debug_stage\n',
'data: {"debug_run_id":"90004","status":"CALLING_SUPERAGENT","safe_summary":"阶段:调用 SuperAgent Open API。"}\n\n',
'event: debug_error\n',
'data: {"debug_run_id":"90004","error_code":"SUPERAGENT_OPEN_API_FAILED","message":"SuperAgent 调用失败。","status":"SUPERAGENT_FAILED"}\n\n',
'event: done\n',
'data: {"status":"done"}\n\n',
]),
)
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
await expect(
uploadDebugEmlSuperAgentRunStream({
hotelId: 'HOTEL-TEST',
debugUploadKey: 'manual-debug-key',
file,
}),
).rejects.toMatchObject({
errorCode: 'SUPERAGENT_OPEN_API_FAILED',
message: 'SuperAgent 调用失败。',
} satisfies Partial<DebugEmlUploadError>)
})
})

View File

@@ -11,6 +11,7 @@ vi.mock('@/services/debugEmlService', async (importOriginal) => {
return {
...actual,
uploadDebugEmlSuperAgentRun: vi.fn(),
uploadDebugEmlSuperAgentRunStream: vi.fn(),
}
})
@@ -66,6 +67,20 @@ function createResult() {
superagent_run_id: 'run-1',
superagent_raw_answer: '{"ok":true}',
superagent_parsed_json: { ok: true },
superagent_trace_events: [
{
event: 'reasoning.summary',
run_id: 'run-1',
message_id: null,
tool_call_id: null,
tool_name: null,
text: '正在分析 Debug 邮件。',
input_summary: null,
output_summary: null,
status: null,
ts: '2026-07-11T10:00:01Z',
},
],
warnings: ['SuperAgent 返回非 JSON 时会展示原始回答'],
status: 'SUPERAGENT_SUCCEEDED',
}
@@ -85,10 +100,47 @@ async function chooseFile(wrapper: ReturnType<typeof mountView>) {
describe('DebugEmlSuperAgentRunView', () => {
beforeEach(() => {
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockReset()
vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockReset()
})
it('submits the selected eml file and renders sanitized debug result', async () => {
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockResolvedValue(createResult())
it('streams the selected eml file and renders stages, trace, and sanitized debug result', async () => {
vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockImplementation(async (_input, handlers) => {
handlers?.onStage?.({
debug_run_id: '90001',
status: 'PARSING_EML',
safe_summary: '阶段:解析 EML 邮件。',
})
handlers?.onStage?.({
debug_run_id: '90001',
status: 'CALLING_SUPERAGENT',
safe_summary: '阶段:调用 SuperAgent Open APIsource_message_id=30001。',
})
handlers?.onTrace?.({
event: 'reasoning.summary',
run_id: 'run-1',
message_id: null,
tool_call_id: null,
tool_name: null,
text: '正在分析 Debug 邮件。',
input_summary: null,
output_summary: null,
status: null,
ts: '2026-07-11T10:00:01Z',
})
handlers?.onTrace?.({
event: 'tool.call.started',
run_id: 'run-1',
message_id: 'ai-1',
tool_call_id: 'tool-1',
tool_name: 'th_hotel_query_case_context',
text: null,
input_summary: '{"group_code":"G001"}',
output_summary: null,
status: null,
ts: '2026-07-11T10:00:02Z',
})
return createResult()
})
const wrapper = mountView()
await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST')
@@ -98,12 +150,24 @@ describe('DebugEmlSuperAgentRunView', () => {
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(service.uploadDebugEmlSuperAgentRun).toHaveBeenCalledWith({
hotelId: 'HOTEL-TEST',
debugUploadKey: 'manual-debug-key',
runLabel: 'frontend-smoke',
file,
})
expect(service.uploadDebugEmlSuperAgentRunStream).toHaveBeenCalledWith(
{
hotelId: 'HOTEL-TEST',
debugUploadKey: 'manual-debug-key',
runLabel: 'frontend-smoke',
file,
},
expect.objectContaining({
onStage: expect.any(Function),
onTrace: expect.any(Function),
}),
)
expect(wrapper.text()).toContain('PARSING_EML')
expect(wrapper.text()).toContain('CALLING_SUPERAGENT')
expect(wrapper.text()).toContain('阶段:解析 EML 邮件。')
expect(wrapper.text()).toContain('reasoning.summary')
expect(wrapper.text()).toContain('正在分析 Debug 邮件。')
expect(wrapper.text()).toContain('th_hotel_query_case_context')
expect(wrapper.text()).toContain('90001')
expect(wrapper.text()).toContain('30001')
expect(wrapper.text()).toContain('DEBUG_EML_UPLOAD')
@@ -116,7 +180,7 @@ describe('DebugEmlSuperAgentRunView', () => {
})
it('does not render raw html with oss urls by default', async () => {
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockResolvedValue(createResult())
vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockResolvedValue(createResult())
const wrapper = mountView()
await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST')
@@ -130,7 +194,7 @@ describe('DebugEmlSuperAgentRunView', () => {
})
it('shows mapped error_code messages without exposing the debug key', async () => {
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockRejectedValue(
vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockRejectedValue(
new DebugEmlUploadError('Debug 上传访问口令缺失或错误。', 401, 'DEBUG_UPLOAD_KEY_INVALID', {
error_code: 'DEBUG_UPLOAD_KEY_INVALID',
}),