mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* feat(native-chat): render omp transcripts
omp already ships as a first-class launchable agent with session_id resume, but
its transcripts had no decoder, so native chat could not render it — the agent
runs and the conversation stays a raw terminal. This adds the decoder and wires
it through the same path Claude, Codex and Grok use.
omp writes one envelope per line, `{ type, id, parentId, timestamp, … }`, where
conversation turns are `type: 'message'` and the rest is session bookkeeping.
Reasoning arrives as a `thinking` content block inside the assistant turn, so
the mapping follows Claude rather than Codex: thinking becomes a text block on
an assistant message, where Codex and Grok emit a separate reasoning role only
because their transcripts carry dedicated reasoning records.
- toolCall -> tool-call, arguments passed through as the object omp writes
- toolResult -> tool role, isError preserved
- developer -> system, matching the Codex non-user/non-assistant fallback
- blob-handle images drop, as the Claude mapper drops an image record with
neither path nor url
- bookkeeping and unrecognized types skip rather than throw
Session files are `<ISO timestamp>_<session id>.jsonl` under a per-cwd directory,
so the resolver matches the id as a base-name suffix the way Codex rollout files
are matched, and honors OMP_CODING_AGENT_DIR through normalizeAgentSessionsDir
so it stays consistent with the AI Vault scanner.
omp records no interruption or abort event, so unlike Claude and Codex there is
no NATIVE_CHAT_INTERRUPTED_STATUS_TEXT path.
Verified against 94,603 lines of real omp transcripts across four sessions:
50,546 records decoded, zero malformed, zero thrown.
* fix(native-chat): complete omp record coverage and gate remote transcripts
Review fixes on the omp transcript decoder.
omp writes several record types with no `content` field, so they decoded
to zero blocks and disappeared from the chat view entirely:
- `bashExecution` / `pythonExecution`: TUI `!command` runs, now a tool turn
- `fileMention`: `@path` attachments, listed by path (never `files[].content`,
which is an auto-read dump)
- `custom_message` and legacy `custom` / `hookMessage` rows, gated on
`display` the way omp's own renderer gates them
Also:
- `stopReason: 'aborted'` turns now surface as the interrupted row, matching
the Claude and Codex decoders. An abort carrying partial content keeps it.
- A cancelled command cell now reads as errored. Every omp cancel path emits
`exitCode: undefined`, which JSON drops, so an `exitCode !== 0` check read a
cancelled run as a clean success.
- omp joins Grok in requiring a locally readable transcript. Its hook reports
no transcript path, so under Model-A SSH the chat view opened against a disk
this process cannot read and never loaded. Applies on mobile too, which
shares the same allowlist.
- The session-file walk prunes omp's per-session subagent artifact
directories, matching the AI Vault scanner. It was returning a subagent
transcript instead of the parent session, and cost a full recursive readdir
on every resolve.
* style(native-chat): apply oxfmt to the omp review fixes
Mobile CI gates `oxfmt --check`; the two root files were unformatted too,
just ungated there. Line wrapping only, no behavior change.
---------
Co-authored-by: plotarmordev <299844489+plotarmordev@users.noreply.github.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
254 lines
8.1 KiB
TypeScript
254 lines
8.1 KiB
TypeScript
import { open, stat } from 'node:fs/promises'
|
|
import type {
|
|
AgentType,
|
|
NativeChatMessage,
|
|
NativeChatTurnLifecycle
|
|
} from '../../shared/native-chat-types'
|
|
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
|
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
|
|
import {
|
|
decodeClaudeTranscriptLine,
|
|
decodeCodexTranscriptLine,
|
|
decodeGrokTranscriptLine,
|
|
decodeOmpTranscriptLine
|
|
} from './transcript-line-decoders'
|
|
import { transcriptFallbackId } from './transcript-fallback-id'
|
|
import {
|
|
nativeChatTurnLifecycleDecoderForAgent,
|
|
type NativeChatTurnLifecycleDecoder
|
|
} from './transcript-turn-lifecycle'
|
|
|
|
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
|
|
const TAIL_CHUNK_BYTES = 64 * 1024
|
|
|
|
export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null
|
|
|
|
export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null {
|
|
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
|
|
if (transcriptAgent === 'claude') {
|
|
return decodeClaudeTranscriptLine
|
|
}
|
|
if (transcriptAgent === 'codex') {
|
|
return decodeCodexTranscriptLine
|
|
}
|
|
if (transcriptAgent === 'grok') {
|
|
return decodeGrokTranscriptLine
|
|
}
|
|
if (transcriptAgent === 'omp') {
|
|
return decodeOmpTranscriptLine
|
|
}
|
|
return null
|
|
}
|
|
|
|
export async function readNativeChatTranscriptTailFile(
|
|
filePath: string,
|
|
limit: number,
|
|
decode: NativeChatLineDecoder,
|
|
includeTrailingLine = false,
|
|
endOffset?: number,
|
|
decodeLifecycle?: NativeChatTurnLifecycleDecoder | null
|
|
): Promise<{
|
|
messages: NativeChatMessage[]
|
|
lifecycle?: NativeChatTurnLifecycle
|
|
consumedTo: number
|
|
hasMore: boolean
|
|
beforeOffset: number
|
|
malformedRecordCount?: number
|
|
oversizedRecordCount?: number
|
|
}> {
|
|
const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER)
|
|
if (end === 0) {
|
|
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
|
|
}
|
|
const handle = await open(filePath, 'r')
|
|
const lineParts: Buffer[] = []
|
|
let lineBytes = 0
|
|
let lineOversized = false
|
|
let lifecycle: NativeChatTurnLifecycle | undefined
|
|
let malformedRecordCount = 0
|
|
let oversizedRecordCount = 0
|
|
let ignoreNextMalformedRecord = false
|
|
try {
|
|
const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end)
|
|
if (consumedTo === 0) {
|
|
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
|
|
}
|
|
const newestFirst: { message: NativeChatMessage; offset: number }[] = []
|
|
const finalByte = Buffer.allocUnsafe(1)
|
|
await handle.read(finalByte, 0, 1, consumedTo - 1)
|
|
ignoreNextMalformedRecord = finalByte[0] !== 0x0a
|
|
let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0)
|
|
while (cursor > 0 && newestFirst.length <= limit) {
|
|
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
|
|
const buffer = Buffer.allocUnsafe(cursor - start)
|
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
|
|
let segmentEnd = bytesRead
|
|
for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) {
|
|
if (buffer[index] !== 0x0a) {
|
|
continue
|
|
}
|
|
retainPart(buffer.subarray(index + 1, segmentEnd))
|
|
if (!lineOversized) {
|
|
decodeLine(start + index + 1, newestFirst)
|
|
}
|
|
resetLine()
|
|
segmentEnd = index
|
|
}
|
|
if (segmentEnd > 0) {
|
|
retainPart(buffer.subarray(0, segmentEnd))
|
|
}
|
|
cursor = start
|
|
}
|
|
if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) {
|
|
decodeLine(0, newestFirst)
|
|
}
|
|
const chronological = newestFirst.toReversed()
|
|
// Why: slice(-0) returns the whole array, so a non-positive limit must
|
|
// window to nothing explicitly rather than leak every buffered record.
|
|
const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : []
|
|
return {
|
|
messages: selected.map((entry) => entry.message),
|
|
...(lifecycle ? { lifecycle } : {}),
|
|
consumedTo,
|
|
hasMore: limit > 0 && chronological.length > limit,
|
|
beforeOffset: selected[0]?.offset ?? end,
|
|
...(malformedRecordCount > 0 ? { malformedRecordCount } : {}),
|
|
...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {})
|
|
}
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
|
|
function retainPart(part: Buffer): void {
|
|
if (lineOversized) {
|
|
return
|
|
}
|
|
lineBytes += part.length
|
|
if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) {
|
|
lineParts.length = 0
|
|
lineOversized = true
|
|
oversizedRecordCount++
|
|
return
|
|
}
|
|
lineParts.push(part)
|
|
}
|
|
|
|
function resetLine(): void {
|
|
lineParts.length = 0
|
|
lineBytes = 0
|
|
lineOversized = false
|
|
}
|
|
|
|
function decodeLine(
|
|
lineOffset: number,
|
|
messages: { message: NativeChatMessage; offset: number }[]
|
|
): void {
|
|
let line = Buffer.concat([...lineParts].toReversed()).toString('utf8')
|
|
if (line.endsWith('\r')) {
|
|
line = line.slice(0, -1)
|
|
}
|
|
if (!line) {
|
|
return
|
|
}
|
|
try {
|
|
JSON.parse(line)
|
|
} catch {
|
|
if (ignoreNextMalformedRecord) {
|
|
ignoreNextMalformedRecord = false
|
|
return
|
|
}
|
|
malformedRecordCount++
|
|
return
|
|
}
|
|
ignoreNextMalformedRecord = false
|
|
const fallbackId = transcriptFallbackId(filePath, lineOffset)
|
|
// Why: scan the same bounded JSONL window for provider-authored lifecycle
|
|
// records so reconnect snapshots can replay completion without guessing
|
|
// from the last visible assistant message.
|
|
lifecycle ??= decodeLifecycle?.(line, fallbackId) ?? undefined
|
|
const message = decode(line, fallbackId)
|
|
if (message) {
|
|
messages.push({ message, offset: lineOffset })
|
|
}
|
|
}
|
|
}
|
|
|
|
async function findLastCompleteLineEnd(
|
|
handle: Awaited<ReturnType<typeof open>>,
|
|
end: number
|
|
): Promise<number> {
|
|
const lastByte = Buffer.allocUnsafe(1)
|
|
await handle.read(lastByte, 0, 1, end - 1)
|
|
if (lastByte[0] === 0x0a) {
|
|
return end
|
|
}
|
|
let cursor = end
|
|
while (cursor > 0) {
|
|
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
|
|
const buffer = Buffer.allocUnsafe(cursor - start)
|
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
|
|
const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a)
|
|
if (newline >= 0) {
|
|
return start + newline + 1
|
|
}
|
|
cursor = start
|
|
}
|
|
return 0
|
|
}
|
|
|
|
export async function readNativeChatTranscriptTail(
|
|
args: ResolveSessionFileOptions & {
|
|
agent: AgentType
|
|
sessionId: string
|
|
transcriptPath?: string
|
|
filePath?: string
|
|
limit: number
|
|
beforeOffset?: number
|
|
}
|
|
): Promise<
|
|
| {
|
|
messages: NativeChatMessage[]
|
|
lifecycle?: NativeChatTurnLifecycle
|
|
hasMore: boolean
|
|
beforeOffset: number
|
|
}
|
|
| { error: string; notFound?: true }
|
|
> {
|
|
const decode = nativeChatLineDecoderForAgent(args.agent)
|
|
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
|
|
const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args))
|
|
if (!decode) {
|
|
return { error: 'Transcript unavailable' }
|
|
}
|
|
// Why: a new agent session can report its id before the first JSONL flush;
|
|
// callers keep that miss in loading/retry rather than showing a false error.
|
|
if (!filePath) {
|
|
return { error: 'Transcript unavailable', notFound: true }
|
|
}
|
|
try {
|
|
const result = await readNativeChatTranscriptTailFile(
|
|
filePath,
|
|
args.limit,
|
|
decode,
|
|
true,
|
|
args.beforeOffset,
|
|
decodeLifecycle
|
|
)
|
|
return {
|
|
messages: result.messages,
|
|
// Why: an older pagination page must not rewind the live lifecycle; only
|
|
// the current transcript tail can authoritatively describe turn state.
|
|
...(args.beforeOffset === undefined && result.lifecycle
|
|
? { lifecycle: result.lifecycle }
|
|
: {}),
|
|
hasMore: result.hasMore,
|
|
beforeOffset: result.beforeOffset
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
|
? { error: message, notFound: true }
|
|
: { error: message }
|
|
}
|
|
}
|