Skip to content

runtime: append newline to non-last steer messages on multi-drain#2518

Merged
simonferquel merged 4 commits intodocker:mainfrom
simonferquel-clanker:fix/steer-queue-newline-separator
Apr 27, 2026
Merged

runtime: append newline to non-last steer messages on multi-drain#2518
simonferquel merged 4 commits intodocker:mainfrom
simonferquel-clanker:fix/steer-queue-newline-separator

Conversation

@simonferquel-clanker
Copy link
Copy Markdown
Contributor

Fixes #2517

Problem

When the runtime drains multiple queued steer messages in one batch, each was injected into the session back-to-back with no separator. Some chat templates concatenate consecutive role=user messages before tokenisation, causing adjacent word fragments from different messages to be glued together.

Fix

  • New drainAndEmitSteered() helper replaces the three inline steerQueue.Drain() + for-loop sites in loop.go.
  • appendNewlineToQueuedMessage() appends \n to every non-last message:
    • Plain-text messages: \n appended to Content.
    • Multi-content messages: \n appended to the last text part's Text field; if no text part exists, a new one is appended.
    • Original QueuedMessage is never mutated (shallow-copy of the parts slice).
  • Messages remain independent — each is still a separate role=user session message.

Tests

  • TestAppendNewlineToQueuedMessage: 4 subtests covering plain-text, multi-content with text part, multi-content without text part, and no-mutation of the original.
  • TestDrainAndEmitSteered_MultipleMessages: 3 plain-text messages → 3 separate session messages, non-last have \n, last does not.
  • TestDrainAndEmitSteered_MultiContent: same for multi-content messages.

When multiple steer messages are drained in one batch they were injected
back-to-back with no separator, causing some chat templates that
concatenate consecutive user messages before tokenisation to glue
adjacent word fragments together.

Fix: introduce drainAndEmitSteered() which appends "\n" to every
non-last message's content before calling appendSteerAndEmit.
For multi-content messages the "\n" is appended to the last text part
(or a new text part is added when none exists). The original
QueuedMessage slice is never mutated — appendNewlineToQueuedMessage
works on a shallow copy.

The three inline steerQueue.Drain() + for-loop sites in loop.go are
replaced by calls to the new helper.

Tests added:
- TestAppendNewlineToQueuedMessage: unit-tests the pure helper across
  plain-text, multi-content-with-text-part, multi-content-no-text-part,
  and no-mutation cases.
- TestDrainAndEmitSteered_MultipleMessages: verifies three plain-text
  messages are kept as separate session messages with \n on non-last ones.
- TestDrainAndEmitSteered_MultiContent: verifies the same for
  multi-content messages.

Fixes docker#2517

Assisted-By: docker-agent
@simonferquel-clanker simonferquel-clanker requested a review from a team as a code owner April 27, 2026 08:42
Replace make([]T, len)+copy with append(nil, src...) to avoid appending
to a non-zero-initialized slice, which triggers the makezero linter.

Assisted-By: docker-agent
Comment thread pkg/runtime/loop.go Outdated
}
}
// No text part found — append a new one.
parts = append(parts, chat.MessagePart{Type: chat.MessagePartTypeText, Text: "\n"})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not useful:

  • pure whitespace parts will be skipped down the line anyway
  • non text parts have a layout that makes them immune to the issue (they have an envelope that acts as a separator)

S1: add NOTE comment in drainAndEmitSteered documenting that the
    appended \n is persisted in session messages and UserMessageEvent.
S2: appendNewlineToQueuedMessage now returns sm unchanged when
    MultiContent has no text part (e.g. image-only) instead of
    appending a synthetic whitespace text part. Non-text parts carry
    their own envelope that acts as a separator, and pure whitespace
    parts are skipped downstream anyway.
S3: drainAndEmitSteered now returns (bool, int) where int is the
    pre-drain message count; callers no longer need a separate
    len(sess.GetAllMessages()) call before draining.
N3: add plain-text no-mutation subtest in TestAppendNewlineToQueuedMessage.
N4: reword appendNewlineToQueuedMessage doc comment.

Assisted-By: docker-agent
@simonferquel-clanker
Copy link
Copy Markdown
Contributor Author

Addressed in the latest commit. For the no-text-part branch (e.g. image-only MultiContent), appendNewlineToQueuedMessage now returns sm unchanged — no synthetic whitespace part is appended. Two reasons (per @simonferquel's review): (1) pure whitespace parts are skipped downstream anyway; (2) non-text parts carry their own provider envelope that already acts as a separator, making them immune to run-on tokenisation.

Comment thread pkg/runtime/loop.go Outdated
// Shallow-copy the slice so we don't mutate the original.
parts := append([]chat.MessagePart(nil), sm.MultiContent...)
// Find the last text part and append \n to it.
for i := len(parts) - 1; i >= 0; i-- {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we iterating here ? Only do append \n on the latest part, and only if it is of text type.

Instead of iterating to find the last text part, only inspect
sm.MultiContent[last]: if it is a text part, append \n; otherwise
return sm unchanged. Non-text parts carry their own provider envelope
that acts as a separator, so no \n is needed regardless of what
precedes them.

Update tests to match: replace the 3-part (text/image/text) subtest
with explicit last-part-text and last-part-non-text cases.

Assisted-By: docker-agent
@simonferquel-clanker
Copy link
Copy Markdown
Contributor Author

Simplified in the latest commit. The loop is gone — appendNewlineToQueuedMessage now only looks at sm.MultiContent[last]: if it's a text part, append \n; if it's any other type, return sm unchanged. Non-text parts have their own provider envelope that already acts as a separator, so no \n is needed regardless of what precedes them.

@simonferquel simonferquel merged commit fa3ac9e into docker:main Apr 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runtime steer-queue drain concatenates messages without separator, causing run-on tokenisation

3 participants