From fe0cd6a61eb1d0a20a7df0cb5ecc2daa57b3dabb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 10:21:10 -0700 Subject: [PATCH 1/6] 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. --- apps/sim/app/api/wand/route.ts | 7 ++ .../sub-block/components/code/code.tsx | 30 ++++++- .../w/[workflowId]/hooks/use-wand.ts | 44 ++++++++-- apps/sim/lib/wand/strip-code-fences.test.ts | 83 ++++++++++++++++++ apps/sim/lib/wand/strip-code-fences.ts | 84 +++++++++++++++++++ 5 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 apps/sim/lib/wand/strip-code-fences.test.ts create mode 100644 apps/sim/lib/wand/strip-code-fences.ts diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 617629d4a06..e0cc0d945d5 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -328,6 +328,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { '\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.' } + // Both the JavaScript and Python function-body prompts share this type, so + // the reinforcement stays language-neutral. + if (generationType === 'javascript-function-body') { + finalSystemPrompt += + '\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.' + } + if (generationType === 'json-object') { finalSystemPrompt += '\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 }.' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index 8cfef78abef..163f80f9abc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -67,9 +67,32 @@ IMPORTANT FORMATTING RULES: 1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes. 2. Reference Input Parameters/Workflow Variables: Use the exact syntax . Do NOT wrap it in quotes. 3. Function Body ONLY: Do NOT include the function signature (e.g., 'def my_func(...)') or surrounding braces. Return the final value with 'return'. -4. Imports: You may add imports as needed (standard library or pip-installed packages) without comments. +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. 5. No Markdown: Do NOT include backticks, code fences, or any markdown. -6. Clarity: Write clean, readable Python code.` +6. Clarity: Write clean, readable Python code. +7. No Explanations: Output the raw Python code only — no prose before or after it. + +Example Scenario: +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." + +Generated Code: +import json +import urllib.error +import urllib.request + +user_id = # Correct: accessing an input parameter without quotes +api_key = {{SERVICE_API_KEY}} # Correct: accessing an environment variable without quotes +url = f"https://api.example.com/users/{user_id}" + +request = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"}) + +try: + with urllib.request.urlopen(request) as response: + # Return the fetched data, which becomes the block's output + return json.loads(response.read().decode()) +except urllib.error.HTTPError as error: + # Raising marks the block execution as failed + raise Exception(f"API request failed with status {error.code}: {error.read().decode()}")` /** * Line height constant for consistent rendering. @@ -330,6 +353,9 @@ export const Code = memo(function Code({ tableId: typeof tableIdValue === 'string' ? tableIdValue : null, sandboxId: typeof sandboxIdValue === 'string' ? sandboxIdValue : null, }, + // Keyed off the same value that swaps the prompt below, so history from the + // previous language cannot steer the next generation back to it. + historyResetKey: typeof languageValue === 'string' ? languageValue : undefined, onStreamStart: () => handleStreamStartRef.current?.(), onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk), onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index 34c0b712ee2..4664e607a3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -8,6 +8,7 @@ import { requestRaw } from '@/lib/api/client' import { isApiClientError } from '@/lib/api/client/errors' import { wandGenerateStreamContract } from '@/lib/api/contracts' import { readSSEStream } from '@/lib/core/utils/sse' +import { shouldStripCodeFences, stripCodeFences } from '@/lib/wand/strip-code-fences' import type { GenerationType } from '@/blocks/types' import { subscriptionKeys } from '@/hooks/queries/subscription' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -100,6 +101,13 @@ interface UseWandProps { wandConfig?: WandConfig currentValue?: string contextParams?: WandContextParams + /** + * Clears the conversation history whenever this value changes. Pass anything + * that invalidates prior turns — a Function block switching language rewrites + * `wandConfig.prompt`, but replayed history would keep steering the model back + * to the previous language. + */ + historyResetKey?: string onGeneratedContent: (content: string) => void onStreamChunk?: (chunk: string) => void onStreamStart?: () => void @@ -110,6 +118,7 @@ export function useWand({ wandConfig, currentValue, contextParams, + historyResetKey, onGeneratedContent, onStreamChunk, onStreamStart, @@ -127,6 +136,18 @@ export function useWand({ const [conversationHistory, setConversationHistory] = useState([]) + /** + * Adjusted during render rather than in an effect so a generation started in + * the same commit as the change can never send the stale history. History is + * already empty on mount, so seeding the tracker with the current key + * correctly makes the first render a no-op. + */ + const [prevHistoryResetKey, setPrevHistoryResetKey] = useState(historyResetKey) + if (prevHistoryResetKey !== historyResetKey) { + setPrevHistoryResetKey(historyResetKey) + setConversationHistory([]) + } + const abortControllerRef = useRef(null) const showPromptInline = useCallback(() => { @@ -224,25 +245,38 @@ export function useWand({ signal: abortControllerRef.current?.signal, }) - if (accumulatedContent) { - onGeneratedContent(accumulatedContent) + /** + * Sanitized once the full response is known, then written back over the + * streamed text. Doing it per-chunk would mean guessing whether a + * trailing backtick run opens a fence or is part of the code, so the + * editor may briefly show a fence that the final value does not. + */ + const generatedContent = shouldStripCodeFences(wandConfig?.generationType) + ? stripCodeFences(accumulatedContent) + : accumulatedContent + + if (generatedContent) { + onGeneratedContent(generatedContent) if (wandConfig?.maintainHistory) { + // The sanitized form goes into history so a single fenced reply + // cannot become the in-context example for every later turn. setConversationHistory((prev) => [ ...prev, { role: 'user', content: currentPrompt }, - { role: 'assistant', content: accumulatedContent }, + { role: 'assistant', content: generatedContent }, ]) } if (onGenerationComplete) { - onGenerationComplete(currentPrompt, accumulatedContent) + onGenerationComplete(currentPrompt, generatedContent) } } logger.debug('Wand generation completed', { prompt, - contentLength: accumulatedContent.length, + contentLength: generatedContent.length, + strippedFences: generatedContent !== accumulatedContent, }) setTimeout(() => { diff --git a/apps/sim/lib/wand/strip-code-fences.test.ts b/apps/sim/lib/wand/strip-code-fences.test.ts new file mode 100644 index 00000000000..04af25bc22e --- /dev/null +++ b/apps/sim/lib/wand/strip-code-fences.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { shouldStripCodeFences, stripCodeFences } from '@/lib/wand/strip-code-fences' + +describe('stripCodeFences', () => { + it('leaves unfenced code untouched', () => { + const code = 'const total = + ;\nreturn total;' + expect(stripCodeFences(code)).toBe(code) + }) + + it('unwraps a fully wrapped response', () => { + expect(stripCodeFences('```python\nresult = + \nreturn result\n```')).toBe( + 'result = + \nreturn result' + ) + }) + + it('unwraps a response with no closing fence', () => { + expect(stripCodeFences('```javascript\nconst x = 1;\nreturn x;')).toBe( + 'const x = 1;\nreturn x;' + ) + }) + + it('unwraps an untagged fence', () => { + expect(stripCodeFences('```\nreturn 1;\n```')).toBe('return 1;') + }) + + it('tolerates leading whitespace before the opening fence', () => { + expect(stripCodeFences('\n ```python\nreturn 1\n```')).toBe('return 1') + }) + + it('preserves indentation inside the fence', () => { + const fenced = '```python\nif :\n return "yes"\nreturn "no"\n```' + expect(stripCodeFences(fenced)).toBe('if :\n return "yes"\nreturn "no"') + }) + + it('keeps every fenced region and drops prose between them', () => { + const fenced = '```js\nconst a = 1;\n```\nThen send it:\n```js\nreturn a;\n```' + expect(stripCodeFences(fenced)).toBe('const a = 1;\nreturn a;') + }) + + it('does not touch code that merely contains a fence later', () => { + const code = 'const doc = `\n```json\n{"a":1}\n```\n`;\nreturn doc;' + expect(stripCodeFences(code)).toBe(code) + }) + + it('returns the original when stripping would leave nothing', () => { + const empty = '```python\n```' + expect(stripCodeFences(empty)).toBe(empty) + }) + + it('is idempotent', () => { + const once = stripCodeFences('```python\nreturn \n```') + expect(stripCodeFences(once)).toBe(once) + }) + + it('handles an empty string', () => { + expect(stripCodeFences('')).toBe('') + }) +}) + +describe('shouldStripCodeFences', () => { + it('strips for code and structured value types', () => { + expect(shouldStripCodeFences('javascript-function-body')).toBe(true) + expect(shouldStripCodeFences('custom-tool-schema')).toBe(true) + expect(shouldStripCodeFences('json-object')).toBe(true) + expect(shouldStripCodeFences('cron-expression')).toBe(true) + }) + + it('does not strip free-form prose', () => { + expect(shouldStripCodeFences('system-prompt')).toBe(false) + }) + + it('does not strip when no generation type is declared', () => { + expect(shouldStripCodeFences(undefined)).toBe(false) + expect(shouldStripCodeFences('')).toBe(false) + }) + + it('does not strip an unrecognized type', () => { + expect(shouldStripCodeFences('something-new')).toBe(false) + }) +}) diff --git a/apps/sim/lib/wand/strip-code-fences.ts b/apps/sim/lib/wand/strip-code-fences.ts new file mode 100644 index 00000000000..8900bb18602 --- /dev/null +++ b/apps/sim/lib/wand/strip-code-fences.ts @@ -0,0 +1,84 @@ +import type { GenerationType } from '@/blocks/types' + +/** A markdown fence delimiter at the start of a line, ignoring indentation. */ +const FENCE_LINE = /^\s*```/ + +/** + * Whether a wand generation's output is a raw machine value, where a leading + * markdown fence is always wrong and must be removed. + * + * Declared as a total `Record` so adding a `GenerationType` fails the build + * until the new type opts in or out deliberately — a silent default would let a + * prose type start stripping fences (or a code type stop) without review. + * + * `system-prompt` is the sole exclusion: it is free-form prose for a model, so a + * fenced example inside it is legitimate authored content, not a formatting slip. + */ +const STRIPS_CODE_FENCES: Record = { + 'javascript-function-body': true, + 'typescript-function-body': true, + 'json-schema': true, + 'json-object': true, + 'table-schema': true, + 'system-prompt': false, + 'custom-tool-schema': true, + 'sql-query': true, + postgrest: true, + 'mongodb-filter': true, + 'mongodb-pipeline': true, + 'mongodb-sort': true, + 'mongodb-documents': true, + 'mongodb-update': true, + 'neo4j-cypher': true, + 'neo4j-parameters': true, + timestamp: true, + timezone: true, + 'cron-expression': true, + 'odata-expression': true, +} + +/** + * Whether generated content for this type should have markdown fences stripped. + * + * An absent type means the field's `wandConfig` never declared one, which is the + * case for free-form prose fields — those are left untouched. + */ +export function shouldStripCodeFences(generationType?: string): boolean { + if (!generationType) return false + return STRIPS_CODE_FENCES[generationType as GenerationType] === true +} + +/** + * Removes the markdown code fences a model wrapped around a raw value. + * + * Applies only when the response *opens* with a fence. Content that merely + * contains a fence later is left untouched, because a backtick run inside a + * template literal or a docstring is valid code that must survive verbatim — + * a false positive here would corrupt working code, which is far worse than + * leaving a rare unwrapped response for the user to fix. + * + * Within a fenced response every fenced region is kept and everything between + * them is dropped: text outside a fence is prose, which is never valid code. + * Falls back to the original text if stripping would leave nothing. + */ +export function stripCodeFences(text: string): string { + if (!text.trimStart().startsWith('```')) return text + + const collected: string[] = [] + let insideFence = false + + for (const line of text.split('\n')) { + if (FENCE_LINE.test(line)) { + insideFence = !insideFence + continue + } + if (insideFence) collected.push(line) + } + + // Trim blank lines only — leading whitespace on a kept line is indentation, + // which is load-bearing in Python. + while (collected.length > 0 && collected[0].trim() === '') collected.shift() + while (collected.length > 0 && collected[collected.length - 1].trim() === '') collected.pop() + + return collected.length > 0 ? collected.join('\n') : text +} From 4e7dfb03f9aa31652cd03c8ee46be5699746f725 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 10:43:36 -0700 Subject: [PATCH 2/6] 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. --- .../w/[workflowId]/hooks/use-wand.ts | 26 +++++++++++-- apps/sim/lib/wand/strip-code-fences.test.ts | 18 ++++++++- apps/sim/lib/wand/strip-code-fences.ts | 37 +++++++++++++------ 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index 4664e607a3f..d9b5838cb34 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' @@ -143,11 +143,23 @@ export function useWand({ * correctly makes the first render a no-op. */ const [prevHistoryResetKey, setPrevHistoryResetKey] = useState(historyResetKey) + const [historyEpoch, setHistoryEpoch] = useState(0) if (prevHistoryResetKey !== historyResetKey) { setPrevHistoryResetKey(historyResetKey) setConversationHistory([]) + setHistoryEpoch((epoch) => epoch + 1) } + /** + * Mirrors {@link historyEpoch} for the in-flight request to read on completion. + * A request that started before a reset must not append its turn to the fresh + * history — its prompt and reply belong to the superseded context. + */ + const historyEpochRef = useRef(historyEpoch) + useEffect(() => { + historyEpochRef.current = historyEpoch + }, [historyEpoch]) + const abortControllerRef = useRef(null) const showPromptInline = useCallback(() => { @@ -192,6 +204,9 @@ export function useWand({ setError(null) setPromptInputValue('') + /** The context this request belongs to; a reset while it streams retires it. */ + const startedHistoryEpoch = historyEpochRef.current + abortControllerRef.current = new AbortController() if (onStreamStart) { @@ -258,9 +273,12 @@ export function useWand({ if (generatedContent) { onGeneratedContent(generatedContent) - if (wandConfig?.maintainHistory) { - // The sanitized form goes into history so a single fenced reply - // cannot become the in-context example for every later turn. + /** + * The sanitized form goes into history so a single fenced reply cannot + * become the in-context example for every later turn. Skipped entirely + * when a reset retired this request's context mid-flight. + */ + if (wandConfig?.maintainHistory && historyEpochRef.current === startedHistoryEpoch) { setConversationHistory((prev) => [ ...prev, { role: 'user', content: currentPrompt }, diff --git a/apps/sim/lib/wand/strip-code-fences.test.ts b/apps/sim/lib/wand/strip-code-fences.test.ts index 04af25bc22e..bf1fab1ead2 100644 --- a/apps/sim/lib/wand/strip-code-fences.test.ts +++ b/apps/sim/lib/wand/strip-code-fences.test.ts @@ -35,9 +35,23 @@ describe('stripCodeFences', () => { expect(stripCodeFences(fenced)).toBe('if :\n return "yes"\nreturn "no"') }) - it('keeps every fenced region and drops prose between them', () => { + it('preserves fence lines embedded inside the fenced body', () => { + const fenced = '```javascript\nconst md = `\n```\nhello\n```\n`;\nreturn md;\n```' + expect(stripCodeFences(fenced)).toBe('const md = `\n```\nhello\n```\n`;\nreturn md;') + }) + + it('preserves a fenced docstring inside a Python body', () => { + const fenced = '```python\ntemplate = """\n```sql\nSELECT 1\n```\n"""\nreturn template\n```' + expect(stripCodeFences(fenced)).toBe( + 'template = """\n```sql\nSELECT 1\n```\n"""\nreturn template' + ) + }) + + it('keeps everything between the outer delimiters for a multi-block answer', () => { + // Prose survives rather than risk dropping code between two delimiters that + // may be a nested literal instead of a block boundary. const fenced = '```js\nconst a = 1;\n```\nThen send it:\n```js\nreturn a;\n```' - expect(stripCodeFences(fenced)).toBe('const a = 1;\nreturn a;') + expect(stripCodeFences(fenced)).toBe('const a = 1;\n```\nThen send it:\n```js\nreturn a;') }) it('does not touch code that merely contains a fence later', () => { diff --git a/apps/sim/lib/wand/strip-code-fences.ts b/apps/sim/lib/wand/strip-code-fences.ts index 8900bb18602..5cfa254218f 100644 --- a/apps/sim/lib/wand/strip-code-fences.ts +++ b/apps/sim/lib/wand/strip-code-fences.ts @@ -57,28 +57,41 @@ export function shouldStripCodeFences(generationType?: string): boolean { * a false positive here would corrupt working code, which is far worse than * leaving a rare unwrapped response for the user to fix. * - * Within a fenced response every fenced region is kept and everything between - * them is dropped: text outside a fence is prose, which is never valid code. + * Only the outermost delimiters are removed: everything between the first and + * last fence line is kept verbatim, including any fence lines inside it. A + * generated body may legitimately contain line-leading backticks (code that + * builds a markdown string), and pairing delimiters off would silently discard + * the lines between them. The cost is that a model which answers with several + * fenced blocks and prose between them keeps that prose — visibly wrong output + * the user can re-roll, rather than code quietly missing a chunk. + * * Falls back to the original text if stripping would leave nothing. */ export function stripCodeFences(text: string): string { if (!text.trimStart().startsWith('```')) return text - const collected: string[] = [] - let insideFence = false + const lines = text.split('\n') + const openingFence = lines.findIndex((line) => FENCE_LINE.test(line)) + if (openingFence === -1) return text - for (const line of text.split('\n')) { - if (FENCE_LINE.test(line)) { - insideFence = !insideFence - continue + let closingFence = -1 + for (let index = lines.length - 1; index > openingFence; index--) { + if (FENCE_LINE.test(lines[index])) { + closingFence = index + break } - if (insideFence) collected.push(line) } + // An unclosed fence (a truncated response) keeps everything after the opener. + const inner = + closingFence === -1 + ? lines.slice(openingFence + 1) + : lines.slice(openingFence + 1, closingFence) + // Trim blank lines only — leading whitespace on a kept line is indentation, // which is load-bearing in Python. - while (collected.length > 0 && collected[0].trim() === '') collected.shift() - while (collected.length > 0 && collected[collected.length - 1].trim() === '') collected.pop() + while (inner.length > 0 && inner[0].trim() === '') inner.shift() + while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop() - return collected.length > 0 ? collected.join('\n') : text + return inner.length > 0 ? inner.join('\n') : text } From fb8337a1b86c57576ed013d19cde5bf1264c4bf6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 10:54:13 -0700 Subject: [PATCH 3/6] 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. --- apps/sim/lib/wand/strip-code-fences.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/wand/strip-code-fences.ts b/apps/sim/lib/wand/strip-code-fences.ts index 5cfa254218f..03eebc0830e 100644 --- a/apps/sim/lib/wand/strip-code-fences.ts +++ b/apps/sim/lib/wand/strip-code-fences.ts @@ -68,12 +68,16 @@ export function shouldStripCodeFences(generationType?: string): boolean { * Falls back to the original text if stripping would leave nothing. */ export function stripCodeFences(text: string): string { - if (!text.trimStart().startsWith('```')) return text - const lines = text.split('\n') const openingFence = lines.findIndex((line) => FENCE_LINE.test(line)) if (openingFence === -1) return text + // Anything non-blank ahead of the first fence means the response does not open + // with one, so the backticks belong to the content. + for (let index = 0; index < openingFence; index++) { + if (lines[index].trim() !== '') return text + } + let closingFence = -1 for (let index = lines.length - 1; index > openingFence; index--) { if (FENCE_LINE.test(lines[index])) { From c89adb24bc2318691a8fa130d169165769629234 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 10:58:09 -0700 Subject: [PATCH 4/6] 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. --- .../[workspaceId]/w/[workflowId]/hooks/use-wand.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index d9b5838cb34..8572a1071c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -111,7 +111,6 @@ interface UseWandProps { onGeneratedContent: (content: string) => void onStreamChunk?: (chunk: string) => void onStreamStart?: () => void - onGenerationComplete?: (prompt: string, generatedContent: string) => void } export function useWand({ @@ -122,7 +121,6 @@ export function useWand({ onGeneratedContent, onStreamChunk, onStreamStart, - onGenerationComplete, }: UseWandProps) { const queryClient = useQueryClient() const { navigateToSettings } = useSettingsNavigation() @@ -285,10 +283,6 @@ export function useWand({ { role: 'assistant', content: generatedContent }, ]) } - - if (onGenerationComplete) { - onGenerationComplete(currentPrompt, generatedContent) - } } logger.debug('Wand generation completed', { @@ -334,7 +328,6 @@ export function useWand({ onGeneratedContent, onStreamChunk, onStreamStart, - onGenerationComplete, queryClient, contextParams?.tableId, contextParams?.sandboxId, From 59853574576f7a63e72419db9b0c1c4e890fecb7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 11:05:01 -0700 Subject: [PATCH 5/6] 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. --- .../w/[workflowId]/hooks/use-wand.ts | 9 ++++- apps/sim/lib/wand/strip-code-fences.test.ts | 5 +++ apps/sim/lib/wand/strip-code-fences.ts | 37 +++++++++---------- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index 8572a1071c8..058e714757e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' @@ -152,9 +152,14 @@ export function useWand({ * Mirrors {@link historyEpoch} for the in-flight request to read on completion. * A request that started before a reset must not append its turn to the fresh * history — its prompt and reply belong to the superseded context. + * + * Synced in a layout effect, not a passive one: passive effects flush in a later + * task, so a request settling between the reset's commit and that flush would + * still read the old epoch and append anyway. Layout effects run synchronously + * during commit, before any promise continuation can observe the ref. */ const historyEpochRef = useRef(historyEpoch) - useEffect(() => { + useLayoutEffect(() => { historyEpochRef.current = historyEpoch }, [historyEpoch]) diff --git a/apps/sim/lib/wand/strip-code-fences.test.ts b/apps/sim/lib/wand/strip-code-fences.test.ts index bf1fab1ead2..d39f31c44af 100644 --- a/apps/sim/lib/wand/strip-code-fences.test.ts +++ b/apps/sim/lib/wand/strip-code-fences.test.ts @@ -40,6 +40,11 @@ describe('stripCodeFences', () => { expect(stripCodeFences(fenced)).toBe('const md = `\n```\nhello\n```\n`;\nreturn md;') }) + it('keeps every line when a body with nested fences is truncated mid-response', () => { + const truncated = '```javascript\nconst md = `\n```\nhello\n`;\nreturn md;' + expect(stripCodeFences(truncated)).toBe('const md = `\n```\nhello\n`;\nreturn md;') + }) + it('preserves a fenced docstring inside a Python body', () => { const fenced = '```python\ntemplate = """\n```sql\nSELECT 1\n```\n"""\nreturn template\n```' expect(stripCodeFences(fenced)).toBe( diff --git a/apps/sim/lib/wand/strip-code-fences.ts b/apps/sim/lib/wand/strip-code-fences.ts index 03eebc0830e..a4012514ee3 100644 --- a/apps/sim/lib/wand/strip-code-fences.ts +++ b/apps/sim/lib/wand/strip-code-fences.ts @@ -57,13 +57,16 @@ export function shouldStripCodeFences(generationType?: string): boolean { * a false positive here would corrupt working code, which is far worse than * leaving a rare unwrapped response for the user to fix. * - * Only the outermost delimiters are removed: everything between the first and - * last fence line is kept verbatim, including any fence lines inside it. A - * generated body may legitimately contain line-leading backticks (code that - * builds a markdown string), and pairing delimiters off would silently discard - * the lines between them. The cost is that a model which answers with several - * fenced blocks and prose between them keeps that prose — visibly wrong output - * the user can re-roll, rather than code quietly missing a chunk. + * Only two lines can ever be removed: the opening fence, and the final line when + * it is also a fence. An interior fence line is always treated as content, because + * a generated body may legitimately contain line-leading backticks (code that + * builds a markdown string) and there is no way to tell that apart from a + * delimiter. Scanning for the *last* fence anywhere would truncate such a body + * whenever the response is cut off before its closing fence. + * + * The cost is that a model which answers with several fenced blocks and prose + * between them keeps that prose — visibly wrong output the user can re-roll, + * rather than code quietly missing a chunk. * * Falls back to the original text if stripping would leave nothing. */ @@ -78,22 +81,16 @@ export function stripCodeFences(text: string): string { if (lines[index].trim() !== '') return text } - let closingFence = -1 - for (let index = lines.length - 1; index > openingFence; index--) { - if (FENCE_LINE.test(lines[index])) { - closingFence = index - break - } - } - - // An unclosed fence (a truncated response) keeps everything after the opener. - const inner = - closingFence === -1 - ? lines.slice(openingFence + 1) - : lines.slice(openingFence + 1, closingFence) + const inner = lines.slice(openingFence + 1) // Trim blank lines only — leading whitespace on a kept line is indentation, // which is load-bearing in Python. + while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop() + + // Only the very last line may close the wrapper. A truncated response simply + // has no closer, and every line after the opener survives. + if (inner.length > 0 && FENCE_LINE.test(inner[inner.length - 1])) inner.pop() + while (inner.length > 0 && inner[0].trim() === '') inner.shift() while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop() From 68e11923fa0086112ccb5bfd6923bb6327d6fe21 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 11:10:47 -0700 Subject: [PATCH 6/6] 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. --- apps/sim/lib/wand/strip-code-fences.test.ts | 8 ++++++++ apps/sim/lib/wand/strip-code-fences.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/wand/strip-code-fences.test.ts b/apps/sim/lib/wand/strip-code-fences.test.ts index d39f31c44af..0dbf8af603a 100644 --- a/apps/sim/lib/wand/strip-code-fences.test.ts +++ b/apps/sim/lib/wand/strip-code-fences.test.ts @@ -45,6 +45,14 @@ describe('stripCodeFences', () => { expect(stripCodeFences(truncated)).toBe('const md = `\n```\nhello\n`;\nreturn md;') }) + it('treats a trailing bare fence as the closer even when the body was truncated at one', () => { + // Irreducibly ambiguous: a trailing bare fence closes the wrapper in every + // well-formed response, and is content only when generation stopped exactly + // at an embedded delimiter. Declining to strip it would leave a stray fence + // in the common case, which is the bug this util exists to fix. + expect(stripCodeFences('```javascript\nconst md = `\n```')).toBe('const md = `') + }) + it('preserves a fenced docstring inside a Python body', () => { const fenced = '```python\ntemplate = """\n```sql\nSELECT 1\n```\n"""\nreturn template\n```' expect(stripCodeFences(fenced)).toBe( diff --git a/apps/sim/lib/wand/strip-code-fences.ts b/apps/sim/lib/wand/strip-code-fences.ts index a4012514ee3..7a888d6bfff 100644 --- a/apps/sim/lib/wand/strip-code-fences.ts +++ b/apps/sim/lib/wand/strip-code-fences.ts @@ -88,7 +88,10 @@ export function stripCodeFences(text: string): string { while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop() // Only the very last line may close the wrapper. A truncated response simply - // has no closer, and every line after the opener survives. + // has no closer, and every line after the opener survives. The one case this + // cannot get right is generation stopping exactly on an embedded delimiter, + // where that final line is content — indistinguishable from a real closer, and + // rarer than the wrap it would otherwise fail to strip. if (inner.length > 0 && FENCE_LINE.test(inner[inner.length - 1])) inner.pop() while (inner.length > 0 && inner[0].trim() === '') inner.shift()