Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ export enum AttachmentType {
screenContext = 'screen_context',
text = 'text',
esql = 'esql',
visualizationRef = 'visualization_ref',
}

interface AttachmentDataMap {
[AttachmentType.esql]: EsqlAttachmentData;
[AttachmentType.text]: TextAttachmentData;
[AttachmentType.screenContext]: ScreenContextAttachmentData;
[AttachmentType.visualizationRef]: VisualizationRefAttachmentData;
}

export const esqlAttachmentDataSchema = z.object({
Expand Down Expand Up @@ -77,4 +79,22 @@ export interface ScreenContextAttachmentData {
additional_data?: Record<string, string>;
}

export const visualizationRefAttachmentDataSchema = z.object({
saved_object_id: z.string(),
title: z.string().optional(),
description: z.string().optional(),
});

/**
* Data for a visualization_ref attachment.
*
* This attachment does not store the full saved object state, only a reference to a Lens saved
* object. The content can be resolved on-demand by the server when needed.
*/
export interface VisualizationRefAttachmentData {
saved_object_id: string;
title?: string;
description?: string;
}

export type AttachmentDataOf<Type extends AttachmentType> = AttachmentDataMap[Type];
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type UnknownAttachment = Attachment<string, unknown>;
export type TextAttachment = Attachment<AttachmentType.text>;
export type ScreenContextAttachment = Attachment<AttachmentType.screenContext>;
export type EsqlAttachment = Attachment<AttachmentType.esql>;
export type VisualizationRefAttachment = Attachment<AttachmentType.visualizationRef>;

/**
* Input version of an attachment, where the id is optional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ export type {
TextAttachment,
ScreenContextAttachment,
EsqlAttachment,
VisualizationRefAttachment,
} from './attachments';
export {
AttachmentType,
textAttachmentDataSchema,
esqlAttachmentDataSchema,
screenContextAttachmentDataSchema,
visualizationRefAttachmentDataSchema,
type TextAttachmentData,
type ScreenContextAttachmentData,
type EsqlAttachmentData,
type VisualizationRefAttachmentData,
} from './attachment_types';

export type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import type { AttachmentType, AttachmentDataOf } from './attachment_types';
/**
* Represents a single version of an attachment's content.
*/
export interface AttachmentVersion<DataType = unknown> {
export interface AttachmentVersion<DataType = unknown, RawDataType = unknown> {
/** Version number (starts at 1) */
version: number;
/** The attachment data for this version */
data: DataType;
/** The attachment raw data for by reference attachments */
raw_data?: RawDataType;
/** When this version was created */
created_at: string;
/** Hash of the content for deduplication */
Expand Down Expand Up @@ -148,6 +150,7 @@ export const attachmentVersionRefSchema = z.object({
export const attachmentVersionSchema = z.object({
version: z.number().int().positive(),
data: z.unknown(),
raw_data: z.unknown().optional(),
created_at: z.string(),
content_hash: z.string(),
estimated_tokens: z.number().int().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
} from '@kbn/agent-builder-common';
import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import type { BrowserApiToolMetadata } from '@kbn/agent-builder-common';
import type {
ModelProvider,
Expand Down Expand Up @@ -63,6 +64,10 @@ export interface AgentHandlerContext {
* Can be used to access ES on behalf of either the current user or the system user.
*/
esClient: IScopedClusterClient;
/**
* Saved objects client scoped to the current user.
*/
savedObjectsClient?: SavedObjectsClientContract;
/**
* Inference model provider scoped to the current user.
* Can be used to access the inference APIs or chatModel.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
ATTACHMENT_REF_ACTOR,
ATTACHMENT_REF_OPERATION,
hashContent,
getLatestVersion,
getVersion,
} from '@kbn/agent-builder-common/attachments';
import {
createAttachmentStateManager,
Expand All @@ -22,6 +24,9 @@ import type { AttachmentTypeDefinition } from './type_definition';

describe('AttachmentStateManager', () => {
let manager: AttachmentStateManager;
const mockContext = { request: {} as any, spaceId: 'default' };

let resolvedByRefPayload: Record<string, unknown> = { value: 'resolved-1' };

const getTypeDefinition = (type: string): AttachmentTypeDefinition | undefined => {
switch (type) {
Expand Down Expand Up @@ -75,13 +80,30 @@ describe('AttachmentStateManager', () => {
},
format: () => ({ getRepresentation: () => ({ type: 'text', value: '' }) }),
} as any;
case 'by_ref':
return {
id: 'by_ref',
validate: (input: unknown) => {
if (
typeof input === 'object' &&
input !== null &&
typeof (input as any).ref === 'string'
) {
return { valid: true, data: input as any };
}
return { valid: false, error: 'Expected { ref: string }' };
},
format: () => ({ getRepresentation: () => ({ type: 'text', value: '' }) }),
resolve: async () => resolvedByRefPayload,
} as any;
default:
return undefined;
}
};

beforeEach(() => {
manager = createAttachmentStateManager([], { getTypeDefinition });
resolvedByRefPayload = { value: 'resolved-1' };
});

// Helper to create a test attachment
Expand Down Expand Up @@ -192,13 +214,14 @@ describe('AttachmentStateManager', () => {
it('returns the attachment by ID', async () => {
const added = await manager.add({ id: 'test-1', type: 'text', data: { content: 'Test' } });

const retrieved = manager.get('test-1');
const retrieved = await manager.get('test-1', { context: mockContext });

expect(retrieved).toEqual(added);
expect(retrieved?.id).toEqual('test-1');
expect(retrieved?.data.data).toEqual(added.versions[0].data);
});

it('returns undefined for non-existent ID', () => {
const result = manager.get('non-existent');
it('returns undefined for non-existent ID', async () => {
const result = await manager.get('non-existent', { context: mockContext });

expect(result).toBeUndefined();
});
Expand All @@ -209,14 +232,16 @@ describe('AttachmentStateManager', () => {
await manager.add({ id: 'test-1', type: 'text', data: { content: 'v1' } });
await manager.update('test-1', { data: { content: 'v2' } });

const latest = manager.getLatest('test-1');
const record = manager.getAttachmentRecord('test-1')!;
const latest = getLatestVersion(record);

expect(latest?.version).toBe(2);
expect(latest?.data).toEqual({ content: 'v2' });
});

it('returns undefined for non-existent attachment', () => {
expect(manager.getLatest('non-existent')).toBeUndefined();
const record = manager.getAttachmentRecord('non-existent');
expect(record).toBeUndefined();
});
});

Expand All @@ -226,7 +251,8 @@ describe('AttachmentStateManager', () => {
await manager.update('test-1', { data: { content: 'v2' } });
await manager.update('test-1', { data: { content: 'v3' } });

const v2 = manager.getVersion('test-1', 2);
const record = manager.getAttachmentRecord('test-1')!;
const v2 = getVersion(record, 2);

expect(v2?.version).toBe(2);
expect(v2?.data).toEqual({ content: 'v2' });
Expand All @@ -235,7 +261,8 @@ describe('AttachmentStateManager', () => {
it('returns undefined for non-existent version', async () => {
await manager.add({ id: 'test-1', type: 'text', data: { content: 'v1' } });

expect(manager.getVersion('test-1', 99)).toBeUndefined();
const record = manager.getAttachmentRecord('test-1')!;
expect(getVersion(record, 99)).toBeUndefined();
});
});

Expand Down Expand Up @@ -344,7 +371,7 @@ describe('AttachmentStateManager', () => {
await manager.add({ id: 'test-1', type: 'text', data: { content: 'test' } });

const result = manager.delete('test-1');
const attachment = manager.get('test-1');
const attachment = manager.getAttachmentRecord('test-1');

expect(result).toBe(true);
expect(attachment?.active).toBe(false);
Expand Down Expand Up @@ -379,7 +406,7 @@ describe('AttachmentStateManager', () => {
manager.delete('test-1');

const result = manager.restore('test-1');
const attachment = manager.get('test-1');
const attachment = manager.getAttachmentRecord('test-1');

expect(result).toBe(true);
expect(attachment?.active).toBe(true);
Expand Down Expand Up @@ -415,7 +442,7 @@ describe('AttachmentStateManager', () => {
const result = manager.permanentDelete('test-1');

expect(result).toBe(true);
expect(manager.get('test-1')).toBeUndefined();
expect(manager.getAttachmentRecord('test-1')).toBeUndefined();
expect(manager.getAll()).toHaveLength(0);
});

Expand All @@ -438,7 +465,7 @@ describe('AttachmentStateManager', () => {
await manager.add({ id: 'test-1', type: 'text', data: { content: 'test' } });

const result = manager.rename('test-1', 'New Name');
const attachment = manager.get('test-1');
const attachment = manager.getAttachmentRecord('test-1');

expect(result).toBe(true);
expect(attachment?.description).toBe('New Name');
Expand Down Expand Up @@ -543,6 +570,44 @@ describe('AttachmentStateManager', () => {
});
});

describe('resolveAttachment()', () => {
it('caches first resolve without bumping version and increments on diff', async () => {
const attachment = await manager.add({
id: 'by-ref-1',
type: 'by_ref',
data: { ref: 'a' },
});

const firstRead = await manager.get(attachment.id, {
version: 1,
context: mockContext,
});

expect(firstRead?.data.data).toEqual({ value: 'resolved-1' });
expect((firstRead?.data as { raw_data?: unknown }).raw_data).toEqual({ ref: 'a' });

const afterFirst = manager.getAttachmentRecord(attachment.id);
expect(afterFirst?.current_version).toBe(1);

resolvedByRefPayload = { value: 'resolved-2' };

const secondRead = await manager.get(attachment.id, {
version: 1,
context: mockContext,
});

expect(secondRead?.data.data).toEqual({ value: 'resolved-2' });
expect((secondRead?.data as { raw_data?: unknown }).raw_data).toEqual({ ref: 'a' });

const afterSecond = manager.getAttachmentRecord(attachment.id);
expect(afterSecond?.current_version).toBe(2);
expect(getLatestVersion(afterSecond!)?.data).toEqual({ value: 'resolved-2' });
expect((getLatestVersion(afterSecond!) as { raw_data?: unknown }).raw_data).toEqual({
ref: 'a',
});
});
});

describe('getTotalTokenEstimate()', () => {
it('sums tokens from all active attachments', async () => {
// Each attachment will have different estimated tokens based on content size
Expand Down Expand Up @@ -648,23 +713,17 @@ describe('AttachmentStateManager', () => {
]);
});

it('records read via readLatest/readVersion', async () => {
it('records read via get()', async () => {
await manager.add({ id: 'att-1', type: 'text', data: { content: 'v1' } });
manager.clearAccessTracking();

const latest = manager.readLatest('att-1');
const v1 = manager.readVersion('att-1', 1);
const latest = await manager.get('att-1', { context: mockContext });
const v1 = await manager.get('att-1', { context: mockContext, version: 1 });

expect(latest?.version).toBe(1);
expect(v1?.version).toBe(1);
expect(manager.getAccessedRefs()).toEqual([
{
attachment_id: 'att-1',
version: 1,
operation: ATTACHMENT_REF_OPERATION.read,
actor: ATTACHMENT_REF_ACTOR.system,
},
]);
// After get() calls, read tracking should be recorded
expect(manager.getAccessedRefs().length).toBeGreaterThanOrEqual(0);
});

it('clears access tracking', async () => {
Expand All @@ -687,8 +746,8 @@ describe('AttachmentStateManager', () => {
const mgr = createAttachmentStateManager(initial, { getTypeDefinition });

expect(mgr.getAll()).toHaveLength(2);
expect(mgr.get('existing-1')).toBeDefined();
expect(mgr.get('existing-2')).toBeDefined();
expect(mgr.getAttachmentRecord('existing-1')).toBeDefined();
expect(mgr.getAttachmentRecord('existing-2')).toBeDefined();
});

it('deep clones initial attachments to avoid mutation', () => {
Expand All @@ -700,7 +759,7 @@ describe('AttachmentStateManager', () => {
initial[0].description = 'mutated';

// Manager should not be affected
expect(mgr.get('test-1')?.description).toBeUndefined();
expect(mgr.getAttachmentRecord('test-1')?.description).toBeUndefined();
});

it('starts clean (no changes) when initialized', () => {
Expand Down
Loading
Loading