unsloth/studio/backend/tests/test_message_content.py
Michael Han faf3343582
Attach long pastes as a text file in Chat (#8472)
* Attach long pastes as a text file in Chat

Pasting a large block of text dumped every line into the composer.
Pastes of 2000+ chars or 40+ lines now become a Pasted_Text_*.txt
attachment; clicking the chip shows the text back in the input.

* Harden the pasted text path from review

- Attach oversized pastes instead of letting them fall back inline
- Match a live File by identity so a user's Pasted_Text_*.txt is untouched
- Skip the text path when the clipboard advertises native images or files
- Drop the inline action if the attachment left the composer while reading

* Name the paste chip after its text and drop the border

- Name the file from the first line of the paste instead of a timestamp
- Chip is borderless, and sits a shade under the composer in dark mode
- Track the attachment id so the chip survives the send without the name

* Make the pasted text path safe at any size

- Mark a sent paste with its own wrapper tag so the chip survives a reload
- Cap the preview and say how much of the paste it is holding back
- Read the name through a window instead of splitting the whole paste
- Attach whitespace-only pastes rather than letting them through inline
- Report an attachment that throws synchronously, instead of losing it

* Keep the sent paste off the render path

- Carry the byte count in the wrapper header and read it in constant time
- Preview sent content by slicing it, instead of unwrapping the whole body
- Drop unwrapAttachmentText, which existed only to copy megabytes

* Title a thread from the paste it was opened with

A paste-only message sends no inline text, so the thread stayed
"New Chat" and the title model got nothing to work from. Titles and
project snippets now fall back to a bounded opening of the attachment.

* import formatBytes from the hub feature index

The deep import into `@/features/hub/lib/format` trips the repo's
`no-restricted-imports` eslint rule, which allows `@/features/[name]` only.
`formatBytes` is already re-exported from `features/hub/index.ts`, and the
built bundle is byte-identical either way.

* share clipboard payload detection between the paste paths

`pasted-text.ts` carried its own copy of the clipboard inspection already in
`clipboard-files.ts`. The two paths run back to back on the same event, so any
drift between them means a paste is handled twice or not at all.

Both now import `clipboard-payload.ts`, which owns `browserClipboardFiles`,
`clipboardHasFileEntries`, `clipboardAdvertisesFiles` and `clipboardHasPlainText`,
plus the private type and file-URI helpers. `pasteClipboardFiles` collapses its
separate `advertisesImage` and `advertisesFile` checks into the one call, since
both uses were the disjunction.

The surviving `clipboardHasLocalFileUri` catches per type rather than abandoning
the scan on the first `getData` that throws, so a readable `text/uri-list` is no
longer hidden by an unreadable sibling type.

Relative imports carry the `.ts` extension: `npm test` runs
`node --experimental-strip-types`, whose ESM resolver does not add it.

* index pasted bodies in chat search

Chat Search builds its text from `message.content` only, so moving a long paste
into an attachment made everything past its first line unfindable. Searching for
a phrase from the middle of a pasted log returned nothing.

`extractText` in `use-chat-search-index.ts` now prepends `attachmentsPastedText`,
which unwraps `<pasted_text ...>` bodies via the new `pastedTextContentBody`.
Only pastes are read: PDF, DOCX and attached text files were never indexed, and
picking them up here would change search behaviour beyond the regression and add
their bodies to an index that already holds 200 threads in memory.

The body goes in whole rather than sampled. That is what the index held before
the paste moved out of `content`, so memory is back where it was.

* queue long pastes while a response is running

`canQueueCurrentPrompt` rejects any composer holding an attachment, so a paste
that used to queue as inline text started failing with "Only text prompts can be
queued". Pasting a log while a response streams is exactly when queueing earns
its keep, and the user was left waiting for the run or pouring the paste back
into the composer by hand.

The queue carries strings, and a pasted attachment is a string the composer
parked in a chip, so `queuePastedTextPrompt` reads those files and folds them
into the queued prompt after whatever was typed. Composers holding a real
attachment still take the existing refusal.

`canQueueCurrentPrompt` keeps its exact conditions; the shared ones move into
`composerAcceptsQueueing` so the pasted-text path cannot drift from them. The
started callback clears only when the text and the attachment ids are still the
ones that were queued, matching the text-only guard it sits beside.

Manual check: start a response, paste 3000 characters, press enter. The chip
clears and the prompt queues with the pasted text; it sends when the run ends.
Repeat with an image attached and the refusal toast still appears.

* keep an unsent paste across a reload

Composer drafts are plain strings in localStorage, and a pasted attachment is a
File held in memory, so refreshing before sending threw the whole paste away.
Inline text survived that reload before this PR moved it into an attachment.

Pastes get their own slot, `chat-draft-pastes:<thread>`, rather than joining the
text draft. Two reasons: the text slot is rewritten 300ms after every keystroke
and a paste can run to megabytes, and a paste large enough to blow the storage
quota now fails on its own key instead of taking the typed draft with it. The
save effect is keyed on the attachment ids, so typing beside a paste does not
touch it.

`createPastedTextFile` keeps the text in a `WeakMap` beside the existing
identity `WeakSet`, which is what lets the save path run without awaiting
`File.text()`. Restore recreates the attachment through the same constructor, so
the chip and its name come back unchanged; names are not stored because a pasted
file is named from its own text.

Restore skips a composer that already holds a paste. The composer outlives a
thread switch, so appending there would stack one thread's paste onto another's;
the stored draft keeps until the composer is clear.

* bound the whole filename scan, not just each line

`firstTextLine` caps how much of a line it copies but stepped over blank lines
one at a time, so a paste of millions of them walked every one before settling
on the fallback name. File creation runs synchronously in the paste handler, so
that walk holds the UI.

The loop now stops after 64 KB. A first line that far down names the file
"Pasted text.txt", which is what an opening of nothing but blank lines was
always going to produce.

* stop a thread switch from dropping a saved paste

Switching from a thread whose composer still held an unsent paste into one with
its own saved paste skipped the restore, since the shared composer already had
an attachment. The restore never retried, and once the outgoing paste cleared,
the save effect wrote the empty composer over the destination's stored paste.

The restore now also depends on the attachment signature, so clearing the
outgoing paste re-runs it. `restoredPasteKeyRef` records which key the restore
finished for, and the save effect writes only for that key, replacing the
one-cycle skip that could not tell "not restored yet" from "restored and now
empty". The key is claimed after `addAttachment` settles, so a save cannot land
between the read and the attachments appearing.

* keep the text restore off the attachment retry

a4ec06e added the attachment signature to the draft restore so a paste could be
retried after the outgoing one cleared, but that effect also calls
`composer.setText(draft)`. Every attachment change then overwrote the live
composer with the last saved snapshot.

"Show in text field" hit this squarely: it inserts the full paste and removes
the chip, and the removal reran the effect well inside the 300ms autosave
window, so the text it had just inserted vanished. Attaching or removing a
paste dropped anything typed since the last save the same way.

The two are now separate effects. Text restore is keyed on the draft key alone,
as it was before a4ec06e. Paste restore keeps the signature dependency and
never touches the composer text.

Manual check: attach a paste, click the chip to show it in the text field, and
the inserted text stays. Type, attach a second paste, and the typing survives.

* carry pasted text through queue, copy and export

Three places still read a message as if all its text were in `content`, so a
paste that crossed the threshold went missing from each.

The Queue button was gated on `canQueueCurrentPrompt`, which is false for any
attachment, and `onQueueClick` returned early on empty composer text. Only the
Enter path got the pasted-text branch in 16d1b72, so a mouse or touch user could
not queue what the keyboard could. Both now go through `queuePastedTextPrompt`.

`CopyButton` copies `getCopyText()`, which reads content alone, so copying a
paste-only turn produced nothing and a turn with an instruction produced only
the instruction. It now appends the pasted bodies.

`messageToText`, `messageToMarkdown` and `messageToPlainText` flatten attachment
text verbatim, so Markdown, CSV, JSONL and fine-tuning exports carried the
`<pasted_text name=... bytes=...>` marker that the same text never had when it
fitted inline. `unwrapPastedTextContent` strips it and returns anything else
untouched, so the `<attachment ...>` wrapper a real file has always exported
with is unchanged.

`attachmentsPastedText` joins with a blank line rather than a space now that
copy uses it; Chat Search collapses whitespace either way.

* unwrap pasted text only where it is an attachment

`messageToPlainText` shares one `collect` between `msg.content` and attachment
content, so b5dc502 ran `unwrapPastedTextContent` over message bodies too. A
message that legitimately opens with `<pasted_text name=...>`, a code sample
about this feature being the obvious case, had its first and last lines stripped
out of fine-tuning exports.

`collect` now takes the source, and only attachment blocks are unwrapped, which
is what `messageToText` and `messageToMarkdown` already did.

* let Deep Research see a pasted question

`create_research_run` rejects a user message whose `content` has no text, and
`_research_question_context` builds the question from `content` alone. A long
paste now lives in an attachment, so a paste-only prompt was refused with
"Deep research requires a user message with non-empty text" and a mixed prompt
quietly researched only the short typed part.

`message_text_with_pastes` in `core/inference/message_content.py` reads a
message's text plus any pasted attachment bodies, with `pasted_text_body`
stripping the `<pasted_text ...>` wrapper the composer adds. Both research paths
use it.

Other attachment types stay out. A PDF or an attached `.txt` was never part of a
message's text, and pulling those into the research question would change what
runs for prompts that have nothing to do with pasting.

* unwrap pasted text in Raw JSONL exports too

b5dc502 stripped the paste wrapper from `messageToText`, `messageToMarkdown` and
`messageToPlainText` but left `messageToOpenAI`, so Raw JSONL and bulk
`jsonl-raw` exports still carried `<pasted_text name=... bytes=...>` and its
closing tag.

It now unwraps text parts that came from attachments, leaving message content
untouched for the same reason as 4011c4a: a body may quote that syntax.

* keep image-edit instructions inline

The generated-image edit overlay submits `composerText`, and its branch in
`handleSubmit` returns early when that is empty. A long edit instruction became
an attachment instead, which left the Send control enabled through
`hasSendableContent` and then did nothing when pressed.

Long pastes are no longer intercepted while the overlay is open, so an edit
instruction stays where the only code path that reads it can find it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* replace the selection a swallowed paste covered

Pasting over selected text normally replaces it. `pasteLongTextAsFile` calls
`preventDefault` and only adds an attachment, so selecting an obsolete prompt
and pasting its replacement left the old text in the composer and sent both.

The paste handler now drops the selected range once the attachment is accepted.

Paste restore also waits on every attachment rather than pasted ones alone. A
composer still holding the previous thread's image passed the old check, so the
destination's paste was restored into a composer that was not yet clear, and
removing that image did not re-run the effect because the signature ignored it.
`composerAttachmentSignature` covers all attachment ids, and the guard is now
"any attachment at all".

* keep a mixed prompt in the order it was written

An attachment is serialised after all inline text, so pasting into the middle of
a prompt reordered it: `before|after` with a paste at the caret sent
`beforeafter` and then the pasted body, turning one sentence into two halves
joined with nothing between them.

Only a paste already heading to the end of the composer becomes an attachment
now. Anything mid-text pastes inline, where the order survives. Recording an
insertion offset and splicing it back at serialisation would put a
paste-specific case into the shared attachment path for every message.

Titles pick up the paste as well. `titleTextOf` returned inline text whenever
there was any, so "summarise this" plus a long paste titled the thread from the
instruction alone, where the same text inline used to reach the title model.
Both are now joined, with the attachment side bounded to 512 characters.

* drop the replaced selection only once the paste is attached

a7b2180 deleted the selected range as soon as `pasteLongTextAsFile` returned,
but that returns synchronously while `addAttachment` is still running. An
attachment that then failed left the user with neither their clipboard nor the
text they had selected to replace.

The deletion moved into the add callback, after the attachment resolves, and it
checks that the composer still holds the text that was pasted into before
splicing.

---------

Co-authored-by: Maheswar Kumar <110882203+mahiatlinux@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-13 05:48:24 -07:00

149 lines
4.9 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for `content_to_text`, the #4383 fix for list-form message content.
Loaded by file path so the test skips importing ``core.inference`` (whose
``__init__`` pulls in the orchestrator + llama_cpp / torch).
"""
import importlib.util
from pathlib import Path
_BACKEND_DIR = Path(__file__).resolve().parent.parent
def _load_message_content():
path = _BACKEND_DIR / "core/inference/message_content.py"
spec = importlib.util.spec_from_file_location("message_content_under_test", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_string_is_returned_unchanged():
mc = _load_message_content()
assert mc.content_to_text("hello world") == "hello world"
assert mc.content_to_text("") == ""
def test_none_becomes_empty_string():
mc = _load_message_content()
assert mc.content_to_text(None) == ""
def test_single_text_part_list():
mc = _load_message_content()
content = [{"type": "text", "text": "hello"}]
assert mc.content_to_text(content) == "hello"
def test_multimodal_list_drops_non_text_parts():
mc = _load_message_content()
content = [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
assert mc.content_to_text(content) == "describe this"
def test_multiple_text_parts_joined_with_newline():
mc = _load_message_content()
content = [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
]
assert mc.content_to_text(content) == "first\nsecond"
def test_bare_string_items_in_list():
mc = _load_message_content()
assert mc.content_to_text(["a", "b"]) == "a\nb"
def test_audio_and_image_only_list_is_empty():
mc = _load_message_content()
content = [
{"type": "image_url", "image_url": {"url": "x"}},
{"type": "input_audio", "input_audio": {"data": "y", "format": "wav"}},
]
assert mc.content_to_text(content) == ""
def test_part_without_type_treated_as_text():
mc = _load_message_content()
# A ``text`` field with no ``type`` is treated as text.
assert mc.content_to_text([{"text": "untyped"}]) == "untyped"
def test_empty_text_parts_skipped():
mc = _load_message_content()
content = [
{"type": "text", "text": ""},
{"type": "text", "text": "kept"},
]
assert mc.content_to_text(content) == "kept"
def test_tuple_behaves_like_list():
mc = _load_message_content()
content = ({"type": "text", "text": "x"}, {"type": "text", "text": "y"})
assert mc.content_to_text(content) == "x\ny"
def test_result_supports_string_ops():
mc = _load_message_content()
# Crux of #4383: result must be a plain str for caller .strip()/.replace().
out = mc.content_to_text([{"type": "text", "text": " padded "}])
assert out.strip() == "padded"
assert isinstance(out, str)
def _pasted(name, body, size):
return f"<pasted_text name={name} bytes={size}>\n{body}\n</pasted_text>"
def test_paste_only_turn_is_not_empty_text():
mc = _load_message_content()
body = "Fix the retry backoff\ndetail"
message = {
"content": [],
"attachments": [{"content": [{"type": "text", "text": _pasted("Fix.txt", body, 28)}]}],
}
# Deep research rejects a message whose text is empty, and a long paste
# carries all of its text in the attachment.
assert mc.message_text_with_pastes(message) == body
def test_typed_text_keeps_the_paste_after_it():
mc = _load_message_content()
message = {
"content": [{"type": "text", "text": "summarise this"}],
"attachments": [{"content": [{"type": "text", "text": _pasted("Log.txt", "line", 4)}]}],
}
assert mc.message_text_with_pastes(message) == "summarise this\n\nline"
def test_other_attachments_are_left_out():
mc = _load_message_content()
message = {
"content": [{"type": "text", "text": "read this"}],
"attachments": [
{"content": [{"type": "text", "text": "[PDF: paper.pdf]\nAbstract"}]},
{"content": [{"type": "text", "text": "<attachment name=n.txt>\nx\n</attachment>"}]},
],
}
assert mc.message_text_with_pastes(message) == "read this"
assert mc.message_text_with_pastes({}) == ""
assert mc.message_text_with_pastes(None) == ""
def test_pasted_body_unwraps_only_the_wrapper():
mc = _load_message_content()
assert mc.pasted_text_body(_pasted("a.txt", "body", 4)) == "body"
# Without the size, and with a body that itself ends in a tag-like line.
assert mc.pasted_text_body("<pasted_text name=a.txt>\nbody") == "body"
assert mc.pasted_text_body("plain text") == ""
assert mc.pasted_text_body("<attachment name=a.txt>\nbody\n</attachment>") == ""
assert mc.pasted_text_body("") == ""