diff --git a/docs/commands/agents.md b/docs/commands/agents.md index f54133a36f0..694ca08c49b 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -31,6 +31,7 @@ netlify agents | [`agents:commit`](/commands/agents#agentscommit) | Commit an agent run’s changes directly to a branch | | [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site | | [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run | +| [`agents:follow-up`](/commands/agents#agentsfollow-up) | Send a follow-up prompt to an existing agent run | | [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site | | [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser | | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run | @@ -49,6 +50,7 @@ netlify agents netlify agents:create --prompt "Add a contact form" netlify agents:list --status running netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests" netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a netlify agents:open 60c7c3b3e7b4a0001f5e4b3a ``` @@ -191,6 +193,41 @@ netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --cumulative netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --no-color | less ``` +--- +## `agents:follow-up` + +Send a follow-up prompt to an existing agent run + +**Usage** + +```bash +netlify agents:follow-up +``` + +**Arguments** + +- id - agent run ID to follow up on +- prompt - the follow-up prompt + +**Flags** + +- `agent` (*string*) - override agent type for this session +- `attach` (*string*) - attach a file or image (repeatable) +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `model` (*string*) - override model for this session +- `project` (*string*) - project ID or name (if not in a linked directory) +- `prompt` (*string*) - follow-up prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests" +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a -p "Fix the lint error" +``` + --- ## `agents:list` diff --git a/docs/index.md b/docs/index.md index 0abb807eaf6..63bb9c2fc32 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,7 @@ Manage Netlify AI agent runs | [`agents:commit`](/commands/agents#agentscommit) | Commit an agent run’s changes directly to a branch | | [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site | | [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run | +| [`agents:follow-up`](/commands/agents#agentsfollow-up) | Send a follow-up prompt to an existing agent run | | [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site | | [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser | | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run | diff --git a/src/commands/agents/agents-follow-up.ts b/src/commands/agents/agents-follow-up.ts new file mode 100644 index 00000000000..b19108f66d8 --- /dev/null +++ b/src/commands/agents/agents-follow-up.ts @@ -0,0 +1,155 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' +import { uploadAttachments, type UploadedAttachment } from './attachments.js' +import { AVAILABLE_AGENTS, TERMINAL_SESSION_STATES, type AvailableAgent } from './constants.js' +import type { CreateAgentRunnerSessionPayload } from './types.js' +import { + checkModelAvailability, + formatBytes, + formatStatus, + getAgentName, + sanitizePromptText, + validateAgent, + validatePrompt, +} from './utils.js' + +interface AgentFollowUpOptions extends OptionValues { + prompt?: string + agent?: string + model?: string + attach?: string[] + json?: boolean +} + +export const agentsFollowUp = async ( + id: string, + promptArg: string, + options: AgentFollowUpOptions, + command: BaseCommand, +) => { + if (!id) return logAndThrowError('Agent run ID is required') + await command.authenticate() + const { siteInfo } = command.netlify + const api = createAgentsApi(command.netlify) + + if (options.attach && options.attach.length > 0 && !siteInfo.account_id) { + return logAndThrowError('Cannot attach files: no account ID is available for this site') + } + + let finalPrompt = promptArg || options.prompt + if (!finalPrompt) { + if (options.json) { + return logAndThrowError('A prompt is required. Pass it as the positional argument or via --prompt.') + } + const { promptInput } = await inquirer.prompt<{ promptInput: string }>([ + { + type: 'input', + name: 'promptInput', + message: 'What would you like the agent to do next?', + validate: validatePrompt, + }, + ]) + finalPrompt = promptInput + } + const promptValid = validatePrompt(finalPrompt) + if (promptValid !== true) return logAndThrowError(promptValid) + + let lastSessionAgent: AvailableAgent | undefined + let lastSessionModel: string | undefined + let recent: import('./types.js').AgentRunnerSession | undefined + try { + const recentSessions = await api.listAgentRunnerSessions(id, { page: 1, per_page: 1, order_by: 'desc' }) + if (recentSessions.length > 0) recent = recentSessions[0] + } catch { + // If lookup fails, fall through and let the create call surface the real error. + } + + if (recent && !TERMINAL_SESSION_STATES.includes(recent.state as (typeof TERMINAL_SESSION_STATES)[number])) { + log(chalk.yellow('A session is already running on this run. Wait for it to finish or stop it first:')) + log(` ${chalk.cyan(`netlify agents:stop ${id}`)}`) + return logAndThrowError('Cannot create a follow-up while a session is still active') + } + + if (recent) { + const previousAgent = recent.agent_config?.agent + if (previousAgent && AVAILABLE_AGENTS.some((entry) => entry.value === previousAgent)) { + lastSessionAgent = previousAgent + } + if (recent.agent_config?.model) lastSessionModel = recent.agent_config.model + } + + let agent: AvailableAgent | undefined = lastSessionAgent + if (options.agent) { + const valid = validateAgent(options.agent) + if (valid !== true) return logAndThrowError(valid) + agent = options.agent as AvailableAgent + } + const model = options.model ?? lastSessionModel + if (model && agent) { + const valid = await checkModelAvailability(api, agent, model) + if (valid !== true) log(chalk.yellow(`⚠ ${valid}`)) + } + + let attachments: UploadedAttachment[] = [] + if (options.attach && options.attach.length > 0 && siteInfo.account_id) { + const uploadSpinner = startSpinner({ text: `Uploading ${options.attach.length.toString()} attachment(s)...` }) + try { + attachments = await uploadAttachments(api, siteInfo.account_id, options.attach) + stopSpinner({ spinner: uploadSpinner }) + for (const file of attachments) { + log(` ${chalk.green('✓')} ${file.filename} ${chalk.dim(`(${formatBytes(file.size)})`)}`) + } + } catch (error_) { + stopSpinner({ spinner: uploadSpinner, error: true }) + const error = error_ as Error + return logAndThrowError(error.message) + } + } + + const payload: CreateAgentRunnerSessionPayload = { + prompt: sanitizePromptText(finalPrompt), + agent, + model, + file_keys: attachments.length > 0 ? attachments.map((entry) => entry.fileKey) : undefined, + } + + const spinner = startSpinner({ text: 'Sending follow-up prompt...' }) + try { + const session = await api.createAgentRunnerSession(id, payload) + stopSpinner({ spinner }) + + if (options.json) { + logJson(session) + return session + } + + log(`${chalk.green('✓')} Follow-up session created!`) + log() + log(chalk.bold('Details:')) + log(` Run ID: ${chalk.cyan(id)}`) + log(` Session ID: ${chalk.cyan(session.id)}`) + log(` Prompt: ${chalk.dim(finalPrompt)}`) + if (agent) log(` Agent: ${chalk.cyan(getAgentName(agent))}${model ? ` (${model})` : ''}`) + log(` Status: ${formatStatus(session.state)}`) + log() + log(chalk.bold('Monitor progress:')) + log(` Watch: ${chalk.cyan(`netlify agents:show ${id} --watch`)}`) + log(` Show: ${chalk.cyan(`netlify agents:show ${id}`)}`) + return session + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent run not found: ${id}`) + if (error.message.toLowerCase().includes('active session')) { + log() + log(chalk.yellow('A session is already running on this run. Wait for it to finish or stop it first:')) + log(` ${chalk.cyan(`netlify agents:stop ${id}`)}`) + } + return logAndThrowError(`Failed to send follow-up: ${error.message}`) + } +} diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index d0fdbcc4c79..59069ef6562 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -38,6 +38,27 @@ export const createAgentsCommand = (program: BaseCommand) => { await agentsCreate(prompt, options, command) }) + program + .command('agents:follow-up') + .argument('', 'agent run ID to follow up on') + .argument('[prompt]', 'the follow-up prompt') + .description('Send a follow-up prompt to an existing agent run') + .option('-p, --prompt ', 'follow-up prompt') + .option('-a, --agent ', 'override agent type for this session') + .option('-m, --model ', 'override model for this session') + .option('--attach ', 'attach a file or image (repeatable)', collect, []) + .option('--project ', 'project ID or name (if not in a linked directory)') + .option('--json', 'output result as JSON') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests"', + 'netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a -p "Fix the lint error"', + ]) + .action(async (id: string, prompt: string, options: OptionValues, command: BaseCommand) => { + const { agentsFollowUp } = await import('./agents-follow-up.js') + await agentsFollowUp(id, prompt, options, command) + }) + program .command('agents:list') .description('List agent runs for the current site') @@ -277,6 +298,7 @@ Note: Agent runs execute remotely on Netlify infrastructure, not locally.`, 'netlify agents:create --prompt "Add a contact form"', 'netlify agents:list --status running', 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch', + 'netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests"', 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a', 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a', ]) diff --git a/tests/integration/commands/agents/agents-follow-up.test.ts b/tests/integration/commands/agents/agents-follow-up.test.ts new file mode 100644 index 00000000000..749959281fe --- /dev/null +++ b/tests/integration/commands/agents/agents-follow-up.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentSession } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:follow-up command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should send a follow-up prompt and create a session', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:follow-up', 'test_id', 'Also add tests'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Follow-up session created!') + expect(cliResponse).toContain('Run ID: test_id') + expect(cliResponse).toContain('Session ID: session_id') + expect(cliResponse).toContain('Prompt: Also add tests') + + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect(sessionRequest).toBeDefined() + expect((sessionRequest?.body as { prompt: string }).prompt).toBe('Also add tests') + }) + }) + }) + + test('should accept prompt via --prompt flag', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli( + ['agents:follow-up', 'test_id', '--prompt', 'Fix the lint error'], + getCLIOptions({ apiUrl, builder }), + ) + + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect((sessionRequest?.body as { prompt: string }).prompt).toBe('Fix the lint error') + }) + }) + }) + + test('should pass agent and model in the request body', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + { + path: 'ai-gateway/providers', + method: 'GET' as const, + response: { providers: {} }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli( + ['agents:follow-up', 'test_id', 'Update README', '--agent', 'claude', '--model', 'claude-3-sonnet'], + getCLIOptions({ apiUrl, builder }), + ) + + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect(sessionRequest?.body).toMatchObject({ + prompt: 'Update README', + agent: 'claude', + model: 'claude-3-sonnet', + }) + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:follow-up', 'test_id', 'Add tests', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentSession + + expect(cliResponse).toEqual(mockAgentSession) + }) + }) + }) + + test('should reject prompts shorter than 5 chars', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:follow-up', 'test_id', 'no'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('more detailed prompt') + }) + }) + }) + + test('should surface "active session" hint on conflict', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + status: 409, + response: { error: 'Cannot start: an active session is already running' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:follow-up', 'test_id', 'Add tests'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to send follow-up') + }) + }) + }) + + test('should carry over agent and model from the latest done session', async (t) => { + const lastSession = { + ...mockAgentSession, + id: 'prev_id', + state: 'done', + agent_config: { agent: 'codex', model: 'gpt-4.1' }, + } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [lastSession], + }, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: { ...mockAgentSession, agent_config: { agent: 'codex', model: 'gpt-4.1' } }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli(['agents:follow-up', 'test_id', 'Update README'], getCLIOptions({ apiUrl, builder })) + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect(sessionRequest?.body).toMatchObject({ + prompt: 'Update README', + agent: 'codex', + model: 'gpt-4.1', + }) + }) + }) + }) + + test('should refuse to send a follow-up while the latest session is still running', async (t) => { + const activeSession = { ...mockAgentSession, state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [activeSession], + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:follow-up', 'test_id', 'Add tests'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Cannot create a follow-up while a session is still active') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:follow-up'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +})