Fix two chat-history bugs in gr.ChatInterface and gr.load_chat - #13742
Conversation
…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>
🪼 branch checks and previews
Install Gradio from this PR pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/bb0fc95eb2d37796f2ef222747ab0c389e95b65a/gradio-6.24.0-py3-none-any.whlInstall 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"; |
🦄 change detectedThis Pull Request includes changes to the following packages.
|
`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>
history the same way for streaming and non-streaming gr.ChatInterface functionsgr.ChatInterface and gr.load_chat
Before / after Spaces
Both Spaces run byte-identical Two plain demos, one per tab. Tab 1 — #10823. A chat function that appends to the
Tab 2 — the review point and #11331. A real
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 The stub, and the reproduction of the review point, follow @hysts's gradio-13742-remaining. |
`_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>
hysts
left a comment
There was a problem hiding this comment.
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_historystores that version. - Images (
file_types=["text_encoded", "image"], the value inload_chat's own docstring).Chatbot.postprocessdoes not accept the substituted part, so the turn dies withValueError: 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.
| ) | ||
| else: | ||
| new_content.append(content) | ||
| message["content"] = new_content |
There was a problem hiding this comment.
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.
|
|
||
|
|
||
| def _is_text_encoded_file(path: str) -> bool: | ||
| return not is_http_url_like(path) and path.lower().endswith(TEXT_FILE_EXTENSIONS) |
There was a problem hiding this comment.
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.
| *args, | ||
| ) -> tuple: | ||
| inputs = [message, history] + list(args) | ||
| # `list(history)` so that mutating it inside the chat function does not change |
There was a problem hiding this comment.
Nit: the copy is list-level, so history[0]["content"] = ... still reaches the conversation on both paths. "appending to it" would be exact.
…ory-is-treated-for-s
`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>
|
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 |
…epancy-between-how-history-is-treated-for-s
|
Thanks for confirming @hysts! |
Fixes #10823
Fixes #11331
1.
historyis treated differently for streaming and non-streaming chat functions (#10823)Mutating
historyinside agr.ChatInterfacechat function changed the conversation for a non-streaming function and was silently dropped for a streaming one._append_message_to_history()rebindshistoryto a new list, and the two paths reach it at different points relative to running the user's function:_submit_fnawaitsself.fn(...)first, then appends — a mutation inside the fn lands on the list that is then copied, so it survives._stream_fncreates the generator (whose body has not run yet), appends the user message — rebindinghistory— 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.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:historyis 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_chatreplays text-encoded files as images (#11331)format_conversation()inlines a text-encoded file into the prompt on the turn it is sent, but turned everyfilecontent part in the history into an image regardless of type:So with the default
file_types="text_encoded", pasting a large prompt (which becomes a.txtattachment) works on that turn, and then every message after it re-sends the.txtbase64-encoded as an image — the payload literally contains"url": "data:text/plain;base64,...". That is the requestvllm serverejects with "model is not multimodal", matching the reported sequence exactly: paste long text (works) → type something short (fails). It also explains whyfile_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\ncontentsformatting are now shared between the two paths so they cannot drift again, and the check also excludes http(s) URLs, whichPath(...).read_text()could not have handled anyway.Verification
test_history_mutation_in_fn_is_ignored_consistently(new) fails onmainatassert non_streaming_history == expected. It is anasynctest andpytest-asynciois 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 underasyncio.run()to confirm.test_format_conversation_replays_text_files_as_text(new) fails onmainwithassert [{'type': 'im...}] == [{'type': 'te...}], and also pins that a.pngin the history is still sent asimage_url.test_load_chat_*tests, which needopenai(not installed in my env).End-to-end for #11331, against a local stand-in for
vllm servethat rejects image content the way a non-multimodal model does:Minimal demos (not committed)
consistent: False→True, and['image_url', ...]→['text', ...]. The demo files were kept untracked and are not part of this PR.🤖 Generated with Claude Code