Skip to content

Commit 9ca96f4

Browse files
committed
fix(copilot): let a read placeholder carry its own kind instead of sniffing its text
Three rounds of review found the same defect in three places: a gate matching a prefix no producer emitted so it never fired, a real file misread as a refusal, a filename newline hiding a refusal. Each fix tightened the text matching and the next round found another way the text was the wrong thing to trust. So stop trusting it. `readPlaceholder.*` now returns the whole read result — content, totalLines, and a `placeholder` kind — and the two classifiers read the kind. A producer cannot emit a placeholder without tagging it, and no amount of user content can imitate one, so both failure directions are gone by construction rather than by a better pattern. `oversized` (the four size refusals) is reported as a tool error; `unreadable` (undecodable, binary, unparseable) is returned as content. The type flows through FileReadResult and ReadResult, so the compiler checks the tag survives to the gates rather than leaving a silent drop to be discovered later. Also removes the totalLines-of-1 heuristic, which was only ever a proxy for this.
1 parent 2544263 commit 9ca96f4

7 files changed

Lines changed: 152 additions & 152 deletions

File tree

apps/sim/lib/copilot/tools/handlers/vfs.test.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,9 @@ describe('vfs handlers oversize policy', () => {
119119

120120
it('fails file-backed oversized read placeholders with original message', async () => {
121121
const vfs = makeVfs()
122-
vfs.readFileContent.mockResolvedValue({
123-
content: '[File too large to display inline: big.txt (6000000 bytes, limit 5242880)]',
124-
totalLines: 1,
125-
})
122+
vfs.readFileContent.mockResolvedValue(
123+
readPlaceholder.fileTooLarge('big.txt', 6_000_000, 5_242_880)
124+
)
126125
getOrMaterializeVFS.mockResolvedValue(vfs)
127126

128127
const result = await executeVfsRead(
@@ -182,17 +181,17 @@ describe('vfs handlers oversize policy', () => {
182181

183182
/**
184183
* Every size refusal is a failed read, whichever path produced it. Built from the
185-
* producers so a prefix leaving `OVERSIZED_PREFIXES` fails here rather than
184+
* producers so one that stops tagging itself `oversized` fails here rather than
186185
* silently downgrading a refusal to a one-line "successful" read.
187186
*/
188187
it.each([
189188
['image', readPlaceholder.imageTooLarge('huge.png', 99, 5)],
190189
['file', readPlaceholder.fileTooLarge('huge.txt', 99, 5)],
191190
['document', readPlaceholder.documentTooLarge('huge.pdf', 99, 5)],
192191
['compiled artifact', readPlaceholder.compiledArtifactTooLarge('app.js', 99, 5)],
193-
])('fails the read when a %s exceeds its size limit', async (_kind, content) => {
192+
])('fails the read when a %s exceeds its size limit', async (_kind, placeholder) => {
194193
const vfs = makeVfs()
195-
vfs.readFileContent.mockResolvedValue({ content, totalLines: 1 })
194+
vfs.readFileContent.mockResolvedValue(placeholder)
196195
getOrMaterializeVFS.mockResolvedValue(vfs)
197196

198197
const result = await executeVfsRead(
@@ -202,14 +201,31 @@ describe('vfs handlers oversize policy', () => {
202201

203202
expect(result.success).toBe(false)
204203
// The placeholder verbatim, not the generic "grep this instead" fallback.
205-
expect(result.error).toBe(content)
204+
expect(result.error).toBe(placeholder.content)
205+
})
206+
207+
it('still fails the read when the stored name contains a newline', async () => {
208+
// Nothing about the message text decides this, so a name that would break a
209+
// text-shape match cannot hide a refusal.
210+
const vfs = makeVfs()
211+
const placeholder = readPlaceholder.fileTooLarge('we\nird.txt', 99, 5)
212+
vfs.readFileContent.mockResolvedValue(placeholder)
213+
getOrMaterializeVFS.mockResolvedValue(vfs)
214+
215+
const result = await executeVfsRead(
216+
{ path: 'files/weird/content' },
217+
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
218+
)
219+
220+
expect(result.success).toBe(false)
221+
expect(result.error).toBe(placeholder.content)
206222
})
207223

208-
it('returns a real file that merely opens like a size refusal', async () => {
209-
// Prefix-only matching would turn this user's file into a tool error. The
210-
// refusal is recognised by its whole shape, which real prose does not have.
224+
it('returns a real file whose content is exactly a size-refusal message', async () => {
225+
// Untagged, so it is content. Recognising refusals by their text would turn this
226+
// user's file into a tool error instead of returning it.
211227
const vfs = makeVfs()
212-
const content = '[Document too large to parse inline: is the message we emit here]'
228+
const { content } = readPlaceholder.documentTooLarge('huge.pdf', 99, 5)
213229
vfs.readFileContent.mockResolvedValue({ content, totalLines: 1 })
214230
getOrMaterializeVFS.mockResolvedValue(vfs)
215231

@@ -226,12 +242,13 @@ describe('vfs handlers oversize policy', () => {
226242
const vfs = makeVfs()
227243
// Not a size problem — the bytes were read fine and the reason is already in the
228244
// message, so the model should see it rather than a "too large, use grep" error.
229-
const content = readPlaceholder.imageUnavailable(
245+
const placeholder = readPlaceholder.imageUnavailable(
230246
'bomb.png',
231247
90,
232248
'It is too large to decode safely.'
233249
)
234-
vfs.readFileContent.mockResolvedValue({ content, totalLines: 1 })
250+
const content = placeholder.content
251+
vfs.readFileContent.mockResolvedValue(placeholder)
235252
getOrMaterializeVFS.mockResolvedValue(vfs)
236253

237254
const result = await executeVfsRead(

apps/sim/lib/copilot/tools/handlers/vfs.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ export async function executeVfsRead(
330330
const isAttachment = hasModelAttachment(uploadResult)
331331
if (
332332
!isAttachment &&
333-
(isOversizedReadPlaceholder(uploadResult.content) ||
333+
(isOversizedReadPlaceholder(uploadResult) ||
334334
serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS)
335335
) {
336336
logger.warn('Upload read result too large', {
@@ -341,7 +341,7 @@ export async function executeVfsRead(
341341
})
342342
return {
343343
success: false,
344-
error: isOversizedReadPlaceholder(uploadResult.content)
344+
error: isOversizedReadPlaceholder(uploadResult)
345345
? uploadResult.content
346346
: // Same as the workspace-file branch below: this size gate runs on
347347
// the whole upload before any window, so "retry with offset/limit"
@@ -400,7 +400,7 @@ export async function executeVfsRead(
400400
const isAttachment = hasModelAttachment(fileContent)
401401
if (
402402
!isAttachment &&
403-
(isOversizedReadPlaceholder(fileContent.content) ||
403+
(isOversizedReadPlaceholder(fileContent) ||
404404
serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS)
405405
) {
406406
logger.warn('File read result too large', {
@@ -411,7 +411,7 @@ export async function executeVfsRead(
411411
})
412412
return {
413413
success: false,
414-
error: isOversizedReadPlaceholder(fileContent.content)
414+
error: isOversizedReadPlaceholder(fileContent)
415415
? fileContent.content
416416
: 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.',
417417
}
@@ -459,7 +459,7 @@ export async function executeVfsRead(
459459
}
460460
if (
461461
!hasModelAttachment(result) &&
462-
(isOversizedReadPlaceholder(result.content) ||
462+
(isOversizedReadPlaceholder(result) ||
463463
serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS)
464464
) {
465465
return {

apps/sim/lib/copilot/vfs/file-reader.ts

Lines changed: 15 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
1212
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
1313
import { recordFileRead } from '@/lib/copilot/request/metrics'
1414
import { markSpanForError } from '@/lib/copilot/request/otel'
15-
import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders'
15+
import { type PlaceholderKind, readPlaceholder } from '@/lib/copilot/vfs/read-placeholders'
1616
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1717
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1818
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -436,6 +436,8 @@ async function prepareImageForVision(
436436
export interface FileReadResult {
437437
content: string
438438
totalLines: number
439+
/** Set when `content` stands in for the file rather than being it — see `readPlaceholder`. */
440+
placeholder?: PlaceholderKind
439441
attachment?: {
440442
type: string
441443
name?: string
@@ -478,10 +480,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
478480
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image)
479481
const imageTooLarge = (bytes: number) => {
480482
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
481-
return {
482-
content: readPlaceholder.imageTooLarge(record.name, bytes, MAX_IMAGE_SOURCE_BYTES),
483-
totalLines: 1,
484-
}
483+
return readPlaceholder.imageTooLarge(record.name, bytes, MAX_IMAGE_SOURCE_BYTES)
485484
}
486485
// The recorded size only skips a doomed download; the cap on the download
487486
// itself is what bounds the bytes actually read.
@@ -492,16 +491,13 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
492491
const prepared = await prepareImageForVision(fetched.buffer, record.type)
493492
if (!prepared.ok) {
494493
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
495-
return {
496-
// The fetched buffer, not `record.size`: the bytes are in hand by now,
497-
// so there is no reason to quote the client-declared figure back.
498-
content: readPlaceholder.imageUnavailable(
499-
record.name,
500-
fetched.buffer.length,
501-
prepared.reason
502-
),
503-
totalLines: 1,
504-
}
494+
// The fetched buffer, not `record.size`: the bytes are in hand by now, so
495+
// there is no reason to quote the client-declared figure back.
496+
return readPlaceholder.imageUnavailable(
497+
record.name,
498+
fetched.buffer.length,
499+
prepared.reason
500+
)
505501
}
506502
const { buffer, mediaType, resized } = prepared.image
507503
const sizeKb = (buffer.length / 1024).toFixed(1)
@@ -531,10 +527,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
531527
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Text)
532528
const textTooLarge = (bytes: number) => {
533529
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge)
534-
return {
535-
content: readPlaceholder.fileTooLarge(record.name, bytes, MAX_TEXT_READ_BYTES),
536-
totalLines: 1,
537-
}
530+
return readPlaceholder.fileTooLarge(record.name, bytes, MAX_TEXT_READ_BYTES)
538531
}
539532
if (record.size > MAX_TEXT_READ_BYTES) return textTooLarge(record.size)
540533

@@ -559,14 +552,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
559552
TraceAttr.CopilotVfsReadOutcome,
560553
CopilotVfsReadOutcome.DocumentTooLarge
561554
)
562-
return {
563-
content: readPlaceholder.documentTooLarge(
564-
record.name,
565-
bytes,
566-
MAX_PARSEABLE_READ_BYTES
567-
),
568-
totalLines: 1,
569-
}
555+
return readPlaceholder.documentTooLarge(record.name, bytes, MAX_PARSEABLE_READ_BYTES)
570556
}
571557
if (record.size > MAX_PARSEABLE_READ_BYTES) return documentTooLarge(record.size)
572558
const fetched = await fetchWithinLimit(record, MAX_PARSEABLE_READ_BYTES)
@@ -594,21 +580,15 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
594580
[TraceAttr.ErrorMessage]: toError(parseErr).message.slice(0, 500),
595581
})
596582
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ParseFailed)
597-
return {
598-
content: readPlaceholder.couldNotParse(record.name, record.type, record.size),
599-
totalLines: 1,
600-
}
583+
return readPlaceholder.couldNotParse(record.name, record.type, record.size)
601584
}
602585
}
603586

604587
span.setAttributes({
605588
[TraceAttr.CopilotVfsReadPath]: CopilotVfsReadPath.Binary,
606589
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.BinaryPlaceholder,
607590
})
608-
return {
609-
content: readPlaceholder.binaryFile(record.name, record.type, record.size),
610-
totalLines: 1,
611-
}
591+
return readPlaceholder.binaryFile(record.name, record.type, record.size)
612592
} catch (err) {
613593
logger.warn('Failed to read workspace file', {
614594
fileName: record.name,

apps/sim/lib/copilot/vfs/operations.test.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,15 @@ describe('grep regex safety', () => {
197197
})
198198

199199
describe('grepReadResult placeholders', () => {
200-
const grepPlaceholder = (content: string) =>
201-
grepReadResult('files/x.png/content', { content, totalLines: 1 }, 'x', 'files/x.png/content')
200+
const grepResult = (result: {
201+
content: string
202+
totalLines: number
203+
placeholder?: 'oversized' | 'unreadable'
204+
}) => grepReadResult('files/x.png/content', result, 'x', 'files/x.png/content')
202205

203206
/**
204-
* Built from the producers rather than hand-copied: a literal here would only
205-
* prove the matcher agrees with this file, which is exactly the drift that let a
206-
* gate test for a prefix no producer emitted. Covers every builder, so dropping
207-
* one from the shared table fails here.
207+
* Built from the producers rather than hand-assembled: covers every builder, so
208+
* one that stops tagging itself fails here.
208209
*/
209210
const everyPlaceholder = Object.entries({
210211
fileTooLarge: readPlaceholder.fileTooLarge('big.txt', 99, 5),
@@ -218,25 +219,19 @@ describe('grepReadResult placeholders', () => {
218219

219220
it.each(everyPlaceholder)(
220221
'reports the %s placeholder instead of grepping it',
221-
(_name, content) => {
222-
expect(() => grepPlaceholder(content)).toThrow(WorkspaceFileGrepError)
223-
expect(() => grepPlaceholder(content)).toThrow(content)
222+
(_name, result) => {
223+
expect(() => grepResult(result)).toThrow(WorkspaceFileGrepError)
224+
expect(() => grepResult(result)).toThrow(result.content)
224225
}
225226
)
226227

227228
it('still greps ordinary single-line content', () => {
228-
expect(grepPlaceholder('x marks the spot')).toHaveLength(1)
229-
})
230-
231-
it('greps a real multi-line file that merely opens like a placeholder', () => {
232-
// The single-line guard is what keeps this file searchable rather than swallowed.
233-
const content = `${readPlaceholder.binaryFile('app.bin', 'text/plain', 10)}\nx marks the spot`
234-
const matches = grepReadResult(
235-
'files/notes.txt/content',
236-
{ content, totalLines: 2 },
237-
'x',
238-
'files/notes.txt/content'
239-
)
240-
expect(matches.length).toBeGreaterThan(0)
229+
expect(grepResult({ content: 'x marks the spot', totalLines: 1 })).toHaveLength(1)
230+
})
231+
232+
it('greps a real file whose content is exactly a placeholder message', () => {
233+
// Untagged, so it is content — text alone never makes something a placeholder.
234+
const { content } = readPlaceholder.binaryFile('app.bin', 'text/plain', 10)
235+
expect(grepResult({ content, totalLines: 1 })).toHaveLength(1)
241236
})
242237
})

apps/sim/lib/copilot/vfs/operations.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { createLogger } from '@sim/logger'
22
import { truncate } from '@sim/utils/string'
33
import micromatch from 'micromatch'
4-
import { isNonGreppablePlaceholder } from '@/lib/copilot/vfs/read-placeholders'
4+
import {
5+
isNonGreppablePlaceholder,
6+
type PlaceholderKind,
7+
} from '@/lib/copilot/vfs/read-placeholders'
58
import {
69
compileLinearRegex,
710
isPlainText,
@@ -75,7 +78,12 @@ export class WorkspaceFileGrepError extends Error {
7578
*/
7679
export function grepReadResult(
7780
path: string,
78-
result: { content: string; totalLines: number; attachment?: unknown },
81+
result: {
82+
content: string
83+
totalLines: number
84+
placeholder?: PlaceholderKind
85+
attachment?: unknown
86+
},
7987
pattern: string,
8088
readHint: string,
8189
options?: GrepOptions
@@ -85,7 +93,7 @@ export function grepReadResult(
8593
`Cannot grep "${path}" — it has no searchable text (image/binary). Use read("${readHint}") to view it.`
8694
)
8795
}
88-
if (isNonGreppablePlaceholder(result.content, result.totalLines)) {
96+
if (isNonGreppablePlaceholder(result)) {
8997
throw new WorkspaceFileGrepError(result.content)
9098
}
9199
return grep(new Map([[path, result.content]]), pattern, undefined, options)
@@ -94,6 +102,7 @@ export function grepReadResult(
94102
export interface ReadResult {
95103
content: string
96104
totalLines: number
105+
placeholder?: PlaceholderKind
97106
}
98107

99108
/**

0 commit comments

Comments
 (0)