Skip to content

Commit 5985357

Browse files
committed
fix(wand): never treat an interior fence line as the closer
A truncated response whose body embeds line-leading backticks lost every line after the first embedded delimiter. Only the opening line and a final fence line are removed now. Sync the history epoch in a layout effect so a request settling before the passive flush cannot append to already-reset history.
1 parent c89adb2 commit 5985357

3 files changed

Lines changed: 29 additions & 22 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react'
1+
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
22
import { toast } from '@sim/emcn'
33
import { createLogger } from '@sim/logger'
44
import { filterUndefined } from '@sim/utils/object'
@@ -152,9 +152,14 @@ export function useWand({
152152
* Mirrors {@link historyEpoch} for the in-flight request to read on completion.
153153
* A request that started before a reset must not append its turn to the fresh
154154
* history — its prompt and reply belong to the superseded context.
155+
*
156+
* Synced in a layout effect, not a passive one: passive effects flush in a later
157+
* task, so a request settling between the reset's commit and that flush would
158+
* still read the old epoch and append anyway. Layout effects run synchronously
159+
* during commit, before any promise continuation can observe the ref.
155160
*/
156161
const historyEpochRef = useRef(historyEpoch)
157-
useEffect(() => {
162+
useLayoutEffect(() => {
158163
historyEpochRef.current = historyEpoch
159164
}, [historyEpoch])
160165

apps/sim/lib/wand/strip-code-fences.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ describe('stripCodeFences', () => {
4040
expect(stripCodeFences(fenced)).toBe('const md = `\n```\nhello\n```\n`;\nreturn md;')
4141
})
4242

43+
it('keeps every line when a body with nested fences is truncated mid-response', () => {
44+
const truncated = '```javascript\nconst md = `\n```\nhello\n`;\nreturn md;'
45+
expect(stripCodeFences(truncated)).toBe('const md = `\n```\nhello\n`;\nreturn md;')
46+
})
47+
4348
it('preserves a fenced docstring inside a Python body', () => {
4449
const fenced = '```python\ntemplate = """\n```sql\nSELECT 1\n```\n"""\nreturn template\n```'
4550
expect(stripCodeFences(fenced)).toBe(

apps/sim/lib/wand/strip-code-fences.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,16 @@ export function shouldStripCodeFences(generationType?: string): boolean {
5757
* a false positive here would corrupt working code, which is far worse than
5858
* leaving a rare unwrapped response for the user to fix.
5959
*
60-
* Only the outermost delimiters are removed: everything between the first and
61-
* last fence line is kept verbatim, including any fence lines inside it. A
62-
* generated body may legitimately contain line-leading backticks (code that
63-
* builds a markdown string), and pairing delimiters off would silently discard
64-
* the lines between them. The cost is that a model which answers with several
65-
* fenced blocks and prose between them keeps that prose — visibly wrong output
66-
* the user can re-roll, rather than code quietly missing a chunk.
60+
* Only two lines can ever be removed: the opening fence, and the final line when
61+
* it is also a fence. An interior fence line is always treated as content, because
62+
* a generated body may legitimately contain line-leading backticks (code that
63+
* builds a markdown string) and there is no way to tell that apart from a
64+
* delimiter. Scanning for the *last* fence anywhere would truncate such a body
65+
* whenever the response is cut off before its closing fence.
66+
*
67+
* The cost is that a model which answers with several fenced blocks and prose
68+
* between them keeps that prose — visibly wrong output the user can re-roll,
69+
* rather than code quietly missing a chunk.
6770
*
6871
* Falls back to the original text if stripping would leave nothing.
6972
*/
@@ -78,22 +81,16 @@ export function stripCodeFences(text: string): string {
7881
if (lines[index].trim() !== '') return text
7982
}
8083

81-
let closingFence = -1
82-
for (let index = lines.length - 1; index > openingFence; index--) {
83-
if (FENCE_LINE.test(lines[index])) {
84-
closingFence = index
85-
break
86-
}
87-
}
88-
89-
// An unclosed fence (a truncated response) keeps everything after the opener.
90-
const inner =
91-
closingFence === -1
92-
? lines.slice(openingFence + 1)
93-
: lines.slice(openingFence + 1, closingFence)
84+
const inner = lines.slice(openingFence + 1)
9485

9586
// Trim blank lines only — leading whitespace on a kept line is indentation,
9687
// which is load-bearing in Python.
88+
while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop()
89+
90+
// Only the very last line may close the wrapper. A truncated response simply
91+
// has no closer, and every line after the opener survives.
92+
if (inner.length > 0 && FENCE_LINE.test(inner[inner.length - 1])) inner.pop()
93+
9794
while (inner.length > 0 && inner[0].trim() === '') inner.shift()
9895
while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop()
9996

0 commit comments

Comments
 (0)