mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { handlers, listeners } = vi.hoisted(() => ({
|
|
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
|
listeners: new Map<string, (_event: unknown, args?: unknown) => unknown>()
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
ipcMain: {
|
|
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
|
handlers.set(channel, handler)
|
|
}),
|
|
on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
|
listeners.set(channel, handler)
|
|
})
|
|
}
|
|
}))
|
|
|
|
import {
|
|
_getNativeChatSenderCleanupCountForTest,
|
|
clearNativeChatSubscriptions,
|
|
clearNativeChatTranscriptCache,
|
|
registerNativeChatHandlers
|
|
} from './native-chat'
|
|
|
|
let tempRoots: string[] = []
|
|
|
|
beforeEach(() => {
|
|
handlers.clear()
|
|
listeners.clear()
|
|
clearNativeChatTranscriptCache()
|
|
clearNativeChatSubscriptions()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
|
tempRoots = []
|
|
})
|
|
|
|
function jsonLines(records: unknown[]): string {
|
|
return records.map((record) => JSON.stringify(record)).join('\n')
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
const start = Date.now()
|
|
while (!predicate()) {
|
|
if (Date.now() - start > timeoutMs) {
|
|
throw new Error('timed out waiting for condition')
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
}
|
|
}
|
|
|
|
async function invokeReadSession(args: {
|
|
agent: string
|
|
sessionId: string
|
|
limit?: number
|
|
transcriptPath?: string
|
|
}): Promise<unknown> {
|
|
registerNativeChatHandlers()
|
|
const handler = handlers.get('nativeChat:readSession')
|
|
if (!handler) {
|
|
throw new Error('handler not registered')
|
|
}
|
|
return handler({}, args)
|
|
}
|
|
|
|
describe('nativeChat:readSession handler', () => {
|
|
it('preserves notFound so a just-created session stays in retry/loading', async () => {
|
|
const result = (await invokeReadSession({
|
|
agent: 'claude',
|
|
sessionId: 'missing-session',
|
|
transcriptPath: join(tmpdir(), 'orca-native-chat-ipc-does-not-exist.jsonl')
|
|
})) as { error?: string; notFound?: true }
|
|
|
|
expect(result.error).toBeDefined()
|
|
expect(result.notFound).toBe(true)
|
|
})
|
|
|
|
it('resolves a Claude transcript and returns the full conversation', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-'))
|
|
tempRoots.push(root)
|
|
const projectsDir = join(root, '.claude', 'projects')
|
|
const projectDir = join(projectsDir, '-repo')
|
|
await mkdir(projectDir, { recursive: true })
|
|
await writeFile(
|
|
join(projectDir, 'sess-ipc.jsonl'),
|
|
jsonLines([
|
|
{
|
|
type: 'user',
|
|
uuid: 'u-1',
|
|
timestamp: '2026-06-01T10:00:00.000Z',
|
|
message: { role: 'user', content: 'Hi' }
|
|
},
|
|
{
|
|
type: 'assistant',
|
|
uuid: 'a-1',
|
|
timestamp: '2026-06-01T10:00:01.000Z',
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
|
|
}
|
|
])
|
|
)
|
|
|
|
// Point homedir-derived Claude root at our fixture via HOME so the resolver
|
|
// (which reads homedir() internally) finds the transcript.
|
|
const previousHome = process.env.HOME
|
|
process.env.HOME = root
|
|
try {
|
|
const result = (await invokeReadSession({ agent: 'claude', sessionId: 'sess-ipc' })) as {
|
|
messages?: unknown[]
|
|
error?: string
|
|
}
|
|
expect(result.error).toBeUndefined()
|
|
expect(result.messages).toHaveLength(2)
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('windows to the most-recent `limit` turns and pages older history when raised', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-limit-'))
|
|
tempRoots.push(root)
|
|
const projectDir = join(root, '.claude', 'projects', '-repo')
|
|
await mkdir(projectDir, { recursive: true })
|
|
// Five user turns; reading with limit 2 returns only the last two, and a
|
|
// larger limit pages in older ones (chronological order preserved).
|
|
const records = [1, 2, 3, 4, 5].map((n) => ({
|
|
type: 'user',
|
|
uuid: `u-${n}`,
|
|
timestamp: `2026-06-01T10:00:0${n}.000Z`,
|
|
message: { role: 'user', content: `m${n}` }
|
|
}))
|
|
await writeFile(join(projectDir, 'sess-limit.jsonl'), jsonLines(records))
|
|
|
|
const previousHome = process.env.HOME
|
|
process.env.HOME = root
|
|
try {
|
|
const windowed = (await invokeReadSession({
|
|
agent: 'claude',
|
|
sessionId: 'sess-limit',
|
|
limit: 2
|
|
})) as { messages: { id: string }[] }
|
|
expect(windowed.messages.map((m) => m.id)).toEqual(['u-4', 'u-5'])
|
|
|
|
const wider = (await invokeReadSession({
|
|
agent: 'claude',
|
|
sessionId: 'sess-limit',
|
|
limit: 4
|
|
})) as { messages: { id: string }[] }
|
|
expect(wider.messages.map((m) => m.id)).toEqual(['u-2', 'u-3', 'u-4', 'u-5'])
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('emits snapshot and appended frames and tears down on destroy', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-'))
|
|
tempRoots.push(root)
|
|
const projectsDir = join(root, '.claude', 'projects')
|
|
const projectDir = join(projectsDir, '-repo')
|
|
await mkdir(projectDir, { recursive: true })
|
|
const filePath = join(projectDir, 'sess-sub.jsonl')
|
|
await writeFile(
|
|
filePath,
|
|
`${jsonLines([
|
|
{
|
|
type: 'user',
|
|
uuid: 'u-1',
|
|
timestamp: '2026-06-01T10:00:00.000Z',
|
|
message: { role: 'user', content: 'Hi' }
|
|
}
|
|
])}\n`
|
|
)
|
|
|
|
registerNativeChatHandlers()
|
|
const subscribe = listeners.get('nativeChat:subscribe')
|
|
expect(subscribe).toBeDefined()
|
|
|
|
const sent: { channel: string; payload: unknown }[] = []
|
|
let destroyedCb: (() => void) | undefined
|
|
const sender = {
|
|
id: 1,
|
|
isDestroyed: () => false,
|
|
once: (event: string, cb: () => void) => {
|
|
if (event === 'destroyed') {
|
|
destroyedCb = cb
|
|
}
|
|
},
|
|
send: (channel: string, payload: unknown) => sent.push({ channel, payload })
|
|
}
|
|
|
|
const previousHome = process.env.HOME
|
|
process.env.HOME = root
|
|
try {
|
|
subscribe!(
|
|
{ sender },
|
|
{
|
|
subscriptionId: 'sub-1',
|
|
agent: 'claude',
|
|
sessionId: 'sess-sub'
|
|
}
|
|
)
|
|
|
|
// The listener dispatches handleSubscribe fire-and-forget; give it a beat
|
|
// to resolve the path and install the watcher before we append.
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
|
|
await appendFile(
|
|
filePath,
|
|
`${JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: 'a-1',
|
|
timestamp: '2026-06-01T10:00:01.000Z',
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
|
|
})}\n`
|
|
)
|
|
|
|
// The first frame is a bounded snapshot and later frames are appends.
|
|
// Collect ids across both and assert the new turn shows up.
|
|
const appendedIds = (): string[] =>
|
|
sent
|
|
.filter((s) => s.channel === 'nativeChat:appended')
|
|
.flatMap((s) =>
|
|
(s.payload as { frame: { messages: { id: string }[] } }).frame.messages.map((m) => m.id)
|
|
)
|
|
await waitFor(() => appendedIds().includes('a-1'))
|
|
const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')!
|
|
const payload = appendedEvent.payload as { subscriptionId: string }
|
|
expect(payload.subscriptionId).toBe('sub-1')
|
|
expect(appendedIds()).toContain('a-1')
|
|
|
|
// Destroyed window tears down the watcher without error.
|
|
expect(destroyedCb).toBeDefined()
|
|
destroyedCb!()
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('drops cleanup registration when sender is destroyed before subscribe completes', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-'))
|
|
tempRoots.push(root)
|
|
const projectDir = join(root, '.claude', 'projects', '-repo')
|
|
await mkdir(projectDir, { recursive: true })
|
|
await writeFile(
|
|
join(projectDir, 'sess-race.jsonl'),
|
|
`${jsonLines([
|
|
{
|
|
type: 'user',
|
|
uuid: 'u-race',
|
|
timestamp: '2026-06-01T10:00:00.000Z',
|
|
message: { role: 'user', content: 'Race' }
|
|
}
|
|
])}\n`
|
|
)
|
|
|
|
registerNativeChatHandlers()
|
|
const subscribe = listeners.get('nativeChat:subscribe')
|
|
expect(subscribe).toBeDefined()
|
|
|
|
let destroyed = false
|
|
let destroyedCb: (() => void) | undefined
|
|
const sender = {
|
|
id: 41,
|
|
isDestroyed: () => destroyed,
|
|
once: (event: string, cb: () => void) => {
|
|
if (event === 'destroyed') {
|
|
destroyedCb = cb
|
|
}
|
|
},
|
|
send: vi.fn()
|
|
}
|
|
|
|
const previousHome = process.env.HOME
|
|
process.env.HOME = root
|
|
try {
|
|
subscribe!(
|
|
{ sender },
|
|
{
|
|
subscriptionId: 'sub-race',
|
|
agent: 'claude',
|
|
sessionId: 'sess-race'
|
|
}
|
|
)
|
|
|
|
expect(destroyedCb).toBeDefined()
|
|
destroyed = true
|
|
destroyedCb!()
|
|
|
|
await waitFor(() => _getNativeChatSenderCleanupCountForTest() === 0)
|
|
expect(sender.send).not.toHaveBeenCalled()
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('returns an error for an unknown session without throwing', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-'))
|
|
tempRoots.push(root)
|
|
const previousHome = process.env.HOME
|
|
process.env.HOME = root
|
|
try {
|
|
const result = (await invokeReadSession({ agent: 'claude', sessionId: 'nope' })) as {
|
|
error?: string
|
|
}
|
|
expect(result.error).toBeTruthy()
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
})
|