mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
🐛 fix: gateway not receiving error reasonDetail in agent_runtime_end event (#13707)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6a40eb8a3b
commit
a4d9967e60
4 changed files with 185 additions and 6 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 ───
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue