Compare commits

...

3 Commits

Author SHA1 Message Date
Sehoon Shon 4f8534b223 feat(cli): integrate TrajectoryProvider into /resume UI 2026-03-19 01:19:39 -04:00
Sehoon Shon b75ec12185 feat(core): define TrajectoryProvider interface 2026-03-19 01:19:34 -04:00
Adam Weidman 2009fbbd92 feat(core): add content-utils (#22984) 2026-03-19 02:30:48 +00:00
9 changed files with 861 additions and 47 deletions
+27
View File
@@ -19,12 +19,16 @@ import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
import { pathToFileURL } from 'node:url';
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
export interface InitializationResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
recentExternalSession?: { prefix: string; id: string; displayName?: string };
}
/**
@@ -60,11 +64,34 @@ export async function initializeApp(
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
// Check for recent external sessions to prompt for resume
let recentExternalSession:
| { prefix: string; id: string; displayName?: string }
| undefined;
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensionLoader = (config as any)._extensionLoader;
if (extensionLoader) {
const workspaceUri = pathToFileURL(process.cwd()).toString();
const recent =
await extensionLoader.getRecentExternalSession(workspaceUri);
if (recent) {
recentExternalSession = {
prefix: recent.prefix,
id: recent.id,
displayName: recent.displayName,
};
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
return {
authError,
accountSuspensionInfo,
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
recentExternalSession,
};
}
+13
View File
@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
generateSummary(config).catch((e) => {
debugLogger.warn('Background summary generation failed:', e);
});
// Show handoff prompt for recent external sessions
if (initializationResult.recentExternalSession) {
const { prefix, id, displayName } =
initializationResult.recentExternalSession;
historyManager.addItem(
{
type: MessageType.INFO,
text: `🛸 You have a recent session in ${displayName || 'an external tool'}. Type "/resume ${prefix}${id}" to bring it into the terminal.`,
},
Date.now(),
);
}
})();
registerCleanup(async () => {
// Turn off mouse scroll.
@@ -8,10 +8,14 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { agentsCommand } from './agentsCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config } from '@google/gemini-cli-core';
import { Storage } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
import * as fs from 'node:fs/promises';
vi.mock('node:fs/promises');
vi.mock('../../utils/agentSettings.js', () => ({
enableAgent: vi.fn(),
@@ -105,40 +109,34 @@ describe('agentsCommand', () => {
);
});
it('should reload the agent registry when reload subcommand is called', async () => {
it('should reload the agent registry when refresh subcommand is called', async () => {
const reloadSpy = vi.fn().mockResolvedValue(undefined);
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
reload: reloadSpy,
});
const reloadCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'reload',
const refreshCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
expect(reloadCommand).toBeDefined();
expect(refreshCommand).toBeDefined();
const result = await reloadCommand!.action!(mockContext, '');
const result = await refreshCommand!.action!(mockContext, '');
expect(reloadSpy).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Reloading agent registry...',
}),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Agents reloaded successfully',
content: 'Agents refreshed successfully.',
});
});
it('should show an error if agent registry is not available during reload', async () => {
it('should show an error if agent registry is not available during refresh', async () => {
mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
const reloadCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'reload',
const refreshCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
const result = await reloadCommand!.action!(mockContext, '');
const result = await refreshCommand!.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
@@ -462,4 +460,56 @@ describe('agentsCommand', () => {
expect(completions).toEqual(['agent1', 'agent2']);
});
});
describe('import sub-command', () => {
it('should import an agent with correct tool mapping', async () => {
vi.mocked(fs.readFile).mockResolvedValue(`---
name: test-claude-agent
tools: Read, Glob, Grep, Bash
model: sonnet
---
System prompt content`);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.spyOn(Storage, 'getUserAgentsDir').mockReturnValue('/mock/agents');
const importCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'import',
);
expect(importCommand).toBeDefined();
const result = await importCommand!.action!(
mockContext,
'/path/to/claude.md',
);
expect(fs.readFile).toHaveBeenCalledWith('/path/to/claude.md', 'utf-8');
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/test-claude-agent\.md$/),
expect.stringContaining('tools:\n - read_file\n - run_command\n'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining(
"Successfully imported agent 'test-claude-agent'",
),
});
});
it('should show error if no file path provided', async () => {
const importCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'import',
);
const result = await importCommand!.action!(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /agents import <file-path>',
});
});
});
});
+142 -10
View File
@@ -8,6 +8,7 @@ import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Content, Part } from '@google/genai';
import type {
CommandContext,
SlashCommand,
@@ -18,6 +19,7 @@ import {
decodeTagName,
type MessageActionReturn,
INITIAL_HISTORY_LENGTH,
type ConversationRecord,
} from '@google/gemini-cli-core';
import path from 'node:path';
import type {
@@ -174,8 +176,119 @@ const resumeCheckpointCommand: SlashCommand = {
const { logger, config } = context.services;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history;
let conversation: readonly Content[] = [];
let authType: string | undefined;
const loadExternalTrajectory = async (
prefix: string,
id: string,
): Promise<Content[] | null> => {
let record: ConversationRecord | null = null;
if (config && config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (
extension.trajectoryProviderModule &&
extension.trajectoryProviderModule.prefix === prefix
) {
try {
record = await extension.trajectoryProviderModule.loadSession(id);
if (record) break;
} catch (_err) {
// Ignore failure
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
}
if (!record) return null;
const conv: Content[] = [];
// Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly
conv.push({ role: 'user', parts: [{ text: '' }] });
for (const m of record.messages) {
if (m.type === 'user') {
const parts = Array.isArray(m.content)
? m.content.map((c: string | Part) =>
typeof c === 'string' ? { text: c } : { text: c.text || '' },
)
: [{ text: '' }];
conv.push({ role: 'user', parts });
} else if (m.type === 'gemini') {
// 1. Model turn: Text + Function Calls
const modelParts: Part[] = [];
if (Array.isArray(m.content)) {
m.content.forEach((c: string | Part) => {
modelParts.push(
typeof c === 'string' ? { text: c } : { text: c.text || '' },
);
});
}
if (m.toolCalls) {
for (const tc of m.toolCalls) {
modelParts.push({
functionCall: {
name: tc.name,
args: tc.args,
},
});
}
}
conv.push({ role: 'model', parts: modelParts });
// 2. User turn: Function Responses (if any)
if (
m.toolCalls &&
m.toolCalls.some((tc: { result?: unknown }) => tc.result)
) {
const responseParts: Part[] = [];
for (const tc of m.toolCalls) {
if (tc.result) {
responseParts.push({
functionResponse: {
name: tc.name,
response: { result: tc.result },
id: tc.id,
},
});
}
}
if (responseParts.length > 0) {
conv.push({ role: 'user', parts: responseParts });
}
}
}
}
return conv;
};
// Check if tag format is prefix:id
const prefixMatch = tag.match(/^([a-zA-Z0-9_]+):(.*)$/);
if (prefixMatch) {
const prefix = prefixMatch[1] + ':';
const id = prefixMatch[2];
const externalConv = await loadExternalTrajectory(prefix, id);
if (!externalConv) {
return {
type: 'message',
messageType: 'error',
content: `No external session found with prefix ${prefix} and id: ${id}`,
};
}
conversation = externalConv;
} else {
const checkpoint = await logger.loadCheckpoint(tag);
if (checkpoint.history.length > 0) {
conversation = checkpoint.history;
authType = checkpoint.authType;
}
}
if (conversation.length === 0) {
return {
@@ -186,15 +299,11 @@ const resumeCheckpointCommand: SlashCommand = {
}
const currentAuthType = config?.getContentGeneratorConfig()?.authType;
if (
checkpoint.authType &&
currentAuthType &&
checkpoint.authType !== currentAuthType
) {
if (authType && currentAuthType && authType !== currentAuthType) {
return {
type: 'message',
messageType: 'error',
content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
content: `Cannot resume chat. It was saved with a different authentication method (${authType}) than the current one (${currentAuthType}).`,
};
}
@@ -206,11 +315,34 @@ const resumeCheckpointCommand: SlashCommand = {
const uiHistory: HistoryItemWithoutId[] = [];
for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) {
const text =
item.parts
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parts = item.parts as Array<{
text?: string;
functionCall?: { name: string };
functionResponse?: { name: string };
}>;
let text =
parts
?.filter((m) => !!m.text)
.map((m) => m.text)
.join('') || '';
const toolCalls = parts?.filter((p) => !!p.functionCall);
if (toolCalls?.length) {
const calls = toolCalls
.map((tc) => `[Tool Call: ${tc.functionCall?.name}]`)
.join('\n');
text = text ? `${text}\n${calls}` : calls;
}
const toolResponses = parts?.filter((p) => !!p.functionResponse);
if (toolResponses?.length) {
const responses = toolResponses
.map((tr) => `[Tool Result: ${tr.functionResponse?.name}]`)
.join('\n');
text = text ? `${text}\n${responses}` : responses;
}
if (!text) {
continue;
}
@@ -12,12 +12,16 @@ import { Colors } from '../colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import type { Config } from '@google/gemini-cli-core';
import { pathToFileURL } from 'node:url';
import { type Config } from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import {
formatRelativeTime,
getSessionFiles,
} from '../../utils/sessionUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
/**
* Props for the main SessionBrowser component.
@@ -41,6 +45,8 @@ export interface SessionBrowserState {
// Data state
/** All loaded sessions */
sessions: SessionInfo[];
/** Extension tabs */
extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
/** Sessions after filtering and sorting */
filteredAndSortedSessions: SessionInfo[];
@@ -55,6 +61,8 @@ export interface SessionBrowserState {
scrollOffset: number;
/** Terminal width for layout calculations */
terminalWidth: number;
/** Current active tab (0: CLI, 1+: External) */
activeTab: number;
// Search state
/** Current search query string */
@@ -83,6 +91,10 @@ export interface SessionBrowserState {
// State setters
/** Update sessions array */
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update extensionTabs array */
setExtensionTabs: React.Dispatch<
React.SetStateAction<Array<{ name: string; sessions: SessionInfo[] }>>
>;
/** Update loading state */
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
/** Update error state */
@@ -91,6 +103,8 @@ export interface SessionBrowserState {
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
/** Update scroll offset */
setScrollOffset: React.Dispatch<React.SetStateAction<number>>;
/** Update active tab */
setActiveTab: (index: number) => void;
/** Update search query */
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
/** Update search mode state */
@@ -352,6 +366,9 @@ export const useSessionBrowserState = (
): SessionBrowserState => {
const { columns: terminalWidth } = useTerminalSize();
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
const [extensionTabs, setExtensionTabs] = useState<
Array<{ name: string; sessions: SessionInfo[] }>
>([]);
const [loading, setLoading] = useState(initialLoading);
const [error, setError] = useState<string | null>(initialError);
const [activeIndex, setActiveIndex] = useState(0);
@@ -365,10 +382,20 @@ export const useSessionBrowserState = (
const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false);
const loadingFullContentRef = useRef(false);
const [activeTab, setActiveTabInternal] = useState(0);
const setActiveTab = useCallback((index: number) => {
setActiveTabInternal(index);
setActiveIndex(0);
setScrollOffset(0);
}, []);
const filteredAndSortedSessions = useMemo(() => {
const filtered = filterSessions(sessions, searchQuery);
const currentTabSessions =
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
const filtered = filterSessions(currentTabSessions, searchQuery);
return sortSessions(filtered, sortOrder, sortReverse);
}, [sessions, searchQuery, sortOrder, sortReverse]);
}, [sessions, extensionTabs, activeTab, searchQuery, sortOrder, sortReverse]);
// Reset full content flag when search is cleared
useEffect(() => {
@@ -386,6 +413,8 @@ export const useSessionBrowserState = (
const state: SessionBrowserState = {
sessions,
setSessions,
extensionTabs,
setExtensionTabs,
loading,
setLoading,
error,
@@ -394,6 +423,8 @@ export const useSessionBrowserState = (
setActiveIndex,
scrollOffset,
setScrollOffset,
activeTab,
setActiveTab,
searchQuery,
setSearchQuery,
isSearchMode,
@@ -415,12 +446,43 @@ export const useSessionBrowserState = (
return state;
};
/**
* Converts an external session info to CLI SessionInfo format.
*/
type ExternalSessionInfo = {
id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
prefix?: string;
};
function convertExternalToSessionInfo(
ext: ExternalSessionInfo,
index: number,
): SessionInfo {
return {
id: ext.prefix ? `${ext.prefix}${ext.id}` : ext.id,
file: ext.id,
fileName: ext.id + '.ext',
startTime: ext.mtime,
lastUpdated: ext.mtime,
messageCount: ext.messageCount || 0,
displayName: ext.displayName || ext.name || 'External Session',
firstUserMessage: ext.displayName || ext.name || '',
isCurrentSession: false,
index,
};
}
/**
* Hook to load sessions on mount.
*/
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const {
setSessions,
setExtensionTabs,
setLoading,
setError,
isSearchMode,
@@ -432,11 +494,58 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const loadSessions = async () => {
try {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
const sessionData = await getSessionFiles(
chatsDir,
config.getSessionId(),
);
const workspaceUri = pathToFileURL(process.cwd()).toString();
const externalTabs: Array<{ name: string; sessions: SessionInfo[] }> =
[];
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (extension.trajectoryProviderModule) {
try {
const sessions =
await extension.trajectoryProviderModule.listSessions(
workspaceUri,
);
const normalizedExt = sessions
.map((ext: any) => ({
...ext,
prefix: extension.trajectoryProviderModule.prefix,
}))
.sort(
(a: any, b: any) =>
new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
)
.map((ext: any, i: number) =>
convertExternalToSessionInfo(ext, i + 1),
);
if (normalizedExt.length > 0) {
externalTabs.push({
name:
extension.trajectoryProviderModule.displayName ||
extension.name,
sessions: normalizedExt,
});
}
} catch (_e) {
// Ignore loader errors
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
const [sessionData] = await Promise.all([
getSessionFiles(chatsDir, config.getSessionId()),
]);
setSessions(sessionData);
setExtensionTabs(externalTabs);
setLoading(false);
} catch (err) {
setError(
@@ -448,7 +557,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadSessions();
}, [config, setSessions, setLoading, setError]);
}, [config, setSessions, setExtensionTabs, setLoading, setError]);
useEffect(() => {
const loadFullContent = async () => {
@@ -610,6 +719,9 @@ export const useSessionBrowserInput = (
}
// Delete session control.
else if (key.sequence === 'x' || key.sequence === 'X') {
// Only allow deleting CLI sessions for now
if (state.activeTab !== 0) return true;
const selectedSession =
state.filteredAndSortedSessions[state.activeIndex];
if (selectedSession && !selectedSession.isCurrentSession) {
@@ -684,6 +796,14 @@ export function SessionBrowserView({
}: {
state: SessionBrowserState;
}): React.JSX.Element {
const tabs: Tab[] = [
{ key: 'cli', header: 'Gemini CLI' },
...state.extensionTabs.map((ext, i) => ({
key: `ext-${i}`,
header: ext.name,
})),
];
if (state.loading) {
return <SessionBrowserLoading />;
}
@@ -692,11 +812,20 @@ export function SessionBrowserView({
return <SessionBrowserError state={state} />;
}
if (state.sessions.length === 0) {
if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
return <SessionBrowserEmpty />;
}
return (
<Box flexDirection="column" paddingX={1}>
<Box marginBottom={1}>
<TabHeader
tabs={tabs}
currentIndex={state.activeTab}
showStatusIcons={false}
/>
</Box>
<SessionListHeader state={state} />
{state.isSearchMode && <SearchModeDisplay state={state} />}
@@ -721,6 +850,14 @@ export function SessionBrowser({
useLoadSessions(config, state);
const moveSelection = useMoveSelection(state);
const cycleSortOrder = useCycleSortOrder(state);
useTabbedNavigation({
tabCount: state.extensionTabs.length + 1,
isActive: !state.isSearchMode,
wrapAround: true,
onTabChange: (index) => state.setActiveTab(index),
});
useSessionBrowserInput(
state,
moveSelection,
+47 -12
View File
@@ -21,6 +21,7 @@ import {
type SessionInfo,
} from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
export { convertSessionToHistoryFormats };
@@ -51,20 +52,54 @@ export const useSessionBrowser = (
handleResumeSession: useCallback(
async (session: SessionInfo) => {
try {
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
let conversation: ConversationRecord;
let filePath: string;
const fileName = session.fileName;
if (session.fileName.endsWith('.ext')) {
// External session
let externalConv: ConversationRecord | null = null;
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (extension.trajectoryProviderModule) {
const prefix =
extension.trajectoryProviderModule.prefix || '';
if (session.id.startsWith(prefix)) {
const originalId = prefix
? session.id.slice(prefix.length)
: session.id;
externalConv =
await extension.trajectoryProviderModule.loadSession(
originalId,
);
if (externalConv) break;
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
const originalFilePath = path.join(chatsDir, fileName);
if (!externalConv) {
throw new Error(`Could not load external session ${session.id}`);
}
conversation = externalConv;
filePath = session.id + '.ext';
} else {
// Regular CLI session
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
const fileName = session.fileName;
filePath = path.join(chatsDir, fileName);
// Load up the conversation.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const conversation: ConversationRecord = JSON.parse(
await fs.readFile(originalFilePath, 'utf8'),
);
// Load up the conversation.
conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
}
// Use the old session's ID to continue it.
const existingSessionId = conversation.sessionId;
@@ -73,7 +108,7 @@ export const useSessionBrowser = (
const resumedSessionData = {
conversation,
filePath: originalFilePath,
filePath,
};
// We've loaded it; tell the UI about it.
@@ -0,0 +1,258 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
geminiPartsToContentParts,
contentPartsToGeminiParts,
toolResultDisplayToContentParts,
buildToolResponseData,
} from './content-utils.js';
import type { Part } from '@google/genai';
import type { ContentPart } from './types.js';
describe('geminiPartsToContentParts', () => {
it('converts text parts', () => {
const parts: Part[] = [{ text: 'hello' }];
expect(geminiPartsToContentParts(parts)).toEqual([
{ type: 'text', text: 'hello' },
]);
});
it('converts thought parts', () => {
const parts: Part[] = [
{ text: 'thinking...', thought: true, thoughtSignature: 'sig123' },
];
expect(geminiPartsToContentParts(parts)).toEqual([
{
type: 'thought',
thought: 'thinking...',
thoughtSignature: 'sig123',
},
]);
});
it('converts thought parts without signature', () => {
const parts: Part[] = [{ text: 'thinking...', thought: true }];
expect(geminiPartsToContentParts(parts)).toEqual([
{ type: 'thought', thought: 'thinking...' },
]);
});
it('converts inlineData parts to media', () => {
const parts: Part[] = [
{ inlineData: { data: 'base64data', mimeType: 'image/png' } },
];
expect(geminiPartsToContentParts(parts)).toEqual([
{ type: 'media', data: 'base64data', mimeType: 'image/png' },
]);
});
it('converts fileData parts to media', () => {
const parts: Part[] = [
{
fileData: {
fileUri: 'gs://bucket/file.pdf',
mimeType: 'application/pdf',
},
},
];
expect(geminiPartsToContentParts(parts)).toEqual([
{
type: 'media',
uri: 'gs://bucket/file.pdf',
mimeType: 'application/pdf',
},
]);
});
it('skips functionCall parts', () => {
const parts: Part[] = [
{ functionCall: { name: 'myFunc', args: { key: 'value' } } },
];
const result = geminiPartsToContentParts(parts);
expect(result).toEqual([]);
});
it('skips functionResponse parts', () => {
const parts: Part[] = [
{
functionResponse: {
name: 'myFunc',
response: { output: 'result' },
},
},
];
const result = geminiPartsToContentParts(parts);
expect(result).toEqual([]);
});
it('serializes unknown part types to text with _meta', () => {
const parts: Part[] = [{ unknownField: 'data' } as Part];
const result = geminiPartsToContentParts(parts);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('text');
expect(result[0]?._meta).toEqual({ partType: 'unknown' });
});
it('handles empty array', () => {
expect(geminiPartsToContentParts([])).toEqual([]);
});
it('handles mixed parts', () => {
const parts: Part[] = [
{ text: 'hello' },
{ inlineData: { data: 'img', mimeType: 'image/jpeg' } },
{ text: 'thought', thought: true },
];
const result = geminiPartsToContentParts(parts);
expect(result).toHaveLength(3);
expect(result[0]?.type).toBe('text');
expect(result[1]?.type).toBe('media');
expect(result[2]?.type).toBe('thought');
});
});
describe('contentPartsToGeminiParts', () => {
it('converts text ContentParts', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
expect(contentPartsToGeminiParts(content)).toEqual([{ text: 'hello' }]);
});
it('converts thought ContentParts', () => {
const content: ContentPart[] = [
{ type: 'thought', thought: 'thinking...', thoughtSignature: 'sig' },
];
expect(contentPartsToGeminiParts(content)).toEqual([
{ text: 'thinking...', thought: true, thoughtSignature: 'sig' },
]);
});
it('converts thought ContentParts without signature', () => {
const content: ContentPart[] = [
{ type: 'thought', thought: 'thinking...' },
];
expect(contentPartsToGeminiParts(content)).toEqual([
{ text: 'thinking...', thought: true },
]);
});
it('converts media ContentParts with data to inlineData', () => {
const content: ContentPart[] = [
{ type: 'media', data: 'base64', mimeType: 'image/png' },
];
expect(contentPartsToGeminiParts(content)).toEqual([
{ inlineData: { data: 'base64', mimeType: 'image/png' } },
]);
});
it('converts media ContentParts with uri to fileData', () => {
const content: ContentPart[] = [
{ type: 'media', uri: 'gs://bucket/file', mimeType: 'application/pdf' },
];
expect(contentPartsToGeminiParts(content)).toEqual([
{
fileData: { fileUri: 'gs://bucket/file', mimeType: 'application/pdf' },
},
]);
});
it('converts reference ContentParts to text', () => {
const content: ContentPart[] = [{ type: 'reference', text: '@file.ts' }];
expect(contentPartsToGeminiParts(content)).toEqual([{ text: '@file.ts' }]);
});
it('handles empty array', () => {
expect(contentPartsToGeminiParts([])).toEqual([]);
});
it('skips media parts with no data or uri', () => {
const content: ContentPart[] = [{ type: 'media', mimeType: 'image/png' }];
expect(contentPartsToGeminiParts(content)).toEqual([]);
});
it('defaults mimeType for media with data but no mimeType', () => {
const content: ContentPart[] = [{ type: 'media', data: 'base64data' }];
const result = contentPartsToGeminiParts(content);
expect(result).toEqual([
{
inlineData: {
data: 'base64data',
mimeType: 'application/octet-stream',
},
},
]);
});
it('serializes unknown ContentPart variants', () => {
// Force an unknown variant past the type system
const content = [
{ type: 'custom_widget', payload: 123 },
] as unknown as ContentPart[];
const result = contentPartsToGeminiParts(content);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
text: JSON.stringify({ type: 'custom_widget', payload: 123 }),
});
});
});
describe('toolResultDisplayToContentParts', () => {
it('returns undefined for undefined', () => {
expect(toolResultDisplayToContentParts(undefined)).toBeUndefined();
});
it('returns undefined for null', () => {
expect(toolResultDisplayToContentParts(null)).toBeUndefined();
});
it('handles string resultDisplay as-is', () => {
const result = toolResultDisplayToContentParts('File written');
expect(result).toEqual([{ type: 'text', text: 'File written' }]);
});
it('stringifies object resultDisplay', () => {
const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' };
const result = toolResultDisplayToContentParts(display);
expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]);
});
});
describe('buildToolResponseData', () => {
it('preserves outputFile and contentLength', () => {
const result = buildToolResponseData({
outputFile: '/tmp/result.txt',
contentLength: 256,
});
expect(result).toEqual({
outputFile: '/tmp/result.txt',
contentLength: 256,
});
});
it('returns undefined for empty response', () => {
const result = buildToolResponseData({});
expect(result).toBeUndefined();
});
it('includes errorType when present', () => {
const result = buildToolResponseData({
errorType: 'permission_denied',
});
expect(result).toEqual({ errorType: 'permission_denied' });
});
it('merges data with other fields', () => {
const result = buildToolResponseData({
data: { custom: 'value' },
outputFile: '/tmp/file.txt',
});
expect(result).toEqual({
custom: 'value',
outputFile: '/tmp/file.txt',
});
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
import type { ContentPart } from './types.js';
/**
* Converts Gemini API Part objects to framework-agnostic ContentPart objects.
* Handles text, thought, inlineData, fileData parts and serializes unknown
* part types to text to avoid silent data loss.
*/
export function geminiPartsToContentParts(parts: Part[]): ContentPart[] {
const result: ContentPart[] = [];
for (const part of parts) {
if ('text' in part && part.text !== undefined) {
if ('thought' in part && part.thought) {
result.push({
type: 'thought',
thought: part.text,
...(part.thoughtSignature
? { thoughtSignature: part.thoughtSignature }
: {}),
});
} else {
result.push({ type: 'text', text: part.text });
}
} else if ('inlineData' in part && part.inlineData) {
result.push({
type: 'media',
data: part.inlineData.data,
mimeType: part.inlineData.mimeType,
});
} else if ('fileData' in part && part.fileData) {
result.push({
type: 'media',
uri: part.fileData.fileUri,
mimeType: part.fileData.mimeType,
});
} else if ('functionCall' in part && part.functionCall) {
continue; // Skip function calls, they are emitted as distinct tool_request events
} else if ('functionResponse' in part && part.functionResponse) {
continue; // Skip function responses, they are tied to tool_response events
} else {
// Fallback: serialize any unrecognized part type to text
result.push({
type: 'text',
text: JSON.stringify(part),
_meta: { partType: 'unknown' },
});
}
}
return result;
}
/**
* Converts framework-agnostic ContentPart objects to Gemini API Part objects.
*/
export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
const result: Part[] = [];
for (const part of content) {
switch (part.type) {
case 'text':
result.push({ text: part.text });
break;
case 'thought':
result.push({
text: part.thought,
thought: true,
...(part.thoughtSignature
? { thoughtSignature: part.thoughtSignature }
: {}),
});
break;
case 'media':
if (part.data) {
result.push({
inlineData: {
data: part.data,
mimeType: part.mimeType ?? 'application/octet-stream',
},
});
} else if (part.uri) {
result.push({
fileData: { fileUri: part.uri, mimeType: part.mimeType },
});
}
break;
case 'reference':
// References are converted to text for the model
result.push({ text: part.text });
break;
default:
// Serialize unknown ContentPart variants instead of dropping them
result.push({ text: JSON.stringify(part) });
break;
}
}
return result;
}
/**
* Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[].
* Handles string, object-valued (FileDiff, SubagentProgress, etc.),
* and undefined resultDisplay consistently.
*/
export function toolResultDisplayToContentParts(
resultDisplay: unknown,
): ContentPart[] | undefined {
if (resultDisplay === undefined || resultDisplay === null) {
return undefined;
}
const text =
typeof resultDisplay === 'string'
? resultDisplay
: JSON.stringify(resultDisplay);
return [{ type: 'text', text }];
}
/**
* Builds the data record for a tool_response AgentEvent, preserving
* all available metadata from the ToolCallResponseInfo.
*/
export function buildToolResponseData(response: {
data?: Record<string, unknown>;
errorType?: string;
outputFile?: string;
contentLength?: number;
}): Record<string, unknown> | undefined {
const parts: Record<string, unknown> = {};
if (response.data) Object.assign(parts, response.data);
if (response.errorType) parts['errorType'] = response.errorType;
if (response.outputFile) parts['outputFile'] = response.outputFile;
if (response.contentLength !== undefined)
parts['contentLength'] = response.contentLength;
return Object.keys(parts).length > 0 ? parts : undefined;
}
+23
View File
@@ -9,6 +9,8 @@ import * as path from 'node:path';
import { inspect } from 'node:util';
import process from 'node:process';
import { z } from 'zod';
import type { ConversationRecord } from '../services/chatRecordingService.js';
export type { ConversationRecord };
import {
AuthType,
createContentGenerator,
@@ -228,6 +230,25 @@ export interface ResolvedExtensionSetting {
source?: string;
}
export interface TrajectoryProvider {
/** Prefix used to identify sessions from this provider (e.g., 'ext:') */
prefix: string;
/** Optional display name for UI Tabs */
displayName?: string;
/** Return an array of conversational tags/ids */
listSessions(workspaceUri?: string): Promise<
Array<{
id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
}>
>;
/** Load a single conversation payload */
loadSession(id: string): Promise<ConversationRecord | null>;
}
export interface AgentRunConfig {
maxTimeMinutes?: number;
maxTurns?: number;
@@ -377,6 +398,8 @@ export interface GeminiCLIExtension {
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
/** Loaded JS module for trajectory decoding */
trajectoryProviderModule?: TrajectoryProvider;
}
export interface ExtensionInstallMetadata {