diff --git a/README.md b/README.md index f09c95470..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 @@ -104,6 +105,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 ``` @@ -116,6 +126,42 @@ 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 project invoke "Summarize this repository." + +# Select explicitly when the project has multiple invokable resources. +agentcore project invoke --runtime checkout "Check order 123." +agentcore project invoke --harness support "Help with my account." + +# Select another deployment target. +agentcore project invoke --target staging --runtime checkout "Run a smoke test." + +# Preserve the Runtime wire response in a JSON envelope. +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 + +# The project-aware shorthand has the same behavior inside a project. +agentcore invoke +``` + +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` preserves the exact wire +response instead. + ### 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..a42d46a54 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 project invoke "Hello!" +``` diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md index b43ddbbba..c7fd0cfaf 100644 --- a/src/assets/templates/hello-world-python/README.md +++ b/src/assets/templates/hello-world-python/README.md @@ -27,9 +27,6 @@ curl -X POST http://localhost:8080/invocations \ -d '{"prompt": "Hello!"}' ``` - - ## Build your agent Start in `main.py`: @@ -58,3 +55,8 @@ for multi-agent patterns, MCP tools, and model configuration. Deploy from the project root with the AgentCore CLI; the CDK app under `agentcore/cdk` provisions the Runtime that hosts this agent. + +```bash +agentcore project deploy +agentcore project invoke "Hello!" +``` diff --git a/src/assets/templates/strands-http-python/README.md b/src/assets/templates/strands-http-python/README.md index eafaa1ec0..efd136ad2 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 project invoke "Hello!" +``` + +The CLI sends `{"prompt": content}` and streams assistant text from the Strands response. Use `--json` to preserve +the exact wire response. 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/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. */} { 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/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/core/dev/inspector/invocations.test.ts b/src/core/dev/inspector/invocations.test.ts index 26d4f72e5..a39096f2c 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,32 +62,15 @@ 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" }, - { 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 () => { @@ -97,6 +79,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, 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" }; +} diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 288bb2199..03d8b53a3 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -3,11 +3,13 @@ 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 { 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"; @@ -16,6 +18,9 @@ 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 json = new FsReadWriteJson({ logger: createSilentLogger() }); const tempDirectories: string[] = []; @@ -76,6 +81,10 @@ async function writeAssembly(project: Project, targetNames: string[]): Promise { + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn }); +} + type HarnessOptions = { account?: string; bootstrap?: BootstrapState; @@ -85,6 +94,7 @@ type HarnessOptions = { template?: boolean; failOperation?: CdkOperation["kind"]; bootstrapError?: Error; + stack?: Stack; }; function harness(options: HarnessOptions = {}) { @@ -95,6 +105,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 +164,10 @@ function harness(options: HarnessOptions = {}) { }, }; }, + describeStack: async (region, provider, stackName) => { + stackReads.push({ stackName, region, credentials: provider }); + return options.stack; + }, }); return { @@ -164,6 +180,7 @@ function harness(options: HarnessOptions = {}) { credentialRegions, credentials, runs, + stackReads, templateLoads: () => templateLoads, templateCleanups: () => templateCleanups, }; @@ -404,3 +421,121 @@ describe("CdkBackend.deploy", () => { expect(subject.runs.map(({ operation }) => operation.kind)).toEqual(["bootstrap"]); }); }); + +describe("CdkBackend.resolveDeployedResource", () => { + 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: 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]); + }, + ); + + 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 recorded stack no longer exists", async () => { + const input = await project(); + await writeDeployedState(input); + 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[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( + 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..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 { @@ -10,7 +11,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 +33,26 @@ import { type CdkCredentialResolver, type CdkRunner, } from "./cdk/toolkit"; +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; @@ -38,6 +64,7 @@ export type CdkBackendConfig = { bootstrap?: BootstrapProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; + describeStack?: StackDescriber; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -51,6 +78,7 @@ export class CdkBackend implements ProjectBackend { private readonly bootstrap: BootstrapProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; + private readonly describeStack: StackDescriber; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -63,6 +91,7 @@ export class CdkBackend implements ProjectBackend { this.bootstrap = config.bootstrap ?? probeBootstrap; this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; + this.describeStack = config.describeStack ?? describeStack; } public async *build(project: Project): AsyncGenerator { @@ -92,14 +121,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 +185,51 @@ export class CdkBackend implements ProjectBackend { return { outputs }; } + public async resolveDeployedResource( + project: Project, + input: ResolveDeployedResourceBackendInput, + ): Promise { + const { target } = input; + 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 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.`, + ); + } + + 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) { + 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/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/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/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/index.tsx b/src/handlers/index.tsx index 87def85c6..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,7 +69,10 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); - root.handler(createProjectHandler({ projectManager: core.projectManager, io })); + root.handler( + withProject({ projectManager: core.projectManager })(createProjectInvokeHandler(core, 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/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/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 new file mode 100644 index 000000000..254f52b6b --- /dev/null +++ b/src/handlers/project/invoke/index.test.tsx @@ -0,0 +1,477 @@ +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 { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + type TestIO, + testIO, +} 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(); +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, + io: TestIO = testIO(), + command: "project" | "alias" = "project", +) { + 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 root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route([ + "node", + "agentcore", + ...(command === "project" ? ["project", "invoke"] : ["invoke"]), + ...args, + "--region", + "us-east-2", + ]); + 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( + 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(io.stderr()).toStartWith("\nstatus=200"); + 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("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', + '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("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("directs binary Runtime responses to the available JSON output mode", async () => { + const io = testIO({ isTTY: true }); + + await expect( + 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"); + + expect(io.stderr()).not.toContain("--output-file"); + }); + + 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("opens the project picker once with the invoke launch options", async () => { + const { launches, resolved } = await runInteractive( + { runtimes: [RUNTIME], harnesses: [HARNESS] }, + { "session-id": "project-session", qualifier: "prod" }, + ); + + expect(launches).toHaveLength(1); + expect(launches[0]!.path).toBe("/agentcore/project/invoke"); + expect(launches[0]!.context.require(ProjectInvokePickerContextKey)).toEqual({ + target: "default", + sessionId: "project-session", + qualifier: "prod", + bearerToken: undefined, + }); + expect(resolved.calls).toEqual([]); + }); + + test("auto-opens the only project resource at its default endpoint", async () => { + const { launches, resolved } = await runInteractive( + { runtimes: [RUNTIME] }, + { "session-id": "project-session" }, + ); + + expect(launches).toHaveLength(1); + 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, + resourceType: "runtime", + 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/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx new file mode 100644 index 000000000..7bb0b9e45 --- /dev/null +++ b/src/handlers/project/invoke/index.tsx @@ -0,0 +1,239 @@ +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 { renderPromptResponseBody } from "../../runtime/invoke/promptResponse"; +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"; +import { + ProjectInvokePickerContextKey, + type ProjectInvokePickerLaunchContext, +} from "./pickerContext"; + +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 jsonOutput = ctx.require(JsonKey); + if (args.content === undefined && jsonOutput) { + throw new InputValidationError("content is required with --json"); + } + + 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, + ); + 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"); + } + 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"); + } + 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", + autoSelectSingleEndpoint: flags.qualifier === undefined, + }), + 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( + jsonOutput + ? response + : { + ...response, + body: renderPromptResponseBody(response.contentType, response.body), + }, + { + stdout: io.stdout, + stderr: io.stderr, + json: jsonOutput, + signal, + }, + { + binaryTtyError: "Binary or unknown response content requires --json", + separateSummaryFromBody: !jsonOutput, + }, + ); + }); + }, + }); diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx new file mode 100644 index 000000000..1320d09ab --- /dev/null +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + AgentRuntimeEndpoint, + 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"; +import type { Project, ResolveDeployedResourceInput } from "../types"; +import { ProjectInvokePickerContextKey } from "./pickerContext"; + +afterEach(cleanupScreens); + +const project: Project = { + name: "orders", + rootPath: "/tmp/orders", + spec: ProjectSpecSchema.parse({ + name: "orders", + version: 1, + runtimes: [ + { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", + }, + ], + harnesses: [{ name: "support", path: "app/support" }], + }), +}; + +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", { + withContext: (ctx) => + 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 waitForText(screen.lastFrame, "send a message…"); + expect(calls).toEqual([ + { + target: "default", + resourceType: "harness", + name: "support", + }, + ]); + }); + + test("auto-selects a selected Runtime's only endpoint", 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); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [runtimeEndpoint("DEFAULT")], + }); + 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 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 () => { + throw new Error("project is not deployed"); + }; + 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, "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 new file mode 100644 index 000000000..edc4e4ad9 --- /dev/null +++ b/src/handlers/project/invoke/pickerContext.ts @@ -0,0 +1,11 @@ +import { contextKey } from "../../../router"; + +export type ProjectInvokePickerLaunchContext = { + target: string; + sessionId?: string; + qualifier?: string; + bearerToken?: string; +}; + +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..73a562cfa --- /dev/null +++ b/src/handlers/project/invoke/screen.tsx @@ -0,0 +1,182 @@ +import { useMemo, useState } from "react"; +import { Box, Text, useApp } from "ink"; +import { Layout } from "../../../components/Layout"; +import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; +import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; +import { Spinner } from "../../../components/ui/spinner"; +import { ProjectKey, type Context } from "../../../router"; +import { HarnessChat } from "../../harness/invoke/screen"; +import { RegionKey } from "../../keys"; +import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; +import type { ScreenProps } from "../../types"; +import { ProjectInvokePickerContextKey } from "./pickerContext"; + +type ProjectInvokableRow = Record & { + resourceType: "runtime" | "harness"; + type: "Runtime" | "Harness"; + name: string; + protocol: string; + source: string; +}; + +const columns = [ + { key: "type", header: "type", width: 10 }, + { key: "name", header: "name", flex: true }, + { key: "protocol", header: "protocol", width: 10 }, + { key: "source", header: "source", width: 24 }, +] satisfies DataTableColumn[]; + +type Destination = + | { + resourceType: "runtime"; + id: string; + ctx: Context; + qualifier?: string; + } + | { + resourceType: "harness"; + id: string; + ctx: Context; + }; + +export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { + const { exit } = useApp(); + const project = ctx.require(ProjectKey); + const launch = ctx.require(ProjectInvokePickerContextKey); + const [destination, setDestination] = useState(); + const [resolving, setResolving] = useState(); + const [error, setError] = useState(); + const rows = useMemo( + () => [ + ...project.spec.runtimes.map(({ name, protocol, codeLocation }) => ({ + resourceType: "runtime" as const, + type: "Runtime" as const, + name, + protocol: protocol ?? "HTTP", + source: codeLocation, + })), + ...project.spec.harnesses.map(({ name, path }) => ({ + resourceType: "harness" as const, + type: "Harness" as const, + name, + protocol: "-", + source: path, + })), + ], + [project], + ); + + const select = async (row: ProjectInvokableRow) => { + if (resolving) return; + 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); + } + }; + + if (destination?.resourceType === "runtime") { + if (!destination.qualifier) { + return ( + setDestination({ ...destination, qualifier })} + onEscape={() => setDestination(undefined)} + /> + ); + } + return ( + setDestination(undefined)} + /> + ); + } + + if (destination?.resourceType === "harness") { + return ( + setDestination(undefined)} + /> + ); + } + + return ( + + + {error ? {error} : null} + void select(row)} + onEscape={exit} + /> + {resolving ? : null} + + + ); +} 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; 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", ]); }); 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/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index c8a3781f5..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(); @@ -925,3 +965,81 @@ describe("Runtime invoke JSON console", () => { } }); }); + +describe("Runtime invoke prompt console", () => { + 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/event-stream", + body: responseBody(Buffer.from(wire.slice(0, 87)), Buffer.from(wire.slice(87))), + }); + 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); + 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"); + }); + + 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/launchContext.ts b/src/handlers/runtime/invoke/launchContext.ts index bcee87f31..7a659dcd5 100644 --- a/src/handlers/runtime/invoke/launchContext.ts +++ b/src/handlers/runtime/invoke/launchContext.ts @@ -2,10 +2,12 @@ import { contextKey } from "../../../router"; export type RuntimeInvokeLaunchContext = { runtimeId: string; + inputMode?: "json" | "prompt"; runtimeSessionId?: string; runtimeUserId?: string; applicationHeaders?: [string, string][]; bearerToken?: string; + autoSelectSingleEndpoint?: boolean; }; export const RuntimeInvokeLaunchContextKey = 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/promptResponse.test.ts b/src/handlers/runtime/invoke/promptResponse.test.ts new file mode 100644 index 000000000..7ef227c01 --- /dev/null +++ b/src/handlers/runtime/invoke/promptResponse.test.ts @@ -0,0 +1,141 @@ +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: {"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("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( + "text object", + ); + }); + + 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 chunk; + 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(chunk), + }); + }); + + 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(""); + }); + + 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("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"); + })(); + 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(chunks).toEqual([]); + }); +}); diff --git a/src/handlers/runtime/invoke/promptResponse.ts b/src/handlers/runtime/invoke/promptResponse.ts new file mode 100644 index 000000000..302ead395 --- /dev/null +++ b/src/handlers/runtime/invoke/promptResponse.ts @@ -0,0 +1,103 @@ +import { parseAgentEvent, type AgentEvent } from "../../../core/project/agentEventParser"; + +function mediaType(contentType: string): string { + return contentType.split(";", 1)[0]!.trim().toLowerCase(); +} + +function parseSseLine(line: string): AgentEvent { + return line.startsWith("data:") + ? parseAgentEvent(line.slice(5).trimStart()) + : { kind: "unsupported" }; +} + +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 pending: Uint8Array[] = []; + const maxSniffBytes = 64 * 1024; + let buffer = ""; + let pendingBytes = 0; + let mode: "sniffing" | "parsed" | "raw" = "sniffing"; + + const processParsedLines = function* (lines: string[]): Generator { + for (const line of lines) { + if (line === "") continue; + const event = parseSseLine(line); + if (event.kind === "error") throw new Error(event.message); + if (event.kind === "text") yield encoder.encode(event.text); + } + }; + + try { + for await (const chunk of body) { + const snapshot = Uint8Array.from(chunk); + if (mode === "raw") { + yield snapshot; + continue; + } + + buffer += decoder.decode(snapshot, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + + if (mode === "parsed") { + yield* processParsedLines(lines); + continue; + } + + pending.push(snapshot); + pendingBytes += snapshot.byteLength; + const firstLine = lines.find((line) => line !== ""); + if (firstLine !== undefined) { + const event = parseSseLine(firstLine); + if (event.kind === "error") { + mode = "parsed"; + pending.length = 0; + throw new Error(event.message); + } + if (event.kind !== "unsupported") { + mode = "parsed"; + pending.length = 0; + yield* processParsedLines(lines); + continue; + } + mode = "raw"; + } else if (pendingBytes >= maxSniffBytes) { + mode = "raw"; + } + + if (mode === "raw") { + yield* pending; + pending.length = 0; + buffer = ""; + } + } + } catch (error) { + if (mode === "sniffing") yield* pending; + throw error; + } + + if (mode === "raw") return; + + buffer += decoder.decode(); + if (mode === "parsed") { + if (buffer) yield* processParsedLines([buffer]); + return; + } + + const event = buffer ? parseSseLine(buffer) : { kind: "unsupported" as const }; + if (event.kind === "error") throw new Error(event.message); + if (event.kind !== "unsupported") { + yield* processParsedLines([buffer]); + } else { + yield* pending; + } +} 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-"; diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 8b2850ca5..80b689308 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 9c51911cd..0af3425dd 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -8,6 +8,12 @@ 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; + separateSummaryFromBody?: boolean; +}; export function classifyRuntimeResponse(contentType: string) { return classifyStreamingResponse(contentType); @@ -28,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, @@ -49,11 +65,23 @@ function summary( export async function writeRuntimeInvokeResponse( response: RuntimeInvokeResponse, 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: "Binary or unknown response content requires --output-file or --json", + binaryTtyError: options.binaryTtyError ?? BINARY_TTY_ERROR, }); } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index b73cc7862..0d9e148af 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; @@ -118,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, @@ -140,20 +142,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(); @@ -168,6 +172,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 +195,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 +212,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"); @@ -242,7 +251,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; @@ -307,6 +320,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; @@ -413,6 +427,7 @@ function RuntimeInvokeConsole({ Request {exchange.payload} + {exchange.heading ?? "Response"} {(prettyJson && exchange.pretty @@ -442,7 +457,7 @@ function RuntimeInvokeConsole({ setInputError(undefined); }} onSubmit={() => void send()} - placeholder="Enter JSON payload" + placeholder={inputMode === "prompt" ? "Enter prompt" : "Enter JSON payload"} submitDisabled={busy} />