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:
Sri Pasumarthi
2026-02-23 12:49:24 -08:00
parent fb1b1b451d
commit ef58958a86
3 changed files with 292 additions and 1 deletions
@@ -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> {