v2.14.0 (#743)
Co-authored-by: Alexis Tacnet <alexis@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com> Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com> Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
ad0d5c9520
commit
3f8487f761
197 changed files with 10819 additions and 2830 deletions
64
tests/cli/textual_ui/test_chat_input_keybindings.py
Normal file
64
tests/cli/textual_ui/test_chat_input_keybindings.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_backspace_deletes_character_like_backspace() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
await pilot.press("a", "b", "c")
|
||||
await pilot.pause(0.1)
|
||||
assert app.query_one(ChatInputContainer).value == "abc"
|
||||
|
||||
await pilot.press("shift+backspace")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.query_one(ChatInputContainer).value == "ab"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_delete_deletes_character_like_delete() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
await pilot.press("a", "b", "c", "left")
|
||||
await pilot.pause(0.1)
|
||||
assert app.query_one(ChatInputContainer).value == "abc"
|
||||
|
||||
await pilot.press("shift+delete")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.query_one(ChatInputContainer).value == "ab"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_backspace_resets_mode_when_empty() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.set_mode("!")
|
||||
await pilot.pause(0.1)
|
||||
assert text_area.input_mode == "!"
|
||||
|
||||
await pilot.press("shift+backspace")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.input_mode == ">"
|
||||
149
tests/cli/textual_ui/test_image_input_validation.py
Normal file
149
tests/cli/textual_ui/test_image_input_validation.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.textual_ui.app import _ImageAttachmentRejection
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
|
||||
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.types import (
|
||||
MAX_IMAGE_BYTES,
|
||||
MAX_IMAGES_PER_MESSAGE,
|
||||
Backend,
|
||||
ImageAttachment,
|
||||
)
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
|
||||
|
||||
def _vision_config(*, supports_images: bool = True):
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest",
|
||||
provider="mistral",
|
||||
alias="devstral-latest",
|
||||
supports_images=supports_images,
|
||||
)
|
||||
]
|
||||
providers = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
return build_test_vibe_config(
|
||||
active_model="devstral-latest", models=models, providers=providers
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_happy_path(tmp_path: Path) -> None:
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
|
||||
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], ImageAttachment)
|
||||
assert result[0].alias == "shot.png"
|
||||
assert result[0].mime_type == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_returns_empty_when_no_images(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "notes.md").write_text("hi")
|
||||
payload = build_path_prompt_payload("read @notes.md", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_rejects_too_many_images(tmp_path: Path) -> None:
|
||||
mentions = []
|
||||
for i in range(MAX_IMAGES_PER_MESSAGE + 1):
|
||||
name = f"img{i}.png"
|
||||
(tmp_path / name).write_bytes(PNG_BYTES)
|
||||
mentions.append(f"@{name}")
|
||||
payload = build_path_prompt_payload(" ".join(mentions), base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, _ImageAttachmentRejection)
|
||||
assert not result.no_vision
|
||||
assert "Too many image attachments" in result.message
|
||||
assert str(MAX_IMAGES_PER_MESSAGE) in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_rejects_non_vision_model(tmp_path: Path) -> None:
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
|
||||
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config(supports_images=False))
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, _ImageAttachmentRejection)
|
||||
assert result.no_vision
|
||||
assert "does not support images" in result.message
|
||||
assert "devstral-latest" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_rejects_oversize_image(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Patch the cap down so we don't need to write 10MB to disk.
|
||||
monkeypatch.setattr("vibe.cli.textual_ui.app.MAX_IMAGE_BYTES", 32)
|
||||
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES + b"\x00" * 64)
|
||||
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, _ImageAttachmentRejection)
|
||||
assert not result.no_vision
|
||||
assert "shot.png" in result.message
|
||||
assert "max" in result.message.lower()
|
||||
|
||||
|
||||
def test_max_image_bytes_default_is_10_mib() -> None:
|
||||
assert MAX_IMAGE_BYTES == 10 * 1024 * 1024
|
||||
|
||||
|
||||
def test_max_images_per_message_default_is_8() -> None:
|
||||
assert MAX_IMAGES_PER_MESSAGE == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_restores_input_when_image_validation_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
|
||||
app = build_test_vibe_app(config=_vision_config(supports_images=False))
|
||||
typed = f"look at @{tmp_path / 'shot.png'}"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInputContainer)
|
||||
chat_input.value = typed
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == typed
|
||||
errors = list(app.query(ErrorMessage))
|
||||
assert errors
|
||||
assert all(not e._show_border for e in errors)
|
||||
221
tests/cli/textual_ui/test_paste_path.py
Normal file
221
tests/cli/textual_ui/test_paste_path.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.chat_input.paste_path import (
|
||||
maybe_prepend_at_for_image_path,
|
||||
rewrite_bare_image_paths_in_text,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
|
||||
|
||||
def test_bare_absolute_image_path_gets_at_prefix(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(str(img))
|
||||
|
||||
assert rewritten == f"@{img}"
|
||||
|
||||
|
||||
def test_quoted_image_path_with_spaces_is_unwrapped_and_quoted(tmp_path: Path) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
pasted = f"'{img}'"
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(pasted)
|
||||
|
||||
assert rewritten == f"@'{img}'"
|
||||
|
||||
|
||||
def test_backslash_escaped_image_path_is_unescaped_and_quoted(tmp_path: Path) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
pasted = str(img).replace(" ", "\\ ")
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(pasted)
|
||||
|
||||
assert rewritten == f"@'{img}'"
|
||||
|
||||
|
||||
def test_non_image_file_path_is_left_untouched(tmp_path: Path) -> None:
|
||||
txt = tmp_path / "readme.md"
|
||||
txt.write_text("hi")
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(str(txt))
|
||||
|
||||
assert rewritten == str(txt)
|
||||
|
||||
|
||||
def test_missing_image_path_is_left_untouched(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "nope.png"
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(str(missing))
|
||||
|
||||
assert rewritten == str(missing)
|
||||
|
||||
|
||||
def test_unresolvable_tilde_user_does_not_crash() -> None:
|
||||
# `~a` raises RuntimeError from Path.expanduser() when user `a` does not
|
||||
# exist; the rewrite hook must swallow it so every keystroke after `~`
|
||||
# does not crash the TUI.
|
||||
assert maybe_prepend_at_for_image_path("~a") == "~a"
|
||||
assert rewrite_bare_image_paths_in_text("hello ~a world") == "hello ~a world"
|
||||
|
||||
|
||||
def test_multiline_paste_is_left_untouched(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
pasted = f"{img}\nother line"
|
||||
|
||||
assert maybe_prepend_at_for_image_path(pasted) == pasted
|
||||
|
||||
|
||||
def test_relative_path_is_left_untouched(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "shot.png").write_bytes(b"\x89PNG")
|
||||
|
||||
assert maybe_prepend_at_for_image_path("shot.png") == "shot.png"
|
||||
|
||||
|
||||
def test_already_at_prefixed_path_is_left_untouched(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
pasted = f"@{img}"
|
||||
assert maybe_prepend_at_for_image_path(pasted) == pasted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paste_event_inserts_at_prefixed_path_into_chat_input(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.post_message(events.Paste(text=str(img)))
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == f"@{img}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paste_event_leaves_non_image_paths_untouched(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
txt = tmp_path / "notes.md"
|
||||
txt.write_text("hi")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.post_message(events.Paste(text=str(txt)))
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == str(txt)
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_handles_bare_path(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
rewritten = rewrite_bare_image_paths_in_text(f"look at {img} please")
|
||||
|
||||
assert rewritten == f"look at @{img} please"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_handles_quoted_path(tmp_path: Path) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
rewritten = rewrite_bare_image_paths_in_text(f"look at '{img}'")
|
||||
|
||||
assert rewritten == f"look at @'{img}'"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_is_idempotent(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
once = rewrite_bare_image_paths_in_text(f"see {img}")
|
||||
twice = rewrite_bare_image_paths_in_text(once)
|
||||
|
||||
assert once == twice == f"see @{img}"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_skips_non_image(tmp_path: Path) -> None:
|
||||
txt = tmp_path / "notes.md"
|
||||
txt.write_text("hi")
|
||||
|
||||
rewritten = rewrite_bare_image_paths_in_text(f"see {txt}")
|
||||
|
||||
assert rewritten == f"see {txt}"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_fast_path_skips_stat_for_plain_text(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
calls = 0
|
||||
original = _Path.is_file
|
||||
|
||||
def _counting_is_file(self):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(self)
|
||||
|
||||
monkeypatch.setattr(_Path, "is_file", _counting_is_file)
|
||||
|
||||
rewrite_bare_image_paths_in_text("hello world, nothing path-shaped here")
|
||||
rewrite_bare_image_paths_in_text("multi\nline\ntext\nwith no slash")
|
||||
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_change_hook_rewrites_quoted_image_path(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
# Simulate a non-bracketed-paste insertion (terminal that does
|
||||
# not emit Paste): set the text directly the way a bulk insert
|
||||
# would land it.
|
||||
text_area.text = f"'{img}'"
|
||||
await pilot.pause()
|
||||
|
||||
# No spaces in the path -> the scanner emits an unquoted `@<path>`.
|
||||
assert chat_input.value == f"@{img}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_change_hook_rewrites_quoted_image_path_with_spaces(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.text = f"'{img}'"
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == f"@'{img}'"
|
||||
18
tests/cli/textual_ui/test_read_widget.py
Normal file
18
tests/cli/textual_ui/test_read_widget.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import _strip_line_numbers
|
||||
|
||||
|
||||
def test_strips_numbered_prefixes() -> None:
|
||||
content = " 1→first\n 42→second\n 100→third"
|
||||
assert _strip_line_numbers(content) == "first\nsecond\nthird"
|
||||
|
||||
|
||||
def test_leaves_warning_lines_untouched() -> None:
|
||||
content = "<vibe_warning>Warning: the file exists but the contents are empty.</vibe_warning>"
|
||||
assert _strip_line_numbers(content) == content
|
||||
|
||||
|
||||
def test_preserves_arrows_inside_content() -> None:
|
||||
content = " 1→a → b → c"
|
||||
assert _strip_line_numbers(content) == "a → b → c"
|
||||
78
tests/cli/textual_ui/test_user_message_attachments.py
Normal file
78
tests/cli/textual_ui/test_user_message_attachments.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||
from vibe.cli.textual_ui.windowing.history import build_history_widgets
|
||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
||||
|
||||
|
||||
def _att(path: Path, alias: str) -> ImageAttachment:
|
||||
path.write_bytes(b"\x89PNG")
|
||||
return ImageAttachment(path=path, alias=alias, mime_type="image/png")
|
||||
|
||||
|
||||
def test_attachments_footer_singular(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
|
||||
assert "attached image:" in rendered
|
||||
assert '[link="file://' in rendered
|
||||
assert "]shot.png[/link]" in rendered
|
||||
|
||||
|
||||
def test_attachments_footer_plural(tmp_path: Path) -> None:
|
||||
a = _att(tmp_path / "a.png", "a.png")
|
||||
b = _att(tmp_path / "b.png", "b.png")
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([a, b])
|
||||
|
||||
assert "attached images:" in rendered
|
||||
assert rendered.count('[link="file://') == 2
|
||||
assert "a.png" in rendered
|
||||
assert "b.png" in rendered
|
||||
|
||||
|
||||
def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "weird [bracket].png")
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
|
||||
# Rich's escape() turns "[" into "\[".
|
||||
assert "\\[bracket]" in rendered
|
||||
|
||||
|
||||
def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
msg = LLMMessage(role=Role.user, content="look at @shot.png", images=[att])
|
||||
|
||||
widgets = build_history_widgets(
|
||||
[msg],
|
||||
tool_call_map={},
|
||||
start_index=0,
|
||||
tools_collapsed=False,
|
||||
history_widget_indices=WeakKeyDictionary(),
|
||||
)
|
||||
|
||||
assert len(widgets) == 1
|
||||
user_widget = widgets[0]
|
||||
assert isinstance(user_widget, UserMessage)
|
||||
assert user_widget._images == [att]
|
||||
|
||||
|
||||
def test_resumed_user_message_with_images_only_still_mounts(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
msg = LLMMessage(role=Role.user, content="", images=[att])
|
||||
|
||||
widgets = build_history_widgets(
|
||||
[msg],
|
||||
tool_call_map={},
|
||||
start_index=0,
|
||||
tools_collapsed=False,
|
||||
history_widget_indices=WeakKeyDictionary(),
|
||||
)
|
||||
|
||||
assert len(widgets) == 1
|
||||
assert isinstance(widgets[0], UserMessage)
|
||||
Loading…
Add table
Add a link
Reference in a new issue