diff --git a/src/components/FormTextInput.tsx b/src/components/FormTextInput.tsx index 6ef232967..4c94c3153 100644 --- a/src/components/FormTextInput.tsx +++ b/src/components/FormTextInput.tsx @@ -11,6 +11,7 @@ export interface FormTextInputProps { errorText: string; value: string; onChange: (value: string) => void; + onSubmit?: (value: string) => void; pattern?: RegExp; // focused controls whether the input captures keystrokes; when several // FormTextInputs are on screen, exactly one should be focused. @@ -24,6 +25,7 @@ export function FormTextInput({ errorText, value, onChange, + onSubmit, pattern, focused = true, }: FormTextInputProps) { @@ -34,7 +36,13 @@ export function FormTextInput({ {helpText} - + {value !== "" && pattern && !pattern.test(value) && ( {errorText} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 39eb95534..0daf8f0ad 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -48,6 +48,9 @@ import { import { MemoryEventScreen } from "../handlers/memory/event/screen.tsx"; import { MemoryEventGetScreen } from "../handlers/memory/event/get/screen.tsx"; import { MemoryEventListScreen } from "../handlers/memory/event/list/screen.tsx"; +import { MemoryRecordScreen } from "../handlers/memory/record/screen.tsx"; +import { MemoryRecordGetScreen } from "../handlers/memory/record/get/screen.tsx"; +import { MemoryRecordListScreen } from "../handlers/memory/record/list/screen.tsx"; import { IdentityScreen } from "../handlers/identity/screen.tsx"; import { ApiKeyCredentialProviderScreen } from "../handlers/identity/api-key-credential-provider/screen.tsx"; import { ApiKeyCredentialProviderListScreen } from "../handlers/identity/api-key-credential-provider/list/screen.tsx"; @@ -387,6 +390,30 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/memory/event/list/:memoryId/:actorId/:sessionId" element={} /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> { { region: "us-east-1" }, ], }); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a session to list"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose an actor to list"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a Memory to list"); }); test("calls listEvents with the exact route scope and Core options", async () => { diff --git a/src/handlers/memory/event/list/screen.tsx b/src/handlers/memory/event/list/screen.tsx index 621d6a565..71fd1ab64 100644 --- a/src/handlers/memory/event/list/screen.tsx +++ b/src/handlers/memory/event/list/screen.tsx @@ -99,7 +99,7 @@ function ActorPicker({ ctx, core, memoryId }: ActorPickerProps) { `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}`, ) } - onBack={() => navigate("/agentcore/memory/event/list")} + onBack={() => navigate(-1)} loadingMessage={`Loading actors for Memory ${memoryId}...`} errorMessage={(error) => `Error loading actors for Memory ${memoryId}: ${error.message}`} emptyMessage={`No actors found for Memory ${memoryId}.`} @@ -140,7 +140,7 @@ function SessionPicker({ ctx, core, memoryId, actorId }: SessionPickerProps) { `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}/${encodeURIComponent(sessionId)}`, ) } - onBack={() => navigate(`/agentcore/memory/event/list/${encodeURIComponent(memoryId)}`)} + onBack={() => navigate(-1)} loadingMessage={`Loading sessions for actor ${actorId}...`} errorMessage={(error) => `Error loading sessions for actor ${actorId}: ${error.message}`} emptyMessage={`No sessions found for actor ${actorId}.`} @@ -187,11 +187,7 @@ function EventPicker({ ctx, core, memoryId, actorId, sessionId }: EventPickerPro `/agentcore/memory/event/get/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}/${encodeURIComponent(sessionId)}/${encodeURIComponent(eventId)}`, ) } - onBack={() => - navigate( - `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}`, - ) - } + onBack={() => navigate(-1)} loadingMessage={`Loading events for session ${sessionId}...`} errorMessage={(error) => `Error loading events for session ${sessionId}: ${error.message}`} emptyMessage={`No events found for session ${sessionId}.`} diff --git a/src/handlers/memory/get/screen.tsx b/src/handlers/memory/get/screen.tsx index ec37ac090..765dc9946 100644 --- a/src/handlers/memory/get/screen.tsx +++ b/src/handlers/memory/get/screen.tsx @@ -5,6 +5,24 @@ import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; +const ACTIONS = [ + { + name: "detail", + description: "show the full JSON definition", + to: (id: string) => `/agentcore/memory/get/${encodeURIComponent(id)}/json`, + }, + { + name: "events", + description: "list this Memory's events", + to: (id: string) => `/agentcore/memory/event/list/${encodeURIComponent(id)}`, + }, + { + name: "records", + description: "list this Memory's records", + to: (id: string) => `/agentcore/memory/record/list/${encodeURIComponent(id)}`, + }, +] as const; + function useMemoryDetail({ ctx, core }: ScreenProps, memoryId: string | undefined) { const opts = coreOptsFromCtx(ctx); return useQuery({ @@ -37,14 +55,11 @@ export function MemoryGetScreen(props: ScreenProps) { }} actions={ memoryId && memory - ? [ - { - name: "detail", - description: "show the full JSON definition", - onSelect: () => - navigate(`/agentcore/memory/get/${encodeURIComponent(memoryId)}/json`), - }, - ] + ? ACTIONS.map((action) => ({ + name: action.name, + description: action.description, + onSelect: () => navigate(action.to(memoryId)), + })) : [] } loadingLabel="Loading Memory…" diff --git a/src/handlers/memory/index.tsx b/src/handlers/memory/index.tsx index 03a6afdee..3fde18341 100644 --- a/src/handlers/memory/index.tsx +++ b/src/handlers/memory/index.tsx @@ -15,5 +15,5 @@ export function createMemoryHandler(core: Core, io: AppIO): Router { .handler(createGetMemoryHandler(core)) .handler(createListMemoriesHandler(core)) .handler(createMemoryEventHandler(core, io)) - .handler(createMemoryRecordHandler(core)); + .handler(createMemoryRecordHandler(core, io)); } diff --git a/src/handlers/memory/memory.screen.test.tsx b/src/handlers/memory/memory.screen.test.tsx index 273d8bcd8..9b064fbdf 100644 --- a/src/handlers/memory/memory.screen.test.tsx +++ b/src/handlers/memory/memory.screen.test.tsx @@ -66,6 +66,17 @@ function coreWithMemories(memories: MemorySummary[]): TestCoreClient { } describe("Memory picker", () => { + test("shows event and record commands in the Memory TUI menu", async () => { + const screen = renderScreen("/agentcore/memory"); + + await waitForText(screen.lastFrame, "manage AgentCore Memories"); + const frame = screen.lastFrame()!; + expect(frame).toContain("get"); + expect(frame).toContain("list"); + expect(frame).toContain("event"); + expect(frame).toContain("record"); + }); + test("renders Memory identity, status, and update time", async () => { const core = coreWithMemories([ memorySummary({ @@ -224,6 +235,53 @@ describe("Memory detail", () => { expect(frame).toContain('"strategies"'); }); + test("unwinds the event flow from an empty actor picker through Memory detail to the list", async () => { + const core = new TestCoreClient(); + core.memory.setListResponse({ memories: [memorySummary()] }); + core.memory.setGetResponse(getMemoryOutput()); + core.memory.setListActorsResponse({ actorSummaries: [] }); + const screen = renderScreen("/agentcore/memory/list", { core }); + + await waitForText(screen.lastFrame, "memory-1"); + await screen.press("return"); + await waitForText(screen.lastFrame, "list this Memory's events"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "choose an actor to list sessions for"); + + await waitFor(() => core.memory.calls.some((call) => call.method === "listActors")); + expect(core.memory.calls.find((call) => call.method === "listActors")?.args[0]).toMatchObject({ + memoryId: "memory-1", + }); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "list this Memory's events"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "updated UTC"); + expect(screen.lastFrame()).not.toContain("list this Memory's events"); + }); + + test("unwinds the record flow through Memory detail to the list", async () => { + const core = new TestCoreClient(); + core.memory.setListResponse({ memories: [memorySummary()] }); + core.memory.setGetResponse(getMemoryOutput()); + const screen = renderScreen("/agentcore/memory/list", { core }); + + await waitForText(screen.lastFrame, "memory-1"); + await screen.press("return"); + await waitForText(screen.lastFrame, "list this Memory's records"); + await screen.press("down"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "choose the namespace scope for the record list"); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "list this Memory's records"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "updated UTC"); + expect(screen.lastFrame()).not.toContain("list this Memory's records"); + }); + test("retries a failed detail query", async () => { const core = new TestCoreClient(); core.memory.setError(new Error("memory unavailable")); diff --git a/src/handlers/memory/memory.test.tsx b/src/handlers/memory/memory.test.tsx index e75096282..905de985a 100644 --- a/src/handlers/memory/memory.test.tsx +++ b/src/handlers/memory/memory.test.tsx @@ -138,6 +138,8 @@ describe("memory TUI dispatch", () => { ["list", ["memory", "list"]], ["event get", ["memory", "event", "get"]], ["event list", ["memory", "event", "list"]], + ["record get", ["memory", "record", "get"]], + ["record list", ["memory", "record", "list"]], ] as const)("opens the TUI for a bare Memory %s leaf", async (_label, args) => { const { core, route } = testMemoryCommand(); diff --git a/src/handlers/memory/record/get/screen.tsx b/src/handlers/memory/record/get/screen.tsx new file mode 100644 index 000000000..1c99bc502 --- /dev/null +++ b/src/handlers/memory/record/get/screen.tsx @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function MemoryRecordGetScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const { memoryId, recordId } = useParams(); + const detail = useQuery({ + queryKey: ["memory-record", opts.region, memoryId, recordId], + queryFn: () => + core.memory.getMemoryRecord( + { + memoryId: memoryId!, + memoryRecordId: recordId!, + }, + opts, + ), + enabled: memoryId !== undefined && recordId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/memory/record/index.tsx b/src/handlers/memory/record/index.tsx index d5264f7b1..b91ad7ba9 100644 --- a/src/handlers/memory/record/index.tsx +++ b/src/handlers/memory/record/index.tsx @@ -1,10 +1,13 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { AppIO } from "../../../io"; import type { Core } from "../../types"; import { createGetMemoryRecordHandler } from "./get"; import { createListMemoryRecordsHandler } from "./list"; -export function createMemoryRecordHandler(core: Core): Router { +export function createMemoryRecordHandler(core: Core, io: AppIO): Router { return new Router("record", "inspect AgentCore Memory records") + .default(renderTui(core, io)) .handler(createGetMemoryRecordHandler(core)) .handler(createListMemoryRecordsHandler(core)); } diff --git a/src/handlers/memory/record/list/screen.tsx b/src/handlers/memory/record/list/screen.tsx new file mode 100644 index 000000000..11def27c6 --- /dev/null +++ b/src/handlers/memory/record/list/screen.tsx @@ -0,0 +1,205 @@ +import type { MemoryContent, MemoryRecordSummary } from "@aws-sdk/client-bedrock-agentcore"; +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { FormRadioGroup, type FormRadioOption } from "../../../../components/FormRadioGroup"; +import { FormTextInput } from "../../../../components/FormTextInput"; +import { Layout } from "../../../../components/Layout"; +import { MemoryPicker } from "../../../../components/MemoryPicker"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { darkTheme } from "../../../../components/ui/_core"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +type RecordScopeKind = "namespace" | "namespace-path"; + +const scopeOptions = [ + { + label: "namespace", + description: "match records whose namespace starts with this prefix", + }, + { + label: "namespace path", + description: "match records under the same namespace hierarchy", + }, +] satisfies FormRadioOption[]; + +interface MemoryRecordRow extends Record { + recordId: string; + content: string; + strategyId: string; + createdAt: string; +} + +const recordColumns = [ + { key: "recordId", header: "id", flex: true }, + { key: "content", header: "content", width: 70, minWidth: 20 }, + { key: "strategyId", header: "strategy", width: 32, minWidth: 16 }, + { + key: "createdAt", + header: "created UTC", + width: 16, + minWidth: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function contentText(content: MemoryContent | undefined): string { + if (content?.text) return content.text.replace(/\s+/g, " "); + return "-"; +} + +function toRow(record: MemoryRecordSummary): MemoryRecordRow { + return { + recordId: record.memoryRecordId ?? "", + content: contentText(record.content), + strategyId: record.memoryStrategyId ?? "-", + createdAt: record.createdAt?.toISOString() ?? "-", + }; +} + +interface MemoryRecordScopeScreenProps { + memoryId: string; +} + +function MemoryRecordScopeScreen({ memoryId }: MemoryRecordScopeScreenProps) { + const navigate = useNavigate(); + const [selectedIndex, setSelectedIndex] = useState(0); + const [scope, setScope] = useState(""); + const [submitted, setSubmitted] = useState(false); + + const submit = (value: string) => { + if (value.trim() === "") { + setSubmitted(true); + return; + } + + const kind: RecordScopeKind = selectedIndex === 0 ? "namespace" : "namespace-path"; + navigate( + `/agentcore/memory/record/list/${encodeURIComponent(memoryId)}/${kind}/${encodeURIComponent(value)}`, + ); + }; + + useInput((_input, key) => { + if (key.escape) { + navigate(-1); + return; + } + if (key.upArrow) { + setSelectedIndex(0); + return; + } + if (key.downArrow) { + setSelectedIndex(1); + } + }); + + return ( + + + + { + setScope(value); + setSubmitted(false); + }} + onSubmit={submit} + /> + {submitted && scope.trim() === "" ? ( + A namespace value is required. + ) : null} + + + ); +} + +interface MemoryRecordPickerProps extends ScreenProps { + memoryId: string; + scopeKind: RecordScopeKind; + scope: string; +} + +function MemoryRecordPicker({ ctx, core, memoryId, scopeKind, scope }: MemoryRecordPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.memory.listMemoryRecords( + { + memoryId, + namespace: scopeKind === "namespace" ? scope : undefined, + namespacePath: scopeKind === "namespace-path" ? scope : undefined, + maxResults: pageSize, + nextToken: token, + }, + opts, + ); + return { + items: response.memoryRecordSummaries ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={recordColumns} + getValue={(row) => row.recordId} + onSelect={(recordId) => + navigate( + `/agentcore/memory/record/get/${encodeURIComponent(memoryId)}/${encodeURIComponent(recordId)}`, + ) + } + onBack={() => navigate(-1)} + loadingMessage={`Loading Memory records for ${memoryId}...`} + errorMessage={(error) => `Error loading Memory records for ${memoryId}: ${error.message}`} + emptyMessage={`No Memory records found for ${scopeKind} ${scope}.`} + emptyPageMessage={`No Memory records on this page for ${scopeKind} ${scope}.`} + /> + ); +} + +export function MemoryRecordListScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { memoryId, scopeKind, scope } = useParams(); + + if (!memoryId) { + return ( + navigate(`/agentcore/memory/record/list/${encodeURIComponent(id)}`)} + /> + ); + } + + if (scope === undefined || (scopeKind !== "namespace" && scopeKind !== "namespace-path")) { + return ; + } + + return ; +} diff --git a/src/handlers/memory/record/record.screen.test.tsx b/src/handlers/memory/record/record.screen.test.tsx new file mode 100644 index 000000000..f9b778479 --- /dev/null +++ b/src/handlers/memory/record/record.screen.test.tsx @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + GetMemoryRecordOutput, + MemoryRecord, + MemoryRecordSummary, +} from "@aws-sdk/client-bedrock-agentcore"; +import type { MemorySummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; +import stringWidth from "string-width"; + +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 record(overrides: Partial = {}): MemoryRecord { + return { + memoryRecordId: "record-1", + content: { text: "Customer prefers email notifications." }, + memoryStrategyId: "strategy-1", + namespaces: ["/customers/acme"], + createdAt: new Date("2026-08-03T12:34:56.000Z"), + ...overrides, + }; +} + +function recordSummary(overrides: Partial = {}): MemoryRecordSummary { + return record(overrides); +} + +describe("Memory record list flow", () => { + test("renders the record command menu", async () => { + const screen = renderScreen("/agentcore/memory/record"); + + await waitForText(screen.lastFrame, "inspect AgentCore Memory records"); + expect(screen.lastFrame()).toContain("list"); + }); + + test("uses the Memory picker before asking for a namespace scope", async () => { + const memoryId = "memory/blue one"; + const core = new TestCoreClient(); + core.memory.setListResponse({ + memories: [memorySummary({ id: memoryId })], + }); + const screen = renderScreen("/agentcore/memory/record/list", { core }); + + await waitForText(screen.lastFrame, memoryId); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → memory → record → list → ${memoryId}`); + + const frame = screen.lastFrame()!; + expect(frame).toContain("scope type"); + expect(frame).toContain("namespace path"); + expect(frame).toContain("namespace"); + expect(core.memory.calls.some((call) => call.method === "listMemoryRecords")).toBe(false); + }); + + test("unwinds the record table through its scope and Memory pickers", async () => { + const memoryId = "memory/blue one"; + const core = new TestCoreClient(); + core.memory.setListResponse({ + memories: [memorySummary({ id: memoryId })], + }); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [recordSummary()], + }); + const screen = renderScreen("/agentcore/memory/record/list", { core }); + + await waitForText(screen.lastFrame, memoryId); + await screen.press("return"); + await waitForText(screen.lastFrame, "scope type"); + await screen.write("/customers/acme"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Customer prefers email notifications."); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "scope type"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a Memory to list records for"); + }); + + test("uses namespace scope again after moving the selector up", async () => { + const core = new TestCoreClient(); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [recordSummary()], + }); + const screen = renderScreen("/agentcore/memory/record/list/memory-1", { core }); + + await waitForText(screen.lastFrame, "scope type"); + await screen.press("down"); + await screen.press("up"); + await screen.write("/customers/acme"); + await screen.press("return"); + await waitFor(() => core.memory.calls.some((call) => call.method === "listMemoryRecords")); + + expect( + core.memory.calls.find((call) => call.method === "listMemoryRecords")?.args[0], + ).toMatchObject({ + namespace: "/customers/acme", + namespacePath: undefined, + }); + }); + + test("submits a namespace prefix and calls listMemoryRecords with exact options", async () => { + const core = new TestCoreClient(); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [recordSummary()], + }); + const screen = renderScreen("/agentcore/memory/record/list/memory-1", { + core, + endpointUrl: memoryEndpointUrl, + }); + + await waitForText(screen.lastFrame, "scope type"); + await screen.write("/customers/acme"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Customer prefers email notifications."); + await waitFor(() => core.memory.calls.some((call) => call.method === "listMemoryRecords")); + + expect(core.memory.calls.filter((call) => call.method === "listMemoryRecords")).toEqual([ + { + method: "listMemoryRecords", + args: [ + { + memoryId: "memory-1", + namespace: "/customers/acme", + namespacePath: undefined, + maxResults: expect.any(Number), + nextToken: undefined, + }, + { + region: "us-east-1", + endpointUrl: memoryEndpointUrl, + }, + ], + }, + ]); + }); + + test("renders a placeholder when a record summary has no text content", async () => { + const core = new TestCoreClient(); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [ + recordSummary({ + memoryRecordId: "no-text", + content: { text: "" }, + }), + ], + }); + const screen = renderScreen( + "/agentcore/memory/record/list/memory-1/namespace/%2Fcustomers%2Facme", + { core }, + ); + + await waitForText(screen.lastFrame, "no-text"); + expect(screen.lastFrame()).toContain("-"); + }); + + test("maps namespace-path scope to namespacePath", async () => { + const core = new TestCoreClient(); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [recordSummary()], + }); + const screen = renderScreen("/agentcore/memory/record/list/memory-1", { core }); + + await waitForText(screen.lastFrame, "scope type"); + await screen.press("down"); + await screen.write("/customers/acme/*"); + await screen.press("return"); + await waitFor(() => core.memory.calls.some((call) => call.method === "listMemoryRecords")); + + expect(core.memory.calls.find((call) => call.method === "listMemoryRecords")).toEqual({ + method: "listMemoryRecords", + args: [ + { + memoryId: "memory-1", + namespace: undefined, + namespacePath: "/customers/acme/*", + maxResults: expect.any(Number), + nextToken: undefined, + }, + { region: "us-east-1" }, + ], + }); + }); + + test("renders record columns and opens the selected record JSON", async () => { + const response: GetMemoryRecordOutput = { + memoryRecord: record({ metadata: { tenant: { stringValue: "acme" } } }), + }; + const core = new TestCoreClient(); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [ + recordSummary({ + memoryRecordId: "record blue", + memoryStrategyId: "summary-strategy", + }), + ], + }); + core.memory.setGetMemoryRecordResponse(response); + const screen = renderScreen( + "/agentcore/memory/record/list/memory-1/namespace/%2Fcustomers%2Facme", + { core }, + ); + + await waitForText(screen.lastFrame, "record blue"); + const frame = screen.lastFrame()!; + expect(frame).toContain("content"); + expect(frame).toContain("strategy"); + expect(frame).toContain("created UTC"); + expect(frame).toContain("summary-strategy"); + expect(frame).toContain("2026-08-03 12:34"); + + await screen.press("return"); + await waitForText(screen.lastFrame, '"tenant"'); + expect(core.memory.calls.find((call) => call.method === "getMemoryRecord")).toEqual({ + method: "getMemoryRecord", + args: [ + { + memoryId: "memory-1", + memoryRecordId: "record blue", + }, + { region: "us-east-1" }, + ], + }); + }); + + test("shows full record identifiers, content, strategy, and timestamps in a wide terminal", async () => { + const memoryRecordId = "mem-6ff41abc-6571-4e56-9c47-90e7581785f1"; + const content = "User is interested in total-market ETFs with low expense ratios."; + const memoryStrategyId = "tui_testdata_semantic-FEeDurAJJ1"; + const core = new TestCoreClient(); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [ + recordSummary({ + memoryRecordId, + content: { text: content }, + memoryStrategyId, + createdAt: new Date("2026-08-04T20:44:00.000Z"), + }), + ], + }); + const screen = renderScreen( + "/agentcore/memory/record/list/memory-1/namespace/%2Fcustomers%2Facme", + { core }, + ); + + await waitForText(screen.lastFrame, "total-market ETFs"); + await screen.resize(191, 24); + + const row = screen + .lastFrame()! + .split("\n") + .find((line) => line.includes(memoryRecordId)); + expect(row).toBeDefined(); + expect(row!).toContain(content); + expect(row!).toContain(memoryStrategyId); + expect(row!).toContain("2026-08-04 20:44"); + expect(stringWidth(row!)).toBeLessThanOrEqual(191); + }); + + test("paginates records and distinguishes later-page empty state", async () => { + const core = new TestCoreClient(); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [recordSummary({ memoryRecordId: "page-one" })], + nextToken: "page-2", + }); + core.memory.setListMemoryRecordsResponse({ memoryRecordSummaries: [] }, "page-2"); + const screen = renderScreen("/agentcore/memory/record/list/memory-1/namespace/%2Fcustomers", { + core, + }); + + await waitForText(screen.lastFrame, "page 1 · more →"); + await screen.write("l"); + await waitForText(screen.lastFrame, "No Memory records on this page for namespace /customers."); + }); + + test("shows the scoped empty state and retries list failures", async () => { + const empty = renderScreen("/agentcore/memory/record/list/memory-1/namespace/%2Fcustomers"); + await waitForText(empty.lastFrame, "No Memory records found for namespace /customers."); + empty.unmount(); + + const core = new TestCoreClient(); + core.memory.setError(new Error("records unavailable")); + const failed = renderScreen("/agentcore/memory/record/list/memory-1/namespace/%2Fcustomers", { + core, + }); + + await waitForText(failed.lastFrame, "records unavailable"); + expect(failed.lastFrame()).toContain("[r] retry"); + + core.memory.setError(undefined); + core.memory.setListMemoryRecordsResponse({ + memoryRecordSummaries: [recordSummary()], + }); + await failed.write("r"); + await waitForText(failed.lastFrame, "Customer prefers email notifications."); + }); + + test("requires a non-empty namespace value", async () => { + const screen = renderScreen("/agentcore/memory/record/list/memory-1"); + + await waitForText(screen.lastFrame, "scope type"); + await screen.press("return"); + await waitForText(screen.lastFrame, "A namespace value is required."); + expect(screen.core.memory.calls).toEqual([]); + }); +}); diff --git a/src/handlers/memory/record/screen.tsx b/src/handlers/memory/record/screen.tsx new file mode 100644 index 000000000..4350a2598 --- /dev/null +++ b/src/handlers/memory/record/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function MemoryRecordScreen(props: ScreenProps) { + return ; +}