mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import type { NativeChatMessage } from '../../src/shared/native-chat-types'
|
|
import { projectNativeChatTranscriptMessages } from '../../src/shared/native-chat-transcript-projection'
|
|
import { createNativeChatMerger, replaceList } from '../../src/shared/native-chat/native-chat-merge'
|
|
import { readNativeChatTranscript } from '../../src/main/native-chat/transcript-reader'
|
|
import {
|
|
readNativeChatTranscriptTail,
|
|
subscribeNativeChatTranscript
|
|
} from '../../src/main/native-chat/transcript-watch'
|
|
|
|
// Claude timestamps queued prompts at send time but writes them after the tool result that absorbed them.
|
|
const PROMPT = {
|
|
type: 'user',
|
|
uuid: 'u-1',
|
|
timestamp: '2026-06-01T10:00:00.000Z',
|
|
message: { role: 'user', content: 'start the long task' }
|
|
}
|
|
const TOOL_CALL = {
|
|
type: 'assistant',
|
|
uuid: 'a-1',
|
|
timestamp: '2026-06-01T10:00:01.000Z',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'sleep 30' } }]
|
|
}
|
|
}
|
|
const TOOL_RESULT = {
|
|
type: 'user',
|
|
uuid: 'r-1',
|
|
timestamp: '2026-06-01T10:00:30.000Z',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }]
|
|
}
|
|
}
|
|
const QUEUED = {
|
|
type: 'attachment',
|
|
uuid: 'q-1',
|
|
parentUuid: 'r-1',
|
|
timestamp: '2026-06-01T10:00:10.000Z',
|
|
attachment: {
|
|
type: 'queued_command',
|
|
prompt: 'also check the config',
|
|
commandMode: 'prompt',
|
|
origin: { kind: 'human' },
|
|
timestamp: '2026-06-01T10:00:10.000Z'
|
|
}
|
|
}
|
|
const REPLY = {
|
|
type: 'assistant',
|
|
uuid: 'a-2',
|
|
timestamp: '2026-06-01T10:00:35.000Z',
|
|
message: {
|
|
role: 'assistant',
|
|
stop_reason: 'end_turn',
|
|
content: [{ type: 'text', text: 'both done' }]
|
|
}
|
|
}
|
|
|
|
const lines = (...records: object[]): string =>
|
|
records.map((record) => `${JSON.stringify(record)}\n`).join('')
|
|
|
|
let roots: string[] = []
|
|
afterEach(async () => {
|
|
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })))
|
|
roots = []
|
|
})
|
|
|
|
async function transcriptFile(content: string): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-file-order-'))
|
|
roots.push(root)
|
|
const filePath = join(root, 'session.jsonl')
|
|
await writeFile(filePath, content)
|
|
return filePath
|
|
}
|
|
|
|
function expectPromptAfterToolRun(messages: NativeChatMessage[]): void {
|
|
const rows = projectNativeChatTranscriptMessages(messages)
|
|
expect(rows.map((row) => row.id)).toEqual(['u-1', 'a-1', 'q-1', 'a-2'])
|
|
expect(rows[1]?.blocks.map((block) => block.type)).toEqual(['tool-call', 'tool-result'])
|
|
}
|
|
|
|
describe('transcript rows keep file order', () => {
|
|
it.each([
|
|
['full read', (filePath: string) => readNativeChatTranscript('claude', 's', { filePath })],
|
|
[
|
|
'modern full read',
|
|
(filePath: string) => readNativeChatTranscript('claude', 's', { filePath, sourceOrder: true })
|
|
],
|
|
[
|
|
'modern tail read',
|
|
(filePath: string) =>
|
|
readNativeChatTranscriptTail({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
filePath,
|
|
limit: 40,
|
|
sourceOrder: true
|
|
})
|
|
],
|
|
[
|
|
'tail read',
|
|
(filePath: string) =>
|
|
readNativeChatTranscriptTail({ agent: 'claude', sessionId: 's', filePath, limit: 40 })
|
|
]
|
|
])(
|
|
'places a mid-turn prompt after the tool result it was read with (%s)',
|
|
async (_name, read) => {
|
|
const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT, QUEUED, REPLY))
|
|
const result = await read(filePath)
|
|
if (!('messages' in result)) {
|
|
throw new Error(result.error)
|
|
}
|
|
expectPromptAfterToolRun(result.messages)
|
|
}
|
|
)
|
|
|
|
it.each([false, true])(
|
|
'places a mid-turn prompt appended after the initial snapshot (modern=%s)',
|
|
async (sourceOrder) => {
|
|
const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT))
|
|
const received: NativeChatMessage[] = []
|
|
const subscription = await subscribeNativeChatTranscript({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
filePath,
|
|
initialLimit: 40,
|
|
sourceOrder,
|
|
onInitialSnapshot: (messages) => received.push(...messages),
|
|
onAppend: (messages) => received.push(...messages),
|
|
debounceMs: 5,
|
|
reconciliationIntervalMs: 20
|
|
})
|
|
try {
|
|
await expect.poll(() => received.length).toBe(3)
|
|
await appendFile(filePath, lines(QUEUED, REPLY))
|
|
await expect.poll(() => received.length).toBe(5)
|
|
expectPromptAfterToolRun(received)
|
|
} finally {
|
|
subscription.unsubscribe()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('canonical source positions', () => {
|
|
const record = (id: string, timestamp: string | null, text = id, role = 'assistant') => ({
|
|
type: role,
|
|
uuid: id,
|
|
timestamp,
|
|
message: { role, content: text }
|
|
})
|
|
const read = async (filePath: string, limit: number, beforeOffset?: number) => {
|
|
const result = await readNativeChatTranscriptTail({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
filePath,
|
|
limit,
|
|
beforeOffset,
|
|
sourceOrder: true
|
|
})
|
|
if (!('messages' in result)) {
|
|
throw new Error(result.error)
|
|
}
|
|
return result
|
|
}
|
|
|
|
it('retains reader-produced tied image pages through prepend, append and reconnect into bubbles', async () => {
|
|
const { applyMobileNativeChatStreamFrame } =
|
|
await import('../../mobile/src/session/native-chat/mobile-native-chat-stream-frame')
|
|
const { foldMobileNativeChatMessages } =
|
|
await import('../../mobile/src/session/mobile-native-chat-render-data')
|
|
const filePath = await transcriptFile(
|
|
lines(
|
|
...[
|
|
['z-source', '[Image: source: /tmp/first.png]'],
|
|
['z-prompt', 'first [Image #1]'],
|
|
['a-source', '[Image: source: /tmp/second.png]'],
|
|
['a-prompt', 'second [Image #1]']
|
|
].map(([id, text]) => record(id, '2026-06-01T10:00:00Z', text, 'user'))
|
|
)
|
|
)
|
|
const tail = await read(filePath, 1)
|
|
const older = await read(filePath, 3, tail.beforeOffset)
|
|
const merger = createNativeChatMerger()
|
|
const frame = (
|
|
type: 'snapshot' | 'appended',
|
|
messages: NativeChatMessage[],
|
|
replaceSnapshot = false
|
|
) =>
|
|
applyMobileNativeChatStreamFrame({
|
|
merger,
|
|
frame: { type, messages, hasMore: true },
|
|
limit: 100,
|
|
replaceSnapshot
|
|
})
|
|
frame('snapshot', tail.messages, true)
|
|
replaceList(merger, [...older.messages, ...merger.list])
|
|
frame('appended', tail.messages)
|
|
frame('snapshot', tail.messages)
|
|
const bubbles = foldMobileNativeChatMessages(merger.list, 'claude')
|
|
expect(bubbles.map((row) => row.id)).toEqual(['z-prompt', 'a-prompt'])
|
|
expect(
|
|
bubbles.map((row) =>
|
|
row.blocks.filter((block) => block.type === 'image-ref').map((block) => block.path)
|
|
)
|
|
).toEqual([['/tmp/first.png'], ['/tmp/second.png']])
|
|
})
|
|
|
|
it.each(['tied', 'backward', 'null'] as const)(
|
|
'keeps absolute UTF8/CRLF positions across full, older and partial append reads (%s)',
|
|
async (clock) => {
|
|
const timestamps =
|
|
clock === 'null'
|
|
? [null, null, null]
|
|
: clock === 'tied'
|
|
? [PROMPT.timestamp, PROMPT.timestamp, PROMPT.timestamp]
|
|
: [TOOL_RESULT.timestamp, QUEUED.timestamp, PROMPT.timestamp]
|
|
const records = ['z-思考', 'm-emoji', 'a-answer'].map((id, i) =>
|
|
record(id, timestamps[i], `${id} 🐳`)
|
|
)
|
|
const encoded = records.map((item) => `${JSON.stringify(item)}\r\n`)
|
|
const filePath = await transcriptFile(encoded.join(''))
|
|
const full = await readNativeChatTranscript('claude', 's', { filePath, sourceOrder: true })
|
|
if (!('messages' in full)) {
|
|
throw new Error(full.error)
|
|
}
|
|
expect(full.messages.map((row) => row.transcriptOrder)).toEqual([
|
|
0,
|
|
Buffer.byteLength(encoded[0]),
|
|
Buffer.byteLength(encoded[0] + encoded[1])
|
|
])
|
|
expect(full.messages.map((row) => row.timestamp)).toEqual(
|
|
timestamps.map((time) => (time === null ? null : Date.parse(time)))
|
|
)
|
|
const tail = await read(filePath, 1)
|
|
const older = await read(filePath, 2, tail.beforeOffset)
|
|
expect([...older.messages, ...tail.messages]).toEqual(full.messages)
|
|
const { createIncrementalTranscriptState, readIncrementalTranscriptMessages } =
|
|
await import('../../src/main/native-chat/transcript-incremental-reader')
|
|
const { decodeClaudeTranscriptLine } =
|
|
await import('../../src/main/native-chat/transcript-line-decoders')
|
|
const state = createIncrementalTranscriptState()
|
|
state.offset = state.pendingStart = Buffer.byteLength(encoded[0])
|
|
await writeFile(filePath, encoded[0] + encoded[1].slice(0, -2))
|
|
expect(
|
|
await readIncrementalTranscriptMessages(
|
|
filePath,
|
|
state,
|
|
decodeClaudeTranscriptLine,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true
|
|
)
|
|
).toEqual([])
|
|
await appendFile(filePath, `\r\n${encoded[2]}`)
|
|
const appended = await readIncrementalTranscriptMessages(
|
|
filePath,
|
|
state,
|
|
decodeClaudeTranscriptLine,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true
|
|
)
|
|
expect(appended).toEqual(full.messages.slice(1))
|
|
expect(projectNativeChatTranscriptMessages(full.messages).map((row) => row.id)).toEqual([
|
|
'z-思考',
|
|
'm-emoji',
|
|
'a-answer'
|
|
])
|
|
}
|
|
)
|
|
|
|
it.each([false, true])(
|
|
'rejoins image units across page and append boundaries, source-first=%s',
|
|
async (sourceFirst) => {
|
|
const { createIncrementalAssembler, reset, applyAppends } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-incremental-assembler')
|
|
const { prepareNativeChatLiveMessages } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-live-message-preparation')
|
|
const { commandMarkersAsMessages } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-command-marker')
|
|
const { createNativeChatMessageListProjection } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-message-list-projection')
|
|
const pair = (id: string, path: string) => {
|
|
const prompt = record(id, PROMPT.timestamp, `${id} [Image #1]`, 'user')
|
|
const images = [1, 2].map((n) =>
|
|
record(
|
|
`${id}-image-${n}`,
|
|
PROMPT.timestamp,
|
|
`[Image: source: /tmp/${path}-${n}.png]`,
|
|
'user'
|
|
)
|
|
)
|
|
return sourceFirst ? [...images, prompt] : [prompt, ...images]
|
|
}
|
|
const records = [...pair('z-first', 'first'), ...pair('a-second', 'second')]
|
|
const filePath = await transcriptFile(lines(...records))
|
|
const page = await read(filePath, 2)
|
|
const older = await read(filePath, 4, page.beforeOffset)
|
|
const assembler = createIncrementalAssembler()
|
|
reset(assembler, older.messages)
|
|
for (const message of page.messages) {
|
|
applyAppends(assembler, [message])
|
|
}
|
|
const marker = commandMarkersAsMessages([
|
|
{ id: 'between', command: '/compact', sentAt: Date.parse(PROMPT.timestamp) }
|
|
])
|
|
const normalized = prepareNativeChatLiveMessages([...assembler.messages, ...marker], 'claude')
|
|
const bubbles = createNativeChatMessageListProjection()(normalized).conversation.filter(
|
|
(row) => row.role === 'user'
|
|
)
|
|
expect(bubbles.map((row) => row.id)).toEqual(['z-first', 'a-second'])
|
|
expect(
|
|
bubbles.map((row) =>
|
|
row.blocks.filter((block) => block.type === 'image-ref').map((block) => block.path)
|
|
)
|
|
).toEqual([
|
|
['/tmp/first-1.png', '/tmp/first-2.png'],
|
|
['/tmp/second-1.png', '/tmp/second-2.png']
|
|
])
|
|
expect(assembler.byId.size).toBe(6)
|
|
expect(bubbles.map((row) => row.transcriptOrder)).toEqual([
|
|
older.messages.find((row) => row.id === 'z-first')?.transcriptOrder,
|
|
[...older.messages, ...page.messages].find((row) => row.id === 'a-second')?.transcriptOrder
|
|
])
|
|
}
|
|
)
|
|
|
|
it('keeps factual clocks for status, clear boundaries, mixed sources and optimistic tiers', async () => {
|
|
const { mergeNativeChatLiveSession } =
|
|
await import('../../src/shared/native-chat/native-chat-live-status')
|
|
const { applyCommandMarkerBoundaries, commandMarkersAsMessages } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-command-marker')
|
|
const { assembleNativeChatSession } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-session-assembler')
|
|
const { NATIVE_CHAT_STREAMING_ID } =
|
|
await import('../../src/shared/native-chat/native-chat-streaming')
|
|
const filePath = await transcriptFile(
|
|
lines(...Array.from({ length: 20 }, (_, i) => record(`old-${i}`, PROMPT.timestamp)))
|
|
)
|
|
const rows = (await read(filePath, 30)).messages
|
|
const time = Date.parse(PROMPT.timestamp)
|
|
expect(
|
|
mergeNativeChatLiveSession({
|
|
messages: rows,
|
|
sessionId: 's',
|
|
agent: 'grok',
|
|
hookState: 'working',
|
|
stateStartedAt: time + 10
|
|
}).status
|
|
).toBe('working')
|
|
expect(
|
|
applyCommandMarkerBoundaries(rows, [{ id: 'clear', command: '/clear', sentAt: time + 10 }])
|
|
).toEqual([])
|
|
const later = { ...rows[1], id: 'later', timestamp: time + 30 }
|
|
const marker = commandMarkersAsMessages([
|
|
{ id: 'compact', command: '/compact', sentAt: time + 20 }
|
|
])[0]
|
|
const echo = (
|
|
id: string,
|
|
timestamp: number | null,
|
|
source: NativeChatMessage['source'] = 'scrape'
|
|
): NativeChatMessage => ({
|
|
id,
|
|
timestamp,
|
|
source,
|
|
role: 'user',
|
|
blocks: [{ type: 'text', text: id }]
|
|
})
|
|
const transcript = [rows[0], later]
|
|
const result = assembleNativeChatSession({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
sources: {
|
|
transcript,
|
|
hook: [echo('hook', time + 10, 'hook')],
|
|
scrape: [
|
|
marker,
|
|
echo('null', null),
|
|
echo('after', time + 40),
|
|
echo(NATIVE_CHAT_STREAMING_ID, null),
|
|
echo('pending:1', null),
|
|
{ ...echo('queued', null), queued: true },
|
|
echo('launch-pending:1', null)
|
|
]
|
|
}
|
|
})
|
|
expect(result.messages.map((row) => row.id)).toEqual([
|
|
'null',
|
|
'old-0',
|
|
'hook',
|
|
marker.id,
|
|
'later',
|
|
'after',
|
|
NATIVE_CHAT_STREAMING_ID,
|
|
'launch-pending:1',
|
|
'pending:1',
|
|
'queued'
|
|
])
|
|
// Contradictory clocks preserve the canonical subsequence, with one explicit supplemental placement.
|
|
expect(
|
|
assembleNativeChatSession({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
sources: {
|
|
transcript: [
|
|
{ ...rows[0], timestamp: 100 },
|
|
{ ...later, timestamp: 0 }
|
|
],
|
|
hook: [echo('middle', 50, 'hook')]
|
|
}
|
|
}).messages.map((row) => row.id)
|
|
).toEqual(['middle', 'old-0', 'later'])
|
|
})
|
|
})
|