import { builtTelemetry, fakeSpan, fakeTracer } from './support/fake-tracer';
import type { BuiltTool } from '../../types/sdk/tool';
import type { BuiltTelemetry } from '../../types/telemetry';
import type { AgentRuntimeConfig } from '../loop/agent-runtime';
import { RuntimeTelemetry, withMemorySpan } from '../telemetry/runtime-telemetry';

describe('RuntimeTelemetry.resolve()', () => {
	it('stamps own telemetry with the runtime name only when it lacks a functionId, without mutating the config', () => {
		const telemetry = builtTelemetry();
		const config = { name: 'my-agent', telemetry } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		expect(runtimeTelemetry.resolve(undefined)?.functionId).toBe('my-agent');
		expect(telemetry.functionId).toBeUndefined();

		const explicit = builtTelemetry({ functionId: 'explicit-id' });
		const explicitRuntime = new RuntimeTelemetry({
			name: 'my-agent',
			telemetry: explicit,
		} as AgentRuntimeConfig);
		expect(explicitRuntime.resolve(undefined)?.functionId).toBe('explicit-id');
	});

	it('re-labels inherited telemetry with the runtime name', () => {
		const inherited = builtTelemetry({ functionId: 'parent-agent' });
		const config = { name: 'child-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		expect(runtimeTelemetry.resolve({ telemetry: inherited })?.functionId).toBe('child-agent');
		expect(runtimeTelemetry.resolve(undefined)).toBeUndefined();
	});
});

describe('RuntimeTelemetry.withRootSpan()', () => {
	it('falls through to fn() without starting a span when telemetry is disabled, root span is off, or the tracer is not active-span-capable', async () => {
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		const disabled = builtTelemetry({ enabled: false, tracer: fakeTracer(fakeSpan()) });
		await expect(
			runtimeTelemetry.withRootSpan(
				'generate',
				{ telemetry: disabled },
				'run-1',
				async () => await Promise.resolve('ok'),
			),
		).resolves.toBe('ok');

		const rootSpanOff = builtTelemetry({
			runtimeRootSpanEnabled: false,
			tracer: fakeTracer(fakeSpan()),
		});
		await expect(
			runtimeTelemetry.withRootSpan(
				'generate',
				{ telemetry: rootSpanOff },
				'run-1',
				async () => await Promise.resolve('ok'),
			),
		).resolves.toBe('ok');

		const noTracer = builtTelemetry();
		await expect(
			runtimeTelemetry.withRootSpan(
				'generate',
				{ telemetry: noTracer },
				'run-1',
				async () => await Promise.resolve('ok'),
			),
		).resolves.toBe('ok');
	});

	it('starts a root span with root: true and generic gen_ai.* attributes, without langsmith.* noise', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer, metadata: { model_id: 'openai/gpt-4o-mini' } });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withRootSpan(
			'generate',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		expect(tracer.startActiveSpan).toHaveBeenCalledTimes(1);
		const [name, options] = tracer.startActiveSpan.mock.calls[0];
		expect(name).toBe('my-agent.generate');
		expect(options).toMatchObject({ root: true });
		expect(options).not.toHaveProperty('links');

		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({
			'gen_ai.operation.name': 'invoke_agent',
			'gen_ai.agent.name': 'my-agent',
			'gen_ai.request.model': 'openai/gpt-4o-mini',
		});
		expect(Object.keys(attributes).some((key) => key.startsWith('langsmith.'))).toBe(false);
		expect(span.end).toHaveBeenCalledTimes(1);
	});

	it('omits root when rootAnchored is false, so the span nests under the ambient context', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer, rootAnchored: false });
		const config = { name: 'sub-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withRootSpan(
			'generate',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		expect(options).not.toHaveProperty('root');
		expect((options as { attributes?: unknown }).attributes).toBeDefined();
		expect(span.end).toHaveBeenCalledTimes(1);
	});

	it('still skips span creation entirely when runtimeRootSpanEnabled is false, even if rootAnchored is also false', async () => {
		const tracer = fakeTracer(fakeSpan());
		const telemetry = builtTelemetry({
			tracer,
			runtimeRootSpanEnabled: false,
			rootAnchored: false,
		});
		const config = { name: 'sub-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await expect(
			runtimeTelemetry.withRootSpan(
				'generate',
				{ telemetry },
				'run-1',
				async () => await Promise.resolve('ok'),
			),
		).resolves.toBe('ok');
		expect(tracer.startActiveSpan).not.toHaveBeenCalled();
	});

	it('tags sub-agent metadata (e.g. source: sub-agent) onto the span attributes like any other metadata key', async () => {
		const tracer = fakeTracer(fakeSpan());
		const telemetry = builtTelemetry({
			tracer,
			rootAnchored: false,
			metadata: { thread_id: 'thread-abc', source: 'sub-agent' },
		});
		const config = { name: 'sub-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withRootSpan(
			'generate',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({ 'ai.telemetry.metadata.source': 'sub-agent' });
	});

	it('passes links through to the tracer only when provided', async () => {
		const tracer = fakeTracer(fakeSpan());
		const telemetry = builtTelemetry({ tracer });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);
		const links = [{ context: 'fake-span-context' }];

		await runtimeTelemetry.withRootSpan(
			'stream',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
			links,
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		expect((options as { links?: unknown }).links).toBe(links);
	});

	it('adds langsmith.* attributes only when telemetry.isLangSmith is set', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer, isLangSmith: true });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withRootSpan(
			'generate',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({
			'langsmith.traceable': 'true',
			'langsmith.trace.name': 'my-agent.generate',
			'langsmith.span.kind': 'chain',
			'langsmith.metadata.agent_name': 'my-agent',
			'langsmith.metadata.agent_run_id': 'run-1',
		});
	});

	it('adds gen_ai.conversation.id when metadata carries a string thread_id', async () => {
		const tracer = fakeTracer(fakeSpan());
		const telemetry = builtTelemetry({ tracer, metadata: { thread_id: 'thread-abc' } });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withRootSpan(
			'generate',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({ 'gen_ai.conversation.id': 'thread-abc' });
	});

	it('omits gen_ai.prompt when the tool summary cannot be JSON-stringified', async () => {
		const tracer = fakeTracer(fakeSpan());
		const telemetry = builtTelemetry({ tracer });
		const circularSchema: Record<string, unknown> = {};
		circularSchema.self = circularSchema;
		const config = {
			name: 'my-agent',
			tools: [{ name: 'circular-tool', inputSchema: circularSchema } as unknown as BuiltTool],
		} as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withRootSpan(
			'generate',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).not.toHaveProperty('gen_ai.prompt');
	});

	it('filters out non-string tool names when building the LangSmith tool catalog', async () => {
		const tracer = fakeTracer(fakeSpan());
		const telemetry = builtTelemetry({ tracer, isLangSmith: true });
		const config = {
			name: 'my-agent',
			tools: [
				{ name: 'valid-tool', description: 'ok' } as unknown as BuiltTool,
				// A tool name that isn't a string shouldn't normally occur (BuiltTool.name
				// is typed as string), but the catalog builder guards against it defensively.
				{ name: 123 as unknown as string, description: 'bad' } as unknown as BuiltTool,
			],
		} as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withRootSpan(
			'generate',
			{ telemetry },
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({
			'langsmith.metadata.available_tools': ['valid-tool'],
		});
	});

	it('falls back to the runtime name for the span name when resolved telemetry has no functionId', async () => {
		const tracer = fakeTracer(fakeSpan());
		const telemetry = builtTelemetry({ tracer, functionId: undefined });
		const config = { name: 'fallback-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);
		// resolve() always stamps a functionId when it returns telemetry, so the
		// `?? this.config.name` fallback in withRootSpan is unreachable through
		// the real resolve() path — spy on it to inject telemetry that skips
		// that stamping, exercising the fallback directly.
		vi.spyOn(runtimeTelemetry, 'resolve').mockReturnValue(telemetry);

		await runtimeTelemetry.withRootSpan(
			'generate',
			undefined,
			'run-1',
			async () => await Promise.resolve('ok'),
		);

		const [name] = tracer.startActiveSpan.mock.calls[0];
		expect(name).toBe('fallback-agent.generate');
	});

	it('records the exception, sets error status, and rethrows when fn() throws', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);
		const error = new Error('boom');

		await expect(
			runtimeTelemetry.withRootSpan('generate', { telemetry }, 'run-1', () => {
				throw error;
			}),
		).rejects.toThrow('boom');

		expect(span.recordException).toHaveBeenCalledWith(error);
		expect(span.setStatus).toHaveBeenCalledWith({ code: 2, message: String(error) });
		expect(span.end).toHaveBeenCalledTimes(1);
	});
});

describe('RuntimeTelemetry.withToolSpan()', () => {
	it('falls through to fn() without starting a span when telemetry is disabled or the tracer is not active-span-capable', async () => {
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		const disabled = builtTelemetry({ enabled: false, tracer: fakeTracer(fakeSpan()) });
		await expect(
			runtimeTelemetry.withToolSpan(
				'tc1',
				'my-tool',
				{ x: 1 },
				disabled,
				async () => await Promise.resolve('ok'),
			),
		).resolves.toBe('ok');

		const noTracer = builtTelemetry();
		await expect(
			runtimeTelemetry.withToolSpan(
				'tc1',
				'my-tool',
				{ x: 1 },
				noTracer,
				async () => await Promise.resolve('ok'),
			),
		).resolves.toBe('ok');
	});

	it('names the span execute_tool {toolName} and carries generic gen_ai.tool.* attributes', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withToolSpan(
			'tc1',
			'my-tool',
			{ x: 1 },
			telemetry,
			async () => await Promise.resolve({ ok: true }),
		);

		expect(tracer.startActiveSpan).toHaveBeenCalledTimes(1);
		const [name, options] = tracer.startActiveSpan.mock.calls[0];
		expect(name).toBe('execute_tool my-tool');

		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({
			'gen_ai.operation.name': 'execute_tool',
			'gen_ai.tool.name': 'my-tool',
			'gen_ai.tool.call.id': 'tc1',
			'gen_ai.agent.name': 'my-agent',
			'gen_ai.tool.call.arguments': '{"x":1}',
		});
		expect(span.setAttributes).toHaveBeenCalledWith(
			expect.objectContaining({ 'gen_ai.tool.call.result': '{"ok":true}' }),
		);
		expect(span.end).toHaveBeenCalledTimes(1);
	});

	it('respects recordInputs/recordOutputs when set to false', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({
			tracer,
			recordInputs: false,
			recordOutputs: false,
		});
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withToolSpan(
			'tc1',
			'my-tool',
			{ x: 1 },
			telemetry,
			async () => await Promise.resolve({ ok: true }),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).not.toHaveProperty('gen_ai.tool.call.arguments');
		expect(attributes).not.toHaveProperty('ai.toolCall.args');
		expect(span.setAttributes).not.toHaveBeenCalled();
	});

	it('records outputs but not inputs when only recordInputs is false', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer, recordInputs: false, recordOutputs: true });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withToolSpan(
			'tc1',
			'my-tool',
			{ x: 1 },
			telemetry,
			async () => await Promise.resolve({ ok: true }),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).not.toHaveProperty('gen_ai.tool.call.arguments');
		expect(attributes).not.toHaveProperty('ai.toolCall.args');
		expect(span.setAttributes).toHaveBeenCalledWith({
			'ai.toolCall.result': '{"ok":true}',
			'gen_ai.tool.call.result': '{"ok":true}',
		});
	});

	it('records inputs but not outputs when only recordOutputs is false', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer, recordInputs: true, recordOutputs: false });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withToolSpan(
			'tc1',
			'my-tool',
			{ x: 1 },
			telemetry,
			async () => await Promise.resolve({ ok: true }),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({
			'gen_ai.tool.call.arguments': '{"x":1}',
			'ai.toolCall.args': '{"x":1}',
		});
		expect(span.setAttributes).not.toHaveBeenCalled();
	});

	it('defaults to recording inputs and outputs when recordInputs/recordOutputs are absent from the telemetry object', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		// BuiltTelemetry declares these as non-optional booleans, but the
		// `?? true` fallback in withToolSpan guards against a telemetry object
		// that doesn't actually carry them at runtime — construct one directly
		// to exercise that fallback rather than the declared type.
		const {
			recordInputs: _recordInputs,
			recordOutputs: _recordOutputs,
			...rest
		} = builtTelemetry({
			tracer,
		});
		const telemetry = rest as unknown as BuiltTelemetry;
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);

		await runtimeTelemetry.withToolSpan(
			'tc1',
			'my-tool',
			{ x: 1 },
			telemetry,
			async () => await Promise.resolve({ ok: true }),
		);

		const [, options] = tracer.startActiveSpan.mock.calls[0];
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toMatchObject({
			'gen_ai.tool.call.arguments': '{"x":1}',
			'ai.toolCall.args': '{"x":1}',
		});
		expect(span.setAttributes).toHaveBeenCalledWith({
			'ai.toolCall.result': '{"ok":true}',
			'gen_ai.tool.call.result': '{"ok":true}',
		});
	});

	it('records the exception, sets error status, and rethrows when fn() throws', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer });
		const config = { name: 'my-agent' } as AgentRuntimeConfig;
		const runtimeTelemetry = new RuntimeTelemetry(config);
		const error = new Error('tool boom');

		await expect(
			runtimeTelemetry.withToolSpan('tc1', 'my-tool', { x: 1 }, telemetry, () => {
				throw error;
			}),
		).rejects.toThrow('tool boom');

		expect(span.recordException).toHaveBeenCalledWith(error);
		expect(span.setStatus).toHaveBeenCalledWith({ code: 2, message: String(error) });
		expect(span.end).toHaveBeenCalledTimes(1);
	});
});

describe('withMemorySpan()', () => {
	it('falls through to fn() without starting a span when telemetry is disabled, undefined, or the tracer is not active-span-capable', async () => {
		const disabled = builtTelemetry({ enabled: false, tracer: fakeTracer(fakeSpan()) });
		await expect(
			withMemorySpan(
				'query_memory',
				'my-agent',
				disabled,
				() => ({}),
				async () =>
					await Promise.resolve({
						result: 'ok',
					}),
			),
		).resolves.toBe('ok');

		const noTracer = builtTelemetry();
		await expect(
			withMemorySpan(
				'query_memory',
				'my-agent',
				noTracer,
				() => ({}),
				async () =>
					await Promise.resolve({
						result: 'ok',
					}),
			),
		).resolves.toBe('ok');

		await expect(
			withMemorySpan(
				'query_memory',
				'my-agent',
				undefined,
				() => ({}),
				async () =>
					await Promise.resolve({
						result: 'ok',
					}),
			),
		).resolves.toBe('ok');
	});

	it('never calls buildInitialAttributes when telemetry is disabled, undefined, or the tracer is not active-span-capable', async () => {
		// Regression guard: callers build these attributes from
		// inferMemoryStoreAttributes(memory), which calls memory.describe() — a
		// method some BuiltMemory implementations don't implement. If this thunk
		// were called eagerly regardless of telemetry state, any such backend
		// would break even with telemetry fully disabled.
		const buildInitialAttributes = vi.fn(() => ({ owners: ['resource-1'] }));
		const fn = async () => await Promise.resolve({ result: 'ok' });

		await withMemorySpan('query_memory', 'my-agent', undefined, buildInitialAttributes, fn);
		await withMemorySpan(
			'query_memory',
			'my-agent',
			builtTelemetry({ enabled: false, tracer: fakeTracer(fakeSpan()) }),
			buildInitialAttributes,
			fn,
		);
		await withMemorySpan('query_memory', 'my-agent', builtTelemetry(), buildInitialAttributes, fn);

		expect(buildInitialAttributes).not.toHaveBeenCalled();
	});

	it('opens a span named query_memory with gen_ai.memory.* attributes from initialAttributes', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer });

		const result = await withMemorySpan(
			'query_memory',
			'my-agent',
			telemetry,
			() => ({
				types: ['session'],
				owners: ['resource-1'],
				storeTypes: ['in_memory'],
				storeNames: ['memory'],
			}),
			async () => await Promise.resolve({ result: { entries: 3 } }),
		);

		expect(result).toEqual({ entries: 3 });
		expect(tracer.startActiveSpan).toHaveBeenCalledTimes(1);
		const [name, options] = tracer.startActiveSpan.mock.calls[0];
		expect(name).toBe('query_memory');
		const attributes = (options as { attributes: Record<string, unknown> }).attributes;
		expect(attributes).toEqual({
			'gen_ai.operation.name': 'query_memory',
			'gen_ai.agent.name': 'my-agent',
			'gen_ai.memory.types': ['session'],
			'gen_ai.memory.owners': ['resource-1'],
			'gen_ai.memory.store.types': ['in_memory'],
			'gen_ai.memory.store.names': ['memory'],
		});
		expect(span.end).toHaveBeenCalledTimes(1);
	});

	it('merges post-hoc attributes returned by fn() onto the span, named save_memory', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer });

		await withMemorySpan(
			'save_memory',
			'my-agent',
			telemetry,
			() => ({ owners: ['resource-1'] }),
			async () =>
				await Promise.resolve({
					result: undefined,
					attributes: { ids: ['m1', 'm2'], operations: ['created', 'created'] },
				}),
		);

		const [name] = tracer.startActiveSpan.mock.calls[0];
		expect(name).toBe('save_memory');
		expect(span.setAttributes).toHaveBeenCalledWith({
			'gen_ai.operation.name': 'save_memory',
			'gen_ai.agent.name': 'my-agent',
			'gen_ai.memory.ids': ['m1', 'm2'],
			'gen_ai.memory.operations': ['created', 'created'],
		});
	});

	it('does not call setAttributes when fn() returns no post-hoc attributes', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer });

		await withMemorySpan(
			'query_memory',
			'my-agent',
			telemetry,
			() => ({}),
			async () =>
				await Promise.resolve({
					result: 'ok',
				}),
		);

		expect(span.setAttributes).not.toHaveBeenCalled();
	});

	it('records the exception, sets error status, ends the span, and rethrows when fn() throws', async () => {
		const span = fakeSpan();
		const tracer = fakeTracer(span);
		const telemetry = builtTelemetry({ tracer });
		const error = new Error('memory boom');

		await expect(
			withMemorySpan(
				'query_memory',
				'my-agent',
				telemetry,
				() => ({}),
				() => {
					throw error;
				},
			),
		).rejects.toThrow('memory boom');

		expect(span.recordException).toHaveBeenCalledWith(error);
		expect(span.setStatus).toHaveBeenCalledWith({ code: 2, message: String(error) });
		expect(span.end).toHaveBeenCalledTimes(1);
	});
});

describe('inferMemoryStoreAttributes()', () => {
	it("returns storeTypes: ['in_memory'] and storeNames from describe() for InMemoryMemory", async () => {
		const { InMemoryMemory } = await import('../memory/memory-store.js');
		const { inferMemoryStoreAttributes } = await import('../telemetry/runtime-telemetry.js');
		const memory = new InMemoryMemory();

		expect(inferMemoryStoreAttributes(memory)).toEqual({
			storeTypes: ['in_memory'],
			storeNames: ['memory'],
		});
	});

	it('omits storeTypes for a backend it cannot identify, but keeps storeNames', async () => {
		const { inferMemoryStoreAttributes } = await import('../telemetry/runtime-telemetry.js');
		const customMemory = {
			describe: () => ({ name: 'n8n', constructorName: 'N8nMemory', connectionParams: null }),
		} as unknown as import('../../types').BuiltMemory;

		expect(inferMemoryStoreAttributes(customMemory)).toEqual({ storeNames: ['n8n'] });
	});

	it('omits storeNames when describe() returns an empty name, but still identifies InMemoryMemory', async () => {
		const { inferMemoryStoreAttributes } = await import('../telemetry/runtime-telemetry.js');
		const unnamedMemory = {
			describe: () => ({ name: '', constructorName: 'InMemoryMemory', connectionParams: null }),
		} as unknown as import('../../types').BuiltMemory;

		expect(inferMemoryStoreAttributes(unnamedMemory)).toEqual({ storeTypes: ['in_memory'] });
	});

	it('returns {} instead of throwing when describe() is broken, so the memory operation still runs', async () => {
		const { inferMemoryStoreAttributes } = await import('../telemetry/runtime-telemetry.js');
		const brokenMemory = {
			describe: () => {
				throw new Error('Method not implemented.');
			},
		} as unknown as import('../../types').BuiltMemory;

		expect(inferMemoryStoreAttributes(brokenMemory)).toEqual({});
	});
});
