import type { AIMessageChunk } from '@langchain/core/messages'; import type { ChatOpenAIReasoningSummary } from '@langchain/openai'; import { getChunkContent, createContentAggregator } from './stream'; import type { ToolCallContent, ToolCallPart } from '@/types/stream'; import type { PartMetadata } from '@/types/graph'; import { Providers, GraphEvents, StepTypes } from '@/common'; describe('getChunkContent', () => { it('should handle reasoning content for OpenAI/Azure providers', () => { const chunk: Partial = { content: 'Regular content', additional_kwargs: { reasoning: { summary: [{ text: 'Reasoning summary text' }], } as Partial, }, }; const result = getChunkContent({ chunk, provider: Providers.OPENAI, reasoningKey: 'reasoning', }); expect(result).toBe('Reasoning summary text'); }); it('should fallback to reasoningKey when no OpenAI reasoning summary', () => { const chunk: Partial = { content: 'Regular content', additional_kwargs: { reasoning_content: 'Reasoning from key', }, }; const result = getChunkContent({ chunk, reasoningKey: 'reasoning_content', }); expect(result).toBe('Reasoning from key'); }); it('should fallback to chunk.content when reasoningKey value is null or undefined', () => { const chunk: Partial = { content: 'Fallback content', additional_kwargs: { reasoning_content: null, }, }; const result = getChunkContent({ chunk, reasoningKey: 'reasoning_content', }); expect(result).toBe('Fallback content'); }); it('should fallback to chunk.content when reasoningKey value is empty string', () => { const chunk: Partial = { content: ' can', additional_kwargs: { reasoning_content: '', }, }; const result = getChunkContent({ chunk, reasoningKey: 'reasoning_content', }); expect(result).toBe(' can'); }); it('should return undefined when no content is available', () => { const chunk: Partial = { additional_kwargs: {}, }; const result = getChunkContent({ chunk, reasoningKey: 'reasoning', }); expect(result).toBeUndefined(); }); it('should handle missing chunk gracefully', () => { const result = getChunkContent({ reasoningKey: 'reasoning', }); expect(result).toBeUndefined(); }); }); describe('createContentAggregator — tool call delta streaming', () => { it('should allow delta updates when content part already has a name', () => { const { contentParts, aggregateContent } = createContentAggregator(); // Step 1: ON_RUN_STEP creates the initial tool call with name aggregateContent({ event: GraphEvents.ON_RUN_STEP, data: { id: 'step-1', index: 0, type: StepTypes.TOOL_CALLS, stepDetails: { type: StepTypes.TOOL_CALLS, tool_calls: [ { id: 'tc-1', name: 'content_tool', args: '{"action":"write"' }, ], }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); // Verify initial content part was created with tool name expect(contentParts[0]).toBeDefined(); expect((contentParts[0] as ToolCallContent).tool_call?.name).toBe( 'content_tool' ); const initialArgs = (contentParts[0] as ToolCallContent).tool_call?.args; // Step 2: ON_RUN_STEP_DELTA — should UPDATE args (not be dropped) aggregateContent({ event: GraphEvents.ON_RUN_STEP_DELTA, data: { id: 'step-1', delta: { type: StepTypes.TOOL_CALLS, tool_calls: [{ args: ',"content":"hello world"', name: undefined }], }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); // Verify args were UPDATED (delta was not dropped) const updatedArgs = (contentParts[0] as ToolCallContent).tool_call?.args; expect(updatedArgs).not.toBe(initialArgs); expect(updatedArgs).toContain('content'); expect(updatedArgs).toContain('hello world'); }); it('should still drop delta updates when no content part exists yet', () => { const { contentParts, aggregateContent } = createContentAggregator(); // Simulate a delta arriving BEFORE the initial ON_RUN_STEP // This creates an entry at index 0 via the delta but with no name aggregateContent({ event: GraphEvents.ON_RUN_STEP, data: { id: 'step-orphan', index: 5, type: StepTypes.TOOL_CALLS, stepDetails: { type: StepTypes.TOOL_CALLS, tool_calls: [], }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); // Delta for an index that has no content part (index 5 has no tool call) aggregateContent({ event: GraphEvents.ON_RUN_STEP_DELTA, data: { id: 'step-orphan', delta: { type: StepTypes.TOOL_CALLS, tool_calls: [{ args: '{"some":"data"}', name: undefined }], }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); // contentParts[5] should remain undefined or have no valid tool call name const part = contentParts[5] as ToolCallContent | undefined; // The part either doesn't exist or has empty name — delta should have been dropped if (part?.tool_call != null) { expect(part.tool_call.name).toBe(''); } }); it('should handle the full lifecycle: ON_RUN_STEP → deltas → ON_RUN_STEP_COMPLETED', () => { const { contentParts, aggregateContent } = createContentAggregator(); // Step 1: Initial tool call aggregateContent({ event: GraphEvents.ON_RUN_STEP, data: { id: 'step-write', index: 0, type: StepTypes.TOOL_CALLS, stepDetails: { type: StepTypes.TOOL_CALLS, tool_calls: [ { id: 'tc-write', name: 'content_tool', args: '{"action":"write"' }, ], }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); // Step 2: Multiple delta updates growing the args aggregateContent({ event: GraphEvents.ON_RUN_STEP_DELTA, data: { id: 'step-write', delta: { type: StepTypes.TOOL_CALLS, tool_calls: [{ args: ',"name":"Dashboard.tsx"', name: undefined }], }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); aggregateContent({ event: GraphEvents.ON_RUN_STEP_DELTA, data: { id: 'step-write', delta: { type: StepTypes.TOOL_CALLS, tool_calls: [{ args: ',"content":"import React', name: undefined }], }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); // Verify args accumulated progressively const midArgs = (contentParts[0] as ToolCallContent).tool_call?.args; expect(midArgs).toContain('Dashboard.tsx'); expect(midArgs).toContain('import React'); // Step 3: Completion with full output aggregateContent({ event: GraphEvents.ON_RUN_STEP_COMPLETED, data: { result: { id: 'step-write', index: 0, type: 'tool_call', tool_call: { id: 'tc-write', name: 'content_tool', args: '{"action":"write","name":"Dashboard.tsx","content":"import React..."}', output: 'Stored "Dashboard.tsx" (content_id: abc). 176 lines, 5000 chars.', progress: 1, }, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }); // Final content part should have output and progress=1 const final = (contentParts[0] as ToolCallContent) .tool_call as ToolCallPart & PartMetadata; expect(final.output).toContain('Stored'); expect(final.progress).toBe(1); expect(final.name).toBe('content_tool'); }); });