Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { invokeAgentRuntime, invokeAgentRuntimeStreaming, invokeAguiRuntime } from '../agentcore.js';
import type { InvokeAgentRuntimeOptions } from '../agentcore.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const commandArgs: Record<string, unknown>[] = [];
const mockSend = vi.fn();

vi.mock('@aws-sdk/client-bedrock-agentcore', () => {
class MockBedrockAgentCoreClient {
send = mockSend;
middlewareStack = { add: vi.fn() };
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor(_config: unknown) {}
}
class MockInvokeAgentRuntimeCommand {
input: Record<string, unknown>;
constructor(args: Record<string, unknown>) {
this.input = args;
commandArgs.push(args);
}
}
return {
BedrockAgentCoreClient: MockBedrockAgentCoreClient,
InvokeAgentRuntimeCommand: MockInvokeAgentRuntimeCommand,
};
});

vi.mock('../account.js', () => ({
getCredentialProvider: vi
.fn()
.mockReturnValue(() => Promise.resolve({ accessKeyId: 'test', secretAccessKey: 'test' })),
}));

function makeByteResponse(body: string) {
return {
runtimeSessionId: 'sess-1',
response: {
transformToByteArray: () => Promise.resolve(new TextEncoder().encode(body)),
},
};
}

function makeStreamResponse(body: string) {
return {
runtimeSessionId: 'sess-1',
response: {
transformToWebStream: () =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(body));
controller.close();
},
}),
},
};
}

const BASE: InvokeAgentRuntimeOptions = {
region: 'us-east-1',
runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/r',
payload: 'hi',
};

describe('SigV4 invoke qualifier', () => {
beforeEach(() => {
commandArgs.length = 0;
mockSend.mockReset();
});

it('sets qualifier on the non-streaming command when endpoint is provided', async () => {
mockSend.mockResolvedValue(makeByteResponse('{"result":"ok"}'));
await invokeAgentRuntime({ ...BASE, endpoint: 'prod' });
expect(commandArgs[0]?.qualifier).toBe('prod');
});

it('omits qualifier on the non-streaming command when no endpoint is provided', async () => {
mockSend.mockResolvedValue(makeByteResponse('{"result":"ok"}'));
await invokeAgentRuntime({ ...BASE });
expect(commandArgs[0]).not.toHaveProperty('qualifier');
});

it('sets qualifier on the streaming command when endpoint is provided', async () => {
mockSend.mockResolvedValue(makeStreamResponse('data: "hi"\n'));
const { stream } = await invokeAgentRuntimeStreaming({ ...BASE, endpoint: 'staging' });
for await (const _ of stream) {
// drain
}
expect(commandArgs[0]?.qualifier).toBe('staging');
});

it('omits qualifier on the streaming command when no endpoint is provided', async () => {
mockSend.mockResolvedValue(makeStreamResponse('data: "hi"\n'));
const { stream } = await invokeAgentRuntimeStreaming({ ...BASE });
for await (const _ of stream) {
// drain
}
expect(commandArgs[0]).not.toHaveProperty('qualifier');
});

it('sets qualifier on the AGUI command when endpoint is provided', async () => {
mockSend.mockResolvedValue(makeStreamResponse('data: {}\n\n'));
await invokeAguiRuntime(
{ region: BASE.region, runtimeArn: BASE.runtimeArn, endpoint: 'prod' },
{ threadId: 't', runId: 'r', messages: [], tools: [], context: [], state: {}, forwardedProps: {} }
);
expect(commandArgs[0]?.qualifier).toBe('prod');
});

it('omits qualifier on the AGUI command when no endpoint is provided', async () => {
mockSend.mockResolvedValue(makeStreamResponse('data: {}\n\n'));
await invokeAguiRuntime(
{ region: BASE.region, runtimeArn: BASE.runtimeArn },
{ threadId: 't', runId: 'r', messages: [], tools: [], context: [], state: {}, forwardedProps: {} }
);
expect(commandArgs[0]).not.toHaveProperty('qualifier');
});
});
5 changes: 5 additions & 0 deletions src/cli/aws/agentcore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt
runtimeSessionId: options.sessionId,
runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID,
...(options.baggage && { baggage: options.baggage }),
...(options.endpoint && { qualifier: options.endpoint }),
});

const response = await client.send(command);
Expand Down Expand Up @@ -463,6 +464,7 @@ export async function invokeAgentRuntime(options: InvokeAgentRuntimeOptions): Pr
runtimeSessionId: options.sessionId,
runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID,
...(options.baggage && { baggage: options.baggage }),
...(options.endpoint && { qualifier: options.endpoint }),
});

const response = await client.send(command);
Expand Down Expand Up @@ -1059,6 +1061,8 @@ export interface AguiInvokeOptions {
headers?: Record<string, string>;
/** Bearer token for CUSTOM_JWT auth — not yet supported for AGUI, will throw if provided */
bearerToken?: string;
/** Runtime endpoint qualifier (the endpoint NAME, e.g. prod/staging). Defaults to DEFAULT when omitted. */
endpoint?: string;
}

export interface AguiStreamingInvokeResult {
Expand Down Expand Up @@ -1090,6 +1094,7 @@ export async function invokeAguiRuntime(
accept: 'text/event-stream',
runtimeSessionId: options.sessionId,
runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID,
...(options.endpoint && { qualifier: options.endpoint }),
});

const response = await client.send(command);
Expand Down
9 changes: 8 additions & 1 deletion src/cli/aws/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ export {
type AgentRuntimeStatusResult,
type GetAgentRuntimeStatusOptions,
} from './agentcore-control';
export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch';
export {
streamLogs,
searchLogs,
resolveEndpointName,
type LogEvent,
type StreamLogsOptions,
type SearchLogsOptions,
} from './cloudwatch';
export { enableTransactionSearch } from './transaction-search';
export {
startPolicyGeneration,
Expand Down
48 changes: 48 additions & 0 deletions src/cli/commands/invoke/__tests__/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,52 @@ describe('invoke command', () => {
expect(result.stderr).not.toContain('requires an interactive terminal');
});
});

// --------------------------------------------------------------------------
// Endpoint mode routing.
//
// The same no-TTY signature lets us prove the env-var fallback no longer
// silently drops into the TUI (which would invoke the DEFAULT endpoint). A
// non-DEFAULT resolved endpoint — whether from --endpoint or the
// AGENTCORE_RUNTIME_ENDPOINT env var — forces CLI mode.
// --------------------------------------------------------------------------
describe('endpoint mode routing', () => {
it('AGENTCORE_RUNTIME_ENDPOINT (no prompt/flag/json) forces CLI mode instead of silently routing to the TUI', async () => {
// Regression for #986/#1554: previously the env var alone never forced CLI
// mode, so the user dropped into the TUI and silently hit DEFAULT. NO --json
// and NO other CLI flag here on purpose — the resolved non-DEFAULT endpoint
// (`endpoint !== undefined` in the gate) is the ONLY thing forcing CLI mode,
// so this exercises the new clause directly. CLI mode reaches the action
// layer, which resolves the invoke target and fails with "No deployed
// targets found" (this project has an agent but no deployed state). If the
// `endpoint !== undefined` clause were removed, this would instead route to
// the TUI and hit the requireTTY guard ("requires an interactive terminal").
const result = await runCLI(['invoke'], projectDir, {
env: { ...telemetry.env, AGENTCORE_RUNTIME_ENDPOINT: 'staging' },
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('No deployed targets found');
expect(result.stderr).not.toContain('requires an interactive terminal');
telemetry.assertMetricEmitted({ command: 'invoke', endpoint_source: 'env' });
});

it('records endpoint_source=flag when --endpoint is passed', async () => {
// --endpoint forces CLI mode; the agent has no named endpoint configured so
// the action layer rejects it — but the telemetry attr is emitted regardless.
const result = await runCLI(['invoke', 'hi', '--endpoint', 'prod', '--json'], projectDir, {
env: telemetry.env,
});
expect(result.exitCode).toBe(1);
const json = JSON.parse(result.stdout);
expect(json.success).toBe(false);
expect(result.stderr).not.toContain('requires an interactive terminal');
telemetry.assertMetricEmitted({ command: 'invoke', endpoint_source: 'flag' });
});

it('records endpoint_source=default when neither --endpoint nor the env var is set', async () => {
const result = await runCLI(['invoke', 'hi', '--json'], projectDir, { env: telemetry.env });
expect(result.exitCode).toBe(1);
telemetry.assertMetricEmitted({ command: 'invoke', endpoint_source: 'default' });
});
});
});
90 changes: 90 additions & 0 deletions src/cli/commands/invoke/__tests__/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,96 @@ describe('resolveInvokeTarget', () => {
expect(result.runtimeArn).toContain('rt-b');
});

describe('named runtime endpoint resolution', () => {
it('resolves an endpoint configured on the runtime to a qualifier', async () => {
const project = makeProject({
runtimes: [
{
name: 'my-agent',
build: 'CodeZip',
codeLocation: './agents/my-agent',
entrypoint: 'main.py',
runtimeVersion: '1.0',
networkMode: 'PUBLIC',
endpoints: { prod: { version: 2 } },
},
] as unknown as AgentCoreProjectSpec['runtimes'],
});

const result = await resolveInvokeTarget({
project,
deployedState: makeDeployedState(),
awsTargets: makeAwsTargets(),
endpointName: 'prod',
});

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.endpoint).toBe('prod');
});

it('resolves an endpoint present only in deployed runtimeEndpoints', async () => {
const deployedState = {
targets: {
default: {
resources: {
runtimes: {
'my-agent': {
runtimeId: 'rt-123',
runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123',
roleArn: 'arn:aws:iam::123456789:role/test-role',
},
},
runtimeEndpoints: {
'my-agent/staging': {
endpointId: 'ep-1',
endpointArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime-endpoint/ep-1',
},
},
},
},
},
} as unknown as DeployedState;

const result = await resolveInvokeTarget({
project: makeProject(),
deployedState,
awsTargets: makeAwsTargets(),
endpointName: 'staging',
});

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.endpoint).toBe('staging');
});

it('returns ResourceNotFoundError for an unknown endpoint name', async () => {
const result = await resolveInvokeTarget({
project: makeProject(),
deployedState: makeDeployedState(),
awsTargets: makeAwsTargets(),
endpointName: 'nope',
});

expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(ResourceNotFoundError);
expect(result.error.message).toContain("Endpoint 'nope' not found");
});

it('leaves endpoint undefined when no endpointName is provided', async () => {
const result = await resolveInvokeTarget({
project: makeProject(),
deployedState: makeDeployedState(),
awsTargets: makeAwsTargets(),
});

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.endpoint).toBeUndefined();
});
});

describe('CUSTOM_JWT token resolution', () => {
function makeJwtProject(): AgentCoreProjectSpec {
return makeProject({
Expand Down
64 changes: 62 additions & 2 deletions src/cli/commands/invoke/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
import { computeInvokeAttrs } from '../utils';
import { describe, expect, it } from 'vitest';
import { computeEndpointSource, computeInvokeAttrs } from '../utils';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

describe('computeEndpointSource', () => {
const original = process.env.AGENTCORE_RUNTIME_ENDPOINT;

beforeEach(() => {
delete process.env.AGENTCORE_RUNTIME_ENDPOINT;
});

afterEach(() => {
if (original === undefined) {
delete process.env.AGENTCORE_RUNTIME_ENDPOINT;
} else {
process.env.AGENTCORE_RUNTIME_ENDPOINT = original;
}
});

it("returns 'flag' when the --endpoint flag is set (even if the env var is also set)", () => {
process.env.AGENTCORE_RUNTIME_ENDPOINT = 'staging';
expect(computeEndpointSource('prod')).toBe('flag');
});

it("returns 'env' when only the env var is set", () => {
process.env.AGENTCORE_RUNTIME_ENDPOINT = 'staging';
expect(computeEndpointSource(undefined)).toBe('env');
});

it("returns 'default' when neither flag nor env var is set", () => {
expect(computeEndpointSource(undefined)).toBe('default');
});
});

describe('computeInvokeAttrs', () => {
it('returns harness when harnessName is set', () => {
Expand Down Expand Up @@ -77,4 +107,34 @@ describe('computeInvokeAttrs', () => {
});
expect(attrs.agent_protocol).toBe('mcp');
});

it("defaults endpoint_source to 'default' when omitted", () => {
const attrs = computeInvokeAttrs({
harnessCount: 0,
runtimeCount: 1,
stream: false,
hasSessionId: false,
});
expect(attrs.endpoint_source).toBe('default');
});

it('passes through the provided endpoint_source', () => {
const flag = computeInvokeAttrs({
harnessCount: 0,
runtimeCount: 1,
stream: false,
hasSessionId: false,
endpointSource: 'flag',
});
expect(flag.endpoint_source).toBe('flag');

const env = computeInvokeAttrs({
harnessCount: 0,
runtimeCount: 1,
stream: false,
hasSessionId: false,
endpointSource: 'env',
});
expect(env.endpoint_source).toBe('env');
});
});
Loading
Loading