Skip to content

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
CopilotKit:mainfrom
kevin9327:fix/langgraph-empty-reply-reaches-client
Open

End an empty reply on a line the surface receives, not one only the graph sees#292
kevin9327 wants to merge 1 commit into
CopilotKit:mainfrom
kevin9327:fix/langgraph-empty-reply-reaches-client

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

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 Unreleased CHANGELOG 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 withVisibleReply substituted a fallback AIMessage into the graph's state. But this service streams AG-UI by reading the run's framework events, and it only ever emits text from on_chat_model_stream (the model's own deltas) and tool events from the tools node. 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 bare RUN_STARTED / RUN_FINISHED pair with nothing between them.

Proof — driving the real reader over a real graph with a fake empty model:

FINAL STATE last message content: "FALLBACK_LINE_SHOULD_BE_VISIBLE"   ← the graph state had it
WHAT THE CLIENT RECEIVES: ["RUN_FINISHED"]                             ← the wire did not

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.
  • "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 old state-based guard got wrong, because hasVisibleText read any text field as visible and would have suppressed the fallback while streaming nothing.
  • withVisibleReply / hasVisibleText are removed: their substitution could not reach the client, and it encoded a second, divergent definition of "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.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.

  • New state that outlives a request? None. Two booleans local to one streamRun call, gone when the stream closes.
  • What happens on the second replica? No shared state, so identical on every replica: each request is served whole by whichever process holds its stream.
  • Anything serialised? N/A — no cross-process state.
  • Anything fanned out to a browser? Only the AG-UI SSE of the same request, on the same connection the caller opened. Unchanged from before.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway: agent-langgraph runs no tools itself; the loop and its callTool back through the deployment are untouched.
  • New refusals and new failures each write a row: N/A — this process writes no audit rows; a mid-stream failure still ends on RUN_ERROR exactly as before.
  • Nothing new is trusted from the client that the server can resolve itself: unchanged.

Changelog

  • The Unreleased entry "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.ts9 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 on RUN_ERROR.
  • cd agent-langgraph && bun test24 pass / 0 fail (deltas, history, model-options, stream).
  • bunx biome format and bunx biome lint on the three changed files — clean.
  • End-to-end, real @langchain/langgraph graph through streamRun:
    • fake empty model → client receives TEXT_MESSAGE_START/CONTENT/END carrying the fallback, then RUN_FINISHED.
    • fake streaming model returning "Hello there" → client receives the text, no fallback.

CI installs agent-langgraph before test:ci (its history tests already import @langchain/core), so the new suite runs in the same job; it needs no provider key or network.

…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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.

1 participant