End an empty reply on a line the surface receives, not one only the graph sees - #292
Open
kevin9327 wants to merge 1 commit into
Open
End an empty reply on a line the surface receives, not one only the graph sees#292kevin9327 wants to merge 1 commit into
kevin9327 wants to merge 1 commit into
Conversation
…raph sees A reply with no text and no tool call ends the graph — the conditional edge sees no calls and stops. CopilotKit#289 tried to give that run a visible line by substituting a fallback AIMessage into the graph's state (withVisibleReply), so the CHANGELOG already promises "an empty reply ends on a visible line rather than in silence". It never reached the person. This service streams AG-UI by reading the run's framework events, and it emits text only from on_chat_model_stream (the model's own deltas) and tool events from the tools node. A message a graph node returns is never one of those, so a fallback placed in state is a fallback the surface never sees: an empty reply still ended on a bare RUN_STARTED/ RUN_FINISHED pair with nothing between them. Proof, driving the real reader over a real graph with a fake empty model: the graph's final state held the fallback, and the client received only ["RUN_FINISHED"]. The guard belongs on the wire, where the surface reads. streamRun now tracks whether anything a person can see reached it — a line of prose or a tool call — and ends a run that produced neither on the same fallback line. "Visible" is decided by textOfChunk, the one rule the streamed deltas already use, so a reply that is only a Responses-API reasoning summary (text the person is never shown) counts as empty here too — the case the state-based guard got wrong, since hasVisibleText read any `text` field as visible. The translation is extracted to stream.ts for the same reason history.ts and deltas.ts are: index.ts calls serve() at module scope, so importing runAgent to test it binds a port. stream.ts imports no runtime module beyond deltas, so its tests need no provider key and no network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kevin9327
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 30, 2026 06:10
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
A LangGraph Bot's reply with no text and no tool call ends the run in silence — the person's message sits there with no answer and no reason. #289 set out to end that on a visible line, and the
UnreleasedCHANGELOG already promises it ("an empty reply ends on a visible line rather than in silence"). The line never reached anyone, and this makes it actually arrive.The empty-reply guard
withVisibleReplysubstituted a fallbackAIMessageinto the graph's state. But this service streams AG-UI by reading the run's framework events, and it only ever emits text fromon_chat_model_stream(the model's own deltas) and tool events from thetoolsnode. A message a graph node returns is none of those, so a fallback placed in state is one the surface never reads. The empty reply still ended on a bareRUN_STARTED/RUN_FINISHEDpair with nothing between them.Proof — driving the real reader over a real graph with a fake empty model:
The fix moves the guard onto the wire, where the surface reads:
streamRun(the event→AG-UI translation, newly extracted) now tracks whether anything a person can see reached it — a line of prose, or a tool call — and ends a run that produced neither on the same fallback line.textOfChunk, the one rule the streamed deltas already use. So a reply that is only a Responses-API reasoning summary (text the person is never shown) counts as empty here too — the case the old state-based guard got wrong, becausehasVisibleTextread anytextfield as visible and would have suppressed the fallback while streaming nothing.withVisibleReply/hasVisibleTextare removed: their substitution could not reach the client, and it encoded a second, divergent definition of "visible".The translation is extracted to
stream.tsfor the same reasonhistory.tsanddeltas.tsare:index.tscallsserve()at module scope, so importingrunAgentto test it binds a port.stream.tsimports no runtime module beyonddeltas.ts, so its tests need no provider key and no network.Where it runs
This is the managed LangGraph Bot process (
agent-langgraph), which answers one AG-UI request per HTTP stream. The change is entirely within the lifetime of a single request/response stream.streamRuncall, gone when the stream closes.Boundary and audit
agent-langgraphruns no tools itself; the loop and itscallToolback through the deployment are untouched.RUN_ERRORexactly as before.Changelog
Unreleasedentry "An empty model reply, or a run with no question, no longer ends in silence" already documents this exact user-facing outcome — it describes the behaviour Refuse a shell the database, show a screen that ended, and unblock a taken-over sign-in #289 intended but did not deliver on the wire. This change makes the shipped code match that entry, so a released deployment behaves as the entry already promises; no new line is added to avoid a second, contradictory entry about the same outcome.Proof
Verified locally (bun 1.3.14):
bun test agent-langgraph/tests/stream.test.ts— 9 pass / 0 fail. New suite: empty reply → fallback delivered; reasoning-only reply → counts as empty → fallback; streamed prose and both tool kinds → forwarded, no fallback; multi-turn message ids advance; mid-stream failure closes the open message and ends onRUN_ERROR.cd agent-langgraph && bun test— 24 pass / 0 fail (deltas, history, model-options, stream).bunx biome formatandbunx biome linton the three changed files — clean.@langchain/langgraphgraph throughstreamRun:TEXT_MESSAGE_START/CONTENT/ENDcarrying the fallback, thenRUN_FINISHED.CI installs
agent-langgraphbeforetest:ci(its history tests already import@langchain/core), so the new suite runs in the same job; it needs no provider key or network.