mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 21:51:11 -07:00
Merge branch 'main' into fix/headless-log
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@ updates to the CLI interface.
|
||||
- `todos` (array of objects, required): The complete list of tasks. Each object
|
||||
includes:
|
||||
- `description` (string): Technical description of the task.
|
||||
- `status` (enum): `pending`, `in_progress`, `completed`, or `cancelled`.
|
||||
- `status` (enum): `pending`, `in_progress`, `completed`, `cancelled`, or
|
||||
`blocked`.
|
||||
|
||||
## Technical behavior
|
||||
|
||||
|
||||
@@ -763,6 +763,48 @@ describe('loadCliConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should add IDE workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH to include directories', async () => {
|
||||
vi.stubEnv(
|
||||
'GEMINI_CLI_IDE_WORKSPACE_PATH',
|
||||
['/project/folderA', '/project/folderB'].join(path.delimiter),
|
||||
);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
const dirs = config.getPendingIncludeDirectories();
|
||||
expect(dirs).toContain('/project/folderA');
|
||||
expect(dirs).toContain('/project/folderB');
|
||||
});
|
||||
|
||||
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
|
||||
const resolveToRealPathSpy = vi
|
||||
.spyOn(ServerConfig, 'resolveToRealPath')
|
||||
.mockImplementation((p) => {
|
||||
if (p.toString().includes('restricted')) {
|
||||
const err = new Error('EACCES: permission denied');
|
||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
vi.stubEnv(
|
||||
'GEMINI_CLI_IDE_WORKSPACE_PATH',
|
||||
['/project/folderA', '/nonexistent/restricted/folder'].join(
|
||||
path.delimiter,
|
||||
),
|
||||
);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
const dirs = config.getPendingIncludeDirectories();
|
||||
expect(dirs).toContain('/project/folderA');
|
||||
expect(dirs).not.toContain('/nonexistent/restricted/folder');
|
||||
|
||||
resolveToRealPathSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use default fileFilter options when unconfigured', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -798,6 +840,7 @@ describe('loadCliConfig', () => {
|
||||
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
|
||||
// Restore ExtensionManager mocks that were reset
|
||||
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||
ExtensionManager.prototype.loadExtensions = vi
|
||||
@@ -809,6 +852,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -430,8 +430,6 @@ export async function loadCliConfig(
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
|
||||
if (argv.sandbox) {
|
||||
process.env['GEMINI_SANDBOX'] = 'true';
|
||||
}
|
||||
@@ -475,10 +473,32 @@ export async function loadCliConfig(
|
||||
...settings.context?.fileFiltering,
|
||||
};
|
||||
|
||||
//changes the includeDirectories to be absolute paths based on the cwd, and also include any additional directories specified via CLI args
|
||||
const includeDirectories = (settings.context?.includeDirectories || [])
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
|
||||
// When running inside VSCode with multiple workspace folders,
|
||||
// automatically add the other folders as include directories
|
||||
// so Gemini has context of all open folders, not just the cwd.
|
||||
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
if (ideWorkspacePath) {
|
||||
const realCwd = resolveToRealPath(cwd);
|
||||
const ideFolders = ideWorkspacePath.split(path.delimiter).filter((p) => {
|
||||
const trimmedPath = p.trim();
|
||||
if (!trimmedPath) return false;
|
||||
try {
|
||||
return resolveToRealPath(trimmedPath) !== realCwd;
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
includeDirectories.push(...ideFolders);
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
@@ -864,7 +884,7 @@ export async function loadCliConfig(
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||
onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model),
|
||||
onReload: async () => {
|
||||
const refreshedSettings = loadSettings(cwd);
|
||||
return {
|
||||
|
||||
@@ -280,14 +280,14 @@ export class AppRig {
|
||||
}
|
||||
|
||||
private stubRefreshAuth() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const gcConfig = this.config as any;
|
||||
gcConfig.refreshAuth = async (authMethod: AuthType) => {
|
||||
gcConfig.modelAvailabilityService.reset();
|
||||
|
||||
const newContentGeneratorConfig = {
|
||||
authType: authMethod,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
|
||||
proxy: gcConfig.getProxy(),
|
||||
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
||||
};
|
||||
@@ -456,7 +456,7 @@ export class AppRig {
|
||||
const actualToolName = toolName === '*' ? undefined : toolName;
|
||||
this.config
|
||||
.getPolicyEngine()
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
.removeRulesForTool(actualToolName as string, source);
|
||||
this.breakpointTools.delete(toolName);
|
||||
}
|
||||
@@ -729,7 +729,7 @@ export class AppRig {
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (recordingService) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(recordingService as any).conversationFile = null;
|
||||
}
|
||||
}
|
||||
@@ -749,7 +749,7 @@ export class AppRig {
|
||||
MockShellExecutionService.reset();
|
||||
ideContextStore.clear();
|
||||
// Forcefully clear IdeClient singleton promise
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(IdeClient as any).instancePromise = null;
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('<ChecklistItem />', () => {
|
||||
{ status: 'in_progress', label: 'Doing this' },
|
||||
{ status: 'completed', label: 'Done this' },
|
||||
{ status: 'cancelled', label: 'Skipped this' },
|
||||
{ status: 'blocked', label: 'Blocked this' },
|
||||
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
|
||||
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -13,7 +13,8 @@ export type ChecklistStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'cancelled';
|
||||
| 'cancelled'
|
||||
| 'blocked';
|
||||
|
||||
export interface ChecklistItemData {
|
||||
status: ChecklistStatus;
|
||||
@@ -48,6 +49,12 @@ const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
|
||||
✗
|
||||
</Text>
|
||||
);
|
||||
case 'blocked':
|
||||
return (
|
||||
<Text color={theme.status.warning} aria-label="Blocked">
|
||||
⛔
|
||||
</Text>
|
||||
);
|
||||
default:
|
||||
checkExhaustive(status);
|
||||
}
|
||||
@@ -70,6 +77,7 @@ export const ChecklistItem: React.FC<ChecklistItemProps> = ({
|
||||
return theme.text.accent;
|
||||
case 'completed':
|
||||
case 'cancelled':
|
||||
case 'blocked':
|
||||
return theme.text.secondary;
|
||||
case 'pending':
|
||||
return theme.text.primary;
|
||||
|
||||
@@ -110,78 +110,17 @@ const SESSIONS_PER_PAGE = 20;
|
||||
// If the SessionItem layout changes, update this accordingly.
|
||||
const FIXED_SESSION_COLUMNS_WIDTH = 30;
|
||||
|
||||
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
|
||||
<>
|
||||
{name}: <Text bold>{shortcut}</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
import {
|
||||
SearchModeDisplay,
|
||||
NavigationHelpDisplay,
|
||||
NoResultsDisplay,
|
||||
} from './SessionBrowser/SessionBrowserNav.js';
|
||||
import { SessionListHeader } from './SessionBrowser/SessionListHeader.js';
|
||||
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
|
||||
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
|
||||
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
|
||||
|
||||
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
|
||||
|
||||
/**
|
||||
* Search input display component.
|
||||
*/
|
||||
const SearchModeDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>Search: </Text>
|
||||
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
|
||||
<Text color={Colors.Gray}> (Esc to cancel)</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Header component showing session count and sort information.
|
||||
*/
|
||||
const SessionListHeader = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={Colors.AccentPurple}>
|
||||
Chat Sessions ({state.totalSessions} total
|
||||
{state.searchQuery ? `, filtered` : ''})
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigation help component showing keyboard shortcuts.
|
||||
*/
|
||||
const NavigationHelp = (): React.JSX.Element => (
|
||||
<Box flexDirection="column">
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Navigate" shortcut="↑/↓" />
|
||||
{' '}
|
||||
<Kbd name="Resume" shortcut="Enter" />
|
||||
{' '}
|
||||
<Kbd name="Search" shortcut="/" />
|
||||
{' '}
|
||||
<Kbd name="Delete" shortcut="x" />
|
||||
{' '}
|
||||
<Kbd name="Quit" shortcut="q" />
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Sort" shortcut="s" />
|
||||
{' '}
|
||||
<Kbd name="Reverse" shortcut="r" />
|
||||
{' '}
|
||||
<Kbd name="First/Last" shortcut="g/G" />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Table header component with column labels and scroll indicators.
|
||||
*/
|
||||
@@ -219,21 +158,6 @@ const SessionTableHeader = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* No results display component for empty search results.
|
||||
*/
|
||||
const NoResultsDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray} dimColor>
|
||||
No sessions found matching '{state.searchQuery}'.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Match snippet display component for search results.
|
||||
*/
|
||||
@@ -398,7 +322,7 @@ const SessionList = ({
|
||||
<Box flexDirection="column">
|
||||
{/* Table Header */}
|
||||
<Box flexDirection="column">
|
||||
{!state.isSearchMode && <NavigationHelp />}
|
||||
{!state.isSearchMode && <NavigationHelpDisplay />}
|
||||
<SessionTableHeader state={state} />
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
|
||||
<>
|
||||
{name}: <Text bold>{shortcut}</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigation help component showing keyboard shortcuts.
|
||||
*/
|
||||
export const NavigationHelpDisplay = (): React.JSX.Element => (
|
||||
<Box flexDirection="column">
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Navigate" shortcut="↑/↓" />
|
||||
{' '}
|
||||
<Kbd name="Resume" shortcut="Enter" />
|
||||
{' '}
|
||||
<Kbd name="Search" shortcut="/" />
|
||||
{' '}
|
||||
<Kbd name="Delete" shortcut="x" />
|
||||
{' '}
|
||||
<Kbd name="Quit" shortcut="q" />
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Sort" shortcut="s" />
|
||||
{' '}
|
||||
<Kbd name="Reverse" shortcut="r" />
|
||||
{' '}
|
||||
<Kbd name="First/Last" shortcut="g/G" />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Search input display component.
|
||||
*/
|
||||
export const SearchModeDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>Search: </Text>
|
||||
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
|
||||
<Text color={Colors.Gray}> (Esc to cancel)</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* No results display component for empty search results.
|
||||
*/
|
||||
export const NoResultsDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray} dimColor>
|
||||
No sessions found matching '{state.searchQuery}'.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SearchModeDisplay,
|
||||
NavigationHelpDisplay,
|
||||
NoResultsDisplay,
|
||||
} from './SessionBrowserNav.js';
|
||||
import { SessionListHeader } from './SessionListHeader.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
describe('SessionBrowser Search and Navigation Components', () => {
|
||||
it('SearchModeDisplay renders correctly with query', async () => {
|
||||
const mockState = { searchQuery: 'test query' } as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SearchModeDisplay state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('NavigationHelp renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(<NavigationHelpDisplay />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionListHeader renders correctly', async () => {
|
||||
const mockState = {
|
||||
totalSessions: 10,
|
||||
searchQuery: '',
|
||||
sortOrder: 'date',
|
||||
sortReverse: false,
|
||||
} as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionListHeader state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionListHeader renders correctly with filter', async () => {
|
||||
const mockState = {
|
||||
totalSessions: 5,
|
||||
searchQuery: 'test',
|
||||
sortOrder: 'name',
|
||||
sortReverse: true,
|
||||
} as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionListHeader state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('NoResultsDisplay renders correctly', async () => {
|
||||
const mockState = { searchQuery: 'no match' } as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<NoResultsDisplay state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
/**
|
||||
* Header component showing session count and sort information.
|
||||
*/
|
||||
export const SessionListHeader = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={Colors.AccentPurple}>
|
||||
Chat Sessions ({state.totalSessions} total
|
||||
{state.searchQuery ? `, filtered` : ''})
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = `
|
||||
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
|
||||
Sort: s Reverse: r First/Last: g/G
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = `
|
||||
"
|
||||
No sessions found matching 'no match'.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = `
|
||||
"
|
||||
Search: test query (Esc to cancel)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = `
|
||||
"Chat Sessions (10 total) sorted by date desc
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = `
|
||||
"Chat Sessions (5 total, filtered) sorted by name asc
|
||||
"
|
||||
`;
|
||||
@@ -1,5 +1,10 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ChecklistItem /> > renders { status: 'blocked', label: 'Blocked this' } item correctly 1`] = `
|
||||
"⛔ Blocked this
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ChecklistItem /> > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `
|
||||
"✗ Skipped this
|
||||
"
|
||||
|
||||
@@ -42,33 +42,19 @@ export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
|
||||
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
name,
|
||||
|
||||
description,
|
||||
|
||||
resultDisplay,
|
||||
|
||||
status,
|
||||
|
||||
availableTerminalHeight,
|
||||
|
||||
terminalWidth,
|
||||
|
||||
emphasis = 'medium',
|
||||
|
||||
renderOutputAsMarkdown = true,
|
||||
|
||||
ptyId,
|
||||
|
||||
config,
|
||||
|
||||
isFirst,
|
||||
|
||||
borderColor,
|
||||
|
||||
borderDimColor,
|
||||
|
||||
isExpandable,
|
||||
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const {
|
||||
@@ -142,11 +128,9 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
|
||||
|
||||
const headerRef = React.useRef<DOMElement>(null);
|
||||
|
||||
const contentRef = React.useRef<DOMElement>(null);
|
||||
|
||||
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
|
||||
|
||||
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -156,7 +140,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
};
|
||||
|
||||
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
|
||||
|
||||
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
|
||||
|
||||
const { shouldShowFocusHint } = useFocusHint(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||
MarkdownDisplay: ({ text }: { text: string }) => <Text>{text}</Text>,
|
||||
}));
|
||||
|
||||
describe('<SubagentGroupDisplay />', () => {
|
||||
const mockToolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'agent_1',
|
||||
description: 'Test agent 1',
|
||||
confirmationDetails: undefined,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
kind: Kind.Agent,
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'api-monitor',
|
||||
state: 'running',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'act-1',
|
||||
type: 'tool_call',
|
||||
status: 'running',
|
||||
content: '',
|
||||
displayName: 'Action Required',
|
||||
description: 'Verify server is running',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
callId: 'call-2',
|
||||
name: 'agent_2',
|
||||
description: 'Test agent 2',
|
||||
confirmationDetails: undefined,
|
||||
status: CoreToolCallStatus.Success,
|
||||
kind: Kind.Agent,
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'db-manager',
|
||||
state: 'completed',
|
||||
result: 'Database schema validated',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'act-2',
|
||||
type: 'thought',
|
||||
status: 'completed',
|
||||
content: 'Database schema validated',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const renderSubagentGroup = (
|
||||
toolCallsToRender: IndividualToolCallDisplay[],
|
||||
height?: number,
|
||||
) => (
|
||||
<OverflowProvider>
|
||||
<KeypressProvider>
|
||||
<SubagentGroupDisplay
|
||||
toolCalls={toolCallsToRender}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</OverflowProvider>
|
||||
);
|
||||
|
||||
it('renders nothing if there are no agent tool calls', async () => {
|
||||
const { lastFrame } = render(renderSubagentGroup([], 40));
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
});
|
||||
|
||||
it('renders collapsed view by default with correct agent counts and states', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
renderSubagentGroup(mockToolCalls, 40),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('expands when availableTerminalHeight is undefined', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
renderSubagentGroup(mockToolCalls, 40),
|
||||
);
|
||||
|
||||
// Default collapsed view
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(ctrl+o to expand)');
|
||||
});
|
||||
|
||||
// Expand view
|
||||
rerender(renderSubagentGroup(mockToolCalls, undefined));
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(ctrl+o to collapse)');
|
||||
});
|
||||
|
||||
// Collapse view
|
||||
rerender(renderSubagentGroup(mockToolCalls, 40));
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(ctrl+o to expand)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useId } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import {
|
||||
isSubagentProgress,
|
||||
checkExhaustive,
|
||||
type SubagentActivityItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SubagentProgressDisplay,
|
||||
formatToolArgs,
|
||||
} from './SubagentProgressDisplay.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
|
||||
export interface SubagentGroupDisplayProps {
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
borderColor?: string;
|
||||
borderDimColor?: boolean;
|
||||
isFirst?: boolean;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
toolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isFirst,
|
||||
isExpandable = true,
|
||||
}) => {
|
||||
const isExpanded = availableTerminalHeight === undefined;
|
||||
const overflowActions = useOverflowActions();
|
||||
const uniqueId = useId();
|
||||
const overflowId = `subagent-${uniqueId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpandable && overflowActions) {
|
||||
// Register with the global overflow system so "ctrl+o to expand" shows in the sticky footer
|
||||
// and AppContainer passes the shortcut through.
|
||||
overflowActions.addOverflowingId(overflowId);
|
||||
}
|
||||
return () => {
|
||||
if (overflowActions) {
|
||||
overflowActions.removeOverflowingId(overflowId);
|
||||
}
|
||||
};
|
||||
}, [isExpandable, overflowActions, overflowId]);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let headerText = '';
|
||||
if (toolCalls.length === 1) {
|
||||
const singleAgent = toolCalls[0].resultDisplay;
|
||||
if (isSubagentProgress(singleAgent)) {
|
||||
switch (singleAgent.state) {
|
||||
case 'completed':
|
||||
headerText = 'Agent Completed';
|
||||
break;
|
||||
case 'cancelled':
|
||||
headerText = 'Agent Cancelled';
|
||||
break;
|
||||
case 'error':
|
||||
headerText = 'Agent Error';
|
||||
break;
|
||||
default:
|
||||
headerText = 'Running Agent...';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
headerText = 'Running Agent...';
|
||||
}
|
||||
} else {
|
||||
let completedCount = 0;
|
||||
let runningCount = 0;
|
||||
for (const tc of toolCalls) {
|
||||
const progress = tc.resultDisplay;
|
||||
if (isSubagentProgress(progress)) {
|
||||
if (progress.state === 'completed') completedCount++;
|
||||
else if (progress.state === 'running') runningCount++;
|
||||
} else {
|
||||
// It hasn't emitted progress yet, but it is "running"
|
||||
runningCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount === toolCalls.length) {
|
||||
headerText = `${toolCalls.length} Agents Completed`;
|
||||
} else if (completedCount > 0) {
|
||||
headerText = `${toolCalls.length} Agents (${runningCount} running, ${completedCount} completed)...`;
|
||||
} else {
|
||||
headerText = `Running ${toolCalls.length} Agents...`;
|
||||
}
|
||||
}
|
||||
const toggleText = `(ctrl+o to ${isExpanded ? 'collapse' : 'expand'})`;
|
||||
|
||||
const renderCollapsedRow = (
|
||||
key: string,
|
||||
agentName: string,
|
||||
icon: React.ReactNode,
|
||||
content: string,
|
||||
displayArgs?: string,
|
||||
) => (
|
||||
<Box key={key} flexDirection="row" marginLeft={0} marginTop={0}>
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text bold color={theme.text.primary} wrap="truncate">
|
||||
{agentName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
</Box>
|
||||
<Box flexShrink={1} minWidth={0}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{content}
|
||||
{displayArgs && ` ${displayArgs}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={terminalWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={isFirst}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingTop={0}
|
||||
paddingBottom={0}
|
||||
>
|
||||
<Box flexDirection="row" gap={1} marginBottom={isExpanded ? 1 : 0}>
|
||||
<Text color={theme.text.secondary}>≡</Text>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{headerText}
|
||||
</Text>
|
||||
{isExpandable && <Text color={theme.text.secondary}>{toggleText}</Text>}
|
||||
</Box>
|
||||
|
||||
{toolCalls.map((toolCall) => {
|
||||
const progress = toolCall.resultDisplay;
|
||||
|
||||
if (!isSubagentProgress(progress)) {
|
||||
const agentName = toolCall.name || 'agent';
|
||||
if (!isExpanded) {
|
||||
return renderCollapsedRow(
|
||||
toolCall.callId,
|
||||
agentName,
|
||||
<Text color={theme.text.primary}>!</Text>,
|
||||
'Starting...',
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box
|
||||
key={toolCall.callId}
|
||||
flexDirection="column"
|
||||
marginLeft={0}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={theme.text.primary}>!</Text>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{agentName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>Starting...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivity: SubagentActivityItem | undefined =
|
||||
progress.recentActivity[progress.recentActivity.length - 1];
|
||||
|
||||
// Collapsed View: Show single compact line per agent
|
||||
if (!isExpanded) {
|
||||
let content = 'Starting...';
|
||||
let formattedArgs: string | undefined;
|
||||
|
||||
if (progress.state === 'completed') {
|
||||
if (
|
||||
progress.terminateReason &&
|
||||
progress.terminateReason !== 'GOAL'
|
||||
) {
|
||||
content = `Finished Early (${progress.terminateReason})`;
|
||||
} else {
|
||||
content = 'Completed successfully';
|
||||
}
|
||||
} else if (lastActivity) {
|
||||
// Match expanded view logic exactly:
|
||||
// Primary text: displayName || content
|
||||
content = lastActivity.displayName || lastActivity.content;
|
||||
|
||||
// Secondary text: description || formatToolArgs(args)
|
||||
if (lastActivity.description) {
|
||||
formattedArgs = lastActivity.description;
|
||||
} else if (lastActivity.type === 'tool_call' && lastActivity.args) {
|
||||
formattedArgs = formatToolArgs(lastActivity.args);
|
||||
}
|
||||
}
|
||||
|
||||
const displayArgs =
|
||||
progress.state === 'completed' ? '' : formattedArgs;
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
const state = progress.state ?? 'running';
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return <Text color={theme.text.primary}>!</Text>;
|
||||
case 'completed':
|
||||
return <Text color={theme.status.success}>✓</Text>;
|
||||
case 'cancelled':
|
||||
return <Text color={theme.status.warning}>ℹ</Text>;
|
||||
case 'error':
|
||||
return <Text color={theme.status.error}>✗</Text>;
|
||||
default:
|
||||
return checkExhaustive(state);
|
||||
}
|
||||
};
|
||||
|
||||
return renderCollapsedRow(
|
||||
toolCall.callId,
|
||||
progress.agentName,
|
||||
renderStatusIcon(),
|
||||
lastActivity?.type === 'thought' ? `💭 ${content}` : content,
|
||||
displayArgs,
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded View: Render full history
|
||||
return (
|
||||
<Box
|
||||
key={toolCall.callId}
|
||||
flexDirection="column"
|
||||
marginLeft={0}
|
||||
marginBottom={1}
|
||||
>
|
||||
<SubagentProgressDisplay
|
||||
progress={progress}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -36,7 +36,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -60,7 +60,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -82,7 +82,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -104,7 +104,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -128,7 +128,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -149,7 +149,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -164,7 +164,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -185,7 +185,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -8,18 +8,21 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import type {
|
||||
SubagentProgress,
|
||||
SubagentActivityItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
|
||||
import { safeJsonToMarkdown } from '@google/gemini-cli-core';
|
||||
|
||||
export interface SubagentProgressDisplayProps {
|
||||
progress: SubagentProgress;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
const formatToolArgs = (args?: string): string => {
|
||||
export const formatToolArgs = (args?: string): string => {
|
||||
if (!args) return '';
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
@@ -54,7 +57,7 @@ const formatToolArgs = (args?: string): string => {
|
||||
|
||||
export const SubagentProgressDisplay: React.FC<
|
||||
SubagentProgressDisplayProps
|
||||
> = ({ progress }) => {
|
||||
> = ({ progress, terminalWidth }) => {
|
||||
let headerText: string | undefined;
|
||||
let headerColor = theme.text.secondary;
|
||||
|
||||
@@ -67,6 +70,9 @@ export const SubagentProgressDisplay: React.FC<
|
||||
} else if (progress.state === 'completed') {
|
||||
headerText = `Subagent ${progress.agentName} completed.`;
|
||||
headerColor = theme.status.success;
|
||||
} else {
|
||||
headerText = `Running subagent ${progress.agentName}...`;
|
||||
headerColor = theme.text.primary;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -146,6 +152,23 @@ export const SubagentProgressDisplay: React.FC<
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{progress.state === 'completed' && progress.result && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{progress.terminateReason && progress.terminateReason !== 'GOAL' && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
Agent Finished Early ({progress.terminateReason})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<MarkdownDisplay
|
||||
text={safeJsonToMarkdown(progress.result)}
|
||||
isPending={false}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,12 +15,14 @@ import type {
|
||||
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
@@ -125,12 +127,36 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
|
||||
if (
|
||||
tool.kind !== Kind.Agent &&
|
||||
tool.resultDisplay !== undefined &&
|
||||
tool.resultDisplay !== ''
|
||||
) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
const countOneLineToolCalls =
|
||||
visibleToolCalls.length - countToolCallsWithResults;
|
||||
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
|
||||
countToolCallsWithResults;
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups: Array<
|
||||
IndividualToolCallDisplay | IndividualToolCallDisplay[]
|
||||
> = [];
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.kind === Kind.Agent) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (Array.isArray(lastGroup)) {
|
||||
lastGroup.push(tool);
|
||||
} else {
|
||||
groups.push([tool]);
|
||||
}
|
||||
} else {
|
||||
groups.push(tool);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [visibleToolCalls]);
|
||||
|
||||
const availableTerminalHeightPerToolMessage = availableTerminalHeight
|
||||
? Math.max(
|
||||
Math.floor(
|
||||
@@ -167,8 +193,29 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
>
|
||||
{visibleToolCalls.map((tool, index) => {
|
||||
{groupedTools.map((group, index) => {
|
||||
const isFirst = index === 0;
|
||||
const resolvedIsFirst =
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst;
|
||||
|
||||
if (Array.isArray(group)) {
|
||||
return (
|
||||
<SubagentGroupDisplay
|
||||
key={group[0].callId}
|
||||
toolCalls={group}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={contentWidth}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
isFirst={resolvedIsFirst}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
|
||||
const commonProps = {
|
||||
@@ -176,10 +223,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst:
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst,
|
||||
isFirst: resolvedIsFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
|
||||
@@ -102,7 +102,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
</Text>
|
||||
);
|
||||
} else if (isSubagentProgress(contentData)) {
|
||||
content = <SubagentProgressDisplay progress={contentData} />;
|
||||
content = (
|
||||
<SubagentProgressDisplay
|
||||
progress={contentData}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<MarkdownDisplay
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SubagentGroupDisplay /> > renders collapsed view by default with correct agent counts and states 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ≡ 2 Agents (1 running, 1 completed)... (ctrl+o to expand) │
|
||||
│ ! api-monitor · Action Required Verify server is running │
|
||||
│ ✓ db-manager · 💭 Completed successfully │
|
||||
"
|
||||
`;
|
||||
+21
-7
@@ -1,7 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders "Request cancelled." with the info icon 1`] = `
|
||||
"ℹ Request cancelled.
|
||||
"Running subagent TestAgent...
|
||||
|
||||
ℹ Request cancelled.
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -11,31 +13,43 @@ exports[`<SubagentProgressDisplay /> > renders cancelled state correctly 1`] = `
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with command fallback 1`] = `
|
||||
"⠋ run_shell_command echo hello
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ run_shell_command echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with description in args 1`] = `
|
||||
"⠋ run_shell_command Say hello
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ run_shell_command Say hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with displayName and description from item 1`] = `
|
||||
"⠋ RunShellCommand Executing echo hello
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ RunShellCommand Executing echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with file_path 1`] = `
|
||||
"✓ write_file /tmp/test.txt
|
||||
"Running subagent TestAgent...
|
||||
|
||||
✓ write_file /tmp/test.txt
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders thought bubbles correctly 1`] = `
|
||||
"💭 Thinking about life
|
||||
"Running subagent TestAgent...
|
||||
|
||||
💭 Thinking about life
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > truncates long args 1`] = `
|
||||
"⠋ run_shell_command This is a very long description that should definitely be tr...
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ run_shell_command This is a very long description that should definitely be tr...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
isBackgroundExecutionData,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -408,7 +409,8 @@ export const useGeminiStream = (
|
||||
// Push completed tools to history as they finish
|
||||
useEffect(() => {
|
||||
const toolsToPush: TrackedToolCall[] = [];
|
||||
for (const tc of toolCalls) {
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const tc = toolCalls[i];
|
||||
if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue;
|
||||
|
||||
if (
|
||||
@@ -416,6 +418,40 @@ export const useGeminiStream = (
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled'
|
||||
) {
|
||||
// TODO(#22883): This lookahead logic is a tactical UI fix to prevent parallel agents
|
||||
// from tearing visually when they finish at slightly different times.
|
||||
// Architecturally, `useGeminiStream` should not be responsible for stitching
|
||||
// together semantic batches using timing/refs. `packages/core` should be
|
||||
// refactored to emit structured `ToolBatch` or `Turn` objects, and this layer
|
||||
// should simply render those semantic boundaries.
|
||||
// If this is an agent tool, look ahead to ensure all subsequent
|
||||
// contiguous agents in the same batch are also finished before pushing.
|
||||
const isAgent = tc.tool?.kind === Kind.Agent;
|
||||
if (isAgent) {
|
||||
let contigAgentsComplete = true;
|
||||
for (let j = i + 1; j < toolCalls.length; j++) {
|
||||
const nextTc = toolCalls[j];
|
||||
if (nextTc.tool?.kind === Kind.Agent) {
|
||||
if (
|
||||
nextTc.status !== 'success' &&
|
||||
nextTc.status !== 'error' &&
|
||||
nextTc.status !== 'cancelled'
|
||||
) {
|
||||
contigAgentsComplete = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// End of the contiguous agent block
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contigAgentsComplete) {
|
||||
// Wait for the entire contiguous block of agents to finish
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
toolsToPush.push(tc);
|
||||
} else {
|
||||
// Stop at first non-terminal tool to preserve order
|
||||
@@ -425,27 +461,27 @@ export const useGeminiStream = (
|
||||
|
||||
if (toolsToPush.length > 0) {
|
||||
const newPushed = new Set(pushedToolCallIdsRef.current);
|
||||
let isFirst = isFirstToolInGroupRef.current;
|
||||
|
||||
for (const tc of toolsToPush) {
|
||||
newPushed.add(tc.request.callId);
|
||||
const isLastInBatch = tc === toolCalls[toolCalls.length - 1];
|
||||
|
||||
const historyItem = mapTrackedToolCallsToDisplay(tc, {
|
||||
borderTop: isFirst,
|
||||
borderBottom: isLastInBatch,
|
||||
...getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: toolCalls },
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
),
|
||||
});
|
||||
addItem(historyItem);
|
||||
isFirst = false;
|
||||
}
|
||||
|
||||
const isLastInBatch =
|
||||
toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1];
|
||||
|
||||
const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, {
|
||||
borderTop: isFirstToolInGroupRef.current,
|
||||
borderBottom: isLastInBatch,
|
||||
...getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: toolCalls },
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
),
|
||||
});
|
||||
addItem(historyItem);
|
||||
|
||||
setPushedToolCallIds(newPushed);
|
||||
setIsFirstToolInGroup(false);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ describe('agent-scheduler', () => {
|
||||
|
||||
it('should create a scheduler with agent-specific config', async () => {
|
||||
const mockConfig = {
|
||||
getPromptRegistry: vi.fn(),
|
||||
getResourceRegistry: vi.fn(),
|
||||
messageBus: mockMessageBus,
|
||||
toolRegistry: mockToolRegistry,
|
||||
} as unknown as Mocked<Config>;
|
||||
@@ -91,6 +93,8 @@ describe('agent-scheduler', () => {
|
||||
} as unknown as Mocked<ToolRegistry>;
|
||||
|
||||
const config = {
|
||||
getPromptRegistry: vi.fn(),
|
||||
getResourceRegistry: vi.fn(),
|
||||
messageBus: mockMessageBus,
|
||||
} as unknown as Mocked<Config>;
|
||||
Object.defineProperty(config, 'toolRegistry', {
|
||||
@@ -123,6 +127,8 @@ describe('agent-scheduler', () => {
|
||||
|
||||
it('should create an AgentLoopContext that has a defined .config property', async () => {
|
||||
const mockConfig = {
|
||||
getPromptRegistry: vi.fn(),
|
||||
getResourceRegistry: vi.fn(),
|
||||
messageBus: mockMessageBus,
|
||||
toolRegistry: mockToolRegistry,
|
||||
promptId: 'test-prompt',
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
CompletedToolCall,
|
||||
} from '../scheduler/types.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import type { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import type { EditorType } from '../utils/editor.js';
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,10 @@ export interface AgentSchedulingOptions {
|
||||
parentCallId?: string;
|
||||
/** The tool registry specific to this agent. */
|
||||
toolRegistry: ToolRegistry;
|
||||
/** The prompt registry specific to this agent. */
|
||||
promptRegistry?: PromptRegistry;
|
||||
/** The resource registry specific to this agent. */
|
||||
resourceRegistry?: ResourceRegistry;
|
||||
/** AbortSignal for cancellation. */
|
||||
signal: AbortSignal;
|
||||
/** Optional function to get the preferred editor for tool modifications. */
|
||||
@@ -51,16 +57,19 @@ export async function scheduleAgentTools(
|
||||
subagent,
|
||||
parentCallId,
|
||||
toolRegistry,
|
||||
promptRegistry,
|
||||
resourceRegistry,
|
||||
signal,
|
||||
getPreferredEditor,
|
||||
onWaitingForConfirmation,
|
||||
} = options;
|
||||
|
||||
// Create a proxy/override of the config to provide the agent-specific tool registry.
|
||||
const schedulerContext = {
|
||||
config,
|
||||
promptId: config.promptId,
|
||||
toolRegistry,
|
||||
promptRegistry: promptRegistry ?? config.getPromptRegistry(),
|
||||
resourceRegistry: resourceRegistry ?? config.getResourceRegistry(),
|
||||
messageBus: toolRegistry.messageBus,
|
||||
geminiClient: config.geminiClient,
|
||||
sandboxManager: config.sandboxManager,
|
||||
|
||||
@@ -13,10 +13,43 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
|
||||
const {
|
||||
mockSendMessageStream,
|
||||
mockScheduleAgentTools,
|
||||
mockSetSystemInstruction,
|
||||
mockCompress,
|
||||
mockMaybeDiscoverMcpServer,
|
||||
mockStopMcp,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSendMessageStream: vi.fn().mockResolvedValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'chunk',
|
||||
value: { candidates: [] },
|
||||
};
|
||||
},
|
||||
}),
|
||||
mockScheduleAgentTools: vi.fn(),
|
||||
mockSetSystemInstruction: vi.fn(),
|
||||
mockCompress: vi.fn(),
|
||||
mockMaybeDiscoverMcpServer: vi.fn().mockResolvedValue(undefined),
|
||||
mockStopMcp: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../tools/mcp-client-manager.js', () => ({
|
||||
McpClientManager: class {
|
||||
maybeDiscoverMcpServer = mockMaybeDiscoverMcpServer;
|
||||
stop = mockStopMcp;
|
||||
},
|
||||
}));
|
||||
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import { LS_TOOL_NAME, READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
|
||||
@@ -70,18 +103,6 @@ import type {
|
||||
import { getModelConfigAlias, type AgentRegistry } from './registry.js';
|
||||
import type { ModelRouterService } from '../routing/modelRouterService.js';
|
||||
|
||||
const {
|
||||
mockSendMessageStream,
|
||||
mockScheduleAgentTools,
|
||||
mockSetSystemInstruction,
|
||||
mockCompress,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSendMessageStream: vi.fn(),
|
||||
mockScheduleAgentTools: vi.fn(),
|
||||
mockSetSystemInstruction: vi.fn(),
|
||||
mockCompress: vi.fn(),
|
||||
}));
|
||||
|
||||
let mockChatHistory: Content[] = [];
|
||||
const mockSetHistory = vi.fn((newHistory: Content[]) => {
|
||||
mockChatHistory = newHistory;
|
||||
@@ -2722,6 +2743,67 @@ describe('LocalAgentExecutor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Isolation', () => {
|
||||
it('should initialize McpClientManager when mcpServers are defined', async () => {
|
||||
const { MCPServerConfig } = await import('../config/config.js');
|
||||
const mcpServers = {
|
||||
'test-server': new MCPServerConfig('node', ['server.js']),
|
||||
};
|
||||
|
||||
const definition = {
|
||||
...createTestDefinition(),
|
||||
mcpServers,
|
||||
};
|
||||
|
||||
vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({
|
||||
maybeDiscoverMcpServer: mockMaybeDiscoverMcpServer,
|
||||
} as unknown as ReturnType<typeof mockConfig.getMcpClientManager>);
|
||||
|
||||
await LocalAgentExecutor.create(definition, mockConfig);
|
||||
|
||||
const mcpManager = mockConfig.getMcpClientManager();
|
||||
expect(mcpManager?.maybeDiscoverMcpServer).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
mcpServers['test-server'],
|
||||
expect.objectContaining({
|
||||
toolRegistry: expect.any(ToolRegistry),
|
||||
promptRegistry: expect.any(PromptRegistry),
|
||||
resourceRegistry: expect.any(ResourceRegistry),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should inherit main registry tools', async () => {
|
||||
const parentMcpTool = new DiscoveredMCPTool(
|
||||
{} as unknown as CallableTool,
|
||||
'main-server',
|
||||
'tool1',
|
||||
'desc1',
|
||||
{},
|
||||
mockConfig.getMessageBus(),
|
||||
);
|
||||
|
||||
parentToolRegistry.registerTool(parentMcpTool);
|
||||
|
||||
const definition = createTestDefinition();
|
||||
definition.toolConfig = undefined; // trigger inheritance
|
||||
|
||||
vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({
|
||||
maybeDiscoverMcpServer: vi.fn(),
|
||||
} as unknown as ReturnType<typeof mockConfig.getMcpClientManager>);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
const agentTools = (
|
||||
executor as unknown as { toolRegistry: ToolRegistry }
|
||||
).toolRegistry.getAllToolNames();
|
||||
|
||||
expect(agentTools).toContain(parentMcpTool.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeclarativeTool instance tools (browser agent pattern)', () => {
|
||||
/**
|
||||
* The browser agent passes DeclarativeTool instances (not string names) in
|
||||
@@ -2827,13 +2909,11 @@ describe('LocalAgentExecutor', () => {
|
||||
const navTool = new MockTool({ name: 'navigate_page' });
|
||||
|
||||
const definition = createInstanceToolDefinition([clickTool, navTool]);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
const registry = executor['toolRegistry'];
|
||||
expect(registry.getTool('click')).toBeDefined();
|
||||
expect(registry.getTool('navigate_page')).toBeDefined();
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
|
||||
@@ -17,6 +16,8 @@ import {
|
||||
type Schema,
|
||||
} from '@google/genai';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { type AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import {
|
||||
DiscoveredMCPTool,
|
||||
@@ -102,14 +103,22 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
private readonly agentId: string;
|
||||
private readonly toolRegistry: ToolRegistry;
|
||||
private readonly promptRegistry: PromptRegistry;
|
||||
private readonly resourceRegistry: ResourceRegistry;
|
||||
private readonly context: AgentLoopContext;
|
||||
private readonly onActivity?: ActivityCallback;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private readonly parentCallId?: string;
|
||||
private hasFailedCompressionAttempt = false;
|
||||
|
||||
private get config(): Config {
|
||||
return this.context.config;
|
||||
private get executionContext(): AgentLoopContext {
|
||||
return {
|
||||
...this.context,
|
||||
toolRegistry: this.toolRegistry,
|
||||
promptRegistry: this.promptRegistry,
|
||||
resourceRegistry: this.resourceRegistry,
|
||||
messageBus: this.toolRegistry.getMessageBus(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,11 +142,27 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
// Create an override object to inject the subagent name into tool confirmation requests
|
||||
const subagentMessageBus = parentMessageBus.derive(definition.name);
|
||||
|
||||
// Create an isolated tool registry for this agent instance.
|
||||
// Create isolated registries for this agent instance.
|
||||
const agentToolRegistry = new ToolRegistry(
|
||||
context.config,
|
||||
subagentMessageBus,
|
||||
);
|
||||
const agentPromptRegistry = new PromptRegistry();
|
||||
const agentResourceRegistry = new ResourceRegistry();
|
||||
|
||||
if (definition.mcpServers) {
|
||||
const globalMcpManager = context.config.getMcpClientManager();
|
||||
if (globalMcpManager) {
|
||||
for (const [name, config] of Object.entries(definition.mcpServers)) {
|
||||
await globalMcpManager.maybeDiscoverMcpServer(name, config, {
|
||||
toolRegistry: agentToolRegistry,
|
||||
promptRegistry: agentPromptRegistry,
|
||||
resourceRegistry: agentResourceRegistry,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parentToolRegistry = context.toolRegistry;
|
||||
const allAgentNames = new Set(
|
||||
context.config.getAgentRegistry().getAllAgentNames(),
|
||||
@@ -153,7 +178,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
return;
|
||||
}
|
||||
|
||||
agentToolRegistry.registerTool(tool);
|
||||
// Clone the tool, so it gets its own state and subagent messageBus
|
||||
const clonedTool = tool.clone(subagentMessageBus);
|
||||
agentToolRegistry.registerTool(clonedTool);
|
||||
};
|
||||
|
||||
const registerToolByName = (toolName: string) => {
|
||||
@@ -228,10 +255,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
return new LocalAgentExecutor(
|
||||
definition,
|
||||
context,
|
||||
agentToolRegistry,
|
||||
parentPromptId,
|
||||
parentCallId,
|
||||
agentToolRegistry,
|
||||
agentPromptRegistry,
|
||||
agentResourceRegistry,
|
||||
onActivity,
|
||||
parentCallId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,14 +273,18 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
private constructor(
|
||||
definition: LocalAgentDefinition<TOutput>,
|
||||
context: AgentLoopContext,
|
||||
toolRegistry: ToolRegistry,
|
||||
parentPromptId: string | undefined,
|
||||
parentCallId: string | undefined,
|
||||
toolRegistry: ToolRegistry,
|
||||
promptRegistry: PromptRegistry,
|
||||
resourceRegistry: ResourceRegistry,
|
||||
onActivity?: ActivityCallback,
|
||||
parentCallId?: string,
|
||||
) {
|
||||
this.definition = definition;
|
||||
this.context = context;
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.promptRegistry = promptRegistry;
|
||||
this.resourceRegistry = resourceRegistry;
|
||||
this.onActivity = onActivity;
|
||||
this.compressionService = new ChatCompressionService();
|
||||
this.parentCallId = parentCallId;
|
||||
@@ -447,7 +480,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
} finally {
|
||||
clearTimeout(graceTimeoutId);
|
||||
logRecoveryAttempt(
|
||||
this.config,
|
||||
this.context.config,
|
||||
new RecoveryAttemptEvent(
|
||||
this.agentId,
|
||||
this.definition.name,
|
||||
@@ -495,7 +528,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
|
||||
|
||||
logAgentStart(
|
||||
this.config,
|
||||
this.context.config,
|
||||
new AgentStartEvent(this.agentId, this.definition.name),
|
||||
);
|
||||
|
||||
@@ -506,7 +539,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
const augmentedInputs = {
|
||||
...inputs,
|
||||
cliVersion: await getVersion(),
|
||||
activeModel: this.config.getActiveModel(),
|
||||
activeModel: this.context.config.getActiveModel(),
|
||||
today: new Date().toLocaleDateString(),
|
||||
};
|
||||
|
||||
@@ -528,14 +561,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
// Capture the index of the last hint before starting to avoid re-injecting old hints.
|
||||
// NOTE: Hints added AFTER this point will be broadcast to all currently running
|
||||
// local agents via the listener below.
|
||||
const startIndex = this.config.injectionService.getLatestInjectionIndex();
|
||||
this.config.injectionService.onInjection(injectionListener);
|
||||
const startIndex =
|
||||
this.context.config.injectionService.getLatestInjectionIndex();
|
||||
this.context.config.injectionService.onInjection(injectionListener);
|
||||
|
||||
try {
|
||||
const initialHints = this.config.injectionService.getInjectionsAfter(
|
||||
startIndex,
|
||||
'user_steering',
|
||||
);
|
||||
const initialHints =
|
||||
this.context.config.injectionService.getInjectionsAfter(
|
||||
startIndex,
|
||||
'user_steering',
|
||||
);
|
||||
const formattedInitialHints = formatUserHintsForModel(initialHints);
|
||||
|
||||
let currentMessage: Content = formattedInitialHints
|
||||
@@ -606,7 +641,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.config.injectionService.offInjection(injectionListener);
|
||||
this.context.config.injectionService.offInjection(injectionListener);
|
||||
|
||||
const globalMcpManager = this.context.config.getMcpClientManager();
|
||||
if (globalMcpManager) {
|
||||
globalMcpManager.removeRegistries({
|
||||
toolRegistry: this.toolRegistry,
|
||||
promptRegistry: this.promptRegistry,
|
||||
resourceRegistry: this.resourceRegistry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === UNIFIED RECOVERY BLOCK ===
|
||||
@@ -719,7 +763,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
} finally {
|
||||
deadlineTimer.abort();
|
||||
logAgentFinish(
|
||||
this.config,
|
||||
this.context.config,
|
||||
new AgentFinishEvent(
|
||||
this.agentId,
|
||||
this.definition.name,
|
||||
@@ -742,7 +786,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
prompt_id,
|
||||
false,
|
||||
model,
|
||||
this.config,
|
||||
this.context.config,
|
||||
this.hasFailedCompressionAttempt,
|
||||
);
|
||||
|
||||
@@ -780,10 +824,11 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
const modelConfigAlias = getModelConfigAlias(this.definition);
|
||||
|
||||
// Resolve the model config early to get the concrete model string (which may be `auto`).
|
||||
const resolvedConfig = this.config.modelConfigService.getResolvedConfig({
|
||||
model: modelConfigAlias,
|
||||
overrideScope: this.definition.name,
|
||||
});
|
||||
const resolvedConfig =
|
||||
this.context.config.modelConfigService.getResolvedConfig({
|
||||
model: modelConfigAlias,
|
||||
overrideScope: this.definition.name,
|
||||
});
|
||||
const requestedModel = resolvedConfig.model;
|
||||
|
||||
let modelToUse: string;
|
||||
@@ -800,7 +845,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
signal,
|
||||
requestedModel,
|
||||
};
|
||||
const router = this.config.getModelRouterService();
|
||||
const router = this.context.config.getModelRouterService();
|
||||
const decision = await router.route(routingContext);
|
||||
modelToUse = decision.model;
|
||||
} catch (error) {
|
||||
@@ -888,7 +933,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
try {
|
||||
return new GeminiChat(
|
||||
this.config,
|
||||
this.executionContext,
|
||||
systemInstruction,
|
||||
[{ functionDeclarations: tools }],
|
||||
startHistory,
|
||||
@@ -1136,13 +1181,15 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
// Execute standard tool calls using the new scheduler
|
||||
if (toolRequests.length > 0) {
|
||||
const completedCalls = await scheduleAgentTools(
|
||||
this.config,
|
||||
this.context.config,
|
||||
toolRequests,
|
||||
{
|
||||
schedulerId: this.agentId,
|
||||
schedulerId: promptId,
|
||||
subagent: this.definition.name,
|
||||
parentCallId: this.parentCallId,
|
||||
toolRegistry: this.toolRegistry,
|
||||
promptRegistry: this.promptRegistry,
|
||||
resourceRegistry: this.resourceRegistry,
|
||||
signal,
|
||||
onWaitingForConfirmation,
|
||||
},
|
||||
@@ -1277,7 +1324,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
let finalPrompt = templateString(promptConfig.systemPrompt, inputs);
|
||||
|
||||
// Append environment context (CWD and folder structure).
|
||||
const dirContext = await getDirectoryContextString(this.config);
|
||||
const dirContext = await getDirectoryContextString(this.context.config);
|
||||
finalPrompt += `\n\n# Environment Context\n${dirContext}`;
|
||||
|
||||
// Append standard rules for non-interactive execution.
|
||||
|
||||
@@ -207,8 +207,11 @@ describe('LocalSubagentInvocation', () => {
|
||||
),
|
||||
},
|
||||
]);
|
||||
expect(result.returnDisplay).toBe('Analysis complete.');
|
||||
expect(result.returnDisplay).not.toContain('Termination Reason');
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Analysis complete.');
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
|
||||
});
|
||||
|
||||
it('should show detailed UI for non-goal terminations (e.g., TIMEOUT)', async () => {
|
||||
@@ -220,11 +223,11 @@ describe('LocalSubagentInvocation', () => {
|
||||
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(result.returnDisplay).toContain(
|
||||
'### Subagent MockAgent Finished Early',
|
||||
);
|
||||
expect(result.returnDisplay).toContain('**Termination Reason:** TIMEOUT');
|
||||
expect(result.returnDisplay).toContain('Partial progress...');
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Partial progress...');
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should stream THOUGHT_CHUNK activities from the executor', async () => {
|
||||
@@ -250,8 +253,8 @@ describe('LocalSubagentInvocation', () => {
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates
|
||||
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
@@ -283,8 +286,8 @@ describe('LocalSubagentInvocation', () => {
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(3);
|
||||
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
@@ -312,7 +315,10 @@ describe('LocalSubagentInvocation', () => {
|
||||
// Execute without the optional callback
|
||||
const result = await invocation.execute(signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.returnDisplay).toBe('Done');
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Done');
|
||||
});
|
||||
|
||||
it('should handle executor run failure', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { LocalAgentExecutor } from './local-executor.js';
|
||||
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
@@ -246,28 +245,27 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
throw cancelError;
|
||||
}
|
||||
|
||||
const displayResult = safeJsonToMarkdown(output.result);
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: 'completed',
|
||||
result: output.result,
|
||||
terminateReason: output.terminate_reason,
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(progress);
|
||||
}
|
||||
|
||||
const resultContent = `Subagent '${this.definition.name}' finished.
|
||||
Termination Reason: ${output.terminate_reason}
|
||||
Result:
|
||||
${output.result}`;
|
||||
|
||||
const displayContent =
|
||||
output.terminate_reason === AgentTerminateMode.GOAL
|
||||
? displayResult
|
||||
: `
|
||||
### Subagent ${this.definition.name} Finished Early
|
||||
|
||||
**Termination Reason:** ${output.terminate_reason}
|
||||
|
||||
**Result/Summary:**
|
||||
${displayResult}
|
||||
`;
|
||||
|
||||
return {
|
||||
llmContent: [{ text: resultContent }],
|
||||
returnDisplay: displayContent,
|
||||
returnDisplay: progress,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface SubagentProgress {
|
||||
agentName: string;
|
||||
recentActivity: SubagentActivityItem[];
|
||||
state?: 'running' | 'completed' | 'error' | 'cancelled';
|
||||
result?: string;
|
||||
terminateReason?: AgentTerminateMode;
|
||||
}
|
||||
|
||||
export function isSubagentProgress(obj: unknown): obj is SubagentProgress {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import type { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import type { SandboxManager } from '../services/sandboxManager.js';
|
||||
import type { Config } from './config.js';
|
||||
|
||||
@@ -24,6 +26,12 @@ export interface AgentLoopContext {
|
||||
/** The registry of tools available to the agent in this context. */
|
||||
readonly toolRegistry: ToolRegistry;
|
||||
|
||||
/** The registry of prompts available to the agent in this context. */
|
||||
readonly promptRegistry: PromptRegistry;
|
||||
|
||||
/** The registry of resources available to the agent in this context. */
|
||||
readonly resourceRegistry: ResourceRegistry;
|
||||
|
||||
/** The bus for user confirmations and messages in this context. */
|
||||
readonly messageBus: MessageBus;
|
||||
|
||||
|
||||
@@ -660,8 +660,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private allowedEnvironmentVariables: string[];
|
||||
private blockedEnvironmentVariables: string[];
|
||||
private readonly enableEnvironmentVariableRedaction: boolean;
|
||||
private promptRegistry!: PromptRegistry;
|
||||
private resourceRegistry!: ResourceRegistry;
|
||||
private _promptRegistry!: PromptRegistry;
|
||||
private _resourceRegistry!: ResourceRegistry;
|
||||
private agentRegistry!: AgentRegistry;
|
||||
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
|
||||
private skillManager!: SkillManager;
|
||||
@@ -1245,8 +1245,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
if (this.getCheckpointingEnabled()) {
|
||||
await this.getGitService();
|
||||
}
|
||||
this.promptRegistry = new PromptRegistry();
|
||||
this.resourceRegistry = new ResourceRegistry();
|
||||
this._promptRegistry = new PromptRegistry();
|
||||
this._resourceRegistry = new ResourceRegistry();
|
||||
|
||||
this.agentRegistry = new AgentRegistry(this);
|
||||
await this.agentRegistry.initialize();
|
||||
@@ -1482,6 +1482,22 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this._toolRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not access directly on Config.
|
||||
* Use the injected AgentLoopContext instead.
|
||||
*/
|
||||
get promptRegistry(): PromptRegistry {
|
||||
return this._promptRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not access directly on Config.
|
||||
* Use the injected AgentLoopContext instead.
|
||||
*/
|
||||
get resourceRegistry(): ResourceRegistry {
|
||||
return this._resourceRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not access directly on Config.
|
||||
* Use the injected AgentLoopContext instead.
|
||||
@@ -1794,7 +1810,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getPromptRegistry(): PromptRegistry {
|
||||
return this.promptRegistry;
|
||||
return this._promptRegistry;
|
||||
}
|
||||
|
||||
getSkillManager(): SkillManager {
|
||||
@@ -1802,7 +1818,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getResourceRegistry(): ResourceRegistry {
|
||||
return this.resourceRegistry;
|
||||
return this._resourceRegistry;
|
||||
}
|
||||
|
||||
getDebugMode(): boolean {
|
||||
|
||||
@@ -118,6 +118,7 @@ export * from './utils/channel.js';
|
||||
export * from './utils/constants.js';
|
||||
export * from './utils/sessionUtils.js';
|
||||
export * from './utils/cache.js';
|
||||
export * from './utils/markdownUtils.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
|
||||
@@ -22,6 +22,7 @@ export const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||||
export enum TaskStatus {
|
||||
OPEN = 'open',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
BLOCKED = 'blocked',
|
||||
CLOSED = 'closed',
|
||||
}
|
||||
export const TaskStatusSchema = z.nativeEnum(TaskStatus);
|
||||
|
||||
@@ -697,6 +697,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps
|
||||
- in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time.
|
||||
- completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed.
|
||||
- cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled.
|
||||
- blocked: Subtask is blocked and cannot be completed at this time.
|
||||
|
||||
|
||||
## Methodology for using this tool
|
||||
@@ -766,6 +767,7 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"blocked",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
@@ -1451,6 +1453,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps
|
||||
- in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time.
|
||||
- completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed.
|
||||
- cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled.
|
||||
- blocked: Subtask is blocked and cannot be completed at this time.
|
||||
|
||||
|
||||
## Methodology for using this tool
|
||||
@@ -1520,6 +1523,7 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"blocked",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
|
||||
@@ -543,6 +543,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps
|
||||
- in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time.
|
||||
- completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed.
|
||||
- cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled.
|
||||
- blocked: Subtask is blocked and cannot be completed at this time.
|
||||
|
||||
|
||||
## Methodology for using this tool
|
||||
@@ -609,7 +610,13 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
[TODOS_ITEM_PARAM_STATUS]: {
|
||||
type: 'string',
|
||||
description: 'The current status of the task.',
|
||||
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
|
||||
enum: [
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
],
|
||||
},
|
||||
},
|
||||
required: [TODOS_ITEM_PARAM_DESCRIPTION, TODOS_ITEM_PARAM_STATUS],
|
||||
|
||||
@@ -518,6 +518,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps
|
||||
- in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time.
|
||||
- completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed.
|
||||
- cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled.
|
||||
- blocked: Subtask is blocked and cannot be completed at this time.
|
||||
|
||||
|
||||
## Methodology for using this tool
|
||||
@@ -584,7 +585,13 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
[TODOS_ITEM_PARAM_STATUS]: {
|
||||
type: 'string',
|
||||
description: 'The current status of the task.',
|
||||
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
|
||||
enum: [
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
],
|
||||
},
|
||||
},
|
||||
required: [TODOS_ITEM_PARAM_DESCRIPTION, TODOS_ITEM_PARAM_STATUS],
|
||||
|
||||
@@ -823,7 +823,12 @@ export type ToolResultDisplay =
|
||||
| TodoList
|
||||
| SubagentProgress;
|
||||
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
export type TodoStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'blocked';
|
||||
|
||||
export interface Todo {
|
||||
description: string;
|
||||
|
||||
@@ -222,15 +222,23 @@ describe('Tracker Tools Integration', () => {
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
dependencies: [],
|
||||
};
|
||||
const t4 = {
|
||||
id: 't4',
|
||||
title: 'T4',
|
||||
type: TaskType.TASK,
|
||||
status: TaskStatus.BLOCKED,
|
||||
dependencies: [],
|
||||
};
|
||||
|
||||
const mockService = {
|
||||
listTasks: async () => [t1, t2, t3],
|
||||
listTasks: async () => [t1, t2, t3, t4],
|
||||
} as unknown as TrackerService;
|
||||
const display = await buildTodosReturnDisplay(mockService);
|
||||
|
||||
expect(display.todos).toEqual([
|
||||
{ description: `task: T3 (t3)`, status: 'in_progress' },
|
||||
{ description: `task: T2 (t2)`, status: 'pending' },
|
||||
{ description: `task: T4 (t4)`, status: 'blocked' },
|
||||
{ description: `task: T1 (t1)`, status: 'completed' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -48,10 +48,11 @@ export async function buildTodosReturnDisplay(
|
||||
}
|
||||
}
|
||||
|
||||
const statusOrder = {
|
||||
const statusOrder: Record<TaskStatus, number> = {
|
||||
[TaskStatus.IN_PROGRESS]: 0,
|
||||
[TaskStatus.OPEN]: 1,
|
||||
[TaskStatus.CLOSED]: 2,
|
||||
[TaskStatus.BLOCKED]: 2,
|
||||
[TaskStatus.CLOSED]: 3,
|
||||
};
|
||||
|
||||
const sortTasks = (a: TrackerTask, b: TrackerTask) => {
|
||||
@@ -80,6 +81,8 @@ export async function buildTodosReturnDisplay(
|
||||
status = 'in_progress';
|
||||
} else if (task.status === TaskStatus.CLOSED) {
|
||||
status = 'completed';
|
||||
} else if (task.status === TaskStatus.BLOCKED) {
|
||||
status = 'blocked';
|
||||
}
|
||||
|
||||
const indent = ' '.repeat(depth);
|
||||
@@ -585,6 +588,7 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
|
||||
const statusEmojis: Record<TaskStatus, string> = {
|
||||
open: '⭕',
|
||||
in_progress: '🚧',
|
||||
blocked: '⛔',
|
||||
closed: '✅',
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('WriteTodosTool', () => {
|
||||
{ description: 'Task 1', status: 'pending' },
|
||||
{ description: 'Task 2', status: 'in_progress' },
|
||||
{ description: 'Task 3', status: 'completed' },
|
||||
{ description: 'Task 4', status: 'blocked' },
|
||||
],
|
||||
};
|
||||
await expect(tool.buildAndExecute(params, signal)).resolves.toBeDefined();
|
||||
@@ -96,13 +97,15 @@ describe('WriteTodosTool', () => {
|
||||
{ description: 'First task', status: 'completed' },
|
||||
{ description: 'Second task', status: 'in_progress' },
|
||||
{ description: 'Third task', status: 'pending' },
|
||||
{ description: 'Fourth task', status: 'blocked' },
|
||||
],
|
||||
};
|
||||
const result = await tool.buildAndExecute(params, signal);
|
||||
const expectedOutput = `Successfully updated the todo list. The current list is now:
|
||||
1. [completed] First task
|
||||
2. [in_progress] Second task
|
||||
3. [pending] Third task`;
|
||||
3. [pending] Third task
|
||||
4. [blocked] Fourth task`;
|
||||
expect(result.llmContent).toBe(expectedOutput);
|
||||
expect(result.returnDisplay).toEqual(params);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const TODO_STATUSES = [
|
||||
'in_progress',
|
||||
'completed',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
] as const;
|
||||
|
||||
export interface WriteTodosToolParams {
|
||||
|
||||
Reference in New Issue
Block a user