From 299cc9bebfbfb3ff051c406a5076a57487a13909 Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Fri, 12 Dec 2025 12:09:04 -0500 Subject: [PATCH] feat(a2a): Introduce /init command for a2a server (#13419) --- packages/a2a-server/src/agent/executor.ts | 9 +- packages/a2a-server/src/agent/task.test.ts | 69 ++++++- packages/a2a-server/src/agent/task.ts | 17 +- .../src/commands/command-registry.ts | 2 + packages/a2a-server/src/commands/init.test.ts | 182 ++++++++++++++++++ packages/a2a-server/src/commands/init.ts | 168 ++++++++++++++++ packages/a2a-server/src/commands/types.ts | 4 + packages/a2a-server/src/http/app.test.ts | 113 +++++++++++ packages/a2a-server/src/http/app.ts | 125 +++++++----- packages/a2a-server/src/types.ts | 1 + packages/cli/src/ui/commands/initCommand.ts | 71 ++----- packages/core/src/commands/init.test.ts | 29 +++ packages/core/src/commands/init.ts | 57 ++++++ packages/core/src/index.ts | 1 + 14 files changed, 742 insertions(+), 106 deletions(-) create mode 100644 packages/a2a-server/src/commands/init.test.ts create mode 100644 packages/a2a-server/src/commands/init.ts create mode 100644 packages/core/src/commands/init.test.ts create mode 100644 packages/core/src/commands/init.ts diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index 998b6839e8..3f87c600fe 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -127,6 +127,7 @@ export class CoderAgentExecutor implements AgentExecutor { contextId, config, eventBus, + agentSettings.autoExecute, ); runtimeTask.taskState = persistedState._taskState; await runtimeTask.geminiClient.initialize(); @@ -145,7 +146,13 @@ export class CoderAgentExecutor implements AgentExecutor { ): Promise { const agentSettings = agentSettingsInput || ({} as AgentSettings); const config = await this.getConfig(agentSettings, taskId); - const runtimeTask = await Task.create(taskId, contextId, config, eventBus); + const runtimeTask = await Task.create( + taskId, + contextId, + config, + eventBus, + agentSettings.autoExecute, + ); await runtimeTask.geminiClient.initialize(); const wrapper = new TaskWrapper(runtimeTask, agentSettings); diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index b4a342707f..862e8abcff 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -20,6 +20,8 @@ import { type ToolCallRequestInfo, type GitService, type CompletedToolCall, + ApprovalMode, + ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; @@ -353,10 +355,12 @@ describe('Task', () => { let task: Task; type SpyInstance = ReturnType; let setTaskStateAndPublishUpdateSpy: SpyInstance; + let mockConfig: Config; + let mockEventBus: ExecutionEventBus; beforeEach(() => { - const mockConfig = createMockConfig(); - const mockEventBus: ExecutionEventBus = { + mockConfig = createMockConfig() as Config; + mockEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), @@ -465,6 +469,67 @@ describe('Task', () => { ); expect(finalCall).toBeUndefined(); }); + + describe('auto-approval', () => { + it('should auto-approve tool calls when autoExecute is true', () => { + task.autoExecute = true; + const onConfirmSpy = vi.fn(); + const toolCalls = [ + { + request: { callId: '1' }, + status: 'awaiting_approval', + confirmationDetails: { onConfirm: onConfirmSpy }, + }, + ] as unknown as ToolCall[]; + + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(toolCalls); + + expect(onConfirmSpy).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ); + }); + + it('should auto-approve tool calls when approval mode is YOLO', () => { + (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); + task.autoExecute = false; + const onConfirmSpy = vi.fn(); + const toolCalls = [ + { + request: { callId: '1' }, + status: 'awaiting_approval', + confirmationDetails: { onConfirm: onConfirmSpy }, + }, + ] as unknown as ToolCall[]; + + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(toolCalls); + + expect(onConfirmSpy).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ); + }); + + it('should NOT auto-approve when autoExecute is false and mode is not YOLO', () => { + task.autoExecute = false; + (mockConfig.getApprovalMode as Mock).mockReturnValue( + ApprovalMode.DEFAULT, + ); + const onConfirmSpy = vi.fn(); + const toolCalls = [ + { + request: { callId: '1' }, + status: 'awaiting_approval', + confirmationDetails: { onConfirm: onConfirmSpy }, + }, + ] as unknown as ToolCall[]; + + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(toolCalls); + + expect(onConfirmSpy).not.toHaveBeenCalled(); + }); + }); }); describe('currentPromptId and promptCount', () => { diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 5322f9edf0..e9a9fb3668 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -73,6 +73,7 @@ export class Task { modelInfo?: string; currentPromptId: string | undefined; promptCount = 0; + autoExecute: boolean; // For tool waiting logic private pendingToolCalls: Map = new Map(); //toolCallId --> status @@ -87,6 +88,7 @@ export class Task { contextId: string, config: Config, eventBus?: ExecutionEventBus, + autoExecute = false, ) { this.id = id; this.contextId = contextId; @@ -98,6 +100,7 @@ export class Task { this.eventBus = eventBus; this.completedToolCalls = []; this._resetToolCompletionPromise(); + this.autoExecute = autoExecute; this.config.setFallbackModelHandler( // For a2a-server, we want to automatically switch to the fallback model // for future requests without retrying the current one. The 'stop' @@ -111,8 +114,9 @@ export class Task { contextId: string, config: Config, eventBus?: ExecutionEventBus, + autoExecute?: boolean, ): Promise { - return new Task(id, contextId, config, eventBus); + return new Task(id, contextId, config, eventBus, autoExecute); } // Note: `getAllMCPServerStatuses` retrieves the status of all MCP servers for the entire @@ -396,8 +400,15 @@ export class Task { } }); - if (this.config.getApprovalMode() === ApprovalMode.YOLO) { - logger.info('[Task] YOLO mode enabled. Auto-approving all tool calls.'); + if ( + this.autoExecute || + this.config.getApprovalMode() === ApprovalMode.YOLO + ) { + logger.info( + '[Task] ' + + (this.autoExecute ? '' : 'YOLO mode enabled. ') + + 'Auto-approving all tool calls.', + ); toolCalls.forEach((tc: ToolCall) => { if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts index 964a2f5f7b..0643eeefce 100644 --- a/packages/a2a-server/src/commands/command-registry.ts +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -5,6 +5,7 @@ */ import { ExtensionsCommand } from './extensions.js'; +import { InitCommand } from './init.js'; import { RestoreCommand } from './restore.js'; import type { Command } from './types.js'; @@ -14,6 +15,7 @@ class CommandRegistry { constructor() { this.register(new ExtensionsCommand()); this.register(new RestoreCommand()); + this.register(new InitCommand()); } register(command: Command) { diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts new file mode 100644 index 0000000000..b897d0b9e3 --- /dev/null +++ b/packages/a2a-server/src/commands/init.test.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { InitCommand } from './init.js'; +import { performInit } from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { CoderAgentExecutor } from '../agent/executor.js'; +import { CoderAgentEvent } from '../types.js'; +import type { ExecutionEventBus } from '@a2a-js/sdk/server'; +import { createMockConfig } from '../utils/testing_utils.js'; +import type { CommandContext } from './types.js'; +import type { CommandActionReturn, Config } from '@google/gemini-cli-core'; +import { logger } from '../utils/logger.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + performInit: vi.fn(), + }; +}); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('../agent/executor.js', () => ({ + CoderAgentExecutor: vi.fn().mockImplementation(() => ({ + execute: vi.fn(), + })), +})); + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +describe('InitCommand', () => { + let eventBus: ExecutionEventBus; + let command: InitCommand; + let context: CommandContext; + let publishSpy: ReturnType; + let mockExecute: ReturnType; + const mockWorkspacePath = path.resolve('/tmp'); + + beforeEach(() => { + process.env['CODER_AGENT_WORKSPACE_PATH'] = mockWorkspacePath; + eventBus = { + publish: vi.fn(), + } as unknown as ExecutionEventBus; + command = new InitCommand(); + const mockConfig = createMockConfig({ + getModel: () => 'gemini-pro', + }); + const mockExecutorInstance = new CoderAgentExecutor(); + context = { + config: mockConfig as unknown as Config, + agentExecutor: mockExecutorInstance, + eventBus, + } as CommandContext; + publishSpy = vi.spyOn(eventBus, 'publish'); + mockExecute = vi.fn(); + vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute); + vi.clearAllMocks(); + }); + + it('has requiresWorkspace set to true', () => { + expect(command.requiresWorkspace).toBe(true); + }); + + describe('execute', () => { + it('handles info from performInit', async () => { + vi.mocked(performInit).mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'GEMINI.md already exists.', + } as CommandActionReturn); + + await command.execute(context, []); + + expect(logger.info).toHaveBeenCalledWith( + '[EventBus event]: ', + expect.objectContaining({ + kind: 'status-update', + status: expect.objectContaining({ + state: 'completed', + message: expect.objectContaining({ + parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }], + }), + }), + }), + ); + + expect(publishSpy).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'status-update', + status: expect.objectContaining({ + state: 'completed', + message: expect.objectContaining({ + parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }], + }), + }), + }), + ); + }); + + it('handles error from performInit', async () => { + vi.mocked(performInit).mockReturnValue({ + type: 'message', + messageType: 'error', + content: 'An error occurred.', + } as CommandActionReturn); + + await command.execute(context, []); + + expect(publishSpy).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'status-update', + status: expect.objectContaining({ + state: 'failed', + message: expect.objectContaining({ + parts: [{ kind: 'text', text: 'An error occurred.' }], + }), + }), + }), + ); + }); + + describe('when handling submit_prompt', () => { + beforeEach(() => { + vi.mocked(performInit).mockReturnValue({ + type: 'submit_prompt', + content: 'Create a new GEMINI.md file.', + } as CommandActionReturn); + }); + + it('writes the file and executes the agent', async () => { + await command.execute(context, []); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(mockWorkspacePath, 'GEMINI.md'), + '', + 'utf8', + ); + expect(mockExecute).toHaveBeenCalled(); + }); + + it('passes autoExecute to the agent executor', async () => { + await command.execute(context, []); + + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + userMessage: expect.objectContaining({ + parts: expect.arrayContaining([ + expect.objectContaining({ + text: 'Create a new GEMINI.md file.', + }), + ]), + metadata: { + coderAgent: { + kind: CoderAgentEvent.StateAgentSettingsEvent, + workspacePath: mockWorkspacePath, + autoExecute: true, + }, + }, + }), + }), + eventBus, + ); + }); + }); + }); +}); diff --git a/packages/a2a-server/src/commands/init.ts b/packages/a2a-server/src/commands/init.ts new file mode 100644 index 0000000000..2a78ae5f95 --- /dev/null +++ b/packages/a2a-server/src/commands/init.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { CoderAgentEvent, type AgentSettings } from '../types.js'; +import { performInit } from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; +import type { CoderAgentExecutor } from '../agent/executor.js'; +import type { + ExecutionEventBus, + RequestContext, + AgentExecutionEvent, +} from '@a2a-js/sdk/server'; +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../utils/logger.js'; + +export class InitCommand implements Command { + name = 'init'; + description = 'Analyzes the project and creates a tailored GEMINI.md file'; + requiresWorkspace = true; + streaming = true; + + private handleMessageResult( + result: { content: string; messageType: 'info' | 'error' }, + context: CommandContext, + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + ): CommandExecutionResponse { + const statusState = result.messageType === 'error' ? 'failed' : 'completed'; + const eventType = + result.messageType === 'error' + ? CoderAgentEvent.StateChangeEvent + : CoderAgentEvent.TextContentEvent; + + const event: AgentExecutionEvent = { + kind: 'status-update', + taskId, + contextId, + status: { + state: statusState, + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: result.content }], + messageId: uuidv4(), + taskId, + contextId, + }, + timestamp: new Date().toISOString(), + }, + final: true, + metadata: { + coderAgent: { kind: eventType }, + model: context.config.getModel(), + }, + }; + + logger.info('[EventBus event]: ', event); + eventBus.publish(event); + return { + name: this.name, + data: result, + }; + } + + private async handleSubmitPromptResult( + result: { content: unknown }, + context: CommandContext, + geminiMdPath: string, + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + ): Promise { + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + if (!context.agentExecutor) { + throw new Error('Agent executor not found in context.'); + } + const agentExecutor = context.agentExecutor as CoderAgentExecutor; + + const agentSettings: AgentSettings = { + kind: CoderAgentEvent.StateAgentSettingsEvent, + workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!, + autoExecute: true, + }; + + if (typeof result.content !== 'string') { + throw new Error('Init command content must be a string.'); + } + const promptText = result.content; + + const requestContext: RequestContext = { + userMessage: { + kind: 'message', + role: 'user', + parts: [{ kind: 'text', text: promptText }], + messageId: uuidv4(), + taskId, + contextId, + metadata: { + coderAgent: agentSettings, + }, + }, + taskId, + contextId, + }; + + // The executor will handle the entire agentic loop, including + // creating the task, streaming responses, and handling tools. + await agentExecutor.execute(requestContext, eventBus); + return { + name: this.name, + data: geminiMdPath, + }; + } + + async execute( + context: CommandContext, + _args: string[] = [], + ): Promise { + if (!context.eventBus) { + return { + name: this.name, + data: 'Use executeStream to get streaming results.', + }; + } + + const geminiMdPath = path.join( + process.env['CODER_AGENT_WORKSPACE_PATH']!, + 'GEMINI.md', + ); + const result = performInit(fs.existsSync(geminiMdPath)); + + const taskId = uuidv4(); + const contextId = uuidv4(); + + switch (result.type) { + case 'message': + return this.handleMessageResult( + result, + context, + context.eventBus, + taskId, + contextId, + ); + case 'submit_prompt': + return this.handleSubmitPromptResult( + result, + context, + geminiMdPath, + context.eventBus, + taskId, + contextId, + ); + default: + throw new Error('Unknown result type from performInit'); + } + } +} diff --git a/packages/a2a-server/src/commands/types.ts b/packages/a2a-server/src/commands/types.ts index aca5693e13..910515eadc 100644 --- a/packages/a2a-server/src/commands/types.ts +++ b/packages/a2a-server/src/commands/types.ts @@ -4,11 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { ExecutionEventBus, AgentExecutor } from '@a2a-js/sdk/server'; import type { Config, GitService } from '@google/gemini-cli-core'; export interface CommandContext { config: Config; git?: GitService; + agentExecutor?: AgentExecutor; + eventBus?: ExecutionEventBus; } export interface CommandArgument { @@ -24,6 +27,7 @@ export interface Command { readonly subCommands?: Command[]; readonly topLevel?: boolean; readonly requiresWorkspace?: boolean; + readonly streaming?: boolean; execute( config: CommandContext, diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 641b3749e6..6ff4b37867 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -1061,5 +1061,118 @@ describe('E2E Tests', () => { expect(response.status).toBe(200); expect(response.body.data).toBe('success'); }); + + it('should include agentExecutor in context', async () => { + const mockCommand = { + name: 'context-check-command', + description: 'checks context', + execute: vi.fn(async (context: CommandContext) => { + if (!context.agentExecutor) { + throw new Error('agentExecutor missing'); + } + return { name: 'context-check-command', data: 'success' }; + }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand); + + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'context-check-command', args: [] }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(res.body.data).toBe('success'); + }); + + describe('/executeCommand streaming', () => { + it('should execute a streaming command and stream back events', (done: ( + err?: unknown, + ) => void) => { + const executeSpy = vi.fn(async (context: CommandContext) => { + context.eventBus?.publish({ + kind: 'status-update', + status: { state: 'working' }, + taskId: 'test-task', + contextId: 'test-context', + final: false, + }); + context.eventBus?.publish({ + kind: 'status-update', + status: { state: 'completed' }, + taskId: 'test-task', + contextId: 'test-context', + final: true, + }); + return { name: 'stream-test', data: 'done' }; + }); + + const mockStreamCommand = { + name: 'stream-test', + description: 'A test streaming command', + streaming: true, + execute: executeSpy, + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand); + + const agent = request.agent(app); + agent + .post('/executeCommand') + .send({ command: 'stream-test', args: [] }) + .set('Content-Type', 'application/json') + .set('Accept', 'text/event-stream') + .on('response', (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on('end', () => { + try { + const events = streamToSSEEvents(data); + expect(events.length).toBe(2); + expect(events[0].result).toEqual({ + kind: 'status-update', + status: { state: 'working' }, + taskId: 'test-task', + contextId: 'test-context', + final: false, + }); + expect(events[1].result).toEqual({ + kind: 'status-update', + status: { state: 'completed' }, + taskId: 'test-task', + contextId: 'test-context', + final: true, + }); + expect(executeSpy).toHaveBeenCalled(); + done(); + } catch (e) { + done(e); + } + }); + }) + .end(); + }); + + it('should handle non-streaming commands gracefully', async () => { + const mockNonStreamCommand = { + name: 'non-stream-test', + description: 'A test non-streaming command', + execute: vi + .fn() + .mockResolvedValue({ name: 'non-stream-test', data: 'done' }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand); + + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'non-stream-test', args: [] }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' }); + }); + }); }); }); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 91f1e70dd4..2439f09b07 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -6,9 +6,14 @@ import express from 'express'; -import type { AgentCard } from '@a2a-js/sdk'; +import type { AgentCard, Message } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; -import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server'; +import { + DefaultRequestHandler, + InMemoryTaskStore, + DefaultExecutionEventBus, + type AgentExecutionEvent, +} from '@a2a-js/sdk/server'; import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; @@ -73,6 +78,76 @@ export function updateCoderAgentCardUrl(port: number) { coderAgentCard.url = `http://localhost:${port}/`; } +async function handleExecuteCommand( + req: express.Request, + res: express.Response, + context: { + config: Awaited>; + git: GitService | undefined; + agentExecutor: CoderAgentExecutor; + }, +) { + logger.info('[CoreAgent] Received /executeCommand request: ', req.body); + const { command, args } = req.body; + try { + if (typeof command !== 'string') { + return res.status(400).json({ error: 'Invalid "command" field.' }); + } + + if (args && !Array.isArray(args)) { + return res.status(400).json({ error: '"args" field must be an array.' }); + } + + const commandToExecute = commandRegistry.get(command); + + if (commandToExecute?.requiresWorkspace) { + if (!process.env['CODER_AGENT_WORKSPACE_PATH']) { + return res.status(400).json({ + error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`, + }); + } + } + + if (!commandToExecute) { + return res.status(404).json({ error: `Command not found: ${command}` }); + } + + if (commandToExecute.streaming) { + const eventBus = new DefaultExecutionEventBus(); + res.setHeader('Content-Type', 'text/event-stream'); + const eventHandler = (event: AgentExecutionEvent) => { + const jsonRpcResponse = { + jsonrpc: '2.0', + id: 'taskId' in event ? event.taskId : (event as Message).messageId, + result: event, + }; + res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`); + }; + eventBus.on('event', eventHandler); + + await commandToExecute.execute({ ...context, eventBus }, args ?? []); + + eventBus.off('event', eventHandler); + eventBus.finished(); + return res.end(); // Explicit return for streaming path + } else { + const result = await commandToExecute.execute(context, args ?? []); + logger.info('[CoreAgent] Sending /executeCommand response: ', result); + return res.status(200).json(result); + } + } catch (e) { + logger.error( + `Error executing /executeCommand: ${command} with args: ${JSON.stringify( + args, + )}`, + e, + ); + const errorMessage = + e instanceof Error ? e.message : 'Unknown error executing command'; + return res.status(500).json({ error: errorMessage }); + } +} + export async function createApp() { try { // Load the server configuration once on startup. @@ -92,8 +167,6 @@ export async function createApp() { await git.initialize(); } - const context = { config, git }; - // loadEnvironment() is called within getConfig now const bucketName = process.env['GCS_BUCKET_NAME']; let taskStoreForExecutor: TaskStore; @@ -113,6 +186,8 @@ export async function createApp() { const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor); + const context = { config, git, agentExecutor }; + const requestHandler = new DefaultRequestHandler( coderAgentCard, taskStoreForHandler, @@ -152,46 +227,8 @@ export async function createApp() { } }); - expressApp.post('/executeCommand', async (req, res) => { - logger.info('[CoreAgent] Received /executeCommand request: ', req.body); - try { - const { command, args } = req.body; - - if (typeof command !== 'string') { - return res.status(400).json({ error: 'Invalid "command" field.' }); - } - - if (args && !Array.isArray(args)) { - return res - .status(400) - .json({ error: '"args" field must be an array.' }); - } - - const commandToExecute = commandRegistry.get(command); - - if (commandToExecute?.requiresWorkspace) { - if (!process.env['CODER_AGENT_WORKSPACE_PATH']) { - return res.status(400).json({ - error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`, - }); - } - } - - if (!commandToExecute) { - return res - .status(404) - .json({ error: `Command not found: ${command}` }); - } - - const result = await commandToExecute.execute(context, args ?? []); - logger.info('[CoreAgent] Sending /executeCommand response: ', result); - return res.status(200).json(result); - } catch (e) { - logger.error('Error executing /executeCommand:', e); - const errorMessage = - e instanceof Error ? e.message : 'Unknown error executing command'; - return res.status(500).json({ error: errorMessage }); - } + expressApp.post('/executeCommand', (req, res) => { + void handleExecuteCommand(req, res, context); }); expressApp.get('/listCommands', (req, res) => { diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts index 74b5ec9320..c3cfc3d85f 100644 --- a/packages/a2a-server/src/types.ts +++ b/packages/a2a-server/src/types.ts @@ -46,6 +46,7 @@ export enum CoderAgentEvent { export interface AgentSettings { kind: CoderAgentEvent.StateAgentSettingsEvent; workspacePath: string; + autoExecute?: boolean; } export interface ToolCallConfirmation { diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index f978fccdf8..6c2209921f 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -12,6 +12,7 @@ import type { SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; +import { performInit } from '@google/gemini-cli-core'; export const initCommand: SlashCommand = { name: 'init', @@ -32,63 +33,21 @@ export const initCommand: SlashCommand = { const targetDir = context.services.config.getTargetDir(); const geminiMdPath = path.join(targetDir, 'GEMINI.md'); - if (fs.existsSync(geminiMdPath)) { - return { - type: 'message', - messageType: 'info', - content: - 'A GEMINI.md file already exists in this directory. No changes were made.', - }; + const result = performInit(fs.existsSync(geminiMdPath)); + + if (result.type === 'submit_prompt') { + // Create an empty GEMINI.md file + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + context.ui.addItem( + { + type: 'info', + text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', + }, + Date.now(), + ); } - // Create an empty GEMINI.md file - fs.writeFileSync(geminiMdPath, '', 'utf8'); - - context.ui.addItem( - { - type: 'info', - text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', - }, - Date.now(), - ); - - return { - type: 'submit_prompt', - content: ` -You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions. - -**Analysis Process:** - -1. **Initial Exploration:** - * Start by listing the files and directories to get a high-level overview of the structure. - * Read the README file (e.g., \`README.md\`, \`README.txt\`) if it exists. This is often the best place to start. - -2. **Iterative Deep Dive (up to 10 files):** - * Based on your initial findings, select a few files that seem most important (e.g., configuration files, main source files, documentation). - * Read them. As you learn more, refine your understanding and decide which files to read next. You don't need to decide all 10 files at once. Let your discoveries guide your exploration. - -3. **Identify Project Type:** - * **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project. - * **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else. - -**GEMINI.md Content Generation:** - -**For a Code Project:** - -* **Project Overview:** Write a clear and concise summary of the project's purpose, main technologies, and architecture. -* **Building and Running:** Document the key commands for building, running, and testing the project. Infer these from the files you've read (e.g., \`scripts\` in \`package.json\`, \`Makefile\`, etc.). If you can't find explicit commands, provide a placeholder with a TODO. -* **Development Conventions:** Describe any coding styles, testing practices, or contribution guidelines you can infer from the codebase. - -**For a Non-Code Project:** - -* **Directory Overview:** Describe the purpose and contents of the directory. What is it for? What kind of information does it hold? -* **Key Files:** List the most important files and briefly explain what they contain. -* **Usage:** Explain how the contents of this directory are intended to be used. - -**Final Output:** - -Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown. -`, - }; + return result as SlashCommandActionReturn; }, }; diff --git a/packages/core/src/commands/init.test.ts b/packages/core/src/commands/init.test.ts new file mode 100644 index 0000000000..9fb4b50305 --- /dev/null +++ b/packages/core/src/commands/init.test.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it } from 'vitest'; +import { performInit } from './init.js'; + +describe('performInit', () => { + it('returns info if GEMINI.md already exists', () => { + const result = performInit(true); + + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toContain('already exists'); + } + }); + + it('returns submit_prompt if GEMINI.md does not exist', () => { + const result = performInit(false); + expect(result.type).toBe('submit_prompt'); + + if (result.type === 'submit_prompt') { + expect(result.content).toContain('You are an AI agent'); + } + }); +}); diff --git a/packages/core/src/commands/init.ts b/packages/core/src/commands/init.ts new file mode 100644 index 0000000000..37b263e972 --- /dev/null +++ b/packages/core/src/commands/init.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandActionReturn } from './types.js'; + +export function performInit(doesGeminiMdExist: boolean): CommandActionReturn { + if (doesGeminiMdExist) { + return { + type: 'message', + messageType: 'info', + content: + 'A GEMINI.md file already exists in this directory. No changes were made.', + }; + } + + return { + type: 'submit_prompt', + content: ` +You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions. + +**Analysis Process:** + +1. **Initial Exploration:** + * Start by listing the files and directories to get a high-level overview of the structure. + * Read the README file (e.g., \`README.md\`, \`README.txt\`) if it exists. This is often the best place to start. + +2. **Iterative Deep Dive (up to 10 files):** + * Based on your initial findings, select a few files that seem most important (e.g., configuration files, main source files, documentation). + * Read them. As you learn more, refine your understanding and decide which files to read next. You don't need to decide all 10 files at once. Let your discoveries guide your exploration. + +3. **Identify Project Type:** + * **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project. + * **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else. + +**GEMINI.md Content Generation:** + +**For a Code Project:** + +* **Project Overview:** Write a clear and concise summary of the project's purpose, main technologies, and architecture. +* **Building and Running:** Document the key commands for building, running, and testing the project. Infer these from the files you've read (e.g., \`scripts\` in \`package.json\`, \`Makefile\`, etc.). If you can't find explicit commands, provide a placeholder with a TODO. +* **Development Conventions:** Describe any coding styles, testing practices, or contribution guidelines you can infer from the codebase. + +**For a Non-Code Project:** + +* **Directory Overview:** Describe the purpose and contents of the directory. What is it for? What kind of information does it hold? +* **Key Files:** List the most important files and briefly explain what they contain. +* **Usage:** Explain how the contents of this directory are intended to be used. + +**Final Output:** + +Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown. +`, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b268ea12df..3ff58417c6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export * from './confirmation-bus/message-bus.js'; // Export Commands logic export * from './commands/extensions.js'; export * from './commands/restore.js'; +export * from './commands/init.js'; export * from './commands/types.js'; // Export Core Logic