diff --git a/README.md b/README.md index abfb0d46a..dea73bad2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It gives you two ways to work, from the same binary: - **A scriptable CLI** — every operation is a flag-driven subcommand that emits JSON (`--json`), so it can be used by codeing agents and can drop cleanly into scripts, CI, and automation. -- **An interactive TUI** — bare Harness and Runtime branches and leaves open +- **An interactive TUI** — bare Harness, Runtime, and Memory branches and leaves open their corresponding menus and selection flows. ```bash @@ -26,7 +26,7 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Commands with operation flags run headlessly. Bare Harness and Runtime branches +Commands with operation flags run headlessly. Bare Harness, Runtime, and Memory branches and leaves open their interactive flows. ``` @@ -242,7 +242,7 @@ accept ARNs, `--version`, `--interactive`, cross-account targets, or custom request paths. All requests use the Runtime `/invocations` route, including MCP Runtimes. -Bare Runtime branches and leaves require a TTY on stdin and stdout. Supplying +Bare Runtime and Memory branches and leaves require a TTY on stdin and stdout. Supplying operation flags runs the command headlessly, and `--json` always suppresses TUI rendering. @@ -252,6 +252,9 @@ agentcore runtime list agentcore runtime get agentcore runtime version list agentcore runtime endpoint list +agentcore memory +agentcore memory list +agentcore memory get ``` --- diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 5d59130e0..5899233ac 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -28,6 +28,9 @@ import { RuntimeListEndpointsScreen } from "../handlers/runtime/endpoint/list/sc import { RuntimeVersionScreen } from "../handlers/runtime/version/screen.tsx"; import { RuntimeGetVersionScreen } from "../handlers/runtime/version/get/screen.tsx"; import { RuntimeListVersionsScreen } from "../handlers/runtime/version/list/screen.tsx"; +import { MemoryScreen } from "../handlers/memory/screen.tsx"; +import { MemoryGetJsonScreen, MemoryGetScreen } from "../handlers/memory/get/screen.tsx"; +import { MemoryListScreen } from "../handlers/memory/list/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -255,6 +258,23 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/runtime/endpoint/list/:runtimeId" element={} /> + } /> + } + /> + } + /> + } + /> + } + /> } /> diff --git a/src/handlers/memory/get/screen.tsx b/src/handlers/memory/get/screen.tsx new file mode 100644 index 000000000..643ffbd3e --- /dev/null +++ b/src/handlers/memory/get/screen.tsx @@ -0,0 +1,103 @@ +import { useQuery } from "@tanstack/react-query"; +import { Box, Text, useInput } from "ink"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../components/JsonDetail"; +import { KeyValueTable } from "../../../components/KeyValueTable.js"; +import { Layout } from "../../../components/Layout"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { Divider } from "../../../components/ui/divider/Divider.js"; +import { Spinner } from "../../../components/ui/spinner"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +function useMemoryDetail({ ctx, core }: ScreenProps, memoryId: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["memory", opts.region, memoryId, "full"], + queryFn: () => core.memory.getMemory(memoryId!, "full", opts), + enabled: memoryId !== undefined, + }); +} + +export function MemoryGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { memoryId } = useParams(); + const detail = useMemoryDetail(props, memoryId); + const memory = detail.data?.memory; + + useInput((input, key) => { + if (key.escape) { + navigate(-1); + return; + } + if (input === "r" && detail.isError) { + void detail.refetch(); + return; + } + if (detail.isError || !memory) return; + if (key.return && memoryId) { + navigate(`/agentcore/memory/get/${encodeURIComponent(memoryId)}/json`); + } + }); + + return ( + + {detail.isPending ? ( + + ) : detail.isError ? ( + Error: {(detail.error as Error).message} + ) : ( + + + + + + + + + + + {"detail".padEnd(9)} + + show the full JSON definition + + + )} + + ); +} + +export function MemoryGetJsonScreen(props: ScreenProps) { + const { memoryId } = useParams(); + const detail = useMemoryDetail(props, memoryId); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/memory/index.tsx b/src/handlers/memory/index.tsx index 8f11ca8df..53ae0a23c 100644 --- a/src/handlers/memory/index.tsx +++ b/src/handlers/memory/index.tsx @@ -1,13 +1,15 @@ +import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; +import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import type { Core } from "../types"; -import { createHelpDefault } from "../help"; import { createGetMemoryHandler } from "./get"; import { createListMemoriesHandler } from "./list"; export function createMemoryHandler(core: Core, io: AppIO): Router { return new Router("memory", "manage AgentCore Memories") - .default(createHelpDefault(io)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) .handler(createGetMemoryHandler(core)) .handler(createListMemoriesHandler(core)); } diff --git a/src/handlers/memory/list/screen.tsx b/src/handlers/memory/list/screen.tsx new file mode 100644 index 000000000..7e8c8104d --- /dev/null +++ b/src/handlers/memory/list/screen.tsx @@ -0,0 +1,60 @@ +import type { MemorySummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import { formatTimestamp } from "../../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../components/ui/data-table"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +interface MemoryRow extends Record { + memoryId: string; + status: string; + updatedAt: string; +} + +export const memoryColumns = [ + { key: "memoryId", header: "id", flex: true }, + { key: "status", header: "status", width: 13 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(memory: MemorySummary): MemoryRow { + return { + memoryId: memory.id ?? "", + status: memory.status ?? "-", + updatedAt: memory.updatedAt?.toISOString() ?? "-", + }; +} + +export function MemoryListScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.memory.listMemories(token, pageSize, opts); + return { + items: response.memories ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={memoryColumns} + getValue={(row) => row.memoryId} + onSelect={(memoryId) => navigate(`/agentcore/memory/get/${encodeURIComponent(memoryId)}`)} + onBack={() => navigate("/agentcore/memory")} + loadingMessage="Loading Memories…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No Memories found in this Region." + emptyPageMessage="No Memories on this page." + /> + ); +} diff --git a/src/handlers/memory/memory.screen.test.tsx b/src/handlers/memory/memory.screen.test.tsx new file mode 100644 index 000000000..273d8bcd8 --- /dev/null +++ b/src/handlers/memory/memory.screen.test.tsx @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + GetMemoryOutput, + Memory, + MemorySummary, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import stringWidth from "string-width"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + tick, + waitFor, + waitForText, +} from "../../testing"; + +afterEach(cleanupScreens); + +const memoryEndpointUrl = "https://memory.test"; + +function memorySummary(overrides: Partial = {}): MemorySummary { + return { + arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/memory-1", + id: "memory-1", + status: "ACTIVE", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + updatedAt: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function memory(overrides: Partial = {}): Memory { + return { + arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/memory-1", + id: "memory-1", + name: "orders-memory", + description: "Memory for the orders agent", + memoryExecutionRoleArn: "arn:aws:iam::123456789012:role/memory-role", + eventExpiryDuration: 30, + status: "ACTIVE", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + updatedAt: new Date("2026-07-20T12:34:56.000Z"), + strategies: [ + { + strategyId: "summary-1", + name: "summary", + type: "SUMMARIZATION", + namespaces: [], + namespaceTemplates: ["/strategies/{memoryStrategyId}/actors/{actorId}"], + status: "ACTIVE", + }, + ], + ...overrides, + }; +} + +function getMemoryOutput(overrides: Partial = {}): GetMemoryOutput { + return { memory: memory(overrides) }; +} + +function coreWithMemories(memories: MemorySummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.memory.setListResponse({ memories }); + return core; +} + +describe("Memory picker", () => { + test("renders Memory identity, status, and update time", async () => { + const core = coreWithMemories([ + memorySummary({ + id: "memory-visible-id", + status: "FAILED", + updatedAt: new Date("2026-07-21T02:03:04.000Z"), + }), + ]); + const screen = renderScreen("/agentcore/memory/list", { core }); + + await waitForText(screen.lastFrame, "memory-visible-id"); + const frame = screen.lastFrame()!; + expect(frame).toContain("id"); + expect(frame).toContain("status"); + expect(frame).toContain("updated UTC"); + expect(frame).toContain("FAILED"); + expect(frame).toContain("2026-07-21 02:03"); + }); + + test("keeps long Memory IDs separate from adjacent columns", async () => { + const memoryId = `memory-${"x".repeat(70)}`; + const core = coreWithMemories([memorySummary({ id: memoryId })]); + const screen = renderScreen("/agentcore/memory/list", { core }); + + await waitForText(screen.lastFrame, "memory-"); + await screen.resize(80, 24); + const row = screen + .lastFrame()! + .split("\n") + .find((line) => line.includes("memory-")); + expect(row).toBeDefined(); + expect(row).toContain("ACTIVE"); + expect(row).not.toContain(memoryId); + expect(stringWidth(row!)).toBeLessThanOrEqual(80); + }); + + test("calls listMemories with exact Core options", async () => { + const core = coreWithMemories([memorySummary()]); + renderScreen("/agentcore/memory/list", { core, endpointUrl: memoryEndpointUrl }); + + await waitFor(() => core.memory.calls.some((call) => call.method === "listMemories")); + expect(core.memory.calls.filter((call) => call.method === "listMemories")).toEqual([ + { + method: "listMemories", + args: [ + undefined, + expect.any(Number), + { + region: "us-east-1", + endpointUrl: memoryEndpointUrl, + }, + ], + }, + ]); + }); + + test("shows first-page and later-page empty states", async () => { + const empty = renderScreen("/agentcore/memory/list"); + await waitForText(empty.lastFrame, "No Memories found in this Region."); + empty.unmount(); + + const core = new TestCoreClient(); + core.memory.setListResponse({ + memories: [memorySummary({ id: "page-one" })], + nextToken: "page-2", + }); + core.memory.setListResponse({ memories: [] }, "page-2"); + const paged = renderScreen("/agentcore/memory/list", { core }); + + await waitForText(paged.lastFrame, "page 1 · more →"); + await paged.write("l"); + await waitForText(paged.lastFrame, "No Memories on this page."); + expect(paged.lastFrame()).not.toContain("No Memories found in this Region."); + }); + + test("bare Memory get redirects to the picker", async () => { + const core = coreWithMemories([memorySummary({ id: "redirected-memory" })]); + const screen = renderScreen("/agentcore/memory/get", { core }); + + await waitForText(screen.lastFrame, "redirected-memory"); + expect(core.memory.calls[0]?.method).toBe("listMemories"); + }); + + test("selection opens the matching Memory detail", async () => { + const memoryId = "memory blue"; + const core = coreWithMemories([memorySummary({ id: memoryId })]); + core.memory.setGetResponse(getMemoryOutput({ id: memoryId })); + const screen = renderScreen("/agentcore/memory/list", { core }); + + await waitForText(screen.lastFrame, memoryId); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → memory → get → ${memoryId}`); + await waitFor(() => + core.memory.calls.some((call) => call.method === "getMemory" && call.args[0] === memoryId), + ); + }); +}); + +describe("Memory detail", () => { + test("loads the full view and renders a resource summary", async () => { + const core = new TestCoreClient(); + core.memory.setGetResponse(getMemoryOutput()); + const screen = renderScreen("/agentcore/memory/get/memory-1", { + core, + endpointUrl: memoryEndpointUrl, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + const frame = screen.lastFrame()!; + expect(frame).toContain("orders-memory"); + expect(frame).toMatch(/eventExpiryDays\s+30/); + expect(frame).toMatch(/strategies\s+1/); + expect(frame).toContain("arn:aws:bedrock-agentcore"); + expect(core.memory.calls.find((call) => call.method === "getMemory")).toEqual({ + method: "getMemory", + args: [ + "memory-1", + "full", + { + region: "us-east-1", + endpointUrl: memoryEndpointUrl, + }, + ], + }); + }); + + test("shows a failure reason only when the service provides one", async () => { + const healthyCore = new TestCoreClient(); + healthyCore.memory.setGetResponse(getMemoryOutput()); + const healthy = renderScreen("/agentcore/memory/get/memory-1", { core: healthyCore }); + + await waitForText(healthy.lastFrame, "show the full JSON definition"); + expect(healthy.lastFrame()).not.toContain("failureReason"); + healthy.unmount(); + + const failedCore = new TestCoreClient(); + failedCore.memory.setGetResponse( + getMemoryOutput({ status: "FAILED", failureReason: "Strategy setup failed" }), + ); + const failed = renderScreen("/agentcore/memory/get/memory-1", { core: failedCore }); + + await waitForText(failed.lastFrame, "Strategy setup failed"); + expect(failed.lastFrame()).toContain("failureReason"); + }); + + test("opens the complete Memory JSON", async () => { + const core = new TestCoreClient(); + core.memory.setGetResponse(getMemoryOutput()); + const screen = renderScreen("/agentcore/memory/get/memory-1", { core }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("return"); + await waitForText(screen.lastFrame, "agentcore → memory → get → memory-1 → json"); + const frame = screen.lastFrame()!; + expect(frame).toContain('"memoryExecutionRoleArn"'); + expect(frame).toContain('"strategies"'); + }); + + test("retries a failed detail query", async () => { + const core = new TestCoreClient(); + core.memory.setError(new Error("memory unavailable")); + const screen = renderScreen("/agentcore/memory/get/memory-1", { core }); + + await waitForText(screen.lastFrame, "memory unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + + core.memory.setError(undefined); + core.memory.setGetResponse(getMemoryOutput()); + await screen.write("r"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + }); + + test("does not open cached detail after a background refresh fails", async () => { + const core = new TestCoreClient(); + core.memory.setGetResponse(getMemoryOutput()); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const screen = renderScreen("/agentcore/memory/get/memory-1", { core, queryClient }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + core.memory.setError(new Error("background refresh failed")); + await queryClient.invalidateQueries({ + queryKey: ["memory", "us-east-1", "memory-1", "full"], + }); + await waitForText(screen.lastFrame, "background refresh failed"); + + await screen.press("return"); + await tick(); + expect(screen.lastFrame()).toContain("agentcore → memory → get → memory-1"); + expect(screen.lastFrame()).not.toContain("→ json"); + }); +}); diff --git a/src/handlers/memory/memory.test.tsx b/src/handlers/memory/memory.test.tsx index edf086dc2..5253c8ad2 100644 --- a/src/handlers/memory/memory.test.tsx +++ b/src/handlers/memory/memory.test.tsx @@ -5,6 +5,7 @@ import { createSilentLogger, fixtureFactories, matchGolden, + TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../testing"; @@ -32,6 +33,21 @@ function createFixtureCore(): CoreClient { }); } +function testMemoryCommand() { + const core = new TestCoreClient(); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + return { + core, + route: (args: string[]) => root.route(["node", "agentcore", ...args, "--region", REGION]), + }; +} + async function run(args: string[]): Promise { const io = testIO(); const root = createRootHandler(createFixtureCore(), { @@ -64,14 +80,28 @@ describe("memory command hierarchy", () => { expect(view?.schema.parse(undefined)).toBeUndefined(); }); - test("prints help for bare `memory` without an SDK call", async () => { - const stdout = await run(["memory"]); + test("prints help for `memory --json` without an SDK call", async () => { + const stdout = await run(["memory", "--json"]); expect(stdout).toContain("Usage: agentcore memory"); expect(stdout).toContain("Commands:"); }); }); +describe("memory TUI dispatch", () => { + test.each([ + ["get", ["memory", "get"]], + ["list", ["memory", "list"]], + ] as const)("opens the TUI for a bare Memory %s leaf", async (_label, args) => { + const { core, route } = testMemoryCommand(); + + await expect(route([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + expect(core.memory.calls).toEqual([]); + }); +}); + describe("memory read-only commands", () => { test("gets a Memory using the full view by default", async () => { const stdout = await run(["memory", "get", "--id", FIXTURE_MEMORY_ID]); diff --git a/src/handlers/memory/screen.tsx b/src/handlers/memory/screen.tsx new file mode 100644 index 000000000..c811be7b9 --- /dev/null +++ b/src/handlers/memory/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../components/RouterScreen"; +import type { ScreenProps } from "../types"; + +export function MemoryScreen(props: ScreenProps) { + return ; +}