mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-07 03:37:38 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user