fix(core,cli): stabilize agent harness, resolve build/lint errors, and fix flaky tests

This commit is contained in:
mkorwel
2026-02-20 20:12:49 +00:00
parent 102881c27f
commit 6deaf3dd0f
16 changed files with 366 additions and 245 deletions
+4 -5
View File
@@ -1020,10 +1020,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
} = activeStream;
const activePtyId = rawActivePtyId ?? undefined;
const loopDetectionConfirmationRequest =
rawLoopDetectionConfirmationRequest as any;
const backgroundShells = rawBackgroundShells as any;
const retryStatus = rawRetryStatus as any;
const loopDetectionConfirmationRequest = rawLoopDetectionConfirmationRequest;
const backgroundShells = rawBackgroundShells;
const retryStatus = rawRetryStatus;
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
@@ -1629,7 +1628,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
(backgroundCurrentShell as any)?.();
backgroundCurrentShell?.();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
toggleBackgroundShell();
@@ -12,11 +12,16 @@ import {
GeminiEventType as ServerGeminiEventType,
ROOT_SCHEDULER_ID,
} from '@google/gemini-cli-core';
import { makeFakeConfig } from '../../../../core/src/test-utils/config.js';
import type {
Config,
ServerGeminiStreamEvent as GeminiEvent,
} from '@google/gemini-cli-core';
import { StreamingState, MessageType } from '../types.js';
import { makeFakeConfig } from '@google/gemini-cli-core/dist/src/test-utils/config.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@google/gemini-cli-core')>();
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
AgentFactory: {
@@ -27,7 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
describe('useAgentHarness', () => {
let mockAddItem: Mock;
let mockConfig: any;
let mockConfig: Config;
let mockOnCancelSubmit: Mock;
beforeEach(() => {
@@ -35,19 +40,22 @@ describe('useAgentHarness', () => {
mockConfig = makeFakeConfig();
mockOnCancelSubmit = vi.fn();
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue({
getTool: vi.fn().mockReturnValue({
displayName: 'TestTool',
createInvocation: vi.fn().mockReturnValue({
getDescription: () => 'Test Tool Description'
})
displayName: 'codebase_investigator',
createInvocation: vi.fn().mockReturnValue({
getDescription: () => 'Test Tool Description',
}),
}),
});
mockConfig.getMessageBus = vi.fn().mockReturnValue({
subscribe: vi.fn().mockReturnValue(vi.fn()),
publish: vi.fn(),
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.spyOn(mockConfig, 'getMessageBus').mockReturnValue({
subscribe: vi.fn().mockReturnValue(vi.fn()),
unsubscribe: vi.fn(),
publish: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.clearAllMocks();
});
@@ -68,29 +76,34 @@ describe('useAgentHarness', () => {
// 1. Send content
await act(async () => {
(result.current as any).processEvent({
result.current.processEvent({
type: ServerGeminiEventType.Content,
value: 'Hello',
});
} as GeminiEvent);
});
expect(result.current.streamingContent).toBe('Hello');
expect(result.current.streamingState).toBe(StreamingState.Responding);
// 2. Send thought
await act(async () => {
(result.current as any).processEvent({
result.current.processEvent({
type: ServerGeminiEventType.Thought,
value: { subject: 'Thinking' },
});
} as GeminiEvent);
});
expect(result.current.thought?.subject).toBe('Thinking');
// 3. Send tool request
await act(async () => {
(result.current as any).processEvent({
result.current.processEvent({
type: ServerGeminiEventType.ToolCallRequest,
value: { name: 'tool_1', callId: 'c1', args: {}, schedulerId: ROOT_SCHEDULER_ID },
});
value: {
name: 'tool_1',
callId: 'c1',
args: {},
schedulerId: ROOT_SCHEDULER_ID,
},
} as GeminiEvent);
});
expect(result.current.toolCalls).toHaveLength(1);
expect(result.current.toolCalls[0].request.name).toBe('tool_1');
@@ -103,28 +116,34 @@ describe('useAgentHarness', () => {
// Start a delegation tool
await act(async () => {
(result.current as any).processEvent({
result.current.processEvent({
type: ServerGeminiEventType.ToolCallRequest,
value: { name: 'subagent_tool', callId: 'c1', args: {}, schedulerId: ROOT_SCHEDULER_ID },
});
value: {
name: 'subagent_tool',
callId: 'c1',
args: {},
schedulerId: ROOT_SCHEDULER_ID,
},
} as GeminiEvent);
});
// Send subagent activity
await act(async () => {
(result.current as any).processEvent({
result.current.processEvent({
type: ServerGeminiEventType.SubagentActivity,
value: {
agentName: 'codebase_investigator',
type: 'THOUGHT',
data: { subject: 'Analyzing logs' },
},
});
} as GeminiEvent);
});
// Verify the tool box resultDisplay was updated with the thought
expect((result.current.toolCalls[0] as any).response?.resultDisplay).toContain(
'🤖💭 Analyzing logs',
);
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result.current.toolCalls[0] as any).response?.resultDisplay,
).toContain('🤖💭 Analyzing logs');
// Send another activity
await act(async () => {
@@ -135,12 +154,13 @@ describe('useAgentHarness', () => {
type: 'TOOL_CALL_START',
data: { name: 'list_directory' },
},
});
} as GeminiEvent);
});
expect((result.current.toolCalls[0] as any).response?.resultDisplay).toContain(
'🛠️ Calling TestTool...',
);
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result.current.toolCalls[0] as any).response?.resultDisplay,
).toContain('🛠️ Calling codebase_investigator...');
});
it('flushes to history on TurnFinished', async () => {
@@ -150,14 +170,21 @@ describe('useAgentHarness', () => {
// Setup some state
await act(async () => {
(result.current as any).processEvent({ type: ServerGeminiEventType.Content, value: 'Done' });
(result.current as any).processEvent({ type: ServerGeminiEventType.TurnFinished });
result.current.processEvent({
type: ServerGeminiEventType.Content,
value: 'Done',
} as GeminiEvent);
result.current.processEvent({
type: ServerGeminiEventType.TurnFinished,
} as GeminiEvent);
});
expect(mockAddItem).toHaveBeenCalledWith(expect.objectContaining({
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.GEMINI,
text: 'Done'
}));
text: 'Done',
}),
);
expect(result.current.streamingContent).toBe(''); // Should be cleared
});
});
+56 -30
View File
@@ -8,12 +8,16 @@ import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import {
GeminiEventType as ServerGeminiEventType,
ROOT_SCHEDULER_ID,
AgentFactory,
MessageBusType,
} from '@google/gemini-cli-core';
import { AgentFactory } from '@google/gemini-cli-core/dist/src/agents/agent-factory.js';
import type {
Config,
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
RetryAttemptPayload,
ToolCallsUpdateMessage,
ValidatingToolCall,
} from '@google/gemini-cli-core';
import { type PartListUnion, type Part } from '@google/genai';
import {
@@ -27,7 +31,6 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
import type { TrackedToolCall } from './useToolScheduler.js';
import { type BackgroundShell } from './shellReducer.js';
import type { RetryAttemptPayload } from '@google/gemini-cli-core';
export interface UseAgentHarnessReturn {
streamingState: StreamingState;
@@ -87,7 +90,7 @@ export const useAgentHarness = (
// Listen to the MessageBus for live tool updates (e.g. from subagents or long-running tools)
useEffect(() => {
const bus = config.getMessageBus();
const handler = (event: any) => {
const handler = (event: ToolCallsUpdateMessage) => {
setToolCalls((prev) => {
const next = [...prev];
for (const coreCall of event.toolCalls) {
@@ -98,16 +101,16 @@ export const useAgentHarness = (
next[index] = {
...next[index],
...coreCall,
} as TrackedToolCall;
};
}
}
toolCallsRef.current = next;
return next;
});
};
bus.subscribe('tool-calls-update' as any, handler);
bus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
return () => {
bus.unsubscribe('tool-calls-update' as any, handler);
bus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
};
}, [config]);
@@ -120,7 +123,7 @@ export const useAgentHarness = (
items.push({
type: MessageType.THINKING,
thought,
} as any as HistoryItemWithoutId);
} as HistoryItemWithoutId);
}
if (toolCalls.length > 0) {
const unpushed = toolCalls.filter(
@@ -128,7 +131,7 @@ export const useAgentHarness = (
);
if (unpushed.length > 0) {
items.push(
mapToDisplayInternal(unpushed as TrackedToolCall[], {
mapToDisplayInternal(unpushed, {
borderBottom: true,
}),
);
@@ -181,7 +184,7 @@ export const useAgentHarness = (
{
setThought(null);
const tool = config.getToolRegistry().getTool(event.value.name);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
const invocation = (tool as any)?.createInvocation?.(
event.value.args,
config.getMessageBus(),
@@ -189,16 +192,17 @@ export const useAgentHarness = (
// In Harness mode, top-level calls might not have schedulerId set yet.
// We default to ROOT_SCHEDULER_ID to ensure they are visible.
const newCall = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newCall: TrackedToolCall = {
request: {
...event.value,
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
},
status: 'validating',
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
tool,
invocation,
} as TrackedToolCall;
tool: tool || undefined,
invocation: invocation || undefined,
} as ValidatingToolCall;
const nextCalls = [...toolCallsRef.current, newCall];
toolCallsRef.current = nextCalls;
@@ -211,10 +215,11 @@ export const useAgentHarness = (
const response = event.value;
const nextCalls = toolCallsRef.current.map((tc) =>
tc.request.callId === response.callId
? ({
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
...tc,
status: 'success',
response: response,
response,
} as unknown as TrackedToolCall)
: tc,
);
@@ -229,7 +234,7 @@ export const useAgentHarness = (
addItem({
type: MessageType.THINKING,
thought: thoughtRef.current,
} as any as HistoryItemWithoutId);
} as HistoryItemWithoutId);
setThought(null);
}
@@ -239,7 +244,7 @@ export const useAgentHarness = (
);
if (unpushed.length > 0) {
addItem(
mapToDisplayInternal(unpushed as TrackedToolCall[], {
mapToDisplayInternal(unpushed, {
borderBottom: true,
}),
);
@@ -275,8 +280,14 @@ export const useAgentHarness = (
(tc.tool?.displayName || tc.request.name) === activity.agentName
) {
matched = true;
const currentCall = tc as any;
let output = currentCall.response?.resultDisplay || '';
let output = '';
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
output = String(tc.response.resultDisplay || '');
}
if (typeof output !== 'string') output = '';
if (activity.type === 'TOOL_CALL_START') {
@@ -285,14 +296,24 @@ export const useAgentHarness = (
const displayName = tool?.displayName || rawName;
output += `🛠️ Calling ${displayName}...\n`;
} else if (activity.type === 'THOUGHT') {
const subject = String(activity.data['subject'] || 'Thinking');
const subject = String(
activity.data['subject'] || 'Thinking',
);
output += `🤖💭 ${subject}\n`;
}
const currentResponse =
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
? tc.response
: {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...tc,
response: {
...(currentCall.response || {}),
...currentResponse,
resultDisplay: output,
},
} as unknown as TrackedToolCall;
@@ -329,15 +350,17 @@ export const useAgentHarness = (
// Listen for nested subagent activity on the MessageBus
useEffect(() => {
const bus = config.getMessageBus();
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
const handler = (event: any) => {
processEvent({
type: ServerGeminiEventType.SubagentActivity,
value: event.activity,
});
} as any as GeminiEvent);
};
bus.subscribe('subagent-activity' as any, handler);
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
bus.subscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
return () => {
bus.unsubscribe('subagent-activity' as any, handler);
bus.unsubscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
};
}, [config, processEvent]);
@@ -350,9 +373,11 @@ export const useAgentHarness = (
const harness = AgentFactory.createHarness(config);
// Convert parts to Part[] array for harness
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
const requestParts: Part[] = Array.isArray(parts)
? (parts as Part[])
: [{ text: String(parts) }];
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
const stream = harness.run(
requestParts,
@@ -406,15 +431,16 @@ export const useAgentHarness = (
*/
function mapToDisplayInternal(
calls: TrackedToolCall[],
options: any,
options: { borderTop?: boolean; borderBottom?: boolean },
): HistoryItemWithoutId {
// We filter out any tool calls that are NOT part of the root harness level.
// This prevents internal subagent work (like list_directory) from appearing
// as loose tool boxes in the main chat.
const filtered = calls.filter((c) => {
// Only show tools belonging to the main top-level session.
return c.schedulerId === ROOT_SCHEDULER_ID;
});
const filtered = calls.filter(
(c) =>
// Only show tools belonging to the main top-level session.
c.schedulerId === ROOT_SCHEDULER_ID,
);
return mapTrackedToolCallsToDisplay(filtered as any, options);
return mapTrackedToolCallsToDisplay(filtered, options);
}