From 0b90d8a755c3865bfaab3a559997268ed7881350 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 25 Aug 2026 21:42:42 +0000 Subject: [PATCH 01/39] 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 | 17 +++- src/core/project/index.tsx | 6 +- src/core/project/manager.test.ts | 94 +++++++++++++++++++ src/core/project/manager.tsx | 31 +++++- src/handlers/project/deploy/index.test.ts | 3 + src/handlers/project/types.ts | 20 ++++ 10 files changed, 423 insertions(+), 15 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 288bb2199..17bd113dd 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"; @@ -85,6 +86,7 @@ type HarnessOptions = { template?: boolean; failOperation?: CdkOperation["kind"]; bootstrapError?: Error; + stack?: Stack; }; function harness(options: HarnessOptions = {}) { @@ -95,6 +97,8 @@ function harness(options: HarnessOptions = {}) { const bootstrapCredentials: CdkCredentialProvider[] = []; const accountRegions: string[] = []; const bootstrapRegions: string[] = []; + const stackReads: { stackName: string; region: string; credentials: CdkCredentialProvider }[] = + []; let templateLoads = 0; let templateCleanups = 0; const credentials: CdkCredentialProvider = async () => ({ @@ -152,6 +156,10 @@ function harness(options: HarnessOptions = {}) { }, }; }, + readStack: async (stackName, region, provider) => { + stackReads.push({ stackName, region, credentials: provider }); + return options.stack; + }, }); return { @@ -164,6 +172,7 @@ function harness(options: HarnessOptions = {}) { credentialRegions, credentials, runs, + stackReads, templateLoads: () => templateLoads, templateCleanups: () => templateCleanups, }; @@ -404,3 +413,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 ee160263e..eb1df5ba6 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 { stackArtifactIdForTarget } from "./cdk/assembly"; import { readDeployedState, updateTargetState } from "./cdk/deployedState"; import { @@ -27,6 +32,12 @@ import { type CdkCredentialResolver, type CdkRunner, } from "./cdk/toolkit"; +import { + cdkStackName, + deployedResourceId, + readDeployedStack, + type DeployedStackReader, +} from "./cdk/deployment"; export type CdkBackendConfig = { logger: Logger; @@ -38,6 +49,7 @@ export type CdkBackendConfig = { bootstrap?: BootstrapProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; + readStack?: DeployedStackReader; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -51,6 +63,7 @@ export class CdkBackend implements ProjectBackend { private readonly bootstrap: BootstrapProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; + private readonly readStack: DeployedStackReader; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -63,6 +76,7 @@ export class CdkBackend implements ProjectBackend { this.bootstrap = config.bootstrap ?? probeBootstrap; this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; + this.readStack = config.readStack ?? readDeployedStack; } public async *build(project: Project): AsyncGenerator { @@ -92,14 +106,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 @@ -163,6 +170,36 @@ export class CdkBackend implements ProjectBackend { return { outputs }; } + 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 eebeccf29..bd26046a8 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -1,4 +1,9 @@ -import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; +import type { + DeployResult, + Project, + ProjectEvent, + ProjectInvokableResource, +} from "../../../handlers/project/types"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; export type DeployBackendInput = { @@ -6,8 +11,18 @@ export type DeployBackendInput = { target: AwsDeploymentTarget; }; +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 45997ff8e..0b9e045c8 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -354,6 +354,9 @@ describe("FsProjectManager.deploy", () => { yield { message: "Backend deployment started" }; return { outputs: { RuntimeArn: "arn:runtime" } }; }, + async resolveDeployedResource() { + return "unused"; + }, }; return { calls, @@ -477,6 +480,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 042c6a13a..081855873 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"; @@ -452,6 +457,24 @@ 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 }); + } + + 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( @@ -469,15 +492,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 }); + 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 6384195b7..afdd29110 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -40,6 +40,9 @@ function fakeBackend(result: DeployResult, events: ProjectEvent[] = []) { yield* events; return result; }, + async resolveDeployedResource() { + return "unused"; + }, }; return { calls, backend }; } diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c96b2e7af..32ab6a786 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"; export const RUNTIME_TEMPLATE_SHORTCUTS = { "hello-world-python": { @@ -126,6 +127,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/). */ @@ -199,6 +211,8 @@ export type AddResourceInput = export type ProjectResource = AddResourceInput["resourceType"]; +export type ProjectInvokableResource = Extract; + export type RemoveResourceInput = | { resourceType: Exclude; @@ -231,6 +245,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 f3ed9952d42fe41bd3dd7009ee64406920a23cf7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 25 Aug 2026 21:42:53 +0000 Subject: [PATCH 02/39] 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 403728e8ff61934088d2da3b25843d0dbe1e6142 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 25 Aug 2026 21:43:02 +0000 Subject: [PATCH 03/39] feat(runtime): add prompt input mode to invoke TUI --- .../runtime/invoke/invoke.screen.test.tsx | 33 +++++++++++++++++++ src/handlers/runtime/invoke/launchContext.ts | 1 + src/handlers/runtime/invoke/screen.tsx | 21 +++++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index c8a3781f5..6acdc5c1b 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -925,3 +925,36 @@ describe("Runtime invoke JSON console", () => { } }); }); + +describe("Runtime invoke prompt console", () => { + test("accepts plain text and sends it as the project prompt payload", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), + }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + inputMode: "prompt", + }), + }); + + await waitForText(screen.lastFrame, "Enter prompt"); + const content = 'say "hello"'; + await screen.write(content); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + + expect(new TextDecoder().decode(invokeRequests(core)[0]!.payload)).toBe( + JSON.stringify({ prompt: content }), + ); + await waitForText(screen.lastFrame, content); + expect(screen.lastFrame()).not.toContain("Enter a valid JSON payload"); + }); +}); diff --git a/src/handlers/runtime/invoke/launchContext.ts b/src/handlers/runtime/invoke/launchContext.ts index bcee87f31..98919d639 100644 --- a/src/handlers/runtime/invoke/launchContext.ts +++ b/src/handlers/runtime/invoke/launchContext.ts @@ -2,6 +2,7 @@ import { contextKey } from "../../../router"; export type RuntimeInvokeLaunchContext = { runtimeId: string; + inputMode?: "json" | "prompt"; runtimeSessionId?: string; runtimeUserId?: string; applicationHeaders?: [string, string][]; diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index b73cc7862..d08099ef5 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -168,6 +168,7 @@ function RuntimeInvokeConsole({ const [payload, setPayload] = useState(""); const [inputError, setInputError] = useState(); const [requestContext, setRequestContext] = useState(initialContext); + const inputMode = initialContext?.inputMode ?? "json"; const [runtimeSessionId, setRuntimeSessionId] = useState( () => initialContext?.runtimeSessionId ?? randomUUID(), ); @@ -190,12 +191,16 @@ function RuntimeInvokeConsole({ const send = async () => { if (abortRef.current || !detail.data) return; - const requestPayload = payload; - try { - JSON.parse(requestPayload); - } catch { - setInputError("Enter a valid JSON payload"); - return; + const displayedPayload = payload; + const requestPayload = + inputMode === "prompt" ? JSON.stringify({ prompt: displayedPayload }) : displayedPayload; + if (inputMode === "json") { + try { + JSON.parse(requestPayload); + } catch { + setInputError("Enter a valid JSON payload"); + return; + } } setInputError(undefined); @@ -203,7 +208,7 @@ function RuntimeInvokeConsole({ const appendExchange = (response: string, state: ExchangeState) => setHistory((current) => [ ...current, - { payload: requestPayload, response, byteCount: 0, state }, + { payload: displayedPayload, response, byteCount: 0, state }, ]); setPayload(""); appendExchange("", "connecting"); @@ -442,7 +447,7 @@ function RuntimeInvokeConsole({ setInputError(undefined); }} onSubmit={() => void send()} - placeholder="Enter JSON payload" + placeholder={inputMode === "prompt" ? "Enter prompt" : "Enter JSON payload"} submitDisabled={busy} /> From 6ff80ee055450a967b7fe11285eea3442a01efcc Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 25 Aug 2026 21:43:13 +0000 Subject: [PATCH 04/39] feat(project): add project-aware invoke command --- src/handlers/index.tsx | 12 +- src/handlers/project/invoke/index.test.tsx | 280 +++++++++++++++++++++ src/handlers/project/invoke/index.tsx | 196 +++++++++++++++ src/handlers/root.test.tsx | 1 + 4 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 src/handlers/project/invoke/index.test.tsx create mode 100644 src/handlers/project/invoke/index.tsx diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 87def85c6..dc0cb34a1 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -8,8 +8,15 @@ import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; +import { createProjectInvokeHandler } from "./project/invoke"; import { renderTui } from "../tui"; -import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; +import { + withRegion, + withJsonRenderer, + withLogging, + withGlobalConfigAccessor, + withProject, +} from "../middleware"; import type { AppIO } from "../io"; import type { Core } from "./types.tsx"; import type { Logger } from "../logging"; @@ -50,6 +57,9 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); + root.handler( + withProject({ projectManager: core.projectManager })(createProjectInvokeHandler(core, io)), + ); root.handler(createProjectHandler({ projectManager: core.projectManager, io })); // Invoking with no subcommand launches the interactive TUI. diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx new file mode 100644 index 000000000..ba6395d91 --- /dev/null +++ b/src/handlers/project/invoke/index.test.tsx @@ -0,0 +1,280 @@ +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 { createRootHandler } from "../../index"; +import { createProjectInvokeHandler } from "."; +import type { ProjectBackend, ResolveDeployedResourceBackendInput } from "../../../core/project"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { JsonKey, RegionKey } from "../../keys"; +import { ProjectKey, ValueContext, type Context } from "../../../router"; +import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import type { Project } from "../types"; +import type { RuntimeInvokeRequest } from "../../runtime/types"; + +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-")); + 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 testBackend() { + const calls: { project: Project; input: ResolveDeployedResourceBackendInput }[] = []; + const backend: ProjectBackend = { + async *build() {}, + async *deploy() { + yield* []; + return { outputs: {} }; + }, + async resolveDeployedResource(project, input) { + calls.push({ project, input }); + return input.resourceType === "runtime" ? RUNTIME_ID : HARNESS_ID; + }, + }; + return { backend, calls }; +} + +async function run( + args: string[], + resources: { runtimes?: unknown[]; harnesses?: unknown[] }, + configure?: (core: TestCoreClient) => void, +) { + await inProject(resources); + const resolved = testBackend(); + const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); + 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" } }, + ); + configure?.(core); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", "invoke", ...args, "--region", "us-east-2"]); + return { core, io, resolved }; +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("project invoke", () => { + test("auto-selects one Runtime and sends the project prompt payload in the target region", async () => { + const content = 'say "hello"\nthen continue'; + const { core, io, resolved } = await run([content], { runtimes: [RUNTIME] }); + + expect(io.stdout()).toBe("runtime response"); + expect(resolved.calls[0]?.input).toEqual({ + target: TARGET, + resourceType: "runtime", + name: "checkout", + }); + expect(core.runtime.calls.map(({ method }) => method)).toEqual(["getRuntime", "invokeRuntime"]); + const request = core.runtime.calls[1]!.args[0] as RuntimeInvokeRequest; + expect(new TextDecoder().decode(request.payload)).toBe(JSON.stringify({ prompt: content })); + expect(request.contentType).toBe("application/json"); + expect(core.runtime.calls[0]!.args[1]).toEqual({ region: TARGET.region }); + expect(core.runtime.calls[1]!.args[1]).toEqual({ region: TARGET.region }); + }); + + test("auto-selects one Harness and sends one user message", async () => { + const { core, io } = await run(["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.find(({ method }) => method === "getHarness")!.args[1] as object, + ).toEqual({ region: TARGET.region }); + expect(JSON.parse(io.stdout()).transcript).toEqual([ + { kind: "user", text: "hello" }, + { kind: "text", text: "harness response", streaming: false }, + ]); + }); + + test("uses an explicit selector when the project contains both resource types", async () => { + const { core } = await run(["hello", "--runtime", "checkout"], { + runtimes: [RUNTIME], + harnesses: [HARNESS], + }); + + expect(core.runtime.calls.some(({ method }) => method === "invokeRuntime")).toBe(true); + expect(core.harness.calls).toEqual([]); + }); + + test("requires a selector when multiple invokable resources exist", async () => { + await expect(run(["hello"], { runtimes: [RUNTIME], harnesses: [HARNESS] })).rejects.toThrow( + /multiple invokable resources.*--runtime.*checkout.*--harness.*support/s, + ); + }); + + test("rejects mutually exclusive selectors", async () => { + await expect( + run(["hello", "--runtime", "checkout", "--harness", "support"], { + runtimes: [RUNTIME], + harnesses: [HARNESS], + }), + ).rejects.toThrow(/--runtime and --harness are mutually exclusive/); + }); + + test("rejects a logical resource that is not in the project", async () => { + await expect(run(["hello", "--runtime", "missing"], { runtimes: [RUNTIME] })).rejects.toThrow( + /Runtime 'missing' was not found.*checkout/s, + ); + }); + + test("rejects a project with no invokable resources", async () => { + await expect(run(["hello"], {})).rejects.toThrow(/no Runtimes or Harnesses/); + }); + + test("requires content when JSON output is requested", async () => { + await expect(run(["--json"], { runtimes: [RUNTIME] })).rejects.toThrow( + /content is required with --json/, + ); + }); + + test("passes a Runtime bearer token through the existing auth normalizer", async () => { + const { core } = await run( + ["hello", "--bearer-token", "token"], + { runtimes: [RUNTIME] }, + (configured) => + configured.runtime.setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + authorizerConfiguration: { customJWTAuthorizer: {} }, + } as GetAgentRuntimeResponse), + ); + + const request = core.runtime.calls.find(({ method }) => method === "invokeRuntime")! + .args[0] as RuntimeInvokeRequest; + expect(request.bearerToken).toBe("token"); + }); + + test("rejects Runtime-only authentication on a Harness", async () => { + await expect( + run(["hello", "--bearer-token", "token"], { harnesses: [HARNESS] }), + ).rejects.toThrow(/--bearer-token is only valid with --runtime/); + }); + + test("preserves Harness session ID validation", async () => { + await expect( + run(["hello", "--session-id", "too-short"], { harnesses: [HARNESS] }), + ).rejects.toThrow(/Harness session ID must be between 33 and 100 characters/); + }); + + test("launches the Runtime TUI in prompt mode with the deployment target region", async () => { + await inProject({ runtimes: [RUNTIME] }); + const resolved = testBackend(); + const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); + const project = await core.projectManager.resolve({ filePath: process.cwd() }); + const io = testIO(); + const launches: { path: string; context: Context }[] = []; + const handler = createProjectInvokeHandler(core, io.io, async (path, context) => { + launches.push({ path, context }); + }); + const context = ValueContext.EmptyContext() + .withValue(ProjectKey, project!) + .withValue(JsonKey, false) + .withValue(RegionKey, "us-east-2"); + + await handler.handle( + context, + { + runtime: undefined, + harness: undefined, + target: "default", + "session-id": "project-session", + qualifier: "prod", + "bearer-token": undefined, + }, + { content: undefined }, + ); + + expect(launches).toHaveLength(1); + expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}/prod`); + expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); + expect(launches[0]!.context.require(RuntimeInvokeLaunchContextKey)).toEqual({ + runtimeId: RUNTIME_ID, + runtimeSessionId: "project-session", + bearerToken: undefined, + inputMode: "prompt", + }); + }); +}); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx new file mode 100644 index 000000000..725db16a3 --- /dev/null +++ b/src/handlers/project/invoke/index.tsx @@ -0,0 +1,196 @@ +import z from "zod"; +import { InputValidationError, ResourceNotFoundError } from "../../../errors"; +import type { AppIO } from "../../../io"; +import { withUserCancellation } from "../../../runnable"; +import { argument, createHandler, flag, ProjectKey, type Context } from "../../../router"; +import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; +import { invokeRuntimeTarget } from "../../runtime/invoke/operation"; +import { + resolveRuntimeInvokeSources, + resolveRuntimeInvokeTuiBearerToken, +} from "../../runtime/invoke/request"; +import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; +import { invokeHarnessTurn } from "../../harness/invoke/operation"; +import { JsonKey, RegionKey } from "../../keys"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import type { Project, ProjectInvokableResource } from "../types"; + +type SelectedResource = { + resourceType: ProjectInvokableResource; + name: string; +}; + +function availableNames(project: Project, resourceType: ProjectInvokableResource): string[] { + return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( + ({ name }) => name, + ); +} + +function selectResource( + project: Project, + runtimeName: string | undefined, + harnessName: string | undefined, +): SelectedResource { + if (runtimeName !== undefined && harnessName !== undefined) { + throw new InputValidationError("--runtime and --harness are mutually exclusive"); + } + if (runtimeName !== undefined) { + const names = availableNames(project, "runtime"); + if (!names.includes(runtimeName)) { + throw new ResourceNotFoundError( + `Runtime '${runtimeName}' was not found. Available Runtimes: ${names.join(", ") || "none"}.`, + ); + } + return { resourceType: "runtime", name: runtimeName }; + } + if (harnessName !== undefined) { + const names = availableNames(project, "harness"); + if (!names.includes(harnessName)) { + throw new ResourceNotFoundError( + `Harness '${harnessName}' was not found. Available Harnesses: ${names.join(", ") || "none"}.`, + ); + } + return { resourceType: "harness", name: harnessName }; + } + + const runtimes = availableNames(project, "runtime"); + const harnesses = availableNames(project, "harness"); + if (runtimes.length + harnesses.length === 1) { + return runtimes.length === 1 + ? { resourceType: "runtime", name: runtimes[0]! } + : { resourceType: "harness", name: harnesses[0]! }; + } + if (runtimes.length === 0 && harnesses.length === 0) { + throw new InputValidationError("This project has no Runtimes or Harnesses to invoke."); + } + throw new InputValidationError( + `Project has multiple invokable resources. Specify one:\n` + + ` --runtime: ${runtimes.join(", ") || "none"}\n` + + ` --harness: ${harnesses.join(", ") || "none"}`, + ); +} + +function targetContext(ctx: Context, region: string): Context { + return ctx.withValue(RegionKey, region); +} + +export const createProjectInvokeHandler = ( + core: Core, + io: AppIO, + renderInvokeTui: typeof renderTuiAt = renderTuiAt, +) => + createHandler({ + name: "invoke", + description: "invoke a Runtime or Harness in the current project", + arguments: [argument("content", "content to send", z.string().optional())], + flags: [ + flag("runtime", "project Runtime to invoke", z.string().optional()), + flag("harness", "project Harness to invoke", z.string().optional()), + flag("target", "project deployment target", z.string().default("default")), + flag("session-id", "session ID to continue", z.string().optional()), + flag("qualifier", "endpoint qualifier", z.string().optional()), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), + ], + handle: async (ctx, flags, args) => { + const project = ctx.require(ProjectKey); + const selected = selectResource(project, flags.runtime, flags.harness); + if (selected.resourceType === "harness" && flags["bearer-token"] !== undefined) { + throw new InputValidationError("--bearer-token is only valid with --runtime"); + } + if ( + selected.resourceType === "harness" && + flags["session-id"] !== undefined && + (flags["session-id"].length < 33 || flags["session-id"].length > 100) + ) { + throw new InputValidationError("Harness session ID must be between 33 and 100 characters"); + } + if (args.content === undefined && ctx.require(JsonKey)) { + throw new InputValidationError("content is required with --json"); + } + + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + ...selected, + }); + const invokeCtx = targetContext(ctx, deployed.target.region); + const options = coreOptsFromCtx(invokeCtx); + + if (args.content === undefined) { + if (selected.resourceType === "runtime") { + let path = `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}`; + if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; + const bearerToken = await resolveRuntimeInvokeTuiBearerToken( + flags["bearer-token"], + io.stdin, + ); + await renderInvokeTui( + path, + invokeCtx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: deployed.id, + runtimeSessionId: flags["session-id"], + bearerToken, + inputMode: "prompt", + }), + core, + io, + ); + return; + } + + 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; + } + + if (selected.resourceType === "harness") { + const result = await invokeHarnessTurn( + core.harness, + { + harnessId: deployed.id, + prompt: args.content, + qualifier: flags.qualifier, + sessionId: flags["session-id"], + }, + options, + ); + invokeCtx.require(JsonRendererKey).renderJson(result); + return; + } + + await withUserCancellation(async (signal) => { + const sources = await resolveRuntimeInvokeSources( + { + payload: JSON.stringify({ prompt: args.content }), + bearerToken: flags["bearer-token"], + }, + io.stdin, + signal, + ); + const response = await invokeRuntimeTarget( + core.runtime, + { + runtimeId: deployed.id, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: "application/json", + runtimeSessionId: flags["session-id"], + bearerToken: sources.bearerToken, + }, + options, + signal, + ); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + json: invokeCtx.require(JsonKey), + signal, + }); + }); + }, + }); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index b3f4e3386..2524c9a40 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -18,6 +18,7 @@ describe("createRootHandler", () => { "gateway", "eval", "config", + "invoke", "project", ]); }); From cbc986bc4e14cfaf907b65e26628d4c23a0dafaf Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 25 Aug 2026 21:43:30 +0000 Subject: [PATCH 05/39] docs(project): document project-aware invoke --- README.md | 25 +++++++++++++++++++ .../hello-world-python-container/README.md | 6 +++++ .../templates/hello-world-python/README.md | 8 +++--- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f09c95470..8604dd0b9 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Identity, and Gateway branches and leaves open their interactive flows. ``` agentcore # interactive TUI +├── invoke # invoke a Runtime or Harness in the current project ├── harness # manage agentcore harnesses │ ├── create # create a harness (auto-provisions a role if none given) │ ├── get # fetch a harness by id @@ -116,6 +117,30 @@ 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 + +From anywhere inside an AgentCore project, invoke a deployed Runtime or Harness +by its logical project name: + +```bash +# A project with exactly one Runtime or Harness needs no selector. +agentcore invoke "Summarize this repository." + +# Select explicitly when the project has multiple invokable resources. +agentcore invoke --runtime checkout "Check order 123." +agentcore invoke --harness support "Help with my account." + +# Select another deployment target. +agentcore invoke --target staging --runtime checkout "Run a smoke test." + +# Omit content to open the selected resource's interactive console. +agentcore invoke --runtime checkout +``` + +Project Runtime content is sent as `{"prompt": content}` with +`application/json`. `--target` defaults to `default` and supplies the AWS +account and region used for resource lookup and invocation. + ### Examples ```bash diff --git a/src/assets/templates/hello-world-python-container/README.md b/src/assets/templates/hello-world-python-container/README.md index 637390bb0..b6c461acc 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: + +```bash +agentcore invoke "Hello!" +``` diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md index b43ddbbba..d04fcfed7 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 invoke "Hello!" +``` From 46e16bfefdb64e6f08f0b3cdebbdb2481431a787 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:02:02 +0000 Subject: [PATCH 06/39] feat(runtime): parse Strands prompt responses --- .../runtime/invoke/promptResponse.test.ts | 89 +++++++++++++++++++ src/handlers/runtime/invoke/promptResponse.ts | 71 +++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/handlers/runtime/invoke/promptResponse.test.ts create mode 100644 src/handlers/runtime/invoke/promptResponse.ts diff --git a/src/handlers/runtime/invoke/promptResponse.test.ts b/src/handlers/runtime/invoke/promptResponse.test.ts new file mode 100644 index 000000000..1789616a4 --- /dev/null +++ b/src/handlers/runtime/invoke/promptResponse.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { renderPromptResponseBody } from "./promptResponse"; + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +async function read(stream: AsyncIterable): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of stream) chunks.push(Uint8Array.from(chunk)); + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +describe("renderPromptResponseBody", () => { + test("passes non-SSE response bodies through unchanged", () => { + const source = body(Buffer.from('{"result":"hello"}')); + expect(renderPromptResponseBody("application/json", source)).toBe(source); + }); + + test("streams Strands text deltas across arbitrary chunk boundaries", async () => { + const wire = [ + 'data: {"init_event_loop":true}\n\n', + 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n', + 'data: {"event":{"contentBlockDelta":{"delta":{"text":"Hello"},"contentBlockIndex":0}}}\n\n', + 'data: {"event":{"contentBlockDelta":{"delta":{"toolUse":{"input":"{}"}}}}}\n\n', + 'data: {"event":{"contentBlockDelta":{"delta":{"text":" world"},"contentBlockIndex":0}}}\n\n', + 'data: {"event":{"messageStop":{"stopReason":"end_turn"}}}\n\n', + ].join(""); + const bytes = Buffer.from(wire); + + expect( + await read( + renderPromptResponseBody( + "text/event-stream; charset=utf-8", + body(bytes.subarray(0, 19), bytes.subarray(19, 97), bytes.subarray(97)), + ), + ), + ).toBe("Hello world"); + }); + + test("preserves whitespace-only Strands text deltas", async () => { + const wire = 'data: {"event":{"contentBlockDelta":{"delta":{"text":" "}}}}\n\n'; + + expect(await read(renderPromptResponseBody("text/event-stream", body(Buffer.from(wire))))).toBe( + " ", + ); + }); + + test("does not recognize non-Strands SSE event shapes", async () => { + const chunks = [ + Buffer.from('data: "plain text"\n\n'), + Buffer.from('data: {"text":"text chunk"}\n\n'), + Buffer.from('data: {"error":"failed"}\n\n'), + ]; + + expect(await read(renderPromptResponseBody("text/event-stream", body(...chunks)))).toBe( + new TextDecoder().decode(Buffer.concat(chunks)), + ); + }); + + test("falls back to the exact raw response when no Strands text is found", async () => { + const chunks = [ + Buffer.from('data: {"event":{"messageStart":{"role":"assistant"}}}\r\n\r\n'), + Buffer.from('data: {"event":{"messageStop":{"stopReason":"end_turn"}}}\r\n\r\n'), + ]; + + expect(await read(renderPromptResponseBody("text/event-stream", body(...chunks)))).toBe( + new TextDecoder().decode(Buffer.concat(chunks)), + ); + }); + + test("preserves buffered raw bytes before propagating a stream failure", async () => { + const source = (async function* () { + yield Buffer.from('data: {"event":{"messageStart":{"role":"assistant"}}}\n\n'); + throw new Error("stream failed"); + })(); + const rendered = renderPromptResponseBody("text/event-stream", source); + const chunks: Uint8Array[] = []; + + await expect(async () => { + for await (const chunk of rendered) chunks.push(Uint8Array.from(chunk)); + }).toThrow("stream failed"); + expect(new TextDecoder().decode(Buffer.concat(chunks))).toBe( + 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n', + ); + }); +}); diff --git a/src/handlers/runtime/invoke/promptResponse.ts b/src/handlers/runtime/invoke/promptResponse.ts new file mode 100644 index 000000000..9ccec885e --- /dev/null +++ b/src/handlers/runtime/invoke/promptResponse.ts @@ -0,0 +1,71 @@ +function mediaType(contentType: string): string { + return contentType.split(";", 1)[0]!.trim().toLowerCase(); +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseStrandsTextDelta(line: string): string | undefined { + if (!line.startsWith("data:")) return undefined; + const raw = line.slice(5).trimStart(); + + try { + const parsed: unknown = JSON.parse(raw); + const event = asRecord(asRecord(parsed)?.event); + const contentBlockDelta = asRecord(event?.contentBlockDelta); + const delta = asRecord(contentBlockDelta?.delta); + return typeof delta?.text === "string" ? delta.text : undefined; + } catch { + return undefined; + } +} + +export function renderPromptResponseBody( + contentType: string, + body: AsyncIterable, +): AsyncIterable { + if (mediaType(contentType) !== "text/event-stream") return body; + return renderSseBody(body); +} + +async function* renderSseBody(body: AsyncIterable): AsyncGenerator { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const rawChunks: Uint8Array[] = []; + let buffer = ""; + let rendered = false; + + const processLines = function* (lines: string[]): Generator { + for (const line of lines) { + const text = parseStrandsTextDelta(line); + if (text === undefined) continue; + if (!rendered) { + rendered = true; + rawChunks.length = 0; + } + yield encoder.encode(text); + } + }; + + try { + for await (const chunk of body) { + const snapshot = Uint8Array.from(chunk); + if (!rendered) rawChunks.push(snapshot); + + buffer += decoder.decode(snapshot, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + yield* processLines(lines); + } + } catch (error) { + if (!rendered) yield* rawChunks; + throw error; + } + + buffer += decoder.decode(); + if (buffer) yield* processLines([buffer]); + if (!rendered) yield* rawChunks; +} From c90f57e0bd69317bae28a83d04e4cc181d20e2ec Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:02:03 +0000 Subject: [PATCH 07/39] feat(runtime): render Strands responses in prompt mode --- .../runtime/invoke/invoke.screen.test.tsx | 16 +++++++++++++--- src/handlers/runtime/invoke/screen.tsx | 7 ++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 6acdc5c1b..7962f9621 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -927,14 +927,20 @@ describe("Runtime invoke JSON console", () => { }); describe("Runtime invoke prompt console", () => { - test("accepts plain text and sends it as the project prompt payload", async () => { + test("sends a project prompt payload and renders its Strands response as text", async () => { + const wire = [ + 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n', + 'data: {"event":{"contentBlockDelta":{"delta":{"text":"Hello"}}}}\n\n', + 'data: {"event":{"contentBlockDelta":{"delta":{"text":" world"}}}}\n\n', + 'data: {"event":{"messageStop":{"stopReason":"end_turn"}}}\n\n', + ].join(""); const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, - contentType: "text/plain", - body: responseBody(Buffer.from("ok")), + contentType: "text/event-stream", + body: responseBody(Buffer.from(wire.slice(0, 87)), Buffer.from(wire.slice(87))), }); const screen = renderScreen(CONSOLE_PATH, { core, @@ -955,6 +961,10 @@ describe("Runtime invoke prompt console", () => { JSON.stringify({ prompt: content }), ); await waitForText(screen.lastFrame, content); + await waitForText(screen.lastFrame, "Hello world"); + await waitForText(screen.lastFrame, "complete · 11 bytes"); expect(screen.lastFrame()).not.toContain("Enter a valid JSON payload"); + expect(screen.lastFrame()).not.toContain("data:"); + expect(screen.lastFrame()).not.toContain("contentBlockDelta"); }); }); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index d08099ef5..8119eb34d 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -18,6 +18,7 @@ import { Spinner } from "../../../components/ui/spinner"; import type { RuntimeInvokeResponse } from "../types"; import { normalizeRuntimeInvokeRequest } from "./request"; import { classifyRuntimeResponse } from "./response"; +import { renderPromptResponseBody } from "./promptResponse"; import { RuntimeInvokeLaunchContextKey, type RuntimeInvokeLaunchContext } from "./launchContext"; const theme = darkTheme; @@ -247,7 +248,11 @@ function RuntimeInvokeConsole({ const decoder = new TextDecoder(); const chunks: Uint8Array[] = []; let responseText = ""; - for await (const chunk of response.body) { + const responseBody = + inputMode === "prompt" + ? renderPromptResponseBody(response.contentType, response.body) + : response.body; + for await (const chunk of responseBody) { const snapshot = Uint8Array.from(chunk); chunks.push(snapshot); byteCount += snapshot.byteLength; From 9562edacb90a7d2b51d6bd1373c1766ce7f778ee Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:02:04 +0000 Subject: [PATCH 08/39] feat(project): render Strands runtime responses --- src/handlers/project/invoke/index.test.tsx | 20 ++++++++++++++++++++ src/handlers/project/invoke/index.tsx | 19 +++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index ba6395d91..80d4a1c6d 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -151,6 +151,26 @@ describe("project invoke", () => { expect(core.runtime.calls[1]!.args[1]).toEqual({ region: TARGET.region }); }); + test("streams only assistant text from a Strands Runtime response", async () => { + const wire = [ + 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n', + 'data: {"event":{"contentBlockDelta":{"delta":{"text":"Hello"}}}}\n\n', + 'data: {"event":{"contentBlockDelta":{"delta":{"text":" world"}}}}\n\n', + 'data: {"event":{"messageStop":{"stopReason":"end_turn"}}}\n\n', + ].join(""); + const { io } = await run(["hello"], { runtimes: [RUNTIME] }, (core) => + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream", + body: body(Buffer.from(wire.slice(0, 91)), Buffer.from(wire.slice(91))), + }), + ); + + expect(io.stdout()).toBe("Hello world"); + expect(io.stdout()).not.toContain("data:"); + expect(io.stdout()).not.toContain("contentBlockDelta"); + }); + test("auto-selects one Harness and sends one user message", async () => { const { core, io } = await run(["hello"], { harnesses: [HARNESS] }); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 725db16a3..1fbf84c02 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -10,6 +10,7 @@ import { resolveRuntimeInvokeSources, resolveRuntimeInvokeTuiBearerToken, } from "../../runtime/invoke/request"; +import { renderPromptResponseBody } from "../../runtime/invoke/promptResponse"; import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; import { invokeHarnessTurn } from "../../harness/invoke/operation"; import { JsonKey, RegionKey } from "../../keys"; @@ -185,12 +186,18 @@ export const createProjectInvokeHandler = ( options, signal, ); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - json: invokeCtx.require(JsonKey), - signal, - }); + await writeRuntimeInvokeResponse( + { + ...response, + body: renderPromptResponseBody(response.contentType, response.body), + }, + { + stdout: io.stdout, + stderr: io.stderr, + json: invokeCtx.require(JsonKey), + signal, + }, + ); }); }, }); From 33c37e3de0f3f8762d02358bd624de553377cd7a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:39:26 +0000 Subject: [PATCH 09/39] fix(runtime): handle Strands stream failures --- .../runtime/invoke/invoke.screen.test.tsx | 30 +++++ .../runtime/invoke/promptResponse.test.ts | 63 +++++++++-- src/handlers/runtime/invoke/promptResponse.ts | 105 ++++++++++++++---- 3 files changed, 171 insertions(+), 27 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 7962f9621..330b24a8f 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -967,4 +967,34 @@ describe("Runtime invoke prompt console", () => { expect(screen.lastFrame()).not.toContain("data:"); expect(screen.lastFrame()).not.toContain("contentBlockDelta"); }); + + test("reports an AgentCore error received after partial Strands text", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream", + body: responseBody( + Buffer.from('data: {"event":{"contentBlockDelta":{"delta":{"text":"partial"}}}}\n\n'), + Buffer.from('data: {"error":"Model access denied"}\n\n'), + ), + }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + inputMode: "prompt", + }), + }); + + await waitForText(screen.lastFrame, "Enter prompt"); + await screen.write("hello"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Model access denied"); + + expect(screen.lastFrame()).toContain("partial"); + expect(screen.lastFrame()).toContain("failed · 7 bytes"); + }); }); diff --git a/src/handlers/runtime/invoke/promptResponse.test.ts b/src/handlers/runtime/invoke/promptResponse.test.ts index 1789616a4..f75737789 100644 --- a/src/handlers/runtime/invoke/promptResponse.test.ts +++ b/src/handlers/runtime/invoke/promptResponse.test.ts @@ -52,7 +52,6 @@ describe("renderPromptResponseBody", () => { const chunks = [ Buffer.from('data: "plain text"\n\n'), Buffer.from('data: {"text":"text chunk"}\n\n'), - Buffer.from('data: {"error":"failed"}\n\n'), ]; expect(await read(renderPromptResponseBody("text/event-stream", body(...chunks)))).toBe( @@ -60,18 +59,68 @@ describe("renderPromptResponseBody", () => { ); }); - test("falls back to the exact raw response when no Strands text is found", async () => { + test("passes through an unsupported SSE frame before the source completes", async () => { + const finish = Promise.withResolvers(); + const source = (async function* () { + yield Buffer.from('data: {"progress":1}\n\n'); + await finish.promise; + })(); + const iterator = renderPromptResponseBody("text/event-stream", source)[Symbol.asyncIterator](); + + const first = await Promise.race([ + iterator.next(), + Bun.sleep(50).then(() => ({ done: true, value: undefined })), + ]); + finish.resolve(); + await iterator.next(); + + expect(first).toEqual({ + done: false, + value: Uint8Array.from(Buffer.from('data: {"progress":1}\n\n')), + }); + }); + + test("ignores non-text Strands frames", async () => { const chunks = [ Buffer.from('data: {"event":{"messageStart":{"role":"assistant"}}}\r\n\r\n'), Buffer.from('data: {"event":{"messageStop":{"stopReason":"end_turn"}}}\r\n\r\n'), ]; - expect(await read(renderPromptResponseBody("text/event-stream", body(...chunks)))).toBe( - new TextDecoder().decode(Buffer.concat(chunks)), + expect(await read(renderPromptResponseBody("text/event-stream", body(...chunks)))).toBe(""); + }); + + test("fails when AgentCore emits an error after partial Strands text", async () => { + const rendered = renderPromptResponseBody( + "text/event-stream", + body( + Buffer.from('data: {"event":{"contentBlockDelta":{"delta":{"text":"partial"}}}}\n\n'), + Buffer.from( + 'data: {"error":"Model access denied","error_type":"AccessDeniedException"}\n\n', + ), + ), ); + const chunks: Uint8Array[] = []; + + await expect(async () => { + for await (const chunk of rendered) chunks.push(Uint8Array.from(chunk)); + }).toThrow("Model access denied"); + expect(new TextDecoder().decode(Buffer.concat(chunks))).toBe("partial"); + }); + + test("fails an initial AgentCore error without rendering its wire frame", async () => { + const rendered = renderPromptResponseBody( + "text/event-stream", + body(Buffer.from('data: {"error":"Model access denied"}\n\n')), + ); + const chunks: Uint8Array[] = []; + + await expect(async () => { + for await (const chunk of rendered) chunks.push(Uint8Array.from(chunk)); + }).toThrow("Model access denied"); + expect(chunks).toEqual([]); }); - test("preserves buffered raw bytes before propagating a stream failure", async () => { + test("propagates an upstream failure after recognizing a Strands stream", async () => { const source = (async function* () { yield Buffer.from('data: {"event":{"messageStart":{"role":"assistant"}}}\n\n'); throw new Error("stream failed"); @@ -82,8 +131,6 @@ describe("renderPromptResponseBody", () => { await expect(async () => { for await (const chunk of rendered) chunks.push(Uint8Array.from(chunk)); }).toThrow("stream failed"); - expect(new TextDecoder().decode(Buffer.concat(chunks))).toBe( - 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n', - ); + expect(chunks).toEqual([]); }); }); diff --git a/src/handlers/runtime/invoke/promptResponse.ts b/src/handlers/runtime/invoke/promptResponse.ts index 9ccec885e..f3ed89430 100644 --- a/src/handlers/runtime/invoke/promptResponse.ts +++ b/src/handlers/runtime/invoke/promptResponse.ts @@ -8,18 +8,38 @@ function asRecord(value: unknown): Record | undefined { : undefined; } -function parseStrandsTextDelta(line: string): string | undefined { - if (!line.startsWith("data:")) return undefined; +type ParsedSseLine = + { kind: "strands"; text?: string } | { kind: "error"; message: string } | { kind: "unknown" }; + +function parseSseLine(line: string): ParsedSseLine { + if (!line.startsWith("data:")) return { kind: "unknown" }; const raw = line.slice(5).trimStart(); try { const parsed: unknown = JSON.parse(raw); - const event = asRecord(asRecord(parsed)?.event); + const root = asRecord(parsed); + if (!root) return { kind: "unknown" }; + if ("error" in root) { + return { + kind: "error", + message: String(root.error) || "Runtime response stream failed", + }; + } + + const event = asRecord(root.event); + if (!event) { + return root.init_event_loop === true || root.start === true || root.start_event_loop === true + ? { kind: "strands" } + : { kind: "unknown" }; + } + const contentBlockDelta = asRecord(event?.contentBlockDelta); const delta = asRecord(contentBlockDelta?.delta); - return typeof delta?.text === "string" ? delta.text : undefined; + return typeof delta?.text === "string" + ? { kind: "strands", text: delta.text } + : { kind: "strands" }; } catch { - return undefined; + return { kind: "unknown" }; } } @@ -34,38 +54,85 @@ export function renderPromptResponseBody( async function* renderSseBody(body: AsyncIterable): AsyncGenerator { const decoder = new TextDecoder(); const encoder = new TextEncoder(); - const rawChunks: Uint8Array[] = []; + const pending: Uint8Array[] = []; + const maxSniffBytes = 64 * 1024; let buffer = ""; - let rendered = false; + let pendingBytes = 0; + let mode: "sniffing" | "strands" | "raw" = "sniffing"; - const processLines = function* (lines: string[]): Generator { + const processStrandsLines = function* (lines: string[]): Generator { for (const line of lines) { - const text = parseStrandsTextDelta(line); - if (text === undefined) continue; - if (!rendered) { - rendered = true; - rawChunks.length = 0; + if (line === "") continue; + const parsed = parseSseLine(line); + if (parsed.kind === "error") throw new Error(parsed.message); + if (parsed.kind === "strands" && parsed.text !== undefined) { + yield encoder.encode(parsed.text); } - yield encoder.encode(text); } }; try { for await (const chunk of body) { const snapshot = Uint8Array.from(chunk); - if (!rendered) rawChunks.push(snapshot); + if (mode === "raw") { + yield snapshot; + continue; + } buffer += decoder.decode(snapshot, { stream: true }); const lines = buffer.split(/\r?\n/); buffer = lines.pop() ?? ""; - yield* processLines(lines); + + if (mode === "strands") { + yield* processStrandsLines(lines); + continue; + } + + pending.push(snapshot); + pendingBytes += snapshot.byteLength; + const firstLine = lines.find((line) => line !== ""); + if (firstLine !== undefined) { + const parsed = parseSseLine(firstLine); + if (parsed.kind === "error") { + mode = "strands"; + pending.length = 0; + throw new Error(parsed.message); + } + if (parsed.kind === "strands") { + mode = "strands"; + pending.length = 0; + yield* processStrandsLines(lines); + continue; + } + mode = "raw"; + } else if (pendingBytes >= maxSniffBytes) { + mode = "raw"; + } + + if (mode === "raw") { + yield* pending; + pending.length = 0; + buffer = ""; + } } } catch (error) { - if (!rendered) yield* rawChunks; + if (mode === "sniffing") yield* pending; throw error; } + if (mode === "raw") return; + buffer += decoder.decode(); - if (buffer) yield* processLines([buffer]); - if (!rendered) yield* rawChunks; + if (mode === "strands") { + if (buffer) yield* processStrandsLines([buffer]); + return; + } + + const parsed = buffer ? parseSseLine(buffer) : { kind: "unknown" as const }; + if (parsed.kind === "error") throw new Error(parsed.message); + if (parsed.kind === "strands") { + yield* processStrandsLines([buffer]); + } else { + yield* pending; + } } From ce91be0e3779a7d7612599cf855fdcf55681bc65 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:39:27 +0000 Subject: [PATCH 10/39] fix(project): preserve Runtime wire output modes --- src/handlers/project/invoke/index.test.tsx | 81 ++++++++++++++++++++-- src/handlers/project/invoke/index.tsx | 32 +++++++-- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 80d4a1c6d..d8320126c 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -18,6 +18,7 @@ import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, + type TestIO, testIO, } from "../../../testing"; import type { Project } from "../types"; @@ -56,7 +57,7 @@ function body(...chunks: Uint8Array[]): AsyncIterable { async function inProject(resources: { runtimes?: unknown[]; harnesses?: unknown[]; -}): Promise { +}): Promise { const root = await mkdtemp(join(tmpdir(), "agentcore-project-invoke-")); temporaryDirectories.push(root); await mkdir(join(root, "agentcore"), { recursive: true }); @@ -69,6 +70,7 @@ async function inProject(resources: { await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify([TARGET])); process.chdir(root); + return root; } function testBackend() { @@ -91,8 +93,9 @@ async function run( args: string[], resources: { runtimes?: unknown[]; harnesses?: unknown[] }, configure?: (core: TestCoreClient) => void, + io: TestIO = testIO(), ) { - await inProject(resources); + const rootPath = await inProject(resources); const resolved = testBackend(); const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); core.runtime @@ -113,14 +116,13 @@ async function run( { messageStop: { stopReason: "end_turn" } }, ); configure?.(core); - const io = testIO(); const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", "invoke", ...args, "--region", "us-east-2"]); - return { core, io, resolved }; + return { core, io, resolved, rootPath }; } afterEach(async () => { @@ -171,6 +173,77 @@ describe("project invoke", () => { expect(io.stdout()).not.toContain("contentBlockDelta"); }); + test("fails an incomplete Strands response after preserving partial text", async () => { + const io = testIO(); + + await expect( + run( + ["hello"], + { runtimes: [RUNTIME] }, + (core) => + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream", + body: body( + Buffer.from('data: {"event":{"contentBlockDelta":{"delta":{"text":"partial"}}}}\n\n'), + Buffer.from('data: {"error":"Model access denied"}\n\n'), + ), + }), + io, + ), + ).rejects.toThrow("response stream failed"); + + expect(io.stdout()).toBe("partial"); + expect(io.stderr()).toContain("complete=false bytes=7 error=response-stream-failed"); + }); + + test("preserves the Runtime wire response in JSON mode", async () => { + const wire = 'data: {"event":{"contentBlockDelta":{"delta":{"text":"hello"}}}}\n\n'; + const { io } = await run(["hello", "--json"], { runtimes: [RUNTIME] }, (core) => + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream; charset=utf-8", + body: body(Buffer.from(wire)), + }), + ); + + expect(JSON.parse(io.stdout())).toMatchObject({ + contentType: "text/event-stream; charset=utf-8", + bodyEncoding: "utf8", + body: wire, + complete: true, + }); + }); + + test("writes the exact Runtime wire response to --output-file", async () => { + const wire = Buffer.from([0, 255, 1]); + const { io, rootPath } = await run( + ["hello", "--output-file", "response.bin"], + { runtimes: [RUNTIME] }, + (core) => + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "application/octet-stream", + body: body(wire), + }), + ); + + expect(Buffer.from(await Bun.file(join(rootPath, "response.bin")).bytes())).toEqual(wire); + expect(io.stdout()).toBe(""); + }); + + test("rejects --json with --output-file", async () => { + await expect( + run(["hello", "--json", "--output-file", "response.bin"], { runtimes: [RUNTIME] }), + ).rejects.toThrow("--json cannot be used with --output-file"); + }); + + test("rejects --output-file for Harness invoke", async () => { + await expect( + run(["hello", "--output-file", "response.bin"], { harnesses: [HARNESS] }), + ).rejects.toThrow("--output-file is only valid with --runtime"); + }); + test("auto-selects one Harness and sends one user message", async () => { const { core, io } = await run(["hello"], { harnesses: [HARNESS] }); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 1fbf84c02..8f37daf3f 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -92,6 +92,11 @@ export const createProjectInvokeHandler = ( flag("target", "project deployment target", z.string().default("default")), flag("session-id", "session ID to continue", z.string().optional()), flag("qualifier", "endpoint qualifier", z.string().optional()), + flag( + "output-file", + "write the Runtime response body to a file", + z.string().min(1, "requires a nonempty path").optional(), + ), flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { sensitive: true, }), @@ -99,9 +104,16 @@ export const createProjectInvokeHandler = ( handle: async (ctx, flags, args) => { const project = ctx.require(ProjectKey); const selected = selectResource(project, flags.runtime, flags.harness); + const jsonOutput = ctx.require(JsonKey); if (selected.resourceType === "harness" && flags["bearer-token"] !== undefined) { throw new InputValidationError("--bearer-token is only valid with --runtime"); } + if (selected.resourceType === "harness" && flags["output-file"] !== undefined) { + throw new InputValidationError("--output-file is only valid with --runtime"); + } + if (jsonOutput && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } if ( selected.resourceType === "harness" && flags["session-id"] !== undefined && @@ -109,8 +121,10 @@ export const createProjectInvokeHandler = ( ) { throw new InputValidationError("Harness session ID must be between 33 and 100 characters"); } - if (args.content === undefined && ctx.require(JsonKey)) { - throw new InputValidationError("content is required with --json"); + if (args.content === undefined && (jsonOutput || flags["output-file"] !== undefined)) { + throw new InputValidationError( + `content is required with ${jsonOutput ? "--json" : "--output-file"}`, + ); } const deployed = await core.projectManager.resolveDeployedResource(project, { @@ -186,15 +200,19 @@ export const createProjectInvokeHandler = ( options, signal, ); + const preserveWireResponse = jsonOutput || flags["output-file"] !== undefined; await writeRuntimeInvokeResponse( - { - ...response, - body: renderPromptResponseBody(response.contentType, response.body), - }, + preserveWireResponse + ? response + : { + ...response, + body: renderPromptResponseBody(response.contentType, response.body), + }, { stdout: io.stdout, stderr: io.stderr, - json: invokeCtx.require(JsonKey), + outputFile: flags["output-file"], + json: jsonOutput, signal, }, ); From 8d81c24351b28c95131a6ab3ce621da51527d2ad Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:39:28 +0000 Subject: [PATCH 11/39] fix(tui): hide unsupported project invoke route --- src/components/RouterScreen.test.tsx | 8 ++++++++ src/handlers/index.tsx | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index f28ee119d..18e73a75d 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -32,6 +32,14 @@ describe("menu rendering", () => { r.unmount(); }); + test("does not offer project invoke without a root TUI route", async () => { + const r = renderScreen("/agentcore"); + await waitForText(r.lastFrame, "harness"); + + expect(r.lastFrame()).not.toMatch(/^[❯ ]*invoke\s/m); + r.unmount(); + }); + test("renders the harness subcommands when mounted at the harness path", async () => { const r = renderScreen("/agentcore/harness"); await waitForText(r.lastFrame, "list"); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index dc0cb34a1..c0415e82c 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -30,7 +30,19 @@ export interface RootHandlerConfig { export function createRootHandler(core: Core, config: RootHandlerConfig): Router { const { io, logger } = config; - const root = new Router("agentcore", "the platform for production AI agents"); + const root = new Router( + "agentcore", + "the platform for production AI agents", + ).supportedTuiCommands( + "harness", + "identity", + "runtime", + "memory", + "gateway", + "eval", + "config", + "project", + ); // Add global flags root.groupFlags(RegionKey, DebugKey, JsonKey, EndpointKey); From 865ea25350be5fa6f6ff6eae7c95e2cdb49d6977 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:39:29 +0000 Subject: [PATCH 12/39] docs(project): document Runtime output modes --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8604dd0b9..e0c4b6154 100644 --- a/README.md +++ b/README.md @@ -133,13 +133,20 @@ agentcore invoke --harness support "Help with my account." # Select another deployment target. agentcore invoke --target staging --runtime checkout "Run a smoke test." +# Preserve the Runtime wire response in a JSON envelope or file. +agentcore invoke --runtime checkout "Check order 123." --json +agentcore invoke --runtime checkout "Check order 123." --output-file response.sse + # Omit content to open the selected resource's interactive console. agentcore invoke --runtime checkout ``` Project Runtime content is sent as `{"prompt": content}` with `application/json`. `--target` defaults to `default` and supplies the AWS -account and region used for resource lookup and invocation. +account and region used for resource lookup and invocation. Default output +streams assistant text from supported Strands SSE responses and passes +unsupported SSE responses through unchanged. `--json` and the Runtime-only +`--output-file` preserve the exact wire response instead. ### Examples From 4b54aa9ea0f7d4cfa6c35ef85989ec1f90ca32c3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 00:17:29 +0000 Subject: [PATCH 13/39] fix(runtime): support caller-specific binary guidance --- src/handlers/runtime/invoke/response.test.ts | 17 +++++++++++++++++ src/handlers/runtime/invoke/response.ts | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 8b2850ca5..3698883ee 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -465,4 +465,21 @@ describe("Runtime invoke response output", () => { "complete=false bytes=0\n", ); }); + + test("uses caller-specific binary TTY guidance", async () => { + const stdout = capture(); + const stderr = capture(); + Object.defineProperty(stdout.stream, "isTTY", { value: true }); + + await expect( + writeRuntimeInvokeResponse( + response({ contentType: "application/octet-stream" }), + { + stdout: stdout.stream, + stderr: stderr.stream, + }, + { binaryTtyError: "Binary project responses require --json" }, + ), + ).rejects.toThrow("Binary project responses require --json"); + }); }); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 9c51911cd..33b1fe047 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -8,6 +8,11 @@ import { import type { RuntimeInvokeResponse } from "../types"; const RESPONSE_STREAM_FAILED = "response stream failed"; +const BINARY_TTY_ERROR = "Binary or unknown response content requires --output-file or --json"; + +type RuntimeInvokeResponseWriterOptions = { + binaryTtyError?: string; +}; export function classifyRuntimeResponse(contentType: string) { return classifyStreamingResponse(contentType); @@ -49,11 +54,12 @@ function summary( export async function writeRuntimeInvokeResponse( response: RuntimeInvokeResponse, output: StreamingResponseOutput, + options: RuntimeInvokeResponseWriterOptions = {}, ): Promise { await writeStreamingResponse(response, output, { metadata: ({ body: _body, ...metadata }) => metadata, summary, fail: (error) => failure(error, output.signal), - binaryTtyError: "Binary or unknown response content requires --output-file or --json", + binaryTtyError: options.binaryTtyError ?? BINARY_TTY_ERROR, }); } From a8d559627cc177f4cfc99a9717f6713d9b6537ce Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 00:17:29 +0000 Subject: [PATCH 14/39] refactor(runtime): target the Strands template event contract --- src/handlers/runtime/invoke/promptResponse.test.ts | 1 - src/handlers/runtime/invoke/promptResponse.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/handlers/runtime/invoke/promptResponse.test.ts b/src/handlers/runtime/invoke/promptResponse.test.ts index f75737789..c371ade15 100644 --- a/src/handlers/runtime/invoke/promptResponse.test.ts +++ b/src/handlers/runtime/invoke/promptResponse.test.ts @@ -21,7 +21,6 @@ describe("renderPromptResponseBody", () => { test("streams Strands text deltas across arbitrary chunk boundaries", async () => { const wire = [ - 'data: {"init_event_loop":true}\n\n', 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n', 'data: {"event":{"contentBlockDelta":{"delta":{"text":"Hello"},"contentBlockIndex":0}}}\n\n', 'data: {"event":{"contentBlockDelta":{"delta":{"toolUse":{"input":"{}"}}}}}\n\n', diff --git a/src/handlers/runtime/invoke/promptResponse.ts b/src/handlers/runtime/invoke/promptResponse.ts index f3ed89430..6ad9b0388 100644 --- a/src/handlers/runtime/invoke/promptResponse.ts +++ b/src/handlers/runtime/invoke/promptResponse.ts @@ -27,11 +27,7 @@ function parseSseLine(line: string): ParsedSseLine { } const event = asRecord(root.event); - if (!event) { - return root.init_event_loop === true || root.start === true || root.start_event_loop === true - ? { kind: "strands" } - : { kind: "unknown" }; - } + if (!event) return { kind: "unknown" }; const contentBlockDelta = asRecord(event?.contentBlockDelta); const delta = asRecord(contentBlockDelta?.delta); From a325b23b0212e3fc40188d1358b58480ebc06cf6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 00:17:30 +0000 Subject: [PATCH 15/39] refactor(project): remove invoke output file mode --- README.md | 7 ++-- src/handlers/project/invoke/index.test.tsx | 46 +++++++++------------- src/handlers/project/invoke/index.tsx | 24 +++-------- 3 files changed, 27 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e0c4b6154..9e0584765 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,8 @@ agentcore invoke --harness support "Help with my account." # Select another deployment target. agentcore invoke --target staging --runtime checkout "Run a smoke test." -# Preserve the Runtime wire response in a JSON envelope or file. +# Preserve the Runtime wire response in a JSON envelope. agentcore invoke --runtime checkout "Check order 123." --json -agentcore invoke --runtime checkout "Check order 123." --output-file response.sse # Omit content to open the selected resource's interactive console. agentcore invoke --runtime checkout @@ -145,8 +144,8 @@ Project Runtime content is sent as `{"prompt": content}` with `application/json`. `--target` defaults to `default` and supplies the AWS account and region used for resource lookup and invocation. Default output streams assistant text from supported Strands SSE responses and passes -unsupported SSE responses through unchanged. `--json` and the Runtime-only -`--output-file` preserve the exact wire response instead. +unsupported SSE responses through unchanged. `--json` preserves the exact wire +response instead. ### Examples diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index d8320126c..edcd5547b 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -57,7 +57,7 @@ function body(...chunks: Uint8Array[]): AsyncIterable { async function inProject(resources: { runtimes?: unknown[]; harnesses?: unknown[]; -}): Promise { +}): Promise { const root = await mkdtemp(join(tmpdir(), "agentcore-project-invoke-")); temporaryDirectories.push(root); await mkdir(join(root, "agentcore"), { recursive: true }); @@ -70,7 +70,6 @@ async function inProject(resources: { await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify([TARGET])); process.chdir(root); - return root; } function testBackend() { @@ -95,7 +94,7 @@ async function run( configure?: (core: TestCoreClient) => void, io: TestIO = testIO(), ) { - const rootPath = await inProject(resources); + await inProject(resources); const resolved = testBackend(); const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); core.runtime @@ -122,7 +121,7 @@ async function run( globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", "invoke", ...args, "--region", "us-east-2"]); - return { core, io, resolved, rootPath }; + return { core, io, resolved }; } afterEach(async () => { @@ -215,33 +214,24 @@ describe("project invoke", () => { }); }); - test("writes the exact Runtime wire response to --output-file", async () => { - const wire = Buffer.from([0, 255, 1]); - const { io, rootPath } = await run( - ["hello", "--output-file", "response.bin"], - { runtimes: [RUNTIME] }, - (core) => - core.runtime.setInvokeResponse({ - statusCode: 200, - contentType: "application/octet-stream", - body: body(wire), - }), - ); - - expect(Buffer.from(await Bun.file(join(rootPath, "response.bin")).bytes())).toEqual(wire); - expect(io.stdout()).toBe(""); - }); + test("directs binary Runtime responses to the available JSON output mode", async () => { + const io = testIO({ isTTY: true }); - test("rejects --json with --output-file", async () => { await expect( - run(["hello", "--json", "--output-file", "response.bin"], { runtimes: [RUNTIME] }), - ).rejects.toThrow("--json cannot be used with --output-file"); - }); + run( + ["hello"], + { runtimes: [RUNTIME] }, + (core) => + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "application/octet-stream", + body: body(Buffer.from([0, 255, 1])), + }), + io, + ), + ).rejects.toThrow("Binary or unknown response content requires --json"); - test("rejects --output-file for Harness invoke", async () => { - await expect( - run(["hello", "--output-file", "response.bin"], { harnesses: [HARNESS] }), - ).rejects.toThrow("--output-file is only valid with --runtime"); + expect(io.stderr()).not.toContain("--output-file"); }); test("auto-selects one Harness and sends one user message", async () => { diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 8f37daf3f..5f472d797 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -92,11 +92,6 @@ export const createProjectInvokeHandler = ( flag("target", "project deployment target", z.string().default("default")), flag("session-id", "session ID to continue", z.string().optional()), flag("qualifier", "endpoint qualifier", z.string().optional()), - flag( - "output-file", - "write the Runtime response body to a file", - z.string().min(1, "requires a nonempty path").optional(), - ), flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { sensitive: true, }), @@ -108,12 +103,6 @@ export const createProjectInvokeHandler = ( if (selected.resourceType === "harness" && flags["bearer-token"] !== undefined) { throw new InputValidationError("--bearer-token is only valid with --runtime"); } - if (selected.resourceType === "harness" && flags["output-file"] !== undefined) { - throw new InputValidationError("--output-file is only valid with --runtime"); - } - if (jsonOutput && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } if ( selected.resourceType === "harness" && flags["session-id"] !== undefined && @@ -121,10 +110,8 @@ export const createProjectInvokeHandler = ( ) { throw new InputValidationError("Harness session ID must be between 33 and 100 characters"); } - if (args.content === undefined && (jsonOutput || flags["output-file"] !== undefined)) { - throw new InputValidationError( - `content is required with ${jsonOutput ? "--json" : "--output-file"}`, - ); + if (args.content === undefined && jsonOutput) { + throw new InputValidationError("content is required with --json"); } const deployed = await core.projectManager.resolveDeployedResource(project, { @@ -200,9 +187,8 @@ export const createProjectInvokeHandler = ( options, signal, ); - const preserveWireResponse = jsonOutput || flags["output-file"] !== undefined; await writeRuntimeInvokeResponse( - preserveWireResponse + jsonOutput ? response : { ...response, @@ -211,10 +197,12 @@ export const createProjectInvokeHandler = ( { stdout: io.stdout, stderr: io.stderr, - outputFile: flags["output-file"], json: jsonOutput, signal, }, + { + binaryTtyError: "Binary or unknown response content requires --json", + }, ); }); }, From 6091ba3560095f4923c6ff13940ef4ff7d6ccb02 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 00:58:02 +0000 Subject: [PATCH 16/39] feat(project): add shared agent event parser --- src/core/project/agentEventParser.test.ts | 69 +++++++++++++++++++++++ src/core/project/agentEventParser.ts | 46 +++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/core/project/agentEventParser.test.ts create mode 100644 src/core/project/agentEventParser.ts diff --git a/src/core/project/agentEventParser.test.ts b/src/core/project/agentEventParser.test.ts new file mode 100644 index 000000000..13e710c21 --- /dev/null +++ b/src/core/project/agentEventParser.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { parseAgentEvent, type AgentEvent } from "./agentEventParser"; + +describe("parseAgentEvent", () => { + test.each([ + { + name: "JSON string", + data: JSON.stringify("string token"), + expected: { kind: "text", text: "string token" }, + }, + { + name: "text object", + data: JSON.stringify({ text: "text token" }), + expected: { kind: "text", text: "text token" }, + }, + { + name: "Converse text delta", + data: JSON.stringify({ + event: { contentBlockDelta: { delta: { text: "delta token" } } }, + }), + expected: { kind: "text", text: "delta token" }, + }, + { + name: "non-JSON token", + data: "raw token", + expected: { kind: "text", text: "raw token" }, + }, + ] satisfies { name: string; data: string; expected: AgentEvent }[])( + "extracts a $name", + ({ data, expected }) => { + expect(parseAgentEvent(data)).toEqual(expected); + }, + ); + + test("extracts an error object", () => { + expect(parseAgentEvent(JSON.stringify({ error: "model denied" }))).toEqual({ + kind: "error", + message: "model denied", + }); + }); + + test.each([ + { + name: "message start", + data: JSON.stringify({ event: { messageStart: { role: "assistant" } } }), + }, + { + name: "message stop", + data: JSON.stringify({ event: { messageStop: { stopReason: "end_turn" } } }), + }, + { name: "empty JSON string", data: JSON.stringify("") }, + { name: "blank text", data: JSON.stringify({ text: "" }) }, + { name: "blank error", data: JSON.stringify({ error: "" }) }, + { name: "empty non-JSON token", data: "" }, + ])("identifies $name as control", ({ data }) => { + expect(parseAgentEvent(data)).toEqual({ kind: "control" }); + }); + + test.each([ + { name: "unknown object", data: JSON.stringify({ progress: 1 }) }, + { name: "JSON number", data: JSON.stringify(42) }, + { name: "JSON boolean", data: JSON.stringify(true) }, + { name: "JSON null", data: JSON.stringify(null) }, + { name: "JSON array", data: JSON.stringify(["token"]) }, + { name: "malformed event", data: JSON.stringify({ event: "not-an-object" }) }, + ])("identifies an unsupported $name", ({ data }) => { + expect(parseAgentEvent(data)).toEqual({ kind: "unsupported" }); + }); +}); diff --git a/src/core/project/agentEventParser.ts b/src/core/project/agentEventParser.ts new file mode 100644 index 000000000..7427dd8ab --- /dev/null +++ b/src/core/project/agentEventParser.ts @@ -0,0 +1,46 @@ +export type AgentEvent = + | { kind: "text"; text: string } + | { kind: "error"; message: string } + | { kind: "control" } + | { kind: "unsupported" }; + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function textEvent(value: unknown): AgentEvent { + const text = String(value); + return text ? { kind: "text", text } : { kind: "control" }; +} + +export function parseAgentEvent(data: string): AgentEvent { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return data ? { kind: "text", text: data } : { kind: "control" }; + } + + if (typeof parsed === "string") return textEvent(parsed); + + const root = asRecord(parsed); + if (!root) return { kind: "unsupported" }; + + if ("error" in root) { + const message = String(root.error); + return message ? { kind: "error", message } : { kind: "control" }; + } + if ("text" in root) return textEvent(root.text); + + if ("event" in root) { + const event = asRecord(root.event); + if (!event) return { kind: "unsupported" }; + const contentBlockDelta = asRecord(event.contentBlockDelta); + const delta = asRecord(contentBlockDelta?.delta); + return typeof delta?.text === "string" ? textEvent(delta.text) : { kind: "control" }; + } + + return { kind: "unsupported" }; +} From 69268572cacab53cb2c4152ef7d06282192423b5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 00:58:02 +0000 Subject: [PATCH 17/39] refactor(dev): use shared agent event parser --- src/core/dev/inspector/invocations.test.ts | 24 +++++++++---------- src/core/dev/inspector/invocations.ts | 27 ++++------------------ 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/src/core/dev/inspector/invocations.test.ts b/src/core/dev/inspector/invocations.test.ts index 26d4f72e5..17c617543 100644 --- a/src/core/dev/inspector/invocations.test.ts +++ b/src/core/dev/inspector/invocations.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; import { type HttpRequestHandler, startHttpServer } from "../../../io/httpServer"; -import { parseAgentEvent } from "./invocations"; import { ServerFarm, fakeSupervisor, post, runningAgent } from "./testkit"; import type { InspectorDeps } from "./types"; @@ -63,17 +62,6 @@ describe("upstream connection failures", () => { }); }); -describe("parseAgentEvent drops non-renderable frames", () => { - test.each([ - { name: "a JSON primitive that is not text", data: JSON.stringify(42) }, - { name: "a blank error field", data: JSON.stringify({ error: "" }) }, - { name: "a blank text field", data: JSON.stringify({ text: "" }) }, - { name: "an empty non-JSON token", data: "" }, - ])("returns null for $name", ({ data }) => { - expect(parseAgentEvent(data)).toBeNull(); - }); -}); - describe("HTTP agent SSE normalization", () => { test.each([ { name: "a bedrock text event", frame: JSON.stringify({ text: "hello" }), expected: "hello" }, @@ -97,6 +85,18 @@ describe("HTTP agent SSE normalization", () => { expect(await response.text()).toBe(`data: ${JSON.stringify({ error: "boom" })}\n\n`); }); + test.each([ + { + name: "a recognized control event", + frame: JSON.stringify({ event: { messageStart: { role: "assistant" } } }), + }, + { name: "an unsupported event", frame: JSON.stringify({ progress: 1 }) }, + ])("does not emit $name", async ({ frame }) => { + const { url } = await inspectorFor(sseAgent([frame])); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(await response.text()).toBe(""); + }); + test("passes a non-SSE response body through untouched", async () => { const { url } = await inspectorFor(() => ({ status: 200, diff --git a/src/core/dev/inspector/invocations.ts b/src/core/dev/inspector/invocations.ts index 313fe6a08..ac9422017 100644 --- a/src/core/dev/inspector/invocations.ts +++ b/src/core/dev/inspector/invocations.ts @@ -12,6 +12,7 @@ import { sseEvent, } from "./respond"; import type { InspectorDeps } from "./types"; +import { parseAgentEvent } from "../../project/agentEventParser"; export async function handleInvocations( deps: InspectorDeps, @@ -89,32 +90,12 @@ async function* transformAgentSse( stream: AsyncIterable, ): AsyncGenerator { for await (const data of sseData(stream)) { - const payload = parseAgentEvent(data); - if (payload !== null) yield sseEvent(payload); + const event = parseAgentEvent(data); + if (event.kind === "text") yield sseEvent(event.text); + if (event.kind === "error") yield sseEvent({ error: event.message }); } } -// Handles bedrock {text}, {error}, ConverseStream contentBlockDelta, bare JSON string, and non-JSON tokens. -export function parseAgentEvent(data: string): string | { error: string } | null { - try { - const parsed: unknown = JSON.parse(data); - if (typeof parsed === "string") return parsed || null; - if (parsed && typeof parsed === "object") { - if ("error" in parsed) { - const error = String((parsed as { error: unknown }).error); - return error ? { error } : null; - } - if ("text" in parsed) return String((parsed as { text: unknown }).text) || null; - const event = (parsed as { event?: { contentBlockDelta?: { delta?: { text?: string } } } }) - .event; - return event?.contentBlockDelta?.delta?.text || null; - } - } catch { - return data || null; - } - return null; -} - // A2A agents speak JSON-RPC at their root path, so {prompt} becomes a message/stream call reduced to text frames. async function invokeA2aAgent( port: number, From 9fed35b9beb9af536e7a8a53cb317cd6edbf7be0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 00:58:03 +0000 Subject: [PATCH 18/39] refactor(invoke): use shared agent event parser --- src/handlers/project/invoke/index.test.tsx | 16 ++++ .../runtime/invoke/promptResponse.test.ts | 15 ++-- src/handlers/runtime/invoke/promptResponse.ts | 83 ++++++------------- 3 files changed, 49 insertions(+), 65 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index edcd5547b..16cdc554c 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -172,6 +172,22 @@ describe("project invoke", () => { expect(io.stdout()).not.toContain("contentBlockDelta"); }); + test.each([ + { name: "JSON string", wire: 'data: "string token"\n\n', expected: "string token" }, + { name: "text object", wire: 'data: {"text":"text token"}\n\n', expected: "text token" }, + { name: "non-JSON token", wire: "data: raw token\n\n", expected: "raw token" }, + ])("streams a $name Runtime response", async ({ wire, expected }) => { + const { io } = await run(["hello"], { runtimes: [RUNTIME] }, (core) => + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream", + body: body(Buffer.from(wire)), + }), + ); + + expect(io.stdout()).toBe(expected); + }); + test("fails an incomplete Strands response after preserving partial text", async () => { const io = testIO(); diff --git a/src/handlers/runtime/invoke/promptResponse.test.ts b/src/handlers/runtime/invoke/promptResponse.test.ts index c371ade15..8171e1fff 100644 --- a/src/handlers/runtime/invoke/promptResponse.test.ts +++ b/src/handlers/runtime/invoke/promptResponse.test.ts @@ -47,14 +47,13 @@ describe("renderPromptResponseBody", () => { ); }); - test("does not recognize non-Strands SSE event shapes", async () => { - const chunks = [ - Buffer.from('data: "plain text"\n\n'), - Buffer.from('data: {"text":"text chunk"}\n\n'), - ]; - - expect(await read(renderPromptResponseBody("text/event-stream", body(...chunks)))).toBe( - new TextDecoder().decode(Buffer.concat(chunks)), + test.each([ + ['data: "JSON string"\n\n', "JSON string"], + ['data: {"text":"text object"}\n\n', "text object"], + ["data: non-JSON token\n\n", "non-JSON token"], + ])("streams a shared agent event shape %#", async (wire, expected) => { + expect(await read(renderPromptResponseBody("text/event-stream", body(Buffer.from(wire))))).toBe( + expected, ); }); diff --git a/src/handlers/runtime/invoke/promptResponse.ts b/src/handlers/runtime/invoke/promptResponse.ts index 6ad9b0388..302ead395 100644 --- a/src/handlers/runtime/invoke/promptResponse.ts +++ b/src/handlers/runtime/invoke/promptResponse.ts @@ -1,42 +1,13 @@ +import { parseAgentEvent, type AgentEvent } from "../../../core/project/agentEventParser"; + function mediaType(contentType: string): string { return contentType.split(";", 1)[0]!.trim().toLowerCase(); } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -type ParsedSseLine = - { kind: "strands"; text?: string } | { kind: "error"; message: string } | { kind: "unknown" }; - -function parseSseLine(line: string): ParsedSseLine { - if (!line.startsWith("data:")) return { kind: "unknown" }; - const raw = line.slice(5).trimStart(); - - try { - const parsed: unknown = JSON.parse(raw); - const root = asRecord(parsed); - if (!root) return { kind: "unknown" }; - if ("error" in root) { - return { - kind: "error", - message: String(root.error) || "Runtime response stream failed", - }; - } - - const event = asRecord(root.event); - if (!event) return { kind: "unknown" }; - - const contentBlockDelta = asRecord(event?.contentBlockDelta); - const delta = asRecord(contentBlockDelta?.delta); - return typeof delta?.text === "string" - ? { kind: "strands", text: delta.text } - : { kind: "strands" }; - } catch { - return { kind: "unknown" }; - } +function parseSseLine(line: string): AgentEvent { + return line.startsWith("data:") + ? parseAgentEvent(line.slice(5).trimStart()) + : { kind: "unsupported" }; } export function renderPromptResponseBody( @@ -54,16 +25,14 @@ async function* renderSseBody(body: AsyncIterable): AsyncGenerator { + const processParsedLines = function* (lines: string[]): Generator { for (const line of lines) { if (line === "") continue; - const parsed = parseSseLine(line); - if (parsed.kind === "error") throw new Error(parsed.message); - if (parsed.kind === "strands" && parsed.text !== undefined) { - yield encoder.encode(parsed.text); - } + const event = parseSseLine(line); + if (event.kind === "error") throw new Error(event.message); + if (event.kind === "text") yield encoder.encode(event.text); } }; @@ -79,8 +48,8 @@ async function* renderSseBody(body: AsyncIterable): AsyncGenerator): AsyncGenerator line !== ""); if (firstLine !== undefined) { - const parsed = parseSseLine(firstLine); - if (parsed.kind === "error") { - mode = "strands"; + const event = parseSseLine(firstLine); + if (event.kind === "error") { + mode = "parsed"; pending.length = 0; - throw new Error(parsed.message); + throw new Error(event.message); } - if (parsed.kind === "strands") { - mode = "strands"; + if (event.kind !== "unsupported") { + mode = "parsed"; pending.length = 0; - yield* processStrandsLines(lines); + yield* processParsedLines(lines); continue; } mode = "raw"; @@ -119,15 +88,15 @@ async function* renderSseBody(body: AsyncIterable): AsyncGenerator Date: Thu, 27 Aug 2026 01:03:57 +0000 Subject: [PATCH 19/39] 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 17bd113dd..252a2a9ab 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -17,6 +17,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"; const tempDirectories: string[] = []; @@ -77,6 +79,13 @@ async function writeAssembly(project: Project, targetNames: string[]): Promise { + await writeFile( + join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH), + JSON.stringify({ targets: { [TARGET.name]: { stackArn } } }), + ); +} + type HarnessOptions = { account?: string; bootstrap?: BootstrapState; @@ -156,7 +165,7 @@ function harness(options: HarnessOptions = {}) { }, }; }, - readStack: async (stackName, region, provider) => { + describeStack: async (region, provider, stackName) => { stackReads.push({ stackName, region, credentials: provider }); return options.stack; }, @@ -415,41 +424,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( @@ -459,10 +501,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 eb1df5ba6..dfbada0a5 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 { @@ -32,12 +33,26 @@ import { type CdkCredentialResolver, type CdkRunner, } 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; @@ -49,7 +64,7 @@ export type CdkBackendConfig = { bootstrap?: BootstrapProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; - readStack?: DeployedStackReader; + describeStack?: StackDescriber; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -63,7 +78,7 @@ export class CdkBackend implements ProjectBackend { private readonly bootstrap: BootstrapProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; - private readonly readStack: DeployedStackReader; + private readonly describeStack: StackDescriber; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -76,7 +91,7 @@ export class CdkBackend implements ProjectBackend { this.bootstrap = config.bootstrap ?? probeBootstrap; 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 { @@ -175,17 +190,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 5ab6a1de64484799cc26dfefee823e4038659b61 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 01:03:58 +0000 Subject: [PATCH 20/39] 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 5063cf9648f366d885064374586c2a8c67fd5e66 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 18:21:35 +0000 Subject: [PATCH 21/39] 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 252a2a9ab..03d8b53a3 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 { BootstrapState } from "./cdk/environment"; import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit"; @@ -19,6 +20,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() }); const tempDirectories: string[] = []; @@ -80,10 +82,7 @@ async function writeAssembly(project: Project, targetNames: string[]): 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 84cde7a095a3f57753252a8b1c3ba4f935dd3dbf Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 18:24:34 +0000 Subject: [PATCH 22/39] docs(templates): document project invoke contract --- src/assets/templates/strands-http-python/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/assets/templates/strands-http-python/README.md b/src/assets/templates/strands-http-python/README.md index eafaa1ec0..7a555a35a 100644 --- a/src/assets/templates/strands-http-python/README.md +++ b/src/assets/templates/strands-http-python/README.md @@ -38,3 +38,12 @@ 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 from the project root: + +```bash +agentcore invoke "Hello!" +``` + +The CLI sends `{"prompt": content}` and streams assistant text from the Strands response. Use `--json` to preserve +the exact wire response. From cb5b431d730677d8d9ca85b05e3edbb12dda2e83 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 19:10:32 +0000 Subject: [PATCH 23/39] fix(project): register invoke under project --- src/handlers/index.tsx | 28 +++------------------- src/handlers/project/index.ts | 9 ++++++- src/handlers/project/invoke/index.test.tsx | 2 +- src/handlers/root.test.tsx | 1 - 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index c0415e82c..0429b2199 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -8,15 +8,8 @@ import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; -import { createProjectInvokeHandler } from "./project/invoke"; import { renderTui } from "../tui"; -import { - withRegion, - withJsonRenderer, - withLogging, - withGlobalConfigAccessor, - withProject, -} from "../middleware"; +import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; import type { AppIO } from "../io"; import type { Core } from "./types.tsx"; import type { Logger } from "../logging"; @@ -30,19 +23,7 @@ export interface RootHandlerConfig { export function createRootHandler(core: Core, config: RootHandlerConfig): Router { const { io, logger } = config; - const root = new Router( - "agentcore", - "the platform for production AI agents", - ).supportedTuiCommands( - "harness", - "identity", - "runtime", - "memory", - "gateway", - "eval", - "config", - "project", - ); + const root = new Router("agentcore", "the platform for production AI agents"); // Add global flags root.groupFlags(RegionKey, DebugKey, JsonKey, EndpointKey); @@ -69,10 +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( - withProject({ projectManager: core.projectManager })(createProjectInvokeHandler(core, io)), - ); - 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..525638b3d 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,11 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }), ), ); + project.handler( + withProject({ projectManager: config.projectManager })( + 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. diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 16cdc554c..f431ee8a2 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -120,7 +120,7 @@ async function run( logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - await root.route(["node", "agentcore", "invoke", ...args, "--region", "us-east-2"]); + await root.route(["node", "agentcore", "project", "invoke", ...args, "--region", "us-east-2"]); return { core, io, resolved }; } diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index 2524c9a40..b3f4e3386 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -18,7 +18,6 @@ describe("createRootHandler", () => { "gateway", "eval", "config", - "invoke", "project", ]); }); From e9d1a2a07785c2d7fb8cac9c42e45a2f60eb276b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 19:10:43 +0000 Subject: [PATCH 24/39] test(tui): remove obsolete root invoke assertion --- src/components/RouterScreen.test.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 18e73a75d..f28ee119d 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -32,14 +32,6 @@ describe("menu rendering", () => { r.unmount(); }); - test("does not offer project invoke without a root TUI route", async () => { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "harness"); - - expect(r.lastFrame()).not.toMatch(/^[❯ ]*invoke\s/m); - r.unmount(); - }); - test("renders the harness subcommands when mounted at the harness path", async () => { const r = renderScreen("/agentcore/harness"); await waitForText(r.lastFrame, "list"); From fff864d3844aa8c37a7bddb306d1c832a332b0c7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 19:10:57 +0000 Subject: [PATCH 25/39] docs(project): use project invoke command --- README.md | 22 +++++++++++++------ .../hello-world-python-container/README.md | 2 +- .../templates/hello-world-python/README.md | 2 +- .../templates/strands-http-python/README.md | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9e0584765..69c72e42d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ Identity, and Gateway branches and leaves open their interactive flows. ``` agentcore # interactive TUI -├── invoke # invoke a Runtime or Harness in the current project ├── harness # manage agentcore harnesses │ ├── create # create a harness (auto-provisions a role if none given) │ ├── get # fetch a harness by id @@ -105,6 +104,15 @@ 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 project Runtime or Harness +│ ├── status # inspect deployed project resources +│ └── build # synthesize deployable artifacts └── config # read/write global config values ``` @@ -124,20 +132,20 @@ by its logical project name: ```bash # A project with exactly one Runtime or Harness needs no selector. -agentcore invoke "Summarize this repository." +agentcore project invoke "Summarize this repository." # Select explicitly when the project has multiple invokable resources. -agentcore invoke --runtime checkout "Check order 123." -agentcore invoke --harness support "Help with my account." +agentcore project invoke --runtime checkout "Check order 123." +agentcore project invoke --harness support "Help with my account." # Select another deployment target. -agentcore invoke --target staging --runtime checkout "Run a smoke test." +agentcore project invoke --target staging --runtime checkout "Run a smoke test." # Preserve the Runtime wire response in a JSON envelope. -agentcore invoke --runtime checkout "Check order 123." --json +agentcore project invoke --runtime checkout "Check order 123." --json # Omit content to open the selected resource's interactive console. -agentcore invoke --runtime checkout +agentcore project invoke --runtime checkout ``` Project Runtime content is sent as `{"prompt": content}` with diff --git a/src/assets/templates/hello-world-python-container/README.md b/src/assets/templates/hello-world-python-container/README.md index b6c461acc..a42d46a54 100644 --- a/src/assets/templates/hello-world-python-container/README.md +++ b/src/assets/templates/hello-world-python-container/README.md @@ -29,5 +29,5 @@ agentcore project deploy Invoke the deployed Runtime: ```bash -agentcore invoke "Hello!" +agentcore project invoke "Hello!" ``` diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md index d04fcfed7..c7fd0cfaf 100644 --- a/src/assets/templates/hello-world-python/README.md +++ b/src/assets/templates/hello-world-python/README.md @@ -58,5 +58,5 @@ Deploy from the project root with the AgentCore CLI; the CDK app under ```bash agentcore project deploy -agentcore invoke "Hello!" +agentcore project invoke "Hello!" ``` diff --git a/src/assets/templates/strands-http-python/README.md b/src/assets/templates/strands-http-python/README.md index 7a555a35a..efd136ad2 100644 --- a/src/assets/templates/strands-http-python/README.md +++ b/src/assets/templates/strands-http-python/README.md @@ -42,7 +42,7 @@ After providing credentials, `agentcore project deploy` will deploy your project Invoke the deployed Runtime from the project root: ```bash -agentcore invoke "Hello!" +agentcore project invoke "Hello!" ``` The CLI sends `{"prompt": content}` and streams assistant text from the Strands response. Use `--json` to preserve From 0428cc15dc3eddb0291184561d93392d47a43d09 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 19:48:18 +0000 Subject: [PATCH 26/39] fix(project): separate runtime response summary --- src/handlers/project/invoke/index.test.tsx | 2 ++ src/handlers/project/invoke/index.tsx | 1 + src/handlers/runtime/invoke/response.test.ts | 23 +++++++++++++++++ src/handlers/runtime/invoke/response.ts | 26 ++++++++++++++++++-- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index f431ee8a2..fe2b7254f 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -139,6 +139,7 @@ describe("project invoke", () => { const { core, io, resolved } = await run([content], { runtimes: [RUNTIME] }); expect(io.stdout()).toBe("runtime response"); + expect(io.stderr()).toStartWith("\nstatus=200"); expect(resolved.calls[0]?.input).toEqual({ target: TARGET, resourceType: "runtime", @@ -168,6 +169,7 @@ describe("project invoke", () => { ); expect(io.stdout()).toBe("Hello world"); + expect(io.stderr()).toStartWith("\nstatus=200"); expect(io.stdout()).not.toContain("data:"); expect(io.stdout()).not.toContain("contentBlockDelta"); }); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 5f472d797..cbb7ed84f 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -202,6 +202,7 @@ export const createProjectInvokeHandler = ( }, { binaryTtyError: "Binary or unknown response content requires --json", + separateSummaryFromBody: !jsonOutput, }, ); }); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 3698883ee..7e6583989 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -80,6 +80,29 @@ describe("Runtime invoke response output", () => { expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); }); + test.each([ + { text: "assistant response", summaryPrefix: "\n" }, + { text: "assistant response\n", summaryPrefix: "" }, + ])( + "separates terminal metadata without adding a blank line for %#", + async ({ text, summaryPrefix }) => { + const stdout = capture(); + const stderr = capture(); + + await writeRuntimeInvokeResponse( + response({ body: body(Buffer.from(text)) }), + { + stdout: stdout.stream, + stderr: stderr.stream, + }, + { separateSummaryFromBody: true }, + ); + + expect(stdout.bytes().toString()).toBe(text); + expect(stderr.bytes().toString()).toStartWith(`${summaryPrefix}status=200`); + }, + ); + test("streams plain text before the Runtime response completes", async () => { const stdout = capture(); const stderr = capture(); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 33b1fe047..0af3425dd 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -12,6 +12,7 @@ const BINARY_TTY_ERROR = "Binary or unknown response content requires --output-f type RuntimeInvokeResponseWriterOptions = { binaryTtyError?: string; + separateSummaryFromBody?: boolean; }; export function classifyRuntimeResponse(contentType: string) { @@ -33,6 +34,16 @@ function failure(error: unknown, signal?: AbortSignal): never { throw new RuntimeInvokeResponseError(RESPONSE_STREAM_FAILED, error); } +async function* trackLastByte( + body: AsyncIterable, + update: (value: number) => void, +): AsyncGenerator { + for await (const chunk of body) { + if (chunk.byteLength > 0) update(chunk[chunk.byteLength - 1]!); + yield chunk; + } +} + function summary( response: RuntimeInvokeResponse, byteCount: number, @@ -56,9 +67,20 @@ export async function writeRuntimeInvokeResponse( output: StreamingResponseOutput, options: RuntimeInvokeResponseWriterOptions = {}, ): Promise { - await writeStreamingResponse(response, output, { + let lastBodyByte: number | undefined; + const trackedResponse = options.separateSummaryFromBody + ? { + ...response, + body: trackLastByte(response.body, (value) => { + lastBodyByte = value; + }), + } + : response; + + await writeStreamingResponse(trackedResponse, output, { metadata: ({ body: _body, ...metadata }) => metadata, - summary, + summary: (...args) => + `${options.separateSummaryFromBody && lastBodyByte !== undefined && lastBodyByte !== 0x0a ? "\n" : ""}${summary(...args)}`, fail: (error) => failure(error, output.signal), binaryTtyError: options.binaryTtyError ?? BINARY_TTY_ERROR, }); From bda474116dc9cd6f8a0bc68e1037217ae94e2b63 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 19:48:23 +0000 Subject: [PATCH 27/39] fix(tui): separate request and response blocks --- src/handlers/runtime/invoke/invoke.screen.test.tsx | 5 +++++ src/handlers/runtime/invoke/screen.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 330b24a8f..b6a47ac68 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -963,6 +963,11 @@ describe("Runtime invoke prompt console", () => { await waitForText(screen.lastFrame, content); await waitForText(screen.lastFrame, "Hello world"); await waitForText(screen.lastFrame, "complete · 11 bytes"); + const lines = screen.lastFrame()!.split("\n"); + const requestLine = lines.findIndex((line) => line.includes(content)); + const responseLine = lines.findIndex((line) => line.includes("Response · 200")); + expect(lines[requestLine + 1]).toBe(""); + expect(responseLine).toBe(requestLine + 2); expect(screen.lastFrame()).not.toContain("Enter a valid JSON payload"); expect(screen.lastFrame()).not.toContain("data:"); expect(screen.lastFrame()).not.toContain("contentBlockDelta"); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 8119eb34d..77706906c 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -423,6 +423,7 @@ function RuntimeInvokeConsole({ Request {exchange.payload} + {exchange.heading ?? "Response"} {(prettyJson && exchange.pretty From 3e978e8b7149cbc3cee9e3f10c114d213939ddcc Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 20:02:25 +0000 Subject: [PATCH 28/39] feat(project): add invoke resource picker --- src/components/Root.tsx | 5 ++ .../project/invoke/invoke.screen.test.tsx | 73 +++++++++++++++++ src/handlers/project/invoke/pickerContext.ts | 14 ++++ src/handlers/project/invoke/screen.tsx | 80 +++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 src/handlers/project/invoke/invoke.screen.test.tsx create mode 100644 src/handlers/project/invoke/pickerContext.ts 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. */} { + test("lists project Runtime and Harness resources and returns the selected row", async () => { + let selected: ProjectInvokeSelection | undefined; + const screen = renderScreen("/agentcore/project/invoke", { + withContext: (ctx) => + ctx.withValue(ProjectKey, project).withValue(ProjectInvokePickerContextKey, { + complete: (selection) => { + selected = selection; + }, + }), + }); + + await waitForText(screen.lastFrame, "checkout"); + expect(screen.lastFrame()).toContain("Runtime"); + expect(screen.lastFrame()).toContain("support"); + expect(screen.lastFrame()).toContain("Harness"); + + await screen.press("down"); + await screen.press("return"); + await waitFor(() => selected !== undefined); + expect(selected).toEqual({ resourceType: "harness", name: "support" }); + }); + + test("escape cancels selection", async () => { + let calls = 0; + let selected: ProjectInvokeSelection | undefined = { + resourceType: "runtime", + name: "sentinel", + }; + const screen = renderScreen("/agentcore/project/invoke", { + withContext: (ctx) => + ctx.withValue(ProjectKey, project).withValue(ProjectInvokePickerContextKey, { + complete: (selection) => { + calls++; + selected = selection; + }, + }), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("escape"); + await waitFor(() => calls === 1); + expect(selected).toBeUndefined(); + }); +}); diff --git a/src/handlers/project/invoke/pickerContext.ts b/src/handlers/project/invoke/pickerContext.ts new file mode 100644 index 000000000..e8a971db0 --- /dev/null +++ b/src/handlers/project/invoke/pickerContext.ts @@ -0,0 +1,14 @@ +import { contextKey } from "../../../router"; +import type { ProjectInvokableResource } from "../types"; + +export type ProjectInvokeSelection = { + resourceType: ProjectInvokableResource; + name: string; +}; + +export type ProjectInvokePickerContext = { + complete: (selection: ProjectInvokeSelection | undefined) => void; +}; + +export const ProjectInvokePickerContextKey = + contextKey("project.invoke.picker"); diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx new file mode 100644 index 000000000..10e2933b7 --- /dev/null +++ b/src/handlers/project/invoke/screen.tsx @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useApp } from "ink"; +import { Layout } from "../../../components/Layout"; +import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; +import { ProjectKey } from "../../../router"; +import type { ScreenProps } from "../../types"; +import { ProjectInvokePickerContextKey, type ProjectInvokeSelection } from "./pickerContext"; + +type ProjectInvokableRow = ProjectInvokeSelection & + Record & { + type: "Runtime" | "Harness"; + }; + +const columns = [ + { key: "type", header: "type", width: 10 }, + { key: "name", header: "project name", flex: true }, +] satisfies DataTableColumn[]; + +export function ProjectInvokePickerScreen({ ctx }: ScreenProps) { + const { exit } = useApp(); + const project = ctx.require(ProjectKey); + const picker = ctx.require(ProjectInvokePickerContextKey); + const completed = useRef(false); + const rows = useMemo( + () => [ + ...project.spec.runtimes.map(({ name }) => ({ + resourceType: "runtime" as const, + type: "Runtime" as const, + name, + })), + ...project.spec.harnesses.map(({ name }) => ({ + resourceType: "harness" as const, + type: "Harness" as const, + name, + })), + ], + [project], + ); + + const complete = (selection: ProjectInvokeSelection | undefined) => { + if (completed.current) return; + completed.current = true; + picker.complete(selection); + exit(); + }; + + useEffect( + () => () => { + if (!completed.current) picker.complete(undefined); + }, + [picker], + ); + + return ( + + complete({ resourceType: row.resourceType, name: row.name })} + onEscape={() => complete(undefined)} + /> + + ); +} From 21a8ce0bba31589826a3e042f16f5f0c7ce46209 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 20:02:40 +0000 Subject: [PATCH 29/39] feat(project): open picker for bare invoke --- src/handlers/project/invoke/index.test.tsx | 13 +++++- src/handlers/project/invoke/index.tsx | 49 ++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index fe2b7254f..ca9365b64 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -23,6 +23,7 @@ import { } from "../../../testing"; import type { Project } from "../types"; import type { RuntimeInvokeRequest } from "../../runtime/types"; +import { ProjectInvokePickerContextKey } from "./pickerContext"; const originalCwd = process.cwd(); const temporaryDirectories: string[] = []; @@ -340,14 +341,23 @@ describe("project invoke", () => { ).rejects.toThrow(/Harness session ID must be between 33 and 100 characters/); }); - test("launches the Runtime TUI in prompt mode with the deployment target region", async () => { + test("picks a project resource before launching the Runtime TUI", async () => { await inProject({ runtimes: [RUNTIME] }); const resolved = testBackend(); const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); const project = await core.projectManager.resolve({ filePath: process.cwd() }); const io = testIO(); + const pickerPaths: string[] = []; const launches: { path: string; context: Context }[] = []; const handler = createProjectInvokeHandler(core, io.io, async (path, context) => { + if (path === "/agentcore/project/invoke") { + pickerPaths.push(path); + context.require(ProjectInvokePickerContextKey).complete({ + resourceType: "runtime", + name: "checkout", + }); + return; + } launches.push({ path, context }); }); const context = ValueContext.EmptyContext() @@ -368,6 +378,7 @@ describe("project invoke", () => { { content: undefined }, ); + expect(pickerPaths).toEqual(["/agentcore/project/invoke"]); expect(launches).toHaveLength(1); expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}/prod`); expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index cbb7ed84f..3819c7c87 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -17,11 +17,9 @@ import { JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import type { Project, ProjectInvokableResource } from "../types"; +import { ProjectInvokePickerContextKey, type ProjectInvokeSelection } from "./pickerContext"; -type SelectedResource = { - resourceType: ProjectInvokableResource; - name: string; -}; +type SelectedResource = ProjectInvokeSelection; function availableNames(project: Project, resourceType: ProjectInvokableResource): string[] { return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( @@ -77,6 +75,22 @@ function targetContext(ctx: Context, region: string): Context { return ctx.withValue(RegionKey, region); } +async function pickProjectResource( + ctx: Context, + core: Core, + io: AppIO, + renderInvokeTui: typeof renderTuiAt, +): Promise { + const selection = Promise.withResolvers(); + await renderInvokeTui( + "/agentcore/project/invoke", + ctx.withValue(ProjectInvokePickerContextKey, { complete: selection.resolve }), + core, + io, + ); + return selection.promise; +} + export const createProjectInvokeHandler = ( core: Core, io: AppIO, @@ -98,8 +112,29 @@ export const createProjectInvokeHandler = ( ], handle: async (ctx, flags, args) => { const project = ctx.require(ProjectKey); - const selected = selectResource(project, flags.runtime, flags.harness); const jsonOutput = ctx.require(JsonKey); + if (args.content === undefined && jsonOutput) { + throw new InputValidationError("content is required with --json"); + } + + let selected: SelectedResource | undefined; + if ( + args.content === undefined && + flags.runtime === undefined && + flags.harness === undefined + ) { + if ( + availableNames(project, "runtime").length + availableNames(project, "harness").length === + 0 + ) { + throw new InputValidationError("This project has no Runtimes or Harnesses to invoke."); + } + selected = await pickProjectResource(ctx, core, io, renderInvokeTui); + if (!selected) return; + } else { + selected = selectResource(project, flags.runtime, flags.harness); + } + if (selected.resourceType === "harness" && flags["bearer-token"] !== undefined) { throw new InputValidationError("--bearer-token is only valid with --runtime"); } @@ -110,10 +145,6 @@ export const createProjectInvokeHandler = ( ) { throw new InputValidationError("Harness session ID must be between 33 and 100 characters"); } - if (args.content === undefined && jsonOutput) { - throw new InputValidationError("content is required with --json"); - } - const deployed = await core.projectManager.resolveDeployedResource(project, { target: flags.target, ...selected, From ab854902d22b9e49cf5fbdacfee1006b34bc3bc8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 20:02:49 +0000 Subject: [PATCH 30/39] docs(project): document bare invoke picker --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 69c72e42d..3eddac702 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,9 @@ agentcore project invoke --runtime checkout "Check order 123." --json # Omit content to open the selected resource's interactive console. agentcore project invoke --runtime checkout + +# Omit content and selectors to choose from the project's Runtimes and Harnesses. +agentcore project invoke ``` Project Runtime content is sent as `{"prompt": content}` with From d8ba4c698bb383ee45d036656a742230d2098bad Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 20:33:04 +0000 Subject: [PATCH 31/39] fix(project): clarify invoke picker name column --- src/handlers/project/invoke/invoke.screen.test.tsx | 2 ++ src/handlers/project/invoke/screen.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index e743023c7..e536ce5c4 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -39,6 +39,8 @@ describe("project invoke picker", () => { }); await waitForText(screen.lastFrame, "checkout"); + expect(screen.lastFrame()).toMatch(/type\s+name/); + expect(screen.lastFrame()).not.toContain("project name"); expect(screen.lastFrame()).toContain("Runtime"); expect(screen.lastFrame()).toContain("support"); expect(screen.lastFrame()).toContain("Harness"); diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 10e2933b7..79f6698b6 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -13,7 +13,7 @@ type ProjectInvokableRow = ProjectInvokeSelection & const columns = [ { key: "type", header: "type", width: 10 }, - { key: "name", header: "project name", flex: true }, + { key: "name", header: "name", flex: true }, ] satisfies DataTableColumn[]; export function ProjectInvokePickerScreen({ ctx }: ScreenProps) { From 6317ba9ef16453f42399fc6a93109e74dac35399 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 20:39:09 +0000 Subject: [PATCH 32/39] 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 77706906c..7e33dd374 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -141,20 +141,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(); @@ -317,6 +319,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 323dba6ae2b0a94051378448e603d1ac4fffc16d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 20:39:21 +0000 Subject: [PATCH 33/39] fix(project): keep invoke picker in one TUI session --- src/handlers/project/invoke/index.test.tsx | 25 +-- src/handlers/project/invoke/index.tsx | 49 ++--- .../project/invoke/invoke.screen.test.tsx | 87 ++++++--- src/handlers/project/invoke/pickerContext.ts | 15 +- src/handlers/project/invoke/screen.tsx | 175 ++++++++++++++---- 5 files changed, 238 insertions(+), 113 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index ca9365b64..429539c8d 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -13,7 +13,6 @@ import type { ProjectBackend, ResolveDeployedResourceBackendInput } from "../../ import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { JsonKey, RegionKey } from "../../keys"; import { ProjectKey, ValueContext, type Context } from "../../../router"; -import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; import { createSilentLogger, TestCoreClient, @@ -341,23 +340,14 @@ describe("project invoke", () => { ).rejects.toThrow(/Harness session ID must be between 33 and 100 characters/); }); - test("picks a project resource before launching the Runtime TUI", async () => { + test("opens the project picker once with the invoke launch options", async () => { await inProject({ runtimes: [RUNTIME] }); const resolved = testBackend(); const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); const project = await core.projectManager.resolve({ filePath: process.cwd() }); const io = testIO(); - const pickerPaths: string[] = []; const launches: { path: string; context: Context }[] = []; const handler = createProjectInvokeHandler(core, io.io, async (path, context) => { - if (path === "/agentcore/project/invoke") { - pickerPaths.push(path); - context.require(ProjectInvokePickerContextKey).complete({ - resourceType: "runtime", - name: "checkout", - }); - return; - } launches.push({ path, context }); }); const context = ValueContext.EmptyContext() @@ -378,15 +368,14 @@ describe("project invoke", () => { { content: undefined }, ); - expect(pickerPaths).toEqual(["/agentcore/project/invoke"]); expect(launches).toHaveLength(1); - expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}/prod`); - expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); - expect(launches[0]!.context.require(RuntimeInvokeLaunchContextKey)).toEqual({ - runtimeId: RUNTIME_ID, - runtimeSessionId: "project-session", + expect(launches[0]!.path).toBe("/agentcore/project/invoke"); + expect(launches[0]!.context.require(ProjectInvokePickerContextKey)).toEqual({ + target: "default", + sessionId: "project-session", + qualifier: "prod", bearerToken: undefined, - inputMode: "prompt", }); + expect(resolved.calls).toEqual([]); }); }); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 3819c7c87..48b16e3e6 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -17,9 +17,15 @@ import { JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import type { Project, ProjectInvokableResource } from "../types"; -import { ProjectInvokePickerContextKey, type ProjectInvokeSelection } from "./pickerContext"; +import { + ProjectInvokePickerContextKey, + type ProjectInvokePickerLaunchContext, +} from "./pickerContext"; -type SelectedResource = ProjectInvokeSelection; +type SelectedResource = { + resourceType: ProjectInvokableResource; + name: string; +}; function availableNames(project: Project, resourceType: ProjectInvokableResource): string[] { return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( @@ -75,22 +81,6 @@ function targetContext(ctx: Context, region: string): Context { return ctx.withValue(RegionKey, region); } -async function pickProjectResource( - ctx: Context, - core: Core, - io: AppIO, - renderInvokeTui: typeof renderTuiAt, -): Promise { - const selection = Promise.withResolvers(); - await renderInvokeTui( - "/agentcore/project/invoke", - ctx.withValue(ProjectInvokePickerContextKey, { complete: selection.resolve }), - core, - io, - ); - return selection.promise; -} - export const createProjectInvokeHandler = ( core: Core, io: AppIO, @@ -117,7 +107,6 @@ export const createProjectInvokeHandler = ( throw new InputValidationError("content is required with --json"); } - let selected: SelectedResource | undefined; if ( args.content === undefined && flags.runtime === undefined && @@ -129,12 +118,26 @@ export const createProjectInvokeHandler = ( ) { throw new InputValidationError("This project has no Runtimes or Harnesses to invoke."); } - selected = await pickProjectResource(ctx, core, io, renderInvokeTui); - if (!selected) return; - } else { - selected = selectResource(project, flags.runtime, flags.harness); + const bearerToken = await resolveRuntimeInvokeTuiBearerToken( + flags["bearer-token"], + io.stdin, + ); + const launchContext: ProjectInvokePickerLaunchContext = { + target: flags.target, + sessionId: flags["session-id"], + qualifier: flags.qualifier, + bearerToken, + }; + await renderInvokeTui( + "/agentcore/project/invoke", + ctx.withValue(ProjectInvokePickerContextKey, launchContext), + core, + io, + ); + return; } + const selected = selectResource(project, flags.runtime, flags.harness); if (selected.resourceType === "harness" && flags["bearer-token"] !== undefined) { throw new InputValidationError("--bearer-token is only valid with --runtime"); } diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index e536ce5c4..174d6d7a6 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; +import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; -import { cleanupScreens, renderScreen, waitFor, waitForText } from "../../../testing"; -import type { Project } from "../types"; -import { ProjectInvokePickerContextKey, type ProjectInvokeSelection } from "./pickerContext"; +import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing"; +import type { Project, ResolveDeployedResourceInput } from "../types"; +import { ProjectInvokePickerContextKey } from "./pickerContext"; afterEach(cleanupScreens); @@ -27,49 +28,83 @@ const project: Project = { }; describe("project invoke picker", () => { - test("lists project Runtime and Harness resources and returns the selected row", async () => { - let selected: ProjectInvokeSelection | undefined; + test("lists project Runtime and Harness resources with project metadata", async () => { const screen = renderScreen("/agentcore/project/invoke", { withContext: (ctx) => - ctx.withValue(ProjectKey, project).withValue(ProjectInvokePickerContextKey, { - complete: (selection) => { - selected = selection; - }, - }), + ctx + .withValue(ProjectKey, project) + .withValue(ProjectInvokePickerContextKey, { target: "default" }), }); await waitForText(screen.lastFrame, "checkout"); expect(screen.lastFrame()).toMatch(/type\s+name/); expect(screen.lastFrame()).not.toContain("project name"); 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("resolves the selected resource and opens its existing TUI in the same screen", async () => { + const calls: ResolveDeployedResourceInput[] = []; + const core = new TestCoreClient(); + core.projectManager.resolveDeployedResource = async (_project, input) => { + calls.push(input); + return { + id: input.resourceType === "runtime" ? "runtime-123" : "harness-123", + target: { + name: "default", + account: "111122223333", + region: "eu-west-1", + }, + }; + }; + core.harness.setGetResponse({ + harness: { + harnessId: "harness-123", + harnessName: "support", + arn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:harness/harness-123", + }, + } as GetHarnessResponse); + const screen = renderScreen("/agentcore/project/invoke", { + core, + withContext: (ctx) => + ctx + .withValue(ProjectKey, project) + .withValue(ProjectInvokePickerContextKey, { target: "default" }), + }); + + await waitForText(screen.lastFrame, "checkout"); await screen.press("down"); await screen.press("return"); - await waitFor(() => selected !== undefined); - expect(selected).toEqual({ resourceType: "harness", name: "support" }); + await waitForText(screen.lastFrame, "send a message…"); + expect(calls).toEqual([ + { + target: "default", + resourceType: "harness", + name: "support", + }, + ]); }); - test("escape cancels selection", async () => { - let calls = 0; - let selected: ProjectInvokeSelection | undefined = { - resourceType: "runtime", - name: "sentinel", + test("shows resolution failures without leaving the picker", async () => { + const core = new TestCoreClient(); + core.projectManager.resolveDeployedResource = async () => { + throw new Error("project is not deployed"); }; const screen = renderScreen("/agentcore/project/invoke", { + core, withContext: (ctx) => - ctx.withValue(ProjectKey, project).withValue(ProjectInvokePickerContextKey, { - complete: (selection) => { - calls++; - selected = selection; - }, - }), + ctx + .withValue(ProjectKey, project) + .withValue(ProjectInvokePickerContextKey, { target: "default" }), }); await waitForText(screen.lastFrame, "checkout"); - await screen.press("escape"); - await waitFor(() => calls === 1); - expect(selected).toBeUndefined(); + await screen.press("return"); + await waitForText(screen.lastFrame, "project is not deployed"); + expect(screen.lastFrame()).toContain("checkout"); }); }); diff --git a/src/handlers/project/invoke/pickerContext.ts b/src/handlers/project/invoke/pickerContext.ts index e8a971db0..edc4e4ad9 100644 --- a/src/handlers/project/invoke/pickerContext.ts +++ b/src/handlers/project/invoke/pickerContext.ts @@ -1,14 +1,11 @@ import { contextKey } from "../../../router"; -import type { ProjectInvokableResource } from "../types"; -export type ProjectInvokeSelection = { - resourceType: ProjectInvokableResource; - name: string; -}; - -export type ProjectInvokePickerContext = { - complete: (selection: ProjectInvokeSelection | undefined) => void; +export type ProjectInvokePickerLaunchContext = { + target: string; + sessionId?: string; + qualifier?: string; + bearerToken?: string; }; export const ProjectInvokePickerContextKey = - contextKey("project.invoke.picker"); + contextKey("project.invoke.picker"); diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 79f6698b6..c2ea92ca5 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -1,60 +1,156 @@ -import { useEffect, useMemo, useRef } from "react"; -import { useApp } from "ink"; +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 { ProjectKey } from "../../../router"; +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"; -import { ProjectInvokePickerContextKey, type ProjectInvokeSelection } from "./pickerContext"; +import { ProjectInvokePickerContextKey } from "./pickerContext"; -type ProjectInvokableRow = ProjectInvokeSelection & - Record & { - type: "Runtime" | "Harness"; - }; +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[]; -export function ProjectInvokePickerScreen({ ctx }: ScreenProps) { +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 picker = ctx.require(ProjectInvokePickerContextKey); - const completed = useRef(false); + const launch = ctx.require(ProjectInvokePickerContextKey); + const [destination, setDestination] = useState(); + const [resolving, setResolving] = useState(); + const [error, setError] = useState(); const rows = useMemo( () => [ - ...project.spec.runtimes.map(({ name }) => ({ + ...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 }) => ({ + ...project.spec.harnesses.map(({ name, path }) => ({ resourceType: "harness" as const, type: "Harness" as const, name, + protocol: "-", + source: path, })), ], [project], ); - const complete = (selection: ProjectInvokeSelection | undefined) => { - if (completed.current) return; - completed.current = true; - picker.complete(selection); - exit(); + const select = async (row: ProjectInvokableRow) => { + if (resolving) return; + if (row.resourceType === "harness" && launch.bearerToken !== undefined) { + setError("--bearer-token is only valid with --runtime"); + return; + } + if ( + row.resourceType === "harness" && + launch.sessionId !== undefined && + (launch.sessionId.length < 33 || launch.sessionId.length > 100) + ) { + setError("Harness session ID must be between 33 and 100 characters"); + return; + } + + setError(undefined); + setResolving(row.name); + try { + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: launch.target, + resourceType: row.resourceType, + name: row.name, + }); + setDestination({ + resourceType: row.resourceType, + id: deployed.id, + ctx: ctx.withValue(RegionKey, deployed.target.region), + ...(row.resourceType === "runtime" && { qualifier: launch.qualifier }), + }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setResolving(undefined); + } }; - useEffect( - () => () => { - if (!completed.current) picker.complete(undefined); - }, - [picker], - ); + 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 ( - complete({ resourceType: row.resourceType, name: row.name })} - onEscape={() => complete(undefined)} - /> + + {error ? {error} : null} + void select(row)} + onEscape={exit} + /> + {resolving ? : null} + ); } From 08732fe52fb6d394601d858e7d021081d4bec93c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 22:09:42 +0000 Subject: [PATCH 34/39] feat(project): add compatible invoke shorthand --- src/components/RouterScreen.test.tsx | 7 ++ src/handlers/index.tsx | 26 +++++- src/handlers/project/invoke/index.test.tsx | 81 ++++++++++++++++++- src/handlers/project/invoke/index.tsx | 22 +++-- .../project/invoke/invoke.screen.test.tsx | 33 +++++++- src/handlers/project/invoke/screen.tsx | 2 +- src/handlers/root.test.tsx | 1 + 7 files changed, 153 insertions(+), 19 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index f28ee119d..29b86db4a 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -32,6 +32,13 @@ describe("menu rendering", () => { r.unmount(); }); + test("does not offer the project-only invoke alias in the root menu", async () => { + const r = renderScreen("/agentcore"); + await waitForText(r.lastFrame, "harness"); + expect(r.lastFrame()).not.toMatch(/^[❯ ]*invoke\s/m); + r.unmount(); + }); + test("renders the harness subcommands when mounted at the harness path", async () => { const r = renderScreen("/agentcore/harness"); await waitForText(r.lastFrame, "list"); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 0429b2199..07c626cbf 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -8,8 +8,15 @@ import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; +import { createProjectInvokeHandler } from "./project/invoke"; import { renderTui } from "../tui"; -import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; +import { + withRegion, + withJsonRenderer, + withLogging, + withGlobalConfigAccessor, + withProject, +} from "../middleware"; import type { AppIO } from "../io"; import type { Core } from "./types.tsx"; import type { Logger } from "../logging"; @@ -23,7 +30,19 @@ export interface RootHandlerConfig { export function createRootHandler(core: Core, config: RootHandlerConfig): Router { const { io, logger } = config; - const root = new Router("agentcore", "the platform for production AI agents"); + const root = new Router( + "agentcore", + "the platform for production AI agents", + ).supportedTuiCommands( + "harness", + "identity", + "runtime", + "memory", + "gateway", + "eval", + "config", + "project", + ); // Add global flags root.groupFlags(RegionKey, DebugKey, JsonKey, EndpointKey); @@ -50,6 +69,9 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); + root.handler( + withProject({ projectManager: core.projectManager })(createProjectInvokeHandler(core, io)), + ); root.handler(createProjectHandler(core, { projectManager: core.projectManager, io })); // Invoking with no subcommand launches the interactive TUI. diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 429539c8d..2832f20b6 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -22,6 +22,7 @@ import { } from "../../../testing"; import type { Project } from "../types"; import type { RuntimeInvokeRequest } from "../../runtime/types"; +import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; import { ProjectInvokePickerContextKey } from "./pickerContext"; const originalCwd = process.cwd(); @@ -93,6 +94,7 @@ async function run( resources: { runtimes?: unknown[]; harnesses?: unknown[] }, configure?: (core: TestCoreClient) => void, io: TestIO = testIO(), + command: "project" | "alias" = "project", ) { await inProject(resources); const resolved = testBackend(); @@ -120,7 +122,14 @@ async function run( logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - await root.route(["node", "agentcore", "project", "invoke", ...args, "--region", "us-east-2"]); + await root.route([ + "node", + "agentcore", + ...(command === "project" ? ["project", "invoke"] : ["invoke"]), + ...args, + "--region", + "us-east-2", + ]); return { core, io, resolved }; } @@ -153,6 +162,30 @@ describe("project invoke", () => { expect(core.runtime.calls[1]!.args[1]).toEqual({ region: TARGET.region }); }); + test("top-level alias reuses project invoke behavior inside a project", async () => { + const { io } = await run(["hello"], { runtimes: [RUNTIME] }, undefined, testIO(), "alias"); + expect(io.stdout()).toBe("runtime response"); + }); + + test("top-level alias fails before service calls outside a project", async () => { + const directory = await mkdtemp(join(tmpdir(), "agentcore-invoke-outside-project-")); + temporaryDirectories.push(directory); + process.chdir(directory); + const io = testIO(); + const core = new TestCoreClient(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await expect( + root.route(["node", "agentcore", "invoke", "hello", "--region", "us-east-2"]), + ).rejects.toThrow(/No AgentCore project found.*agentcore\/agentcore\.json/s); + expect(core.runtime.calls).toEqual([]); + expect(core.harness.calls).toEqual([]); + }); + test("streams only assistant text from a Strands Runtime response", async () => { const wire = [ 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n', @@ -341,7 +374,7 @@ describe("project invoke", () => { }); test("opens the project picker once with the invoke launch options", async () => { - await inProject({ runtimes: [RUNTIME] }); + await inProject({ runtimes: [RUNTIME], harnesses: [HARNESS] }); const resolved = testBackend(); const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); const project = await core.projectManager.resolve({ filePath: process.cwd() }); @@ -378,4 +411,48 @@ describe("project invoke", () => { }); expect(resolved.calls).toEqual([]); }); + + test("auto-opens the only project resource at its default endpoint", async () => { + await inProject({ runtimes: [RUNTIME] }); + const resolved = testBackend(); + const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); + const project = await core.projectManager.resolve({ filePath: process.cwd() }); + const io = testIO(); + const launches: { path: string; context: Context }[] = []; + const handler = createProjectInvokeHandler(core, io.io, async (path, context) => { + launches.push({ path, context }); + }); + const context = ValueContext.EmptyContext() + .withValue(ProjectKey, project!) + .withValue(JsonKey, false) + .withValue(RegionKey, "us-east-2"); + + await handler.handle( + context, + { + runtime: undefined, + harness: undefined, + target: "default", + "session-id": "project-session", + qualifier: undefined, + "bearer-token": undefined, + }, + { content: undefined }, + ); + + expect(launches).toHaveLength(1); + expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}/DEFAULT`); + expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); + expect(launches[0]!.context.require(RuntimeInvokeLaunchContextKey)).toEqual({ + runtimeId: RUNTIME_ID, + runtimeSessionId: "project-session", + bearerToken: undefined, + inputMode: "prompt", + }); + expect(resolved.calls[0]!.input).toEqual({ + target: TARGET, + resourceType: "runtime", + name: "checkout", + }); + }); }); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 48b16e3e6..2bd7a80ac 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -107,17 +107,11 @@ export const createProjectInvokeHandler = ( throw new InputValidationError("content is required with --json"); } - if ( - args.content === undefined && - flags.runtime === undefined && - flags.harness === undefined - ) { - if ( - availableNames(project, "runtime").length + availableNames(project, "harness").length === - 0 - ) { - throw new InputValidationError("This project has no Runtimes or Harnesses to invoke."); - } + const bareInvoke = + args.content === undefined && flags.runtime === undefined && flags.harness === undefined; + const invokableCount = + availableNames(project, "runtime").length + availableNames(project, "harness").length; + if (bareInvoke && invokableCount > 1) { const bearerToken = await resolveRuntimeInvokeTuiBearerToken( flags["bearer-token"], io.stdin, @@ -157,8 +151,10 @@ export const createProjectInvokeHandler = ( if (args.content === undefined) { if (selected.resourceType === "runtime") { - let path = `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}`; - if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; + const qualifier = flags.qualifier ?? "DEFAULT"; + const path = + `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}` + + `/${encodeURIComponent(qualifier)}`; const bearerToken = await resolveRuntimeInvokeTuiBearerToken( flags["bearer-token"], io.stdin, diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index 174d6d7a6..3f5a151c0 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -1,5 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; -import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import type { + GetAgentRuntimeResponse, + GetHarnessResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing"; @@ -89,6 +92,34 @@ describe("project invoke picker", () => { ]); }); + test("opens a selected Runtime at DEFAULT without another endpoint picker", async () => { + const core = new TestCoreClient(); + core.projectManager.resolveDeployedResource = async (_project, input) => ({ + id: "runtime-123", + target: { + name: input.target, + account: "111122223333", + region: "eu-west-1", + }, + }); + core.runtime.setGetResponse({ + agentRuntimeArn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime/runtime-123", + } as GetAgentRuntimeResponse); + const screen = renderScreen("/agentcore/project/invoke", { + core, + withContext: (ctx) => + ctx + .withValue(ProjectKey, project) + .withValue(ProjectInvokePickerContextKey, { target: "default" }), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Enter prompt"); + expect(screen.lastFrame()).toContain("runtime-123 → DEFAULT"); + expect(screen.lastFrame()).not.toContain("choose an endpoint"); + }); + test("shows resolution failures without leaving the picker", async () => { const core = new TestCoreClient(); core.projectManager.resolveDeployedResource = async () => { diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index c2ea92ca5..0df17384c 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -93,7 +93,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { resourceType: row.resourceType, id: deployed.id, ctx: ctx.withValue(RegionKey, deployed.target.region), - ...(row.resourceType === "runtime" && { qualifier: launch.qualifier }), + ...(row.resourceType === "runtime" && { qualifier: launch.qualifier ?? "DEFAULT" }), }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index b3f4e3386..2524c9a40 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -18,6 +18,7 @@ describe("createRootHandler", () => { "gateway", "eval", "config", + "invoke", "project", ]); }); From a5c1aa89ac4ed15f6fa7448f670f7f95b1b7c8a6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 22:09:56 +0000 Subject: [PATCH 35/39] docs(project): document invoke shorthand --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 3eddac702..8573ca6d4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Identity, and Gateway branches and leaves open their interactive flows. ``` agentcore # interactive TUI +├── invoke # project-aware invoke shorthand (requires a project) ├── harness # manage agentcore harnesses │ ├── create # create a harness (auto-provisions a role if none given) │ ├── get # fetch a harness by id @@ -149,6 +150,9 @@ agentcore project invoke --runtime checkout # Omit content and selectors to choose from the project's Runtimes and Harnesses. agentcore project invoke + +# The project-aware shorthand has the same behavior inside a project. +agentcore invoke ``` Project Runtime content is sent as `{"prompt": content}` with From d6e5c36a13562c9276a14406800a2ebf6f9a592f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 01:49:32 +0000 Subject: [PATCH 36/39] feat(tui): auto-select sole picker result --- src/components/PaginatedTablePicker.tsx | 32 +++++++++++++-- src/components/RuntimeEndpointPicker.tsx | 3 ++ .../runtime/invoke/invoke.screen.test.tsx | 40 +++++++++++++++++++ src/handlers/runtime/invoke/launchContext.ts | 1 + src/handlers/runtime/invoke/screen.tsx | 1 + 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/components/PaginatedTablePicker.tsx b/src/components/PaginatedTablePicker.tsx index 8b373f158..f9105477e 100644 --- a/src/components/PaginatedTablePicker.tsx +++ b/src/components/PaginatedTablePicker.tsx @@ -1,5 +1,6 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Text, useInput } from "ink"; +import { useEffect, useRef } from "react"; import { Layout } from "./Layout"; import { usePagedList } from "./usePagedList"; import { darkTheme } from "./ui/_core.js"; @@ -27,6 +28,7 @@ export interface PaginatedTablePickerProps>({ @@ -45,6 +47,7 @@ export function PaginatedTablePicker emptyMessage, emptyPageMessage, maxPageSize, + autoSelectSingle = false, }: PaginatedTablePickerProps) { const paging = usePagedList(maxPageSize); const list = useQuery({ @@ -57,6 +60,27 @@ export function PaginatedTablePicker const pageTransition = list.isFetching && !list.isPending; const mappedRows = (list.data?.items ?? []).map(toRow); const rows = sortRows ? sortRows(mappedRows) : mappedRows; + const autoSelectIdentity = JSON.stringify(queryKey); + const lastAutoSelection = useRef(undefined); + const autoSelectValue = + autoSelectSingle && + !list.isPending && + !list.isError && + !list.isFetching && + paging.pageIndex === 0 && + nextToken === undefined && + rows.length === 1 + ? getValue(rows[0]!) + : undefined; + const autoSelecting = + autoSelectValue !== undefined && + lastAutoSelection.current !== `${autoSelectIdentity}:${autoSelectValue}`; + + useEffect(() => { + if (!autoSelecting || autoSelectValue === undefined) return; + lastAutoSelection.current = `${autoSelectIdentity}:${autoSelectValue}`; + onSelect(autoSelectValue); + }, [autoSelectIdentity, autoSelectValue, autoSelecting, onSelect]); useInput( (input, key) => { @@ -70,7 +94,7 @@ export function PaginatedTablePicker } if (input === "r" && list.isError) void list.refetch(); }, - { isActive: list.isPending || list.isError || pageTransition }, + { isActive: list.isPending || list.isError || pageTransition || autoSelecting }, ); return ( @@ -78,7 +102,7 @@ export function PaginatedTablePicker breadcrumb={breadcrumb} description={description} keyHints={[ - ...(!list.isPending && !list.isError && !pageTransition + ...(!list.isPending && !list.isError && !pageTransition && !autoSelecting ? [ { key: "↑↓/jk", label: "navigate" }, ...(paginated ? [{ key: "←→/hl", label: "page" }] : []), @@ -92,8 +116,8 @@ export function PaginatedTablePicker { key: "ctl+c", label: "quit" }, ]} > - {list.isPending ? ( - + {list.isPending || autoSelecting ? ( + ) : list.isError ? ( {errorMessage(list.error as Error)} ) : ( diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index fc46edcc3..bcd6ff54c 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -43,6 +43,7 @@ export interface RuntimeEndpointPickerProps extends ScreenProps { description?: string; onSelect: (qualifier: string) => void; onEscape?: () => void; + autoSelectSingle?: boolean; } export function RuntimeEndpointPicker({ @@ -53,6 +54,7 @@ export function RuntimeEndpointPicker({ description, onSelect, onEscape, + autoSelectSingle, }: RuntimeEndpointPickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); @@ -79,6 +81,7 @@ export function RuntimeEndpointPicker({ errorMessage={(error) => `Error loading endpoints for Runtime ${runtimeId}: ${error.message}`} emptyMessage="This Runtime has no endpoints." emptyPageMessage={`No endpoints on this page for Runtime ${runtimeId}.`} + autoSelectSingle={autoSelectSingle} /> ); } diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index b6a47ac68..2aa5fc8d4 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -140,6 +140,46 @@ describe("Runtime invoke routing", () => { expect(screen.lastFrame()).not.toContain("MCP session ID"); }); + test("auto-selects a sole endpoint when the launch context opts in", async () => { + const core = new TestCoreClient(); + core.runtime + .setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }) + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + inputMode: "prompt", + autoSelectSingleEndpoint: true, + }), + }); + + await waitForText(screen.lastFrame, "Enter prompt"); + expect(screen.lastFrame()).toContain(`${RUNTIME_ID} → ${QUALIFIER}`); + expect(screen.lastFrame()).not.toContain("choose an endpoint"); + }); + + test("does not auto-select when another endpoint page exists", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + nextToken: "page-2", + }); + const screen = renderScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + autoSelectSingleEndpoint: true, + }), + }); + + await waitForText(screen.lastFrame, QUALIFIER); + expect(screen.lastFrame()).toContain("choose an endpoint"); + expect(screen.lastFrame()).not.toContain("Enter JSON payload"); + }); + test("escape switches endpoints without restoring the launch session", async () => { const nextQualifier = "back-endpoint"; const core = new TestCoreClient(); diff --git a/src/handlers/runtime/invoke/launchContext.ts b/src/handlers/runtime/invoke/launchContext.ts index 98919d639..7a659dcd5 100644 --- a/src/handlers/runtime/invoke/launchContext.ts +++ b/src/handlers/runtime/invoke/launchContext.ts @@ -7,6 +7,7 @@ export type RuntimeInvokeLaunchContext = { runtimeUserId?: string; applicationHeaders?: [string, string][]; bearerToken?: string; + autoSelectSingleEndpoint?: boolean; }; export const RuntimeInvokeLaunchContextKey = diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 7e33dd374..0d9e148af 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -119,6 +119,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { runtimeId={runtimeId} breadcrumb={["agentcore", "runtime", "invoke", runtimeId]} description="choose an endpoint to invoke" + autoSelectSingle={initialContext?.autoSelectSingleEndpoint} onSelect={(selected) => navigate(invokePath(runtimeId, selected), { replace: returnOnEscape === true, From aecf4561bd3b741782dfc238100588cd9255d10b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 01:49:52 +0000 Subject: [PATCH 37/39] fix(project): discover runtime endpoints before invoke --- src/handlers/project/invoke/index.test.tsx | 3 +- src/handlers/project/invoke/index.tsx | 7 ++- .../project/invoke/invoke.screen.test.tsx | 49 ++++++++++++++++++- src/handlers/project/invoke/screen.tsx | 3 +- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 2832f20b6..3d6bae167 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -441,13 +441,14 @@ describe("project invoke", () => { ); expect(launches).toHaveLength(1); - expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}/DEFAULT`); + 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)).toEqual({ runtimeId: RUNTIME_ID, runtimeSessionId: "project-session", bearerToken: undefined, inputMode: "prompt", + autoSelectSingleEndpoint: true, }); expect(resolved.calls[0]!.input).toEqual({ target: TARGET, diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 2bd7a80ac..7bb0b9e45 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -151,10 +151,8 @@ export const createProjectInvokeHandler = ( if (args.content === undefined) { if (selected.resourceType === "runtime") { - const qualifier = flags.qualifier ?? "DEFAULT"; - const path = - `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}` + - `/${encodeURIComponent(qualifier)}`; + let path = `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}`; + if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; const bearerToken = await resolveRuntimeInvokeTuiBearerToken( flags["bearer-token"], io.stdin, @@ -166,6 +164,7 @@ export const createProjectInvokeHandler = ( runtimeSessionId: flags["session-id"], bearerToken, inputMode: "prompt", + autoSelectSingleEndpoint: flags.qualifier === undefined, }), core, io, diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index 3f5a151c0..1320d09ab 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { + AgentRuntimeEndpoint, GetAgentRuntimeResponse, GetHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; @@ -30,6 +31,20 @@ const project: Project = { }), }; +function runtimeEndpoint(name: string): AgentRuntimeEndpoint { + return { + id: name, + name, + agentRuntimeEndpointArn: `arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime-endpoint/${name}`, + agentRuntimeArn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime/runtime-123", + createdAt: new Date("2026-08-28T00:00:00.000Z"), + liveVersion: "1", + targetVersion: "1", + status: "READY", + lastUpdatedAt: new Date("2026-08-28T00:00:00.000Z"), + }; +} + describe("project invoke picker", () => { test("lists project Runtime and Harness resources with project metadata", async () => { const screen = renderScreen("/agentcore/project/invoke", { @@ -92,7 +107,7 @@ describe("project invoke picker", () => { ]); }); - test("opens a selected Runtime at DEFAULT without another endpoint picker", async () => { + test("auto-selects a selected Runtime's only endpoint", async () => { const core = new TestCoreClient(); core.projectManager.resolveDeployedResource = async (_project, input) => ({ id: "runtime-123", @@ -105,6 +120,9 @@ describe("project invoke picker", () => { core.runtime.setGetResponse({ agentRuntimeArn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime/runtime-123", } as GetAgentRuntimeResponse); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [runtimeEndpoint("DEFAULT")], + }); const screen = renderScreen("/agentcore/project/invoke", { core, withContext: (ctx) => @@ -120,6 +138,35 @@ describe("project invoke picker", () => { expect(screen.lastFrame()).not.toContain("choose an endpoint"); }); + test("shows the endpoint picker when a selected Runtime has multiple endpoints", async () => { + const core = new TestCoreClient(); + core.projectManager.resolveDeployedResource = async (_project, input) => ({ + id: "runtime-123", + target: { + name: input.target, + account: "111122223333", + region: "eu-west-1", + }, + }); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [runtimeEndpoint("DEFAULT"), runtimeEndpoint("production")], + }); + const screen = renderScreen("/agentcore/project/invoke", { + core, + withContext: (ctx) => + ctx + .withValue(ProjectKey, project) + .withValue(ProjectInvokePickerContextKey, { target: "default" }), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("return"); + await waitForText(screen.lastFrame, "production"); + expect(screen.lastFrame()).toContain("choose an endpoint"); + expect(screen.lastFrame()).toContain("DEFAULT"); + expect(screen.lastFrame()).not.toContain("Enter prompt"); + }); + test("shows resolution failures without leaving the picker", async () => { const core = new TestCoreClient(); core.projectManager.resolveDeployedResource = async () => { diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 0df17384c..73a562cfa 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -93,7 +93,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { resourceType: row.resourceType, id: deployed.id, ctx: ctx.withValue(RegionKey, deployed.target.region), - ...(row.resourceType === "runtime" && { qualifier: launch.qualifier ?? "DEFAULT" }), + ...(row.resourceType === "runtime" && { qualifier: launch.qualifier }), }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); @@ -111,6 +111,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { runtimeId={destination.id} breadcrumb={["agentcore", "runtime", "invoke", destination.id]} description="choose an endpoint to invoke" + autoSelectSingle onSelect={(qualifier) => setDestination({ ...destination, qualifier })} onEscape={() => setDestination(undefined)} /> From fc8e69f7cedcf5363b1ef38fb2088a5636186cfb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 02:22:24 +0000 Subject: [PATCH 38/39] test(invoke): localize agent event coverage --- src/core/dev/inspector/invocations.test.ts | 16 ++++-------- .../runtime/invoke/promptResponse.test.ts | 25 ++++++++++++------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/core/dev/inspector/invocations.test.ts b/src/core/dev/inspector/invocations.test.ts index 17c617543..a39096f2c 100644 --- a/src/core/dev/inspector/invocations.test.ts +++ b/src/core/dev/inspector/invocations.test.ts @@ -63,20 +63,14 @@ describe("upstream connection failures", () => { }); describe("HTTP agent SSE normalization", () => { - test.each([ - { name: "a bedrock text event", frame: JSON.stringify({ text: "hello" }), expected: "hello" }, - { name: "a bare JSON string token", frame: JSON.stringify("world"), expected: "world" }, - { - name: "a ConverseStream content delta", - frame: JSON.stringify({ event: { contentBlockDelta: { delta: { text: "delta" } } } }), - expected: "delta", - }, - { name: "a non-JSON plain-text token", frame: "raw-token", expected: "raw-token" }, - ])("normalizes $name to a data frame", async ({ frame, expected }) => { + test("normalizes a shared agent text event to a data frame", async () => { + const frame = JSON.stringify({ + event: { contentBlockDelta: { delta: { text: "delta" } } }, + }); const { url } = await inspectorFor(sseAgent([frame])); const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); expect(response.headers.get("content-type")).toContain("text/event-stream"); - expect(await response.text()).toBe(`data: ${JSON.stringify(expected)}\n\n`); + expect(await response.text()).toBe(`data: ${JSON.stringify("delta")}\n\n`); }); test("re-frames an agent error event as an error payload", async () => { diff --git a/src/handlers/runtime/invoke/promptResponse.test.ts b/src/handlers/runtime/invoke/promptResponse.test.ts index 8171e1fff..7ef227c01 100644 --- a/src/handlers/runtime/invoke/promptResponse.test.ts +++ b/src/handlers/runtime/invoke/promptResponse.test.ts @@ -47,20 +47,27 @@ describe("renderPromptResponseBody", () => { ); }); - test.each([ - ['data: "JSON string"\n\n', "JSON string"], - ['data: {"text":"text object"}\n\n', "text object"], - ["data: non-JSON token\n\n", "non-JSON token"], - ])("streams a shared agent event shape %#", async (wire, expected) => { + test("streams a shared agent text event", async () => { + const wire = 'data: {"text":"text object"}\n\n'; + expect(await read(renderPromptResponseBody("text/event-stream", body(Buffer.from(wire))))).toBe( - expected, + "text object", ); }); - test("passes through an unsupported SSE frame before the source completes", async () => { + test.each([ + { + name: "an unsupported SSE frame", + chunk: Buffer.from('data: {"progress":1}\n\n'), + }, + { + name: "an unterminated payload at the sniff limit", + chunk: Buffer.alloc(64 * 1024, "x"), + }, + ])("passes through $name before the source completes", async ({ chunk }) => { const finish = Promise.withResolvers(); const source = (async function* () { - yield Buffer.from('data: {"progress":1}\n\n'); + yield chunk; await finish.promise; })(); const iterator = renderPromptResponseBody("text/event-stream", source)[Symbol.asyncIterator](); @@ -74,7 +81,7 @@ describe("renderPromptResponseBody", () => { expect(first).toEqual({ done: false, - value: Uint8Array.from(Buffer.from('data: {"progress":1}\n\n')), + value: Uint8Array.from(chunk), }); }); From 4cce501c5e440e25647287ff0af55b603ee47b10 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 02:22:35 +0000 Subject: [PATCH 39/39] test(project): tighten invoke integration coverage --- src/handlers/project/invoke/index.test.tsx | 152 +++++++++++-------- src/handlers/runtime/invoke/response.test.ts | 17 --- 2 files changed, 85 insertions(+), 84 deletions(-) diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 3d6bae167..254f52b6b 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -133,6 +133,48 @@ async function run( return { core, io, resolved }; } +type InteractiveInvokeFlags = { + runtime?: string; + harness?: string; + target: string; + "session-id"?: string; + qualifier?: string; + "bearer-token"?: string; +}; + +async function runInteractive( + resources: { runtimes?: unknown[]; harnesses?: unknown[] }, + flags: Partial = {}, +) { + await inProject(resources); + const resolved = testBackend(); + const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); + const project = await core.projectManager.resolve({ filePath: process.cwd() }); + const launches: { path: string; context: Context }[] = []; + const handler = createProjectInvokeHandler(core, testIO().io, async (path, context) => { + launches.push({ path, context }); + }); + const context = ValueContext.EmptyContext() + .withValue(ProjectKey, project!) + .withValue(JsonKey, false) + .withValue(RegionKey, "us-east-2"); + + await handler.handle( + context, + { + runtime: undefined, + harness: undefined, + target: "default", + "session-id": undefined, + qualifier: undefined, + "bearer-token": undefined, + ...flags, + }, + { content: undefined }, + ); + return { core, launches, resolved }; +} + afterEach(async () => { process.chdir(originalCwd); await Promise.all( @@ -202,27 +244,10 @@ describe("project invoke", () => { ); expect(io.stdout()).toBe("Hello world"); - expect(io.stderr()).toStartWith("\nstatus=200"); expect(io.stdout()).not.toContain("data:"); expect(io.stdout()).not.toContain("contentBlockDelta"); }); - test.each([ - { name: "JSON string", wire: 'data: "string token"\n\n', expected: "string token" }, - { name: "text object", wire: 'data: {"text":"text token"}\n\n', expected: "text token" }, - { name: "non-JSON token", wire: "data: raw token\n\n", expected: "raw token" }, - ])("streams a $name Runtime response", async ({ wire, expected }) => { - const { io } = await run(["hello"], { runtimes: [RUNTIME] }, (core) => - core.runtime.setInvokeResponse({ - statusCode: 200, - contentType: "text/event-stream", - body: body(Buffer.from(wire)), - }), - ); - - expect(io.stdout()).toBe(expected); - }); - test("fails an incomplete Strands response after preserving partial text", async () => { const io = testIO(); @@ -374,31 +399,9 @@ describe("project invoke", () => { }); test("opens the project picker once with the invoke launch options", async () => { - await inProject({ runtimes: [RUNTIME], harnesses: [HARNESS] }); - const resolved = testBackend(); - const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const io = testIO(); - const launches: { path: string; context: Context }[] = []; - const handler = createProjectInvokeHandler(core, io.io, async (path, context) => { - launches.push({ path, context }); - }); - const context = ValueContext.EmptyContext() - .withValue(ProjectKey, project!) - .withValue(JsonKey, false) - .withValue(RegionKey, "us-east-2"); - - await handler.handle( - context, - { - runtime: undefined, - harness: undefined, - target: "default", - "session-id": "project-session", - qualifier: "prod", - "bearer-token": undefined, - }, - { content: undefined }, + const { launches, resolved } = await runInteractive( + { runtimes: [RUNTIME], harnesses: [HARNESS] }, + { "session-id": "project-session", qualifier: "prod" }, ); expect(launches).toHaveLength(1); @@ -413,31 +416,9 @@ describe("project invoke", () => { }); test("auto-opens the only project resource at its default endpoint", async () => { - await inProject({ runtimes: [RUNTIME] }); - const resolved = testBackend(); - const core = new TestCoreClient({ backends: { CDK: resolved.backend } }); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const io = testIO(); - const launches: { path: string; context: Context }[] = []; - const handler = createProjectInvokeHandler(core, io.io, async (path, context) => { - launches.push({ path, context }); - }); - const context = ValueContext.EmptyContext() - .withValue(ProjectKey, project!) - .withValue(JsonKey, false) - .withValue(RegionKey, "us-east-2"); - - await handler.handle( - context, - { - runtime: undefined, - harness: undefined, - target: "default", - "session-id": "project-session", - qualifier: undefined, - "bearer-token": undefined, - }, - { content: undefined }, + const { launches, resolved } = await runInteractive( + { runtimes: [RUNTIME] }, + { "session-id": "project-session" }, ); expect(launches).toHaveLength(1); @@ -456,4 +437,41 @@ describe("project invoke", () => { name: "checkout", }); }); + + test("opens an explicit Runtime qualifier without endpoint discovery", async () => { + const { core, launches } = await runInteractive( + { runtimes: [RUNTIME] }, + { "session-id": "project-session", qualifier: "prod" }, + ); + + expect(launches).toHaveLength(1); + expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}/prod`); + expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); + expect(launches[0]!.context.require(RuntimeInvokeLaunchContextKey)).toEqual({ + runtimeId: RUNTIME_ID, + runtimeSessionId: "project-session", + bearerToken: undefined, + inputMode: "prompt", + autoSelectSingleEndpoint: false, + }); + expect(core.runtime.calls).toEqual([]); + }); + + test("auto-opens the only Harness with project launch options", async () => { + const { launches, resolved } = await runInteractive( + { harnesses: [HARNESS] }, + { "session-id": "project-session".repeat(3), qualifier: "prod" }, + ); + + expect(launches).toHaveLength(1); + expect(launches[0]!.path).toBe( + `/agentcore/harness/invoke/${HARNESS_ID}/${"project-session".repeat(3)}?qualifier=prod`, + ); + expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); + expect(resolved.calls[0]!.input).toEqual({ + target: TARGET, + resourceType: "harness", + name: "support", + }); + }); }); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 7e6583989..80b689308 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -488,21 +488,4 @@ describe("Runtime invoke response output", () => { "complete=false bytes=0\n", ); }); - - test("uses caller-specific binary TTY guidance", async () => { - const stdout = capture(); - const stderr = capture(); - Object.defineProperty(stdout.stream, "isTTY", { value: true }); - - await expect( - writeRuntimeInvokeResponse( - response({ contentType: "application/octet-stream" }), - { - stdout: stdout.stream, - stderr: stderr.stream, - }, - { binaryTtyError: "Binary project responses require --json" }, - ), - ).rejects.toThrow("Binary project responses require --json"); - }); });