feat(core): infrastructure for event-driven subagent history (#23914)

This commit is contained in:
Abhi
2026-03-31 17:54:22 -04:00
committed by GitHub
parent 6d48a12efe
commit 9364dd8a49
16 changed files with 525 additions and 91 deletions

View File

@@ -10,12 +10,19 @@ import {
type ToolResultDisplay,
debugLogger,
CoreToolCallStatus,
type SubagentActivityItem,
} from '@google/gemini-cli-core';
import {
type HistoryItemToolGroup,
type IndividualToolCallDisplay,
} from '../types.js';
function hasSubagentHistory(
call: ToolCall,
): call is ToolCall & { subagentHistory: SubagentActivityItem[] } {
return 'subagentHistory' in call && call.subagentHistory !== undefined;
}
/**
* Transforms `ToolCall` objects into `HistoryItemToolGroup` objects for UI
* display. This is a pure projection layer and does not track interaction
@@ -115,6 +122,9 @@ export function mapToDisplay(
progressTotal,
approvalMode: call.approvalMode,
originalRequestName: call.request.originalRequestName,
subagentHistory: hasSubagentHistory(call)
? call.subagentHistory
: undefined,
};
});

View File

@@ -163,7 +163,6 @@ describe('useToolScheduler', () => {
},
};
// 1. Initial success
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
@@ -172,7 +171,6 @@ describe('useToolScheduler', () => {
} as ToolCallsUpdateMessage);
});
// 2. Mark as submitted
act(() => {
const [, , markAsSubmitted] = result.current;
markAsSubmitted(['call-1']);
@@ -180,7 +178,7 @@ describe('useToolScheduler', () => {
expect(result.current[0][0].responseSubmittedToGemini).toBe(true);
// 3. Receive another update (should preserve the true flag)
// Verify flag is preserved across updates
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
@@ -348,7 +346,6 @@ describe('useToolScheduler', () => {
confirmationDetails: { type: 'info', title: 'Confirm', prompt: 'Yes?' },
};
// 1. Populate state with multiple schedulers
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
@@ -372,7 +369,6 @@ describe('useToolScheduler', () => {
toolCalls.find((t) => t.request.callId === 'call-sub'),
).toBeDefined();
// 2. Call setToolCallsForDisplay (e.g., simulate a manual update or clear)
act(() => {
const [, , , setToolCalls] = result.current;
setToolCalls((prev) =>
@@ -380,7 +376,6 @@ describe('useToolScheduler', () => {
);
});
// 3. Verify that tools are still present and maintain their scheduler IDs
const [toolCalls2] = result.current;
expect(toolCalls2).toHaveLength(2);
expect(toolCalls2.every((t) => t.responseSubmittedToGemini)).toBe(true);
@@ -482,7 +477,6 @@ describe('useToolScheduler', () => {
confirmationDetails: { type: 'info', title: 'Confirm', prompt: 'Yes?' },
} as WaitingToolCall;
// 1. Initial approval request
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
@@ -493,7 +487,6 @@ describe('useToolScheduler', () => {
expect(result.current[0]).toHaveLength(1);
// 2. Approved and executing
const approvedCall = {
...subagentCall,
status: CoreToolCallStatus.Executing as const,
@@ -510,7 +503,7 @@ describe('useToolScheduler', () => {
expect(result.current[0]).toHaveLength(1);
expect(result.current[0][0].status).toBe(CoreToolCallStatus.Executing);
// 3. New turn with a background tool (should NOT be shown)
// Background tool should not be shown
const backgroundTool = {
status: CoreToolCallStatus.Executing as const,
request: {
@@ -595,4 +588,144 @@ describe('useToolScheduler', () => {
vi.useRealTimers();
});
it('accumulates SUBAGENT_ACTIVITY events and attaches them to toolCalls', async () => {
const { result } = await renderHook(() =>
useToolScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: CoreToolCallStatus.Executing as const,
request: {
callId: 'call-1',
name: 'research',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool({ name: 'research' }),
invocation: createMockInvocation(),
} as ExecutingToolCall;
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
expect(result.current[0]).toHaveLength(1);
expect(result.current[0][0].subagentHistory).toBeUndefined();
act(() => {
void mockMessageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: 'research',
activity: {
id: '1',
type: 'thought',
content: 'Thinking...',
status: 'running',
},
});
});
expect(result.current[0][0].subagentHistory).toHaveLength(1);
expect(result.current[0][0].subagentHistory![0].content).toBe(
'Thinking...',
);
act(() => {
void mockMessageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: 'research',
activity: {
id: '2',
type: 'tool_call',
content: 'Calling tool',
status: 'completed',
},
});
});
expect(result.current[0][0].subagentHistory).toHaveLength(2);
expect(result.current[0][0].subagentHistory![1].content).toBe(
'Calling tool',
);
});
it('replaces SUBAGENT_ACTIVITY events by ID instead of appending', async () => {
const { result } = await renderHook(() =>
useToolScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: CoreToolCallStatus.Executing as const,
request: {
callId: 'call-1',
name: 'research',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool({ name: 'research' }),
invocation: createMockInvocation(),
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
});
});
act(() => {
void mockMessageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: 'research',
activity: {
id: '1',
type: 'thought',
content: 'Thinking...',
status: 'running',
},
});
});
expect(result.current[0][0].subagentHistory).toHaveLength(1);
expect(result.current[0][0].subagentHistory![0].content).toBe(
'Thinking...',
);
// Publish same ID with updated content
act(() => {
void mockMessageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: 'research',
activity: {
id: '1',
type: 'thought',
content: 'Thinking... Done!',
status: 'completed',
},
});
});
// Should still be length 1, and content should be updated
expect(result.current[0][0].subagentHistory).toHaveLength(1);
expect(result.current[0][0].subagentHistory![0].content).toBe(
'Thinking... Done!',
);
expect(result.current[0][0].subagentHistory![0].status).toBe('completed');
});
});

View File

@@ -15,6 +15,8 @@ import {
type EditorType,
type ToolCallsUpdateMessage,
CoreToolCallStatus,
type SubagentActivityItem,
type SubagentActivityMessage,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
@@ -33,6 +35,7 @@ export type CancelAllFn = (signal: AbortSignal) => void;
*/
export type TrackedToolCall = ToolCall & {
responseSubmittedToGemini?: boolean;
subagentHistory?: SubagentActivityItem[];
};
// Narrowed types for specific statuses (used by useGeminiStream)
@@ -81,6 +84,9 @@ export function useToolScheduler(
Record<string, TrackedToolCall[]>
>({});
const [lastToolOutputTime, setLastToolOutputTime] = useState<number>(0);
const [subagentHistoryMap, setSubagentHistoryMap] = useState<
Record<string, SubagentActivityItem[]>
>({});
const messageBus = useMemo(() => config.getMessageBus(), [config]);
@@ -173,10 +179,37 @@ export function useToolScheduler(
};
}, [messageBus, internalAdaptToolCalls]);
useEffect(() => {
const handler = (event: SubagentActivityMessage) => {
setSubagentHistoryMap((prev) => {
const history = prev[event.subagentName] ?? [];
const index = history.findIndex(
(item) => item.id === event.activity.id,
);
const nextHistory = [...history];
if (index >= 0) {
nextHistory[index] = event.activity;
} else {
nextHistory.push(event.activity);
}
return {
...prev,
[event.subagentName]: nextHistory,
};
});
};
messageBus.subscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
return () => {
messageBus.unsubscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
};
}, [messageBus]);
const schedule: ScheduleFn = useCallback(
async (request, signal) => {
// Clear state for new run
setToolCallsMap({});
setSubagentHistoryMap({});
// 1. Await Core Scheduler directly
const results = await scheduler.schedule(request, signal);
@@ -216,10 +249,38 @@ export function useToolScheduler(
);
// Flatten the map for the UI components that expect a single list of tools.
const toolCalls = useMemo(
() => Object.values(toolCallsMap).flat(),
[toolCallsMap],
);
const toolCalls = useMemo(() => {
const flattened = Object.values(toolCallsMap).flat();
return flattened.map((tc) => {
let subagentName = tc.request.name;
if (tc.request.name === 'invoke_subagent') {
const argsObj = tc.request.args;
let parsedArgs: unknown = argsObj;
if (typeof argsObj === 'string') {
try {
parsedArgs = JSON.parse(argsObj);
} catch {
parsedArgs = null;
}
}
if (typeof parsedArgs === 'object' && parsedArgs !== null) {
for (const [key, value] of Object.entries(parsedArgs)) {
if (key === 'subagent_name' && typeof value === 'string') {
subagentName = value;
break;
}
}
}
}
return {
...tc,
subagentHistory: subagentHistoryMap[subagentName] ?? tc.subagentHistory,
};
});
}, [toolCallsMap, subagentHistoryMap]);
// Provide a setter that maintains compatibility with legacy [].
const setToolCallsForDisplay = useCallback(
@@ -272,7 +333,6 @@ function adaptToolCalls(
return coreCalls.map((coreCall): TrackedToolCall => {
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
let status = coreCall.status;
// If a tool call has completed but scheduled a tail call, it is in a transitional
// state. Force the UI to render it as "executing".