mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d78db4f514 | |||
| 3e1be515e5 | |||
| 469f5b7a68 | |||
| b2946b1052 |
@@ -1185,6 +1185,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
useAgentStream({
|
||||
agent: streamAgent,
|
||||
addItem: historyManager.addItem,
|
||||
handleSlashCommand,
|
||||
onCancelSubmit,
|
||||
isShellFocused: embeddedShellFocused,
|
||||
logger,
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import type { LegacyAgentProtocol } from '@google/gemini-cli-core';
|
||||
import type { AgentProtocol } from '@google/gemini-cli-core';
|
||||
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||
|
||||
// --- MOCKS ---
|
||||
|
||||
const mockLegacyAgentProtocol = vi.hoisted(() => ({
|
||||
const mockAgentProtocol = vi.hoisted(() => ({
|
||||
send: vi.fn().mockResolvedValue({ streamId: 'test-stream-id' }),
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -40,24 +40,31 @@ describe('useAgentStream', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize on mount', async () => {
|
||||
await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: vi.fn().mockResolvedValue(false),
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLegacyAgentProtocol.subscribe).toHaveBeenCalled();
|
||||
expect(mockAgentProtocol.subscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call agent.send when submitQuery is called', async () => {
|
||||
const mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
@@ -67,7 +74,7 @@ describe('useAgentStream', () => {
|
||||
await result.current.submitQuery('hello');
|
||||
});
|
||||
|
||||
expect(mockLegacyAgentProtocol.send).toHaveBeenCalledWith({
|
||||
expect(mockAgentProtocol.send).toHaveBeenCalledWith({
|
||||
message: { content: [{ type: 'text', text: 'hello' }] },
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
@@ -76,17 +83,70 @@ describe('useAgentStream', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should update streamingState based on agent_start and agent_end events', async () => {
|
||||
it('should intercept slash commands and not call agent.send if handled', async () => {
|
||||
const mockHandleSlashCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'handled' });
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/about');
|
||||
});
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/about');
|
||||
expect(mockAgentProtocol.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should intercept slash commands and call agent.send with new content if submit_prompt', async () => {
|
||||
const mockHandleSlashCommand = vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: 'modified prompt',
|
||||
});
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/mcp-prompt');
|
||||
});
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/mcp-prompt');
|
||||
expect(mockAgentProtocol.send).toHaveBeenCalledWith({
|
||||
message: { content: [{ type: 'text', text: 'modified prompt' }] },
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: MessageType.USER, text: '/mcp-prompt' }),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update streamingState based on agent_start and agent_end events', async () => {
|
||||
const mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventHandler = vi.mocked(mockAgentProtocol.subscribe).mock
|
||||
.calls[0][0];
|
||||
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
@@ -116,14 +176,15 @@ describe('useAgentStream', () => {
|
||||
it('should accumulate text content and update pendingHistoryItems', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: vi.fn().mockResolvedValue(false),
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
|
||||
const eventHandler = vi.mocked(mockAgentProtocol.subscribe).mock
|
||||
.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
@@ -160,14 +221,15 @@ describe('useAgentStream', () => {
|
||||
it('should process thought events and update thought state', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: vi.fn().mockResolvedValue(false),
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
|
||||
const eventHandler = vi.mocked(mockAgentProtocol.subscribe).mock
|
||||
.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
@@ -190,8 +252,9 @@ describe('useAgentStream', () => {
|
||||
it('should call agent.abort when cancelOngoingRequest is called', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
agent: mockAgentProtocol as unknown as AgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
handleSlashCommand: vi.fn().mockResolvedValue(false),
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
@@ -201,7 +264,7 @@ describe('useAgentStream', () => {
|
||||
await result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled();
|
||||
expect(mockAgentProtocol.abort).toHaveBeenCalled();
|
||||
expect(mockOnCancelSubmit).toHaveBeenCalledWith(false, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
import {
|
||||
getErrorMessage,
|
||||
MessageSenderType,
|
||||
debugLogger,
|
||||
geminiPartsToContentParts,
|
||||
displayContentToString,
|
||||
partToString,
|
||||
parseThought,
|
||||
CoreToolCallStatus,
|
||||
type ApprovalMode,
|
||||
@@ -20,15 +22,16 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentProtocol,
|
||||
type Logger,
|
||||
type Part,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryItemWithoutId,
|
||||
LoopDetectionConfirmationRequest,
|
||||
IndividualToolCallDisplay,
|
||||
HistoryItemToolDisplayGroup,
|
||||
SlashCommandProcessorResult,
|
||||
} from '../types.js';
|
||||
import { StreamingState, MessageType } from '../types.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
|
||||
import { type BackgroundTask } from './useExecutionLifecycle.js';
|
||||
@@ -41,6 +44,9 @@ import { useKeypress } from './useKeypress.js';
|
||||
export interface UseAgentStreamOptions {
|
||||
agent?: AgentProtocol;
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
handleSlashCommand: (
|
||||
cmd: string,
|
||||
) => Promise<SlashCommandProcessorResult | false>;
|
||||
onCancelSubmit: (
|
||||
shouldRestorePrompt?: boolean,
|
||||
clearBuffer?: boolean,
|
||||
@@ -56,6 +62,7 @@ export interface UseAgentStreamOptions {
|
||||
export const useAgentStream = ({
|
||||
agent,
|
||||
addItem,
|
||||
handleSlashCommand,
|
||||
onCancelSubmit,
|
||||
isShellFocused,
|
||||
logger,
|
||||
@@ -333,8 +340,8 @@ export const useAgentStream = ({
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape' && !isShellFocused) {
|
||||
void cancelOngoingRequest(false);
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
void cancelOngoingRequest();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -348,7 +355,7 @@ export const useAgentStream = ({
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (
|
||||
query: Part[] | string,
|
||||
query: PartListUnion,
|
||||
options?: { isContinuation: boolean },
|
||||
_prompt_id?: string,
|
||||
) => {
|
||||
@@ -360,16 +367,59 @@ export const useAgentStream = ({
|
||||
|
||||
geminiMessageBufferRef.current = '';
|
||||
|
||||
let localQuery: PartListUnion = query;
|
||||
|
||||
if (!options?.isContinuation) {
|
||||
if (typeof query === 'string') {
|
||||
addItem({ type: MessageType.USER, text: query }, timestamp);
|
||||
void logger?.logMessage(MessageSenderType.USER, query);
|
||||
let shouldAddToHistory = true;
|
||||
if (typeof localQuery === 'string') {
|
||||
const trimmedQuery = localQuery.trim();
|
||||
void logger?.logMessage(MessageSenderType.USER, trimmedQuery);
|
||||
|
||||
if (isSlashCommand(trimmedQuery)) {
|
||||
const slashResult = await handleSlashCommand(trimmedQuery);
|
||||
if (slashResult) {
|
||||
if (slashResult.type === 'submit_prompt') {
|
||||
localQuery = slashResult.content;
|
||||
} else if (slashResult.type === 'schedule_tool') {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `The /${slashResult.toolName} command is not yet supported in Agent mode.`,
|
||||
},
|
||||
timestamp,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
// 'handled' or other types that don't need LLM submission
|
||||
shouldAddToHistory = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAddToHistory) {
|
||||
const originalQueryText =
|
||||
typeof query === 'string' ? query : partToString(query);
|
||||
|
||||
addItem(
|
||||
{ type: MessageType.USER, text: originalQueryText },
|
||||
timestamp,
|
||||
);
|
||||
if (typeof localQuery !== 'string') {
|
||||
void logger?.logMessage(
|
||||
MessageSenderType.USER,
|
||||
partToString(localQuery),
|
||||
);
|
||||
}
|
||||
}
|
||||
startNewPrompt();
|
||||
}
|
||||
|
||||
const parts = geminiPartsToContentParts(
|
||||
typeof query === 'string' ? [{ text: query }] : query,
|
||||
(Array.isArray(localQuery) ? localQuery : [localQuery]).map((p) =>
|
||||
typeof p === 'string' ? { text: p } : p,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -384,9 +434,8 @@ export const useAgentStream = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
[agent, addItem, logger, startNewPrompt],
|
||||
[agent, addItem, logger, startNewPrompt, handleSlashCommand],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTools.length > 0) {
|
||||
const isNewBatch = !trackedTools.some((tc) =>
|
||||
@@ -397,6 +446,7 @@ export const useAgentStream = ({
|
||||
setIsFirstToolInGroup(true);
|
||||
}
|
||||
} else if (streamingState === StreamingState.Idle) {
|
||||
// Clear when idle to be ready for next turn
|
||||
setPushedToolCallIds(new Set());
|
||||
setIsFirstToolInGroup(true);
|
||||
}
|
||||
|
||||
@@ -387,6 +387,11 @@ export const useGeminiStream = (
|
||||
[setIsResponding],
|
||||
);
|
||||
|
||||
const streamingState = useMemo(
|
||||
() => calculateStreamingState(isResponding, toolCalls),
|
||||
[isResponding, toolCalls],
|
||||
);
|
||||
|
||||
const {
|
||||
handleShellCommand,
|
||||
activeShellPtyId,
|
||||
@@ -409,11 +414,7 @@ export const useGeminiStream = (
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
activeBackgroundExecutionId,
|
||||
);
|
||||
|
||||
const streamingState = useMemo(
|
||||
() => calculateStreamingState(isResponding, toolCalls),
|
||||
[isResponding, toolCalls],
|
||||
streamingState === StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
|
||||
// Reset tracking when a new batch of tools starts
|
||||
@@ -599,6 +600,7 @@ export const useGeminiStream = (
|
||||
backgroundTasks,
|
||||
settings.merged.ui?.compactToolOutput,
|
||||
]);
|
||||
|
||||
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
||||
const remainingTools = toolCalls.filter(
|
||||
(tc) => !pushedToolCallIds.has(tc.request.callId),
|
||||
@@ -983,16 +985,28 @@ export const useGeminiStream = (
|
||||
|
||||
if (postSubmitPrompt) {
|
||||
localQueryToSendToGemini = postSubmitPrompt;
|
||||
addItem(
|
||||
{ type: MessageType.USER, text: trimmedQuery },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return {
|
||||
queryToSend: localQueryToSendToGemini,
|
||||
shouldProceed: true,
|
||||
};
|
||||
}
|
||||
|
||||
addItem(
|
||||
{ type: MessageType.USER, text: trimmedQuery },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
case 'submit_prompt': {
|
||||
localQueryToSendToGemini = slashCommandResult.content;
|
||||
addItem(
|
||||
{ type: MessageType.USER, text: trimmedQuery },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
return {
|
||||
queryToSend: localQueryToSendToGemini,
|
||||
|
||||
Reference in New Issue
Block a user