Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2116,6 +2116,41 @@ def _schedule():
pass


def _prepend_note_to_message(message, note: str):
"""Prepend a one-shot system-style note to a user message.

``message`` is normally a plain string, but when the user attaches an image
to a vision-capable model it becomes a list of OpenAI-style content parts
(text + ``image_url`` blocks). Naively doing ``note + "\\n\\n" + message``
then raises ``TypeError: can only concatenate str (not "list") to str`` —
e.g. running ``/model ...`` (which queues a model-switch note) and then
sending a pasted image in the same turn.

Returns the message with ``note`` prepended:
* ``str`` → ``f"{note}\\n\\n{message}"`` (just ``note`` when empty)
* ``list`` → note folded into the first text part, or inserted as a new
leading ``{"type": "text"}`` part when there is no text part.
Unknown shapes are returned unchanged (fail-open).
"""
note = str(note or "").strip()
if not note:
return message
if isinstance(message, str):
return f"{note}\n\n{message}" if message else note
if isinstance(message, list):
parts = list(message)
for i, part in enumerate(parts):
if isinstance(part, dict) and part.get("type") == "text":
merged = dict(part)
text = merged.get("text", "")
merged["text"] = f"{note}\n\n{text}" if text else note
parts[i] = merged
return parts
# No text part (image-only) — insert the note as a leading text block.
return [{"type": "text", "text": note}, *parts]
return message


# ---------------------------------------------------------------------------
# File-drop / local attachment detection — extracted as pure helpers for tests.
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -12135,17 +12170,21 @@ def run_agent():
reset_current_session_key = None # type: ignore[assignment]
_approval_session_token = None
agent_message = _voice_prefix + message if _voice_prefix else message
# Prepend pending model switch note so the model knows about the switch
# Prepend pending notes via _prepend_note_to_message, which
# handles both plain-string and multimodal content-parts list
# messages. Naive ``note + "\n\n" + agent_message`` crashed with
# TypeError when an image was attached (agent_message is a list)
# and a /model or /reload-skills note was queued for the turn.
_msn = getattr(self, '_pending_model_switch_note', None)
if _msn:
agent_message = _msn + "\n\n" + agent_message
agent_message = _prepend_note_to_message(agent_message, _msn)
self._pending_model_switch_note = None
# Prepend pending /reload-skills note so the model sees which
# skills were added/removed before handling this turn. Same
# one-shot queue pattern as the model-switch note above.
_srn = getattr(self, '_pending_skills_reload_note', None)
if _srn:
agent_message = _srn + "\n\n" + agent_message
agent_message = _prepend_note_to_message(agent_message, _srn)
self._pending_skills_reload_note = None
try:
result = self.agent.run_conversation(
Expand Down
80 changes: 80 additions & 0 deletions tests/cli/test_prepend_note_to_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Tests for cli._prepend_note_to_message.

Regression coverage for the TypeError raised when a queued /model or
/reload-skills note was prepended to a multimodal (image-attached) message:
``can only concatenate str (not "list") to str``.
"""

from cli import _prepend_note_to_message


def test_string_message_gets_note_prepended():
assert _prepend_note_to_message("hello", "NOTE") == "NOTE\n\nhello"


def test_empty_note_returns_message_unchanged():
assert _prepend_note_to_message("hello", "") == "hello"
assert _prepend_note_to_message("hello", " ") == "hello"
parts = [{"type": "text", "text": "hi"}]
assert _prepend_note_to_message(parts, "") == parts


def test_note_is_stripped():
assert _prepend_note_to_message("hello", " NOTE ") == "NOTE\n\nhello"


def test_empty_string_message_yields_just_note():
# No trailing blank lines when the user message is empty.
assert _prepend_note_to_message("", "NOTE") == "NOTE"


def test_empty_text_part_yields_just_note():
message = [
{"type": "text", "text": ""},
{"type": "image_url", "image_url": {"url": "x"}},
]
result = _prepend_note_to_message(message, "NOTE")
assert result[0]["text"] == "NOTE"
assert result[1]["type"] == "image_url"


def test_list_message_folds_note_into_first_text_part():
message = [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:..."}},
]
result = _prepend_note_to_message(message, "NOTE")

assert result[0]["type"] == "text"
assert result[0]["text"] == "NOTE\n\ndescribe this"
# Image part is preserved untouched.
assert result[1] == {"type": "image_url", "image_url": {"url": "data:..."}}
# Original message is not mutated.
assert message[0]["text"] == "describe this"


def test_image_only_list_gets_leading_text_part():
message = [{"type": "image_url", "image_url": {"url": "data:..."}}]
result = _prepend_note_to_message(message, "NOTE")

assert result[0] == {"type": "text", "text": "NOTE"}
assert result[1]["type"] == "image_url"


def test_list_message_does_not_raise_typeerror():
# The exact #repro shape: multimodal list + queued note must not raise
# "can only concatenate str (not 'list') to str".
message = [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "x"}},
]
result = _prepend_note_to_message(
message, "Model switched to gpt-5.5 (provider: openai-codex)."
)
assert isinstance(result, list)
assert result[0]["text"].startswith("Model switched to gpt-5.5")


def test_unknown_shape_returned_unchanged():
assert _prepend_note_to_message(123, "NOTE") == 123
assert _prepend_note_to_message(None, "NOTE") is None
Loading