diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts new file mode 100644 index 00000000000..255ff253ec1 --- /dev/null +++ b/src/commands/agents/api.ts @@ -0,0 +1,250 @@ +// TODO: Migrate to @netlify/api once these endpoints are public. +// They are marked x-internal in bitballoon (#21736). +// Method names mirror the bitballoon @operation_id to keep the future swap quick. + +import type { NetlifyOptions } from '../types.js' +import { parseLinkHeader } from './utils.js' +import type { + AgentRunner, + AgentRunnerSession, + AiGatewayProvidersResponse, + CreateAgentRunnerPayload, + CreateAgentRunnerSessionPayload, + DeleteUrlResponse, + DiffParams, + ListAgentRunnerSessionsFilters, + ListAgentRunnersFilters, + PaginatedResult, + UploadUrlResponse, +} from './types.js' + +const DEFAULT_PER_PAGE = 100 + +type RawResponseHandler = (response: Response) => Promise + +type SearchParamValue = string | number | boolean | null | undefined + +const buildSearchParams = (entries: Record): URLSearchParams => { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(entries)) { + if (value === undefined || value === null || value === '') continue + params.set(key, value.toString()) + } + return params +} + +const readPagination = (response: Response, page: number, perPage: number): { total?: number; hasNext: boolean } => { + const totalHeader = response.headers.get('Total') + const total = totalHeader != null ? Number.parseInt(totalHeader, 10) : undefined + const links = parseLinkHeader(response.headers.get('Link')) + const hasNext = Boolean(links.next) || (total != null && page * perPage < total) + return { total: Number.isFinite(total) ? total : undefined, hasNext } +} + +export const createAgentsApi = (netlify: NetlifyOptions) => { + const { api, apiOpts } = netlify + const baseUrl = api.basePath + + const baseHeaders = (extra: Record = {}): Record => ({ + Authorization: `Bearer ${api.accessToken ?? ''}`, + 'User-Agent': apiOpts.userAgent, + ...extra, + }) + + const throwForStatus = async (response: Response): Promise => { + const errorData = (await response.json().catch(() => ({}))) as { error?: string } + const error = new Error( + errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`, + ) as Error & { status?: number } + error.status = response.status + throw error + } + + const requestRaw = async (path: string, init: RequestInit, handler: RawResponseHandler): Promise => { + const response = await fetch(`${baseUrl}${path}`, init) + if (!response.ok) await throwForStatus(response) + return handler(response) + } + + const requestJson = async (path: string, init: RequestInit = {}): Promise => + requestRaw(path, init, async (response) => { + if (response.status === 202) return undefined as T + const text = await response.text() + if (!text) return undefined as T + return JSON.parse(text) as T + }) + + const requestNoContent = (path: string, init: RequestInit = {}): Promise => + requestRaw(path, init, () => Promise.resolve(undefined)) + + const jsonInit = (method: string, body?: unknown): RequestInit => ({ + method, + headers: baseHeaders(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + const getInit = (): RequestInit => ({ method: 'GET', headers: baseHeaders() }) + + const listAgentRunners = async ( + siteId: string, + filters: ListAgentRunnersFilters = {}, + ): Promise> => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, site_id: siteId, page, per_page: perPage }) + const response = await fetch(`${baseUrl}/agent_runners?${params.toString()}`, getInit()) + if (!response.ok) await throwForStatus(response) + const data = (await response.json()) as AgentRunner[] + const { total, hasNext } = readPagination(response, page, perPage) + return { data, total, page, perPage, hasNext } + } + + const listAgentRunnersForAccount = async ( + accountSlug: string, + filters: ListAgentRunnersFilters = {}, + ): Promise> => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, page, per_page: perPage }) + const response = await fetch( + `${baseUrl}/${encodeURIComponent(accountSlug)}/agent_runners?${params.toString()}`, + getInit(), + ) + if (!response.ok) await throwForStatus(response) + const data = (await response.json()) as AgentRunner[] + const { total, hasNext } = readPagination(response, page, perPage) + return { data, total, page, perPage, hasNext } + } + + const getAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}`, getInit()) + + const createAgentRunner = (siteId: string, payload: CreateAgentRunnerPayload): Promise => { + const params = buildSearchParams({ site_id: siteId }) + return requestJson(`/agent_runners?${params.toString()}`, jsonInit('POST', payload)) + } + + const deleteAgentRunner = (id: string): Promise => + requestNoContent(`/agent_runners/${id}`, { method: 'DELETE', headers: baseHeaders() }) + + const archiveAgentRunner = (id: string): Promise => + requestNoContent(`/agent_runners/${id}/archive`, { method: 'POST', headers: baseHeaders() }) + + const listAgentRunnerSessions = async ( + id: string, + filters: ListAgentRunnerSessionsFilters = {}, + ): Promise => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, page, per_page: perPage }) + return requestJson(`/agent_runners/${id}/sessions?${params.toString()}`, getInit()) + } + + const getAgentRunnerSession = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/sessions/${sessionId}`, getInit()) + + const createAgentRunnerSession = ( + id: string, + payload: CreateAgentRunnerSessionPayload, + ): Promise => + requestJson(`/agent_runners/${id}/sessions`, jsonInit('POST', payload)) + + const redeployAgentRunnerSession = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/sessions/${sessionId}/redeploy`, jsonInit('POST')) + + const getAgentRunnerDiff = async (id: string, params: DiffParams = {}): Promise> => { + const page = params.page ?? 1 + const perPage = params.per_page ?? DEFAULT_PER_PAGE + const stripBinary = params.strip_binary ?? true + const search = buildSearchParams({ page, per_page: perPage, strip_binary: stripBinary }) + const response = await fetch(`${baseUrl}/agent_runners/${id}/diff?${search.toString()}`, getInit()) + if (!response.ok) { + if (response.status === 404) return { data: '', total: 0, page, perPage, hasNext: false } + await throwForStatus(response) + } + const body = await response.text() + const { total, hasNext } = readPagination(response, page, perPage) + return { data: body, total, page, perPage, hasNext } + } + + const getSessionDiff = async (id: string, sessionId: string, kind: 'result' | 'cumulative'): Promise => { + const response = await fetch(`${baseUrl}/agent_runners/${id}/sessions/${sessionId}/diff/${kind}`, getInit()) + if (response.status === 404) return '' + if (!response.ok) await throwForStatus(response) + return response.text() + } + + const agentRunnerPullRequest = (id: string): Promise => + requestJson(`/agent_runners/${id}/pull_request`, jsonInit('POST')) + + const agentRunnerCommitToBranch = (id: string, targetBranch: string): Promise => + requestJson(`/agent_runners/${id}/commit`, jsonInit('POST', { target_branch: targetBranch })) + + const agentRunnerPublishToProduction = (id: string): Promise => + requestJson(`/agent_runners/${id}/publish_to_production`, jsonInit('POST')) + + const revertAgentRunner = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId })) + + const updateAgentRunner = (id: string, payload: { title?: string; base_deploy_id?: string }): Promise => + requestJson(`/agent_runners/${id}`, jsonInit('PATCH', payload)) + + const rebaseAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}/rebase`, jsonInit('POST')) + + const mergeTargetAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}/merge_target`, jsonInit('POST')) + + const syncGitOriginAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}/sync_git_origin`, jsonInit('POST')) + + const createAgentRunnerUploadUrl = (payload: { + account_id: string + filename: string + content_type: string + }): Promise => + requestJson(`/agent_runners/upload_url`, jsonInit('POST', payload)) + + const createAgentRunnerDeleteUrl = (payload: { account_id: string; file_key: string }): Promise => + requestJson(`/agent_runners/delete_url`, jsonInit('POST', payload)) + + let providersCache: AiGatewayProvidersResponse | null = null + const listAiGatewayProviders = async (): Promise => { + if (providersCache) return providersCache + // Public endpoint by design — no auth header. The provider+model list is meant + // for external clients to discover the agent → provider → model relationship. + const response = await fetch(`${baseUrl}/ai-gateway/providers`) + if (!response.ok) await throwForStatus(response) + providersCache = (await response.json()) as AiGatewayProvidersResponse + return providersCache + } + + return { + listAgentRunners, + listAgentRunnersForAccount, + getAgentRunner, + createAgentRunner, + updateAgentRunner, + deleteAgentRunner, + archiveAgentRunner, + listAgentRunnerSessions, + getAgentRunnerSession, + createAgentRunnerSession, + redeployAgentRunnerSession, + getAgentRunnerDiff, + getSessionResultDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'result'), + getSessionCumulativeDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'cumulative'), + agentRunnerPullRequest, + agentRunnerCommitToBranch, + agentRunnerPublishToProduction, + revertAgentRunner, + rebaseAgentRunner, + mergeTargetAgentRunner, + syncGitOriginAgentRunner, + createAgentRunnerUploadUrl, + createAgentRunnerDeleteUrl, + listAiGatewayProviders, + } +} + +export type AgentsApi = ReturnType diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts new file mode 100644 index 00000000000..e9dc3a832e1 --- /dev/null +++ b/src/commands/agents/attachments.ts @@ -0,0 +1,112 @@ +import fs from 'fs/promises' +import path from 'path' + +import type { AgentsApi } from './api.js' +import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS_PER_REQUEST } from './constants.js' +import { formatBytes, getMimeType } from './utils.js' + +export interface UploadedAttachment { + path: string + filename: string + fileKey: string + size: number + contentType: string +} + +const cleanupOrphans = async (api: AgentsApi, accountId: string, fileKeys: string[]): Promise => { + await Promise.allSettled( + fileKeys.map(async (fileKey) => { + try { + const { delete_url: deleteUrl } = await api.createAgentRunnerDeleteUrl({ + account_id: accountId, + file_key: fileKey, + }) + await fetch(deleteUrl, { method: 'DELETE' }) + } catch { + // Best-effort cleanup; if it fails, the orphan is the user's tenant problem. + } + }), + ) +} + +export const uploadAttachments = async ( + api: AgentsApi, + accountId: string, + filePaths: string[], +): Promise => { + if (filePaths.length === 0) return [] + if (filePaths.length > MAX_ATTACHMENTS_PER_REQUEST) { + throw new Error( + `Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`, + ) + } + + const resolved = await Promise.all( + filePaths.map(async (filePath) => { + const absolute = path.resolve(filePath) + const stat = await fs.stat(absolute).catch(() => null) + if (!stat?.isFile()) { + throw new Error(`Attachment not found or not a file: ${filePath}`) + } + if (stat.size > MAX_ATTACHMENT_SIZE_BYTES) { + throw new Error( + `Attachment ${filePath} is ${formatBytes(stat.size)}, exceeds the ${formatBytes( + MAX_ATTACHMENT_SIZE_BYTES, + )} limit`, + ) + } + const filename = path.basename(absolute) + return { path: absolute, filename, size: stat.size, contentType: getMimeType(filename) } + }), + ) + + const uploaded: UploadedAttachment[] = [] + try { + for (const file of resolved) { + const { upload_url: uploadUrl, file_key: fileKey } = await api.createAgentRunnerUploadUrl({ + account_id: accountId, + filename: file.filename, + content_type: file.contentType, + }) + + const body = await fs.readFile(file.path) + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, 60_000) + let putResponse: Response + try { + putResponse = await fetch(uploadUrl, { + method: 'PUT', + body: new Uint8Array(body), + headers: { 'Content-Type': file.contentType }, + signal: controller.signal, + }) + } catch (error_) { + const error = error_ as Error + if (error.name === 'AbortError') { + throw new Error(`Upload of ${file.filename} timed out after 60s`) + } + throw error + } finally { + clearTimeout(timeout) + } + if (!putResponse.ok) { + throw new Error( + `Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`, + ) + } + uploaded.push({ ...file, fileKey }) + } + return uploaded + } catch (error) { + if (uploaded.length > 0) { + await cleanupOrphans( + api, + accountId, + uploaded.map((entry) => entry.fileKey), + ) + } + throw error + } +} diff --git a/src/commands/agents/constants.ts b/src/commands/agents/constants.ts index 03b5f94b287..0c608afedfc 100644 --- a/src/commands/agents/constants.ts +++ b/src/commands/agents/constants.ts @@ -1,27 +1,32 @@ import { chalk } from '../../utils/command-helpers.js' -/** - * Available agent types for task creation - */ export const AVAILABLE_AGENTS = [ { name: 'Claude', value: 'claude' }, { name: 'Codex', value: 'codex' }, { name: 'Gemini', value: 'gemini' }, ] as const -/** - * Valid agent task states - */ -export const AGENT_STATES = ['new', 'running', 'done', 'error', 'cancelled', 'archived'] as const +export const AGENT_TO_PROVIDER = { + claude: 'anthropic', + codex: 'openai', + gemini: 'gemini', +} as const -/** - * Valid agent session states - */ +export const AGENT_STATES = ['new', 'running', 'done', 'error', 'cancelled', 'archived'] as const export const SESSION_STATES = ['new', 'running', 'done', 'error', 'cancelled'] as const -/** - * Color mapping for agent task status display - */ +export const SESSION_MODES = [ + 'normal', + 'redeploy', + 'rebase', + 'git_sync', + 'create', + 'ask', + 'conflict_resolution', +] as const + +export const LIST_STATUS_FILTERS = ['running', 'done', 'error', 'archived'] as const + export const STATUS_COLORS = { new: chalk.blue, running: chalk.yellow, @@ -31,9 +36,14 @@ export const STATUS_COLORS = { archived: chalk.dim, } as const -/** - * Type definitions extracted from constants - */ +export const TERMINAL_AGENT_STATES = ['done', 'error', 'cancelled', 'archived'] as const +export const TERMINAL_SESSION_STATES = ['done', 'error', 'cancelled'] as const + +export const MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024 +export const MAX_ATTACHMENTS_PER_REQUEST = 20 + export type AgentState = (typeof AGENT_STATES)[number] export type SessionState = (typeof SESSION_STATES)[number] +export type SessionMode = (typeof SESSION_MODES)[number] +export type ListStatusFilter = (typeof LIST_STATUS_FILTERS)[number] export type AvailableAgent = (typeof AVAILABLE_AGENTS)[number]['value'] diff --git a/src/commands/agents/types.ts b/src/commands/agents/types.ts index 345b87101f3..93a895c3882 100644 --- a/src/commands/agents/types.ts +++ b/src/commands/agents/types.ts @@ -1,4 +1,4 @@ -import type { AgentState, SessionState, AvailableAgent } from './constants.js' +import type { AgentState, ListStatusFilter, SessionState, SessionMode, AvailableAgent } from './constants.js' export interface AgentConfig { agent?: AvailableAgent @@ -6,6 +6,13 @@ export interface AgentConfig { [key: string]: unknown } +export interface AgentRunnerUser { + id: string + full_name?: string + email?: string + avatar_url?: string +} + export interface AgentRunner { id: string site_id?: string @@ -18,11 +25,57 @@ export interface AgentRunner { branch?: string result_branch?: string current_task?: string + base_deploy_id?: string + sha?: string + + pr_url?: string + pr_branch?: string + pr_state?: string + pr_number?: number + pr_is_being_created?: boolean + pr_error?: string + + merge_commit_sha?: string + merge_commit_error?: string + merge_commit_is_being_created?: boolean + + attached_file_keys?: string[] + active_session_created_at?: string + last_session_created_at?: string + has_result_diff?: boolean + latest_session_deploy_id?: string - user?: { - id: string - full_name?: string - } + latest_session_deploy_url?: string + latest_session_deploy_screenshot_url?: string + latest_session_state?: SessionState + latest_session_mode?: SessionMode + latest_session_is_published?: boolean + + needs_git_sync?: boolean + rebase_available?: boolean + merge_target_available?: boolean + + user?: AgentRunnerUser + contributors?: AgentRunnerUser[] +} + +export interface AgentRunnerSessionUsage { + total_input_tokens?: number + total_output_tokens?: number + total_cached_input_tokens?: number + total_cached_output_tokens?: number + total_tokens?: number + total_input_microcents?: number + total_output_microcents?: number + total_cached_input_microcents?: number + total_cached_output_microcents?: number + total_tool_calls_microcents?: number + total_credits_cost?: number +} + +export interface AgentRunnerSessionStep { + title?: string + message?: string } export interface AgentRunnerSession { @@ -30,19 +83,94 @@ export interface AgentRunnerSession { agent_runner_id: string dev_server_id?: string state: SessionState + mode?: SessionMode created_at: string updated_at: string done_at?: string title?: string + current_task?: string prompt: string agent_config?: AgentConfig result?: string result_diff?: string + cumulative_diff?: string duration?: number - steps?: { - title?: string - message?: string - }[] + steps?: AgentRunnerSessionStep[] + user?: AgentRunnerUser + attached_file_keys?: string[] + result_zip_file_name?: string + is_published?: boolean + is_discarded?: boolean + commit_sha?: string + source_session_id?: string + deploy_id?: string + deploy_url?: string + usage?: AgentRunnerSessionUsage + credit_limit_exceeded?: boolean + metadata?: Record +} + +export interface CreateAgentRunnerPayload { + prompt: string + agent: AvailableAgent + model?: string + branch?: string + deploy_id?: string + parent_agent_runner_id?: string + file_keys?: string[] +} + +export interface CreateAgentRunnerSessionPayload { + prompt: string + agent?: AvailableAgent + model?: string + file_keys?: string[] +} + +export interface ListAgentRunnersFilters { + state?: ListStatusFilter + branch?: string + result_branch?: string + user_id?: string + title?: string + from?: number + to?: number + page?: number + per_page?: number +} + +export interface ListAgentRunnerSessionsFilters { + state?: SessionState + from?: number + to?: number + order_by?: 'asc' | 'desc' + include_discarded?: boolean + page?: number + per_page?: number +} + +export interface DiffParams { + page?: number + per_page?: number + strip_binary?: boolean +} + +export interface PaginatedResult { + data: T + total?: number + page: number + perPage: number + hasNext: boolean +} + +export interface UploadUrlResponse { + upload_url: string + file_key: string +} + +export interface DeleteUrlResponse { + delete_url: string + file_key: string } export interface APIError { @@ -50,3 +178,13 @@ export interface APIError { message: string error?: string } + +export interface AiGatewayProviderInfo { + token_env_var: string + url_env_var: string + models: string[] +} + +export interface AiGatewayProvidersResponse { + providers: Partial> +} diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index 308fb6fb717..5762c40ef6b 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -1,5 +1,11 @@ -import { AVAILABLE_AGENTS, STATUS_COLORS } from './constants.js' +import path from 'path' + import { chalk } from '../../utils/command-helpers.js' +import { AGENT_TO_PROVIDER, AVAILABLE_AGENTS, LIST_STATUS_FILTERS, STATUS_COLORS } from './constants.js' +import type { ListStatusFilter } from './constants.js' +import type { AgentsApi } from './api.js' +import type { AvailableAgent } from './constants.js' +import type { AgentRunnerSessionUsage } from './types.js' export const truncateText = (text: string, maxLength: number): string => { if (text.length <= maxLength) return text @@ -7,8 +13,7 @@ export const truncateText = (text: string, maxLength: number): string => { } export const formatDate = (dateString: string): string => { - const date = new Date(dateString) - return date.toLocaleString() + return new Date(dateString).toLocaleString() } export const formatDuration = (startTime: string, endTime?: string): string => { @@ -34,7 +39,16 @@ export const formatStatus = (status: string): string => { return colorFn(status.toUpperCase()) } -export const validatePrompt = (input: string): boolean | string => { +const PR_STATE_LABELS: Record = { + open: 'Open', + draft: 'Draft', + closed: 'Closed', + merged: 'Merged', +} + +export const formatPrState = (state: string): string => PR_STATE_LABELS[state.toLowerCase()] ?? state + +export const validatePrompt = (input: string): true | string => { if (!input || input.trim().length === 0) { return 'Please provide a prompt for the agent' } @@ -44,15 +58,156 @@ export const validatePrompt = (input: string): boolean | string => { return true } -export const validateAgent = (agent: string): boolean | string => { - const validAgents = AVAILABLE_AGENTS.map((a) => a.value) as string[] +export const TITLE_MAX_LENGTH = 200 + +const UNICODE_TAG_PATTERN = /[\u{E0000}-\u{E007F}]/gu +const CONTROL_CHAR_PATTERN = /\p{Cc}/gu + +export const sanitizePromptText = (text: string): string => text.replace(UNICODE_TAG_PATTERN, '') + +export const sanitizeRunnerTitle = (title: string): string => + sanitizePromptText(title).replace(CONTROL_CHAR_PATTERN, '').trim() + +export const validateRunnerTitle = (title: string): true | string => { + const sanitized = sanitizeRunnerTitle(title) + if (!sanitized) return 'A non-empty title is required' + if (sanitized.length > TITLE_MAX_LENGTH) return `Title must be ${TITLE_MAX_LENGTH.toString()} characters or fewer` + return true +} + +export const validateAgent = (agent: string): true | string => { + const validAgents = AVAILABLE_AGENTS.map((entry) => entry.value) as string[] if (!validAgents.includes(agent)) { return `Invalid agent. Available agents: ${validAgents.join(', ')}` } return true } +export const validateListStatusFilter = (status: string): true | string => { + if ((LIST_STATUS_FILTERS as readonly string[]).includes(status)) return true + return `--status accepts only ${LIST_STATUS_FILTERS.map((entry) => `"${entry}"`).join(', ')}` +} + +export const isListStatusFilter = (status: string): status is ListStatusFilter => + (LIST_STATUS_FILTERS as readonly string[]).includes(status) + +export const checkModelAvailability = async ( + api: AgentsApi, + agent: AvailableAgent, + model: string, +): Promise => { + let providers + try { + providers = await api.listAiGatewayProviders() + } catch { + return true + } + const providerName = AGENT_TO_PROVIDER[agent] + const models = providers.providers[providerName]?.models + if (!models) return true + if (models.includes(model)) return true + return `Unknown model "${model}" for agent "${agent}". Known ${providerName} models: ${models.join( + ', ', + )}. Pass through if a newer one has rolled out.` +} + export const getAgentName = (agent: string): string => { - const entry = AVAILABLE_AGENTS.find((a) => a.value === agent) + const entry = AVAILABLE_AGENTS.find((candidate) => candidate.value === agent) return entry ? entry.name : agent } + +const NETLIFY_WEB_UI = (process.env.NETLIFY_WEB_UI ?? 'https://app.netlify.com').replace(/\/+$/, '') + +export const buildAgentDashboardUrl = (siteName: string, agentId: string): string => + `${NETLIFY_WEB_UI}/projects/${siteName}/agent-runs/${agentId}` + +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes.toString()} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +export const formatTokenCount = (count?: number): string => { + if (count == null) return '-' + if (count < 1000) return count.toString() + if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k` + return `${(count / 1_000_000).toFixed(2)}M` +} + +export const formatUsage = (usage?: AgentRunnerSessionUsage): string[] => { + if (!usage) return [] + const lines: string[] = [] + const tokens = usage.total_tokens + if (tokens != null) { + const breakdown = [ + usage.total_input_tokens != null ? `in ${formatTokenCount(usage.total_input_tokens)}` : null, + usage.total_output_tokens != null ? `out ${formatTokenCount(usage.total_output_tokens)}` : null, + usage.total_cached_input_tokens || usage.total_cached_output_tokens + ? `cached ${formatTokenCount((usage.total_cached_input_tokens ?? 0) + (usage.total_cached_output_tokens ?? 0))}` + : null, + ].filter(Boolean) + lines.push(`Tokens: ${formatTokenCount(tokens)}${breakdown.length > 0 ? ` (${breakdown.join(', ')})` : ''}`) + } + if (usage.total_credits_cost != null) { + lines.push(`Credits: ${usage.total_credits_cost.toFixed(4)}`) + } + return lines +} + +const MIME_BY_EXT: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.log': 'text/plain', + '.json': 'application/json', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', + '.toml': 'application/toml', + '.csv': 'text/csv', + '.html': 'text/html', + '.htm': 'text/html', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.jsx': 'text/javascript', + '.css': 'text/css', +} + +export const getMimeType = (filename: string): string => { + const ext = path.extname(filename).toLowerCase() + return MIME_BY_EXT[ext] ?? 'application/octet-stream' +} + +export const formatDiff = (diff: string): string => { + if (!diff) return '' + const lines = diff.split('\n') + return lines + .map((line) => { + if (line.startsWith('diff --git') || line.startsWith('index ')) return chalk.bold(line) + if (line.startsWith('--- ') || line.startsWith('+++ ')) return chalk.bold(line) + if (line.startsWith('@@')) return chalk.cyan(line) + if (line.startsWith('+')) return chalk.green(line) + if (line.startsWith('-')) return chalk.red(line) + return line + }) + .join('\n') +} + +export const parseLinkHeader = (linkHeader: string | null): Record => { + if (!linkHeader) return {} + const result: Record = {} + for (const part of linkHeader.split(',')) { + const match = /<([^>]+)>;\s*rel="([^"]+)"/.exec(part.trim()) + if (match) result[match[2]] = match[1] + } + return result +} diff --git a/tests/integration/utils/mock-api.ts b/tests/integration/utils/mock-api.ts index eafd4795452..24ae081ce0c 100644 --- a/tests/integration/utils/mock-api.ts +++ b/tests/integration/utils/mock-api.ts @@ -21,7 +21,7 @@ interface MockApiOptions { export interface MockApi { apiUrl: string - requests: { path: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] + requests: { path: string; originalUrl: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] server: Server close: () => Promise } @@ -34,6 +34,7 @@ export interface MockApiTestContext { const addRequest = (requests: MockApi['requests'], request: express.Request) => { requests.push({ path: request.path, + originalUrl: request.originalUrl, body: request.body, method: request.method, headers: request.headers, @@ -66,6 +67,10 @@ export const startMockApi = ({ routes, silent }: MockApiOptions): Promise Promise, silent = false, ) => {