diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index 0ec32c4991a..853e60ce484 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -271,7 +271,7 @@ sim files versions read [options] | Option | Required | Description | | --- | --- | --- | | `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | -| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--offset ` | No | First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line. | | `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | @@ -497,7 +497,7 @@ sim files read [options] | Option | Required | Description | | --- | --- | --- | | `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | -| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--offset ` | No | First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line. | | `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 19be6334bea..f7e2a05d058 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -1039,7 +1039,7 @@ sim files versions read [options] | Option | Required | Description | | --- | --- | --- | | `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | -| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--offset ` | No | First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line. | | `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | @@ -1281,7 +1281,7 @@ sim files read [options] | Option | Required | Description | | --- | --- | --- | | `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | -| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--offset ` | No | First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line. | | `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index c374293200e..bc682bdbbe7 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -790,11 +790,11 @@ "name": "offset", "in": "query", "required": false, - "description": "First line to return, 1-based. Absent starts at the first line.", + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", "schema": { - "description": "First line to return, 1-based. Absent starts at the first line.", + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", "type": "integer", - "minimum": 1, + "minimum": 0, "maximum": 9007199254740991 } }, @@ -1241,11 +1241,11 @@ "name": "offset", "in": "query", "required": false, - "description": "First line to return, 1-based. Absent starts at the first line.", + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", "schema": { - "description": "First line to return, 1-based. Absent starts at the first line.", + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", "type": "integer", - "minimum": 1, + "minimum": 0, "maximum": 9007199254740991 } }, diff --git a/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts index a0c7b08609d..4f5f9aceafb 100644 --- a/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts @@ -112,6 +112,16 @@ describe('GET /api/v2/files/[fileId]/text', () => { expect(mocks.readText).not.toHaveBeenCalled() }) + /** + * Callers used to zero-based paging send `offset=0` for the first window. It + * names no other line, so it reads from the first one instead of failing. + */ + it('reads offset 0 as the first line', async () => { + const response = await GET(textRequest(`workspaceId=${WORKSPACE_ID}&offset=0&limit=2`), context) + + expect(response.status).toBe(200) + }) + it('conceals a cross-tenant file as a missing file', async () => { mocks.readText.mockRejectedValueOnce(new NoWorkspaceAccessError()) diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 9829ed78d50..599f3894312 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -209,11 +209,12 @@ export const fileManageContentBodySchema = z fileId: fileIdSelectionSchema.optional(), fileInput: z.unknown().optional(), /** - * First line to return, 1-based. Applied to each selected file separately, - * because a selection can be several files and one running offset across - * them would depend on an ordering the caller cannot see. + * First line to return, 1-based; 0 also starts at the first line, so a + * zero-based caller's first window reads instead of failing. Applied to each + * selected file separately, because a selection can be several files and one + * running offset across them would depend on an ordering the caller cannot see. */ - offset: z.number().int().min(1, 'offset starts at line 1').optional(), + offset: z.number().int().min(0, 'offset cannot be negative').optional(), /** How many lines to return from `offset`. Absent reads to the end. */ limit: z.number().int().min(1, 'limit must be at least 1').optional(), }) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 8e36c324a53..9e8a8003ca5 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -853,9 +853,11 @@ export const v2ReadFileTextQuerySchema = z offset: z.coerce .number() .int() - .min(1, 'offset starts at line 1') + .min(0, 'offset cannot be negative') .optional() - .describe('First line to return, 1-based. Absent starts at the first line.'), + .describe( + 'First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.' + ), limit: z.coerce .number() .int() diff --git a/apps/sim/lib/internal/file/operations.test.ts b/apps/sim/lib/internal/file/operations.test.ts index 5e3e0ce1195..8488db23333 100644 --- a/apps/sim/lib/internal/file/operations.test.ts +++ b/apps/sim/lib/internal/file/operations.test.ts @@ -808,6 +808,21 @@ describe('file manage operations', () => { }, }) }) + it('reads offset 0 as the first line of each selected file', async () => { + const response = await POST( + createMockRequest('POST', { + operation: 'content', + workspaceId: 'workspace-1', + fileId: 'file-1', + offset: 0, + limit: 1, + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: { contents: ['content:file-1.txt'], lineRanges: [{ offset: 1, lineCount: 1 }] }, + }) + }) it('does not bypass complete extraction limits with a raw-text fallback', async () => { vi.mocked(isSupportedFileType).mockReturnValueOnce(true) vi.mocked(parseBuffer).mockRejectedValueOnce( diff --git a/apps/sim/lib/mothership/agent-cli/engines/file-read.test.ts b/apps/sim/lib/mothership/agent-cli/engines/file-read.test.ts index 6d6b67ac323..40c5c585800 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/file-read.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/file-read.test.ts @@ -205,11 +205,28 @@ describe('content-aware files read augmentation', () => { } ) + it('forwards --offset 0 to the text API instead of refusing it', async () => { + mockResolveWorkspaceFileReference.mockResolvedValue({ + ...file, + name: 'notes.txt', + type: 'text/plain', + }) + mocks.request.mockResolvedValue(textResponse('notes.txt')) + const result = await runEngine('files read', ['uploads/notes.txt'], runtime, { + offset: '0', + limit: '2', + }) + expect(result.exitCode).toBe(0) + expect(mocks.request).toHaveBeenCalledWith(`/api/v2/files/${fileId}/text`, { + query: { workspaceId, offset: '0', limit: '2' }, + }) + }) + it('rejects incompatible flags and invalid byte/line bounds before lookup', async () => { for (const flags of [ { render: true, limit: '2' } as const, { pages: '1', offset: '1' }, - { offset: '0' }, + { offset: '-1' }, { offset: true } as const, { limit: true } as const, { 'max-bytes': true } as const, diff --git a/apps/sim/lib/workspace-files/text-lines.test.ts b/apps/sim/lib/workspace-files/text-lines.test.ts index 15830cee287..d4ba1276d0f 100644 --- a/apps/sim/lib/workspace-files/text-lines.test.ts +++ b/apps/sim/lib/workspace-files/text-lines.test.ts @@ -25,6 +25,12 @@ describe('file text line windows', () => { } } ) + it('reads offset 0 as the first line and reports the 1-based offset it used', () => { + expect(sliceFileTextLines('one\ntwo\nthree', 0, 2, false)).toEqual({ + text: 'one\ntwo', + lineRange: { offset: 1, lineCount: 2, totalLines: 3, totalLinesExact: true }, + }) + }) it('reads a late window in a newline-dense document', () => { const result = sliceFileTextLines( `${'abc\n'.repeat(1_000_000)}tail needle\n`, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 7ca984566ed..152e6f26f81 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -20276,7 +20276,8 @@ export const V2_OPERATIONS = { }, offset: { kind: 'integer', - describe: 'First line to return, 1-based. Absent starts at the first line.', + describe: + 'First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.', }, limit: { kind: 'integer', @@ -20300,7 +20301,8 @@ export const V2_OPERATIONS = { }, offset: { kind: 'integer', - describe: 'First line to return, 1-based. Absent starts at the first line.', + describe: + 'First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.', }, limit: { kind: 'integer',