Skip to content

Commit 05becd7

Browse files
authored
feat: add TUI wizard streaming steps for memory record streaming (#534)
* feat: add TUI wizard streaming steps for memory record streaming * feat: centralize ARN validation with isValidArn utility
1 parent a8a1f79 commit 05becd7

File tree

9 files changed

+240
-32
lines changed

9 files changed

+240
-32
lines changed

docs/memory.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,13 @@ pushed to a delivery target in your account, enabling event-driven architectures
229229

230230
### Enabling Streaming
231231

232+
Via the interactive wizard:
233+
234+
```bash
235+
agentcore add memory
236+
# Select "Yes" when prompted for streaming, then provide the data stream ARN and content level
237+
```
238+
232239
Via CLI flags:
233240

234241
```bash

src/cli/commands/add/validate.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getSupportedModelProviders,
1616
matchEnumValue,
1717
} from '../../../schema';
18+
import { ARN_VALIDATION_MESSAGE, isValidArn } from '../shared/arn-utils';
1819
import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils';
1920
import { validateVpcOptions } from '../shared/vpc-utils';
2021
import { validateJwtAuthorizerOptions } from './auth-options';
@@ -697,8 +698,8 @@ export function validateAddMemoryOptions(options: AddMemoryOptions): ValidationR
697698
return { valid: false, error: '--data-stream-arn is required when --delivery-type is set' };
698699
}
699700

700-
if (options.dataStreamArn && !options.dataStreamArn.startsWith('arn:')) {
701-
return { valid: false, error: '--data-stream-arn must be a valid ARN (starts with arn:)' };
701+
if (options.dataStreamArn && !isValidArn(options.dataStreamArn)) {
702+
return { valid: false, error: `--data-stream-arn: ${ARN_VALIDATION_MESSAGE}` };
702703
}
703704

704705
if (
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { isValidArn } from '../arn-utils';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('isValidArn', () => {
5+
it('accepts a valid Kinesis stream ARN', () => {
6+
expect(isValidArn('arn:aws:kinesis:us-west-2:123456789012:stream/my-stream')).toBe(true);
7+
});
8+
9+
it('accepts a valid Lambda ARN', () => {
10+
expect(isValidArn('arn:aws:lambda:us-east-1:123456789012:function:my-func')).toBe(true);
11+
});
12+
13+
it('rejects a string that does not start with arn:', () => {
14+
expect(isValidArn('not-an-arn')).toBe(false);
15+
});
16+
17+
it('rejects an ARN with too few parts', () => {
18+
expect(isValidArn('arn:aws:kinesis:us-west-2:123456789012')).toBe(false);
19+
});
20+
21+
it('accepts an ARN with colons in the resource part', () => {
22+
expect(isValidArn('arn:aws:kinesis:us-west-2:123456789012:stream:extra:parts')).toBe(true);
23+
});
24+
25+
it('rejects an empty string', () => {
26+
expect(isValidArn('')).toBe(false);
27+
});
28+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const ARN_PART_COUNT = 6;
2+
const ARN_FORMAT = 'arn:partition:service:region:account:resource';
3+
4+
/**
5+
* Check whether a string looks like a valid ARN (starts with `arn:` and has at least 6 colon-separated parts).
6+
*/
7+
export function isValidArn(value: string): boolean {
8+
return value.startsWith('arn:') && value.split(':').length >= ARN_PART_COUNT;
9+
}
10+
11+
export const ARN_VALIDATION_MESSAGE = `Must be a valid ARN (${ARN_FORMAT})`;

src/cli/tui/hooks/useCreateMemory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface CreateMemoryConfig {
88
name: string;
99
eventExpiryDuration: number;
1010
strategies: { type: string }[];
11+
streaming?: { dataStreamArn: string; contentLevel: string };
1112
}
1213

1314
interface CreateStatus<T> {
@@ -27,6 +28,8 @@ export function useCreateMemory() {
2728
name: config.name,
2829
expiry: config.eventExpiryDuration,
2930
strategies: strategiesStr || undefined,
31+
dataStreamArn: config.streaming?.dataStreamArn,
32+
contentLevel: config.streaming?.contentLevel,
3033
});
3134
if (!addResult.success) {
3235
throw new Error(addResult.error ?? 'Failed to create memory');

src/cli/tui/screens/memory/AddMemoryScreen.tsx

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MemoryStrategyType } from '../../../../schema';
2-
import { AgentNameSchema } from '../../../../schema';
2+
import { AgentNameSchema, StreamContentLevelSchema } from '../../../../schema';
3+
import { ARN_VALIDATION_MESSAGE, isValidArn } from '../../../commands/shared/arn-utils';
34
import {
45
ConfirmReview,
56
Panel,
@@ -14,7 +15,7 @@ import { HELP_TEXT } from '../../constants';
1415
import { useListNavigation, useMultiSelectNavigation } from '../../hooks';
1516
import { generateUniqueName } from '../../utils';
1617
import type { AddMemoryConfig } from './types';
17-
import { EVENT_EXPIRY_OPTIONS, MEMORY_STEP_LABELS, MEMORY_STRATEGY_OPTIONS } from './types';
18+
import { CONTENT_LEVEL_OPTIONS, EVENT_EXPIRY_OPTIONS, MEMORY_STEP_LABELS, MEMORY_STRATEGY_OPTIONS } from './types';
1819
import { useAddMemoryWizard } from './useAddMemoryWizard';
1920
import React, { useMemo } from 'react';
2021

@@ -24,6 +25,11 @@ interface AddMemoryScreenProps {
2425
existingMemoryNames: string[];
2526
}
2627

28+
const STREAMING_OPTIONS: SelectableItem[] = [
29+
{ id: 'no', title: 'No', description: 'No streaming' },
30+
{ id: 'yes', title: 'Yes', description: 'Stream memory record events to a delivery target (e.g. Kinesis)' },
31+
];
32+
2733
export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: AddMemoryScreenProps) {
2834
const wizard = useAddMemoryWizard();
2935

@@ -37,9 +43,17 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add
3743
[]
3844
);
3945

46+
const contentLevelItems: SelectableItem[] = useMemo(
47+
() => CONTENT_LEVEL_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })),
48+
[]
49+
);
50+
4051
const isNameStep = wizard.step === 'name';
4152
const isExpiryStep = wizard.step === 'expiry';
4253
const isStrategiesStep = wizard.step === 'strategies';
54+
const isStreamingStep = wizard.step === 'streaming';
55+
const isStreamArnStep = wizard.step === 'streamArn';
56+
const isContentLevelStep = wizard.step === 'contentLevel';
4357
const isConfirmStep = wizard.step === 'confirm';
4458

4559
const expiryNav = useListNavigation({
@@ -58,6 +72,20 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add
5872
requireSelection: false,
5973
});
6074

75+
const streamingNav = useListNavigation({
76+
items: STREAMING_OPTIONS,
77+
onSelect: item => wizard.setStreamingEnabled(item.id === 'yes'),
78+
onExit: () => wizard.goBack(),
79+
isActive: isStreamingStep,
80+
});
81+
82+
const contentLevelNav = useListNavigation({
83+
items: contentLevelItems,
84+
onSelect: item => wizard.setContentLevel(StreamContentLevelSchema.parse(item.id)),
85+
onExit: () => wizard.goBack(),
86+
isActive: isContentLevelStep,
87+
});
88+
6189
useListNavigation({
6290
items: [{ id: 'confirm', title: 'Confirm' }],
6391
onSelect: () => onComplete(wizard.config),
@@ -67,16 +95,37 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add
6795

6896
const helpText = isStrategiesStep
6997
? 'Space toggle · Enter confirm · Esc back'
70-
: isExpiryStep
98+
: isExpiryStep || isStreamingStep || isContentLevelStep
7199
? HELP_TEXT.NAVIGATE_SELECT
72100
: isConfirmStep
73101
? HELP_TEXT.CONFIRM_CANCEL
74102
: HELP_TEXT.TEXT_INPUT;
75103

76104
const headerContent = <StepIndicator steps={wizard.steps} currentStep={wizard.step} labels={MEMORY_STEP_LABELS} />;
77105

106+
const confirmFields = useMemo(
107+
() => [
108+
{ label: 'Name', value: wizard.config.name },
109+
{ label: 'Event Expiry', value: `${wizard.config.eventExpiryDuration} days` },
110+
{ label: 'Strategies', value: wizard.config.strategies.map(s => s.type).join(', ') || 'None' },
111+
...(wizard.config.streaming
112+
? [
113+
{ label: 'Stream ARN', value: wizard.config.streaming.dataStreamArn },
114+
{ label: 'Content Level', value: wizard.config.streaming.contentLevel },
115+
]
116+
: [{ label: 'Streaming', value: 'Disabled' }]),
117+
],
118+
[wizard.config]
119+
);
120+
78121
return (
79-
<Screen title="Add Memory" onExit={onExit} helpText={helpText} headerContent={headerContent}>
122+
<Screen
123+
title="Add Memory"
124+
onExit={onExit}
125+
helpText={helpText}
126+
headerContent={headerContent}
127+
exitEnabled={isNameStep}
128+
>
80129
<Panel>
81130
{isNameStep && (
82131
<TextInput
@@ -109,15 +158,36 @@ export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: Add
109158
/>
110159
)}
111160

112-
{isConfirmStep && (
113-
<ConfirmReview
114-
fields={[
115-
{ label: 'Name', value: wizard.config.name },
116-
{ label: 'Event Expiry', value: `${wizard.config.eventExpiryDuration} days` },
117-
{ label: 'Strategies', value: wizard.config.strategies.map(s => s.type).join(', ') || 'None' },
118-
]}
161+
{isStreamingStep && (
162+
<WizardSelect
163+
title="Enable memory record streaming?"
164+
description="Stream memory record lifecycle events to a delivery target"
165+
items={STREAMING_OPTIONS}
166+
selectedIndex={streamingNav.selectedIndex}
167+
/>
168+
)}
169+
170+
{isStreamArnStep && (
171+
<TextInput
172+
key="streamArn"
173+
prompt="Delivery target ARN (e.g. Kinesis stream)"
174+
initialValue=""
175+
onSubmit={wizard.setStreamArn}
176+
onCancel={() => wizard.goBack()}
177+
customValidation={value => isValidArn(value) || ARN_VALIDATION_MESSAGE}
119178
/>
120179
)}
180+
181+
{isContentLevelStep && (
182+
<WizardSelect
183+
title="Stream content level"
184+
description="What data to include in stream events"
185+
items={contentLevelItems}
186+
selectedIndex={contentLevelNav.selectedIndex}
187+
/>
188+
)}
189+
190+
{isConfirmStep && <ConfirmReview fields={confirmFields} />}
121191
</Panel>
122192
</Screen>
123193
);

src/cli/tui/screens/memory/types.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
1-
import type { MemoryStrategyType } from '../../../../schema';
1+
import type { MemoryStrategyType, StreamContentLevel } from '../../../../schema';
22
import { MemoryStrategyTypeSchema } from '../../../../schema';
33

44
// ─────────────────────────────────────────────────────────────────────────────
55
// Memory Flow Types
66
// ─────────────────────────────────────────────────────────────────────────────
77

8-
export type AddMemoryStep = 'name' | 'expiry' | 'strategies' | 'confirm';
8+
export type AddMemoryStep = 'name' | 'expiry' | 'strategies' | 'streaming' | 'streamArn' | 'contentLevel' | 'confirm';
99

1010
export interface AddMemoryStrategyConfig {
1111
type: MemoryStrategyType;
1212
}
1313

14+
export interface AddMemoryStreamingConfig {
15+
dataStreamArn: string;
16+
contentLevel: StreamContentLevel;
17+
}
18+
1419
export interface AddMemoryConfig {
1520
name: string;
1621
eventExpiryDuration: number;
1722
strategies: AddMemoryStrategyConfig[];
23+
streaming?: AddMemoryStreamingConfig;
1824
}
1925

2026
export const MEMORY_STEP_LABELS: Record<AddMemoryStep, string> = {
2127
name: 'Name',
2228
expiry: 'Expiry',
2329
strategies: 'Strategies',
30+
streaming: 'Streaming',
31+
streamArn: 'Stream ARN',
32+
contentLevel: 'Content Level',
2433
confirm: 'Confirm',
2534
};
2635

@@ -49,6 +58,15 @@ export const EVENT_EXPIRY_OPTIONS = [
4958
{ id: 365, title: '365 days', description: 'Maximum retention' },
5059
] as const;
5160

61+
export const CONTENT_LEVEL_OPTIONS = [
62+
{ id: 'FULL_CONTENT' as const, title: 'Full content', description: 'Include memory record text in stream events' },
63+
{
64+
id: 'METADATA_ONLY' as const,
65+
title: 'Metadata only',
66+
description: 'Only include metadata (IDs, timestamps, namespaces)',
67+
},
68+
] as const satisfies readonly { id: StreamContentLevel; title: string; description: string }[];
69+
5270
// ─────────────────────────────────────────────────────────────────────────────
5371
// Defaults
5472
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)