Skip to content

fix(cli): keep attached images when interrupted input is re-queued - #73104

Open
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/cli-interrupt-image-requeue-5202
Open

fix(cli): keep attached images when interrupted input is re-queued#73104
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/cli-interrupt-image-requeue-5202

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

This supersedes #5202 (@mattsegura, 2026-04-05)

  • What fix(cli): preserve image interrupts when re-queueing #5202 got right: the diagnosis. It correctly traced image loss to the chat() interrupt-requeue path, and the credit for finding this belongs there.
  • What it did not do: its tests/test_cli_interrupt_payloads.py exercises three extracted module-level helpers in isolation and never drives the live HermesCLI.chat() requeue branch — the exact deficiency named in the 2026-07-12 review on that PR. It also carries an unrelated process_loop display refactor, and its cli.py hunks are stale against a cli.py that has been rewritten around them in the 3.5 months since (the requeue branch moved from ~line 6666 to 13466; the tuple unpack it adds to process_loop is already inline on main at cli.py:16132-16134).
  • What this adds: the shape requested in that review — a focused type-aware merge at the requeue site only, plus tuple/image cases added to the live-path test at tests/cli/test_cli_interrupt_ack_race.py, asserting the queued next-turn payload retains (text, [Path, ...]).

What does this PR do?

Interrupting a running turn while images are attached silently destroys the message and the images.

The Enter handler bundles attachments as a (text, [Path, ...]) tuple (cli.py:14343) and, in the default busy_input_mode == "interrupt", puts that tuple on _interrupt_queue (cli.py:14395). The image path always goes there — the agent.redirect() fast path above it is gated on if not images and text. chat()'s requeue branch then ran:

all_parts = [pending_message]
...
combined = "\n".join(all_parts)      # cli.py:13475

which raises TypeError: sequence item 0: expected str instance, tuple found.

pending_message is itself bound from the queue value — result.get("interrupt_message") or interrupt_msg (cli.py:13298) and pending_message = interrupt_msg (cli.py:13312), and interrupt_msg comes straight off _interrupt_queue. turn_finalizer.py:625 returns agent._interrupt_message verbatim, so the tuple survives the round trip intact. A single interrupted image message is enough to trigger the crash — a second queued message is not required.

The failure is invisible and unrecoverable:

  • chat()'s whole body sits under a bare except Exception as e: print(f"Error: {e}") (cli.py:13492), so the user sees one line of error text and nothing else.
  • The parts were already removed from the queue with get_nowait() before the join, so the message and its images are gone — not requeued, not re-displayed, not recallable.

The fix merges the parts type-aware at the requeue site: join the text, concatenate the image lists, and emit a plain str when no part carries images so the common text-only path is unchanged. process_loop already unpacks the (text, images) form at cli.py:16132-16134, so the merged payload round-trips into the next turn as a real multimodal message.

Sibling-site sweep

grep -n '_interrupt_queue\|_pending_input' cli.py — every producer and consumer of these two queues was enumerated. The type-blind join exists at exactly one site; the rest are already tuple-safe and are deliberately left untouched:

Site Role Status
cli.py:13475 (chat() requeue) joins drained parts the bug — fixed here
cli.py:14370 (queue mode) _pending_input.put(payload) safe — puts the tuple verbatim
cli.py:14395 (interrupt mode) _interrupt_queue.put(payload) safe — the producer, correct as-is
cli.py:14423 (agent idle) _pending_input.put(payload) safe — verbatim
cli.py:13117 (clarify race) parks interrupt_msg in _pending_input safe — verbatim, no join
cli.py:10208-10211 _drain_interrupt_queue_to_pending_input() moves strays across safe — verbatim, no join
cli.py:10255-10262 goal-continuation peek inspects queued entries safe — already unpacks (text, images)
cli.py:6815 alt submit path puts plain str safe — no images on that path
cli.py:13488 leftover /steer preview slices a str out of scope — /steer is text-only by construction (the Enter handler falls back to queue mode when images are attached)

No mirror of this join exists in tui_gateway/, gateway/, or apps/desktop/grep -rn "all_parts" --include='*.py' . returns this one production site.

Related Issue

No filed issue — this supersedes open PR #5202 and implements the review left on it.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • cli.py — replaced the type-blind "\n".join(all_parts) in HermesCLI.chat()'s interrupt-requeue branch with a type-aware merge: text joined with \n in arrival order, image lists concatenated in arrival order, result emitted as (text, images) only when at least one part carries images and as a plain str otherwise. preview now derives from the joined text (so it stays a str) and gains an [N images attached] note when attachments are present, including the text-empty case. The existing n > 1 vs single-message print branch is unchanged.
  • tests/cli/test_cli_interrupt_ack_race.py — added three tests on the live HermesCLI.chat() path: a single image interrupt, a mixed text+image merge, and a text-only guard.

How to Test

Reproduction (before this PR): attach an image with the agent running, press Enter (default busy_input_mode = interrupt). The turn is interrupted, then Error: sequence item 0: expected str instance, tuple found prints and your message and image never come back.

Automated, on the live chat() requeue branch:

uv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest tests/cli/test_cli_interrupt_ack_race.py -v

Fails-before / passes-after, verified in both directions by reverting only the cli.py hunk:

Test On main (prod hunk reverted) With this PR
test_interrupt_with_attached_images_is_requeued_intact FAIL_pending_input is empty; captured stdout shows the swallowed Error: sequence item 0: expected str instance, tuple found PASS — payload is ("what is in this screenshot?", [Path(...)])
test_interrupt_merges_text_and_image_parts_without_losing_attachments FAIL — same swallowed TypeError, all three parts lost PASS — text joined, both image paths preserved in order
test_text_only_interrupt_still_requeues_a_plain_string PASS PASS — proves the text-only path is untouched

Full results:

  • tests/cli/test_cli_interrupt_ack_race.py — 11 passed (8 pre-existing + 3 new).
  • tests/cli/ — 1232 passed, 1 failed. The single failure, test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode, is a pre-existing baseline: the identical pytest tests/cli/ -q -p no:randomly -n 4 run on clean origin/main (ef2670113) fails the same test (1229 passed, 1 failed), and it passes in isolation on both. It is a parallel-ordering artifact, unrelated to this change and untouched by it.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/cli/ -q and all tests pass (one pre-existing baseline failure, reproduced on clean origin/main — see above)
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.12

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure in-process payload handling, no filesystem or path-separator behaviour
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Related / Positioning

Interrupting a running turn with images attached silently destroyed the
message.

The Enter handler bundles attachments as a `(text, [Path, ...])` tuple
and, in the default `busy_input_mode == "interrupt"`, puts that tuple on
`_interrupt_queue`. `HermesCLI.chat()`'s requeue branch then ran
`"\n".join(all_parts)` over the drained parts, which raises
`TypeError: sequence item 0: expected str instance, tuple found`. Because
`pending_message` is itself bound from the queue value, a single
interrupted image message is enough to trip it — no second message
needed.

The failure was invisible and unrecoverable: `chat()` runs under a bare
`except Exception`, so the user only saw a one-line `Error: ...`, and the
parts had already been removed from the queue with `get_nowait()`. The
message and its images were gone — not requeued, not shown, not
recallable.

Merge the parts type-aware at the requeue site instead: join the text,
concatenate the image lists, and emit a plain `str` when no part carries
images so the common text-only path is byte-for-byte unchanged.
`process_loop` already unpacks the `(text, images)` form, so the merged
payload round-trips into the next turn as a real multimodal message. The
preview string stays a `str` and gains an image count when attachments
are present.

Supersedes NousResearch#5202, which diagnosed the same defect in April but tested
extracted helpers in isolation and never exercised the live `chat()`
requeue branch.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard needs-decision Awaiting maintainer decision before any implementation labels Jul 28, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #5202 diagnoses the same image-interrupt loss, but this PR applies the repair and regression coverage at the current live HermesCLI.chat() requeue site. The helper-only older patch is stale; a maintainer should select the current-path implementation.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused fix. The premise is confirmed on current main: cli.py:14114-14129 drains interrupt payloads and calls "\n".join(all_parts), while cli.py:15023-15076 can enqueue attached-image input as (text, images). The proposed merge preserves text ordering and image ordering, and its tuple result is consumed by the existing unpack at cli.py:16821-16825.

The added tests exercise the live HermesCLI.chat() requeue path for one image payload, mixed text/image payloads, and the existing text-only representation. The change stays confined to CLI payload handling and adds no tool, configuration, cache, or message-history surface. GitHub CI for 9be184e1563212b477de43ebfde84e09f92a2265 is passing.

Automated hermes-sweeper review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants