Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion src/main/ipc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const mock = vi.hoisted(() => {
fromWebContents: vi.fn(() => mockWindow),
getAllWindows: vi.fn(() => [mockWindow])
},
termFidelityCorpusDumper: {
dump: vi.fn()
},
brokerManager: {
start: vi.fn(),
shutdown: vi.fn(async () => undefined),
Expand All @@ -44,6 +47,7 @@ const mock = vi.hoisted(() => {
sendMessageAndWaitForDelivery: vi.fn(),
reconcileMessages: vi.fn(),
listAgents: vi.fn(),
listBrokerDetails: vi.fn(),
getAgentOutput: vi.fn(),
generateCommitDraft: vi.fn(),
getStatus: vi.fn(),
Expand Down Expand Up @@ -87,7 +91,10 @@ const mock = vi.hoisted(() => {

vi.mock('electron', () => ({
app: {
quit: vi.fn()
quit: vi.fn(),
getPath: vi.fn(() => '/tmp/pear-test-user-data'),
getAppPath: vi.fn(() => process.cwd()),
getVersion: vi.fn(() => '1.0.0-test')
},
ipcMain: mock.ipcMain,
dialog: {
Expand Down Expand Up @@ -121,6 +128,14 @@ vi.mock('./broker', () => ({
isCommandAvailableWithAugmentedPath: mock.isCommandAvailableWithAugmentedPath
}))

vi.mock('./term-fidelity-corpus', () => ({
TermFidelityCorpusDumper: class {
dump(...args: unknown[]): unknown {
return mock.termFidelityCorpusDumper.dump(...args)
}
}
}))

vi.mock('./git', () => ({
getSelectedDiff: vi.fn()
}))
Expand Down Expand Up @@ -168,6 +183,56 @@ import type { FactoryConfigReadResult, FactoryStatus } from '../shared/types/ipc
const asConfigResult = (value: unknown): FactoryConfigReadResult => value as FactoryConfigReadResult
const asStatus = (value: unknown): FactoryStatus => value as FactoryStatus

describe('registerIpcHandlers term-fidelity:dump-corpus', () => {
beforeEach(() => {
mock.handlers.clear()
mock.ipcMain.handle.mockClear()
mock.ipcMain.on.mockClear()
mock.termFidelityCorpusDumper.dump.mockReset()
mock.brokerManager.listBrokerDetails.mockReset()
registerIpcHandlers()
})

it('captures the sender page and supplies production metadata to the corpus writer', async () => {
const input = {
projectId: 'project-1',
agentName: 'codex-1',
cli: 'codex',
renderer: { rows: 2, cols: 10, text: 'renderer' },
broker: { rows: 2, cols: 10, text: 'broker' },
telemetryLines: ['confirmed']
}
const png = Buffer.from('png')
const capturePage = vi.fn(async () => ({ toPNG: () => png }))
mock.brokerManager.listBrokerDetails.mockResolvedValueOnce([
{
projectId: 'project-1',
name: 'pear-project-1',
session: { brokerVersion: '10.6.3' }
}
])
mock.termFidelityCorpusDumper.dump.mockImplementationOnce(
async (receivedInput, options) => {
expect(receivedInput).toEqual(input)
expect(options.rootDir).toBe('/tmp/pear-test-user-data/term-fidelity-corpus')
expect(await options.capturePage()).toEqual(png)
expect(await options.relayVersions()).toMatchObject({
pear: '1.0.0-test',
'agent-relay': '^10.6.3',
'broker:pear-project-1': '10.6.3'
})
return { dumped: true, path: '/tmp/bundle' }
}
)

const handler = mock.handlers.get('term-fidelity:dump-corpus')
const result = await handler?.({ sender: { capturePage } }, input)

expect(result).toEqual({ dumped: true, path: '/tmp/bundle' })
expect(capturePage).toHaveBeenCalledTimes(1)
})
})

describe('registerIpcHandlers broker:start', () => {
beforeEach(() => {
mock.handlers.clear()
Expand Down
48 changes: 47 additions & 1 deletion src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,44 @@ import type {
FactoryConfigReadResult,
FactoryIssueStatus,
FactoryNodeConfig,
FactoryStatus
FactoryStatus,
TermFidelityCorpusInput
} from '../shared/types/ipc'
import type { ProactiveAgentDraft } from './proactive-agent.types'
import { TermFidelityCorpusDumper } from './term-fidelity-corpus'

const termFidelityCorpusDumper = new TermFidelityCorpusDumper()

async function termFidelityRelayVersions(projectId?: string): Promise<Record<string, string>> {
let declaredRelayVersions: Record<string, string> = {}
try {
const manifest = JSON.parse(
await readFile(join(app.getAppPath(), 'package.json'), 'utf8')
) as { dependencies?: Record<string, unknown> } | null
declaredRelayVersions = Object.fromEntries(
Object.entries(manifest?.dependencies || {}).filter(
([name, version]) =>
(name === 'agent-relay' || name.startsWith('@agent-relay/')) &&
typeof version === 'string'
)
) as Record<string, string>
Comment on lines +55 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If package.json parses to null (which is technically valid JSON), manifest will be null. Accessing manifest.dependencies would then throw a TypeError. Although this is wrapped in a try/catch block, using optional chaining (manifest?.dependencies) and typing manifest as nullable is more robust and prevents throwing unnecessary exceptions.

    const manifest = JSON.parse(
      await readFile(join(app.getAppPath(), 'package.json'), 'utf8')
    ) as { dependencies?: Record<string, unknown> } | null
    declaredRelayVersions = Object.fromEntries(
      Object.entries(manifest?.dependencies || {}).filter(
        ([name, version]) =>
          (name === 'agent-relay' || name.startsWith('@agent-relay/')) &&
          typeof version === 'string'
      )
    ) as Record<string, string>

} catch {
// Broker version metadata below still makes the bundle actionable when a
// packaged app manifest is unavailable.
}
const versions: Record<string, string> = {
pear: app.getVersion(),
...declaredRelayVersions
}
const details = await brokerManager.listBrokerDetails().catch(() => [])
for (const detail of details) {
if (projectId && detail.projectId !== projectId) continue
if (detail.session?.brokerVersion) {
versions[`broker:${detail.name}`] = detail.session.brokerVersion
}
}
return versions
}

function toBrokerSpawnAgentResult(result: BrokerSpawnAgentResult): BrokerSpawnAgentResult {
return {
Expand Down Expand Up @@ -515,6 +550,17 @@ export function registerIpcHandlers(): void {
return true
})

ipcMain.handle(
'term-fidelity:dump-corpus',
async (event, input: TermFidelityCorpusInput) => {
return termFidelityCorpusDumper.dump(input, {
rootDir: join(app.getPath('userData'), 'term-fidelity-corpus'),
capturePage: async () => (await event.sender.capturePage()).toPNG(),
relayVersions: () => termFidelityRelayVersions(input.projectId)
})
}
)

// --- Project ---
ipcMain.handle('project:list', () => {
const data = loadStore()
Expand Down
169 changes: 169 additions & 0 deletions src/main/term-fidelity-corpus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import {
TERM_FIDELITY_CORPUS_DUMP_GAP_MS,
TERM_FIDELITY_CORPUS_WARN_GAP_MS,
TermFidelityCorpusDumper
} from './term-fidelity-corpus'
import type { TermFidelityCorpusInput } from '../shared/types/ipc'

const temporaryRoots: string[] = []

async function temporaryRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'pear-term-fidelity-corpus-'))
temporaryRoots.push(root)
return root
}

function corpusInput(agentName = 'codex-1'): TermFidelityCorpusInput {
return {
projectId: 'project-1',
agentName,
cli: 'codex',
renderer: { rows: 2, cols: 10, text: 'row one\nGARBAGE' },
broker: { rows: 2, cols: 10, text: 'row one\nrow two' },
telemetryLines: [
'[terminal] viewport diverged from broker screen; confirmed after 2 quiet checks'
]
}
}

afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})

describe('TermFidelityCorpusDumper', () => {
it('rejects malformed IPC payloads before accessing nested fields', () => {
const dumper = new TermFidelityCorpusDumper()
const options = {
rootDir: '/tmp/not-used',
capturePage: async () => Buffer.from('unused'),
relayVersions: async () => ({})
}

expect(() => dumper.dump(null as unknown as TermFidelityCorpusInput, options)).toThrow(
'input must be an object'
)
expect(() => dumper.dump({ agentName: 'a', cli: 'codex' } as TermFidelityCorpusInput, options)).toThrow(
'renderer grid must be an object'
)
})

it('writes the production bundle layout and exactly one log line', async () => {
const rootDir = await temporaryRoot()
const screenshot = Buffer.from([0x89, 0x50, 0x4e, 0x47])
const capturePage = vi.fn(async () => screenshot)
const log = vi.fn()
const now = Date.parse('2026-07-17T12:34:56.789Z')
const dumper = new TermFidelityCorpusDumper({ now: () => now, log })

const result = await dumper.dump(corpusInput(), {
rootDir,
capturePage,
relayVersions: async () => ({
broker: '10.6.3',
'@agent-relay/sdk': '^10.6.3'
})
})

expect(result.dumped).toBe(true)
if (!result.dumped) throw new Error('Expected a corpus dump')
expect(basename(result.path)).toBe('2026-07-17T12-34-56-789Z')
expect(await readFile(join(result.path, 'renderer.txt'), 'utf8')).toBe('row one\nGARBAGE')
expect(await readFile(join(result.path, 'broker.txt'), 'utf8')).toBe('row one\nrow two')
expect(await readFile(join(result.path, 'screen.png'))).toEqual(screenshot)
expect(JSON.parse(await readFile(join(result.path, 'meta.json'), 'utf8'))).toEqual({
capturedAt: '2026-07-17T12:34:56.789Z',
projectId: 'project-1',
agentName: 'codex-1',
cli: 'codex',
dims: {
renderer: { rows: 2, cols: 10 },
broker: { rows: 2, cols: 10 }
},
relayVersions: {
broker: '10.6.3',
'@agent-relay/sdk': '^10.6.3'
},
reconcilerTelemetryLines: corpusInput().telemetryLines
})
expect(capturePage).toHaveBeenCalledTimes(1)
expect(log).toHaveBeenCalledTimes(1)
expect(log).toHaveBeenCalledWith(expect.stringContaining(result.path))
})

it('coalesces concurrent triggers and rate-limits each project + agent for five minutes', async () => {
const rootDir = await temporaryRoot()
let currentTime = 1_000_000
let releaseCapture: ((png: Buffer) => void) | undefined
const capturePage = vi.fn(
() => new Promise<Buffer>((resolve) => {
releaseCapture = resolve
})
)
const log = vi.fn()
const dumper = new TermFidelityCorpusDumper({ now: () => currentTime, log })
const options = { rootDir, capturePage, relayVersions: async () => ({}) }

const first = dumper.dump(corpusInput(), options)
const duplicate = dumper.dump(corpusInput(), options)
expect(duplicate).toBe(first)
expect(capturePage).toHaveBeenCalledTimes(1)
releaseCapture?.(Buffer.from('png'))
expect((await first).dumped).toBe(true)
expect((await duplicate).dumped).toBe(true)

const limited = await dumper.dump(corpusInput(), options)
expect(limited).toEqual({ dumped: false, reason: 'rate-limited' })
expect(capturePage).toHaveBeenCalledTimes(1)

// A distinct agent has an independent budget.
const otherAgent = dumper.dump(corpusInput('codex-2'), {
...options,
capturePage: vi.fn(async () => Buffer.from('other'))
})
expect((await otherAgent).dumped).toBe(true)

currentTime += TERM_FIDELITY_CORPUS_DUMP_GAP_MS
const afterGap = dumper.dump(corpusInput(), {
...options,
capturePage: vi.fn(async () => Buffer.from('after-gap'))
})
expect((await afterGap).dumped).toBe(true)
expect(log).toHaveBeenCalledTimes(3)
})

it('degrades capture failures to a rate-limited warning and allows retry', async () => {
const rootDir = await temporaryRoot()
let currentTime = 2_000_000
const warn = vi.fn()
const dumper = new TermFidelityCorpusDumper({ now: () => currentTime, warn })
const failingOptions = {
rootDir,
capturePage: async (): Promise<Buffer> => {
throw new Error('capture\nfailed')
},
relayVersions: async () => ({})
}

expect(await dumper.dump(corpusInput(), failingOptions)).toEqual({
dumped: false,
reason: 'failed'
})
expect(await dumper.dump(corpusInput(), failingOptions)).toEqual({
dumped: false,
reason: 'failed'
})
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(expect.not.stringContaining('\nfailed'))

currentTime += TERM_FIDELITY_CORPUS_WARN_GAP_MS
expect(await dumper.dump(corpusInput(), failingOptions)).toEqual({
dumped: false,
reason: 'failed'
})
expect(warn).toHaveBeenCalledTimes(2)
})
})
Loading
Loading