Skip to content

fix(chat-bot): show one tool line while a turn runs, the answer once it ends - #989

Merged
JeremyFunk merged 2 commits into
mainfrom
fix/chat-bot-message-rendering
Sep 23, 2026
Merged

JeremyFunk merged 2 commits into
mainfrom
fix/chat-bot-message-rendering

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

From a live test of the bot in a channel: it was giving its thinking in the message, and it kept every tool it had touched in the finished reply.

Both come from the same place — the relay was rendering what the web transcript renders. Web is a transcript, and the prose-between-calls interleaving is exactly what it is for. A channel is read between other people's messages, where the same content lands as a colleague thinking out loud.

Reasoning itself was never the problem: apps/ai/src/chat/events.ts drops ReasoningDelta, so model reasoning does not reach the wire. What the reader saw was the model's own interleaved narration ("Let me look at the errors…", "Now I'll check…") streamed as prose, plus the running list of tool activity.

Rendering rules as implemented

renderChatMessage(message, context, running = false). Segments come from ChatMessage.toolCalls[i].textOffset — the text between consecutive offsets — counting only the calls that ran (a proposed one has not).

While the turn runs (only the driver passes true):

  • one activity block carrying the latest call alone — name and status, the existing ChatToolActivity naming, so a sub-agent folds into the same line as reviewer (3 steps)… rather than a card of its own
  • the text after that call, because it may be the answer. A new call turns that text into narration, and the next render simply drops it — the driver already re-renders for turn-retry, so retraction costs nothing new
  • everything before it is not shown
  • the typing indicator is unchanged

Once the turn has ended (any reason, and also a stream that died before reaching a turn-end):

  • the prose after the last call that ran, with its charts, entity cards and approvals as before
  • no activity block at all
  • the existing neutral notices still close a failed / max-steps / aborted turn

Open points, decided

  • No tool calls at all → every segment is the answer, since there is nothing to cut on. Falls out of the offsets being empty.
  • Final segment empty, an earlier one substantive → show the last non-empty segment. Rare, and it is what makes a turn that stopped without answering say how far it got instead of showing a bare failure notice beside nothing.
  • A turn that ends having written nothing at all → a short neutral notice, Finished without a reply. The driver fills an empty render with the Working on it… placeholder, which on a finished turn would have been the channel's last word on it. Pinned by a driver test on exactly that stream (tool call → result → turn-end stop, no text).
  • A proposed call does not cut prose. It never ran, so it has no result to supersede what came before it, and those words are what explain the change somebody is being asked to approve. It also means settling a proposal does not move the text.
  • A call recorded before textOffset existed defaults to the end of the text, matching web's call.textOffset ?? text.length. Defaulting to 0 would declare the narration before a real call to be the answer as soon as a legacy call was the last one in a message.
  • Calls are read in the order they were made, never sorted by offset. A turn-retry can leave a retracted attempt's call holding an offset past the end of the text it took back; the answer belongs after the call actually made last, and sorting picked the stale one instead. slice clamps, so a stale offset cuts nothing.
  • Cold renders get the finished message by default. The parameter defaults to false, so Approve a chat-platform write as the Maple user who clicked #980's settledMessageBlocks — which calls renderChatMessage(message, context) — gets the finished rendering without knowing it asked for one. Nothing was renamed; the behaviour change is inside the existing functions.
  • Chart numbering survives the cut. Whatever mints a chart's image numbers every fence in the whole message, so the dropped prefix is scanned for fences and the answer's charts start from that count. A fence left open across the cut is counted by both scans alike. Pinned by a test, alongside the existing one that holds this renderer to chartFences' numbering.

Prompt

One bullet on CONNECTOR_SYSTEM_PROMPT (not SYSTEM_PROMPT, which is pinned byte-for-byte):

What you write after your last tool call is what gets posted; anything you write between calls is treated as working notes and is not shown, so state findings rather than steps

Tests

  • driver, against testchat: interleaved narration + two tool calls + a final answer — the first state the reader sees asserted exactly, one tool on the line at a time in call order, the narration never reaching any post or edit, the finished message carrying the answer alone; a turn that stops without a word; a sub-agent on that same line
  • render: running vs finished for the same message, narration dropped, the proposal case both ways, the stopped-without-answering fallback, a legacy call with no offset, a retry that leaves a stale offset, chart numbering across a dropped segment
  • the Discord dialect is untouched — it already renders an activity block as one line

bun run --cwd packages/chat-platform test (136), bun run --cwd apps/chat-bot test (42), bun run --cwd apps/ai test src/chat/prompts.test.ts (6), the package's own typecheck, and oxlint over the touched paths.

Summary by CodeRabbit

  • Improvements
    • While a response is in progress, the chat displays the latest tool activity in a status block. Sub-agent activity appears with its associated tool.
    • Intermediate narration between tool calls is not posted. When the response finishes, the chat shows the final answer without the working notes.
    • If a turn ends without a reply, the chat displays “Finished without a reply.”

…it ends

A turn relayed into a channel was showing everything the web transcript shows:
every tool it touched, and the prose the model wrote between the calls. Web is a
transcript and that interleaving is the point; a channel is read between other
people's messages, where it lands as the bot thinking out loud.

`renderChatMessage` now takes what the render is of. While the turn runs it is
the latest tool call as one status line, plus the text after that call, which may
yet be the answer — the next call turns that text into narration and the next
render retracts it. Once the turn has ended it is the answer alone: the prose
after the last call that ran, with its charts, entities and approvals, and no
activity line at all. A turn that stopped without answering falls back to the
last segment that said anything, which is better beside a failure notice than
nothing.

Omitting the option renders the finished message, so a cold re-render off
`history()` needs to say nothing to get the right one. A proposed call neither
reports progress nor cuts prose — it has not run, and the words before it are
what explains the change somebody is being asked to approve.

The connector prompt gains the matching line: only what the model writes after
its last tool call is posted.
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: f5b46056-a713-4ea7-ad83-1ddae4674517

📥 Commits

Reviewing files that changed from the base of the PR and between c2eaf91 and a0eced9.

📒 Files selected for processing (5)
  • apps/ai/src/chat/prompts.ts
  • packages/chat-platform/src/driver.test.ts
  • packages/chat-platform/src/driver.ts
  • packages/chat-platform/src/render/message.test.ts
  • packages/chat-platform/src/render/message.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

The chat prompt and renderer update which narration and tool activity appear in channel output. The turn driver passes running state to the renderer and displays a notice when a turn ends without a reply.

Changes

Chat turn rendering

Layer / File(s) Summary
Answer and activity rendering
apps/ai/src/chat/prompts.ts, packages/chat-platform/src/render/message.ts, packages/chat-platform/src/render/message.test.ts
The prompt directs the model to report findings rather than steps. The renderer shows only the latest non-proposed tool call while a turn is running and selects answer text using tool-call offsets. Tests cover narration filtering, missing and stale offsets, fallback text, and chart numbering.
Turn driver running state
packages/chat-platform/src/driver.ts, packages/chat-platform/src/driver.test.ts
The driver passes running state to the renderer and clears it before the final flush. When a turn ends without rendered content, it displays “Finished without a reply.” Tests cover tool activity output, final answer output, and sub-agent status details.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to a0ece

The updated channel rendering and completion behavior are covered by the described implementation and tests, with no actionable merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 5…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: showing one tool activity line during a running turn and displaying the answer after the turn ends.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A turn that ran a tool and then stopped without a closing word now rendered as
no blocks at all, and the driver fills an empty render with "Working on it…" —
so the last thing the channel said about a finished turn was that it was still
working. An empty render on a turn that has ended is now a short neutral notice
instead.

Three corrections underneath it:

A call recorded before `textOffset` existed defaults to the end of the text, as
web's own re-interleaving does, not to the start. Defaulting to 0 declared the
narration before a real call to be the answer as soon as a legacy call was the
last one in a message.

Calls are read in the order the model made them rather than sorted by offset. A
`turn-retry` can leave a retracted attempt's call holding an offset past the end
of the text it took back, and the answer belongs after the call that was
actually made last — sorting picked the stale one. `slice` clamps, so the stale
offset cuts nothing, which is what the new retry test pins.

The render option collapses to a plain defaulted parameter, since the driver is
its only caller and a turn is either running or it is not.
@JeremyFunk
JeremyFunk merged commit c153ad0 into main Sep 23, 2026
36 checks passed
@JeremyFunk
JeremyFunk deleted the fix/chat-bot-message-rendering branch September 23, 2026 18:10
JeremyFunk added a commit that referenced this pull request Sep 23, 2026
…ween segments (#1000)

* fix(chat-bot): keep model markup out of a reply, and a blank line between segments

Three things a live Discord turn showed, none of which #989 could reach.

A tool call reaching the reader as text. At the end of its budget the engine
makes one more model call with `toolChoice: "none"`, and a model denied tools
can write its native tool-call markup into `content` instead; the provider
passes it through as prose and nothing downstream strips it. Inline thinking
tags are the same class: the reasoning channel is dropped already, `content`
was not. Both are now removed at the engine-to-wire boundary, by a filter that
keeps state across deltas because a tag arrives split as readily as whole. A
leaked tool call is logged once per turn, tag only, so the rate is visible.

Two segments running together. The cut that picks a turn's answer usually
leaves one segment, but the empty-final-segment fallback and a retry's stale
offset can both leave a boundary inside what is shown, and model text carries
no separator of its own — "...at 40%.Two failure signatures." Segments now join
on a blank line.

And the prompt: the working-notes rule stays, because it is true, with the
answer required in full after the final call and running commentary named.

* docs(chat-platform): put answerOffset's docblock back on answerOffset

* fix(chat-bot): let the model quote a tag, and never break a fence to add a blank line

Four ways the first pass could damage a reply it was meant to protect.

A tag in a code span is the model talking ABOUT markup, and the style rules now
invite exactly that: `<tool_call>` in backticks opened a block that never
closed and swallowed the rest of the reply. A quoted opener is prose.

Two halves of a tag on either side of a removed block spliced into a whole one
on the way out — `a<th<tool_call>x</tool_call>ink>` emitted `<think>`. The text
before a block now keeps its own held tail instead of being flushed whole.

A segment boundary can land inside a chart fence, and breaking one there stops
it parsing and puts its payload in the channel as prose, under an index the
image endpoint no longer agrees with. A boundary inside an unclosed fence is
not a place to break.

And the leak warning runs on every ending, not only on success: a turn that
leaked and then failed is the interesting one.

* fix(chat-bot): flush the held tail at turn end, and ask the fence scanner itself

Two from the review bots, both real.

A turn ending mid-tag lost what the sanitizer was holding — a reply ending on
`<` dropped it, because nothing released the buffer at `RunCompleted`,
`RunFailed` or `RunInterrupted`. A tag that never arrived was only ever prose,
so it is flushed as the turn's last delta. A block the turn ended inside
flushes nothing: that text is markup.

And counting backtick runs is not the fence grammar. A fence is a line
construct with a variable tick count, so a four-tick chart holds lines of three
as payload and prose can name ``` mid-line without opening anything — both
directions wrong, one of them by splitting a chart's JSON. `hasOpenFence` now
lives beside `splitChartFences` and runs the same scan, which is the whole
reason that scan is one function.
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.

1 participant