From 2fd2b15412332418d7c23c2a9638456c929e71ef Mon Sep 17 00:00:00 2001 From: zkasuran Date: Tue, 11 Aug 2026 21:04:50 +0530 Subject: [PATCH 1/4] fix(server-utils): Record LangGraph span I/O for non-MessagesAnnotation state instrumentCompiledGraphInvoke read the graph input and output through `args[0].messages` and `result.messages`. Those keys only exist for graphs built on MessagesAnnotation, so a graph with a custom state annotation had its `invoke_agent` span input and output silently left empty with no error. Keep the existing message path unchanged. When there is no `messages` array, fall back to serializing the whole input and output state onto `gen_ai.input.messages` and `gen_ai.response.text`, wrapped as a single role/content message so the attribute stays a valid chat array (the same shape LangChain LLM prompts already use). A null input still records nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/ai/langgraph/index.ts | 36 +++--- .../server-utils/src/ai/langgraph/utils.ts | 7 +- .../test/ai/lib/tracing/langgraph.test.ts | 109 +++++++++++++++++- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index 00825cbd6206..b757d79bf1a3 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -154,21 +154,29 @@ export function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_TOOL_DEFINITIONS, JSON.stringify(tools)); } - // Parse input messages - const inputMessages = - args.length > 0 ? ((args[0] as { messages?: LangChainMessage[] } | null)?.messages ?? []) : []; - - if (inputMessages && recordInputs) { - const normalizedMessages = normalizeLangChainMessages(inputMessages); - const { systemInstructions, filteredMessages } = extractSystemInstructions(normalizedMessages); - - if (systemInstructions) { - span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); + // Parse input state. MessagesAnnotation graphs expose a `messages` array. A custom state + // annotation exposes arbitrary keys instead, so the whole state is recorded as a fallback + // rather than dropped silently. + const inputState = args.length > 0 ? args[0] : undefined; + const inputMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages ?? []; + + if (recordInputs) { + if (inputMessages.length > 0) { + const normalizedMessages = normalizeLangChainMessages(inputMessages); + const { systemInstructions, filteredMessages } = extractSystemInstructions(normalizedMessages); + + if (systemInstructions) { + span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); + } + + span.setAttributes({ + [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), + }); + } else if (inputState && typeof inputState === 'object') { + span.setAttributes({ + [GEN_AI_INPUT_MESSAGES]: stringify([{ role: 'user', content: stringify(inputState) }]), + }); } - - span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), - }); } // Call original invoke diff --git a/packages/server-utils/src/ai/langgraph/utils.ts b/packages/server-utils/src/ai/langgraph/utils.ts index 86e43d2d33a0..4afd0236c4b7 100644 --- a/packages/server-utils/src/ai/langgraph/utils.ts +++ b/packages/server-utils/src/ai/langgraph/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable typescript-eslint/no-deprecated */ -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, stringify } from '@sentry/core'; import type { Span, SpanAttributes } from '@sentry/core'; import { GEN_AI_AGENT_NAME, @@ -276,6 +276,11 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag const outputMessages = resultObj?.messages; if (!outputMessages || !Array.isArray(outputMessages)) { + // A custom state annotation has no `messages` array. Record the whole output state as a + // fallback so it is not dropped silently. + if (result && typeof result === 'object') { + span.setAttribute(GEN_AI_RESPONSE_TEXT, stringify([{ role: 'assistant', content: stringify(result) }])); + } return; } diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index b1f79c9d184c..98fe90b77736 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT } from '@sentry/conventions/attributes'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; import { instrumentCreateReactAgent, instrumentStateGraph, instrumentStateGraphCompile, } from '../../../../src/ai/langgraph'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('langgraph double-patch guard', () => { it('instrumentStateGraphCompile returns the same wrapper when applied twice', () => { @@ -32,3 +36,106 @@ describe('instrumentStateGraph', () => { expect(stateGraph.compile).not.toBe(originalCompile); }); }); + +describe('invoke_agent input/output recording', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + // Regression test for a graph built on a custom state annotation (no `messages` key). The + // instrumentation used to read `args[0].messages` only, so the whole state was dropped. + it('records the full state for a graph that does not use MessagesAnnotation', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (input: Record) => ({ ...input, expanded: 'expanded idea', validated: true }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + const result = await graph.invoke({ idea: 'test idea' }); + + expect(result).toEqual({ idea: 'test idea', expanded: 'expanded idea', validated: true }); + expect(endedSpans).toHaveLength(1); + + const data = spanToJSON(endedSpans[0]!).data; + + const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; + expect(inputMessages).toBeDefined(); + const parsedInput = JSON.parse(inputMessages!) as Array<{ role: string; content: string }>; + expect(parsedInput).toHaveLength(1); + expect(parsedInput[0]!.role).toBe('user'); + expect(JSON.parse(parsedInput[0]!.content)).toEqual({ idea: 'test idea' }); + + const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; + expect(responseText).toBeDefined(); + const parsedOutput = JSON.parse(responseText!) as Array<{ role: string; content: string }>; + expect(parsedOutput[0]!.role).toBe('assistant'); + expect(JSON.parse(parsedOutput[0]!.content)).toEqual({ + idea: 'test idea', + expanded: 'expanded idea', + validated: true, + }); + }); + + it('still records chat messages for a MessagesAnnotation graph', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (input: { messages: Array<{ role: string; content: string }> }) => ({ + messages: [...input.messages, { role: 'assistant', content: 'The weather is sunny' }], + }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + await graph.invoke({ messages: [{ role: 'user', content: 'What is the weather today?' }] }); + + const data = spanToJSON(endedSpans[0]!).data; + + const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; + expect(inputMessages).toBeDefined(); + expect(JSON.parse(inputMessages!)).toEqual([{ role: 'user', content: 'What is the weather today?' }]); + + const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; + expect(responseText).toBeDefined(); + expect(responseText).toContain('The weather is sunny'); + }); + + it('does not record input messages when invoked with null input', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (_input?: unknown) => ({ messages: [{ role: 'assistant', content: 'resumed' }] }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + await expect(graph.invoke(null)).resolves.toBeDefined(); + + const data = spanToJSON(endedSpans[0]!).data; + expect(data[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + }); +}); From 508fded471021620d99e2c924ade3ef9e51c7b76 Mon Sep 17 00:00:00 2001 From: zkasuran Date: Thu, 13 Aug 2026 06:36:48 +0530 Subject: [PATCH 2/4] fix(server-utils): Route empty LangGraph messages array on the chat path An explicit empty `messages` array was gated by `length > 0`, so it fell through to the custom-state branch and got wrapped as a synthetic user message holding the whole input object. The output helper already routes on `Array.isArray`, so an empty array stays on the chat path there. Match that on the input read: key on `Array.isArray(state.messages)`, so an empty MessagesAnnotation input records an empty chat array while a null or resume input still records nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/ai/langgraph/index.ts | 14 ++++++----- .../test/ai/lib/tracing/langgraph.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index b757d79bf1a3..a02464634193 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -154,14 +154,16 @@ export function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_TOOL_DEFINITIONS, JSON.stringify(tools)); } - // Parse input state. MessagesAnnotation graphs expose a `messages` array. A custom state - // annotation exposes arbitrary keys instead, so the whole state is recorded as a fallback - // rather than dropped silently. + // Parse input state. MessagesAnnotation graphs expose a `messages` array (possibly empty); + // a custom state annotation exposes arbitrary keys instead. Route on whether `messages` is + // an array, mirroring the output side in setResponseAttributes, so an empty chat history is + // recorded as an empty chat array rather than misread as custom state and wrapped. const inputState = args.length > 0 ? args[0] : undefined; - const inputMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages ?? []; + const stateMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages; + const inputMessages = Array.isArray(stateMessages) ? stateMessages : null; if (recordInputs) { - if (inputMessages.length > 0) { + if (inputMessages) { const normalizedMessages = normalizeLangChainMessages(inputMessages); const { systemInstructions, filteredMessages } = extractSystemInstructions(normalizedMessages); @@ -183,7 +185,7 @@ export function instrumentCompiledGraphInvoke( const result = await Reflect.apply(target, thisArg, args); if (recordOutputs) { - setResponseAttributes(span, inputMessages ?? null, result); + setResponseAttributes(span, inputMessages, result); } return result; diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index 98fe90b77736..da7f2a220ecf 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -123,6 +123,31 @@ describe('invoke_agent input/output recording', () => { expect(responseText).toContain('The weather is sunny'); }); + it('records an empty messages array on the chat path rather than wrapping it as custom state', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (_input: { messages: Array<{ role: string; content: string }> }) => ({ + messages: [{ role: 'assistant', content: 'Hello' }], + }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + await graph.invoke({ messages: [] }); + + const data = spanToJSON(endedSpans[0]!).data; + + const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; + expect(inputMessages).toBeDefined(); + expect(JSON.parse(inputMessages!)).toEqual([]); + + const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; + expect(responseText).toBeDefined(); + expect(responseText).toContain('Hello'); + }); + it('does not record input messages when invoked with null input', async () => { const endedSpans = setupClient(); From c0df556c1ad676d606acaac45675538ba6b9ff6c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 21 Sep 2026 16:34:50 -0400 Subject: [PATCH 3/4] test(server-utils): Read LangGraph span attributes from `attributes` `spanToJSON` on develop returns the streamed span shape, which exposes `attributes` instead of `data`. --- .../server-utils/test/ai/lib/tracing/langgraph.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index da7f2a220ecf..420c9d1fff57 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -78,7 +78,7 @@ describe('invoke_agent input/output recording', () => { expect(result).toEqual({ idea: 'test idea', expanded: 'expanded idea', validated: true }); expect(endedSpans).toHaveLength(1); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; expect(inputMessages).toBeDefined(); @@ -112,7 +112,7 @@ describe('invoke_agent input/output recording', () => { const graph = stateGraph.compile(); await graph.invoke({ messages: [{ role: 'user', content: 'What is the weather today?' }] }); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; expect(inputMessages).toBeDefined(); @@ -137,7 +137,7 @@ describe('invoke_agent input/output recording', () => { const graph = stateGraph.compile(); await graph.invoke({ messages: [] }); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; expect(inputMessages).toBeDefined(); @@ -160,7 +160,7 @@ describe('invoke_agent input/output recording', () => { const graph = stateGraph.compile(); await expect(graph.invoke(null)).resolves.toBeDefined(); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; expect(data[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); }); }); From d34ea302d6e8637330d51ff4de57ea4f52b4136e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 21 Sep 2026 16:39:51 -0400 Subject: [PATCH 4/4] ref(server-utils): Tighten LangGraph state recording and its tests --- .../server-utils/src/ai/langgraph/index.ts | 14 +- .../server-utils/src/ai/langgraph/utils.ts | 3 +- .../test/ai/lib/tracing/langgraph.test.ts | 122 ++++++------------ 3 files changed, 45 insertions(+), 94 deletions(-) diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index a02464634193..0d6a6fab985b 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -154,13 +154,9 @@ export function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_TOOL_DEFINITIONS, JSON.stringify(tools)); } - // Parse input state. MessagesAnnotation graphs expose a `messages` array (possibly empty); - // a custom state annotation exposes arbitrary keys instead. Route on whether `messages` is - // an array, mirroring the output side in setResponseAttributes, so an empty chat history is - // recorded as an empty chat array rather than misread as custom state and wrapped. - const inputState = args.length > 0 ? args[0] : undefined; - const stateMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages; - const inputMessages = Array.isArray(stateMessages) ? stateMessages : null; + // Custom state annotations have no `messages` array, the whole state is recorded instead. + const inputState = args[0] as { messages?: LangChainMessage[] } | null | undefined; + const inputMessages = Array.isArray(inputState?.messages) ? inputState.messages : null; if (recordInputs) { if (inputMessages) { @@ -175,9 +171,7 @@ export function instrumentCompiledGraphInvoke( [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), }); } else if (inputState && typeof inputState === 'object') { - span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: stringify([{ role: 'user', content: stringify(inputState) }]), - }); + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify([{ role: 'user', content: stringify(inputState) }])); } } diff --git a/packages/server-utils/src/ai/langgraph/utils.ts b/packages/server-utils/src/ai/langgraph/utils.ts index 4afd0236c4b7..e9a39186d827 100644 --- a/packages/server-utils/src/ai/langgraph/utils.ts +++ b/packages/server-utils/src/ai/langgraph/utils.ts @@ -276,8 +276,7 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag const outputMessages = resultObj?.messages; if (!outputMessages || !Array.isArray(outputMessages)) { - // A custom state annotation has no `messages` array. Record the whole output state as a - // fallback so it is not dropped silently. + // Custom state annotations have no `messages` array, the whole state is recorded instead. if (result && typeof result === 'object') { span.setAttribute(GEN_AI_RESPONSE_TEXT, stringify([{ role: 'assistant', content: stringify(result) }])); } diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index 420c9d1fff57..02c7c4b95978 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -61,106 +61,64 @@ describe('invoke_agent input/output recording', () => { return endedSpans; } - // Regression test for a graph built on a custom state annotation (no `messages` key). The - // instrumentation used to read `args[0].messages` only, so the whole state was dropped. - it('records the full state for a graph that does not use MessagesAnnotation', async () => { + async function getInvokeAttributes(invoke: (input: T) => Promise, input: T) { const endedSpans = setupClient(); - - const compiled = { - invoke: async (input: Record) => ({ ...input, expanded: 'expanded idea', validated: true }), - }; - const stateGraph = { compile: () => compiled }; + const stateGraph = { compile: () => ({ invoke }) }; instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - const result = await graph.invoke({ idea: 'test idea' }); + await stateGraph.compile().invoke(input); - expect(result).toEqual({ idea: 'test idea', expanded: 'expanded idea', validated: true }); expect(endedSpans).toHaveLength(1); + return spanToJSON(endedSpans[0]!).attributes; + } - const data = spanToJSON(endedSpans[0]!).attributes; - - const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; - expect(inputMessages).toBeDefined(); - const parsedInput = JSON.parse(inputMessages!) as Array<{ role: string; content: string }>; - expect(parsedInput).toHaveLength(1); - expect(parsedInput[0]!.role).toBe('user'); - expect(JSON.parse(parsedInput[0]!.content)).toEqual({ idea: 'test idea' }); - - const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; - expect(responseText).toBeDefined(); - const parsedOutput = JSON.parse(responseText!) as Array<{ role: string; content: string }>; - expect(parsedOutput[0]!.role).toBe('assistant'); - expect(JSON.parse(parsedOutput[0]!.content)).toEqual({ - idea: 'test idea', - expanded: 'expanded idea', - validated: true, - }); + it('records the full state for a graph that does not use MessagesAnnotation', async () => { + const attributes = await getInvokeAttributes( + async (input: Record) => ({ ...input, expanded: 'expanded idea', validated: true }), + { idea: 'test idea' }, + ); + + expect(JSON.parse(attributes[GEN_AI_INPUT_MESSAGES] as string)).toEqual([ + { role: 'user', content: JSON.stringify({ idea: 'test idea' }) }, + ]); + expect(JSON.parse(attributes[GEN_AI_RESPONSE_TEXT] as string)).toEqual([ + { + role: 'assistant', + content: JSON.stringify({ idea: 'test idea', expanded: 'expanded idea', validated: true }), + }, + ]); }); it('still records chat messages for a MessagesAnnotation graph', async () => { - const endedSpans = setupClient(); - - const compiled = { - invoke: async (input: { messages: Array<{ role: string; content: string }> }) => ({ + const attributes = await getInvokeAttributes( + async (input: { messages: Array<{ role: string; content: string }> }) => ({ messages: [...input.messages, { role: 'assistant', content: 'The weather is sunny' }], }), - }; - const stateGraph = { compile: () => compiled }; - - instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - await graph.invoke({ messages: [{ role: 'user', content: 'What is the weather today?' }] }); - - const data = spanToJSON(endedSpans[0]!).attributes; - - const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; - expect(inputMessages).toBeDefined(); - expect(JSON.parse(inputMessages!)).toEqual([{ role: 'user', content: 'What is the weather today?' }]); + { messages: [{ role: 'user', content: 'What is the weather today?' }] }, + ); - const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; - expect(responseText).toBeDefined(); - expect(responseText).toContain('The weather is sunny'); + expect(JSON.parse(attributes[GEN_AI_INPUT_MESSAGES] as string)).toEqual([ + { role: 'user', content: 'What is the weather today?' }, + ]); + expect(attributes[GEN_AI_RESPONSE_TEXT]).toContain('The weather is sunny'); }); - it('records an empty messages array on the chat path rather than wrapping it as custom state', async () => { - const endedSpans = setupClient(); - - const compiled = { - invoke: async (_input: { messages: Array<{ role: string; content: string }> }) => ({ - messages: [{ role: 'assistant', content: 'Hello' }], - }), - }; - const stateGraph = { compile: () => compiled }; - - instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - await graph.invoke({ messages: [] }); - - const data = spanToJSON(endedSpans[0]!).attributes; - - const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; - expect(inputMessages).toBeDefined(); - expect(JSON.parse(inputMessages!)).toEqual([]); + it('records an empty messages array as an empty chat array', async () => { + const attributes = await getInvokeAttributes( + async (_input: { messages: unknown[] }) => ({ messages: [{ role: 'assistant', content: 'Hello' }] }), + { messages: [] }, + ); - const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; - expect(responseText).toBeDefined(); - expect(responseText).toContain('Hello'); + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBe('[]'); + expect(attributes[GEN_AI_RESPONSE_TEXT]).toContain('Hello'); }); it('does not record input messages when invoked with null input', async () => { - const endedSpans = setupClient(); - - const compiled = { - invoke: async (_input?: unknown) => ({ messages: [{ role: 'assistant', content: 'resumed' }] }), - }; - const stateGraph = { compile: () => compiled }; - - instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - await expect(graph.invoke(null)).resolves.toBeDefined(); + const attributes = await getInvokeAttributes( + async (_input: null) => ({ messages: [{ role: 'assistant', content: 'resumed' }] }), + null, + ); - const data = spanToJSON(endedSpans[0]!).attributes; - expect(data[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); }); });