Skip to content

Commit 2a70483

Browse files
committed
fix(wand): preserve nested fences and retire stale history on reset
Slice only the outermost fence delimiters so a fenced body containing line-leading backticks keeps every interior line, and skip the history append when a language reset retired the request mid-flight.
1 parent 532cbb6 commit 2a70483

3 files changed

Lines changed: 63 additions & 18 deletions

File tree

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { toast } from '@sim/emcn'
33
import { createLogger } from '@sim/logger'
44
import { filterUndefined } from '@sim/utils/object'
@@ -143,11 +143,23 @@ export function useWand({
143143
* correctly makes the first render a no-op.
144144
*/
145145
const [prevHistoryResetKey, setPrevHistoryResetKey] = useState(historyResetKey)
146+
const [historyEpoch, setHistoryEpoch] = useState(0)
146147
if (prevHistoryResetKey !== historyResetKey) {
147148
setPrevHistoryResetKey(historyResetKey)
148149
setConversationHistory([])
150+
setHistoryEpoch((epoch) => epoch + 1)
149151
}
150152

153+
/**
154+
* Mirrors {@link historyEpoch} for the in-flight request to read on completion.
155+
* A request that started before a reset must not append its turn to the fresh
156+
* history — its prompt and reply belong to the superseded context.
157+
*/
158+
const historyEpochRef = useRef(historyEpoch)
159+
useEffect(() => {
160+
historyEpochRef.current = historyEpoch
161+
}, [historyEpoch])
162+
151163
const abortControllerRef = useRef<AbortController | null>(null)
152164

153165
const showPromptInline = useCallback(() => {
@@ -192,6 +204,9 @@ export function useWand({
192204
setError(null)
193205
setPromptInputValue('')
194206

207+
/** The context this request belongs to; a reset while it streams retires it. */
208+
const startedHistoryEpoch = historyEpochRef.current
209+
195210
abortControllerRef.current = new AbortController()
196211

197212
if (onStreamStart) {
@@ -258,9 +273,12 @@ export function useWand({
258273
if (generatedContent) {
259274
onGeneratedContent(generatedContent)
260275

261-
if (wandConfig?.maintainHistory) {
262-
// The sanitized form goes into history so a single fenced reply
263-
// cannot become the in-context example for every later turn.
276+
/**
277+
* The sanitized form goes into history so a single fenced reply cannot
278+
* become the in-context example for every later turn. Skipped entirely
279+
* when a reset retired this request's context mid-flight.
280+
*/
281+
if (wandConfig?.maintainHistory && historyEpochRef.current === startedHistoryEpoch) {
264282
setConversationHistory((prev) => [
265283
...prev,
266284
{ role: 'user', content: currentPrompt },

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,23 @@ describe('stripCodeFences', () => {
3535
expect(stripCodeFences(fenced)).toBe('if <flag>:\n return "yes"\nreturn "no"')
3636
})
3737

38-
it('keeps every fenced region and drops prose between them', () => {
38+
it('preserves fence lines embedded inside the fenced body', () => {
39+
const fenced = '```javascript\nconst md = `\n```\nhello\n```\n`;\nreturn md;\n```'
40+
expect(stripCodeFences(fenced)).toBe('const md = `\n```\nhello\n```\n`;\nreturn md;')
41+
})
42+
43+
it('preserves a fenced docstring inside a Python body', () => {
44+
const fenced = '```python\ntemplate = """\n```sql\nSELECT 1\n```\n"""\nreturn template\n```'
45+
expect(stripCodeFences(fenced)).toBe(
46+
'template = """\n```sql\nSELECT 1\n```\n"""\nreturn template'
47+
)
48+
})
49+
50+
it('keeps everything between the outer delimiters for a multi-block answer', () => {
51+
// Prose survives rather than risk dropping code between two delimiters that
52+
// may be a nested literal instead of a block boundary.
3953
const fenced = '```js\nconst a = 1;\n```\nThen send it:\n```js\nreturn a;\n```'
40-
expect(stripCodeFences(fenced)).toBe('const a = 1;\nreturn a;')
54+
expect(stripCodeFences(fenced)).toBe('const a = 1;\n```\nThen send it:\n```js\nreturn a;')
4155
})
4256

4357
it('does not touch code that merely contains a fence later', () => {

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

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,28 +57,41 @@ 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-
* Within a fenced response every fenced region is kept and everything between
61-
* them is dropped: text outside a fence is prose, which is never valid code.
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.
67+
*
6268
* Falls back to the original text if stripping would leave nothing.
6369
*/
6470
export function stripCodeFences(text: string): string {
6571
if (!text.trimStart().startsWith('```')) return text
6672

67-
const collected: string[] = []
68-
let insideFence = false
73+
const lines = text.split('\n')
74+
const openingFence = lines.findIndex((line) => FENCE_LINE.test(line))
75+
if (openingFence === -1) return text
6976

70-
for (const line of text.split('\n')) {
71-
if (FENCE_LINE.test(line)) {
72-
insideFence = !insideFence
73-
continue
77+
let closingFence = -1
78+
for (let index = lines.length - 1; index > openingFence; index--) {
79+
if (FENCE_LINE.test(lines[index])) {
80+
closingFence = index
81+
break
7482
}
75-
if (insideFence) collected.push(line)
7683
}
7784

85+
// An unclosed fence (a truncated response) keeps everything after the opener.
86+
const inner =
87+
closingFence === -1
88+
? lines.slice(openingFence + 1)
89+
: lines.slice(openingFence + 1, closingFence)
90+
7891
// Trim blank lines only — leading whitespace on a kept line is indentation,
7992
// which is load-bearing in Python.
80-
while (collected.length > 0 && collected[0].trim() === '') collected.shift()
81-
while (collected.length > 0 && collected[collected.length - 1].trim() === '') collected.pop()
93+
while (inner.length > 0 && inner[0].trim() === '') inner.shift()
94+
while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop()
8295

83-
return collected.length > 0 ? collected.join('\n') : text
96+
return inner.length > 0 ? inner.join('\n') : text
8497
}

0 commit comments

Comments
 (0)