diff --git a/src/server/modules/AgentRuntime/GatewayStreamNotifier.ts b/src/server/modules/AgentRuntime/GatewayStreamNotifier.ts index 933b70d51a..4d1b59acfd 100644 --- a/src/server/modules/AgentRuntime/GatewayStreamNotifier.ts +++ b/src/server/modules/AgentRuntime/GatewayStreamNotifier.ts @@ -1,7 +1,11 @@ import debug from 'debug'; import urlJoin from 'url-join'; -import type { StreamChunkData, StreamEvent } from './StreamEventManager'; +import { + getDefaultReasonDetail, + type StreamChunkData, + type StreamEvent, +} from './StreamEventManager'; import type { IStreamEventManager } from './types'; const log = debug('lobe-server:agent-runtime:gateway-notifier'); @@ -88,8 +92,11 @@ export class GatewayStreamNotifier implements IStreamEventManager { reasonDetail, ); + const effectiveReasonDetail = reasonDetail || getDefaultReasonDetail(finalState, reason); + const errorType = finalState?.error?.type || finalState?.error?.errorType; + this.pushEvent(operationId, { - data: { finalState, reason, reasonDetail }, + data: { errorType, finalState, reason, reasonDetail: effectiveReasonDetail }, operationId, stepIndex, timestamp: Date.now(), diff --git a/src/server/modules/AgentRuntime/StreamEventManager.ts b/src/server/modules/AgentRuntime/StreamEventManager.ts index aef379fc3b..d492b40898 100644 --- a/src/server/modules/AgentRuntime/StreamEventManager.ts +++ b/src/server/modules/AgentRuntime/StreamEventManager.ts @@ -7,13 +7,32 @@ import { getAgentRuntimeRedisClient } from './redis'; const log = debug('lobe-server:agent-runtime:stream-event-manager'); const timing = debug('lobe-server:agent-runtime:timing'); -const getDefaultReasonDetail = (finalState: any, reason?: string): string => { +const extractReasonFromError = (error: any): string | undefined => { + if (!error) return undefined; + + // ChatMessageError format: { body: { error: { message } }, message, type } + if (error.body?.error?.message) return error.body.error.message; + if (error.body?.message) return error.body.message; + + // ChatCompletionErrorPayload format: { error: { message }, errorType } + if (error.error?.error?.message) return error.error.error.message; + if (error.error?.message) return error.error.message; + + // Direct message (skip "[object Object]") + if (error.message && error.message !== '[object Object]' && error.message !== 'error') { + return error.message; + } + + return error.type || error.errorType || undefined; +}; + +export const getDefaultReasonDetail = (finalState: any, reason?: string): string => { if (reason === 'error') { - return finalState?.error?.message || finalState?.error?.type || 'Agent runtime failed'; + return extractReasonFromError(finalState?.error) || 'Agent runtime failed'; } if (reason === 'interrupted') { - return finalState?.error?.message || 'Agent runtime interrupted'; + return extractReasonFromError(finalState?.error) || 'Agent runtime interrupted'; } return 'Agent runtime completed successfully'; diff --git a/src/server/modules/AgentRuntime/__tests__/GatewayStreamNotifier.test.ts b/src/server/modules/AgentRuntime/__tests__/GatewayStreamNotifier.test.ts index da3e109592..e61cc032c8 100644 --- a/src/server/modules/AgentRuntime/__tests__/GatewayStreamNotifier.test.ts +++ b/src/server/modules/AgentRuntime/__tests__/GatewayStreamNotifier.test.ts @@ -154,6 +154,73 @@ describe('GatewayStreamNotifier', () => { // Gateway handles session completion directly in pushEvent on agent_runtime_end expect(urls).not.toContain(`${gatewayUrl}/api/operations/update-status`); }); + + it('computes effectiveReasonDetail when reasonDetail is omitted', async () => { + const finalState = { + error: { + error: { message: 'Budget exceeded' }, + errorType: 'InsufficientBudgetForModel', + }, + }; + + await notifier.publishAgentRuntimeEnd('op-1', 0, finalState, 'error'); + await new Promise((r) => setTimeout(r, 50)); + + const pushCall = mockFetch.mock.calls.find((c: any[]) => c[0].includes('push-event')); + const body = JSON.parse(pushCall![1].body); + expect(body.event.data.reasonDetail).toBe('Budget exceeded'); + }); + + it('uses provided reasonDetail over computed one', async () => { + const finalState = { + error: { message: 'Some error' }, + }; + + await notifier.publishAgentRuntimeEnd('op-1', 0, finalState, 'error', 'Custom detail'); + await new Promise((r) => setTimeout(r, 50)); + + const pushCall = mockFetch.mock.calls.find((c: any[]) => c[0].includes('push-event')); + const body = JSON.parse(pushCall![1].body); + expect(body.event.data.reasonDetail).toBe('Custom detail'); + }); + + it('includes errorType from finalState.error.type', async () => { + const finalState = { + error: { message: 'Budget exceeded', type: 'InsufficientBudgetForModel' }, + }; + + await notifier.publishAgentRuntimeEnd('op-1', 0, finalState, 'error'); + await new Promise((r) => setTimeout(r, 50)); + + const pushCall = mockFetch.mock.calls.find((c: any[]) => c[0].includes('push-event')); + const body = JSON.parse(pushCall![1].body); + expect(body.event.data.errorType).toBe('InsufficientBudgetForModel'); + }); + + it('includes errorType from finalState.error.errorType', async () => { + const finalState = { + error: { + error: { message: 'Bad key' }, + errorType: 'InvalidProviderAPIKey', + }, + }; + + await notifier.publishAgentRuntimeEnd('op-1', 0, finalState, 'error'); + await new Promise((r) => setTimeout(r, 50)); + + const pushCall = mockFetch.mock.calls.find((c: any[]) => c[0].includes('push-event')); + const body = JSON.parse(pushCall![1].body); + expect(body.event.data.errorType).toBe('InvalidProviderAPIKey'); + }); + + it('errorType is undefined when no error in finalState', async () => { + await notifier.publishAgentRuntimeEnd('op-1', 0, { status: 'done' }, 'completed'); + await new Promise((r) => setTimeout(r, 50)); + + const pushCall = mockFetch.mock.calls.find((c: any[]) => c[0].includes('push-event')); + const body = JSON.parse(pushCall![1].body); + expect(body.event.data.errorType).toBeUndefined(); + }); }); // ─── Read/subscribe methods: must delegate directly to inner ─── diff --git a/src/server/modules/AgentRuntime/__tests__/StreamEventManager.test.ts b/src/server/modules/AgentRuntime/__tests__/StreamEventManager.test.ts index 88c629540c..03e4ac3215 100644 --- a/src/server/modules/AgentRuntime/__tests__/StreamEventManager.test.ts +++ b/src/server/modules/AgentRuntime/__tests__/StreamEventManager.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { StreamEventManager } from '../StreamEventManager'; +import { getDefaultReasonDetail, StreamEventManager } from '../StreamEventManager'; // Mock Redis client const mockRedis = { @@ -185,4 +185,90 @@ describe('StreamEventManager', () => { ); }); }); + + describe('getDefaultReasonDetail', () => { + it('should return success message for non-error reasons', () => { + expect(getDefaultReasonDetail({}, 'completed')).toBe('Agent runtime completed successfully'); + expect(getDefaultReasonDetail({}, undefined)).toBe('Agent runtime completed successfully'); + }); + + it('should extract from ChatMessageError format (body.error.message)', () => { + const state = { + error: { + body: { error: { message: 'Rate limit exceeded' } }, + message: 'ProviderBizError', + type: 'ProviderBizError', + }, + }; + expect(getDefaultReasonDetail(state, 'error')).toBe('Rate limit exceeded'); + }); + + it('should extract from ChatMessageError format (body.message)', () => { + const state = { + error: { + body: { message: 'Service unavailable' }, + message: 'error', + type: 'InternalServerError', + }, + }; + expect(getDefaultReasonDetail(state, 'error')).toBe('Service unavailable'); + }); + + it('should extract from ChatCompletionErrorPayload format (error.message)', () => { + const state = { + error: { + error: { message: 'Budget exceeded' }, + errorType: 'InsufficientBudgetForModel', + provider: 'lobehub', + }, + }; + expect(getDefaultReasonDetail(state, 'error')).toBe('Budget exceeded'); + }); + + it('should extract from nested ChatCompletionErrorPayload (error.error.message)', () => { + const state = { + error: { + error: { + error: { message: '无效的令牌' }, + message: '无效的令牌', + status: 401, + }, + errorType: 'InvalidProviderAPIKey', + }, + }; + expect(getDefaultReasonDetail(state, 'error')).toBe('无效的令牌'); + }); + + it('should skip [object Object] and fallback to type', () => { + const state = { + error: { + message: '[object Object]', + type: 'ProviderBizError', + }, + }; + expect(getDefaultReasonDetail(state, 'error')).toBe('ProviderBizError'); + }); + + it('should use direct message when it is a real string', () => { + const state = { + error: { message: 'Connection timeout', type: 'NetworkError' }, + }; + expect(getDefaultReasonDetail(state, 'error')).toBe('Connection timeout'); + }); + + it('should fallback to default message when error is empty', () => { + expect(getDefaultReasonDetail({}, 'error')).toBe('Agent runtime failed'); + expect(getDefaultReasonDetail({ error: {} }, 'error')).toBe('Agent runtime failed'); + expect(getDefaultReasonDetail(null, 'error')).toBe('Agent runtime failed'); + }); + + it('should handle interrupted reason', () => { + const state = { error: { message: 'User cancelled' } }; + expect(getDefaultReasonDetail(state, 'interrupted')).toBe('User cancelled'); + }); + + it('should fallback for interrupted without error', () => { + expect(getDefaultReasonDetail({}, 'interrupted')).toBe('Agent runtime interrupted'); + }); + }); });