diff --git a/integrations/langgraph/typescript/src/agent.ts b/integrations/langgraph/typescript/src/agent.ts index 02b2e374b7..47c7cb5672 100644 --- a/integrations/langgraph/typescript/src/agent.ts +++ b/integrations/langgraph/typescript/src/agent.ts @@ -1354,7 +1354,7 @@ export class LangGraphAgent extends AbstractAgent { predictStateTool.tool === toolCallData?.name, ); - const isToolCallStartEvent = !hasCurrentStream && toolCallData?.name; + let isToolCallStartEvent = !hasCurrentStream && toolCallData?.name; const isToolCallArgsEvent = hasCurrentStream && currentStream?.toolCallId && toolCallData?.args; const isToolCallEndEvent = @@ -1443,7 +1443,14 @@ export class LangGraphAgent extends AbstractAgent { if (resolved) { this.messagesInProcess[this.activeRun!.id] = null; } - break; + // The chunk that ends streamed text can also open a tool call: Anthropic + // streams the `tool_use` block of a message right after its text block. + // Fall through to the tool-call start below instead of dropping the call + // and every argument chunk that follows it (the Python adapter already + // handles this transition). + if (!toolCallData?.name) break; + isToolCallStartEvent = true; + this.activeRun!.hasFunctionStreaming = true; } if (isToolCallStartEvent && shouldEmitToolCalls) { diff --git a/integrations/langgraph/typescript/src/text-then-tool-call.test.ts b/integrations/langgraph/typescript/src/text-then-tool-call.test.ts new file mode 100644 index 0000000000..2cfe396b20 --- /dev/null +++ b/integrations/langgraph/typescript/src/text-then-tool-call.test.ts @@ -0,0 +1,108 @@ +import { EventType } from "@ag-ui/client"; +import { describe, expect, it } from "vitest"; + +import { LangGraphAgent } from "./agent"; + +function createAgent() { + const agent = new LangGraphAgent({ + graphId: "test-graph", + deploymentUrl: "http://localhost:8000", + }); + const events: any[] = []; + (agent as any).subscriber = { next: (e: any) => events.push(e) }; + (agent as any).activeRun = { + id: "run-1", + threadId: "thread-1", + hasFunctionStreaming: false, + }; + (agent as any).messagesInProcess = {}; + (agent as any).emittedToolCallStartIds = new Set(); + return { agent, events }; +} + +function streamChunk(content: unknown[], toolCallChunks: unknown[] = []) { + return { + event: "on_chat_model_stream", + metadata: { "emit-messages": true, "emit-tool-calls": true }, + data: { + chunk: { + id: "msg-1", + content, + tool_call_chunks: toolCallChunks, + response_metadata: {}, + }, + }, + }; +} + +describe("LangGraphAgent text followed by a tool call in one message", () => { + it("starts the tool call carried by the chunk that ends the streamed text", () => { + const { agent, events } = createAgent(); + + // Anthropic content-block streaming: a text block, then a tool_use block + // with empty args, then input_json_delta chunks, then the end chunk. + const chunks = [ + streamChunk([ + { type: "text", text: "Building the dashboard.", index: 0 }, + ]), + streamChunk( + [ + { + type: "tool_use", + id: "call-1", + name: "render_ui", + input: {}, + index: 1, + }, + ], + [ + { + name: "render_ui", + args: "", + id: "call-1", + index: 1, + type: "tool_call_chunk", + }, + ], + ), + streamChunk( + [ + { + type: "input_json_delta", + partial_json: '{"surfaceId":"x"}', + index: 1, + }, + ], + [ + { + name: null, + args: '{"surfaceId":"x"}', + id: null, + index: 1, + type: "tool_call_chunk", + }, + ], + ), + streamChunk([]), + ]; + for (const chunk of chunks) agent.handleSingleEvent(chunk); + + expect(events.map((e) => e.type)).toEqual([ + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + ]); + expect(events[3]).toMatchObject({ + toolCallId: "call-1", + toolCallName: "render_ui", + }); + expect(events[4]).toMatchObject({ + toolCallId: "call-1", + delta: '{"surfaceId":"x"}', + }); + expect((agent as any).activeRun.hasFunctionStreaming).toBe(true); + }); +});