Skip to content

Commit eaee669

Browse files
committed
Support split table tools and option recovery
1 parent b5bae34 commit eaee669

12 files changed

Lines changed: 1551 additions & 2 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,3 +1330,33 @@ describe('parseSpecialTags sim_key placeholder', () => {
13301330
}
13311331
})
13321332
})
1333+
1334+
describe('recoverTrailingBareOptions', () => {
1335+
const bareOptions =
1336+
'{"1": {"title": "Fix the tracker", "description": "debug"}, "2": {"title": "Inspect the miss", "description": "look"}}'
1337+
1338+
it('renders a trailing bare-JSON options payload as an options card', () => {
1339+
const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, false)
1340+
const last = segments[segments.length - 1]
1341+
expect(last.type).toBe('options')
1342+
if (last.type === 'options') {
1343+
expect(last.data['1']?.title).toBe('Fix the tracker')
1344+
}
1345+
expect(segments[0]).toEqual({ type: 'text', content: 'Here they are.' })
1346+
})
1347+
1348+
it('never recovers mid-stream — a partial JSON tail must not flicker into a card', () => {
1349+
const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, true)
1350+
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
1351+
})
1352+
1353+
it('leaves ordinary JSON prose alone', () => {
1354+
const { segments } = parseSpecialTags('The config is {"retries": 3, "mode": "fast"}', false)
1355+
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
1356+
})
1357+
1358+
it('does not double-render when a real options tag already parsed', () => {
1359+
const { segments } = parseSpecialTags(`Pick one <options>${bareOptions}</options>`, false)
1360+
expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1)
1361+
})
1362+
})

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,8 +1442,51 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
14421442
segments.push({ type: 'text', content })
14431443
}
14441444

1445+
if (!isStreaming) {
1446+
recoverTrailingBareOptions(segments)
1447+
}
1448+
14451449
return { segments, hasPendingTag }
14461450
}
1451+
/**
1452+
* Recovers a trailing bare-JSON options payload the model emitted WITHOUT the
1453+
* `<options>` wrapper (observed when an automation prompt asks the model to
1454+
* "(re)send suggested actions" and it answers with the JSON as content). The
1455+
* shape check is strict — a non-empty object whose every value is
1456+
* { title, description } with numeric-string keys — so ordinary JSON in prose
1457+
* cannot false-positive. Only a message's FINAL text segment is considered,
1458+
* mirroring the tag contract (options go last), and only when no options tag
1459+
* already parsed. Never applied mid-stream: a partial JSON tail must not
1460+
* flicker between prose and a card.
1461+
*/
1462+
function recoverTrailingBareOptions(segments: ContentSegment[]): void {
1463+
const last = segments[segments.length - 1]
1464+
if (!last || last.type !== 'text') return
1465+
if (segments.some((segment) => segment.type === 'options')) return
1466+
const text = last.content
1467+
if (!text.trimEnd().endsWith('}')) return
1468+
// The payload nests objects, so the START brace is the first one from which
1469+
// the remainder parses — probe brace positions left to right (bounded).
1470+
let start = -1
1471+
let parsed: unknown
1472+
let probe = text.indexOf('{')
1473+
for (let attempts = 0; probe !== -1 && attempts < 20; attempts++) {
1474+
try {
1475+
parsed = JSON.parse(text.slice(probe).trim())
1476+
start = probe
1477+
break
1478+
} catch {
1479+
probe = text.indexOf('{', probe + 1)
1480+
}
1481+
}
1482+
if (start === -1) return
1483+
if (!isOptionsTagData(parsed) || Object.keys(parsed as object).length === 0) return
1484+
if (!Object.keys(parsed as object).every((key) => /^\d+$/.test(key))) return
1485+
const prefix = text.slice(0, start).replace(/\s+$/, '')
1486+
segments.pop()
1487+
if (prefix) segments.push({ type: 'text', content: prefix })
1488+
segments.push({ type: 'options', data: parsed })
1489+
}
14471490

14481491
interface SpecialTagsProps {
14491492
segment: Exclude<ContentSegment, { type: 'text' }>

0 commit comments

Comments
 (0)