fix(headless): complete debug diagnostics parity for json and stream-json

This commit is contained in:
Dmitry Lyalin
2026-02-25 10:23:16 -08:00
parent 8fb2f1e7f8
commit 96aa6004fb
8 changed files with 401 additions and 9 deletions
@@ -41,6 +41,25 @@ describe('JsonFormatter', () => {
expect(parsed.response).toBe('Red text and Green text');
});
it('should include auth method and user tier when provided', () => {
const formatter = new JsonFormatter();
const formatted = formatter.format(
'test-session-id',
'hello',
undefined,
undefined,
'gemini-api-key',
'free',
);
expect(JSON.parse(formatted)).toEqual({
session_id: 'test-session-id',
auth_method: 'gemini-api-key',
user_tier: 'free',
response: 'hello',
});
});
it('should strip control characters from response text', () => {
const formatter = new JsonFormatter();
const responseWithControlChars =
@@ -138,6 +157,87 @@ describe('JsonFormatter', () => {
expect(JSON.parse(formatted)).toEqual(expected);
});
it('should include debug diagnostic stats when enabled', () => {
const formatter = new JsonFormatter();
const stats: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: {
totalRequests: 2,
totalErrors: 1,
totalLatencyMs: 1234,
},
tokens: {
input: 10,
prompt: 10,
candidates: 5,
total: 15,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {},
},
'gemini-2.5-flash': {
api: {
totalRequests: 3,
totalErrors: 0,
totalLatencyMs: 2345,
},
tokens: {
input: 10,
prompt: 10,
candidates: 5,
total: 15,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
auto_accept: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const formatted = formatter.format(
'test-session-id',
'hello',
stats,
undefined,
'oauth-personal',
'pro',
{
includeDiagnostics: true,
retryCount: 0,
loopDetected: true,
loopType: 'llm_detected_loop',
},
);
const parsed = JSON.parse(formatted);
expect(parsed.stats.api_requests).toBe(5);
expect(parsed.stats.api_errors).toBe(1);
expect(parsed.stats.retry_count).toBe(0);
expect(parsed.stats.loop_detected).toBe(true);
expect(parsed.stats.loop_type).toBe('llm_detected_loop');
});
it('should format error as JSON', () => {
const formatter = new JsonFormatter();
const error: JsonError = {
+31 -2
View File
@@ -6,7 +6,14 @@
import stripAnsi from 'strip-ansi';
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
import type { JsonError, JsonOutput } from './types.js';
import type { JsonError, JsonOutput, JsonOutputStats } from './types.js';
type JsonFormatDiagnostics = {
includeDiagnostics?: boolean;
retryCount?: number;
loopDetected?: boolean;
loopType?: string;
};
export class JsonFormatter {
format(
@@ -16,6 +23,7 @@ export class JsonFormatter {
error?: JsonError,
authMethod?: string,
userTier?: string,
diagnostics?: JsonFormatDiagnostics,
): string {
const output: JsonOutput = {};
@@ -36,7 +44,28 @@ export class JsonFormatter {
}
if (stats) {
output.stats = stats;
const outputStats: JsonOutputStats = { ...stats };
if (diagnostics?.includeDiagnostics) {
let apiRequests = 0;
let apiErrors = 0;
for (const modelMetrics of Object.values(stats.models)) {
apiRequests += modelMetrics.api.totalRequests;
apiErrors += modelMetrics.api.totalErrors;
}
outputStats.api_requests = apiRequests;
outputStats.api_errors = apiErrors;
outputStats.retry_count = diagnostics.retryCount ?? 0;
if (diagnostics.loopDetected) {
outputStats.loop_detected = true;
}
if (diagnostics.loopType) {
outputStats.loop_type = diagnostics.loopType;
}
}
output.stats = outputStats;
}
if (error) {
@@ -473,6 +473,68 @@ describe('StreamJsonFormatter', () => {
expect(result.duration_ms).toBe(5000);
});
it('should include diagnostic stats when enabled', () => {
const metrics = createMockMetrics();
metrics.models['gemini-pro'] = {
api: { totalRequests: 2, totalErrors: 1, totalLatencyMs: 1000 },
tokens: {
input: 10,
prompt: 10,
candidates: 5,
total: 15,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {},
};
metrics.models['gemini-flash'] = {
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 2000 },
tokens: {
input: 20,
prompt: 20,
candidates: 10,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {},
};
const result = formatter.convertToStreamStats(metrics, 750, {
includeDiagnostics: true,
retryCount: 0,
});
expect(result.api_requests).toBe(5);
expect(result.api_errors).toBe(1);
expect(result.retry_count).toBe(0);
});
it('should not include diagnostic stats when disabled', () => {
const metrics = createMockMetrics();
metrics.models['gemini-pro'] = {
api: { totalRequests: 2, totalErrors: 1, totalLatencyMs: 1000 },
tokens: {
input: 10,
prompt: 10,
candidates: 5,
total: 15,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {},
};
const result = formatter.convertToStreamStats(metrics, 750);
expect(result.api_requests).toBeUndefined();
expect(result.api_errors).toBeUndefined();
expect(result.retry_count).toBeUndefined();
});
});
describe('JSON validity', () => {
@@ -75,10 +75,7 @@ export class StreamJsonFormatter {
}
stats.api_requests = apiRequests;
stats.api_errors = apiErrors;
if (options.retryCount && options.retryCount > 0) {
stats.retry_count = options.retryCount;
}
stats.retry_count = options.retryCount ?? 0;
}
return stats;
+9 -1
View File
@@ -18,12 +18,20 @@ export interface JsonError {
code?: string | number;
}
export interface JsonOutputStats extends SessionMetrics {
api_requests?: number;
api_errors?: number;
retry_count?: number;
loop_detected?: boolean;
loop_type?: string;
}
export interface JsonOutput {
session_id?: string;
auth_method?: string;
user_tier?: string;
response?: string;
stats?: SessionMetrics;
stats?: JsonOutputStats;
error?: JsonError;
}