From d823c69de1a28cdcdcbf2ac0af062728e534ad05 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 6 Oct 2026 19:59:08 +0000 Subject: [PATCH 1/6] fix(export): streamline harness progress and output --- src/handlers/project/export/harness.test.ts | 235 ++++++++++++++++++-- src/handlers/project/export/harness.ts | 101 +++++---- 2 files changed, 278 insertions(+), 58 deletions(-) diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index 5a91a3286d..e24e9a4ac8 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { AgentCoreCLIError } from "../../../errors"; import { createRootHandler } from "../../index"; import { @@ -11,17 +11,19 @@ import { TestCoreClient, TestGlobalConfigAccessor, testIO, + type TestIOOptions, } from "../../../testing"; const HARNESS_ARN = "arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-abc123"; +const ANSI_SEQUENCE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[A-Za-z]`, "g"); function testExportCommand() { const core = new TestCoreClient(); // A fresh root per invocation, so wiring-time state (e.g. the add router's // pinned cwd) always reflects the directory the test has cd'd into. The core // client is shared so mock responses and recorded calls span invocations. - const route = (args: string[]) => { - const io = testIO({}); + const route = (args: string[], options: TestIOOptions = {}) => { + const io = testIO(options); const root = createRootHandler(core, { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -35,7 +37,8 @@ function testExportCommand() { io: undefined as unknown as ReturnType, core, project: (args: string[]) => route(args), - run: (args: string[] = []) => route(["export", "harness", ...args]), + run: (args: string[] = [], options: TestIOOptions = {}) => + route(["export", "harness", ...args], options), }; return subject; } @@ -213,21 +216,227 @@ describe("project export harness handler", () => { ); }); - test("emits a machine-readable summary with --json", async () => { + test.each([ + ["local", "exportme", ["--name", "exportme"]], + ["service ARN", "remote_harness", ["--arn", HARNESS_ARN]], + ] as const)( + "renders completed TTY progress for a %s harness", + async (source, harnessName, args) => { + const subject = testExportCommand(); + await inProjectWithHarness(subject); + if (source === "service ARN") { + subject.core.harness.setGetResponse({ + harness: { + harnessName, + model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, + }, + } as never); + } + + await subject.run([...args], { isTTY: true }); + + const output = subject.io.stderr().replace(ANSI_SEQUENCE, ""); + if (source === "service ARN") { + expect(output).toContain("✓ Fetching harness from the service"); + expect(output.indexOf("✓ Fetching harness from the service")).toBeLessThan( + output.indexOf("✓ Reading project spec file"), + ); + } else { + expect(output).toContain("✓ Reading harness configuration"); + } + expect(output).toContain("✓ Reading project spec file"); + expect(output).toContain(`✓ Mapping harness '${harnessName}'`); + expect(output).toContain(`✓ Rendering agent code at 'app/${harnessName}Agent'`); + expect(output).toContain("✓ Writing EXPORT_NOTES.md"); + expect(output).toContain("✓ Updating project spec file"); + expect(output).toContain("✓ Syncing Python dependencies with uv"); + expect(output).toContain( + `Exported harness '${harnessName}' to runtime agent '${harnessName}Agent' (${join("app", `${harnessName}Agent`)})\n`, + ); + expect(output).toContain( + "Next steps:\n Review the generated code\n agentcore build\n agentcore deploy", + ); + expect(output.indexOf("✓ Syncing Python dependencies with uv")).toBeLessThan( + output.indexOf("Exported harness"), + ); + expect(subject.io.stdout()).toBe(""); + }, + ); + + test.each([ + ["local", false, "exportme", ["--name", "exportme"]], + ["local", true, "exportme", ["--name", "exportme"]], + ["service ARN", true, "remote_harness", ["--arn", HARNESS_ARN]], + ] as const)( + "emits only JSON and plain progress for a %s harness (TTY: %s)", + async (source, isTTY, harnessName, args) => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + if (source === "service ARN") { + subject.core.harness.setGetResponse({ + harness: { + harnessName, + model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, + }, + } as never); + } + + await subject.run([...args, "--json"], { isTTY }); + + expect(JSON.parse(subject.io.stdout())).toEqual({ + harnessName, + agentName: `${harnessName}Agent`, + agentPath: join(projectRoot, "app", `${harnessName}Agent`), + notesPath: join(projectRoot, "app", `${harnessName}Agent`, "EXPORT_NOTES.md"), + notes: [], + }); + const progress = subject.io.stderr(); + expect(progress).toContain("Reading project spec file"); + expect(progress).toContain("Syncing Python dependencies with uv"); + if (source === "service ARN") { + expect(progress.split("\n")[0]).toBe("Fetching harness from the service"); + } + expect(progress).not.toContain(String.fromCharCode(0x1b)); + expect(progress).not.toContain("✓"); + expect(progress).not.toContain("Exported harness"); + expect(progress).not.toContain("Next steps:"); + expect(progress).not.toContain("agentcore build"); + expect(progress).not.toContain("agentcore deploy"); + }, + ); + + test.each([false, true])( + "keeps external memory details in EXPORT_NOTES.md (JSON: %s)", + async (jsonOutput) => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + const memoryArn = "arn:aws:bedrock-agentcore:us-west-2:111122223333:memory/external-abc123"; + subject.core.harness.setGetResponse({ + harness: { + harnessName: "remote_harness", + model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, + memory: { + agentCoreMemoryConfiguration: { arn: memoryArn, messagesCount: 12 }, + }, + }, + } as never); + + await subject.run(["--arn", HARNESS_ARN, ...(jsonOutput ? ["--json"] : [])], { isTTY: true }); + + const notesPath = join(projectRoot, "app", "remote_harnessAgent", "EXPORT_NOTES.md"); + const notes = await readFile(notesPath, "utf8"); + const categories = [ + "Harness memory tuning requires manual follow-up", + "External memory reference not exported", + ]; + for (const category of categories) expect(notes).toContain(`### ${category}`); + expect(notes).toContain("messagesCount=12"); + expect(notes).toContain(memoryArn); + expect(notes).toContain("runtime role needs memory permissions on that ARN"); + expect(notes).toContain("memory/session.py"); + const output = subject.io.stderr().replace(ANSI_SEQUENCE, ""); + expect(output).not.toContain(memoryArn); + expect(output).not.toContain("messagesCount=12"); + expect(output).not.toContain("runtime role needs memory permissions on that ARN"); + if (jsonOutput) { + const summary = JSON.parse(subject.io.stdout()); + expect(summary.notesPath).toBe(notesPath); + expect(summary.notes.map((note: { category: string }) => note.category)).toEqual( + categories, + ); + for (const note of summary.notes) expect(notes).toContain(note.message); + expect(subject.io.stderr()).not.toContain(String.fromCharCode(0x1b)); + expect(output).not.toContain("export notes requiring manual follow-up:"); + expect(output).not.toContain( + `Review ${join("app", "remote_harnessAgent", "EXPORT_NOTES.md")}`, + ); + expect(output).not.toContain("Next steps:"); + expect(output).not.toContain("Exported harness"); + } else { + expect(output).toContain( + "2 export notes requiring manual follow-up:\n" + + categories.map((category) => ` - ${category}\n`).join("") + + `Review ${join("app", "remote_harnessAgent", "EXPORT_NOTES.md")} for details.\n`, + ); + expect(output).toContain( + `Exported harness 'remote_harness' to runtime agent 'remote_harnessAgent' (${join("app", "remote_harnessAgent")})`, + ); + expect(output).not.toContain(notesPath); + expect(subject.io.stdout()).toBe(""); + } + }, + ); + + test("resolves displayed export paths from a nested project directory", async () => { const subject = testExportCommand(); const projectRoot = await inProjectWithHarness(subject); + const memoryArn = "arn:aws:bedrock-agentcore:us-west-2:111122223333:memory/external-abc123"; + subject.core.harness.setGetResponse({ + harness: { + harnessName: "remote_harness", + model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, + memory: { + agentCoreMemoryConfiguration: { arn: memoryArn, messagesCount: 12 }, + }, + }, + } as never); + const invocationDirectory = join(projectRoot, "app", "exportme"); + process.chdir(invocationDirectory); - await subject.run(["--name", "exportme", "--json"]); + await subject.run(["--arn", HARNESS_ARN]); - expect(JSON.parse(subject.io.stdout())).toEqual({ - harnessName: "exportme", - agentName: "exportmeAgent", - agentPath: join(projectRoot, "app", "exportmeAgent"), - notesPath: join(projectRoot, "app", "exportmeAgent", "EXPORT_NOTES.md"), - notes: [], - }); + const output = subject.io.stderr(); + const displayedAgentPath = output.match( + /Exported harness 'remote_harness' to runtime agent 'remote_harnessAgent' \(([^)]+)\)/, + )?.[1]; + const displayedNotesPath = output.match(/Review (.+) for details\./)?.[1]; + const agentPath = join(projectRoot, "app", "remote_harnessAgent"); + const notesPath = join(agentPath, "EXPORT_NOTES.md"); + expect(displayedAgentPath).toBe(join("..", "remote_harnessAgent")); + expect(resolve(invocationDirectory, displayedAgentPath!)).toBe(agentPath); + expect(existsSync(join(agentPath, "main.py"))).toBe(true); + expect(displayedNotesPath).toBe(join("..", "remote_harnessAgent", "EXPORT_NOTES.md")); + expect(resolve(invocationDirectory, displayedNotesPath!)).toBe(notesPath); + const notes = await readFile(resolve(invocationDirectory, displayedNotesPath!), "utf8"); + expect(notes).toContain("### External memory reference not exported"); + expect(notes).toContain(memoryArn); + expect(notes).toContain("messagesCount=12"); + expect(notes).toContain("runtime role needs memory permissions on that ARN"); + expect(subject.io.stdout()).toBe(""); }); + test.each(["local configuration", "service fetch"] as const)( + "marks a failed %s step on a TTY without claiming success", + async (failure) => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + const specPath = join(projectRoot, "agentcore", "agentcore.json"); + const specBefore = await readFile(specPath, "utf8"); + let failedStep: string; + if (failure === "service fetch") { + const error = new Error("Harness fetch denied"); + subject.core.harness.setError(error); + await expect(subject.run(["--arn", HARNESS_ARN], { isTTY: true })).rejects.toBe(error); + failedStep = "Fetching harness from the service"; + } else { + await writeFile(join(projectRoot, "app", "exportme", "system-prompt.md"), " \n"); + await expect(subject.run(["--name", "exportme"], { isTTY: true })).rejects.toThrow( + "empty or whitespace-only", + ); + failedStep = "Reading harness configuration"; + } + + const output = subject.io.stderr().replace(ANSI_SEQUENCE, ""); + expect(output).toContain(`✕ ${failedStep}`); + expect(output).not.toContain(`✓ ${failedStep}`); + expect(output).not.toContain("Exported harness"); + expect(output).not.toContain("Next steps:"); + expect(subject.io.stdout()).toBe(""); + expect(existsSync(join(projectRoot, "app", "exportmeAgent"))).toBe(false); + expect(await readFile(specPath, "utf8")).toBe(specBefore); + }, + ); + test("exports a service harness by ARN, fetching from the ARN's region", async () => { const subject = testExportCommand(); const projectRoot = await inProjectWithHarness(subject); diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index 15deacf161..ea8debf272 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -1,10 +1,11 @@ import z from "zod"; +import { relative } from "node:path"; import { InputValidationError, ResourceNotFoundError } from "../../../errors"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; +import { runWithProgress } from "../../../tui/progress"; import { JsonKey } from "../../keys"; import { AgentNameSchema } from "../../../projectSchemas/runtime"; -import { formatExportNotes } from "../../../core/project/templates/export"; import { assertMutuallyExclusiveFlags, coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; import type { ExportProjectResourceConfig } from "./types"; @@ -36,50 +37,41 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) const project = ctx.require(ProjectKey); const jsonOutput = ctx.require(JsonKey); - let input: ExportHarnessInput; - if (flags.arn) { - config.io.stderr.write(`Fetching harness from the service\n`); - const harnessId = harnessIdFromArn(flags.arn); - // The ARN names the region the harness lives in and takes precedence over - // the CLI's resolved region, so service fetches never drift to ambient config. - const coreOpts = coreOptsFromCtx(ctx); - const region = regionFromHarnessArn(flags.arn); - const response = await config.core.harness.getHarness(harnessId, { ...coreOpts, region }); - if (!response.harness) { - throw new ResourceNotFoundError(`no harness exists for '${flags.arn}'`); - } - const { spec, systemPrompt, notes, modelAdditionalParams } = mapServiceHarnessToSpec( - response.harness, - ); - input = { - prefetched: { spec, systemPrompt, notes, modelAdditionalParams }, - targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), - }; - } else { - input = { - harnessName: flags.name!, - targetAgentName: resolveTargetAgentName(flags["target-agent-name"], flags.name!), - }; - } - - // Progress goes to stderr, keeping stdout for machine output. Driven by - // hand because the result is the generator's return value. - const exportRun = config.projectManager.exportHarness(project, input); - let next = await exportRun.next(); - while (!next.done) { - if (next.value.type === "step") config.io.stderr.write(`${next.value.message}\n`); - next = await exportRun.next(); - } - const result = next.value; - - config.io.stderr.write( - `Exported harness '${result.harnessName}' to runtime agent '${result.agentName}' (${result.agentPath})\n`, - ); - for (const line of formatExportNotes(result.notes, result.notesPath)) { - config.io.stderr.write(`${line.text}\n`); - } - config.io.stderr.write( - "Next steps: review the generated code, then `agentcore build` and `agentcore deploy`\n", + const result = await runWithProgress( + (async function* () { + let input: ExportHarnessInput; + if (flags.arn) { + const harnessId = harnessIdFromArn(flags.arn); + // The ARN's region takes precedence over the CLI's resolved region. + const coreOpts = coreOptsFromCtx(ctx); + const region = regionFromHarnessArn(flags.arn); + yield { type: "step" as const, message: "Fetching harness from the service" }; + const response = await config.core.harness.getHarness(harnessId, { + ...coreOpts, + region, + }); + if (!response.harness) { + throw new ResourceNotFoundError(`no harness exists for '${flags.arn}'`); + } + const { spec, systemPrompt, notes, modelAdditionalParams } = mapServiceHarnessToSpec( + response.harness, + ); + input = { + prefetched: { spec, systemPrompt, notes, modelAdditionalParams }, + targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), + }; + } else { + input = { + harnessName: flags.name!, + targetAgentName: resolveTargetAgentName(flags["target-agent-name"], flags.name!), + }; + } + return yield* config.projectManager.exportHarness(project, input); + })(), + { + io: config.io, + interactive: jsonOutput ? false : undefined, + }, ); if (jsonOutput) { @@ -90,7 +82,26 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) notesPath: result.notesPath, notes: result.notes, }); + return; } + + config.io.stderr.write( + `Exported harness '${result.harnessName}' to runtime agent '${result.agentName}' (${relative(process.cwd(), result.agentPath)})\n`, + ); + if (result.notes.length > 0) { + config.io.stderr.write( + `${result.notes.length} export ${result.notes.length === 1 ? "note" : "notes"} requiring manual follow-up:\n`, + ); + for (const note of result.notes) { + config.io.stderr.write(` - ${note.category}\n`); + } + config.io.stderr.write( + `Review ${relative(process.cwd(), result.notesPath)} for details.\n`, + ); + } + config.io.stderr.write( + "Next steps:\n Review the generated code\n agentcore build\n agentcore deploy\n", + ); }, }); From 41d9a0980825582e5935adb03dcfbb8235be94f7 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 6 Oct 2026 19:59:08 +0000 Subject: [PATCH 2/6] fix(cli): suppress SDK Node support notice --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index 8abc433ca0..cf9ea0e979 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,9 @@ import { CommandRunMetricEventKey, ValueContext } from "./router"; import { NpmCliVersionManager, printUpdateNotice } from "./cliVersionManager"; import { CliVersionManagerKey } from "./handlers/keys"; +// Suppress only the AWS SDK's Node.js version support warning before constructing clients. +process.env.AWS_SDK_JS_NODE_VERSION_SUPPORT_WARNING_DISABLED = "true"; + process.exit( await runWithExitCode(async (argv: string[]) => { const startTime = Date.now(); From ebe66ccf24cef2ee89aaa569a78f4aba88bd25cb Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 6 Oct 2026 20:37:52 +0000 Subject: [PATCH 3/6] fix(export): fold result into review next step --- src/handlers/project/export/harness.test.ts | 34 ++++++++------------- src/handlers/project/export/harness.ts | 20 +++--------- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index e24e9a4ac8..66066192b0 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -166,7 +166,7 @@ describe("project export harness handler", () => { }); expect(subject.io.stderr()).toContain( - "Exported harness 'exportme' to runtime agent 'exportmeAgent'", + `Review the generated code in ${join("app", "exportmeAgent")}`, ); expect(subject.io.stdout()).toBe(""); }); @@ -251,14 +251,12 @@ describe("project export harness handler", () => { expect(output).toContain("✓ Updating project spec file"); expect(output).toContain("✓ Syncing Python dependencies with uv"); expect(output).toContain( - `Exported harness '${harnessName}' to runtime agent '${harnessName}Agent' (${join("app", `${harnessName}Agent`)})\n`, - ); - expect(output).toContain( - "Next steps:\n Review the generated code\n agentcore build\n agentcore deploy", + `Next steps:\n Review the generated code in ${join("app", `${harnessName}Agent`)}\n agentcore build\n agentcore deploy`, ); expect(output.indexOf("✓ Syncing Python dependencies with uv")).toBeLessThan( - output.indexOf("Exported harness"), + output.indexOf("Next steps:"), ); + expect(output).not.toContain("Exported harness"); expect(subject.io.stdout()).toBe(""); }, ); @@ -346,21 +344,16 @@ describe("project export harness handler", () => { ); for (const note of summary.notes) expect(notes).toContain(note.message); expect(subject.io.stderr()).not.toContain(String.fromCharCode(0x1b)); - expect(output).not.toContain("export notes requiring manual follow-up:"); - expect(output).not.toContain( - `Review ${join("app", "remote_harnessAgent", "EXPORT_NOTES.md")}`, - ); + expect(output).not.toContain("manual follow-ups in EXPORT_NOTES.md"); expect(output).not.toContain("Next steps:"); expect(output).not.toContain("Exported harness"); } else { expect(output).toContain( - "2 export notes requiring manual follow-up:\n" + - categories.map((category) => ` - ${category}\n`).join("") + - `Review ${join("app", "remote_harnessAgent", "EXPORT_NOTES.md")} for details.\n`, - ); - expect(output).toContain( - `Exported harness 'remote_harness' to runtime agent 'remote_harnessAgent' (${join("app", "remote_harnessAgent")})`, + `Next steps:\n Review the generated code in ${join("app", "remote_harnessAgent")} (2 manual follow-ups in EXPORT_NOTES.md)\n agentcore build\n agentcore deploy`, ); + for (const category of categories) expect(output).not.toContain(category); + expect(output).not.toContain("Exported harness"); + expect(output).not.toContain("requiring manual follow-up:"); expect(output).not.toContain(notesPath); expect(subject.io.stdout()).toBe(""); } @@ -387,17 +380,16 @@ describe("project export harness handler", () => { const output = subject.io.stderr(); const displayedAgentPath = output.match( - /Exported harness 'remote_harness' to runtime agent 'remote_harnessAgent' \(([^)]+)\)/, + /Review the generated code in (.+) \(2 manual follow-ups in EXPORT_NOTES\.md\)/, )?.[1]; - const displayedNotesPath = output.match(/Review (.+) for details\./)?.[1]; const agentPath = join(projectRoot, "app", "remote_harnessAgent"); const notesPath = join(agentPath, "EXPORT_NOTES.md"); expect(displayedAgentPath).toBe(join("..", "remote_harnessAgent")); expect(resolve(invocationDirectory, displayedAgentPath!)).toBe(agentPath); expect(existsSync(join(agentPath, "main.py"))).toBe(true); - expect(displayedNotesPath).toBe(join("..", "remote_harnessAgent", "EXPORT_NOTES.md")); - expect(resolve(invocationDirectory, displayedNotesPath!)).toBe(notesPath); - const notes = await readFile(resolve(invocationDirectory, displayedNotesPath!), "utf8"); + const displayedNotesPath = resolve(invocationDirectory, displayedAgentPath!, "EXPORT_NOTES.md"); + expect(displayedNotesPath).toBe(notesPath); + const notes = await readFile(displayedNotesPath, "utf8"); expect(notes).toContain("### External memory reference not exported"); expect(notes).toContain(memoryArn); expect(notes).toContain("messagesCount=12"); diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index ea8debf272..03dc2df983 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -85,22 +85,12 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) return; } + const reviewNotes = + result.notes.length > 0 + ? ` (${result.notes.length} manual follow-up${result.notes.length === 1 ? "" : "s"} in EXPORT_NOTES.md)` + : ""; config.io.stderr.write( - `Exported harness '${result.harnessName}' to runtime agent '${result.agentName}' (${relative(process.cwd(), result.agentPath)})\n`, - ); - if (result.notes.length > 0) { - config.io.stderr.write( - `${result.notes.length} export ${result.notes.length === 1 ? "note" : "notes"} requiring manual follow-up:\n`, - ); - for (const note of result.notes) { - config.io.stderr.write(` - ${note.category}\n`); - } - config.io.stderr.write( - `Review ${relative(process.cwd(), result.notesPath)} for details.\n`, - ); - } - config.io.stderr.write( - "Next steps:\n Review the generated code\n agentcore build\n agentcore deploy\n", + `Next steps:\n Review the generated code in ${relative(process.cwd(), result.agentPath)}${reviewNotes}\n agentcore build\n agentcore deploy\n`, ); }, }); From 1c6d075de6d8094c0bd5ac4c183253bcc9197e86 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 7 Oct 2026 14:10:14 +0000 Subject: [PATCH 4/6] test(export): update existing output assertions --- src/handlers/project/export/harness.test.ts | 232 ++------------------ 1 file changed, 17 insertions(+), 215 deletions(-) diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index 66066192b0..a9e1837647 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { AgentCoreCLIError } from "../../../errors"; import { createRootHandler } from "../../index"; import { @@ -11,19 +11,17 @@ import { TestCoreClient, TestGlobalConfigAccessor, testIO, - type TestIOOptions, } from "../../../testing"; const HARNESS_ARN = "arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-abc123"; -const ANSI_SEQUENCE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[A-Za-z]`, "g"); function testExportCommand() { const core = new TestCoreClient(); // A fresh root per invocation, so wiring-time state (e.g. the add router's // pinned cwd) always reflects the directory the test has cd'd into. The core // client is shared so mock responses and recorded calls span invocations. - const route = (args: string[], options: TestIOOptions = {}) => { - const io = testIO(options); + const route = (args: string[]) => { + const io = testIO({}); const root = createRootHandler(core, { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -37,8 +35,7 @@ function testExportCommand() { io: undefined as unknown as ReturnType, core, project: (args: string[]) => route(args), - run: (args: string[] = [], options: TestIOOptions = {}) => - route(["export", "harness", ...args], options), + run: (args: string[] = []) => route(["export", "harness", ...args]), }; return subject; } @@ -166,8 +163,9 @@ describe("project export harness handler", () => { }); expect(subject.io.stderr()).toContain( - `Review the generated code in ${join("app", "exportmeAgent")}`, + `Next steps:\n Review the generated code in ${join("app", "exportmeAgent")}\n agentcore build\n agentcore deploy`, ); + expect(subject.io.stderr()).not.toContain("Exported harness"); expect(subject.io.stdout()).toBe(""); }); @@ -216,219 +214,23 @@ describe("project export harness handler", () => { ); }); - test.each([ - ["local", "exportme", ["--name", "exportme"]], - ["service ARN", "remote_harness", ["--arn", HARNESS_ARN]], - ] as const)( - "renders completed TTY progress for a %s harness", - async (source, harnessName, args) => { - const subject = testExportCommand(); - await inProjectWithHarness(subject); - if (source === "service ARN") { - subject.core.harness.setGetResponse({ - harness: { - harnessName, - model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, - }, - } as never); - } - - await subject.run([...args], { isTTY: true }); - - const output = subject.io.stderr().replace(ANSI_SEQUENCE, ""); - if (source === "service ARN") { - expect(output).toContain("✓ Fetching harness from the service"); - expect(output.indexOf("✓ Fetching harness from the service")).toBeLessThan( - output.indexOf("✓ Reading project spec file"), - ); - } else { - expect(output).toContain("✓ Reading harness configuration"); - } - expect(output).toContain("✓ Reading project spec file"); - expect(output).toContain(`✓ Mapping harness '${harnessName}'`); - expect(output).toContain(`✓ Rendering agent code at 'app/${harnessName}Agent'`); - expect(output).toContain("✓ Writing EXPORT_NOTES.md"); - expect(output).toContain("✓ Updating project spec file"); - expect(output).toContain("✓ Syncing Python dependencies with uv"); - expect(output).toContain( - `Next steps:\n Review the generated code in ${join("app", `${harnessName}Agent`)}\n agentcore build\n agentcore deploy`, - ); - expect(output.indexOf("✓ Syncing Python dependencies with uv")).toBeLessThan( - output.indexOf("Next steps:"), - ); - expect(output).not.toContain("Exported harness"); - expect(subject.io.stdout()).toBe(""); - }, - ); - - test.each([ - ["local", false, "exportme", ["--name", "exportme"]], - ["local", true, "exportme", ["--name", "exportme"]], - ["service ARN", true, "remote_harness", ["--arn", HARNESS_ARN]], - ] as const)( - "emits only JSON and plain progress for a %s harness (TTY: %s)", - async (source, isTTY, harnessName, args) => { - const subject = testExportCommand(); - const projectRoot = await inProjectWithHarness(subject); - if (source === "service ARN") { - subject.core.harness.setGetResponse({ - harness: { - harnessName, - model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, - }, - } as never); - } - - await subject.run([...args, "--json"], { isTTY }); - - expect(JSON.parse(subject.io.stdout())).toEqual({ - harnessName, - agentName: `${harnessName}Agent`, - agentPath: join(projectRoot, "app", `${harnessName}Agent`), - notesPath: join(projectRoot, "app", `${harnessName}Agent`, "EXPORT_NOTES.md"), - notes: [], - }); - const progress = subject.io.stderr(); - expect(progress).toContain("Reading project spec file"); - expect(progress).toContain("Syncing Python dependencies with uv"); - if (source === "service ARN") { - expect(progress.split("\n")[0]).toBe("Fetching harness from the service"); - } - expect(progress).not.toContain(String.fromCharCode(0x1b)); - expect(progress).not.toContain("✓"); - expect(progress).not.toContain("Exported harness"); - expect(progress).not.toContain("Next steps:"); - expect(progress).not.toContain("agentcore build"); - expect(progress).not.toContain("agentcore deploy"); - }, - ); - - test.each([false, true])( - "keeps external memory details in EXPORT_NOTES.md (JSON: %s)", - async (jsonOutput) => { - const subject = testExportCommand(); - const projectRoot = await inProjectWithHarness(subject); - const memoryArn = "arn:aws:bedrock-agentcore:us-west-2:111122223333:memory/external-abc123"; - subject.core.harness.setGetResponse({ - harness: { - harnessName: "remote_harness", - model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, - memory: { - agentCoreMemoryConfiguration: { arn: memoryArn, messagesCount: 12 }, - }, - }, - } as never); - - await subject.run(["--arn", HARNESS_ARN, ...(jsonOutput ? ["--json"] : [])], { isTTY: true }); - - const notesPath = join(projectRoot, "app", "remote_harnessAgent", "EXPORT_NOTES.md"); - const notes = await readFile(notesPath, "utf8"); - const categories = [ - "Harness memory tuning requires manual follow-up", - "External memory reference not exported", - ]; - for (const category of categories) expect(notes).toContain(`### ${category}`); - expect(notes).toContain("messagesCount=12"); - expect(notes).toContain(memoryArn); - expect(notes).toContain("runtime role needs memory permissions on that ARN"); - expect(notes).toContain("memory/session.py"); - const output = subject.io.stderr().replace(ANSI_SEQUENCE, ""); - expect(output).not.toContain(memoryArn); - expect(output).not.toContain("messagesCount=12"); - expect(output).not.toContain("runtime role needs memory permissions on that ARN"); - if (jsonOutput) { - const summary = JSON.parse(subject.io.stdout()); - expect(summary.notesPath).toBe(notesPath); - expect(summary.notes.map((note: { category: string }) => note.category)).toEqual( - categories, - ); - for (const note of summary.notes) expect(notes).toContain(note.message); - expect(subject.io.stderr()).not.toContain(String.fromCharCode(0x1b)); - expect(output).not.toContain("manual follow-ups in EXPORT_NOTES.md"); - expect(output).not.toContain("Next steps:"); - expect(output).not.toContain("Exported harness"); - } else { - expect(output).toContain( - `Next steps:\n Review the generated code in ${join("app", "remote_harnessAgent")} (2 manual follow-ups in EXPORT_NOTES.md)\n agentcore build\n agentcore deploy`, - ); - for (const category of categories) expect(output).not.toContain(category); - expect(output).not.toContain("Exported harness"); - expect(output).not.toContain("requiring manual follow-up:"); - expect(output).not.toContain(notesPath); - expect(subject.io.stdout()).toBe(""); - } - }, - ); - - test("resolves displayed export paths from a nested project directory", async () => { + test("emits a machine-readable summary with --json", async () => { const subject = testExportCommand(); const projectRoot = await inProjectWithHarness(subject); - const memoryArn = "arn:aws:bedrock-agentcore:us-west-2:111122223333:memory/external-abc123"; - subject.core.harness.setGetResponse({ - harness: { - harnessName: "remote_harness", - model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, - memory: { - agentCoreMemoryConfiguration: { arn: memoryArn, messagesCount: 12 }, - }, - }, - } as never); - const invocationDirectory = join(projectRoot, "app", "exportme"); - process.chdir(invocationDirectory); - await subject.run(["--arn", HARNESS_ARN]); + await subject.run(["--name", "exportme", "--json"]); - const output = subject.io.stderr(); - const displayedAgentPath = output.match( - /Review the generated code in (.+) \(2 manual follow-ups in EXPORT_NOTES\.md\)/, - )?.[1]; - const agentPath = join(projectRoot, "app", "remote_harnessAgent"); - const notesPath = join(agentPath, "EXPORT_NOTES.md"); - expect(displayedAgentPath).toBe(join("..", "remote_harnessAgent")); - expect(resolve(invocationDirectory, displayedAgentPath!)).toBe(agentPath); - expect(existsSync(join(agentPath, "main.py"))).toBe(true); - const displayedNotesPath = resolve(invocationDirectory, displayedAgentPath!, "EXPORT_NOTES.md"); - expect(displayedNotesPath).toBe(notesPath); - const notes = await readFile(displayedNotesPath, "utf8"); - expect(notes).toContain("### External memory reference not exported"); - expect(notes).toContain(memoryArn); - expect(notes).toContain("messagesCount=12"); - expect(notes).toContain("runtime role needs memory permissions on that ARN"); - expect(subject.io.stdout()).toBe(""); + expect(JSON.parse(subject.io.stdout())).toEqual({ + harnessName: "exportme", + agentName: "exportmeAgent", + agentPath: join(projectRoot, "app", "exportmeAgent"), + notesPath: join(projectRoot, "app", "exportmeAgent", "EXPORT_NOTES.md"), + notes: [], + }); + expect(subject.io.stderr()).not.toContain("Next steps:"); + expect(subject.io.stderr()).not.toContain("Exported harness"); }); - test.each(["local configuration", "service fetch"] as const)( - "marks a failed %s step on a TTY without claiming success", - async (failure) => { - const subject = testExportCommand(); - const projectRoot = await inProjectWithHarness(subject); - const specPath = join(projectRoot, "agentcore", "agentcore.json"); - const specBefore = await readFile(specPath, "utf8"); - let failedStep: string; - if (failure === "service fetch") { - const error = new Error("Harness fetch denied"); - subject.core.harness.setError(error); - await expect(subject.run(["--arn", HARNESS_ARN], { isTTY: true })).rejects.toBe(error); - failedStep = "Fetching harness from the service"; - } else { - await writeFile(join(projectRoot, "app", "exportme", "system-prompt.md"), " \n"); - await expect(subject.run(["--name", "exportme"], { isTTY: true })).rejects.toThrow( - "empty or whitespace-only", - ); - failedStep = "Reading harness configuration"; - } - - const output = subject.io.stderr().replace(ANSI_SEQUENCE, ""); - expect(output).toContain(`✕ ${failedStep}`); - expect(output).not.toContain(`✓ ${failedStep}`); - expect(output).not.toContain("Exported harness"); - expect(output).not.toContain("Next steps:"); - expect(subject.io.stdout()).toBe(""); - expect(existsSync(join(projectRoot, "app", "exportmeAgent"))).toBe(false); - expect(await readFile(specPath, "utf8")).toBe(specBefore); - }, - ); - test("exports a service harness by ARN, fetching from the ARN's region", async () => { const subject = testExportCommand(); const projectRoot = await inProjectWithHarness(subject); From 7cf6d3eb964ed7bf75118bebb54d03e5f3c90b37 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 7 Oct 2026 14:44:32 +0000 Subject: [PATCH 5/6] refactor(export): remove unused note formatter --- src/core/project/templates/export.test.ts | 18 ------------- src/core/project/templates/export.ts | 31 +---------------------- 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index 14f8e841d7..2b4ca67d9b 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -21,7 +21,6 @@ import { MEMORY_NAME_NOT_FOUND_NOTE_CATEGORY, MODEL_API_KEY_NOTE_CATEGORY, buildExportNotesMarkdown, - formatExportNotes, mapHarnessToExportPlan, matchesAllowedTools, type HarnessExportInput, @@ -795,21 +794,4 @@ describe("export notes rendering", () => { const markdown = buildExportNotesMarkdown([], "assistant", "assistantAgent", "v"); expect(markdown).toContain("No manual steps required."); }); - - test("formatExportNotes renders a warning block or a quiet confirmation", () => { - expect(formatExportNotes([], "notes.md")).toEqual([ - { text: "No manual follow-up required. (Details: notes.md)", tone: "dim" }, - ]); - const lines = formatExportNotes( - [{ category: "Cat", message: "line one\nline two" }], - "notes.md", - ); - expect(lines.map((line) => line.text)).toEqual([ - "1 export note requiring manual follow-up:", - " - Cat", - " line one", - " line two", - "These notes are also saved to notes.md", - ]); - }); }); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index 702fce355c..8bdb1595b5 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -32,12 +32,6 @@ export interface ExportNote { message: string; } -/** A single rendered output line + a tone the caller maps to its own styling. */ -export interface ExportNoteLine { - text: string; - tone: "warn" | "dim"; -} - /** Everything the mapper needs; all reads are done by the caller. */ export interface HarnessExportInput { harnessName: string; @@ -977,7 +971,7 @@ function fnmatch(pattern: string, str: string): boolean { } // ============================================================================ -// EXPORT_NOTES.md + display formatting +// EXPORT_NOTES.md // ============================================================================ /** Render the EXPORT_NOTES.md content written into the exported agent's directory. */ @@ -1010,26 +1004,3 @@ export function buildExportNotesMarkdown( lines.push(""); return lines.join("\n"); } - -/** - * Format export notes into styled lines for the export success path. Pure so the - * CLI (and a future TUI screen) render identical wording. - */ -export function formatExportNotes(notes: ExportNote[], notesFileHint: string): ExportNoteLine[] { - if (notes.length === 0) { - return [{ text: `No manual follow-up required. (Details: ${notesFileHint})`, tone: "dim" }]; - } - - const label = notes.length === 1 ? "note" : "notes"; - const lines: ExportNoteLine[] = [ - { text: `${notes.length} export ${label} requiring manual follow-up:`, tone: "warn" }, - ]; - for (const note of notes) { - lines.push({ text: ` - ${note.category}`, tone: "warn" }); - for (const messageLine of note.message.split("\n")) { - lines.push({ text: ` ${messageLine}`, tone: "dim" }); - } - } - lines.push({ text: `These notes are also saved to ${notesFileHint}`, tone: "dim" }); - return lines; -} From cb6d67f392ddd01dbdd0c8573b45703a6a819170 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 7 Oct 2026 15:04:23 +0000 Subject: [PATCH 6/6] fix(export): restore success summary --- src/handlers/project/export/harness.test.ts | 7 ++++++- src/handlers/project/export/harness.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index a9e1837647..21fc42abe3 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -165,7 +165,9 @@ describe("project export harness handler", () => { expect(subject.io.stderr()).toContain( `Next steps:\n Review the generated code in ${join("app", "exportmeAgent")}\n agentcore build\n agentcore deploy`, ); - expect(subject.io.stderr()).not.toContain("Exported harness"); + expect(subject.io.stderr()).toContain( + `Exported harness 'exportme' to runtime agent 'exportmeAgent' (${join("app", "exportmeAgent")})\nNext steps:`, + ); expect(subject.io.stdout()).toBe(""); }); @@ -265,6 +267,9 @@ describe("project export harness handler", () => { expect(spec.runtimes.map((runtime: { name: string }) => runtime.name)).toContain( "exported_arn", ); + expect(subject.io.stderr()).toContain( + `Exported harness 'remote_harness' to runtime agent 'exported_arn' (${join("app", "exported_arn")})`, + ); }); test("defaults the --arn target name from the fetched harness name", async () => { diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index 03dc2df983..e08502d8f2 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -85,12 +85,16 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) return; } + const agentPath = relative(process.cwd(), result.agentPath); + config.io.stderr.write( + `Exported harness '${result.harnessName}' to runtime agent '${result.agentName}' (${agentPath})\n`, + ); const reviewNotes = result.notes.length > 0 ? ` (${result.notes.length} manual follow-up${result.notes.length === 1 ? "" : "s"} in EXPORT_NOTES.md)` : ""; config.io.stderr.write( - `Next steps:\n Review the generated code in ${relative(process.cwd(), result.agentPath)}${reviewNotes}\n agentcore build\n agentcore deploy\n`, + `Next steps:\n Review the generated code in ${agentPath}${reviewNotes}\n agentcore build\n agentcore deploy\n`, ); }, });