diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 0daf8f0ad..dd5163b06 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -51,6 +51,10 @@ 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 { MemoryActorScreen } from "../handlers/memory/actor/screen.tsx"; +import { MemoryActorListScreen } from "../handlers/memory/actor/list/screen.tsx"; +import { MemorySessionScreen } from "../handlers/memory/session/screen.tsx"; +import { MemorySessionListScreen } from "../handlers/memory/session/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"; @@ -414,6 +418,34 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/memory/record/list/:memoryId/:scopeKind/:scope" element={} /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> = {}): 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 actor(overrides: Partial = {}): ActorSummary { + return { + actorId: "actor-1", + ...overrides, + }; +} + +function session(overrides: Partial = {}): SessionSummary { + return { + sessionId: "session-1", + actorId: "actor-1", + createdAt: new Date("2026-08-02T10:20:30.000Z"), + ...overrides, + }; +} + +describe("Memory actor list flow", () => { + test("uses the Memory picker, lists actors, then opens the actor's sessions", async () => { + const memoryId = "memory/blue one"; + const actorId = "actor/blue one"; + const core = new TestCoreClient(); + core.memory.setListResponse({ memories: [memorySummary({ id: memoryId })] }); + core.memory.setListActorsResponse({ actorSummaries: [actor({ actorId })] }); + core.memory.setListSessionsResponse({ + sessionSummaries: [session({ actorId })], + }); + const screen = renderScreen("/agentcore/memory/actor/list", { core }); + + await waitForText(screen.lastFrame, memoryId); + await screen.press("return"); + await waitForText(screen.lastFrame, actorId); + await screen.press("return"); + await waitForText(screen.lastFrame, "session-1"); + + expect(core.memory.calls.find((call) => call.method === "listActors")).toEqual({ + method: "listActors", + args: [ + { + memoryId, + maxResults: expect.any(Number), + nextToken: undefined, + }, + { region: "us-east-1" }, + ], + }); + expect(core.memory.calls.find((call) => call.method === "listSessions")).toEqual({ + method: "listSessions", + args: [ + { + memoryId, + actorId, + maxResults: expect.any(Number), + nextToken: undefined, + }, + { region: "us-east-1" }, + ], + }); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose an actor to list sessions for"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a Memory to list actors for"); + }); + + test("shows empty and retry states for actor lists", async () => { + const empty = renderScreen("/agentcore/memory/actor/list/memory-1"); + await waitForText(empty.lastFrame, "No actors found for Memory memory-1."); + empty.unmount(); + + const core = new TestCoreClient(); + core.memory.setError(new Error("actors unavailable")); + const failed = renderScreen("/agentcore/memory/actor/list/memory-1", { core }); + + await waitForText(failed.lastFrame, "actors unavailable"); + core.memory.setError(undefined); + core.memory.setListActorsResponse({ actorSummaries: [actor()] }); + await failed.write("r"); + await waitFor(() => failed.lastFrame()?.includes("actor-1") ?? false); + }); +}); diff --git a/src/handlers/memory/actor/index.tsx b/src/handlers/memory/actor/index.tsx new file mode 100644 index 000000000..293d9ff8d --- /dev/null +++ b/src/handlers/memory/actor/index.tsx @@ -0,0 +1,11 @@ +import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; +import { createListMemoryActorsHandler } from "./list"; + +export function createMemoryActorHandler(core: Core, io: AppIO): Router { + return new Router("actor", "inspect actors in AgentCore Memories") + .default(renderTui(core, io)) + .handler(createListMemoryActorsHandler(core)); +} diff --git a/src/handlers/memory/actor/list/index.tsx b/src/handlers/memory/actor/list/index.tsx new file mode 100644 index 000000000..b8e978cc5 --- /dev/null +++ b/src/handlers/memory/actor/list/index.tsx @@ -0,0 +1,33 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createListMemoryActorsHandler = (core: Core) => + createHandler({ + name: "list", + description: "list actors in an AgentCore Memory", + flags: [ + flag("memory", "the ID of the Memory", z.string().optional()), + flag("max-results", "maximum number of actors to return", z.number().optional()), + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.memory) { + throw new InputValidationError("required option '--memory ' not specified"); + } + + const response = await core.memory.listActors( + { + memoryId: flags.memory, + maxResults: flags["max-results"], + nextToken: flags["next-token"], + }, + coreOptsFromCtx(ctx), + ); + + ctx.require(JsonRendererKey).renderJson(response); + }, + }); diff --git a/src/handlers/memory/actor/list/screen.tsx b/src/handlers/memory/actor/list/screen.tsx new file mode 100644 index 000000000..e3f49eb2c --- /dev/null +++ b/src/handlers/memory/actor/list/screen.tsx @@ -0,0 +1,35 @@ +import { useNavigate, useParams } from "react-router"; +import { MemoryPicker } from "../../../../components/MemoryPicker"; +import type { ScreenProps } from "../../../types"; +import { MemoryActorPicker } from "../../listPickers"; + +export function MemoryActorListScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { memoryId } = useParams(); + + if (!memoryId) { + return ( + navigate(`/agentcore/memory/actor/list/${encodeURIComponent(id)}`)} + /> + ); + } + + return ( + + navigate( + `/agentcore/memory/session/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}`, + ) + } + onBack={() => navigate(-1)} + /> + ); +} diff --git a/src/handlers/memory/actor/screen.tsx b/src/handlers/memory/actor/screen.tsx new file mode 100644 index 000000000..a832104b3 --- /dev/null +++ b/src/handlers/memory/actor/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function MemoryActorScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/memory/event/list/screen.tsx b/src/handlers/memory/event/list/screen.tsx index 71fd1ab64..f203ee0b9 100644 --- a/src/handlers/memory/event/list/screen.tsx +++ b/src/handlers/memory/event/list/screen.tsx @@ -1,4 +1,4 @@ -import type { ActorSummary, Event, SessionSummary } from "@aws-sdk/client-bedrock-agentcore"; +import type { Event } from "@aws-sdk/client-bedrock-agentcore"; import { useNavigate, useParams } from "react-router"; import { MemoryPicker } from "../../../../components/MemoryPicker"; import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; @@ -6,41 +6,7 @@ import { formatTimestamp } from "../../../../components/formatTimestamp"; import type { DataTableColumn } from "../../../../components/ui/data-table"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; - -interface ActorRow extends Record { - actorId: string; -} - -const actorColumns = [ - { key: "actorId", header: "actor id", flex: true }, -] satisfies DataTableColumn[]; - -function toActorRow(actor: ActorSummary): ActorRow { - return { actorId: actor.actorId ?? "" }; -} - -interface SessionRow extends Record { - sessionId: string; - createdAt: string; -} - -const sessionColumns = [ - { key: "sessionId", header: "session id", flex: true }, - { - key: "createdAt", - header: "created UTC", - width: 16, - minWidth: 11, - render: formatTimestamp, - }, -] satisfies DataTableColumn[]; - -function toSessionRow(session: SessionSummary): SessionRow { - return { - sessionId: session.sessionId ?? "", - createdAt: session.createdAt?.toISOString() ?? "-", - }; -} +import { MemoryActorPicker, MemorySessionPicker } from "../../listPickers"; interface EventRow extends Record { eventId: string; @@ -68,87 +34,6 @@ function toEventRow(event: Event): EventRow { }; } -interface ActorPickerProps extends ScreenProps { - memoryId: string; -} - -function ActorPicker({ ctx, core, memoryId }: ActorPickerProps) { - const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); - - return ( - { - const response = await core.memory.listActors( - { memoryId, maxResults: pageSize, nextToken: token }, - opts, - ); - return { - items: response.actorSummaries ?? [], - nextToken: response.nextToken, - }; - }} - toRow={toActorRow} - columns={actorColumns} - getValue={(row) => row.actorId} - onSelect={(actorId) => - navigate( - `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}`, - ) - } - 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}.`} - emptyPageMessage={`No actors on this page for Memory ${memoryId}.`} - /> - ); -} - -interface SessionPickerProps extends ScreenProps { - memoryId: string; - actorId: string; -} - -function SessionPicker({ ctx, core, memoryId, actorId }: SessionPickerProps) { - const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); - - return ( - { - const response = await core.memory.listSessions( - { memoryId, actorId, maxResults: pageSize, nextToken: token }, - opts, - ); - return { - items: response.sessionSummaries ?? [], - nextToken: response.nextToken, - }; - }} - toRow={toSessionRow} - columns={sessionColumns} - getValue={(row) => row.sessionId} - onSelect={(sessionId) => - navigate( - `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}/${encodeURIComponent(sessionId)}`, - ) - } - 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}.`} - emptyPageMessage={`No sessions on this page for actor ${actorId}.`} - /> - ); -} - interface EventPickerProps extends ScreenProps { memoryId: string; actorId: string; @@ -212,11 +97,38 @@ export function MemoryEventListScreen(props: ScreenProps) { } if (!actorId) { - return ; + return ( + + navigate( + `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(id)}`, + ) + } + onBack={() => navigate(-1)} + /> + ); } if (!sessionId) { - return ; + return ( + + navigate( + `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}/${encodeURIComponent(id)}`, + ) + } + onBack={() => navigate(-1)} + /> + ); } return ; diff --git a/src/handlers/memory/get/screen.tsx b/src/handlers/memory/get/screen.tsx index 765dc9946..1904fde3e 100644 --- a/src/handlers/memory/get/screen.tsx +++ b/src/handlers/memory/get/screen.tsx @@ -21,6 +21,16 @@ const ACTIONS = [ description: "list this Memory's records", to: (id: string) => `/agentcore/memory/record/list/${encodeURIComponent(id)}`, }, + { + name: "actors", + description: "list this Memory's actors", + to: (id: string) => `/agentcore/memory/actor/list/${encodeURIComponent(id)}`, + }, + { + name: "sessions", + description: "choose an actor to list this Memory's sessions", + to: (id: string) => `/agentcore/memory/session/list/${encodeURIComponent(id)}`, + }, ] as const; function useMemoryDetail({ ctx, core }: ScreenProps, memoryId: string | undefined) { diff --git a/src/handlers/memory/index.tsx b/src/handlers/memory/index.tsx index 3fde18341..533cea491 100644 --- a/src/handlers/memory/index.tsx +++ b/src/handlers/memory/index.tsx @@ -3,10 +3,12 @@ import { Router } from "../../router"; import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import type { Core } from "../types"; +import { createMemoryActorHandler } from "./actor"; import { createMemoryEventHandler } from "./event"; import { createGetMemoryHandler } from "./get"; import { createListMemoriesHandler } from "./list"; import { createMemoryRecordHandler } from "./record"; +import { createMemorySessionHandler } from "./session"; export function createMemoryHandler(core: Core, io: AppIO): Router { return new Router("memory", "manage AgentCore Memories") @@ -15,5 +17,7 @@ export function createMemoryHandler(core: Core, io: AppIO): Router { .handler(createGetMemoryHandler(core)) .handler(createListMemoriesHandler(core)) .handler(createMemoryEventHandler(core, io)) - .handler(createMemoryRecordHandler(core, io)); + .handler(createMemoryRecordHandler(core, io)) + .handler(createMemoryActorHandler(core, io)) + .handler(createMemorySessionHandler(core, io)); } diff --git a/src/handlers/memory/listPickers.tsx b/src/handlers/memory/listPickers.tsx new file mode 100644 index 000000000..02cedb49f --- /dev/null +++ b/src/handlers/memory/listPickers.tsx @@ -0,0 +1,137 @@ +import type { ActorSummary, SessionSummary } from "@aws-sdk/client-bedrock-agentcore"; +import { PaginatedTablePicker } from "../../components/PaginatedTablePicker"; +import { formatTimestamp } from "../../components/formatTimestamp"; +import type { DataTableColumn } from "../../components/ui/data-table"; +import type { ScreenProps } from "../types"; +import { coreOptsFromCtx } from "../utils"; + +interface MemoryActorRow extends Record { + actorId: string; +} + +const actorColumns = [ + { key: "actorId", header: "actor id", flex: true }, +] satisfies DataTableColumn[]; + +function toActorRow(actor: ActorSummary): MemoryActorRow { + return { actorId: actor.actorId ?? "" }; +} + +export interface MemoryActorPickerProps extends ScreenProps { + memoryId: string; + breadcrumb: string[]; + description: string; + onSelect: (actorId: string) => void; + onBack: () => void; +} + +export function MemoryActorPicker({ + ctx, + core, + memoryId, + breadcrumb, + description, + onSelect, + onBack, +}: MemoryActorPickerProps) { + const opts = coreOptsFromCtx(ctx); + + return ( + { + const response = await core.memory.listActors( + { memoryId, maxResults: pageSize, nextToken: token }, + opts, + ); + return { + items: response.actorSummaries ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toActorRow} + columns={actorColumns} + getValue={(row) => row.actorId} + onSelect={onSelect} + onBack={onBack} + loadingMessage={`Loading actors for Memory ${memoryId}...`} + errorMessage={(error) => `Error loading actors for Memory ${memoryId}: ${error.message}`} + emptyMessage={`No actors found for Memory ${memoryId}.`} + emptyPageMessage={`No actors on this page for Memory ${memoryId}.`} + /> + ); +} + +interface MemorySessionRow extends Record { + sessionId: string; + createdAt: string; +} + +const sessionColumns = [ + { key: "sessionId", header: "session id", flex: true }, + { + key: "createdAt", + header: "created UTC", + width: 16, + minWidth: 11, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toSessionRow(session: SessionSummary): MemorySessionRow { + return { + sessionId: session.sessionId ?? "", + createdAt: session.createdAt?.toISOString() ?? "-", + }; +} + +export interface MemorySessionPickerProps extends ScreenProps { + memoryId: string; + actorId: string; + breadcrumb: string[]; + description: string; + onSelect: (sessionId: string) => void; + onBack: () => void; +} + +export function MemorySessionPicker({ + ctx, + core, + memoryId, + actorId, + breadcrumb, + description, + onSelect, + onBack, +}: MemorySessionPickerProps) { + const opts = coreOptsFromCtx(ctx); + + return ( + { + const response = await core.memory.listSessions( + { memoryId, actorId, maxResults: pageSize, nextToken: token }, + opts, + ); + return { + items: response.sessionSummaries ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toSessionRow} + columns={sessionColumns} + getValue={(row) => row.sessionId} + onSelect={onSelect} + onBack={onBack} + loadingMessage={`Loading sessions for actor ${actorId}...`} + errorMessage={(error) => `Error loading sessions for actor ${actorId}: ${error.message}`} + emptyMessage={`No sessions found for actor ${actorId}.`} + emptyPageMessage={`No sessions on this page for actor ${actorId}.`} + /> + ); +} diff --git a/src/handlers/memory/memory.screen.test.tsx b/src/handlers/memory/memory.screen.test.tsx index 9b064fbdf..9922768f0 100644 --- a/src/handlers/memory/memory.screen.test.tsx +++ b/src/handlers/memory/memory.screen.test.tsx @@ -66,7 +66,7 @@ function coreWithMemories(memories: MemorySummary[]): TestCoreClient { } describe("Memory picker", () => { - test("shows event and record commands in the Memory TUI menu", async () => { + test("shows event, record, actor, and session commands in the Memory TUI menu", async () => { const screen = renderScreen("/agentcore/memory"); await waitForText(screen.lastFrame, "manage AgentCore Memories"); @@ -75,6 +75,8 @@ describe("Memory picker", () => { expect(frame).toContain("list"); expect(frame).toContain("event"); expect(frame).toContain("record"); + expect(frame).toContain("actor"); + expect(frame).toContain("session"); }); test("renders Memory identity, status, and update time", async () => { @@ -190,6 +192,8 @@ describe("Memory detail", () => { expect(frame).toMatch(/eventExpiryDays\s+30/); expect(frame).toMatch(/strategies\s+1/); expect(frame).toContain("arn:aws:bedrock-agentcore"); + expect(frame).toContain("list this Memory's actors"); + expect(frame).toContain("choose an actor to list this Memory's sessions"); expect(core.memory.calls.find((call) => call.method === "getMemory")).toEqual({ method: "getMemory", args: [ diff --git a/src/handlers/memory/memory.test.tsx b/src/handlers/memory/memory.test.tsx index 905de985a..cca52184e 100644 --- a/src/handlers/memory/memory.test.tsx +++ b/src/handlers/memory/memory.test.tsx @@ -1,15 +1,19 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import type { + ActorSummary, Event, EventMetadataFilterExpression, GetEventOutput, GetMemoryRecordOutput, + ListActorsOutput, ListEventsOutput, ListMemoryRecordsOutput, + ListSessionsOutput, MemoryMetadataFilterExpression, MemoryRecord, MemoryRecordSummary, + SessionSummary, } from "@aws-sdk/client-bedrock-agentcore"; import { CoreClient } from "../../core"; import { @@ -39,6 +43,16 @@ const SESSION_ID = "session-1"; const EVENT_ID = "event-1"; const RECORD_ID = "record-1"; +const actorSummary: ActorSummary = { + actorId: ACTOR_ID, +}; + +const sessionSummary: SessionSummary = { + actorId: ACTOR_ID, + sessionId: SESSION_ID, + createdAt: new Date("2026-08-03T12:00:00.000Z"), +}; + const event: Event = { memoryId: EVENT_MEMORY_ID, actorId: ACTOR_ID, @@ -105,6 +119,8 @@ describe("memory command hierarchy", () => { const memory = root.children().find((child) => child.name() === "memory"); const event = memory?.children().find((child) => child.name() === "event"); const record = memory?.children().find((child) => child.name() === "record"); + const actor = memory?.children().find((child) => child.name() === "actor"); + const session = memory?.children().find((child) => child.name() === "session"); expect(memory?.flags().map((flag) => flag.name)).not.toContain("interactive"); expect(memory?.children().map((child) => child.name())).toEqual([ @@ -112,9 +128,13 @@ describe("memory command hierarchy", () => { "list", "event", "record", + "actor", + "session", ]); expect(event?.children().map((child) => child.name())).toEqual(["get", "list"]); expect(record?.children().map((child) => child.name())).toEqual(["get", "list"]); + expect(actor?.children().map((child) => child.name())).toEqual(["list"]); + expect(session?.children().map((child) => child.name())).toEqual(["list"]); }); test("keeps an omitted get view undefined for empty-flag routing", () => { @@ -140,6 +160,8 @@ describe("memory TUI dispatch", () => { ["event list", ["memory", "event", "list"]], ["record get", ["memory", "record", "get"]], ["record list", ["memory", "record", "list"]], + ["actor list", ["memory", "actor", "list"]], + ["session list", ["memory", "session", "list"]], ] as const)("opens the TUI for a bare Memory %s leaf", async (_label, args) => { const { core, route } = testMemoryCommand(); @@ -420,6 +442,106 @@ describe("memory event commands", () => { }); }); +describe("memory actor commands", () => { + test("lists actors with pagination options", async () => { + const response: ListActorsOutput = { + actorSummaries: [actorSummary], + nextToken: "page-3", + }; + const core = new TestCoreClient(); + core.memory.setListActorsResponse(response, "page-2"); + const command = testMemoryCommand(core); + + await command.route([ + "memory", + "actor", + "list", + "--memory", + EVENT_MEMORY_ID, + "--max-results", + "1", + "--next-token", + "page-2", + ]); + + expect(core.memory.calls).toEqual([ + { + method: "listActors", + args: [ + { + memoryId: EVENT_MEMORY_ID, + maxResults: 1, + nextToken: "page-2", + }, + { region: REGION }, + ], + }, + ]); + expect(JSON.parse(command.stdout())).toEqual(JSON.parse(JSON.stringify(response))); + }); + + test("rejects a missing Memory selector for actor list", async () => { + const command = testMemoryCommand(); + + await expect(command.route(["memory", "actor", "list", "--json"])).rejects.toThrow( + "--memory ", + ); + expect(command.core.memory.calls).toEqual([]); + }); +}); + +describe("memory session commands", () => { + test("lists an actor's sessions with pagination options", async () => { + const response: ListSessionsOutput = { + sessionSummaries: [sessionSummary], + nextToken: "page-3", + }; + const core = new TestCoreClient(); + core.memory.setListSessionsResponse(response, "page-2"); + const command = testMemoryCommand(core); + + await command.route([ + "memory", + "session", + "list", + "--memory", + EVENT_MEMORY_ID, + "--actor-id", + ACTOR_ID, + "--max-results", + "1", + "--next-token", + "page-2", + ]); + + expect(core.memory.calls).toEqual([ + { + method: "listSessions", + args: [ + { + memoryId: EVENT_MEMORY_ID, + actorId: ACTOR_ID, + maxResults: 1, + nextToken: "page-2", + }, + { region: REGION }, + ], + }, + ]); + expect(JSON.parse(command.stdout())).toEqual(JSON.parse(JSON.stringify(response))); + }); + + test.each([ + ["memory", ["--json"], "--memory "], + ["actor", ["--memory", EVENT_MEMORY_ID, "--json"], "--actor-id "], + ] as const)("rejects a missing %s selector for session list", async (_name, flags, expected) => { + const command = testMemoryCommand(); + + await expect(command.route(["memory", "session", "list", ...flags])).rejects.toThrow(expected); + expect(command.core.memory.calls).toEqual([]); + }); +}); + describe("memory record commands", () => { test("gets a record and renders the response unchanged", async () => { const response: GetMemoryRecordOutput = { memoryRecord }; diff --git a/src/handlers/memory/session/index.tsx b/src/handlers/memory/session/index.tsx new file mode 100644 index 000000000..fd6624ba6 --- /dev/null +++ b/src/handlers/memory/session/index.tsx @@ -0,0 +1,11 @@ +import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; +import { createListMemorySessionsHandler } from "./list"; + +export function createMemorySessionHandler(core: Core, io: AppIO): Router { + return new Router("session", "inspect sessions in AgentCore Memories") + .default(renderTui(core, io)) + .handler(createListMemorySessionsHandler(core)); +} diff --git a/src/handlers/memory/session/list/index.tsx b/src/handlers/memory/session/list/index.tsx new file mode 100644 index 000000000..511b7bf99 --- /dev/null +++ b/src/handlers/memory/session/list/index.tsx @@ -0,0 +1,38 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createListMemorySessionsHandler = (core: Core) => + createHandler({ + name: "list", + description: "list sessions in an AgentCore Memory", + flags: [ + flag("memory", "the ID of the Memory", z.string().optional()), + flag("actor-id", "the ID of the actor", z.string().optional()), + flag("max-results", "maximum number of sessions to return", z.number().optional()), + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.memory) { + throw new InputValidationError("required option '--memory ' not specified"); + } + if (!flags["actor-id"]) { + throw new InputValidationError("required option '--actor-id ' not specified"); + } + + const response = await core.memory.listSessions( + { + memoryId: flags.memory, + actorId: flags["actor-id"], + maxResults: flags["max-results"], + nextToken: flags["next-token"], + }, + coreOptsFromCtx(ctx), + ); + + ctx.require(JsonRendererKey).renderJson(response); + }, + }); diff --git a/src/handlers/memory/session/list/screen.tsx b/src/handlers/memory/session/list/screen.tsx new file mode 100644 index 000000000..e6658ce3d --- /dev/null +++ b/src/handlers/memory/session/list/screen.tsx @@ -0,0 +1,53 @@ +import { useNavigate, useParams } from "react-router"; +import { MemoryPicker } from "../../../../components/MemoryPicker"; +import type { ScreenProps } from "../../../types"; +import { MemoryActorPicker, MemorySessionPicker } from "../../listPickers"; + +export function MemorySessionListScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { memoryId, actorId } = useParams(); + + if (!memoryId) { + return ( + navigate(`/agentcore/memory/session/list/${encodeURIComponent(id)}`)} + /> + ); + } + + if (!actorId) { + return ( + + navigate( + `/agentcore/memory/session/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(id)}`, + ) + } + onBack={() => navigate(-1)} + /> + ); + } + + return ( + + navigate( + `/agentcore/memory/event/list/${encodeURIComponent(memoryId)}/${encodeURIComponent(actorId)}/${encodeURIComponent(id)}`, + ) + } + onBack={() => navigate(-1)} + /> + ); +} diff --git a/src/handlers/memory/session/screen.tsx b/src/handlers/memory/session/screen.tsx new file mode 100644 index 000000000..45d94202f --- /dev/null +++ b/src/handlers/memory/session/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function MemorySessionScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/memory/session/session.screen.test.tsx b/src/handlers/memory/session/session.screen.test.tsx new file mode 100644 index 000000000..91782f1b0 --- /dev/null +++ b/src/handlers/memory/session/session.screen.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Event, SessionSummary } from "@aws-sdk/client-bedrock-agentcore"; +import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing"; + +afterEach(cleanupScreens); + +function session(overrides: Partial = {}): SessionSummary { + return { + sessionId: "session-1", + actorId: "actor-1", + createdAt: new Date("2026-08-02T10:20:30.000Z"), + ...overrides, + }; +} + +function event(overrides: Partial = {}): Event { + return { + memoryId: "memory-1", + actorId: "actor-1", + sessionId: "session-1", + eventId: "event-1", + eventTimestamp: new Date("2026-08-03T12:34:56.000Z"), + payload: [], + ...overrides, + }; +} + +describe("Memory session list flow", () => { + test("renders session details and opens the selected session's events", async () => { + const memoryId = "memory-1"; + const actorId = "actor-1"; + const sessionId = "session blue"; + const core = new TestCoreClient(); + core.memory.setListSessionsResponse({ + sessionSummaries: [session({ actorId, sessionId })], + }); + core.memory.setListEventsResponse({ + events: [event({ memoryId, actorId, sessionId })], + }); + const screen = renderScreen(`/agentcore/memory/session/list/${memoryId}/${actorId}`, { core }); + + await waitForText(screen.lastFrame, sessionId); + const frame = screen.lastFrame()!; + expect(frame).toContain("session id"); + expect(frame).toContain("created UTC"); + expect(frame).toContain("2026-08-02 10:20"); + + await screen.press("return"); + await waitForText(screen.lastFrame, "event-1"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a session to list events for"); + }); + + test("shows empty and retry states for session lists", async () => { + const empty = renderScreen("/agentcore/memory/session/list/memory-1/actor-1"); + await waitForText(empty.lastFrame, "No sessions found for actor actor-1."); + empty.unmount(); + + const core = new TestCoreClient(); + core.memory.setError(new Error("sessions unavailable")); + const failed = renderScreen("/agentcore/memory/session/list/memory-1/actor-1", { core }); + + await waitForText(failed.lastFrame, "sessions unavailable"); + core.memory.setError(undefined); + core.memory.setListSessionsResponse({ sessionSummaries: [session()] }); + await failed.write("r"); + await waitForText(failed.lastFrame, "session-1"); + }); +});