From dfa6be5de52becac71556f08bdc92347c7256554 Mon Sep 17 00:00:00 2001 From: cocosheng-g Date: Thu, 13 Nov 2025 12:39:35 -0500 Subject: [PATCH 1/2] add /init command in a2a server --- packages/a2a-server/src/agent/executor.ts | 9 +- packages/a2a-server/src/agent/task.test.ts | 68 +++++- packages/a2a-server/src/agent/task.ts | 17 +- .../src/commands/command-registry.ts | 2 + packages/a2a-server/src/commands/init.test.ts | 197 ++++++++++++++++++ packages/a2a-server/src/commands/init.ts | 142 +++++++++++++ packages/a2a-server/src/commands/types.ts | 9 + packages/a2a-server/src/http/app.test.ts | 110 ++++++++++ packages/a2a-server/src/http/app.ts | 39 +++- packages/a2a-server/src/types.ts | 1 + packages/cli/src/ui/commands/initCommand.ts | 69 ++---- packages/core/src/commands/init.test.ts | 29 +++ packages/core/src/commands/init.ts | 57 +++++ packages/core/src/index.ts | 1 + 14 files changed, 685 insertions(+), 65 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 998b6839e82..3f87c600feb 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 b4a342707f8..0190173b8ef 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,66 @@ 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); + 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 5322f9edf04..e9a9fb36689 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 964a2f5f7be..0643eeefceb 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 00000000000..dd24747b8bb --- /dev/null +++ b/packages/a2a-server/src/commands/init.test.ts @@ -0,0 +1,197 @@ +/** + * @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 { 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 } 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; + + beforeEach(() => { + process.env['CODER_AGENT_WORKSPACE_PATH'] = '/tmp'; + eventBus = { + publish: vi.fn(), + } as unknown as ExecutionEventBus; + command = new InitCommand(); + const mockConfig = createMockConfig({ + getModel: () => 'gemini-pro', + }); + context = { + config: mockConfig, + } as CommandContext; + publishSpy = vi.spyOn(eventBus, 'publish'); + mockExecute = vi.fn(); + vi.mocked(CoderAgentExecutor).mockImplementation( + () => + ({ + execute: mockExecute, + }) as unknown as CoderAgentExecutor, + ); + vi.clearAllMocks(); + }); + + it('has requiresWorkspace set to true', () => { + expect(command.requiresWorkspace).toBe(true); + }); + + it('has autoExecute set to true', () => { + expect(command.autoExecute).toBe(true); + }); + + describe('execute', () => { + it('returns a message indicating to use executeStream', async () => { + const result = await command.execute(context, []); + expect(result).toEqual({ + name: 'init', + data: 'Use executeStream to get streaming results.', + }); + }); + }); + + describe('executeStream', () => { + it('handles info from performInit', async () => { + vi.mocked(performInit).mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'GEMINI.md already exists.', + } as CommandActionReturn); + + await command.executeStream(context, [], eventBus); + + 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.executeStream(context, [], eventBus); + + 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 (previously new_file)', () => { + 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.executeStream(context, [], eventBus); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/tmp/GEMINI.md', + '', + 'utf8', + ); + expect(CoderAgentExecutor).toHaveBeenCalled(); + expect(mockExecute).toHaveBeenCalled(); + }); + + it('passes autoExecute=false to the agent executor', async () => { + await command.executeStream(context, [], eventBus, false); + + 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: '/tmp', + autoExecute: false, + }, + }, + }), + }), + eventBus, + ); + }); + }); + }); +}); diff --git a/packages/a2a-server/src/commands/init.ts b/packages/a2a-server/src/commands/init.ts new file mode 100644 index 00000000000..4d1fd675952 --- /dev/null +++ b/packages/a2a-server/src/commands/init.ts @@ -0,0 +1,142 @@ +/** + * @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 { CoderAgentExecutor } from '../agent/executor.js'; +import type { + ExecutionEventBus, + RequestContext, + AgentExecutionEvent, +} from '@a2a-js/sdk/server'; +import { v4 as uuidv4 } from 'uuid'; +import { InMemoryTaskStore } from '@a2a-js/sdk/server'; +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; + autoExecute = true; + + async execute( + _context: CommandContext, + _args: string[], + ): Promise { + return { + name: this.name, + data: 'Use executeStream to get streaming results.', + }; + } + + async executeStream( + context: CommandContext, + _args: string[] = [], + eventBus: ExecutionEventBus, + autoExecute?: boolean, + ): Promise { + const geminiMdPath = path.join( + process.env['CODER_AGENT_WORKSPACE_PATH']!, + 'GEMINI.md', + ); + const result = performInit(fs.existsSync(geminiMdPath)); + + const taskId = uuidv4(); + const contextId = uuidv4(); + + if (result.type === 'message') { + 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, + }; + } else if (result.type === 'submit_prompt') { + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + // The executor needs a TaskStore. For this one-off command, + // an in-memory one is sufficient. + const taskStore = new InMemoryTaskStore(); + const agentExecutor = new CoderAgentExecutor(taskStore); + + const agentSettings: AgentSettings = { + kind: CoderAgentEvent.StateAgentSettingsEvent, + workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!, + autoExecute, + }; + + 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, + }; + } + return { + name: this.name, + data: 'OK', + }; + } +} diff --git a/packages/a2a-server/src/commands/types.ts b/packages/a2a-server/src/commands/types.ts index aca5693e132..4e467f3f2c3 100644 --- a/packages/a2a-server/src/commands/types.ts +++ b/packages/a2a-server/src/commands/types.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import type { Config, GitService } from '@google/gemini-cli-core'; export interface CommandContext { @@ -24,11 +25,19 @@ export interface Command { readonly subCommands?: Command[]; readonly topLevel?: boolean; readonly requiresWorkspace?: boolean; + readonly autoExecute?: boolean; execute( config: CommandContext, args: string[], ): Promise; + + executeStream?( + config: CommandContext, + args: string[], + eventBus: ExecutionEventBus, + autoExecute?: boolean, + ): Promise; } export interface CommandExecutionResponse { diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 641b3749e6d..03740e4dacb 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -1061,5 +1061,115 @@ describe('E2E Tests', () => { expect(response.status).toBe(200); expect(response.body.data).toBe('success'); }); + + describe('/executeCommand streaming', () => { + it('should execute a streaming command and stream back events', (done: ( + err?: unknown, + ) => void) => { + const executeStreamSpy = vi.fn((context, args, eventBus) => { + eventBus.publish({ kind: 'test-event-1' }); + eventBus.publish({ kind: 'test-event-2' }); + eventBus.finished(); + return Promise.resolve({ name: 'stream-test', data: 'done' }); + }); + + const mockStreamCommand = { + name: 'stream-test', + description: 'A test streaming command', + execute: vi.fn(), + executeStream: executeStreamSpy, + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand); + + const agent = request.agent(app); + agent + .post('/executeCommand') + .send({ command: 'stream-test', args: [] }) + .set('Content-Type', 'application/json') + .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: 'test-event-1' }); + expect(events[1].result).toEqual({ kind: 'test-event-2' }); + expect(executeStreamSpy).toHaveBeenCalled(); + done(); + } catch (e) { + done(e); + } + }); + }) + .end(); + }); + + it('should pass autoExecute=true to executeStream if configured on command', (done: ( + err?: unknown, + ) => void) => { + const executeStreamSpy = vi.fn( + (context, args, eventBus, autoExecute) => { + eventBus.finished(); + return Promise.resolve({ + name: 'auto-execute-test', + data: autoExecute, + }); + }, + ); + + const mockAutoExecuteCommand = { + name: 'auto-execute-test', + description: 'A test auto-execute command', + autoExecute: true, + execute: vi.fn(), + executeStream: executeStreamSpy, + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue( + mockAutoExecuteCommand, + ); + + const agent = request.agent(app); + agent + .post('/executeCommand') + .send({ command: 'auto-execute-test', args: [] }) + .set('Content-Type', 'application/json') + .on('response', (res) => { + res.on('data', () => {}); // Consume stream + res.on('end', () => { + try { + // Verify the 4th argument (autoExecute) was true + expect(executeStreamSpy.mock.calls[0][3]).toBe(true); + 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 91f1e70dd41..73bc12c0c2f 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -8,7 +8,12 @@ import express from 'express'; import type { AgentCard } 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'; @@ -183,9 +188,35 @@ export async function createApp() { .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); + if (commandToExecute.executeStream) { + const eventBus = new DefaultExecutionEventBus(); + res.setHeader('Content-Type', 'application/json'); + const eventHandler = (event: AgentExecutionEvent) => { + const jsonRpcResponse = { + jsonrpc: '2.0', + id: null, + result: event, + }; + res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`); + }; + eventBus.on('event', eventHandler); + + await commandToExecute.executeStream( + context, + args ?? [], + eventBus, + commandToExecute.autoExecute, + ); + + eventBus.off('event', eventHandler); + eventBus.finished(); + return res.end(); // Explicit return for streaming path + } else if (commandToExecute.execute) { + const result = await commandToExecute.execute(context, args ?? []); + logger.info('[CoreAgent] Sending /executeCommand response: ', result); + return res.status(200).json(result); + } + return res.status(200).json({}); } catch (e) { logger.error('Error executing /executeCommand:', e); const errorMessage = diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts index 74b5ec93208..c3cfc3d85fe 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 f978fccdf8a..6c2209921fd 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.', - }; - } - - // 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:** + const result = performInit(fs.existsSync(geminiMdPath)); -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. + if (result.type === 'submit_prompt') { + // Create an empty GEMINI.md file + fs.writeFileSync(geminiMdPath, '', 'utf8'); -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:** + context.ui.addItem( + { + type: 'info', + text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', + }, + Date.now(), + ); + } -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 00000000000..9fb4b50305d --- /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 00000000000..37b263e972c --- /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 b268ea12df6..3ff58417c6d 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 From 742ee492802fa0822eb006a6544bf06745a942fa Mon Sep 17 00:00:00 2001 From: cocosheng-g Date: Thu, 13 Nov 2025 12:39:35 -0500 Subject: [PATCH 2/2] add /init command in a2a server --- packages/a2a-server/src/agent/task.test.ts | 1 + packages/a2a-server/src/commands/init.test.ts | 51 ++--- packages/a2a-server/src/commands/init.ts | 200 ++++++++++-------- packages/a2a-server/src/commands/types.ts | 13 +- packages/a2a-server/src/http/app.test.ts | 111 +++++----- packages/a2a-server/src/http/app.ts | 144 +++++++------ 6 files changed, 268 insertions(+), 252 deletions(-) diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 0190173b8ef..862e8abcff2 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -492,6 +492,7 @@ describe('Task', () => { 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 = [ { diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts index dd24747b8bb..b897d0b9e3d 100644 --- a/packages/a2a-server/src/commands/init.test.ts +++ b/packages/a2a-server/src/commands/init.test.ts @@ -8,12 +8,13 @@ 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 } from '@google/gemini-cli-core'; +import type { CommandActionReturn, Config } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -49,9 +50,10 @@ describe('InitCommand', () => { let context: CommandContext; let publishSpy: ReturnType; let mockExecute: ReturnType; + const mockWorkspacePath = path.resolve('/tmp'); beforeEach(() => { - process.env['CODER_AGENT_WORKSPACE_PATH'] = '/tmp'; + process.env['CODER_AGENT_WORKSPACE_PATH'] = mockWorkspacePath; eventBus = { publish: vi.fn(), } as unknown as ExecutionEventBus; @@ -59,17 +61,15 @@ describe('InitCommand', () => { const mockConfig = createMockConfig({ getModel: () => 'gemini-pro', }); + const mockExecutorInstance = new CoderAgentExecutor(); context = { - config: mockConfig, + config: mockConfig as unknown as Config, + agentExecutor: mockExecutorInstance, + eventBus, } as CommandContext; publishSpy = vi.spyOn(eventBus, 'publish'); mockExecute = vi.fn(); - vi.mocked(CoderAgentExecutor).mockImplementation( - () => - ({ - execute: mockExecute, - }) as unknown as CoderAgentExecutor, - ); + vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute); vi.clearAllMocks(); }); @@ -77,21 +77,7 @@ describe('InitCommand', () => { expect(command.requiresWorkspace).toBe(true); }); - it('has autoExecute set to true', () => { - expect(command.autoExecute).toBe(true); - }); - describe('execute', () => { - it('returns a message indicating to use executeStream', async () => { - const result = await command.execute(context, []); - expect(result).toEqual({ - name: 'init', - data: 'Use executeStream to get streaming results.', - }); - }); - }); - - describe('executeStream', () => { it('handles info from performInit', async () => { vi.mocked(performInit).mockReturnValue({ type: 'message', @@ -99,7 +85,7 @@ describe('InitCommand', () => { content: 'GEMINI.md already exists.', } as CommandActionReturn); - await command.executeStream(context, [], eventBus); + await command.execute(context, []); expect(logger.info).toHaveBeenCalledWith( '[EventBus event]: ', @@ -134,7 +120,7 @@ describe('InitCommand', () => { content: 'An error occurred.', } as CommandActionReturn); - await command.executeStream(context, [], eventBus); + await command.execute(context, []); expect(publishSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -149,7 +135,7 @@ describe('InitCommand', () => { ); }); - describe('when handling submit_prompt (previously new_file)', () => { + describe('when handling submit_prompt', () => { beforeEach(() => { vi.mocked(performInit).mockReturnValue({ type: 'submit_prompt', @@ -158,19 +144,18 @@ describe('InitCommand', () => { }); it('writes the file and executes the agent', async () => { - await command.executeStream(context, [], eventBus); + await command.execute(context, []); expect(fs.writeFileSync).toHaveBeenCalledWith( - '/tmp/GEMINI.md', + path.join(mockWorkspacePath, 'GEMINI.md'), '', 'utf8', ); - expect(CoderAgentExecutor).toHaveBeenCalled(); expect(mockExecute).toHaveBeenCalled(); }); - it('passes autoExecute=false to the agent executor', async () => { - await command.executeStream(context, [], eventBus, false); + it('passes autoExecute to the agent executor', async () => { + await command.execute(context, []); expect(mockExecute).toHaveBeenCalledWith( expect.objectContaining({ @@ -183,8 +168,8 @@ describe('InitCommand', () => { metadata: { coderAgent: { kind: CoderAgentEvent.StateAgentSettingsEvent, - workspacePath: '/tmp', - autoExecute: false, + workspacePath: mockWorkspacePath, + autoExecute: true, }, }, }), diff --git a/packages/a2a-server/src/commands/init.ts b/packages/a2a-server/src/commands/init.ts index 4d1fd675952..2a78ae5f957 100644 --- a/packages/a2a-server/src/commands/init.ts +++ b/packages/a2a-server/src/commands/init.ts @@ -13,130 +13,156 @@ import type { CommandContext, CommandExecutionResponse, } from './types.js'; -import { CoderAgentExecutor } from '../agent/executor.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 { InMemoryTaskStore } from '@a2a-js/sdk/server'; 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; - autoExecute = true; + streaming = true; - async execute( - _context: CommandContext, - _args: string[], - ): Promise { + 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: 'Use executeStream to get streaming results.', + data: result, }; } - async executeStream( + private async handleSubmitPromptResult( + result: { content: unknown }, context: CommandContext, - _args: string[] = [], + geminiMdPath: string, eventBus: ExecutionEventBus, - autoExecute?: boolean, + taskId: string, + contextId: string, ): Promise { - const geminiMdPath = path.join( - process.env['CODER_AGENT_WORKSPACE_PATH']!, - 'GEMINI.md', - ); - const result = performInit(fs.existsSync(geminiMdPath)); + fs.writeFileSync(geminiMdPath, '', 'utf8'); - const taskId = uuidv4(); - const contextId = uuidv4(); + 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 (result.type === 'message') { - const statusState = - result.messageType === 'error' ? 'failed' : 'completed'; - const eventType = - result.messageType === 'error' - ? CoderAgentEvent.StateChangeEvent - : CoderAgentEvent.TextContentEvent; + if (typeof result.content !== 'string') { + throw new Error('Init command content must be a string.'); + } + const promptText = result.content; - const event: AgentExecutionEvent = { - kind: 'status-update', + const requestContext: RequestContext = { + userMessage: { + kind: 'message', + role: 'user', + parts: [{ kind: 'text', text: promptText }], + messageId: uuidv4(), 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(), + coderAgent: agentSettings, }, - }; + }, + taskId, + contextId, + }; - logger.info('[EventBus event]: ', event); - eventBus.publish(event); + // 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: result, + data: 'Use executeStream to get streaming results.', }; - } else if (result.type === 'submit_prompt') { - fs.writeFileSync(geminiMdPath, '', 'utf8'); - - // The executor needs a TaskStore. For this one-off command, - // an in-memory one is sufficient. - const taskStore = new InMemoryTaskStore(); - const agentExecutor = new CoderAgentExecutor(taskStore); + } - const agentSettings: AgentSettings = { - kind: CoderAgentEvent.StateAgentSettingsEvent, - workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!, - autoExecute, - }; + const geminiMdPath = path.join( + process.env['CODER_AGENT_WORKSPACE_PATH']!, + 'GEMINI.md', + ); + const result = performInit(fs.existsSync(geminiMdPath)); - if (typeof result.content !== 'string') { - throw new Error('Init command content must be a string.'); - } - const promptText = result.content; + const taskId = uuidv4(); + const contextId = uuidv4(); - const requestContext: RequestContext = { - userMessage: { - kind: 'message', - role: 'user', - parts: [{ kind: 'text', text: promptText }], - messageId: uuidv4(), + switch (result.type) { + case 'message': + return this.handleMessageResult( + result, + context, + context.eventBus, 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, - }; + ); + case 'submit_prompt': + return this.handleSubmitPromptResult( + result, + context, + geminiMdPath, + context.eventBus, + taskId, + contextId, + ); + default: + throw new Error('Unknown result type from performInit'); } - return { - name: this.name, - data: 'OK', - }; } } diff --git a/packages/a2a-server/src/commands/types.ts b/packages/a2a-server/src/commands/types.ts index 4e467f3f2c3..910515eadce 100644 --- a/packages/a2a-server/src/commands/types.ts +++ b/packages/a2a-server/src/commands/types.ts @@ -4,12 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ExecutionEventBus } from '@a2a-js/sdk/server'; +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 { @@ -25,19 +27,12 @@ export interface Command { readonly subCommands?: Command[]; readonly topLevel?: boolean; readonly requiresWorkspace?: boolean; - readonly autoExecute?: boolean; + readonly streaming?: boolean; execute( config: CommandContext, args: string[], ): Promise; - - executeStream?( - config: CommandContext, - args: string[], - eventBus: ExecutionEventBus, - autoExecute?: boolean, - ): Promise; } export interface CommandExecutionResponse { diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 03740e4dacb..6ff4b378672 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -1062,22 +1062,56 @@ describe('E2E Tests', () => { 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 executeStreamSpy = vi.fn((context, args, eventBus) => { - eventBus.publish({ kind: 'test-event-1' }); - eventBus.publish({ kind: 'test-event-2' }); - eventBus.finished(); - return Promise.resolve({ name: 'stream-test', data: 'done' }); + 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', - execute: vi.fn(), - executeStream: executeStreamSpy, + streaming: true, + execute: executeSpy, }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand); @@ -1086,6 +1120,7 @@ describe('E2E Tests', () => { .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) => { @@ -1095,53 +1130,21 @@ describe('E2E Tests', () => { try { const events = streamToSSEEvents(data); expect(events.length).toBe(2); - expect(events[0].result).toEqual({ kind: 'test-event-1' }); - expect(events[1].result).toEqual({ kind: 'test-event-2' }); - expect(executeStreamSpy).toHaveBeenCalled(); - done(); - } catch (e) { - done(e); - } - }); - }) - .end(); - }); - - it('should pass autoExecute=true to executeStream if configured on command', (done: ( - err?: unknown, - ) => void) => { - const executeStreamSpy = vi.fn( - (context, args, eventBus, autoExecute) => { - eventBus.finished(); - return Promise.resolve({ - name: 'auto-execute-test', - data: autoExecute, - }); - }, - ); - - const mockAutoExecuteCommand = { - name: 'auto-execute-test', - description: 'A test auto-execute command', - autoExecute: true, - execute: vi.fn(), - executeStream: executeStreamSpy, - }; - vi.spyOn(commandRegistry, 'get').mockReturnValue( - mockAutoExecuteCommand, - ); - - const agent = request.agent(app); - agent - .post('/executeCommand') - .send({ command: 'auto-execute-test', args: [] }) - .set('Content-Type', 'application/json') - .on('response', (res) => { - res.on('data', () => {}); // Consume stream - res.on('end', () => { - try { - // Verify the 4th argument (autoExecute) was true - expect(executeStreamSpy.mock.calls[0][3]).toBe(true); + 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); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 73bc12c0c2f..2439f09b072 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -6,7 +6,7 @@ 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, @@ -78,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. @@ -97,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; @@ -118,6 +186,8 @@ export async function createApp() { const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor); + const context = { config, git, agentExecutor }; + const requestHandler = new DefaultRequestHandler( coderAgentCard, taskStoreForHandler, @@ -157,72 +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}` }); - } - - if (commandToExecute.executeStream) { - const eventBus = new DefaultExecutionEventBus(); - res.setHeader('Content-Type', 'application/json'); - const eventHandler = (event: AgentExecutionEvent) => { - const jsonRpcResponse = { - jsonrpc: '2.0', - id: null, - result: event, - }; - res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`); - }; - eventBus.on('event', eventHandler); - - await commandToExecute.executeStream( - context, - args ?? [], - eventBus, - commandToExecute.autoExecute, - ); - - eventBus.off('event', eventHandler); - eventBus.finished(); - return res.end(); // Explicit return for streaming path - } else if (commandToExecute.execute) { - const result = await commandToExecute.execute(context, args ?? []); - logger.info('[CoreAgent] Sending /executeCommand response: ', result); - return res.status(200).json(result); - } - return res.status(200).json({}); - } 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) => {