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
+150
View File
@@ -734,6 +734,79 @@ describe('runNonInteractive', () => {
);
});
it('should include debug diagnostics in JSON output when debug mode is enabled', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello World' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
const debugMetrics: 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: {},
},
},
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,
},
};
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(mockConfig.getDebugMode).mockReturnValue(true);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
authType: 'gemini-api-key',
});
vi.mocked(mockConfig.getUserTierName).mockReturnValue('free');
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(debugMetrics);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Test input',
prompt_id: 'prompt-id-json-debug',
});
const parsed = JSON.parse(getWrittenOutput());
expect(parsed.auth_method).toBe('gemini-api-key');
expect(parsed.user_tier).toBe('free');
expect(parsed.stats.api_requests).toBe(2);
expect(parsed.stats.api_errors).toBe(1);
expect(parsed.stats.retry_count).toBe(0);
});
it('should write JSON output with stats for tool-only commands (no text response)', async () => {
// Test the scenario where a command completes successfully with only tool calls
// but no text response - this would have caught the original bug
@@ -1416,6 +1489,10 @@ describe('runNonInteractive', () => {
CoreEvent.UserFeedback,
expect.any(Function),
);
expect(mockCoreEvents.on).toHaveBeenCalledWith(
CoreEvent.RetryAttempt,
expect.any(Function),
);
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
});
@@ -1441,6 +1518,10 @@ describe('runNonInteractive', () => {
CoreEvent.UserFeedback,
expect.any(Function),
);
expect(mockCoreEvents.off).toHaveBeenCalledWith(
CoreEvent.RetryAttempt,
expect.any(Function),
);
});
it('logs to process.stderr when UserFeedback event is received', async () => {
@@ -1724,6 +1805,75 @@ describe('runNonInteractive', () => {
},
);
it('should emit loop_detected and legacy warning error events in debug stream-json mode', async () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(mockConfig.getDebugMode).mockReturnValue(true);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
authType: 'oauth-personal',
});
vi.mocked(mockConfig.getUserTierName).mockReturnValue('pro');
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
MOCK_SESSION_METRICS,
);
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.LoopDetected,
value: { loopType: 'llm_detected_loop' },
},
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Loop debug test',
prompt_id: 'prompt-id-loop-debug',
});
const outputLines = getWrittenOutput()
.trim()
.split('\n')
.map((line) => JSON.parse(line));
expect(outputLines[0]).toMatchObject({
type: 'init',
auth_method: 'oauth-personal',
user_tier: 'pro',
});
expect(outputLines).toContainEqual(
expect.objectContaining({
type: 'loop_detected',
loop_type: 'llm_detected_loop',
}),
);
expect(outputLines).toContainEqual(
expect.objectContaining({
type: 'error',
severity: 'warning',
message: 'Loop detected, stopping execution',
}),
);
expect(outputLines).toContainEqual(
expect.objectContaining({
type: 'result',
stats: expect.objectContaining({
api_requests: 0,
api_errors: 0,
retry_count: 0,
}),
}),
);
});
it('should log error when tool recording fails', async () => {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
+27 -1
View File
@@ -96,6 +96,8 @@ export async function runNonInteractive({
const startTime = Date.now();
let retryCount = 0;
let loopDetected = false;
let detectedLoopType: string | undefined;
const debugMode = config.getDebugMode();
const streamFormatter =
config.getOutputFormat() === OutputFormat.STREAM_JSON
@@ -377,14 +379,26 @@ export async function runNonInteractive({
}
toolCallRequests.push(event.value);
} else if (event.type === GeminiEventType.LoopDetected) {
const loopType = event.value?.loopType;
loopDetected = true;
if (loopType) {
detectedLoopType = loopType;
}
if (debugMode) {
const loopType = event.value?.loopType;
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.LOOP_DETECTED,
timestamp: new Date().toISOString(),
...(loopType && { loop_type: loopType }),
});
// Keep emitting the legacy warning event for existing parsers.
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'warning',
message: 'Loop detected, stopping execution',
});
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
const loopTypeStr = loopType ? ` (${loopType})` : '';
process.stderr.write(
@@ -540,6 +554,12 @@ export async function runNonInteractive({
undefined,
authMethod,
userTier,
{
includeDiagnostics: debugMode,
retryCount,
loopDetected,
loopType: detectedLoopType,
},
),
);
} else {
@@ -574,6 +594,12 @@ export async function runNonInteractive({
undefined,
authMethod,
userTier,
{
includeDiagnostics: debugMode,
retryCount,
loopDetected,
loopType: detectedLoopType,
},
),
);
} else {