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 }),

@agentcore-cli-automation agentcore-cli-automation Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this comment anchors on the qualifier addition site for the non-streaming SigV4 path, but the issue is in invokeAguiRuntime at agentcore.ts:1088.

invokeAguiRuntime builds InvokeAgentRuntimeCommand here without a qualifier, so agentcore invoke --endpoint prod against an AGUI-protocol agent will silently route to DEFAULT — the same bug this PR is fixing for SigV4 HTTP invokes. The PR description's "out-of-scope follow-up" only calls out A2A (invokeA2ARuntime) and MCP (mcpRpcCall), but AGUI has the same gap.

Options:

  1. Add ...(options.endpoint && { qualifier: options.endpoint }) here (and thread endpoint through AguiInvokeOptions + the AGUI call site in action.ts:636), matching what was done for invokeAgentRuntime / invokeAgentRuntimeStreaming.
  2. Update the PR description's follow-up scope to explicitly include AGUI alongside A2A and MCP so it doesn't get lost.
  3. Reject --endpoint with a clear error for AGUI/A2A/MCP protocols at the validation layer (validate.ts or in action.ts before the protocol branches), so users aren't silently misled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (option 1). AguiInvokeOptions now has an endpoint field, invokeAguiRuntime sets ...(options.endpoint && { qualifier: options.endpoint }) on its InvokeAgentRuntimeCommand, and the resolved endpoint is threaded through both the CLI AGUI call site (action.ts:636) and the TUI AGUI call site (useInvokeFlow.ts). So HTTP, streaming, and AGUI SigV4 invokes all honor --endpoint now. Added qualifier tests for the AGUI path in agentcore-invoke-qualifier.test.ts. The PR-body follow-up note now scopes only A2A (invokeA2ARuntime) and MCP (mcpRpcCall) as remaining work.

});

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' });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test claims to verify the regression "AGENTCORE_RUNTIME_ENDPOINT (no prompt/flag) forces CLI mode instead of silently routing to the TUI", but the invocation passes --json, which on its own already forces CLI mode (it's in the gate list at command.tsx:380). So even if the new endpoint !== undefined clause were removed entirely, this test would still pass — the env-var-only path is not actually being exercised. The endpoint_source: 'env' telemetry assertion only confirms computeEndpointSource() detected the env var, not that it forced CLI mode.

Fix options:

  1. Drop --json from this test and assert on stderr/stdout containing the action-layer "No prompt provided" error (and still not.toContain('requires an interactive terminal')). That actually exercises the regression.
  2. Keep --json but add a second test without it that asserts the env var alone forces CLI mode (e.g. the action-layer error appears, not the TUI's requireTTY guard).
  3. Replace this test with one that asserts behavior we couldn't get before the fix — e.g. spy/intercept and confirm the action layer receives a non-undefined endpoint when only the env var is set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed. Dropped --json (and every other CLI flag) so the env-var-resolved endpoint is the ONLY thing forcing CLI mode, which exercises the new endpoint !== undefined gate clause directly. The test now asserts the action-layer error 'No deployed targets found' appears on stderr (and that 'requires an interactive terminal' does NOT), proving CLI mode was forced rather than routing to the TUI. I verified the negative control: with the endpoint !== undefined clause removed and rebuilt, the same invocation routes to the TUI and hits 'Error: This command requires an interactive terminal', so the test genuinely fails without the fix and passes with it.


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