mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 21:07:00 -07:00
feat: Implement direct command handling for /status and /mcp, intercept $commit and $review-pr for prompt mutation, and send available commands to the client.
This commit is contained in:
@@ -20,7 +20,8 @@ async function run(cmd) {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
} catch (_e) {
|
||||
// eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type Config,
|
||||
type MessageBus,
|
||||
LlmRole,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SettingScope,
|
||||
@@ -426,6 +427,7 @@ describe('Session', () => {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getMcpServers: vi.fn(),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
shouldIgnoreFile: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
@@ -450,6 +452,24 @@ describe('Session', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send available commands', async () => {
|
||||
await session.sendAvailableCommands();
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'status' }),
|
||||
expect.objectContaining({ name: 'mcp' }),
|
||||
expect.objectContaining({ name: '$commit' }),
|
||||
expect.objectContaining({ name: '$review-pr' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle prompt with text response', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
@@ -477,6 +497,163 @@ describe('Session', () => {
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /status command directly with newlines', async () => {
|
||||
mockConfig.getActiveModel.mockReturnValue('gemini-1.5-pro-test');
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/status\nTell me more' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should handle /status command directly', async () => {
|
||||
mockConfig.getActiveModel.mockReturnValue('gemini-1.5-pro-test');
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/status' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: expect.objectContaining({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('gemini-1.5-pro-test'),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
// Chat should not be called
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle /mcp command directly', async () => {
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
'test-mcp': {},
|
||||
} as Record<string, MCPServerConfig>);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/mcp' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: expect.objectContaining({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('`test-mcp`'),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should intercept $commit command and mutate prompt', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Committing...' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
// Should replace `$commit` with the instruction
|
||||
prompt: [{ type: 'text', text: '$commit my cool changes' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
// The prompt text should be modified to include the commit instruction
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Create a git commit based on the current changes using the tools available. my cool changes',
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal),
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should intercept $commit command with leading spaces and case insensitivity', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Committing...' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
// Should replace `$commit` with the instruction
|
||||
prompt: [{ type: 'text', text: ' \n$cOmMiT my cool changes' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
// The prompt text should be modified to include the commit instruction
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Create a git commit based on the current changes using the tools available. my cool changes',
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal),
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should intercept $review-pr command and mutate prompt', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Reviewing...' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '$review-pr' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Review the current pull request using the tools available.',
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal),
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tool calls', async () => {
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
|
||||
@@ -224,6 +224,9 @@ export class GeminiAgent {
|
||||
const session = new Session(sessionId, chat, config, this.connection);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
modes: {
|
||||
@@ -281,6 +284,9 @@ export class GeminiAgent {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
|
||||
return {
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
@@ -429,6 +435,30 @@ export class Session {
|
||||
return {};
|
||||
}
|
||||
|
||||
async sendAvailableCommands(): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Display session configuration and token usage',
|
||||
},
|
||||
{
|
||||
name: 'mcp',
|
||||
description: 'List configured MCP tools',
|
||||
},
|
||||
{
|
||||
name: '$commit',
|
||||
description: 'Create a git commit',
|
||||
},
|
||||
{
|
||||
name: '$review-pr',
|
||||
description: 'Review a pull request',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
|
||||
for (const msg of messages) {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
@@ -509,6 +539,23 @@ export class Session {
|
||||
|
||||
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
|
||||
// Command interception
|
||||
if (
|
||||
parts.length > 0 &&
|
||||
typeof parts[0] === 'object' &&
|
||||
parts[0] !== null &&
|
||||
'text' in parts[0] &&
|
||||
parts[0].text
|
||||
) {
|
||||
const firstText = parts[0].text.trim();
|
||||
if (firstText.startsWith('/') || firstText.startsWith('$')) {
|
||||
const handled = await this.handleCommand(firstText, parts);
|
||||
if (handled) {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
while (nextMessage !== null) {
|
||||
@@ -605,6 +652,72 @@ export class Session {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
|
||||
private async handleCommand(
|
||||
commandText: string,
|
||||
parts: Part[],
|
||||
): Promise<boolean> {
|
||||
const rawCommand = commandText.split(/\s+/)[0] || '';
|
||||
const commandToMatch = rawCommand.toLowerCase();
|
||||
|
||||
if (commandToMatch === '/status') {
|
||||
const activeModel = this.config.getActiveModel();
|
||||
const resolvedModel = resolveModel(activeModel);
|
||||
const content = `**Session Status**\n\n- Active Model: \`${resolvedModel}\``;
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: content },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (commandToMatch === '/mcp') {
|
||||
const mcpServers = this.config.getMcpServers() || {};
|
||||
let content = '**Configured MCP Servers**\n';
|
||||
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
if (serverNames.length === 0) {
|
||||
content += '\nNo MCP servers configured.';
|
||||
} else {
|
||||
content += '\n' + serverNames.map((name) => `- \`${name}\``).join('\n');
|
||||
}
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: content },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (commandToMatch === '$commit') {
|
||||
const textPart = parts[0];
|
||||
if (textPart && 'text' in textPart && typeof textPart.text === 'string') {
|
||||
textPart.text = textPart.text
|
||||
.replace(
|
||||
/^\s*\$commit/i,
|
||||
'Create a git commit based on the current changes using the tools available.',
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
return false; // Proceed with LLM execution
|
||||
}
|
||||
|
||||
if (commandToMatch === '$review-pr') {
|
||||
const textPart = parts[0];
|
||||
if (textPart && 'text' in textPart && typeof textPart.text === 'string') {
|
||||
textPart.text = textPart.text
|
||||
.replace(
|
||||
/^\s*\$review-pr/i,
|
||||
'Review the current pull request using the tools available.',
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
return false; // Proceed with LLM execution
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async sendUpdate(
|
||||
update: acp.SessionNotification['update'],
|
||||
): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user