diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index a4b80286..1c3a089d 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -1,6 +1,7 @@ import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema/index.js'; import { computeResourceStatuses, handleProjectStatus } from '../action.js'; import type { StatusContext } from '../action.js'; +import { buildRuntimeInvocationUrl } from '../constants.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockGetAgentRuntimeStatus = vi.fn(); @@ -631,3 +632,176 @@ describe('handleProjectStatus — live enrichment', () => { expect(mockGetEvaluator).not.toHaveBeenCalled(); }); }); + +describe('buildRuntimeInvocationUrl', () => { + it('constructs the correct invocation URL with encoded ARN', () => { + const url = buildRuntimeInvocationUrl( + 'us-east-1', + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/travelplanner_FlightsMcp-abcdefgh' + ); + expect(url).toBe( + 'https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A123456789012%3Aruntime%2Ftravelplanner_FlightsMcp-abcdefgh/invocations' + ); + }); + + it('handles different regions', () => { + const url = buildRuntimeInvocationUrl( + 'eu-west-1', + 'arn:aws:bedrock-agentcore:eu-west-1:111111111111:runtime/my-agent-xyz' + ); + expect(url).toBe( + 'https://bedrock-agentcore.eu-west-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aeu-west-1%3A111111111111%3Aruntime%2Fmy-agent-xyz/invocations' + ); + }); +}); + +describe('handleProjectStatus — invocation URL enrichment', () => { + beforeEach(() => { + mockGetAgentRuntimeStatus.mockReset(); + mockGetEvaluator.mockReset(); + mockGetOnlineEvaluationConfig.mockReset(); + }); + + afterEach(() => vi.clearAllMocks()); + + it('sets invocationUrl on deployed agents after runtime status enrichment', async () => { + const runtimeArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/proj_MyAgent-abc123'; + + mockGetAgentRuntimeStatus.mockResolvedValue({ + runtimeId: 'proj_MyAgent-abc123', + status: 'READY', + }); + + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [{ name: 'MyAgent' }], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: { + runtimes: { + MyAgent: { + runtimeId: 'proj_MyAgent-abc123', + runtimeArn, + roleArn: 'arn:aws:iam::123456789012:role/test', + }, + }, + }, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'MyAgent'); + expect(agentEntry).toBeDefined(); + expect(agentEntry!.invocationUrl).toBe( + `https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${encodeURIComponent(runtimeArn)}/invocations` + ); + }); + + it('does not set invocationUrl on local-only agents', async () => { + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [{ name: 'LocalAgent' }], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: {}, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'LocalAgent'); + expect(agentEntry).toBeDefined(); + expect(agentEntry!.invocationUrl).toBeUndefined(); + }); + + it('still sets invocationUrl when runtime status fetch fails', async () => { + const runtimeArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/proj_FailAgent-xyz'; + mockGetAgentRuntimeStatus.mockRejectedValue(new Error('Timeout')); + + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [{ name: 'FailAgent' }], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: { + runtimes: { + FailAgent: { + runtimeId: 'proj_FailAgent-xyz', + runtimeArn, + roleArn: 'arn:aws:iam::123456789012:role/test', + }, + }, + }, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'FailAgent'); + expect(agentEntry).toBeDefined(); + expect(agentEntry!.error).toBe('Timeout'); + expect(agentEntry!.invocationUrl).toBe( + `https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${encodeURIComponent(runtimeArn)}/invocations` + ); + }); + + it('does not set invocationUrl on pending-removal agents', async () => { + mockGetAgentRuntimeStatus.mockResolvedValue({ + runtimeId: 'proj_OldAgent-abc', + status: 'READY', + }); + + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: { + runtimes: { + OldAgent: { + runtimeId: 'proj_OldAgent-abc', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/proj_OldAgent-abc', + roleArn: 'arn:aws:iam::123456789012:role/test', + }, + }, + }, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'OldAgent'); + expect(agentEntry).toBeDefined(); + expect(agentEntry!.deploymentState).toBe('pending-removal'); + expect(agentEntry!.invocationUrl).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 4f9ed1bc..29222fc2 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -5,6 +5,7 @@ import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-con import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import type { ResourceDeploymentState } from './constants'; +import { buildRuntimeInvocationUrl } from './constants'; export type { ResourceDeploymentState }; @@ -23,6 +24,7 @@ export interface ResourceStatusEntry { identifier?: string; detail?: string; error?: string; + invocationUrl?: string; } export interface ProjectStatusResult { @@ -284,16 +286,20 @@ export async function handleProjectStatus( const agentState = agentStates[entry.name]; if (!agentState) return; + const invocationUrl = entry.identifier + ? buildRuntimeInvocationUrl(targetConfig.region, entry.identifier) + : undefined; + try { const runtimeStatus = await getAgentRuntimeStatus({ region: targetConfig.region, runtimeId: agentState.runtimeId, }); - resources[i] = { ...entry, detail: runtimeStatus.status }; + resources[i] = { ...entry, detail: runtimeStatus.status, invocationUrl }; logger.log(` ${entry.name}: ${runtimeStatus.status} (${agentState.runtimeId})`); } catch (error) { const errorMsg = getErrorMessage(error); - resources[i] = { ...entry, error: errorMsg }; + resources[i] = { ...entry, error: errorMsg, invocationUrl }; logger.log(` ${entry.name}: ERROR - ${errorMsg}`, 'error'); } }) diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index f7335362..9e6de5e3 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -154,7 +154,14 @@ export const registerStatus = (program: Command) => { Agents {agents.map(entry => ( - + + + {entry.invocationUrl && ( + + {' '}URL: {entry.invocationUrl} + + )} + ))} )} diff --git a/src/cli/commands/status/constants.ts b/src/cli/commands/status/constants.ts index 6dfbc08d..e9b047d5 100644 --- a/src/cli/commands/status/constants.ts +++ b/src/cli/commands/status/constants.ts @@ -13,3 +13,8 @@ export const DEPLOYMENT_STATE_LABELS: Record = 'local-only': 'Local only', 'pending-removal': 'Removed locally', }; + +export function buildRuntimeInvocationUrl(region: string, runtimeArn: string): string { + const encodedArn = encodeURIComponent(runtimeArn); + return `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/${encodedArn}/invocations`; +} diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index e9da36dd..eacb9bd1 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -72,6 +72,7 @@ function ResourceRow({ statusColor, deploymentState, identifier, + invocationUrl, }: { icon: string; color: string; @@ -81,6 +82,7 @@ function ResourceRow({ statusColor?: string; deploymentState?: ResourceStatusEntry['deploymentState']; identifier?: string; + invocationUrl?: string; }) { const badge = deploymentState ? getDeploymentBadge(deploymentState) : undefined; @@ -98,6 +100,11 @@ function ResourceRow({ {' '}ID: {identifier} )} + {invocationUrl && ( + + {' '}URL: {invocationUrl} + + )} ); } @@ -182,6 +189,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res statusColor={runtimeStatusColor} deploymentState={rsEntry?.deploymentState} identifier={rsEntry?.identifier} + invocationUrl={rsEntry?.invocationUrl} /> ); })}