From e828f8548844622ed472f6811c5bb77aba283915 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 25 Aug 2026 21:42:42 +0000 Subject: [PATCH 01/15] feat(project): resolve deployed invoke resources --- src/core/project/backends/cdk.test.ts | 71 ++++++++++++++ src/core/project/backends/cdk.ts | 55 +++++++++-- .../project/backends/cdk/deployment.test.ts | 67 +++++++++++++ src/core/project/backends/cdk/deployment.ts | 74 +++++++++++++++ src/core/project/backends/types.ts | 11 +++ src/core/project/index.tsx | 6 +- src/core/project/manager.test.ts | 94 +++++++++++++++++++ src/core/project/manager.tsx | 37 ++++++-- src/handlers/project/deploy/index.test.ts | 3 + src/handlers/project/types.ts | 20 ++++ 10 files changed, 421 insertions(+), 17 deletions(-) create mode 100644 src/core/project/backends/cdk/deployment.test.ts create mode 100644 src/core/project/backends/cdk/deployment.ts diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 8c0506628..789f83af3 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; +import type { Stack } from "@aws-sdk/client-cloudformation"; import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { createSilentLogger } from "../../../testing"; @@ -122,6 +123,7 @@ type HarnessOptions = { bootstrapError?: Error; /** Whether CloudFormation still holds the target's stack. Defaults to present. */ stackExists?: boolean; + stack?: Stack; }; function harness(options: HarnessOptions = {}) { @@ -133,6 +135,8 @@ function harness(options: HarnessOptions = {}) { const accountRegions: string[] = []; const bootstrapRegions: string[] = []; const stackProbes: string[] = []; + const stackReads: { stackName: string; region: string; credentials: CdkCredentialProvider }[] = + []; let templateLoads = 0; let templateCleanups = 0; const credentials: CdkCredentialProvider = async () => ({ @@ -194,6 +198,10 @@ function harness(options: HarnessOptions = {}) { }, }; }, + readStack: async (stackName, region, provider) => { + stackReads.push({ stackName, region, credentials: provider }); + return options.stack; + }, }); return { @@ -207,6 +215,7 @@ function harness(options: HarnessOptions = {}) { credentials, runs, stackProbes, + stackReads, templateLoads: () => templateLoads, templateCleanups: () => templateCleanups, }; @@ -563,3 +572,65 @@ describe("CdkBackend.deploy", () => { expect(subject.runs.map(({ operation }) => operation.kind)).toEqual(["bootstrap"]); }); }); + +describe("CdkBackend.resolveDeployedResource", () => { + test("reads the selected stack and resolves its Runtime ID output", async () => { + const input = await project(); + const subject = harness({ + stack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: "AgentCore-example-default-checkout-RuntimeId", + OutputValue: "checkout-AbCdEf1234", + }, + ], + }, + }); + + const id = await subject.backend.resolveDeployedResource(input, { + target: TARGET, + resourceType: "runtime", + name: "checkout", + }); + + expect(id).toBe("checkout-AbCdEf1234"); + expect(subject.stackReads).toEqual([ + { + stackName: "AgentCore-example-default", + region: TARGET.region, + credentials: subject.credentials, + }, + ]); + expect(subject.accountCredentials).toEqual([subject.credentials]); + }); + + test("fails actionably when the project stack does not exist", async () => { + const input = await project(); + const subject = harness(); + + await expect( + subject.backend.resolveDeployedResource(input, { + target: TARGET, + resourceType: "harness", + name: "support", + }), + ).rejects.toThrow(/not deployed.*project deploy --target default/s); + }); + + test("rejects the wrong account before reading CloudFormation", async () => { + const input = await project(); + const subject = harness({ account: "999900001111" }); + + await expect( + subject.backend.resolveDeployedResource(input, { + target: TARGET, + resourceType: "runtime", + name: "checkout", + }), + ).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s); + expect(subject.stackReads).toEqual([]); + }); +}); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index c8e7fa525..7dd0e2dbe 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -10,7 +10,12 @@ import { type ReadWriteJson, } from "../../../io"; import type { Logger } from "../../../logging"; -import type { DeployBackendInput, ProjectBackend } from "./types"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import type { + DeployBackendInput, + ProjectBackend, + ResolveDeployedResourceBackendInput, +} from "./types"; import { createCloudFormationClient } from "../../factories"; import type { CreateCloudFormationClient } from "../../types"; import { @@ -38,6 +43,12 @@ import { type CdkRunner, type CdkRunOptions, } from "./cdk/toolkit"; +import { + cdkStackName, + deployedResourceId, + readDeployedStack, + type DeployedStackReader, +} from "./cdk/deployment"; export type CdkBackendConfig = { logger: Logger; @@ -51,6 +62,7 @@ export type CdkBackendConfig = { stack?: StackProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; + readStack?: DeployedStackReader; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -65,6 +77,7 @@ export class CdkBackend implements ProjectBackend { private readonly stack: StackProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; + private readonly readStack: DeployedStackReader; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -86,6 +99,7 @@ export class CdkBackend implements ProjectBackend { ((stackName, region, credentials) => probeStack(stackName, region, credentials, readStack)); this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; + this.readStack = config.readStack ?? readDeployedStack; } public async *build(project: Project): AsyncGenerator { @@ -115,14 +129,7 @@ export class CdkBackend implements ProjectBackend { ): AsyncGenerator { const { target } = input; yield { message: `Verifying AWS account ${target.account}` }; - const credentials = await this.resolveCredentials(target.region); - const account = await this.resolveAccount(target.region, credentials); - if (account !== target.account) { - throw new ProjectStateError( - `Deployment target '${target.name}' expects AWS account ${target.account}, ` + - `but the active credentials belong to ${account}.`, - ); - } + const credentials = await this.credentialsFor(target); // Validate any existing deployed state before mutating AWS. A malformed file // must fail here — not after bootstrap/deploy — so we never leave AWS changed @@ -241,6 +248,36 @@ export class CdkBackend implements ProjectBackend { return { outputs: {}, tornDown: true }; } + public async resolveDeployedResource( + project: Project, + input: ResolveDeployedResourceBackendInput, + ): Promise { + const { target } = input; + const credentials = await this.credentialsFor(target); + + const stackName = cdkStackName(project.name, target.name); + const stack = await this.readStack(stackName, target.region, credentials); + if (!stack) { + throw new ProjectStateError( + `Project '${project.name}' is not deployed to target '${target.name}'. ` + + `Run 'agentcore project deploy --target ${target.name}' first.`, + ); + } + return deployedResourceId(stack, { stackName, targetName: target.name, ...input }); + } + + private async credentialsFor(target: AwsDeploymentTarget) { + const credentials = await this.resolveCredentials(target.region); + const account = await this.resolveAccount(target.region, credentials); + if (account !== target.account) { + throw new ProjectStateError( + `Deployment target '${target.name}' expects AWS account ${target.account}, ` + + `but the active credentials belong to ${account}.`, + ); + } + return credentials; + } + private cdkDirectory(project: Project): string { return join(project.rootPath, "agentcore", "cdk"); } diff --git a/src/core/project/backends/cdk/deployment.test.ts b/src/core/project/backends/cdk/deployment.test.ts new file mode 100644 index 000000000..26b9f4092 --- /dev/null +++ b/src/core/project/backends/cdk/deployment.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import type { Stack } from "@aws-sdk/client-cloudformation"; +import { cdkStackName, deployedResourceId } from "./deployment"; + +function stack(outputs: NonNullable): Stack { + return { + StackName: "AgentCore-orders-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: outputs, + }; +} + +describe("cdkStackName", () => { + test("matches the stack name emitted by the generated CDK app", () => { + expect(cdkStackName("order_service", "pre_prod")).toBe("AgentCore-order-service-pre-prod"); + }); +}); + +describe("deployedResourceId", () => { + test("resolves a Runtime ID by its stable CloudFormation export name", () => { + const deployed = stack([ + { + ExportName: "AgentCore-orders-default-checkout-agent-RuntimeId", + OutputValue: "checkout_agent-AbCdEf1234", + }, + ]); + + expect( + deployedResourceId(deployed, { + stackName: "AgentCore-orders-default", + targetName: "default", + resourceType: "runtime", + name: "checkout_agent", + }), + ).toBe("checkout_agent-AbCdEf1234"); + }); + + test("resolves a Harness ID by its stable CloudFormation export name", () => { + const deployed = stack([ + { + ExportName: "AgentCore-orders-default-Harness-support-agent-Id", + OutputValue: "support_agent-AbCdEf1234", + }, + ]); + + expect( + deployedResourceId(deployed, { + stackName: "AgentCore-orders-default", + targetName: "default", + resourceType: "harness", + name: "support_agent", + }), + ).toBe("support_agent-AbCdEf1234"); + }); + + test("fails when the selected resource has no deployed ID output", () => { + expect(() => + deployedResourceId(stack([]), { + stackName: "AgentCore-orders-pre-prod", + targetName: "pre-prod", + resourceType: "runtime", + name: "checkout", + }), + ).toThrow(/Runtime 'checkout'.*not deployed.*pre-prod/s); + }); +}); diff --git a/src/core/project/backends/cdk/deployment.ts b/src/core/project/backends/cdk/deployment.ts new file mode 100644 index 000000000..59adb4c9e --- /dev/null +++ b/src/core/project/backends/cdk/deployment.ts @@ -0,0 +1,74 @@ +import type { Stack } from "@aws-sdk/client-cloudformation"; +import { ProjectStateError } from "../../../../errors/errors"; +import type { ProjectInvokableResource } from "../../../../handlers/project/types"; +import type { CdkCredentialProvider } from "./toolkit"; + +export type DeployedStackReader = ( + stackName: string, + region: string, + credentials: CdkCredentialProvider, +) => Promise; + +function sanitizeName(name: string): string { + return name.replaceAll("_", "-"); +} + +export function cdkStackName(projectName: string, targetName: string): string { + return `AgentCore-${sanitizeName(projectName)}-${sanitizeName(targetName)}`; +} + +function resourceExportName( + stackName: string, + resourceType: ProjectInvokableResource, + name: string, +): string { + const resourceName = sanitizeName(name); + return resourceType === "runtime" + ? `${stackName}-${resourceName}-RuntimeId` + : `${stackName}-Harness-${resourceName}-Id`; +} + +export function deployedResourceId( + stack: Stack, + input: { + stackName: string; + targetName: string; + resourceType: ProjectInvokableResource; + name: string; + }, +): string { + const exportName = resourceExportName(input.stackName, input.resourceType, input.name); + const id = stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; + if (id) return id; + + const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; + throw new ProjectStateError( + `${label} '${input.name}' is not deployed to target '${input.targetName}'. ` + + `Run 'agentcore project deploy --target ${input.targetName}' first.`, + ); +} + +function isStackNotFound(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { name?: unknown; message?: unknown }; + return ( + candidate.name === "ValidationError" && + typeof candidate.message === "string" && + /Stack with id .+ does not exist/i.test(candidate.message) + ); +} + +export const readDeployedStack: DeployedStackReader = async (stackName, region, credentials) => { + const { CloudFormationClient, DescribeStacksCommand } = + await import("@aws-sdk/client-cloudformation"); + const client = new CloudFormationClient({ credentials, region }); + try { + const response = await client.send(new DescribeStacksCommand({ StackName: stackName })); + return response.Stacks?.[0]; + } catch (error) { + if (isStackNotFound(error)) return undefined; + throw error; + } finally { + client.destroy(); + } +}; diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index fb1ef5b20..92baed6fe 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -2,6 +2,7 @@ import type { DeployResult, Project, ProjectEvent, + ProjectInvokableResource, TeardownConfirmationHandler, } from "../../../handlers/project/types"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; @@ -13,8 +14,18 @@ export type DeployBackendInput = { confirmTeardown: TeardownConfirmationHandler; }; +export type ResolveDeployedResourceBackendInput = { + target: AwsDeploymentTarget; + resourceType: ProjectInvokableResource; + name: string; +}; + /** Builds the deployable artifacts owned by a project's selected backend. */ export interface ProjectBackend { build(project: Project): AsyncGenerator; deploy(project: Project, input: DeployBackendInput): AsyncGenerator; + resolveDeployedResource( + project: Project, + input: ResolveDeployedResourceBackendInput, + ): Promise; } diff --git a/src/core/project/index.tsx b/src/core/project/index.tsx index 830a3455d..6755909d0 100644 --- a/src/core/project/index.tsx +++ b/src/core/project/index.tsx @@ -1,3 +1,7 @@ export { FsProjectManager } from "./manager"; export { CdkBackend, type CdkBackendConfig } from "./backends/cdk"; -export type { DeployBackendInput, ProjectBackend } from "./backends/types"; +export type { + DeployBackendInput, + ProjectBackend, + ResolveDeployedResourceBackendInput, +} from "./backends/types"; diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 89c435159..d57fd9880 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -356,6 +356,9 @@ describe("FsProjectManager.deploy", () => { yield { message: "Backend deployment started" }; return { outputs: { RuntimeArn: "arn:runtime" } }; }, + async resolveDeployedResource() { + return "unused"; + }, }; return { calls, @@ -485,6 +488,97 @@ describe("FsProjectManager.deploy", () => { }); }); +describe("FsProjectManager.resolveDeployedResource", () => { + const targets: AwsDeploymentTarget[] = [ + { + name: "default", + account: "111122223333", + region: "us-east-1", + }, + { + name: "prod", + account: "444455556666", + region: "eu-west-1", + }, + ]; + + async function projectWithTargets(rootPath: string): Promise { + await mkdir(join(rootPath, "agentcore"), { recursive: true }); + await writeFile(join(rootPath, "agentcore", "aws-targets.json"), JSON.stringify(targets)); + return { + name: "example", + rootPath, + spec: ProjectSpecSchema.parse({ name: "example", version: 1 }), + }; + } + + test("resolves the target and delegates physical ID lookup to the project backend", async () => { + const root = await inTempDirectory(); + const project = await projectWithTargets(root); + const calls: unknown[] = []; + const backend = { + async *build() {}, + async *deploy() { + yield* []; + return { outputs: {} }; + }, + async resolveDeployedResource(inputProject: Project, input: unknown) { + calls.push({ project: inputProject, input }); + return "runtime-123"; + }, + } as ProjectBackend; + const subject = new FsProjectManager({ + logger: createSilentLogger(), + backends: { CDK: backend }, + }); + + const resolved = await subject.resolveDeployedResource(project, { + target: "prod", + resourceType: "runtime", + name: "checkout", + }); + + expect(resolved).toEqual({ id: "runtime-123", target: targets[1]! }); + expect(calls).toEqual([ + { + project, + input: { + target: targets[1], + resourceType: "runtime", + name: "checkout", + }, + }, + ]); + }); + + test("rejects an unknown target before invoking the backend", async () => { + const root = await inTempDirectory(); + const project = await projectWithTargets(root); + const backend = { + async *build() {}, + async *deploy() { + yield* []; + return { outputs: {} }; + }, + async resolveDeployedResource() { + throw new Error("backend should not be called"); + }, + } as ProjectBackend; + const subject = new FsProjectManager({ + logger: createSilentLogger(), + backends: { CDK: backend }, + }); + + await expect( + subject.resolveDeployedResource(project, { + target: "missing", + resourceType: "harness", + name: "support", + }), + ).rejects.toThrow(/no deployment target named 'missing'.*default, prod/s); + }); +}); + describe("FsProjectManager.resolve", () => { test("round-trips a project it just created", async () => { const root = await inTempDirectory(); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 9ede69c1f..41cf4a574 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -6,6 +6,8 @@ import type { CreateProjectInput, DeployProjectInput, DeployResult, + ResolveDeployedResourceInput, + ResolvedDeployedResource, ResolveProjectInput, Project, ProjectManager, @@ -43,7 +45,10 @@ import { import z from "zod"; import { CdkBackend } from "./backends/cdk"; import type { ProjectBackend } from "./backends/types"; -import { AwsDeploymentTargetsSchema } from "../../projectSchemas/aws-targets"; +import { + AwsDeploymentTargetsSchema, + type AwsDeploymentTarget, +} from "../../projectSchemas/aws-targets"; import type { RuntimeResourceConfig } from "../../handlers/project/add/runtime/types"; import type { TemplateRenderer } from "./templates/types"; import { HandlebarsTemplateRenderer } from "./templates/renderer"; @@ -455,6 +460,27 @@ export class FsProjectManager implements ProjectManager { project: Project, input: DeployProjectInput, ): AsyncGenerator { + const target = await this.resolveTarget(project, input.target); + return yield* this.backendFor(project).deploy(project, { + target, + confirmTeardown: input.confirmTeardown, + }); + } + + public async resolveDeployedResource( + project: Project, + input: ResolveDeployedResourceInput, + ): Promise { + const target = await this.resolveTarget(project, input.target); + const id = await this.backendFor(project).resolveDeployedResource(project, { + target, + resourceType: input.resourceType, + name: input.name, + }); + return { id, target }; + } + + private async resolveTarget(project: Project, name: string): Promise { const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); if (!existsSync(targetsPath)) { throw new ProjectStateError( @@ -472,18 +498,15 @@ export class FsProjectManager implements ProjectManager { ); } - const target = targets.find((candidate) => candidate.name === input.target); + const target = targets.find((candidate) => candidate.name === name); if (!target) { throw new ProjectStateError( - `Project '${project.name}' has no deployment target named '${input.target}'. ` + + `Project '${project.name}' has no deployment target named '${name}'. ` + `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, ); } - return yield* this.backendFor(project).deploy(project, { - target, - confirmTeardown: input.confirmTeardown, - }); + return target; } private backendFor(project: Project): ProjectBackend { diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index 1c00dafe3..62227b449 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -60,6 +60,9 @@ function fakeBackend( yield* events; return result; }, + async resolveDeployedResource() { + return "unused"; + }, }; return { calls, confirmations, backend }; } diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 19b3a8ef4..dbd9d25d0 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -11,6 +11,7 @@ import { AgentNameSchema, BuildTypeSchema, EntrypointSchema } from "../../projec import { RuntimeVersionSchema } from "../../projectSchemas/constants"; import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; import type { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; +import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets"; type CreateProjectInputBase = { /** The name of the project; also the directory it is scaffolded into. */ @@ -110,6 +111,17 @@ export type ResolveProjectInput = { filePath: string; }; +export type ResolveDeployedResourceInput = { + target: string; + resourceType: ProjectInvokableResource; + name: string; +}; + +export type ResolvedDeployedResource = { + id: string; + target: AwsDeploymentTarget; +}; + export type Project = { name: string; /** Absolute path to the project root (the parent of agentcore/). */ @@ -183,6 +195,8 @@ export type AddResourceInput = export type ProjectResource = AddResourceInput["resourceType"]; +export type ProjectInvokableResource = Extract; + export type RemoveResourceInput = | { resourceType: Exclude; @@ -215,6 +229,12 @@ export interface ProjectManager { /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; + /** Resolve a logical project resource to its deployed physical ID and target. */ + resolveDeployedResource( + project: Project, + input: ResolveDeployedResourceInput, + ): Promise; + /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator; From 99e76433fdc5b2e0b64abbf26796bbe27fd0a2b2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 01:03:57 +0000 Subject: [PATCH 02/15] refactor(project): resolve resources from deployed stack state --- src/core/project/backends/cdk.test.ts | 127 +++++++++++++++++++------- src/core/project/backends/cdk.ts | 56 +++++++++--- 2 files changed, 139 insertions(+), 44 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 789f83af3..2f06eb0c9 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -18,6 +18,8 @@ const TARGET = { account: "111122223333", region: "us-east-1", } as const; +const STACK_ARN = + "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc"; /** A template holding only what CDK adds itself, as an empty project synthesizes. */ const METADATA_ONLY = { CDKMetadata: { Type: "AWS::CDK::Metadata" } }; @@ -112,6 +114,13 @@ async function writeAssembly( ); } +async function writeDeployedState(input: Project, stackArn = STACK_ARN): Promise { + await writeFile( + join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH), + JSON.stringify({ targets: { [TARGET.name]: { stackArn } } }), + ); +} + type HarnessOptions = { account?: string; bootstrap?: BootstrapState; @@ -198,7 +207,7 @@ function harness(options: HarnessOptions = {}) { }, }; }, - readStack: async (stackName, region, provider) => { + describeStack: async (region, provider, stackName) => { stackReads.push({ stackName, region, credentials: provider }); return options.stack; }, @@ -574,41 +583,74 @@ describe("CdkBackend.deploy", () => { }); describe("CdkBackend.resolveDeployedResource", () => { - test("reads the selected stack and resolves its Runtime ID output", async () => { - const input = await project(); - const subject = harness({ - stack: { - StackName: "AgentCore-example-default", - CreationTime: new Date(0), - StackStatus: "CREATE_COMPLETE", - Outputs: [ - { - ExportName: "AgentCore-example-default-checkout-RuntimeId", - OutputValue: "checkout-AbCdEf1234", - }, - ], - }, - }); + test.each([ + { + resourceType: "runtime" as const, + name: "checkout_agent", + exportName: "AgentCore-example-default-checkout-agent-RuntimeId", + id: "checkout_agent-AbCdEf1234", + }, + { + resourceType: "harness" as const, + name: "support_agent", + exportName: "AgentCore-example-default-Harness-support-agent-Id", + id: "support_agent-AbCdEf1234", + }, + ])( + "reads deployed state and resolves a $resourceType ID from its live stack", + async (example) => { + const input = await project(); + await writeDeployedState(input); + const subject = harness({ + stack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: example.exportName, + OutputValue: example.id, + }, + ], + }, + }); - const id = await subject.backend.resolveDeployedResource(input, { - target: TARGET, - resourceType: "runtime", - name: "checkout", - }); + const id = await subject.backend.resolveDeployedResource(input, { + target: TARGET, + resourceType: example.resourceType, + name: example.name, + }); + + expect(id).toBe(example.id); + expect(subject.stackReads).toEqual([ + { + stackName: STACK_ARN, + region: TARGET.region, + credentials: subject.credentials, + }, + ]); + expect(subject.accountCredentials).toEqual([subject.credentials]); + }, + ); - expect(id).toBe("checkout-AbCdEf1234"); - expect(subject.stackReads).toEqual([ - { - stackName: "AgentCore-example-default", - region: TARGET.region, - credentials: subject.credentials, - }, - ]); - expect(subject.accountCredentials).toEqual([subject.credentials]); + test("fails without reading AWS when the target has no deployed stack ARN", async () => { + const input = await project(); + const subject = harness(); + + await expect( + subject.backend.resolveDeployedResource(input, { + target: TARGET, + resourceType: "harness", + name: "support", + }), + ).rejects.toThrow(/not deployed.*project deploy --target default/s); + expect(subject.stackReads).toEqual([]); + expect(subject.accountCredentials).toEqual([]); }); - test("fails actionably when the project stack does not exist", async () => { + test("fails actionably when the recorded stack no longer exists", async () => { const input = await project(); + await writeDeployedState(input); const subject = harness(); await expect( @@ -618,10 +660,33 @@ describe("CdkBackend.resolveDeployedResource", () => { name: "support", }), ).rejects.toThrow(/not deployed.*project deploy --target default/s); + expect(subject.stackReads[0]?.stackName).toBe(STACK_ARN); + }); + + test("fails when the live stack has no output for the selected resource", async () => { + const input = await project(); + await writeDeployedState(input); + const subject = harness({ + stack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [], + }, + }); + + await expect( + subject.backend.resolveDeployedResource(input, { + target: TARGET, + resourceType: "runtime", + name: "checkout", + }), + ).rejects.toThrow(/Runtime 'checkout'.*not deployed.*default/s); }); test("rejects the wrong account before reading CloudFormation", async () => { const input = await project(); + await writeDeployedState(input); const subject = harness({ account: "999900001111" }); await expect( diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 7dd0e2dbe..776360e30 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import type { Stack } from "@aws-sdk/client-cloudformation"; import { MalformedServiceResponseError, ProjectStateError } from "../../../errors/errors"; import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; import { @@ -43,12 +44,26 @@ import { type CdkRunner, type CdkRunOptions, } from "./cdk/toolkit"; -import { - cdkStackName, - deployedResourceId, - readDeployedStack, - type DeployedStackReader, -} from "./cdk/deployment"; +import { describeStack } from "./cdk/stackReader"; + +type StackDescriber = typeof describeStack; + +function sanitizeName(name: string): string { + return name.replaceAll("_", "-"); +} + +function deployedResourceId( + stack: Stack, + input: ResolveDeployedResourceBackendInput, +): string | undefined { + if (!stack.StackName) return undefined; + const resourceName = sanitizeName(input.name); + const exportName = + input.resourceType === "runtime" + ? `${stack.StackName}-${resourceName}-RuntimeId` + : `${stack.StackName}-Harness-${resourceName}-Id`; + return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; +} export type CdkBackendConfig = { logger: Logger; @@ -62,7 +77,7 @@ export type CdkBackendConfig = { stack?: StackProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; - readStack?: DeployedStackReader; + describeStack?: StackDescriber; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -77,7 +92,7 @@ export class CdkBackend implements ProjectBackend { private readonly stack: StackProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; - private readonly readStack: DeployedStackReader; + private readonly describeStack: StackDescriber; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -99,7 +114,7 @@ export class CdkBackend implements ProjectBackend { ((stackName, region, credentials) => probeStack(stackName, region, credentials, readStack)); this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; - this.readStack = config.readStack ?? readDeployedStack; + this.describeStack = config.describeStack ?? describeStack; } public async *build(project: Project): AsyncGenerator { @@ -253,17 +268,32 @@ export class CdkBackend implements ProjectBackend { input: ResolveDeployedResourceBackendInput, ): Promise { const { target } = input; - const credentials = await this.credentialsFor(target); + const deployedState = await readDeployedState(this.json, project.rootPath); + const stackArn = deployedState.targets[target.name]?.stackArn; + if (!stackArn) { + throw new ProjectStateError( + `Project '${project.name}' is not deployed to target '${target.name}'. ` + + `Run 'agentcore project deploy --target ${target.name}' first.`, + ); + } - const stackName = cdkStackName(project.name, target.name); - const stack = await this.readStack(stackName, target.region, credentials); + const credentials = await this.credentialsFor(target); + const stack = await this.describeStack(target.region, credentials, stackArn); if (!stack) { throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + `Run 'agentcore project deploy --target ${target.name}' first.`, ); } - return deployedResourceId(stack, { stackName, targetName: target.name, ...input }); + + const id = deployedResourceId(stack, input); + if (id) return id; + + const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; + throw new ProjectStateError( + `${label} '${input.name}' is not deployed to target '${target.name}'. ` + + `Run 'agentcore project deploy --target ${target.name}' first.`, + ); } private async credentialsFor(target: AwsDeploymentTarget) { From 31d9223d074ace6560754712fdf42565f9ecd899 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 01:03:58 +0000 Subject: [PATCH 03/15] refactor(project): remove duplicate deployment reader --- .../project/backends/cdk/deployment.test.ts | 67 ----------------- src/core/project/backends/cdk/deployment.ts | 74 ------------------- 2 files changed, 141 deletions(-) delete mode 100644 src/core/project/backends/cdk/deployment.test.ts delete mode 100644 src/core/project/backends/cdk/deployment.ts diff --git a/src/core/project/backends/cdk/deployment.test.ts b/src/core/project/backends/cdk/deployment.test.ts deleted file mode 100644 index 26b9f4092..000000000 --- a/src/core/project/backends/cdk/deployment.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Stack } from "@aws-sdk/client-cloudformation"; -import { cdkStackName, deployedResourceId } from "./deployment"; - -function stack(outputs: NonNullable): Stack { - return { - StackName: "AgentCore-orders-default", - CreationTime: new Date(0), - StackStatus: "CREATE_COMPLETE", - Outputs: outputs, - }; -} - -describe("cdkStackName", () => { - test("matches the stack name emitted by the generated CDK app", () => { - expect(cdkStackName("order_service", "pre_prod")).toBe("AgentCore-order-service-pre-prod"); - }); -}); - -describe("deployedResourceId", () => { - test("resolves a Runtime ID by its stable CloudFormation export name", () => { - const deployed = stack([ - { - ExportName: "AgentCore-orders-default-checkout-agent-RuntimeId", - OutputValue: "checkout_agent-AbCdEf1234", - }, - ]); - - expect( - deployedResourceId(deployed, { - stackName: "AgentCore-orders-default", - targetName: "default", - resourceType: "runtime", - name: "checkout_agent", - }), - ).toBe("checkout_agent-AbCdEf1234"); - }); - - test("resolves a Harness ID by its stable CloudFormation export name", () => { - const deployed = stack([ - { - ExportName: "AgentCore-orders-default-Harness-support-agent-Id", - OutputValue: "support_agent-AbCdEf1234", - }, - ]); - - expect( - deployedResourceId(deployed, { - stackName: "AgentCore-orders-default", - targetName: "default", - resourceType: "harness", - name: "support_agent", - }), - ).toBe("support_agent-AbCdEf1234"); - }); - - test("fails when the selected resource has no deployed ID output", () => { - expect(() => - deployedResourceId(stack([]), { - stackName: "AgentCore-orders-pre-prod", - targetName: "pre-prod", - resourceType: "runtime", - name: "checkout", - }), - ).toThrow(/Runtime 'checkout'.*not deployed.*pre-prod/s); - }); -}); diff --git a/src/core/project/backends/cdk/deployment.ts b/src/core/project/backends/cdk/deployment.ts deleted file mode 100644 index 59adb4c9e..000000000 --- a/src/core/project/backends/cdk/deployment.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Stack } from "@aws-sdk/client-cloudformation"; -import { ProjectStateError } from "../../../../errors/errors"; -import type { ProjectInvokableResource } from "../../../../handlers/project/types"; -import type { CdkCredentialProvider } from "./toolkit"; - -export type DeployedStackReader = ( - stackName: string, - region: string, - credentials: CdkCredentialProvider, -) => Promise; - -function sanitizeName(name: string): string { - return name.replaceAll("_", "-"); -} - -export function cdkStackName(projectName: string, targetName: string): string { - return `AgentCore-${sanitizeName(projectName)}-${sanitizeName(targetName)}`; -} - -function resourceExportName( - stackName: string, - resourceType: ProjectInvokableResource, - name: string, -): string { - const resourceName = sanitizeName(name); - return resourceType === "runtime" - ? `${stackName}-${resourceName}-RuntimeId` - : `${stackName}-Harness-${resourceName}-Id`; -} - -export function deployedResourceId( - stack: Stack, - input: { - stackName: string; - targetName: string; - resourceType: ProjectInvokableResource; - name: string; - }, -): string { - const exportName = resourceExportName(input.stackName, input.resourceType, input.name); - const id = stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; - if (id) return id; - - const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; - throw new ProjectStateError( - `${label} '${input.name}' is not deployed to target '${input.targetName}'. ` + - `Run 'agentcore project deploy --target ${input.targetName}' first.`, - ); -} - -function isStackNotFound(error: unknown): boolean { - if (!error || typeof error !== "object") return false; - const candidate = error as { name?: unknown; message?: unknown }; - return ( - candidate.name === "ValidationError" && - typeof candidate.message === "string" && - /Stack with id .+ does not exist/i.test(candidate.message) - ); -} - -export const readDeployedStack: DeployedStackReader = async (stackName, region, credentials) => { - const { CloudFormationClient, DescribeStacksCommand } = - await import("@aws-sdk/client-cloudformation"); - const client = new CloudFormationClient({ credentials, region }); - try { - const response = await client.send(new DescribeStacksCommand({ StackName: stackName })); - return response.Stacks?.[0]; - } catch (error) { - if (isStackNotFound(error)) return undefined; - throw error; - } finally { - client.destroy(); - } -}; From beeed438f90017d4f60af20807a8fbe32fc7dfa7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 18:21:35 +0000 Subject: [PATCH 04/15] test(project): seed deployed state through shared helper --- src/core/project/backends/cdk.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 2f06eb0c9..992527927 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -5,10 +5,11 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import type { Stack } from "@aws-sdk/client-cloudformation"; import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; +import { FsReadWriteJson } from "../../../io"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { createSilentLogger } from "../../../testing"; import { CdkBackend } from "./cdk"; -import { DEPLOYED_STATE_RELATIVE_PATH } from "./cdk/deployedState"; +import { DEPLOYED_STATE_RELATIVE_PATH, updateTargetState } from "./cdk/deployedState"; import type { DeployBackendInput } from "./types"; import type { BootstrapState } from "./cdk/environment"; import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit"; @@ -20,6 +21,7 @@ const TARGET = { } as const; const STACK_ARN = "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc"; +const json = new FsReadWriteJson({ logger: createSilentLogger() }); /** A template holding only what CDK adds itself, as an empty project synthesizes. */ const METADATA_ONLY = { CDKMetadata: { Type: "AWS::CDK::Metadata" } }; @@ -115,10 +117,7 @@ async function writeAssembly( } async function writeDeployedState(input: Project, stackArn = STACK_ARN): Promise { - await writeFile( - join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH), - JSON.stringify({ targets: { [TARGET.name]: { stackArn } } }), - ); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn }); } type HarnessOptions = { From 28dbfbe48f2de12045888cad52f649e32d06815d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 25 Aug 2026 21:42:53 +0000 Subject: [PATCH 05/15] refactor(invoke): share Runtime and Harness operations --- src/handlers/harness/invoke/index.tsx | 36 ++++-------------- src/handlers/harness/invoke/operation.ts | 48 ++++++++++++++++++++++++ src/handlers/runtime/invoke/index.tsx | 47 ++++++++++++----------- src/handlers/runtime/invoke/operation.ts | 14 +++++++ src/handlers/runtime/invoke/request.ts | 5 ++- 5 files changed, 98 insertions(+), 52 deletions(-) create mode 100644 src/handlers/harness/invoke/operation.ts create mode 100644 src/handlers/runtime/invoke/operation.ts diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx index 034d33107..11a63781a 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -6,13 +6,7 @@ import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonKey } from "../../keys.tsx"; import { JsonRendererKey, renderTuiAt } from "../../../tui"; import { InputValidationError } from "../../../errors"; -import { - applyEvent, - finishTurn, - newSessionId, - newTurn, - type TranscriptItem, -} from "./transcript.tsx"; +import { invokeHarnessTurn } from "./operation.ts"; export const createInvokeHarnessHandler = (core: Core, io: AppIO) => createHandler({ @@ -54,33 +48,17 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) => } const opts = coreOptsFromCtx(ctx); - const detail = await core.harness.getHarness(flags["id"], opts); - const sessionId = flags["session-id"] ?? newSessionId(); - - const response = await core.harness.invokeHarness( + const result = await invokeHarnessTurn( + core.harness, { - harnessArn: detail.harness?.arn, + harnessId: flags["id"], + prompt: flags["prompt"], qualifier: flags["qualifier"] ?? "DEFAULT", - runtimeSessionId: sessionId, - messages: [{ role: "user", content: [{ text: flags["prompt"] }] }], + sessionId: flags["session-id"], }, opts, ); - - const turn = newTurn(); - for await (const event of response.stream ?? []) { - applyEvent(turn, event); - } - finishTurn(turn); - - const transcript: TranscriptItem[] = [{ kind: "user", text: flags["prompt"] }, ...turn.items]; - ctx.require(JsonRendererKey).renderJson({ - sessionId, - stopReason: turn.stopReason, - usage: turn.usage, - latencyMs: turn.latencyMs, - transcript, - }); + ctx.require(JsonRendererKey).renderJson(result); }, }); diff --git a/src/handlers/harness/invoke/operation.ts b/src/handlers/harness/invoke/operation.ts new file mode 100644 index 000000000..7b55bfec3 --- /dev/null +++ b/src/handlers/harness/invoke/operation.ts @@ -0,0 +1,48 @@ +import type { CoreOptions } from "../../../core/types"; +import type { CoreHarnessClient } from "../types"; +import { applyEvent, finishTurn, newSessionId, newTurn, type TranscriptItem } from "./transcript"; + +export type HarnessInvokeResult = { + sessionId: string; + stopReason?: string; + usage?: ReturnType["usage"]; + latencyMs?: number; + transcript: TranscriptItem[]; +}; + +export async function invokeHarnessTurn( + client: CoreHarnessClient, + input: { + harnessId: string; + prompt: string; + qualifier?: string; + sessionId?: string; + }, + options: CoreOptions, + signal?: AbortSignal, +): Promise { + const detail = await client.getHarness(input.harnessId, options); + const sessionId = input.sessionId ?? newSessionId(); + const response = await client.invokeHarness( + { + harnessArn: detail.harness?.arn, + qualifier: input.qualifier ?? "DEFAULT", + runtimeSessionId: sessionId, + messages: [{ role: "user", content: [{ text: input.prompt }] }], + }, + options, + signal, + ); + + const turn = newTurn(); + for await (const event of response.stream ?? []) applyEvent(turn, event); + finishTurn(turn); + + return { + sessionId, + stopReason: turn.stopReason, + usage: turn.usage, + latencyMs: turn.latencyMs, + transcript: [{ kind: "user", text: input.prompt }, ...turn.items], + }; +} diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index d19ad0103..147b476ea 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -8,7 +8,6 @@ import { JsonKey } from "../../keys"; import { ExitCode, withUserCancellation } from "../../../runnable"; import { renderTuiAt } from "../../../tui"; import { - normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, resolveRuntimeInvokeTuiBearerToken, @@ -16,6 +15,7 @@ import { } from "./request"; import { writeRuntimeInvokeResponse } from "./response"; import { RuntimeInvokeLaunchContextKey } from "./launchContext"; +import { invokeRuntimeTarget } from "./operation"; export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => createHandler({ @@ -114,27 +114,30 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => signal, ); const options = coreOptsFromCtx(ctx); - const runtime = await core.runtime.getRuntime(runtimeId, options, signal); - const request = normalizeRuntimeInvokeRequest(runtime, { - runtimeId, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }); - const response = await core.runtime.invokeRuntime(request, options, signal); + const response = await invokeRuntimeTarget( + core.runtime, + { + runtimeId, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }, + options, + signal, + ); await writeRuntimeInvokeResponse(response, { stdout: io.stdout, stderr: io.stderr, diff --git a/src/handlers/runtime/invoke/operation.ts b/src/handlers/runtime/invoke/operation.ts new file mode 100644 index 000000000..beab59e63 --- /dev/null +++ b/src/handlers/runtime/invoke/operation.ts @@ -0,0 +1,14 @@ +import type { CoreOptions } from "../../../core/types"; +import type { CoreRuntimeClient, RuntimeInvokeResponse } from "../types"; +import { normalizeRuntimeInvokeRequest, type RuntimeInvokeInput } from "./request"; + +export async function invokeRuntimeTarget( + client: CoreRuntimeClient, + input: RuntimeInvokeInput, + options: CoreOptions, + signal?: AbortSignal, +): Promise { + const runtime = await client.getRuntime(input.runtimeId, options, signal); + const request = normalizeRuntimeInvokeRequest(runtime, input); + return client.invokeRuntime(request, options, signal); +} diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 4da78c5ac..1633321a7 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -9,7 +9,10 @@ export const runtimeIdSchema = z .string() .refine((value) => !value.startsWith("arn:"), "must be a Runtime ID, not an ARN"); -type RuntimeInvokeInput = Omit & +export type RuntimeInvokeInput = Omit< + RuntimeInvokeRequest, + "accountId" | "qualifier" | "contentType" +> & Partial>; const CUSTOM_HEADER_PREFIX = "x-amzn-bedrock-agentcore-runtime-custom-"; From e003c91db894cbccfa90516e581d03d6ce2e95b8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 20:39:09 +0000 Subject: [PATCH 06/15] refactor(invoke): support embedded invoke consoles --- src/handlers/harness/invoke/screen.tsx | 3 +++ src/handlers/runtime/invoke/screen.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/handlers/harness/invoke/screen.tsx b/src/handlers/harness/invoke/screen.tsx index 9d2bc1a7f..a01fd1d62 100644 --- a/src/handlers/harness/invoke/screen.tsx +++ b/src/handlers/harness/invoke/screen.tsx @@ -78,6 +78,7 @@ export interface HarnessChatProps extends ScreenProps { // variant is the command hosting the chat: it names the breadcrumb and picks // the starting mode ("exec" starts in exec mode; "invoke" in chat mode). variant: "invoke" | "exec"; + onBack?: () => void; } // HarnessChat is the conversation view shared by `invoke` and `exec`: a @@ -92,6 +93,7 @@ export function HarnessChat({ initialSessionId, initialQualifier, variant, + onBack, }: HarnessChatProps) { const opts = coreOptsFromCtx(ctx); const { columns, rows } = useWindowSize(); @@ -278,6 +280,7 @@ export function HarnessChat({ } if (key.escape) { if (streamingRef.current) abortRef.current?.abort(); + else if (onBack) onBack(); else navigate(-1); return; } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index b73cc7862..6c7d123f9 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -140,20 +140,22 @@ export function RuntimeInvokeScreen(props: ScreenProps) { ); } -type RuntimeInvokeConsoleProps = ScreenProps & { +export type RuntimeInvokeConsoleProps = ScreenProps & { runtimeId: string; qualifier: string; initialContext?: RuntimeInvokeLaunchContext; returnOnEscape?: boolean; + onBack?: () => void; }; -function RuntimeInvokeConsole({ +export function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier, initialContext, returnOnEscape, + onBack, }: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); @@ -307,6 +309,7 @@ function RuntimeInvokeConsole({ } if (key.escape) { if (abortRef.current) abortRef.current.abort(); + else if (onBack) onBack(); else if (returnOnEscape) navigate(-1); else setTargetPicker({ stage: "endpoint", runtimeId: target.runtimeId }); return; From c670e91c599842aea79b60a705cc2c2414178f88 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 19:56:29 +0000 Subject: [PATCH 07/15] feat(project): add Runtime and Harness invoke commands --- src/handlers/project/invoke/harness.tsx | 68 ++++++ src/handlers/project/invoke/index.test.tsx | 268 +++++++++++++++++++++ src/handlers/project/invoke/index.tsx | 28 +++ src/handlers/project/invoke/runtime.tsx | 151 ++++++++++++ src/handlers/project/invoke/selection.ts | 34 +++ 5 files changed, 549 insertions(+) create mode 100644 src/handlers/project/invoke/harness.tsx create mode 100644 src/handlers/project/invoke/index.test.tsx create mode 100644 src/handlers/project/invoke/index.tsx create mode 100644 src/handlers/project/invoke/runtime.tsx create mode 100644 src/handlers/project/invoke/selection.ts diff --git a/src/handlers/project/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx new file mode 100644 index 000000000..b11c33f0b --- /dev/null +++ b/src/handlers/project/invoke/harness.tsx @@ -0,0 +1,68 @@ +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import type { AppIO } from "../../../io"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import { invokeHarnessTurn } from "../../harness/invoke/operation"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { selectProjectResource } from "./selection"; + +export const createProjectInvokeHarnessHandler = ( + core: Core, + io: AppIO, + renderInvokeTui: typeof renderTuiAt = renderTuiAt, +) => + createHandler({ + name: "harness", + description: "invoke a Harness from the current project", + flags: [ + flag("name", "the logical project Harness name", z.string().optional()), + flag("target", "project deployment target", z.string().default("default")), + flag("prompt", "the message to send to the Harness", z.string().optional()), + flag( + "session-id", + "the Runtime session ID to continue (33-100 characters)", + z.string().min(33).max(100).optional(), + ), + flag( + "qualifier", + "the Harness endpoint qualifier to invoke (default DEFAULT)", + z.string().optional(), + ), + ], + handle: async (ctx, flags) => { + const project = ctx.require(ProjectKey); + const name = selectProjectResource(project, "harness", flags.name); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "harness", + name, + }); + const invokeCtx = ctx.withValue(RegionKey, deployed.target.region); + + if (!flags.prompt) { + if (invokeCtx.require(JsonKey)) { + throw new InputValidationError("required option '--prompt ' not specified"); + } + let path = `/agentcore/harness/invoke/${encodeURIComponent(deployed.id)}`; + if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; + if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; + await renderInvokeTui(path, invokeCtx, core, io); + return; + } + + const result = await invokeHarnessTurn( + core.harness, + { + harnessId: deployed.id, + prompt: flags.prompt, + qualifier: flags.qualifier, + sessionId: flags["session-id"], + }, + coreOptsFromCtx(invokeCtx), + ); + invokeCtx.require(JsonRendererKey).renderJson(result); + }, + }); diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx new file mode 100644 index 000000000..6fd0d1896 --- /dev/null +++ b/src/handlers/project/invoke/index.test.tsx @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { InvokeHarnessRequest } from "@aws-sdk/client-bedrock-agentcore"; +import type { + GetAgentRuntimeResponse, + GetHarnessResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { ProjectBackend, ResolveDeployedResourceBackendInput } from "../../../core/project"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { ProjectKey, ValueContext, type Context } from "../../../router"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { createRootHandler } from "../../index"; +import { JsonKey, RegionKey } from "../../keys"; +import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; +import type { RuntimeInvokeRequest } from "../../runtime/types"; +import type { Project } from "../types"; +import { createProjectInvokeHandler } from "."; +import { createProjectInvokeHarnessHandler } from "./harness"; +import { createProjectInvokeRuntimeHandler } from "./runtime"; + +const originalCwd = process.cwd(); +const temporaryDirectories: string[] = []; + +const TARGET = { + name: "default", + account: "111122223333", + region: "eu-west-1", +} as const; +const RUNTIME_ID = "checkout-AbCdEf1234"; +const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:runtime/${RUNTIME_ID}`; +const HARNESS_ID = "support-AbCdEf1234"; +const HARNESS_ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:harness/${HARNESS_ID}`; +const RUNTIME = { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", +} as const; +const HARNESS = { name: "support", path: "app/support" } as const; + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +async function inProject(resources: { + runtimes?: unknown[]; + harnesses?: unknown[]; +}): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-project-invoke-reduced-")); + temporaryDirectories.push(root); + await mkdir(join(root, "agentcore"), { recursive: true }); + const spec = ProjectSpecSchema.parse({ + name: "orders", + version: 1, + runtimes: resources.runtimes ?? [], + harnesses: resources.harnesses ?? [], + }); + await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); + await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify([TARGET])); + process.chdir(root); +} + +function backend() { + const calls: ResolveDeployedResourceBackendInput[] = []; + const value: ProjectBackend = { + async *build() {}, + async *deploy() { + yield* []; + return { outputs: {} }; + }, + async resolveDeployedResource(_project, input) { + calls.push(input); + return input.resourceType === "runtime" ? RUNTIME_ID : HARNESS_ID; + }, + }; + return { calls, value }; +} + +function configureCore(core: TestCoreClient): void { + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: body(Buffer.from("runtime response")), + }); + core.harness + .setGetResponse({ + harness: { harnessId: HARNESS_ID, harnessName: "support", arn: HARNESS_ARN }, + } as GetHarnessResponse) + .setInvokeEvents( + { messageStart: { role: "assistant" } }, + { contentBlockDelta: { contentBlockIndex: 0, delta: { text: "harness response" } } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: "end_turn" } }, + ); +} + +async function run(args: string[], resources: { runtimes?: unknown[]; harnesses?: unknown[] }) { + await inProject(resources); + const resolved = backend(); + const core = new TestCoreClient({ backends: { CDK: resolved.value } }); + configureCore(core); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", "project", "invoke", ...args]); + return { core, io, resolved }; +} + +function context(project: Project): Context { + return ValueContext.EmptyContext() + .withValue(ProjectKey, project) + .withValue(JsonKey, false) + .withValue(RegionKey, "us-east-1"); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("project invoke", () => { + test("invokes the sole Runtime with its existing payload contract in the target region", async () => { + const payload = '{"custom":"wire shape"}'; + const { core, io, resolved } = await run( + ["runtime", "--payload", payload, "--content-type", "application/custom+json"], + { runtimes: [RUNTIME] }, + ); + + const request = core.runtime.calls.find(({ method }) => method === "invokeRuntime")! + .args[0] as RuntimeInvokeRequest; + expect(new TextDecoder().decode(request.payload)).toBe(payload); + expect(request.contentType).toBe("application/custom+json"); + expect(core.runtime.calls.at(-1)!.args[1]).toEqual({ region: TARGET.region }); + expect(io.stdout()).toBe("runtime response"); + expect(resolved.calls).toEqual([{ target: TARGET, resourceType: "runtime", name: "checkout" }]); + }); + + test("invokes a named Harness with its existing prompt contract in the target region", async () => { + const { core, io } = await run(["harness", "--name", "support", "--prompt", "hello"], { + harnesses: [HARNESS], + }); + + const request = core.harness.calls.find(({ method }) => method === "invokeHarness")! + .args[0] as InvokeHarnessRequest; + expect(request).toMatchObject({ + harnessArn: HARNESS_ARN, + qualifier: "DEFAULT", + messages: [{ role: "user", content: [{ text: "hello" }] }], + }); + expect(core.harness.calls.at(-1)!.args[1]).toEqual({ region: TARGET.region }); + expect(JSON.parse(io.stdout()).transcript).toContainEqual({ + kind: "text", + text: "harness response", + streaming: false, + }); + }); + + test("requires --name when the project has multiple Runtimes", async () => { + await expect( + run(["runtime", "--payload", "{}"], { + runtimes: [RUNTIME, { ...RUNTIME, name: "inventory" }], + }), + ).rejects.toThrow(/multiple Runtimes.*--name.*checkout, inventory/s); + }); + + test("opens the existing Runtime TUI with the resolved project Runtime", async () => { + await inProject({ runtimes: [RUNTIME] }); + const resolved = backend(); + const core = new TestCoreClient({ backends: { CDK: resolved.value } }); + const project = await core.projectManager.resolve({ filePath: process.cwd() }); + const launches: { path: string; context: Context }[] = []; + const handler = createProjectInvokeRuntimeHandler(core, testIO().io, async (path, ctx) => { + launches.push({ path, context: ctx }); + }); + + await handler.handle( + context(project!), + { + name: "checkout", + target: "default", + payload: undefined, + qualifier: undefined, + "content-type": undefined, + accept: undefined, + "session-id": "project-session", + "user-id": undefined, + header: undefined, + "bearer-token": undefined, + "mcp-session-id": undefined, + "mcp-protocol-version": undefined, + "mcp-method": undefined, + "mcp-name": undefined, + "trace-id": undefined, + "trace-parent": undefined, + "trace-state": undefined, + baggage: undefined, + "output-file": undefined, + }, + {}, + ); + + expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}`); + expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); + expect(launches[0]!.context.require(RuntimeInvokeLaunchContextKey)).toMatchObject({ + runtimeId: RUNTIME_ID, + runtimeSessionId: "project-session", + }); + }); + + test("opens the existing Harness TUI with the resolved project Harness", async () => { + await inProject({ harnesses: [HARNESS] }); + const resolved = backend(); + const core = new TestCoreClient({ backends: { CDK: resolved.value } }); + const project = await core.projectManager.resolve({ filePath: process.cwd() }); + const launches: { path: string; context: Context }[] = []; + const handler = createProjectInvokeHarnessHandler(core, testIO().io, async (path, ctx) => { + launches.push({ path, context: ctx }); + }); + + await handler.handle( + context(project!), + { + name: "support", + target: "default", + prompt: undefined, + "session-id": undefined, + qualifier: "prod", + }, + {}, + ); + + expect(launches[0]!.path).toBe(`/agentcore/harness/invoke/${HARNESS_ID}?qualifier=prod`); + expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); + }); + + test("bare project invoke opens the project resource picker", async () => { + await inProject({ runtimes: [RUNTIME], harnesses: [HARNESS] }); + const core = new TestCoreClient(); + const project = await core.projectManager.resolve({ filePath: process.cwd() }); + const launches: string[] = []; + const handler = createProjectInvokeHandler(core, testIO().io, async (path) => { + launches.push(path); + }); + + await handler.defaultHandler()!.handle(context(project!), {}, {}); + + expect(launches).toEqual(["/agentcore/project/invoke"]); + }); +}); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx new file mode 100644 index 000000000..5c996cc48 --- /dev/null +++ b/src/handlers/project/invoke/index.tsx @@ -0,0 +1,28 @@ +import type { AppIO } from "../../../io"; +import { InputValidationError } from "../../../errors"; +import { Router } from "../../../router"; +import { renderTuiAt } from "../../../tui"; +import { withProject } from "../../../middleware"; +import { JsonKey } from "../../keys"; +import type { Core } from "../../types"; +import { createProjectInvokeHarnessHandler } from "./harness"; +import { createProjectInvokeRuntimeHandler } from "./runtime"; + +export function createProjectInvokeHandler( + core: Core, + io: AppIO, + renderInvokeTui: typeof renderTuiAt = renderTuiAt, +): Router { + return new Router("invoke", "invoke a Runtime or Harness from the current project") + .use(withProject({ projectManager: core.projectManager })) + .handler(createProjectInvokeRuntimeHandler(core, io, renderInvokeTui)) + .handler(createProjectInvokeHarnessHandler(core, io, renderInvokeTui)) + .default((ctx) => { + if (ctx.require(JsonKey)) { + throw new InputValidationError( + "a Runtime or Harness invoke subcommand is required with --json", + ); + } + return renderInvokeTui("/agentcore/project/invoke", ctx, core, io); + }); +} diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx new file mode 100644 index 000000000..a5dcaab05 --- /dev/null +++ b/src/handlers/project/invoke/runtime.tsx @@ -0,0 +1,151 @@ +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import type { AppIO } from "../../../io"; +import { ExitCode, withUserCancellation } from "../../../runnable"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { renderTuiAt } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; +import { invokeRuntimeTarget } from "../../runtime/invoke/operation"; +import { + parseRuntimeInvokeHeaders, + resolveRuntimeInvokeSources, + resolveRuntimeInvokeTuiBearerToken, +} from "../../runtime/invoke/request"; +import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { selectProjectResource } from "./selection"; + +export const createProjectInvokeRuntimeHandler = ( + core: Core, + io: AppIO, + renderInvokeTui: typeof renderTuiAt = renderTuiAt, +) => + createHandler({ + name: "runtime", + description: "invoke a Runtime from the current project", + flags: [ + flag("name", "the logical project Runtime name", z.string().optional()), + flag("target", "project deployment target", z.string().default("default")), + flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), + flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("session-id", "the Runtime session ID", z.string().optional()), + flag("user-id", "the Runtime user ID", z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag("mcp-method", "the MCP method", z.string().optional()), + flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), + flag("trace-id", "the X-Ray trace ID", z.string().optional()), + flag("trace-parent", "the W3C trace parent", z.string().optional()), + flag("trace-state", "the W3C trace state", z.string().optional()), + flag("baggage", "the W3C baggage", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), + ], + handle: async (ctx, flags) => { + const project = ctx.require(ProjectKey); + const name = selectProjectResource(project, "runtime", flags.name); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "runtime", + name, + }); + const invokeCtx = ctx.withValue(RegionKey, deployed.target.region); + + if (flags.payload === undefined) { + const hasHeadlessOnlyFlag = Object.entries(flags).some( + ([flagName, value]) => + ![ + "name", + "target", + "qualifier", + "payload", + "session-id", + "user-id", + "header", + "bearer-token", + ].includes(flagName) && value !== undefined, + ); + if (invokeCtx.require(JsonKey) || hasHeadlessOnlyFlag) { + throw new InputValidationError("required option '--payload ' not specified", { + exitCode: ExitCode.USAGE, + }); + } + let path = `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}`; + if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const bearerToken = await resolveRuntimeInvokeTuiBearerToken( + flags["bearer-token"], + io.stdin, + ); + await renderInvokeTui( + path, + invokeCtx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: deployed.id, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken, + }), + core, + io, + ); + return; + } + + if (invokeCtx.require(JsonKey) && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } + await withUserCancellation(async (signal) => { + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload!, bearerToken: flags["bearer-token"] }, + io.stdin, + signal, + ); + const response = await invokeRuntimeTarget( + core.runtime, + { + runtimeId: deployed.id, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }, + coreOptsFromCtx(invokeCtx), + signal, + ); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: invokeCtx.require(JsonKey), + signal, + }); + }); + }, + }); diff --git a/src/handlers/project/invoke/selection.ts b/src/handlers/project/invoke/selection.ts new file mode 100644 index 000000000..b0ec3275e --- /dev/null +++ b/src/handlers/project/invoke/selection.ts @@ -0,0 +1,34 @@ +import { InputValidationError, ResourceNotFoundError } from "../../../errors"; +import type { Project, ProjectInvokableResource } from "../types"; + +export function projectResourceNames( + project: Project, + resourceType: ProjectInvokableResource, +): string[] { + return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( + ({ name }) => name, + ); +} + +export function selectProjectResource( + project: Project, + resourceType: ProjectInvokableResource, + name: string | undefined, +): string { + const names = projectResourceNames(project, resourceType); + const label = resourceType === "runtime" ? "Runtime" : "Harness"; + + if (name !== undefined) { + if (names.includes(name)) return name; + throw new ResourceNotFoundError( + `${label} '${name}' was not found. Available ${label}s: ${names.join(", ") || "none"}.`, + ); + } + if (names.length === 1) return names[0]!; + if (names.length === 0) { + throw new InputValidationError(`This project has no ${label}s to invoke.`); + } + throw new InputValidationError( + `Project has multiple ${label}s. Specify --name: ${names.join(", ")}.`, + ); +} From 78b50162401ba46b49618ef2e5838e31f7943dd0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 19:56:39 +0000 Subject: [PATCH 08/15] feat(project): add invoke resource picker --- src/components/Root.tsx | 5 + .../project/invoke/invoke.screen.test.tsx | 109 +++++++++++++ src/handlers/project/invoke/screen.tsx | 148 ++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 src/handlers/project/invoke/invoke.screen.test.tsx create mode 100644 src/handlers/project/invoke/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 2c87399d9..592f01380 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -107,6 +107,7 @@ import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx"; import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; +import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -140,6 +141,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { } /> + } + /> } /> {/* Bare `get` (no id) has nothing to show — send the user to the list. */} ({ + id: input.resourceType === "runtime" ? "runtime-123" : "harness-123", + target: { name: "default", account: "111122223333", region: "eu-west-1" }, + }); + value.runtime + .setListEndpointsResponse({ runtimeEndpoints: [endpoint("DEFAULT")] }) + .setGetResponse({ + agentRuntimeArn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime/runtime-123", + } as GetAgentRuntimeResponse); + value.harness.setGetResponse({ + harness: { + harnessId: "harness-123", + harnessName: "support", + arn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:harness/harness-123", + }, + } as GetHarnessResponse); + return value; +} + +describe("project invoke picker", () => { + test("lists project Runtime and Harness resources", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + expect(screen.lastFrame()).toContain("Runtime"); + expect(screen.lastFrame()).toContain("HTTP"); + expect(screen.lastFrame()).toContain("app/checkout"); + expect(screen.lastFrame()).toContain("support"); + expect(screen.lastFrame()).toContain("Harness"); + expect(screen.lastFrame()).toContain("app/support"); + }); + + test("opens the selected Harness chat in the same TUI", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core(), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "send a message…"); + expect(screen.lastFrame()).toContain("harness-123"); + }); + + test("uses the existing Runtime endpoint picker before its JSON console", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core(), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("return"); + await waitForText(screen.lastFrame, "DEFAULT"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + expect(screen.lastFrame()).not.toContain("Enter prompt"); + }); +}); diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx new file mode 100644 index 000000000..3f5ae3080 --- /dev/null +++ b/src/handlers/project/invoke/screen.tsx @@ -0,0 +1,148 @@ +import { useMemo, useState } from "react"; +import { Box, Text, useApp } from "ink"; +import { Layout } from "../../../components/Layout"; +import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; +import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; +import { Spinner } from "../../../components/ui/spinner"; +import { ProjectKey, type Context } from "../../../router"; +import { HarnessChat } from "../../harness/invoke/screen"; +import { RegionKey } from "../../keys"; +import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; +import type { ScreenProps } from "../../types"; + +type ProjectInvokableRow = Record & { + resourceType: "runtime" | "harness"; + type: "Runtime" | "Harness"; + name: string; + protocol: string; + source: string; +}; + +const columns = [ + { key: "type", header: "type", width: 10 }, + { key: "name", header: "name", flex: true }, + { key: "protocol", header: "protocol", width: 10 }, + { key: "source", header: "source", width: 24 }, +] satisfies DataTableColumn[]; + +type Destination = + | { resourceType: "runtime"; id: string; ctx: Context; qualifier?: string } + | { resourceType: "harness"; id: string; ctx: Context }; + +export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { + const { exit } = useApp(); + const project = ctx.require(ProjectKey); + const [destination, setDestination] = useState(); + const [resolving, setResolving] = useState(); + const [error, setError] = useState(); + const rows = useMemo( + () => [ + ...project.spec.runtimes.map(({ name, protocol, codeLocation }) => ({ + resourceType: "runtime" as const, + type: "Runtime" as const, + name, + protocol: protocol ?? "HTTP", + source: codeLocation, + })), + ...project.spec.harnesses.map(({ name, path }) => ({ + resourceType: "harness" as const, + type: "Harness" as const, + name, + protocol: "-", + source: path, + })), + ], + [project], + ); + + const select = async (row: ProjectInvokableRow) => { + if (resolving) return; + setError(undefined); + setResolving(row.name); + try { + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: "default", + resourceType: row.resourceType, + name: row.name, + }); + setDestination({ + resourceType: row.resourceType, + id: deployed.id, + ctx: ctx.withValue(RegionKey, deployed.target.region), + }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setResolving(undefined); + } + }; + + if (destination?.resourceType === "runtime") { + if (!destination.qualifier) { + return ( + setDestination({ ...destination, qualifier })} + onEscape={() => setDestination(undefined)} + /> + ); + } + return ( + setDestination(undefined)} + /> + ); + } + + if (destination?.resourceType === "harness") { + return ( + setDestination(undefined)} + /> + ); + } + + return ( + + + {error ? {error} : null} + void select(row)} + onEscape={exit} + /> + {resolving ? : null} + + + ); +} From 5530ae849b3bee181df5752933af0de0dee63f8c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 19:57:13 +0000 Subject: [PATCH 09/15] feat(project): register invoke commands --- src/handlers/index.tsx | 2 +- src/handlers/project/index.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 87def85c6..0429b2199 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -50,7 +50,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); - root.handler(createProjectHandler({ projectManager: core.projectManager, io })); + root.handler(createProjectHandler(core, { projectManager: core.projectManager, io })); // Invoking with no subcommand launches the interactive TUI. root.default(renderTui(core, io)); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 774a2ca39..55bc84140 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -14,13 +14,15 @@ import { createStatusProjectHandler } from "./status"; import { createBuildProjectHandler } from "./build"; import type { ProjectManager } from "./types"; import { createAddProjectResourceHandler } from "./add"; +import { createProjectInvokeHandler } from "./invoke"; +import type { Core } from "../types"; type ProjectHandlerConfig = { projectManager: ProjectManager; io: AppIO; }; -export function createProjectHandler(config: ProjectHandlerConfig): Router { +export function createProjectHandler(core: Core, config: ProjectHandlerConfig): Router { const project = new Router("project", "manage an AgentCore project"); project.handler( @@ -57,6 +59,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }), ), ); + project.handler(createProjectInvokeHandler(core, config.io)); project.handler(createStatusProjectHandler()); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. From 2a8500709344c3418a9389188d60b30b95a6acce Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 19:57:27 +0000 Subject: [PATCH 10/15] docs(project): document project-aware invoke --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index f09c95470..dba3bf51a 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,17 @@ agentcore # interactive TUI │ ├── get # get an evaluator by id (type-agnostic) │ ├── list # list evaluators (server-side paginated) │ └── delete # delete an evaluator by id +├── project # manage an AgentCore project +│ ├── create # create a project +│ ├── add # add project resources +│ ├── remove # remove project resources +│ ├── dev # run the project locally +│ ├── deploy # deploy the project +│ ├── invoke # invoke a deployed project resource +│ │ ├── runtime # use the existing Runtime invoke experience +│ │ └── harness # use the existing Harness invoke experience +│ ├── status # inspect deployed project resources +│ └── build # synthesize deployable artifacts └── config # read/write global config values ``` @@ -116,6 +127,26 @@ Global flags (declared at the root, available on every command): | `--debug` | Debug logging. | | `--endpoint-url` | Override the service endpoint URL (e.g. for testing against a stub). | +### Invoke a project resource + +Run `agentcore project invoke` from inside a project to choose a deployed +Runtime or Harness interactively. Headless invocation keeps each resource's +existing input contract: + +```bash +agentcore project invoke runtime \ + --name checkout \ + --payload '{"prompt":"Check order 123."}' \ + --content-type application/json + +agentcore project invoke harness \ + --name support \ + --prompt "Help with my account." +``` + +Use `--target` to select a deployment target. When a project declares exactly +one resource of the requested type, `--name` may be omitted. + ### Examples ```bash From 95ac22cf15ee7612d29d7e5d991d96e3558df320 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 19:57:38 +0000 Subject: [PATCH 11/15] docs(templates): document deployed Runtime invoke --- .../templates/hello-world-python-container/README.md | 6 ++++++ src/assets/templates/hello-world-python/README.md | 8 +++++--- src/assets/templates/strands-http-python/README.md | 6 ++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/assets/templates/hello-world-python-container/README.md b/src/assets/templates/hello-world-python-container/README.md index 637390bb0..190d41163 100644 --- a/src/assets/templates/hello-world-python-container/README.md +++ b/src/assets/templates/hello-world-python-container/README.md @@ -25,3 +25,9 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore project deploy ``` + +Invoke the deployed Runtime with its native payload: + +```bash +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md index b43ddbbba..d2cadd33d 100644 --- a/src/assets/templates/hello-world-python/README.md +++ b/src/assets/templates/hello-world-python/README.md @@ -27,9 +27,6 @@ curl -X POST http://localhost:8080/invocations \ -d '{"prompt": "Hello!"}' ``` - - ## Build your agent Start in `main.py`: @@ -58,3 +55,8 @@ for multi-agent patterns, MCP tools, and model configuration. Deploy from the project root with the AgentCore CLI; the CDK app under `agentcore/cdk` provisions the Runtime that hosts this agent. + +```bash +agentcore project deploy +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` diff --git a/src/assets/templates/strands-http-python/README.md b/src/assets/templates/strands-http-python/README.md index eafaa1ec0..5714aafbf 100644 --- a/src/assets/templates/strands-http-python/README.md +++ b/src/assets/templates/strands-http-python/README.md @@ -38,3 +38,9 @@ Command Prompt, or `.\.venv\Scripts\activate.ps1` in Windows PowerShell. # Deployment After providing credentials, `agentcore project deploy` will deploy your project into Amazon Bedrock AgentCore. + +Invoke the deployed Runtime with its native payload: + +```bash +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` From b8f1965fdc0472f5b0abfd832300defeb3b99cd5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 21:11:50 +0000 Subject: [PATCH 12/15] test(project): inline deployed state setup --- src/core/project/backends/cdk.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 992527927..9af307045 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -116,10 +116,6 @@ async function writeAssembly( ); } -async function writeDeployedState(input: Project, stackArn = STACK_ARN): Promise { - await updateTargetState(json, input.rootPath, TARGET.name, { stackArn }); -} - type HarnessOptions = { account?: string; bootstrap?: BootstrapState; @@ -599,7 +595,7 @@ describe("CdkBackend.resolveDeployedResource", () => { "reads deployed state and resolves a $resourceType ID from its live stack", async (example) => { const input = await project(); - await writeDeployedState(input); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); const subject = harness({ stack: { StackName: "AgentCore-example-default", @@ -649,7 +645,7 @@ describe("CdkBackend.resolveDeployedResource", () => { test("fails actionably when the recorded stack no longer exists", async () => { const input = await project(); - await writeDeployedState(input); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); const subject = harness(); await expect( @@ -664,7 +660,7 @@ describe("CdkBackend.resolveDeployedResource", () => { test("fails when the live stack has no output for the selected resource", async () => { const input = await project(); - await writeDeployedState(input); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); const subject = harness({ stack: { StackName: "AgentCore-example-default", @@ -685,7 +681,7 @@ describe("CdkBackend.resolveDeployedResource", () => { test("rejects the wrong account before reading CloudFormation", async () => { const input = await project(); - await writeDeployedState(input); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); const subject = harness({ account: "999900001111" }); await expect( From 82510e40f050f343f642a864eba4a56c6a7cb8b4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 21:24:09 +0000 Subject: [PATCH 13/15] refactor(project): clarify deployed resource lookup --- src/core/project/backends/cdk.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 776360e30..d96e64085 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -48,20 +48,16 @@ import { describeStack } from "./cdk/stackReader"; type StackDescriber = typeof describeStack; -function sanitizeName(name: string): string { - return name.replaceAll("_", "-"); -} - -function deployedResourceId( +function findDeployedResourceId( stack: Stack, input: ResolveDeployedResourceBackendInput, ): string | undefined { if (!stack.StackName) return undefined; - const resourceName = sanitizeName(input.name); + const exportResourceName = input.name.replaceAll("_", "-"); const exportName = input.resourceType === "runtime" - ? `${stack.StackName}-${resourceName}-RuntimeId` - : `${stack.StackName}-Harness-${resourceName}-Id`; + ? `${stack.StackName}-${exportResourceName}-RuntimeId` + : `${stack.StackName}-Harness-${exportResourceName}-Id`; return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; } @@ -286,7 +282,7 @@ export class CdkBackend implements ProjectBackend { ); } - const id = deployedResourceId(stack, input); + const id = findDeployedResourceId(stack, input); if (id) return id; const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; From 5f2ac62d6af4bf2dd76def8dc81faa79b9fba5a3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 21:40:35 +0000 Subject: [PATCH 14/15] refactor(project): clarify target credential helper --- src/core/project/backends/cdk.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index d96e64085..8f3895323 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -140,7 +140,7 @@ export class CdkBackend implements ProjectBackend { ): AsyncGenerator { const { target } = input; yield { message: `Verifying AWS account ${target.account}` }; - const credentials = await this.credentialsFor(target); + const credentials = await this.credentialsForTarget(target); // Validate any existing deployed state before mutating AWS. A malformed file // must fail here — not after bootstrap/deploy — so we never leave AWS changed @@ -273,7 +273,7 @@ export class CdkBackend implements ProjectBackend { ); } - const credentials = await this.credentialsFor(target); + const credentials = await this.credentialsForTarget(target); const stack = await this.describeStack(target.region, credentials, stackArn); if (!stack) { throw new ProjectStateError( @@ -292,7 +292,7 @@ export class CdkBackend implements ProjectBackend { ); } - private async credentialsFor(target: AwsDeploymentTarget) { + private async credentialsForTarget(target: AwsDeploymentTarget) { const credentials = await this.resolveCredentials(target.region); const account = await this.resolveAccount(target.region, credentials); if (account !== target.account) { From 98f2da8ef91b927485fc136616b0e95e03ae8ed3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 22:54:08 +0000 Subject: [PATCH 15/15] test(project): remove redundant resource resolution tests --- src/core/project/manager.test.ts | 91 -------------------------------- 1 file changed, 91 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index d57fd9880..6024b16d6 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -488,97 +488,6 @@ describe("FsProjectManager.deploy", () => { }); }); -describe("FsProjectManager.resolveDeployedResource", () => { - const targets: AwsDeploymentTarget[] = [ - { - name: "default", - account: "111122223333", - region: "us-east-1", - }, - { - name: "prod", - account: "444455556666", - region: "eu-west-1", - }, - ]; - - async function projectWithTargets(rootPath: string): Promise { - await mkdir(join(rootPath, "agentcore"), { recursive: true }); - await writeFile(join(rootPath, "agentcore", "aws-targets.json"), JSON.stringify(targets)); - return { - name: "example", - rootPath, - spec: ProjectSpecSchema.parse({ name: "example", version: 1 }), - }; - } - - test("resolves the target and delegates physical ID lookup to the project backend", async () => { - const root = await inTempDirectory(); - const project = await projectWithTargets(root); - const calls: unknown[] = []; - const backend = { - async *build() {}, - async *deploy() { - yield* []; - return { outputs: {} }; - }, - async resolveDeployedResource(inputProject: Project, input: unknown) { - calls.push({ project: inputProject, input }); - return "runtime-123"; - }, - } as ProjectBackend; - const subject = new FsProjectManager({ - logger: createSilentLogger(), - backends: { CDK: backend }, - }); - - const resolved = await subject.resolveDeployedResource(project, { - target: "prod", - resourceType: "runtime", - name: "checkout", - }); - - expect(resolved).toEqual({ id: "runtime-123", target: targets[1]! }); - expect(calls).toEqual([ - { - project, - input: { - target: targets[1], - resourceType: "runtime", - name: "checkout", - }, - }, - ]); - }); - - test("rejects an unknown target before invoking the backend", async () => { - const root = await inTempDirectory(); - const project = await projectWithTargets(root); - const backend = { - async *build() {}, - async *deploy() { - yield* []; - return { outputs: {} }; - }, - async resolveDeployedResource() { - throw new Error("backend should not be called"); - }, - } as ProjectBackend; - const subject = new FsProjectManager({ - logger: createSilentLogger(), - backends: { CDK: backend }, - }); - - await expect( - subject.resolveDeployedResource(project, { - target: "missing", - resourceType: "harness", - name: "support", - }), - ).rejects.toThrow(/no deployment target named 'missing'.*default, prod/s); - }); -}); - describe("FsProjectManager.resolve", () => { test("round-trips a project it just created", async () => { const root = await inTempDirectory();