-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(wand): stop markdown code fences landing in generated code #6289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
fe0cd6a
fix(wand): stop markdown code fences landing in generated code
waleedlatif1 4e7dfb0
fix(wand): preserve nested fences and retire stale history on reset
waleedlatif1 fb8337a
refactor(wand): drop unreachable guard in fence stripper
waleedlatif1 c89adb2
chore(wand): remove unused onGenerationComplete callback
waleedlatif1 5985357
fix(wand): never treat an interior fence line as the closer
waleedlatif1 68e1192
test(wand): record the trailing-fence ambiguity as a decision
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /** | ||
| * @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 = <a> + <b>;\nreturn total;' | ||
| expect(stripCodeFences(code)).toBe(code) | ||
| }) | ||
|
|
||
| it('unwraps a fully wrapped response', () => { | ||
| expect(stripCodeFences('```python\nresult = <num1> + <num2>\nreturn result\n```')).toBe( | ||
| 'result = <num1> + <num2>\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 <flag>:\n return "yes"\nreturn "no"\n```' | ||
| expect(stripCodeFences(fenced)).toBe('if <flag>:\n return "yes"\nreturn "no"') | ||
| }) | ||
|
|
||
| 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;\n```\nThen send it:\n```js\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 <x>\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) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| 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<GenerationType, boolean> = { | ||
| '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. | ||
| * | ||
| * 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 { | ||
| 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])) { | ||
| closingFence = index | ||
| break | ||
| } | ||
| } | ||
|
waleedlatif1 marked this conversation as resolved.
Outdated
|
||
|
|
||
| // An unclosed fence (a truncated response) keeps everything after the opener. | ||
| const inner = | ||
| closingFence === -1 | ||
| ? lines.slice(openingFence + 1) | ||
| : lines.slice(openingFence + 1, closingFence) | ||
|
waleedlatif1 marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Trim blank lines only — leading whitespace on a kept line is indentation, | ||
| // which is load-bearing in Python. | ||
| while (inner.length > 0 && inner[0].trim() === '') inner.shift() | ||
| while (inner.length > 0 && inner[inner.length - 1].trim() === '') inner.pop() | ||
|
|
||
| return inner.length > 0 ? inner.join('\n') : text | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.