fix(webui,slack): close a fence only on its own delimiter; hold text rather than slice a char

The bare-number renderer toggled its fence state on any fence-like line,
so a ~~~ line inside a ``` block closed the state early and a bare
number after it was escaped inside the code. It now tracks the opening
delimiter and width and closes only on the same character at that width
or wider with nothing after it, as CommonMark specifies. Regression
proven against the previous version.

The Slack plan's publish boundary is a line end or the position past a
whitespace char, both char boundaries; the slice now goes through a
checked get that holds the text if that ever stops being true, with a
multibyte paragraph pinned. The final-reply fixture in the chat-events
race test carries the wire's required generated_at and asserts it is the
rendered timestamp.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
BenKurrek
2026-09-02 09:35:44 -04:00
parent 2d8dcdc8b1
commit 4bcdc7678a
5 changed files with 59 additions and 7 deletions

View File

@@ -1125,6 +1125,16 @@ mod tests {
"the hash covers exactly the published prefix so a rewrite is still detected"
);
// Multibyte text ahead of the boundary never moves it off a char.
let mut multibyte = ReplyDocument::default();
multibyte.append_answer("héllo 世界 — ünïcode.\n\nnächster Absatz");
let plan = plan_for(&multibyte);
assert_eq!(text_chunks(&plan), vec!["héllo 世界 — ünïcode.\n\n"]);
assert_eq!(
plan.applied.to_chars,
"héllo 世界 — ünïcode.\n\n".chars().count() as u64
);
let mut unfinished = ReplyDocument::default();
unfinished.append_answer("Still typing the first");
let plan = plan_for(&unfinished);

View File

@@ -95,7 +95,10 @@ pub(super) fn plan_chunks(
let publish = if document.is_terminal() || document.answer.finalized || attention_is_new {
delta
} else {
&delta[..publishable_len(prefix, delta)]
// `publishable_len` only ever returns a line end or a position past a
// whitespace char, both char boundaries; should that ever change,
// hold the text rather than slice inside a char.
delta.get(..publishable_len(prefix, delta)).unwrap_or("")
};
if !publish.is_empty() {
for piece in markdown_pieces(publish) {

View File

@@ -78,3 +78,17 @@ test("renderMarkdown keeps a standalone numeric sentence visible instead of an e
/<pre><code class="language-text">19\.\n?<\/code>/,
);
});
test("renderMarkdown closes a fence only on its own delimiter at its own width", () => {
// A ``` block may contain a ~~~ line; it does not close the block, so a
// bare number inside stays literal code, never an escaped `19\.`.
const other = renderMarkdown("```text\n~~~\n19.\n```");
assert.match(other, /<pre><code class="language-text">~~~\n19\.\n<\/code><\/pre>/);
assert.doesNotMatch(other, /19\\\./);
// Nor does a shorter run of the same delimiter.
const shorter = renderMarkdown("````text\n```\n19.\n````\n\n19.");
assert.match(shorter, /<pre><code class="language-text">```\n19\.\n<\/code><\/pre>/);
assert.match(shorter, />19\.</, "the bare number after the block is still visible text");
assert.doesNotMatch(shorter, /<ol/);
});

View File

@@ -97,18 +97,32 @@ function renderGemojiShortcodes(token: Token): void {
// a chat it is a sentence — a model answering "19." must stay visible — so the
// marker's delimiter is escaped. Fenced code is left untouched.
const BARE_ORDERED_MARKER = /^(\s{0,3})(\d{1,9})([.)])\s*$/;
const CODE_FENCE = /^\s{0,3}(?:`{3,}|~{3,})/;
const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})(.*)$/;
function keepBareNumbersVisible(content: string): string {
let inFence = false;
// CommonMark closes a fence only with the same delimiter character, at
// least as long as the opening run, and nothing but whitespace after it.
let fence: { delimiter: string; width: number } | null = null;
return content
.split("\n")
.map((line) => {
if (CODE_FENCE.test(line)) {
inFence = !inFence;
const match = CODE_FENCE.exec(line);
if (match) {
const [, run, rest] = match;
if (!fence) {
fence = { delimiter: run[0], width: run.length };
return line;
}
if (
run[0] === fence.delimiter &&
run.length >= fence.width &&
rest.trim() === ""
) {
fence = null;
}
return line;
}
if (inFence) return line;
if (fence) return line;
return line.replace(BARE_ORDERED_MARKER, "$1$2\\$3");
})
.join("\n");

View File

@@ -3193,13 +3193,24 @@ test("useChatEvents: a final reply for a locally stopped run lifts the stop befo
// instead of being fenced away.
harness.handleEvent({
type: "final_reply",
frame: { reply: { turn_run_id: "run-1", text: "The answer is 42." } },
frame: {
reply: {
turn_run_id: "run-1",
text: "The answer is 42.",
generated_at: "2026-09-02T09:00:00Z",
},
},
});
const assistant = harness.messages.filter((m) => m.role === "assistant");
assert.equal(assistant.length, 1, "the final reply is rendered");
assert.equal(assistant[0].content, "The answer is 42.");
assert.equal(assistant[0].isFinalReply, true);
assert.equal(
assistant[0].timestamp,
"2026-09-02T09:00:00Z",
"the wire's generated_at is the rendered timestamp",
);
assert.equal(
harness.messages.some((message) => isRunStoppedMessageId(message.id)),
false,