Repository navigation
fix(chat-bot): show one tool line while a turn runs, the answer once it ends - #989
Conversation
…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.
|
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 configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review. 📝 WalkthroughWalkthroughThe 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. ChangesChat turn rendering
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix Merge Risk: ⚪ Minimal · up to 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)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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.
…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.
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.tsdropsReasoningDelta, 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 fromChatMessage.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):ChatToolActivitynaming, so a sub-agent folds into the same line asreviewer (3 steps)…rather than a card of its ownturn-retry, so retraction costs nothing newOnce the turn has ended (any reason, and also a stream that died before reaching a
turn-end):failed/max-steps/ aborted turnOpen points, decided
Finished without a reply.The driver fills an empty render with theWorking 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).textOffsetexisted defaults to the end of the text, matching web'scall.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.turn-retrycan 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.sliceclamps, so a stale offset cuts nothing.settledMessageBlocks— which callsrenderChatMessage(message, context)— gets the finished rendering without knowing it asked for one. Nothing was renamed; the behaviour change is inside the existing functions.chartFences' numbering.Prompt
One bullet on
CONNECTOR_SYSTEM_PROMPT(notSYSTEM_PROMPT, which is pinned byte-for-byte):Tests
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 linebun 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 owntypecheck, and oxlint over the touched paths.Summary by CodeRabbit