Skip to content

fix(langgraph): don't re-emit TOOL_CALL_START on HITL resume#2166

Open
NathanTarbert wants to merge 1 commit into
mainfrom
fix/langgraph-ts-resume-tool-call-dedup
Open

fix(langgraph): don't re-emit TOOL_CALL_START on HITL resume#2166
NathanTarbert wants to merge 1 commit into
mainfrom
fix/langgraph-ts-resume-tool-call-dedup

Conversation

@NathanTarbert

Copy link
Copy Markdown
Contributor

Fixes #2014

Problem

When a run resumes after a HITL interrupt, LangGraphAgent.handleStreamEvents resets this.emittedToolCallStartIds to a fresh Set(). OnToolEnd's synthetic TOOL_CALL_START + TOOL_CALL_ARGS + TOOL_CALL_END triple is gated only on that per-run Set, so on a resume (a second run() on the same thread) the Set is empty, the guard passes, and the tool call the frontend already received in the prior run is re-announced.

Visible symptoms:

  • Parent follow-up text from the resumed run streams in, then disappears when MESSAGES_SNAPSHOT arrives.
  • The tool call reverts to a loading / incomplete state.

Fix

Dedupe against the durable thread history already available in this.messages (set by AbstractAgent.run() from input.messages). On a HITL resume the client replays the full conversation, so the originating assistant message — carrying the tool call's id in its toolCalls — is present. A new same-run tool call is unaffected because OnChatModelStream has already added its id to emittedToolCallStartIds, so the existing gate fires first; the history lookup only matters in the resume gap.

The guard is applied to both synthetic-emit paths in OnToolEnd (the update.messages Command path and the single-message path). TOOL_CALL_RESULT still fires normally in both.

This mirrors the approach the reporter validated locally on 0.0.36. (A cleaner long-term option is reading the authoritative checkpoint via client.threads.getState, but the this.messages lookup is sufficient for the full-history-replay / CopilotKit case.)

Tests

Added resume-tool-call-dedup.test.ts (3 cases):

  • resume, single path → no TOOL_CALL_START/ARGS/END, TOOL_CALL_RESULT still fires;
  • resume, Command/update.messages path → same;
  • fresh tool call (empty history) → START/ARGS/END still emitted (regression guard).

TDD-verified: the two resume cases fail against the unpatched OnToolEnd and pass with the fix. The tool-call-adjacent suites (predict-state-e2e, messages-tuple, interrupts) stay green (62 passing together).

Note: 3 pre-existing failures in interrupt-outcome-resume-roundtrip / a2ui-tool / legacy-resume-lifecycle reproduce on a clean main in this workspace (local @ag-ui/client version skew) and are unrelated to this change.

OnToolEnd's synthetic TOOL_CALL_START/ARGS/END triple was gated only on the
per-run emittedToolCallStartIds Set, which handleStreamEvents resets to a fresh
Set on every run(). On a HITL resume (a second run() on the same thread) the Set
is empty, so the guard passes and the tool call the frontend already received in
the prior run is re-announced. Symptom: parent follow-up text streams in, then
disappears when MESSAGES_SNAPSHOT arrives, and the tool call reverts to a loading
state.

Dedupe against the durable thread history in this.messages (the full
conversation the client replays on resume) instead: if the originating assistant
message for the tool call is already present, skip the synthetic triple in both
the update.messages path and the single-message path. TOOL_CALL_RESULT still
fires normally.

Fixes #2014
@NathanTarbert
NathanTarbert requested a review from a team as a code owner July 11, 2026 22:07
@github-actions

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1783807674 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1783807674' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1783807674' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1783807674' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1783807674' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1783807674' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1783807674' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1783807674

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 5030466

@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2166

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2166

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2166

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2166

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2166

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2166

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2166

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2166

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2166

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2166

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2166

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2166

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2166

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2166

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2166

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2166

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2166

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2166

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2166

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2166

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2166

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2166

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2166

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2166

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2166

commit: 7b3d2c7

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: @ag-ui/langgraph OnToolEnd re-synthesizes TOOL_CALL_START/ARGS/END on HITL resume — emittedToolCallStartIds is per-run, not durable

1 participant