🐛 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:
Arvin Xu 2026-04-10 01:51:19 +08:00 committed by GitHub
parent 6a40eb8a3b
commit a4d9967e60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 185 additions and 6 deletions

View file

@ -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(),

View file

@ -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';

View file

@ -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 ───

View file

@ -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');
});
});
});