!feat(core): surface loop detection as a warning

This commit is contained in:
Adam Weidman
2026-03-23 09:35:41 -04:00
parent db35ecd705
commit 3878599772
2 changed files with 23 additions and 16 deletions
@@ -395,15 +395,18 @@ describe('translateEvent', () => {
});
describe('LoopDetected events', () => {
it('emits a custom loop_detected event', () => {
it('emits a non-fatal warning error event', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.LoopDetected,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('custom');
expect((result[0] as AgentEvent<'custom'>).kind).toBe('loop_detected');
expect(result[0]?.type).toBe('error');
const loopWarning = result[0] as AgentEvent<'error'>;
expect(loopWarning.fatal).toBe(false);
expect(loopWarning.message).toBe('Loop detected, stopping execution');
expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED');
});
});
@@ -676,6 +679,10 @@ describe('mapError', () => {
expect(result.status).toBe('RESOURCE_EXHAUSTED');
expect(result.message).toBe('Rate limit');
expect(result.fatal).toBe(true);
expect(result._meta?.['rawError']).toEqual({
message: 'Rate limit',
status: 429,
});
});
it('maps Error instances', () => {
+13 -13
View File
@@ -59,13 +59,12 @@ export function createTranslationState(streamId?: string): TranslationState {
// Helpers
// ---------------------------------------------------------------------------
function makeEvent(
type: AgentEventType,
function makeEvent<T extends AgentEventType>(
type: T,
state: TranslationState,
payload: Partial<AgentEvent>,
payload: Partial<AgentEvent<T>>,
): AgentEvent {
const id = `${state.streamId}-${state.eventCounter++}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- constructing AgentEvent from common fields + payload
return {
...payload,
id,
@@ -169,12 +168,13 @@ export function translateEvent(
case GeminiEventType.LoopDetected:
ensureStreamStart(state, out);
out.push(
makeEvent('custom', state, {
kind: 'loop_detected',
makeEvent('error', state, {
status: 'INTERNAL',
message: 'Loop detected, stopping execution',
fatal: false,
_meta: { code: 'LOOP_DETECTED' },
}),
);
// No agent_end — the stream continues. Consumer decides how to handle:
// non-interactive emits a warning, interactive shows a confirmation dialog.
break;
case GeminiEventType.ContextWindowWillOverflow:
@@ -380,7 +380,8 @@ export function mapHttpToGrpcStatus(
/**
* Maps a StructuredError (or unknown error value) to an ErrorData payload.
* Preserves error metadata (name, code, stack) in _meta.
* Preserves selected error metadata in _meta and includes raw structured
* errors for lossless debugging.
*/
export function mapError(
error: unknown,
@@ -397,14 +398,13 @@ export function mapError(
}
}
const hasMeta = Object.keys(meta).length > 0;
if (isStructuredError(error)) {
const structuredMeta = { ...meta, rawError: error };
return {
status: mapHttpToGrpcStatus(error.status),
message: error.message,
fatal: true,
...(hasMeta ? { _meta: meta } : {}),
_meta: structuredMeta,
};
}
@@ -413,7 +413,7 @@ export function mapError(
status: 'INTERNAL',
message: error.message,
fatal: true,
...(hasMeta ? { _meta: meta } : {}),
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
};
}