实现Debug EML实时Trace调试链路
This commit is contained in:
@@ -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>)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 API,source_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',
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user