Skip to content

Fix two chat-history bugs in gr.ChatInterface and gr.load_chat - #13742

Merged
abidlabs merged 9 commits into
mainfrom
fix/issue-10823-discrepancy-between-how-history-is-treated-for-s
Aug 14, 2026
Merged

Fix two chat-history bugs in gr.ChatInterface and gr.load_chat#13742
abidlabs merged 9 commits into
mainfrom
fix/issue-10823-discrepancy-between-how-history-is-treated-for-s

Conversation

@abidlabs

@abidlabs abidlabs commented Aug 12, 2026

Copy link
Copy Markdown
Member

Fixes #10823
Fixes #11331

1. history is treated differently for streaming and non-streaming chat functions (#10823)

Mutating history inside a gr.ChatInterface chat function changed the conversation for a non-streaming function and was silently dropped for a streaming one. _append_message_to_history() rebinds history to a new list, and the two paths reach it at different points relative to running the user's function:

  • _submit_fn awaits self.fn(...) first, then appends — a mutation inside the fn lands on the list that is then copied, so it survives.
  • _stream_fn creates the generator (whose body has not run yet), appends the user message — rebinding history — and only then advances the generator. The generator mutates the original list, which is no longer the one being yielded, so the mutation is dropped.
non-streaming: [{'role': 'assistant', 'content': 'INJECTED BY FN'},   # note the ordering
                {'role': 'user', 'content': 'hi'},
                {'role': 'assistant', 'content': 'reply'}]
streaming:     [{'role': 'user', 'content': 'hi'},
                {'role': 'assistant', 'content': 'reply'}]

Fix: the chat function gets its own copy of the history (list(history)) in both paths, so mutating it never affects the conversation. This removes the order-dependence rather than relying on it, and matches the documented contract: history is an input, and messages reach the chatbot by being returned or yielded.

Note this also drops the odd non-streaming ordering above, where an injected assistant message landed before the user message that triggered it. For the metadata use case raised in the thread (injecting messages so they persist with save_history=True), the real fix is a supported channel for it — related: #11828.

2. gr.load_chat replays text-encoded files as images (#11331)

format_conversation() inlines a text-encoded file into the prompt on the turn it is sent, but turned every file content part in the history into an image regardless of type:

elif content["type"] == "file":
    new_content.append({"type": "image_url", "image_url": {"url": encode_url_or_file_to_base64(...)}})

So with the default file_types="text_encoded", pasting a large prompt (which becomes a .txt attachment) works on that turn, and then every message after it re-sends the .txt base64-encoded as an image — the payload literally contains "url": "data:text/plain;base64,...". That is the request vllm serve rejects with "model is not multimodal", matching the reported sequence exactly: paste long text (works) → type something short (fails). It also explains why file_types=[] made the error go away: no attachment, so nothing to replay.

Fix: text-encoded files in the history are inlined as text, the same as when first sent. Images still go out as image_url. The extension check and the ## filename\ncontents formatting are now shared between the two paths so they cannot drift again, and the check also excludes http(s) URLs, which Path(...).read_text() could not have handled anyway.

Verification

$ python -m pytest test/test_chat_interface.py test/test_external.py -q
76 passed
  • test_history_mutation_in_fn_is_ignored_consistently (new) fails on main at assert non_streaming_history == expected. It is an async test and pytest-asyncio is not installed in my local env, so pytest cannot collect it here (test_example_caching_lazy, an existing async test in the same file, fails locally for the same reason — both are fine in CI); I ran the body directly under asyncio.run() to confirm.
  • test_format_conversation_replays_text_files_as_text (new) fails on main with assert [{'type': 'im...}] == [{'type': 'te...}], and also pins that a .png in the history is still sent as image_url.
  • The 3 remaining local failures are the pre-existing test_load_chat_* tests, which need openai (not installed in my env).

End-to-end for #11331, against a local stand-in for vllm serve that rejects image content the way a non-multimodal model does:

before:  payload content types: ['image_url', 'text', 'text']
         BadRequestError: 400 - 'Model Qwen/Qwen3-8B is not multimodal, but multimodal input was provided.'
after:   payload content types: ['text', 'text', 'text']
         response: ok

Minimal demos (not committed)

# #10823
import asyncio
import gradio as gr


def echo_nonstreaming(message, history):
    history.append({"role": "assistant", "content": "INJECTED BY FN"})
    return "reply"


def echo_streaming(message, history):
    history.append({"role": "assistant", "content": "INJECTED BY FN"})
    yield "reply"


async def main():
    _, non_streaming = await gr.ChatInterface(echo_nonstreaming)._submit_fn("hi", [])
    streaming = None
    async for _, streaming in gr.ChatInterface(echo_streaming)._stream_fn("hi", []):
        pass
    print("consistent:", non_streaming == streaming)


asyncio.run(main())
# #11331
from pathlib import Path
from gradio.external import format_conversation

pasted = Path("pasted_text.txt")
pasted.write_text("a very long pasted prompt\n" * 50)

history = [  # what the Chatbot holds after a turn where a long prompt was pasted
    {"role": "user", "content": [{"type": "file", "file": {"path": str(pasted)}}]},
    {"role": "assistant", "content": [{"type": "text", "text": "ok"}]},
]

conversation = format_conversation(history, "and now a short one")
print([part["type"] for m in conversation for part in m["content"]])

consistent: FalseTrue, and ['image_url', ...]['text', ...]. The demo files were kept untracked and are not part of this PR.

🤖 Generated with Claude Code

…ions

Mutating `history` inside a `gr.ChatInterface` chat function changed the
conversation for a non-streaming function but was silently dropped for a
streaming one. The two paths copy the history at different points relative to
running the function: `_submit_fn` awaits the function first and then appends
(and copies), while `_stream_fn` appends the user message -- rebinding `history`
to a new list -- before the generator's body has started, so the generator
mutates a list that is no longer the one being yielded.

The chat function now receives its own copy of the history in both paths, so a
mutation there never affects the conversation. `history` is an input: messages
reach the chatbot by being returned or yielded. This also drops the odd
non-streaming result where an injected assistant message was ordered *before*
the user message that triggered it.

Fixes #10823

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gradio-pr-bot

gradio-pr-bot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

🪼 branch checks and previews

Name Status URL
Spaces ready! Spaces preview
Website ready! Website preview
🦄 Changes detected! Details

Install Gradio from this PR

pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/bb0fc95eb2d37796f2ef222747ab0c389e95b65a/gradio-6.24.0-py3-none-any.whl

Install Gradio Python Client from this PR

pip install "gradio-client @ git+https://github.com/gradio-app/gradio@bb0fc95eb2d37796f2ef222747ab0c389e95b65a#subdirectory=client/python"

Import Gradio JS Client from this PR via CDN

import { Client } from "https://huggingface.co/buckets/gradio/npm-previews/resolve/bb0fc95eb2d37796f2ef222747ab0c389e95b65a/browser.js";

@gradio-pr-bot

gradio-pr-bot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

🦄 change detected

This Pull Request includes changes to the following packages.

Package Version
gradio patch

  • Fix two chat-history bugs in gr.ChatInterface and gr.load_chat

Something isn't right?

  • Maintainers can change the version label to modify the version bump.
  • If the bot has failed to detect any changes, or if this pull request needs to update multiple packages to different versions or requires a more comprehensive changelog entry, maintainers can update the changelog file directly.

abidlabs and others added 2 commits August 11, 2026 20:45
`format_conversation()` inlines a text-encoded file into the prompt on the turn
it is sent, but turned every `file` content part in the *history* into
`{"type": "image_url", ...}` regardless of its type. So the turn where a large
pasted prompt became a .txt attachment worked, and every message after it sent
that .txt back base64-encoded as an image -- which is why the reporter's next
short message failed with "model is not multimodal" on `vllm serve`.

Text-encoded files in the history are now inlined as text the same way they are
when first sent; images still go out as `image_url`.

Fixes #11331

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ruff flags a loop control variable that the body does not use; collecting the
stream and asserting on the last chunk says the same thing without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abidlabs abidlabs changed the title Treat history the same way for streaming and non-streaming gr.ChatInterface functions Fix two chat-history bugs in gr.ChatInterface and gr.load_chat Aug 12, 2026
@abidlabs

abidlabs commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Before / after Spaces

  • Before fix — latest released Gradio (6.24.0), all three behaviours present
  • After fix — wheel from this PR at 653e3e383, all three fixed

Both Spaces run byte-identical app.py; only the installed gradio differs, so every difference between the pages is the patch. Each page names which side it is on load by probing behaviour rather than reading the version — the PR wheel carries the same version number as the release it was cut from.

Two plain demos, one per tab.

Tab 1 — #10823. A chat function that appends to the history it was handed, the way you would when assembling a prompt. Send one message.

before after
your message appears in the conversation twice once

Tab 2 — the review point and #11331. A real gr.load_chat pointed at an in-process OpenAI-compatible stub that accepts every content type and answers 200, so nothing depends on a request being rejected. Attach either example, then send any second message so the first turn is replayed out of the history.

before after
the second message dies with Invalid message for Chatbot component completes
assistant replies on screen 1 — the second reply is lost 2
the .txt replayed to the model as image_url, data:text/plain;base64,… text, as first sent

Driven in Chromium on both sides; those rows are what I observed, not what I expect. Worth one correction to my own earlier reading of the review: on the release the text case dies in Chatbot.postprocess too, not just the image case — the rewritten part is an image_url either way, so both attachments break for the same reason. Locally the same pair produced 5 server tracebacks before the patch and 0 after.

The stub, and the reproduction of the review point, follow @hysts's gradio-13742-remaining.

@abidlabs
abidlabs requested review from dawoodkhan82 and hysts August 12, 2026 06:03
abidlabs and others added 2 commits August 11, 2026 23:06
`_history_for_fn()` only wrapped `list()`, so call `list(history)` directly at
both call sites with a short comment instead of a helper and a docstring.

Drop both changeset files: the changeset action generates one from the PR title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abidlabs
abidlabs marked this pull request as ready for review August 12, 2026 06:13

@hysts hysts left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both diagnoses look right and the fixes are in the right places. One thing I would like resolved first, and it is one line.

format_conversation() writes its result back into the history it is handed:

message["content"] = new_content
conversation.append(message)

Up to 5.50.0 it built a new dict and left the input alone, so this looks like a slip in the 6.0 rewrite (029034f):

conversation.append({"role": message["role"], "content": message["content"]})

Those dicts are the ones in chatbot_state, so gr.load_chat edits the conversation every time it replays an attachment. Two symptoms:

  • The case this PR fixes. The request is now correct, but the attachment in the transcript is replaced by the file's contents as text, and save_history stores that version.
  • Images (file_types=["text_encoded", "image"], the value in load_chat's own docstring). Chatbot.postprocess does not accept the substituted part, so the turn dies with ValueError: Invalid message for Chatbot component. Every 6.x release, fine on 5.50.0.

Both are reproducible on the wheel built from this PR: hysts-debug/gradio-13742-remaining. It is a real gr.load_chat pointed at an in-process stub that accepts every content type, so nothing there depends on a request being rejected.

The one-line change is inline. Two smaller notes are also inline, neither blocking.

Comment thread gradio/external.py Outdated
)
else:
new_content.append(content)
message["content"] = new_content

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the write-back. {**message, ...} keeps role, metadata and options, so the request goes out unchanged and the history simply stops being edited:

-        message["content"] = new_content
-        conversation.append(message)
+        conversation.append({**message, "content": new_content})

With that, an image in the history survives Chatbot.postprocess, and in the text case the transcript keeps its attachment instead of turning into the file's contents. test_format_conversation_replays_text_files_as_text still passes, and an assert history == before test would pin the property itself.

Comment thread gradio/external.py Outdated


def _is_text_encoded_file(path: str) -> bool:
return not is_http_url_like(path) and path.lower().endswith(TEXT_FILE_EXTENSIONS)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two unrelated things about this check, neither blocking.

is_http_url_like sends a remote .txt down the image branch, so it goes out as {"image_url": {"url": "data:text/plain;base64,..."}}, which is the shape this PR is removing. Before, it raised in Path(url).read_text() instead. Reachability is low either way, since uploads land as local cache paths.

Separately, path.lower() is compared against a tuple that still contains .R and .Rmd, so neither plot.R nor plot.r can match and R sources take the base64 image path, which is #11331 for a different extension. They are the only non-lowercase entries in TEXT_FILE_EXTENSIONS. That predates this PR, but this helper is now the one place to fix it.

Comment thread gradio/chat_interface.py Outdated
*args,
) -> tuple:
inputs = [message, history] + list(args)
# `list(history)` so that mutating it inside the chat function does not change

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the copy is list-level, so history[0]["content"] = ... still reaches the conversation on both paths. "appending to it" would be exact.

abidlabs and others added 3 commits August 13, 2026 09:20
`format_conversation` wrote `new_content` back into each message, and those
dicts are the ones in `chatbot_state`. So replaying an attachment edited the
transcript: a text file became the file's contents (and `save_history` stored
that), and an image became a substituted part that `Chatbot.postprocess`
rejects with `ValueError: Invalid message for Chatbot component`. Build a new
dict instead, so `role`/`metadata`/`options` still go out unchanged.

Also, in `_is_text_encoded_file`:
- compare against a lowercased copy of `TEXT_FILE_EXTENSIONS`, so the `.R` and
  `.Rmd` entries can actually match (`MultimodalTextbox` accepts `plot.r` and
  `plot.R`, but neither could match an uppercase entry after `path.lower()`,
  so R sources took the base64 image path — #11331 for another extension);
- drop the `is_http_url_like` guard and read remote text files over HTTP in
  `_text_encoded_file_as_prompt`, so a remote `.txt` is inlined as text rather
  than sent as `image_url` with a `data:text/plain;base64,...` payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ty` anchors the `NormalizedMessageDict` diagnostic to the dict literal, not to
the call, so an inline literal cannot be silenced from the call line. Matches the
sibling test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abidlabs

Copy link
Copy Markdown
Member Author

Thanks @hysts for the review and suggestion! It should be fixed now, I've updated the test Spaces to confirm:

Screen.Recording.2026-08-13.at.10.40.03.AM.mov

@hysts hysts left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update @abidlabs ! LGTM!

@abidlabs

Copy link
Copy Markdown
Member Author

Thanks for confirming @hysts!

@abidlabs
abidlabs merged commit 3f52f17 into main Aug 14, 2026
26 checks passed
@abidlabs
abidlabs deleted the fix/issue-10823-discrepancy-between-how-history-is-treated-for-s branch August 14, 2026 00:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants