Skip to content

Commit b20cfcd

Browse files
committed
fix(chat): enforce the table selection budget over the whole rendered content
Cursor Bugbot: the budget subtracted only the header and divider before packing rows, then prepended the 'Selected ...' prose and the newlines afterward, so the final content could exceed MAX_TABLE_SELECTION_CONTENT_LENGTH whenever the last accepted row left less slack than the prefix needed. The cap the TSDoc promises was not actually enforced. The prior test passed while missing this: its rows were wide enough that packing stopped far short of the limit, so the boundary was never exercised. Replaced with rows sized to fill the budget almost exactly, asserting both that the content stays within the cap and that it still approaches it (so the assertion can't pass by emitting an empty table). Also capitalize Chat in the three 'Add to Chat' menu labels, matching the constitution's module naming and the existing 'Fix in Chat' / 'Troubleshoot in Chat' UI strings.
1 parent 231e0a0 commit b20cfcd

6 files changed

Lines changed: 67 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function EditorContextMenu({
6767
<>
6868
<DropdownMenuItem disabled={!hasSelection} onSelect={onAddToChat}>
6969
<Blimp />
70-
Add to chat
70+
Add to Chat
7171
</DropdownMenuItem>
7272
<DropdownMenuSeparator />
7373
</>

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ export function EditorBubbleMenu({
254254
<>
255255
<ToolbarButton
256256
icon={Blimp}
257-
label='Add to chat'
257+
label='Add to Chat'
258258
isActive={false}
259259
onClick={onAddToChat}
260260
/>

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function ContextMenu({
8585
disableDuplicate = false,
8686
disableDelete = false,
8787
onAddToChat,
88-
addToChatLabel = 'Add to chat',
88+
addToChatLabel = 'Add to Chat',
8989
}: ContextMenuProps) {
9090
const count = selectedRowCount.toLocaleString()
9191
const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4631,7 +4631,7 @@ export function TableGrid({
46314631
disableDuplicate={!canInsertFullRow}
46324632
disableDelete={!canDeleteRow}
46334633
onAddToChat={contextMenuRowIds.length > 0 ? handleAddSelectionToChat : undefined}
4634-
addToChatLabel={contextMenuColumnIds ? 'Add cell range to chat' : 'Add rows to chat'}
4634+
addToChatLabel={contextMenuColumnIds ? 'Add cell range to Chat' : 'Add rows to Chat'}
46354635
/>
46364636

46374637
<ExpandedCellPopover

apps/sim/lib/copilot/chat/process-contents.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,46 @@ describe('processContextsServer - table_selection contexts', () => {
491491
expect(result).toEqual([])
492492
})
493493

494+
it('keeps the whole rendered content within budget when rows pack tightly', async () => {
495+
// Rows small enough to fill the budget almost exactly: the last accepted row
496+
// leaves only a few characters of slack, so a budget that forgot to reserve
497+
// the prose prefix and newlines overruns the cap here while passing on
498+
// coarse fixtures that stop far short of the limit.
499+
const cell = 'x'.repeat(100)
500+
const rows = Array.from({ length: MAX_TABLE_SELECTION_ROWS }, (_, i) => ({
501+
id: `r${i}`,
502+
data: { c_notes: cell },
503+
}))
504+
getTableById.mockResolvedValue({
505+
name: 'Sales',
506+
workspaceId: 'ws-1',
507+
schema: { columns: [{ id: 'c_notes', name: 'Notes' }] },
508+
})
509+
getRowsByIds.mockResolvedValue(rows)
510+
511+
const result = await processContextsServer(
512+
[
513+
{
514+
kind: 'table_selection',
515+
tableId: 'tbl-1',
516+
tableName: 'Sales',
517+
label: 'Sales (500 rows)',
518+
rowIds: rows.map((r) => r.id),
519+
} as ChatContext,
520+
],
521+
'user-1',
522+
'summarize',
523+
'ws-1'
524+
)
525+
526+
const [ctx] = result
527+
expect(ctx.content.length).toBeLessThanOrEqual(MAX_TABLE_SELECTION_CONTENT_LENGTH)
528+
// Guard against passing by emitting almost nothing — it must still be a
529+
// real table that genuinely approaches the cap.
530+
expect(ctx.content.length).toBeGreaterThan(MAX_TABLE_SELECTION_CONTENT_LENGTH * 0.9)
531+
expect(ctx.content).toContain('omitted for length')
532+
})
533+
494534
it('spends a character budget across rows and reports what it omitted', async () => {
495535
// Row/column caps alone don't bound prompt cost: wide cells blow past the
496536
// budget long before MAX_TABLE_SELECTION_ROWS.

apps/sim/lib/copilot/chat/process-contents.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,14 @@ async function resolveFileSelectionResource(
930930
}
931931
}
932932

933+
/**
934+
* Longest form the size clause can take for `total` rows — every row omitted.
935+
* Used to reserve prefix space before the real counts are known.
936+
*/
937+
function worstCaseSizeClause(total: number): string {
938+
return `${total} of ${total}, ${total} omitted for length`
939+
}
940+
933941
/** Renders one cell for a markdown table row, escaping the delimiters. */
934942
function renderTableCell(value: unknown): string {
935943
if (value === null || value === undefined) return ''
@@ -971,24 +979,32 @@ async function resolveTableSelectionResource(
971979

972980
const header = `| ${columns.map((c) => c.name).join(' | ')} |`
973981
const divider = `| ${columns.map(() => '---').join(' | ')} |`
982+
const scope = hasColumnScope ? 'cell range' : 'rows'
983+
const describe = (size: string) =>
984+
`Selected ${scope} from table "${table.name}" (${size}):\n\n${header}\n${divider}\n`
974985

975-
// Spend the character budget row by row: the row cap alone doesn't bound the
976-
// prompt cost. The first row is always emitted, so a single oversized row
977-
// still yields a table rather than an empty one.
986+
// Spend the character budget row by row. Everything that is not a row — the
987+
// prose, the table head, and every newline — is reserved up front, or the cap
988+
// is silently overrun whenever the last row leaves less slack than the prefix
989+
// needs. The reserve uses the longest form the size clause can take (every row
990+
// omitted), since its real value isn't known until packing finishes; a few
991+
// characters of unused slack beats overshooting the documented bound.
978992
const lines: string[] = []
979-
let remaining = MAX_TABLE_SELECTION_CONTENT_LENGTH - header.length - divider.length
993+
let remaining =
994+
MAX_TABLE_SELECTION_CONTENT_LENGTH - describe(worstCaseSizeClause(rows.length)).length
980995
for (const row of rows) {
981996
const line = `| ${columns.map((col) => renderTableCell(row.data[getColumnId(col)])).join(' | ')} |`
982-
if (lines.length > 0 && line.length > remaining) break
997+
// The first row always goes in, so a single oversized row still yields a
998+
// table rather than an empty one.
999+
if (lines.length > 0 && line.length + 1 > remaining) break
9831000
lines.push(line)
9841001
remaining -= line.length + 1
9851002
}
9861003

9871004
const omitted = rows.length - lines.length
9881005
const shown = `${lines.length} ${lines.length === 1 ? 'row' : 'rows'}`
989-
const scope = hasColumnScope ? 'cell range' : 'rows'
9901006
const size = omitted > 0 ? `${shown} of ${rows.length}, ${omitted} omitted for length` : shown
991-
const content = `Selected ${scope} from table "${table.name}" (${size}):\n\n${header}\n${divider}\n${lines.join('\n')}`
1007+
const content = `${describe(size)}${lines.join('\n')}`
9921008
return {
9931009
type: 'table_selection',
9941010
tag: label ? `@${label}` : '@',

0 commit comments

Comments
 (0)