diff --git a/src/shared/agent-hook-spool-read.test.ts b/src/shared/agent-hook-spool-read.test.ts new file mode 100644 index 00000000000..d589f5b2274 --- /dev/null +++ b/src/shared/agent-hook-spool-read.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readSpoolFile } from './agent-hook-spool' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-spool-read-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function write(contents: string): string { + const file = join(dir, 'spool.jsonl') + writeFileSync(file, contents) + return file +} + +function record(paneKey: string): string { + return JSON.stringify({ paneKey, source: 'PreToolUse', payload: {}, receivedAt: Date.now() }) +} + +describe('readSpoolFile', () => { + it('leaves a trailing line without its newline unconsumed', () => { + const complete = `${record('a')}\n` + const result = readSpoolFile(write(`${complete}${record('b')}`)) + + expect(result.records.map((entry) => entry.paneKey)).toEqual(['a']) + // The in-flight final record must stay replayable: consumed stops at the last newline. + expect(result.consumed).toBe(Buffer.byteLength(complete)) + }) + + it('consumes through the final newline when every line is complete', () => { + const contents = `${record('a')}\n${record('b')}\n` + const result = readSpoolFile(write(contents)) + + expect(result.records.map((entry) => entry.paneKey)).toEqual(['a', 'b']) + expect(result.consumed).toBe(Buffer.byteLength(contents)) + }) + + it('skips blank lines without consuming less than the bytes they occupy', () => { + const contents = `${record('a')}\n\n\n${record('b')}\n` + const result = readSpoolFile(write(contents)) + + expect(result.records.map((entry) => entry.paneKey)).toEqual(['a', 'b']) + expect(result.consumed).toBe(Buffer.byteLength(contents)) + }) + + it('returns nothing for an empty file', () => { + expect(readSpoolFile(write(''))).toEqual({ records: [], consumed: 0 }) + }) + + it('returns nothing for a file that is one torn line', () => { + expect(readSpoolFile(write(record('a'))).records).toEqual([]) + expect(readSpoolFile(write(record('a'))).consumed).toBe(0) + }) + + it('returns nothing for a missing file', () => { + expect(readSpoolFile(join(dir, 'absent.jsonl'))).toEqual({ records: [], consumed: 0 }) + }) +}) diff --git a/src/shared/agent-hook-spool.ts b/src/shared/agent-hook-spool.ts index 3ad478ed8a5..275e6f5ce52 100644 --- a/src/shared/agent-hook-spool.ts +++ b/src/shared/agent-hook-spool.ts @@ -67,19 +67,17 @@ export function readSpoolFile( const records: SpoolRecord[] = [] let consumed = 0 let start = 0 - for (let end = 0; end <= bytes.length; end += 1) { - if (end !== bytes.length && bytes[end] !== 0x0a) { - continue - } + // indexOf, not a per-byte loop: this runs over every spooled file before the hook listener binds, + // and Buffer.indexOf finds the newline with memchr instead of an interpreted scan. + for (;;) { + const end = bytes.indexOf(0x0a, start) // A final line without its newline may still be in flight from a hook writer. // Leave it untouched until the writer terminates the record explicitly. - if (end === bytes.length && (end === 0 || bytes[end - 1] !== 0x0a)) { + if (end === -1) { break } const lineBytes = bytes.subarray(start, end) - if (end !== bytes.length) { - consumed = end + 1 - } + consumed = end + 1 start = end + 1 if (lineBytes.length === 0) { continue