Skip to content

Commit 2d90c72

Browse files
authored
fix(wand): stop markdown code fences landing in generated code (#6289)
* fix(wand): stop markdown code fences landing in generated code Strip fences from wand output for raw-value generation types, reset the conversation history when a Function block switches language, and give the Python prompt the worked example the JavaScript one already had. * 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. * refactor(wand): drop unreachable guard in fence stripper The trimStart check made the -1 branch dead and stated "opens with a fence" twice. Derive it once from the first fence line's position. * chore(wand): remove unused onGenerationComplete callback No call site ever passed it, so the branch never ran. The props interface makes the removal compile-time verified. * 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. * test(wand): record the trailing-fence ambiguity as a decision A bare fence on the last line closes the wrapper in every well-formed response and is content only when generation stopped exactly on an embedded delimiter. Nothing separates the two, so assert the chosen behavior instead of leaving it implicit.
1 parent 0583e20 commit 2d90c72

5 files changed

Lines changed: 310 additions & 16 deletions

File tree

apps/sim/app/api/wand/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
328328
'\n\nIMPORTANT: Return ONLY the raw cron expression (e.g., "0 9 * * 1-5"). Do NOT wrap it in markdown code blocks, backticks, or quotes. Do NOT include any explanation or text before or after the expression.'
329329
}
330330

331+
// Both the JavaScript and Python function-body prompts share this type, so
332+
// the reinforcement stays language-neutral.
333+
if (generationType === 'javascript-function-body') {
334+
finalSystemPrompt +=
335+
'\n\nIMPORTANT: Return ONLY the raw function body. Do NOT wrap it in markdown code blocks (no ```javascript, no ```python, no ```). Do NOT include any explanation before or after the code.'
336+
}
337+
331338
if (generationType === 'json-object') {
332339
finalSystemPrompt +=
333340
'\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,32 @@ IMPORTANT FORMATTING RULES:
6767
1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes.
6868
2. Reference Input Parameters/Workflow Variables: Use the exact syntax <variable_name>. Do NOT wrap it in quotes.
6969
3. Function Body ONLY: Do NOT include the function signature (e.g., 'def my_func(...)') or surrounding braces. Return the final value with 'return'.
70-
4. Imports: You may add imports as needed (standard library or pip-installed packages) without comments.
70+
4. Imports: The Python standard library is always available. Third-party packages are available ONLY when the block has a sandbox selected — the sandbox's package list is appended below when one is. Never import a package that is not on that list.
7171
5. No Markdown: Do NOT include backticks, code fences, or any markdown.
72-
6. Clarity: Write clean, readable Python code.`
72+
6. Clarity: Write clean, readable Python code.
73+
7. No Explanations: Output the raw Python code only — no prose before or after it.
74+
75+
Example Scenario:
76+
User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable."
77+
78+
Generated Code:
79+
import json
80+
import urllib.error
81+
import urllib.request
82+
83+
user_id = <userId> # Correct: accessing an input parameter without quotes
84+
api_key = {{SERVICE_API_KEY}} # Correct: accessing an environment variable without quotes
85+
url = f"https://api.example.com/users/{user_id}"
86+
87+
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"})
88+
89+
try:
90+
with urllib.request.urlopen(request) as response:
91+
# Return the fetched data, which becomes the block's output
92+
return json.loads(response.read().decode())
93+
except urllib.error.HTTPError as error:
94+
# Raising marks the block execution as failed
95+
raise Exception(f"API request failed with status {error.code}: {error.read().decode()}")`
7396

7497
/**
7598
* Line height constant for consistent rendering.
@@ -330,6 +353,9 @@ export const Code = memo(function Code({
330353
tableId: typeof tableIdValue === 'string' ? tableIdValue : null,
331354
sandboxId: typeof sandboxIdValue === 'string' ? sandboxIdValue : null,
332355
},
356+
// Keyed off the same value that swaps the prompt below, so history from the
357+
// previous language cannot steer the next generation back to it.
358+
historyResetKey: typeof languageValue === 'string' ? languageValue : undefined,
333359
onStreamStart: () => handleStreamStartRef.current?.(),
334360
onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk),
335361
onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content),

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

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, 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'
@@ -8,6 +8,7 @@ import { requestRaw } from '@/lib/api/client'
88
import { isApiClientError } from '@/lib/api/client/errors'
99
import { wandGenerateStreamContract } from '@/lib/api/contracts'
1010
import { readSSEStream } from '@/lib/core/utils/sse'
11+
import { shouldStripCodeFences, stripCodeFences } from '@/lib/wand/strip-code-fences'
1112
import type { GenerationType } from '@/blocks/types'
1213
import { subscriptionKeys } from '@/hooks/queries/subscription'
1314
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -100,20 +101,26 @@ interface UseWandProps {
100101
wandConfig?: WandConfig
101102
currentValue?: string
102103
contextParams?: WandContextParams
104+
/**
105+
* Clears the conversation history whenever this value changes. Pass anything
106+
* that invalidates prior turns — a Function block switching language rewrites
107+
* `wandConfig.prompt`, but replayed history would keep steering the model back
108+
* to the previous language.
109+
*/
110+
historyResetKey?: string
103111
onGeneratedContent: (content: string) => void
104112
onStreamChunk?: (chunk: string) => void
105113
onStreamStart?: () => void
106-
onGenerationComplete?: (prompt: string, generatedContent: string) => void
107114
}
108115

109116
export function useWand({
110117
wandConfig,
111118
currentValue,
112119
contextParams,
120+
historyResetKey,
113121
onGeneratedContent,
114122
onStreamChunk,
115123
onStreamStart,
116-
onGenerationComplete,
117124
}: UseWandProps) {
118125
const queryClient = useQueryClient()
119126
const { navigateToSettings } = useSettingsNavigation()
@@ -127,6 +134,35 @@ export function useWand({
127134

128135
const [conversationHistory, setConversationHistory] = useState<ChatMessage[]>([])
129136

137+
/**
138+
* Adjusted during render rather than in an effect so a generation started in
139+
* the same commit as the change can never send the stale history. History is
140+
* already empty on mount, so seeding the tracker with the current key
141+
* correctly makes the first render a no-op.
142+
*/
143+
const [prevHistoryResetKey, setPrevHistoryResetKey] = useState(historyResetKey)
144+
const [historyEpoch, setHistoryEpoch] = useState(0)
145+
if (prevHistoryResetKey !== historyResetKey) {
146+
setPrevHistoryResetKey(historyResetKey)
147+
setConversationHistory([])
148+
setHistoryEpoch((epoch) => epoch + 1)
149+
}
150+
151+
/**
152+
* Mirrors {@link historyEpoch} for the in-flight request to read on completion.
153+
* A request that started before a reset must not append its turn to the fresh
154+
* 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.
160+
*/
161+
const historyEpochRef = useRef(historyEpoch)
162+
useLayoutEffect(() => {
163+
historyEpochRef.current = historyEpoch
164+
}, [historyEpoch])
165+
130166
const abortControllerRef = useRef<AbortController | null>(null)
131167

132168
const showPromptInline = useCallback(() => {
@@ -171,6 +207,9 @@ export function useWand({
171207
setError(null)
172208
setPromptInputValue('')
173209

210+
/** The context this request belongs to; a reset while it streams retires it. */
211+
const startedHistoryEpoch = historyEpochRef.current
212+
174213
abortControllerRef.current = new AbortController()
175214

176215
if (onStreamStart) {
@@ -224,25 +263,37 @@ export function useWand({
224263
signal: abortControllerRef.current?.signal,
225264
})
226265

227-
if (accumulatedContent) {
228-
onGeneratedContent(accumulatedContent)
229-
230-
if (wandConfig?.maintainHistory) {
266+
/**
267+
* Sanitized once the full response is known, then written back over the
268+
* streamed text. Doing it per-chunk would mean guessing whether a
269+
* trailing backtick run opens a fence or is part of the code, so the
270+
* editor may briefly show a fence that the final value does not.
271+
*/
272+
const generatedContent = shouldStripCodeFences(wandConfig?.generationType)
273+
? stripCodeFences(accumulatedContent)
274+
: accumulatedContent
275+
276+
if (generatedContent) {
277+
onGeneratedContent(generatedContent)
278+
279+
/**
280+
* The sanitized form goes into history so a single fenced reply cannot
281+
* become the in-context example for every later turn. Skipped entirely
282+
* when a reset retired this request's context mid-flight.
283+
*/
284+
if (wandConfig?.maintainHistory && historyEpochRef.current === startedHistoryEpoch) {
231285
setConversationHistory((prev) => [
232286
...prev,
233287
{ role: 'user', content: currentPrompt },
234-
{ role: 'assistant', content: accumulatedContent },
288+
{ role: 'assistant', content: generatedContent },
235289
])
236290
}
237-
238-
if (onGenerationComplete) {
239-
onGenerationComplete(currentPrompt, accumulatedContent)
240-
}
241291
}
242292

243293
logger.debug('Wand generation completed', {
244294
prompt,
245-
contentLength: accumulatedContent.length,
295+
contentLength: generatedContent.length,
296+
strippedFences: generatedContent !== accumulatedContent,
246297
})
247298

248299
setTimeout(() => {
@@ -282,7 +333,6 @@ export function useWand({
282333
onGeneratedContent,
283334
onStreamChunk,
284335
onStreamStart,
285-
onGenerationComplete,
286336
queryClient,
287337
contextParams?.tableId,
288338
contextParams?.sandboxId,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { shouldStripCodeFences, stripCodeFences } from '@/lib/wand/strip-code-fences'
6+
7+
describe('stripCodeFences', () => {
8+
it('leaves unfenced code untouched', () => {
9+
const code = 'const total = <a> + <b>;\nreturn total;'
10+
expect(stripCodeFences(code)).toBe(code)
11+
})
12+
13+
it('unwraps a fully wrapped response', () => {
14+
expect(stripCodeFences('```python\nresult = <num1> + <num2>\nreturn result\n```')).toBe(
15+
'result = <num1> + <num2>\nreturn result'
16+
)
17+
})
18+
19+
it('unwraps a response with no closing fence', () => {
20+
expect(stripCodeFences('```javascript\nconst x = 1;\nreturn x;')).toBe(
21+
'const x = 1;\nreturn x;'
22+
)
23+
})
24+
25+
it('unwraps an untagged fence', () => {
26+
expect(stripCodeFences('```\nreturn 1;\n```')).toBe('return 1;')
27+
})
28+
29+
it('tolerates leading whitespace before the opening fence', () => {
30+
expect(stripCodeFences('\n ```python\nreturn 1\n```')).toBe('return 1')
31+
})
32+
33+
it('preserves indentation inside the fence', () => {
34+
const fenced = '```python\nif <flag>:\n return "yes"\nreturn "no"\n```'
35+
expect(stripCodeFences(fenced)).toBe('if <flag>:\n return "yes"\nreturn "no"')
36+
})
37+
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('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+
48+
it('treats a trailing bare fence as the closer even when the body was truncated at one', () => {
49+
// Irreducibly ambiguous: a trailing bare fence closes the wrapper in every
50+
// well-formed response, and is content only when generation stopped exactly
51+
// at an embedded delimiter. Declining to strip it would leave a stray fence
52+
// in the common case, which is the bug this util exists to fix.
53+
expect(stripCodeFences('```javascript\nconst md = `\n```')).toBe('const md = `')
54+
})
55+
56+
it('preserves a fenced docstring inside a Python body', () => {
57+
const fenced = '```python\ntemplate = """\n```sql\nSELECT 1\n```\n"""\nreturn template\n```'
58+
expect(stripCodeFences(fenced)).toBe(
59+
'template = """\n```sql\nSELECT 1\n```\n"""\nreturn template'
60+
)
61+
})
62+
63+
it('keeps everything between the outer delimiters for a multi-block answer', () => {
64+
// Prose survives rather than risk dropping code between two delimiters that
65+
// may be a nested literal instead of a block boundary.
66+
const fenced = '```js\nconst a = 1;\n```\nThen send it:\n```js\nreturn a;\n```'
67+
expect(stripCodeFences(fenced)).toBe('const a = 1;\n```\nThen send it:\n```js\nreturn a;')
68+
})
69+
70+
it('does not touch code that merely contains a fence later', () => {
71+
const code = 'const doc = `\n```json\n{"a":1}\n```\n`;\nreturn doc;'
72+
expect(stripCodeFences(code)).toBe(code)
73+
})
74+
75+
it('returns the original when stripping would leave nothing', () => {
76+
const empty = '```python\n```'
77+
expect(stripCodeFences(empty)).toBe(empty)
78+
})
79+
80+
it('is idempotent', () => {
81+
const once = stripCodeFences('```python\nreturn <x>\n```')
82+
expect(stripCodeFences(once)).toBe(once)
83+
})
84+
85+
it('handles an empty string', () => {
86+
expect(stripCodeFences('')).toBe('')
87+
})
88+
})
89+
90+
describe('shouldStripCodeFences', () => {
91+
it('strips for code and structured value types', () => {
92+
expect(shouldStripCodeFences('javascript-function-body')).toBe(true)
93+
expect(shouldStripCodeFences('custom-tool-schema')).toBe(true)
94+
expect(shouldStripCodeFences('json-object')).toBe(true)
95+
expect(shouldStripCodeFences('cron-expression')).toBe(true)
96+
})
97+
98+
it('does not strip free-form prose', () => {
99+
expect(shouldStripCodeFences('system-prompt')).toBe(false)
100+
})
101+
102+
it('does not strip when no generation type is declared', () => {
103+
expect(shouldStripCodeFences(undefined)).toBe(false)
104+
expect(shouldStripCodeFences('')).toBe(false)
105+
})
106+
107+
it('does not strip an unrecognized type', () => {
108+
expect(shouldStripCodeFences('something-new')).toBe(false)
109+
})
110+
})

0 commit comments

Comments
 (0)