Skip to content

Commit 1943cfe

Browse files
j15zclaude
andcommitted
fix(copilot): trust the raw body for markers once it is known not to be JSON
`literalTextReason` blanks a body's quoted regions before scanning it for tag markers, so that syntax quoted inside a JSON string is not mistaken for a real nested tag. That blanking assumes quotes delimit JSON strings. One unbalanced `"` breaks the assumption: everything after it is treated as string content, which can hide a genuine marker. The verdict then degrades from `foreign-markers` to `never-a-payload`, and the resume changes with it — from the marker offset to past the close — flattening a real tag inside the span. A card already on screen un-renders into raw JSON when the closing tag finally arrives, and a valid tag after it never renders. Blanking is only meaningful while the body might BE JSON. Once viability or a failed parse has proved it never was, that premise is void and the raw text is the honest evidence, so rescan it and resume at the marker. Both routes to "never JSON" now funnel through one branch. The rescan applies to the failed-parse route too, not only the viability one — patching just the latter leaves the same defect reachable through the former. Behaviour for a body that IS valid JSON is untouched: a well-formed payload that fails its shape guard is still discarded, and tag syntax quoted inside a valid payload is still invisible to the scan. Adds the repro as two tests — the settled parse, and frame-by-frame so the un-render is pinned directly — plus two unbalanced-quote fragments to the property corpus. Reverting the rescan fails exactly those two tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2a4c0f4 commit 1943cfe

2 files changed

Lines changed: 66 additions & 3 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,52 @@ describe('parseSpecialTags with <question>', () => {
353353
expect(renderedText(segments)).toContain('</workflow_resource>')
354354
})
355355

356+
it('finds a nested tag an unbalanced quote hid from the blanked scan', () => {
357+
// One stray `"` is enough to make blankJsonStringLiterals treat the REST of
358+
// the body as a string literal, hiding the real `<options>` marker from the
359+
// scan. The verdict then degrades from `foreign-markers` to `never-a-payload`
360+
// and resumes past the close, flattening both nested tags into one literal
361+
// span — so a card already on screen un-renders when the close arrives.
362+
//
363+
// Blanking is only meaningful while the body might BE json. Once viability
364+
// has proved it never was, the raw text is the honest evidence.
365+
const raw =
366+
'Saved <workspace_resource>the notes file "notes.md and here is what to do next: ' +
367+
'<options>[{"title":"Ship it","description":"Open the PR"}]</options>\n' +
368+
'Full path: <workspace_resource>{"type":"file","path":"files/a.md","title":"a.md"}</workspace_resource>'
369+
370+
const { segments } = parseSpecialTags(raw, false)
371+
expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1)
372+
expect(segments.filter((segment) => segment.type === 'workspace_resource')).toHaveLength(1)
373+
// Balancing the quote must reach the same two cards — the quote is the only
374+
// difference, so this pins that it was never load-bearing for the outcome.
375+
const balanced = raw.replace('the notes file "notes.md', 'the notes file notes.md')
376+
const control = parseSpecialTags(balanced, false).segments
377+
expect(control.filter((segment) => segment.type === 'options')).toHaveLength(1)
378+
expect(control.filter((segment) => segment.type === 'workspace_resource')).toHaveLength(1)
379+
})
380+
381+
it('never un-renders that card as the closing tag arrives', () => {
382+
// The frame-level face of the case above, and the invariant it broke: the
383+
// options card is on screen for many frames before the final `>` lands. A
384+
// card that renders must never revert to raw text.
385+
const raw =
386+
'Saved <workspace_resource>the notes file "notes.md and here is what to do next: ' +
387+
'<options>[{"title":"Ship it","description":"Open the PR"}]</options>\n' +
388+
'Full path: <workspace_resource>{"type":"file","path":"files/a.md","title":"a.md"}</workspace_resource>'
389+
390+
let sawCard = false
391+
for (let end = 1; end <= raw.length; end++) {
392+
const { segments } = parseSpecialTags(raw.slice(0, end), true)
393+
const hasCard = segments.some((segment) => segment.type === 'options')
394+
if (hasCard) sawCard = true
395+
expect(!sawCard || hasCard, `options card retracted at frame ${end}`).toBe(true)
396+
}
397+
expect(sawCard).toBe(true)
398+
// ...and the settled parse still has it.
399+
expect(parseSpecialTags(raw, false).segments.some((s) => s.type === 'options')).toBe(true)
400+
})
401+
356402
it('does not delete tag syntax quoted inside the body it rescans', () => {
357403
// The rescan decides on the BLANKED body, so a tag quoted inside a JSON
358404
// string is invisible to it. Resuming at the opener would re-scan that
@@ -849,6 +895,8 @@ describe('parser properties', () => {
849895
"<workspace_resource>{'type':'file'}</workspace_resource> single quotes. ",
850896
'<workspace_resource>the gmail-agent workflow</workspace_resource> prose body. ',
851897
'<thinking>reasoning <options> with a nested marker</thinking> after. ',
898+
'<workspace_resource>the notes file "notes.md unbalanced quote</workspace_resource> after. ',
899+
'<workspace_resource>notes "unbalanced then <options> marker</workspace_resource> tail. ',
852900
'\n\nA paragraph break above. ',
853901
`${'long filler prose. '.repeat(300)}crossing the scan window. `,
854902
]

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -887,13 +887,28 @@ function classifyBody(tagName: (typeof SPECIAL_TAG_NAMES)[number], body: string)
887887
return { kind: 'nested-marker', offsetInBody: verdict.markerOffset }
888888
}
889889
if (inspected.truncated) return { kind: 'unexamined' }
890-
if (verdict?.reason === 'never-a-payload') return { kind: 'never-json' }
891890

892891
// Dropping text is only defensible for a payload the agent actually FORMED.
893892
// `{the Q4 report}` is prose in braces and `{type: "file"}` is an ordinary
894893
// model slip; bracket depth cannot tell either from a real payload, only a
895-
// parse can.
896-
if (isJsonBodied && !isParseableJson(body)) return { kind: 'never-json' }
894+
// parse can. Both routes to that answer are funnelled through one place so the
895+
// rescan below cannot be added to one and forgotten on the other.
896+
const neverJson =
897+
verdict?.reason === 'never-a-payload' || (isJsonBodied && !isParseableJson(body))
898+
899+
if (neverJson) {
900+
// literalTextReason blanked this body's quoted regions on the assumption it
901+
// was JSON. It never was, so that assumption is void — and a body with an
902+
// odd number of `"` blanks the WRONG regions, which can hide a real marker
903+
// and turn what should be `nested-marker` into `never-json`. The difference
904+
// is not academic: `never-json` resumes past the close, flattening a genuine
905+
// tag inside the span, so a card already on screen un-renders when the close
906+
// finally arrives. With the JSON premise gone, the raw text is the honest
907+
// evidence, and a marker in it means the close we matched belongs elsewhere.
908+
const rawMarker = TAG_SHAPED_MARKER.exec(inspected.text)
909+
if (rawMarker) return { kind: 'nested-marker', offsetInBody: rawMarker.index }
910+
return { kind: 'never-json' }
911+
}
897912

898913
return { kind: 'broken-payload' }
899914
}

0 commit comments

Comments
 (0)