diff --git a/src/core/core.test.ts b/src/core/core.test.ts index b4135e9e5..ed42479ad 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -7,6 +7,7 @@ import type { IAMClient } from "@aws-sdk/client-iam"; import type { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import type { XRayClient } from "@aws-sdk/client-xray"; import type { ApplicationSignalsClient } from "@aws-sdk/client-application-signals"; +import { SilentCLIError } from "../errors"; import { GetEventCommand, GetMemoryRecordCommand, @@ -573,6 +574,7 @@ test("invokeRuntime maps all modeled IAM fields and response metadata", async () expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); const { response, ...metadata } = sdkResponse; expect(result).toEqual({ + kind: "stream", ...metadata, body: response, }); @@ -630,6 +632,7 @@ test("invokeRuntime aborts an established IAM response stream", async () => { { region: "us-east-1" }, controller.signal, ); + if (response.kind !== "stream") throw new Error("expected a streaming response"); const iterator = response.body[Symbol.asyncIterator](); expect(await iterator.next()).toEqual({ done: false, value: Buffer.from("partial") }); @@ -745,6 +748,7 @@ test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", as ); expect(result.runtimeSessionId).toBe("returned-runtime"); expect(result.mcpSessionId).toBe("returned-mcp"); + if (result.kind !== "stream") throw new Error("expected a streaming response"); const resultBytes: Uint8Array[] = []; for await (const chunk of result.body) resultBytes.push(chunk); expect(Buffer.concat(resultBytes)).toEqual(Buffer.from([1, 2])); @@ -824,25 +828,24 @@ test("CUSTOM_JWT modeled header failures do not expose their values", async () = expect(fetched).toBe(false); }); -test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async () => { +test("CUSTOM_JWT non-2xx cancels the response body and returns status metadata", async () => { const { logger, logs } = captureLogs(); let cancelled = 0; - let read = false; - const response = { - ok: false, - status: 401, - headers: new Headers(), - body: { - cancel: async () => { - cancelled++; - }, - async *[Symbol.asyncIterator]() { - read = true; - yield new TextEncoder().encode("secret response body"); - }, - }, - } as unknown as Response; - const core = customJwtCore(async () => response, { logger }); + const core = customJwtCore( + async () => + new Response( + new ReadableStream({ + cancel() { + cancelled++; + }, + }), + { + status: 401, + headers: { "X-Amzn-RequestId": "request-123" }, + }, + ), + { logger }, + ); const request: RuntimeInvokeRequest = { runtimeId: "runtime-123", accountId: "123456789012", @@ -850,14 +853,20 @@ test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async payload: new TextEncoder().encode("secret payload"), contentType: "application/json", bearerToken: "secret-token", + runtimeSessionId: "runtime-session-value", applicationHeaders: [["X-Secret", "secret-header-value"]], }; - await expect(core.runtime.invokeRuntime(request, { region: "us-east-1" })).rejects.toThrow( - /^HTTP 401$/, - ); + const response = await core.runtime.invokeRuntime(request, { region: "us-east-1" }); + + expect(response).toMatchObject({ + kind: "rejected", + statusCode: 401, + error: { + requestId: "request-123", + }, + }); expect(cancelled).toBe(1); - expect(read).toBe(false); expectSafeDebugLog( logs, "Runtime invocation returned a non-success response", @@ -868,11 +877,11 @@ test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async region: "us-east-1", httpStatusCode: 401, }, - ["secret response body", "secret payload", "secret-token", "secret-header-value"], + ["secret payload", "secret-token", "secret-header-value", "runtime-session-value"], ); }); -test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () => { +test("CUSTOM_JWT transport failures are visible without exposing arbitrary causes", async () => { const { logger, logs } = captureLogs(); const core = customJwtCore( async () => { @@ -883,20 +892,20 @@ test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () => { logger }, ); - await expect( - core.runtime.invokeRuntime( - { - runtimeId: "runtime-123", - accountId: "123456789012", - qualifier: "DEFAULT", - payload: new TextEncoder().encode("secret payload"), - contentType: "application/json", - bearerToken: "secret-token", - applicationHeaders: [["X-Secret", "secret-header-value"]], - }, - { region: "us-east-1" }, - ), - ).rejects.toThrow(/^Runtime invocation failed$/); + const pending = core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new TextEncoder().encode("secret payload"), + contentType: "application/json", + bearerToken: "secret-token", + applicationHeaders: [["X-Secret", "secret-header-value"]], + }, + { region: "us-east-1" }, + ); + await expect(pending).rejects.toThrow(/^Runtime invocation failed$/); + await expect(pending).rejects.not.toBeInstanceOf(SilentCLIError); expectSafeDebugLog( logs, "Runtime invocation transport failed", @@ -928,6 +937,7 @@ test("invokeRuntime exposes an empty async iterable when the SDK omits the body" { region: "us-east-1" }, ); + if (response.kind !== "stream") throw new Error("expected a streaming response"); const chunks: Uint8Array[] = []; for await (const chunk of response.body) chunks.push(chunk); expect(chunks).toEqual([]); diff --git a/src/core/dev/localInvoke.ts b/src/core/dev/localInvoke.ts index 92db71231..7312d18cb 100644 --- a/src/core/dev/localInvoke.ts +++ b/src/core/dev/localInvoke.ts @@ -86,6 +86,7 @@ export async function invokeLocalRuntime( const body = (response.body as AsyncIterable | null) ?? emptyBody(); return { + kind: "stream", statusCode: response.status, contentType: response.headers.get("content-type") ?? "", runtimeSessionId: diff --git a/src/core/eval.tsx b/src/core/eval.tsx index a82229096..60da2a22d 100644 --- a/src/core/eval.tsx +++ b/src/core/eval.tsx @@ -928,6 +928,20 @@ export class EvalClient implements CoreEvalClient { options, signal, ); + if (response.kind === "rejected") { + throw new AgentCoreCLIError(`Runtime invocation failed (HTTP ${response.statusCode})`, { + source: + response.statusCode >= 400 && response.statusCode < 500 + ? ERROR_SOURCE.USER + : ERROR_SOURCE.SERVICE, + meta: { + httpStatusCode: response.statusCode, + ...(response.error.requestId !== undefined && { + requestId: response.error.requestId, + }), + }, + }); + } // Read to completion to free the socket; a scripted example ignores the text. let text = ""; const decoder = new TextDecoder(); diff --git a/src/core/invokeRuntime.ts b/src/core/invokeRuntime.ts index 9555f2516..4c299ee33 100644 --- a/src/core/invokeRuntime.ts +++ b/src/core/invokeRuntime.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { InvokeAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore"; -import { InputValidationError, RuntimeInvokeResponseError } from "../errors"; +import { InputValidationError, NetworkingError } from "../errors"; import type { Logger } from "../logging"; import type { AwsClients, CoreFetch, CoreOptions } from "./types"; import { abortable } from "./abortable"; @@ -30,7 +30,7 @@ export type RuntimeInvokeRequest = { baggage?: string; }; -export type RuntimeInvokeResponse = { +export type RuntimeInvokeResponseMetadata = { statusCode: number; contentType: string; runtimeSessionId?: string; @@ -40,9 +40,22 @@ export type RuntimeInvokeResponse = { traceParent?: string; traceState?: string; baggage?: string; +}; + +export type RuntimeInvokeStreamResponse = RuntimeInvokeResponseMetadata & { + kind: "stream"; body: AsyncIterable; }; +export type RuntimeInvokeRejectedResponse = RuntimeInvokeResponseMetadata & { + kind: "rejected"; + error: { + requestId?: string; + }; +}; + +export type RuntimeInvokeResponse = RuntimeInvokeStreamResponse | RuntimeInvokeRejectedResponse; + // The CUSTOM_JWT path puts the account id in the invocation URL, so a bad ARN must fail here. export function accountIdFromRuntimeArn(arn: string | undefined): string { const id = arn?.match(/^arn:[^:]+:bedrock-agentcore:[^:]*:(\d{12}):runtime\//)?.[1]; @@ -152,17 +165,37 @@ async function invokeRuntimeWithCustomJwt( : typeof error, }) .debug("Runtime invocation transport failed"); - throw new RuntimeInvokeResponseError("Runtime invocation failed", error); + throw new NetworkingError("Runtime invocation failed"); } if (!response.ok) { logger .child({ httpStatusCode: response.status }) .debug("Runtime invocation returned a non-success response"); + const requestId = + response.headers.get("x-amzn-requestid") ?? + response.headers.get("x-amz-request-id") ?? + undefined; await response.body?.cancel().catch(() => undefined); - throw new RuntimeInvokeResponseError(`HTTP ${response.status}`); + return { + kind: "rejected", + statusCode: response.status, + contentType: response.headers.get("content-type") ?? "", + runtimeSessionId: + response.headers.get("x-amzn-bedrock-agentcore-runtime-session-id") ?? undefined, + mcpSessionId: response.headers.get("mcp-session-id") ?? undefined, + mcpProtocolVersion: response.headers.get("mcp-protocol-version") ?? undefined, + traceId: response.headers.get("x-amzn-trace-id") ?? undefined, + traceParent: response.headers.get("traceparent") ?? undefined, + traceState: response.headers.get("tracestate") ?? undefined, + baggage: response.headers.get("baggage") ?? undefined, + error: { + ...(requestId !== undefined && { requestId }), + }, + }; } const body = (response.body as AsyncIterable | null) ?? emptyBody(); return { + kind: "stream", statusCode: response.status, contentType: response.headers.get("content-type") ?? "", runtimeSessionId: @@ -207,6 +240,7 @@ async function invokeRuntimeWithIam( const body = (response.response as AsyncIterable | undefined) ?? emptyBody(); return { + kind: "stream", statusCode: response.statusCode ?? 0, contentType: response.contentType ?? "", runtimeSessionId: response.runtimeSessionId, diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 2daa77dcf..ddc40b5f2 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -124,6 +124,7 @@ async function routedCommand( core.runtime .setGetResponse({ agentRuntimeArn: arn(`runtime/${RUNTIME_ID}`) } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: (async function* () { diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index f6d1308ad..e4dfc2f47 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -207,6 +207,7 @@ describe("Runtime invoke routing", () => { runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], }) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from("ok")), @@ -328,6 +329,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from("ok")), @@ -371,6 +373,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from("ok")), @@ -398,6 +401,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from("ok")), @@ -475,6 +479,7 @@ describe("Runtime invoke JSON console", () => { core.runtime.invokeRuntime = async (request) => { requests.push(request); return { + kind: "stream" as const, statusCode: 200, contentType: "text/plain", body: @@ -509,6 +514,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", runtimeSessionId: "returned-runtime", @@ -545,6 +551,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/event-stream", runtimeSessionId: "returned-runtime", @@ -581,6 +588,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType, body: responseBody(body), @@ -612,6 +620,29 @@ describe("Runtime invoke JSON console", () => { expect(screen.lastFrame()).toContain("failed · 0 bytes"); }); + test("shows a rejected response without treating it as streamed output", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + kind: "rejected", + statusCode: 401, + contentType: "application/json", + error: { + requestId: "request-123", + }, + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "Runtime invocation failed · HTTP 401"); + expect(screen.lastFrame()).toContain("Request ID: request-123"); + expect(screen.lastFrame()).toContain("failed · 0 bytes"); + }); + test("formats modeled AWS service errors with their diagnostics", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); @@ -658,6 +689,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: (async function* () { @@ -683,6 +715,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from(response)), @@ -711,6 +744,7 @@ describe("Runtime invoke JSON console", () => { core.runtime.invokeRuntime = async (request) => { requests.push(request); return { + kind: "stream" as const, statusCode: 200, contentType: "text/event-stream", runtimeSessionId: "returned-runtime", @@ -761,6 +795,7 @@ describe("Runtime invoke JSON console", () => { core.runtime.invokeRuntime = async (request) => { requests.push(request); return { + kind: "stream" as const, statusCode: 200, contentType: "application/json", body: responseBody(Buffer.from('{"ok":true}')), @@ -812,6 +847,7 @@ describe("Runtime invoke JSON console", () => { runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], }) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", runtimeSessionId: "returned-runtime", @@ -850,6 +886,7 @@ describe("Runtime invoke JSON console", () => { expect(screen.lastFrame()).toContain("MCP session ID: Not set"); core.runtime.setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from("new response")), @@ -891,6 +928,7 @@ describe("Runtime invoke JSON console", () => { runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], }) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from("ok")), @@ -935,6 +973,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "application/json", body: responseBody(Buffer.from(raw)), @@ -959,6 +998,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "application/octet-stream", body: (async function* () { @@ -1003,6 +1043,7 @@ describe("Runtime invoke JSON console", () => { core.runtime.invokeRuntime = async (_request, _options, nextSignal) => { signal = nextSignal; return { + kind: "stream" as const, statusCode: 200, contentType: "text/event-stream", runtimeSessionId: "interrupted-runtime", diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 909e82eb3..0b87e1936 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -32,12 +32,18 @@ function body(...chunks: Uint8Array[]): AsyncIterable { })(); } -function captureIO(input?: Uint8Array): { io: AppIO; bytes: () => Buffer } { +function captureIO(input?: Uint8Array): { + io: AppIO; + bytes: () => Buffer; + stderrBytes: () => Buffer; +} { const stdin = new PassThrough() as unknown as NodeJS.ReadStream; const stdout = new PassThrough(); const stderr = new PassThrough(); const chunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); if (input) stdin.end(input); return { io: { @@ -46,6 +52,7 @@ function captureIO(input?: Uint8Array): { io: AppIO; bytes: () => Buffer } { stderr: stderr as unknown as NodeJS.WriteStream, }, bytes: () => Buffer.concat(chunks), + stderrBytes: () => Buffer.concat(stderrChunks), }; } @@ -77,6 +84,7 @@ async function run( const core = new TestCoreClient(); core.runtime.setGetResponse(getResponse); core.runtime.setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "application/json", body: body(Uint8Array.from([0, 255]), Uint8Array.from([10, 1])), @@ -98,7 +106,12 @@ describe("runtime invoke", () => { agentRuntimeArn: RUNTIME_ARN, ...(input === "bearer-token" && { authorizerConfiguration: { customJWTAuthorizer: {} } }), } as GetAgentRuntimeResponse) - .setInvokeResponse({ statusCode: 200, contentType: "text/plain", body: stream }); + .setInvokeResponse({ + kind: "stream", + statusCode: 200, + contentType: "text/plain", + body: stream, + }); const io = testIO({ isTTY: true }); const stdin = io.io.stdin as unknown as PassThrough; const pending = runCommand(core, io.io, [ @@ -133,7 +146,12 @@ describe("runtime invoke", () => { const stream = new StreamController(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) - .setInvokeResponse({ statusCode: 200, contentType: "text/plain", body: stream }); + .setInvokeResponse({ + kind: "stream", + statusCode: 200, + contentType: "text/plain", + body: stream, + }); const io = testIO({ isTTY: true }); const pending = runCommand(core, io.io, [ "runtime", @@ -293,6 +311,7 @@ describe("runtime invoke", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/event-stream", body: (async function* () { @@ -393,6 +412,7 @@ describe("runtime invoke", () => { core.runtime.invokeRuntime = async (request, options, signal) => { core.runtime.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); return { + kind: "stream" as const, statusCode: 200, contentType: "text/event-stream", body: (async function* () { @@ -496,6 +516,7 @@ describe("runtime invoke", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "application/octet-stream", body: (async function* () { @@ -727,6 +748,7 @@ describe("runtime invoke", () => { outputCore.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ + kind: "stream", statusCode: 200, contentType: "text/plain", body: body(Buffer.from("response")), @@ -769,4 +791,52 @@ describe("runtime invoke", () => { ExitCode.FAILURE, ]); }); + + test("prints rejected CUSTOM_JWT responses before exiting unsuccessfully", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + authorizerConfiguration: { customJWTAuthorizer: {} }, + } as GetAgentRuntimeResponse); + core.runtime.setInvokeResponse({ + kind: "rejected", + statusCode: 401, + contentType: "application/json", + error: { + requestId: "request-123", + }, + }); + const output = captureIO(); + const errors: string[] = []; + const errorLog = spyOn(console, "error").mockImplementation((message) => { + errors.push(String(message)); + }); + + try { + const code = await runWithExitCode(async () => + runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--bearer-token", + "secret-token", + ]), + ); + + expect(code).toBe(ExitCode.FAILURE); + expect(errors).toEqual([]); + expect(output.bytes()).toHaveLength(0); + expect(output.stderrBytes().toString()).toBe( + "status=401 content-type=application/json runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=0 error=HTTP 401 request-id=request-123\n", + ); + expect(output.stderrBytes().toString()).not.toContain("secret-token"); + } finally { + errorLog.mockRestore(); + } + }); }); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 8b2850ca5..ceb9bf71e 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; import { SilentCLIError, UserCancellationError } from "../../../errors"; import { waitFor } from "../../../testing"; -import type { RuntimeInvokeResponse } from "../types"; +import type { RuntimeInvokeRejectedResponse, RuntimeInvokeStreamResponse } from "../types"; import { writeRuntimeInvokeResponse } from "./response"; const files: string[] = []; @@ -27,8 +27,11 @@ function failingBody(error: Error): AsyncIterable { })(); } -function response(overrides: Partial = {}): RuntimeInvokeResponse { +function response( + overrides: Partial = {}, +): RuntimeInvokeStreamResponse { return { + kind: "stream", statusCode: 200, contentType: "text/plain", body: body(Buffer.from("ok")), @@ -36,6 +39,18 @@ function response(overrides: Partial = {}): RuntimeInvoke }; } +function rejectedResponse( + overrides: Partial = {}, +): RuntimeInvokeRejectedResponse { + return { + kind: "rejected", + statusCode: 401, + contentType: "application/json", + error: {}, + ...overrides, + }; +} + function capture() { const stream = new PassThrough(); const chunks: Buffer[] = []; @@ -239,6 +254,61 @@ describe("Runtime invoke response output", () => { expect(stderr.bytes()).toHaveLength(0); }); + test.each([false, true])( + "reports a rejected Runtime response once without writing response output (json=%s)", + async (json) => { + const stdout = capture(); + const stderr = capture(); + const result = rejectedResponse({ + statusCode: 401, + contentType: "application/json", + error: { + requestId: "request-123", + }, + }); + + const pending = writeRuntimeInvokeResponse(result, { + stdout: stdout.stream, + stderr: stderr.stream, + json, + }); + + await expect(pending).rejects.toBeInstanceOf(SilentCLIError); + await expect(pending).rejects.toThrow("HTTP 401"); + expect(stdout.bytes()).toHaveLength(0); + expect(stderr.bytes().toString()).toBe( + "status=401 content-type=application/json runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=0 error=HTTP 401 request-id=request-123\n", + ); + }, + ); + + test("does not create an output file for a rejected Runtime response", async () => { + const file = join(tmpdir(), `runtime-invoke-rejected-${process.pid}-${files.length}`); + files.push(file); + const stdout = capture(); + const stderr = capture(); + + await expect( + writeRuntimeInvokeResponse( + rejectedResponse({ + statusCode: 403, + error: {}, + }), + { + stdout: stdout.stream, + stderr: stderr.stream, + outputFile: file, + }, + ), + ).rejects.toThrow("HTTP 403"); + + expect(await Bun.file(file).exists()).toBe(false); + expect(stdout.bytes()).toHaveLength(0); + expect(stderr.bytes().toString()).toContain("complete=false bytes=0 error=HTTP 403"); + }); + test("preserves partial stdout and reports one static incomplete summary", 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..3eacb58b8 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,3 +1,4 @@ +import { pipeline } from "node:stream/promises"; import { RuntimeInvokeResponseError, UserCancellationError } from "../../../errors"; import { classifyStreamingResponse, @@ -5,7 +6,11 @@ import { writeStreamingResponseFile, type StreamingResponseOutput, } from "../../../io"; -import type { RuntimeInvokeResponse } from "../types"; +import type { + RuntimeInvokeRejectedResponse, + RuntimeInvokeResponse, + RuntimeInvokeStreamResponse, +} from "../types"; const RESPONSE_STREAM_FAILED = "response stream failed"; @@ -14,7 +19,7 @@ export function classifyRuntimeResponse(contentType: string) { } export async function writeRuntimeInvokeFile( - response: RuntimeInvokeResponse, + response: RuntimeInvokeStreamResponse, path: string, signal?: AbortSignal, onBytes?: (size: number) => void, @@ -42,16 +47,34 @@ function summary( `mcp-protocol-version=${value(response.mcpProtocolVersion)} ` + `trace-id=${value(response.traceId)} trace-parent=${value(response.traceParent)} ` + `trace-state=${value(response.traceState)} baggage=${value(response.baggage)} ` + - `complete=${complete} bytes=${byteCount}${error ? ` error=${error}` : ""}\n` + `complete=${complete} bytes=${byteCount}${error ? ` error=${error}` : ""}` + + `${response.kind === "rejected" && response.error.requestId ? ` request-id=${response.error.requestId}` : ""}\n` ); } +async function writeRejectedRuntimeResponse( + response: RuntimeInvokeRejectedResponse, + output: StreamingResponseOutput, +): Promise { + try { + await output.beforeOutput?.(); + await pipeline([summary(response, 0, false, `HTTP ${response.statusCode}`)], output.stderr, { + end: false, + signal: output.signal, + }); + } catch (error) { + failure(error, output.signal); + } + throw new RuntimeInvokeResponseError(`HTTP ${response.statusCode}`); +} + export async function writeRuntimeInvokeResponse( response: RuntimeInvokeResponse, output: StreamingResponseOutput, ): Promise { + if (response.kind === "rejected") return writeRejectedRuntimeResponse(response, output); await writeStreamingResponse(response, output, { - metadata: ({ body: _body, ...metadata }) => metadata, + metadata: ({ body: _body, kind: _kind, ...metadata }) => metadata, summary, fail: (error) => failure(error, output.signal), binaryTtyError: "Binary or unknown response content requires --output-file or --json", diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 618254426..a3fe3dc67 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -242,6 +242,17 @@ export function RuntimeInvokeConsole({ ...(mcp && { mcpSessionId, mcpProtocolVersion }), }); const response = await core.runtime.invokeRuntime(request, opts, controller.signal); + if (response.kind === "rejected") { + updateExchange({ + error: { + name: "Runtime invocation failed", + statusCode: response.statusCode, + requestId: response.error.requestId, + }, + state: "failed", + }); + return; + } updateExchange({ heading: `Response · ${response.statusCode} · ${response.contentType || "-"}`, metadata: metadata(response), diff --git a/src/handlers/runtime/types.tsx b/src/handlers/runtime/types.tsx index 2a5521f01..788777acd 100644 --- a/src/handlers/runtime/types.tsx +++ b/src/handlers/runtime/types.tsx @@ -32,7 +32,7 @@ export type RuntimeInvokeRequest = { baggage?: string; }; -export type RuntimeInvokeResponse = { +export type RuntimeInvokeResponseMetadata = { statusCode: number; contentType: string; runtimeSessionId?: string; @@ -42,9 +42,22 @@ export type RuntimeInvokeResponse = { traceParent?: string; traceState?: string; baggage?: string; +}; + +export type RuntimeInvokeStreamResponse = RuntimeInvokeResponseMetadata & { + kind: "stream"; body: AsyncIterable; }; +export type RuntimeInvokeRejectedResponse = RuntimeInvokeResponseMetadata & { + kind: "rejected"; + error: { + requestId?: string; + }; +}; + +export type RuntimeInvokeResponse = RuntimeInvokeStreamResponse | RuntimeInvokeRejectedResponse; + export type RuntimeShellRequest = { runtimeArn: string; qualifier: string; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 3000b8483..ef03d534a 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -377,6 +377,7 @@ async function* events(items: T[]): AsyncGenerator { } const DEFAULT_RUNTIME_INVOKE_RESPONSE: RuntimeInvokeResponse = { + kind: "stream", statusCode: 200, contentType: "application/json", body: events([]), @@ -852,6 +853,7 @@ export class TestRuntimeClient implements CoreRuntimeClient { ): Promise { this.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); if (this.error) throw this.error; + if (this.invokeResponse.kind === "rejected") return this.invokeResponse; return { ...this.invokeResponse, body: this.invokeBodies.shift() ?? this.invokeResponse.body,