From efb149532686a17addad5270fc26b19cb321e883 Mon Sep 17 00:00:00 2001 From: yushen Date: Thu, 5 Feb 2026 22:23:55 +0800 Subject: [PATCH] fix(hub): fence close must reject info strings per CommonMark spec The closing fence regex was not checking for an empty info string, allowing e.g. ```python to incorrectly close an open fence. Also adds missing test for tool_execution_update passthrough. Co-Authored-By: Claude Opus 4.5 --- src/hub/block-chunker.test.ts | 17 +++++++++++++++++ src/hub/block-chunker.ts | 4 ++-- src/hub/message-aggregator.test.ts | 8 ++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/hub/block-chunker.test.ts b/src/hub/block-chunker.test.ts index a1b36b16..dfda6cc6 100644 --- a/src/hub/block-chunker.test.ts +++ b/src/hub/block-chunker.test.ts @@ -223,6 +223,23 @@ describe("BlockChunker", () => { expect(chunk + remainder).toBe(text); }); + it("does not treat fence with info string as closing fence", () => { + const chunker = new BlockChunker(cfg({ minChars: 10, maxChars: 500 })); + // The second ```python should NOT close the first fence (CommonMark: closing fence has no info string) + const text = "Before.\n\n```python\ncode line 1\n```python\ncode line 2\n```\n\nAfter text here."; + const result = chunker.tryChunk(text); + expect(result).not.toBeNull(); + const chunk = result!.chunk; + // The split should not land between ```python and ```python (inside fence) + // It should either be before the fence or after the closing ``` + const fenceOpens = (chunk.match(/```python/g) || []).length; + const fenceCloses = (chunk.match(/^```$/gm) || []).length; + if (fenceOpens > 0) { + // If chunk includes opening fences, it must include the real close + expect(fenceCloses).toBeGreaterThanOrEqual(1); + } + }); + it("handles multiple sequential code blocks", () => { const chunker = new BlockChunker(cfg({ minChars: 10, maxChars: 500 })); const text = "```js\nfoo()\n```\n\n```py\nbar()\n```\n\nEnd."; diff --git a/src/hub/block-chunker.ts b/src/hub/block-chunker.ts index a7f04ed7..6daadad0 100644 --- a/src/hub/block-chunker.ts +++ b/src/hub/block-chunker.ts @@ -51,8 +51,8 @@ function detectFenceAt(text: string, upTo: number): FenceInfo | null { if (openFence === null) { // Opening a new fence openFence = { marker, lang }; - } else if (markerChar === openFence.marker[0] && marker.length >= openFence.marker.length) { - // Closing the current fence (same char, at least as many chars) + } else if (markerChar === openFence.marker[0] && marker.length >= openFence.marker.length && lang === "") { + // Closing the current fence (same char, at least as many chars, no info string per CommonMark) openFence = null; } // Otherwise: different char or shorter marker, not a close — ignore diff --git a/src/hub/message-aggregator.test.ts b/src/hub/message-aggregator.test.ts index 135a748c..52516648 100644 --- a/src/hub/message-aggregator.test.ts +++ b/src/hub/message-aggregator.test.ts @@ -112,6 +112,14 @@ describe("MessageAggregator", () => { expect(onBlock).not.toHaveBeenCalled(); }); + it("passes through tool_execution_update immediately", () => { + const agg = new MessageAggregator(smallConfig(), onBlock, onPassthrough); + const event = { type: "tool_execution_update", toolCallId: "tool-1", content: "output" } as unknown as AgentEvent; + agg.handleEvent(event); + expect(onPassthrough).toHaveBeenCalledWith(event); + expect(onBlock).not.toHaveBeenCalled(); + }); + it("passes through compaction_start immediately", () => { const agg = new MessageAggregator(smallConfig(), onBlock, onPassthrough); const event = makeCompactionStart();