Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 11 additions & 4 deletions docs/ab-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,21 @@ Promote does not deploy — review the change and run `agentcore deploy` to roll

## Invocation URL

`view ab-test <id>` shows an **Invocation URL** derived from the test's gateway. Send traffic there and the gateway
splits it between the variants per the configured weights:
`view ab-test <id>` shows a URL derived from the test's gateway. Send traffic there and the gateway splits it between
the variants per the configured weights:

```
https://<gatewayId>.gateway.bedrock-agentcore.<region>.amazonaws.com/<target-or-agent>/invocations
https://<gatewayId>.gateway.bedrock-agentcore.<region>.amazonaws.com/<gateway-target>/invocations
```

(target-based uses the control target's path; config-bundle uses the agent name.)
The path segment is always a **gateway target** name — never a runtime name.

- **target-based** — a complete **Invocation URL**, using the control variant's target.
- **config-bundle** — the **Gateway URL** only. These tests attach to the whole gateway rather than to one target (the
variants are configuration bundles), so the path segment is whichever gateway target you invoke. Append
`/<gateway-target>/invocations` yourself; list the gateway's targets with `agentcore status --json`.

With `--json`, target-based reports `invocationUrl`; config-bundle reports `gatewayUrl` plus `invocationUrlHint`.

## Results

Expand Down
5 changes: 3 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1267,7 +1267,8 @@ agentcore archive ab-test -i <ab-test-id>
View job history and details. Works for all four job types — `recommendation`, `batch-evaluation`, `ab-test`, and
`insights`. With no `[id]` it lists every job of that type; with an `[id]` it shows that job's detail (status, inputs,
and results). Without `--json` the command launches the interactive TUI; with `--json` it prints a machine-readable
record (the `ab-test` detail also includes `invocationUrl`).
record (the `ab-test` detail also includes `invocationUrl` for target-based tests, or `gatewayUrl` + `invocationUrlHint`
for config-bundle tests — see [A/B tests](ab-tests.md#invocation-url)).

```bash
# List all jobs of a type
Expand All @@ -1279,7 +1280,7 @@ agentcore view insights
# Detail for one job (JSON is non-interactive)
agentcore view recommendation <id> --json
agentcore view batch-evaluation <id> --json
agentcore view ab-test <id> --json # JSON includes invocationUrl + results
agentcore view ab-test <id> --json # JSON includes gateway URL fields + results
```

Each `view <type>` subcommand accepts the same argument and flags:
Expand Down
27 changes: 24 additions & 3 deletions src/cli/commands/view/command.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { ConfigIO, JobNotFoundError, serializeResult } from '../../../lib';
import { createJobEngine } from '../../operations/jobs';
import type { ABTestJobRecord, JobType } from '../../operations/jobs';
import { getInvocationUrl, printABTestDetail, printABTestHistory } from '../../operations/jobs/ab-test/format';
import {
INVOCATION_PATH_HINT,
getInvocationUrl,
isGatewayBaseUrl,
printABTestDetail,
printABTestHistory,
} from '../../operations/jobs/ab-test/format';
import { printBatchEvaluationDetail, printBatchEvaluationHistory } from '../../operations/jobs/batch-evaluation/format';
import { printInsightsDetail, printInsightsHistory } from '../../operations/jobs/insights/format';
import { printRecommendationDetail, printRecommendationHistory } from '../../operations/jobs/recommendation/format';
Expand Down Expand Up @@ -44,6 +50,22 @@ const TYPE_META: Record<
},
};

/**
* URL fields for `view ab-test --json`.
*
* Target-based tests have a real invocation path, so they keep `invocationUrl`. Config-bundle tests do
* not (the path is whichever gateway target the caller invokes — see getInvocationUrl), so they report
* `gatewayUrl` + `invocationUrlHint`. Scripts reading `.invocationUrl` therefore get nothing for those
* tests rather than a URL that 404s (issue #1854).
*/
function abTestUrlFields(record: ABTestJobRecord): Record<string, string | undefined> {
const url = getInvocationUrl(record);
if (url && isGatewayBaseUrl(record)) {
return { gatewayUrl: url, invocationUrlHint: INVOCATION_PATH_HINT };
}
return { invocationUrl: url };
}

function registerViewSubcommand(viewCmd: Command, type: JobType) {
const meta = TYPE_META[type];

Expand All @@ -65,8 +87,7 @@ function registerViewSubcommand(viewCmd: Command, type: JobType) {
if (!record) {
throw new JobNotFoundError(`${meta.label} "${id}" not found.`);
}
const extra =
type === 'ab-test' ? { invocationUrl: getInvocationUrl(record as unknown as ABTestJobRecord) } : {};
const extra = type === 'ab-test' ? abTestUrlFields(record as unknown as ABTestJobRecord) : {};
console.log(JSON.stringify(serializeResult({ success: true, ...record, ...extra })));
return { job_type: type };
});
Expand Down
52 changes: 51 additions & 1 deletion src/cli/operations/jobs/ab-test/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ABTestJobRecord } from '../../shared/types';
import { printABTestDetail } from '../format';
import { getInvocationUrl, isGatewayBaseUrl, printABTestDetail } from '../format';
import { afterEach, describe, expect, it, vi } from 'vitest';

function baseRecord(overrides: Partial<ABTestJobRecord> = {}): ABTestJobRecord {
Expand Down Expand Up @@ -45,3 +45,53 @@ describe('printABTestDetail — gateway filter', () => {
expect(output).toContain('Gateway filter: none');
});
});

const GW_BASE = 'https://gw-abc.gateway.bedrock-agentcore.us-east-1.amazonaws.com';

describe('getInvocationUrl', () => {
it('target-based: builds a full invocation URL from the control target name', () => {
expect(getInvocationUrl(baseRecord())).toBe(`${GW_BASE}/ctrl/invocations`);
expect(isGatewayBaseUrl(baseRecord())).toBe(false);
});

it('target-based: returns undefined when the control target name is missing', () => {
expect(getInvocationUrl(baseRecord({ variants: [] }))).toBeUndefined();
});

// Regression for #1854: `agent` holds the RUNTIME name, which is not a valid gateway path segment.
// Emitting it produced URLs failing with "No Target found for Target name: <runtime>".
it('config-bundle: returns the gateway base URL, never the runtime name as a path', () => {
const record = baseRecord({ mode: 'config-bundle', agent: 'CustomerSupportAB', variants: [] });
expect(getInvocationUrl(record)).toBe(GW_BASE);
expect(getInvocationUrl(record)).not.toContain('CustomerSupportAB');
expect(isGatewayBaseUrl(record)).toBe(true);
});

it('returns undefined for a gateway ARN it cannot parse', () => {
expect(getInvocationUrl(baseRecord({ gatewayArn: 'not-an-arn' }))).toBeUndefined();
});
});

describe('printABTestDetail — invocation URL', () => {
afterEach(() => {
vi.restoreAllMocks();
});

function capture(record: ABTestJobRecord): string {
const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn());
printABTestDetail(record);
return spy.mock.calls.map(c => c.join(' ')).join('\n');
}

it('labels a target-based URL as the invocation URL', () => {
const output = capture(baseRecord());
expect(output).toContain(`Invocation URL: ${GW_BASE}/ctrl/invocations`);
});

it('labels a config-bundle URL as the gateway URL and hints at the missing path', () => {
const output = capture(baseRecord({ mode: 'config-bundle', agent: 'CustomerSupportAB', variants: [] }));
expect(output).toContain(`Gateway URL: ${GW_BASE}`);
expect(output).toContain('append /<gateway-target>/invocations');
expect(output).not.toContain('Invocation URL:');
});
});
32 changes: 27 additions & 5 deletions src/cli/operations/jobs/ab-test/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ import { formatJobDate } from '../shared/format';
import type { ABTestJobRecord } from '../shared/types';

/**
* Derive the gateway invocation URL from the stored gateway ARN.
* Target-based: `https://{gateway}/{control-target-name}/invocations`.
* Config-bundle: `https://{gateway}/{agent-name}/invocations`.
* Derive the URL to send test traffic to, from the stored gateway ARN.
*
* Target-based: a full invocation URL, `https://{gateway}/{control-target-name}/invocations`.
*
* Config-bundle: the gateway base URL only. These tests attach to the whole gateway — the variants
* are configuration bundles, and the service splits traffic with a gateway rule — so the path segment
* is whichever gateway target the caller invokes, which the CLI cannot know. The path segment must be
* a gateway TARGET name; substituting the runtime name (`record.agent`) produced URLs that failed with
* "No Target found for Target name: <runtime>" whenever a target was not named identically to its
* runtime (issue #1854). Callers append the target path themselves.
*/
export function getInvocationUrl(record: ABTestJobRecord): string | undefined {
const parts = record.gatewayArn.split(':');
Expand All @@ -18,9 +25,17 @@ export function getInvocationUrl(record: ABTestJobRecord): string | undefined {
const targetName = record.variants[0]?.targetName;
return targetName ? `${baseUrl}/${targetName}/invocations` : undefined;
}
return record.agent ? `${baseUrl}/${record.agent}/invocations` : undefined;
return baseUrl;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This always returns only the gateway base URL. This breaks working same-name configurations and scripts consuming .invocationUrl, while leaving issue #1854’s workflow manual. Creation already loads gateway targets and runtime mappings, so it should emit a complete URL when one target uniquely matches, and expose candidates or a clear ambiguity when several match.

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.

I was thinking of this but I wanted to establish to the user that config bundles are applied to all gateway targets. However, I could go either with this.

}

/** True when `getInvocationUrl` yields a gateway base URL that still needs a target path appended. */
export function isGatewayBaseUrl(record: ABTestJobRecord): boolean {
return record.mode !== 'target-based';
}

/** Names what the caller must append to a gateway base URL to reach a variant. */
export const INVOCATION_PATH_HINT = 'append /<gateway-target>/invocations (see `agentcore status --json`)';

export function printABTestHistory(records: ABTestJobRecord[]): void {
if (records.length === 0) {
console.log('No A/B test jobs found. Run `agentcore run ab-test` to create one.');
Expand All @@ -47,7 +62,14 @@ export function printABTestDetail(record: ABTestJobRecord): void {
console.log(`Gateway: ${record.gatewayArn}`);
console.log(`Gateway filter: ${record.gatewayFilter?.targetPaths?.[0] ?? 'none'}`);
const invocationUrl = getInvocationUrl(record);
if (invocationUrl) console.log(`Invocation URL: ${invocationUrl}`);
if (invocationUrl) {
if (isGatewayBaseUrl(record)) {
console.log(`Gateway URL: ${invocationUrl}`);
console.log(` → ${INVOCATION_PATH_HINT}`);
} else {
console.log(`Invocation URL: ${invocationUrl}`);
}
}
console.log(`Started: ${formatJobDate(record.createdAt)}`);
if (record.completedAt) console.log(`Stopped: ${formatJobDate(record.completedAt)}`);
if (record.maxDurationExpiresAt) console.log(`Max duration expires: ${formatJobDate(record.maxDurationExpiresAt)}`);
Expand Down
23 changes: 17 additions & 6 deletions src/cli/tui/screens/job-detail/ABTestDetailView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getErrorMessage } from '../../../errors';
import { isTerminal } from '../../../operations/jobs';
import type { ABTestJobRecord, DebugCheckResult, JobEngine } from '../../../operations/jobs';
import { getInvocationUrl } from '../../../operations/jobs/ab-test/format';
import { INVOCATION_PATH_HINT, getInvocationUrl, isGatewayBaseUrl } from '../../../operations/jobs/ab-test/format';
import { Panel } from '../../components';
import { lifecycleColor, statusColor } from './helpers';
import { Box, Text, useInput } from 'ink';
Expand Down Expand Up @@ -129,11 +129,22 @@ export function ABTestDetailView({
<Text>
<Text bold>Gateway:</Text> {record.gatewayArn}
</Text>
{invocationUrl && (
<Text>
<Text bold>Invocation URL:</Text> {invocationUrl}
</Text>
)}
{invocationUrl &&
(isGatewayBaseUrl(record) ? (
<>
<Text>
<Text bold>Gateway URL:</Text> {invocationUrl}
</Text>
<Text dimColor>
{' → '}
{INVOCATION_PATH_HINT}
</Text>
</>
) : (
<Text>
<Text bold>Invocation URL:</Text> {invocationUrl}
</Text>
))}
{record.createdAt && (
<Text>
<Text bold>Started:</Text> {new Date(record.createdAt).toLocaleString()}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/tui/screens/run-ab-test/RunABTestFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ async function loadResources(): Promise<{ resources: ABTestResources; region: st
for (const name of Object.keys(resources.onlineEvalConfigs ?? {})) onlineEvalConfigs.add(name);
}

// Gateway-target names come from project spec (deployed as `${project}-${target}`).
// Gateway-target names come from project spec (deployed by their spec name as-is; only the gateway
// itself is prefixed with `${project}-`).
for (const gw of projectSpec.agentCoreGateways ?? []) {
for (const t of gw.targets ?? []) {
if (t.targetType === 'httpRuntime') targets.add(t.name);
Expand Down
Loading