feat(native-chat): render omp transcripts (#11523)

* 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>
This commit is contained in:
plotarmordev
2026-08-07 00:58:29 -07:00
committed by GitHub
co-authored by plotarmordev Brennan Benson
parent f057cbc85f
commit 9e4e6ddae5
20 changed files with 808 additions and 16 deletions
@@ -97,6 +97,24 @@ describe('resolveMobileNativeChat', () => {
).toBeNull()
})
// Why: omp's hook reports no transcript path either, so mobile can only show
// its chat when the serving host is the one holding the session file.
it('admits omp only when its transcript is readable by the serving host', () => {
const tab = { type: 'terminal', launchAgent: 'omp' }
expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({
agent: 'omp'
})
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment'))
).toMatchObject({ agent: 'omp' })
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))
).toBeNull()
expect(canShowMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))).toBe(
false
)
})
it('returns null for a plain shell (no agent)', () => {
expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull()
})
@@ -1,10 +1,14 @@
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host'
import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support'
import {
isNativeChatSupportedAgent,
nativeChatRequiresLocalTranscript
} from '../../../src/shared/native-chat-agent-support'
// Why: native chat renders an agent's own JSONL transcript, and the host
// resolver knows these transcript layouts. Grok is additionally gated on host
// readability because Model-A SSH stores its transcript on the remote target.
// resolver knows these transcript layouts. Agents whose hook reports no
// transcript path (Grok, omp) are additionally gated on host readability,
// because Model-A SSH stores their transcript on the remote target.
export function isMobileNativeChatTranscriptReadable(
connectionId: string | null | undefined
): boolean {
@@ -50,7 +54,7 @@ export function resolveMobileNativeChat(
if (!agent || !isNativeChatSupportedAgent(agent)) {
return null
}
if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) {
if (nativeChatRequiresLocalTranscript(agent) && !nativeChatTranscriptIsLocalReadable) {
return null
}
return {
@@ -137,6 +137,58 @@ describe('resolveSessionFilePath', () => {
expect(resolved).toBe(target)
})
it('matches omp transcripts by session id suffix inside the per-cwd directory', async () => {
const root = await makeRoot('orca-native-chat-resolve-omp-')
const ompSessionsDir = join(root, 'omp-sessions')
const cwdDir = join(ompSessionsDir, '-Users-ada-repo')
await mkdir(cwdDir, { recursive: true })
const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-1.jsonl')
await writeFile(target, '{}\n')
const resolved = await resolveSessionFilePath('omp', 'sess-omp-1', { ompSessionsDir })
expect(resolved).toBe(target)
})
it('never descends into an omp session artifact dir', async () => {
// Why: a session's task-subagent transcripts sit in its same-named
// `<stamp>_<uuid>/` artifact dir, and a label-named child CAN end in
// `_<session id>`. Asserting the parent wins would only prove the prune on a
// filesystem that happens to enumerate the dir first, so give the id exactly
// one match — inside the artifact dir. Pruned resolves to null; descending
// finds the child, whatever order readdir returns.
const root = await makeRoot('orca-native-chat-resolve-omp-artifact-')
const ompSessionsDir = join(root, 'omp-sessions')
const cwdDir = join(ompSessionsDir, '-Users-ada-repo')
const stem = '2026-07-16T00-27-02-222Z_019fd8e2-fd56-7000-acfe-2e497adfa83c'
await mkdir(join(cwdDir, stem), { recursive: true })
await writeFile(join(cwdDir, `${stem}.jsonl`), '{}\n')
await writeFile(join(cwdDir, stem, 'worker_sess-omp-child.jsonl'), '{}\n')
await expect(
resolveSessionFilePath('omp', 'sess-omp-child', { ompSessionsDir })
).resolves.toBeNull()
// The parent transcript itself still resolves through the pruned walk.
await expect(
resolveSessionFilePath('omp', '019fd8e2-fd56-7000-acfe-2e497adfa83c', { ompSessionsDir })
).resolves.toBe(join(cwdDir, `${stem}.jsonl`))
})
it('honors OMP_CODING_AGENT_DIR when resolving omp transcripts', async () => {
const root = await makeRoot('orca-native-chat-resolve-omp-env-')
const cwdDir = join(root, 'omp-sessions', '-Users-ada-repo')
await mkdir(cwdDir, { recursive: true })
const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-env.jsonl')
await writeFile(target, '{}\n')
const previous = process.env.OMP_CODING_AGENT_DIR
process.env.OMP_CODING_AGENT_DIR = join(root, 'omp-sessions')
try {
await expect(resolveSessionFilePath('omp', 'sess-omp-env')).resolves.toBe(target)
} finally {
restoreEnv('OMP_CODING_AGENT_DIR', previous)
}
})
it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => {
// Orca launches Codex with its own managed CODEX_HOME, so rollout files land
// under <userData>/codex-runtime-home/home/sessions, NOT ~/.codex/sessions.
@@ -4,6 +4,8 @@ import { basename, extname, join } from 'node:path'
import type { AgentType } from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { walkSessionFiles } from '../ai-vault/session-scanner-discovery'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts'
import { normalizeAgentSessionsDir } from '../ai-vault/session-scanner-values'
import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths'
import {
findGrokChatHistoryBySessionId,
@@ -37,6 +39,15 @@ function grokSessionsDir(): string {
return resolveGrokSessionsDir(process.env, homedir())
}
/** Mirrors the AI Vault scanner so an OMP_CODING_AGENT_DIR override resolves the
* same root for both, rather than leaving native chat pointed at the default. */
function ompSessionsDir(): string {
return normalizeAgentSessionsDir(
process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'),
'.omp'
)
}
export type ResolveSessionFileOptions = {
/** Override the Claude projects root (used by tests / isolated scans). */
claudeProjectsDir?: string
@@ -45,6 +56,8 @@ export type ResolveSessionFileOptions = {
codexSessionsDirs?: string[]
/** Override the Grok sessions root (`~/.grok/sessions`). */
grokSessionsDir?: string
/** Override the omp sessions root (`~/.omp/agent/sessions`). */
ompSessionsDir?: string
/** Authoritative transcript path reported by the agent hook
* (`providerSession.transcriptPath`). When set and the file exists, it is used
* directly — recent Claude Code names the transcript with a UUID that differs
@@ -94,6 +107,9 @@ export async function resolveSessionFilePath(
if (transcriptAgent === 'grok') {
return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir())
}
if (transcriptAgent === 'omp') {
return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir())
}
return null
}
@@ -143,3 +159,30 @@ async function resolveGrokSessionFile(
const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId)
return history
}
// omp keeps one directory per working directory (`-Documents-dog-app`) with the
// transcript inside it, named `<ISO timestamp>_<session id>.jsonl` — so match the
// id as a base-name suffix, the way Codex rollout files are matched, and let the
// walk cover the per-cwd subdirectories.
async function resolveOmpSessionFile(
sessionId: string,
sessionsDir: string
): Promise<string | null> {
const files = await walkSessionFiles(sessionsDir, 'omp', [], {
extensions: new Set(['.jsonl']),
// Why: a session's task-subagent transcripts live in its same-named
// `<stamp>_<uuid>/` artifact dir, and a label-named child can still end in
// `_<session id>` — so descending would let a subagent transcript win the
// suffix match over its own parent. Prune the subtree exactly as the AI
// Vault scanner does (session-scanner-source-discovery.ts): it keeps the
// walk at one readdir per workspace dir regardless of how much the session
// delegated. Depth 0 is the workspace dir, which is never an artifact dir.
directoryPredicate: (name, depth) =>
depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name),
filePredicate: (path) => {
const name = basename(path, extname(path))
return name === sessionId || name.endsWith(`_${sessionId}`)
}
})
return files[0] ?? null
}
@@ -0,0 +1,227 @@
// omp (pi-agent) JSONL line → NativeChatMessage decoder.
//
// Conversation turns are `type: 'message'`; every other record type is session
// bookkeeping. Rendering follows file order, so `parentId` is not consulted:
// omp's file is really a tree and its own TUI renders pathTo(leaf), so a session
// rewound onto a new branch shows the abandoned turns here too. That matches the
// Claude decoder, which ignores `parentUuid` the same way; unwinding the branch
// needs a stateful decoder contract shared by every agent, not an omp-only fix.
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatBlock,
type NativeChatMessage
} from '../../shared/native-chat-types'
import {
asRecord,
extractString,
parseJsonObject,
timestampMs
} from '../ai-vault/session-scanner-values'
import { toolResultOutput } from './transcript-record-blocks'
/**
* omp session rows: `type: 'message'` turns carrying user/assistant/toolResult/
* developer records with text, thinking, toolCall and image content blocks, the
* content-less bash/python execution cells, plus the `type: 'custom_message'`
* rows extensions inject into the conversation.
* Session bookkeeping rows (session_init, mode_change, compaction, custom) are
* skipped, as are records of an unrecognized type.
*/
export function decodeOmpTranscriptLine(
line: string,
fallbackId: string
): NativeChatMessage | null {
const record = parseJsonObject(line)
if (!record || (record.type !== 'message' && record.type !== 'custom_message')) {
return null
}
const id = extractString(record.id) ?? fallbackId
const timestamp = parseTimestamp(record.timestamp)
if (record.type === 'custom_message') {
// Why: these extension-authored turns reach the model, and omp's own
// transcript renders them — but only when `display` is set; the rest are
// extension state it never shows (CustomMessageEntry, session-entries.d.ts).
const customBlocks = record.display === true ? ompContentBlocks(record.content) : []
return customBlocks.length === 0
? null
: { id, role: 'system', blocks: customBlocks, timestamp, source: 'transcript' }
}
const message = asRecord(record.message)
if (!message) {
return null
}
const role = extractString(message.role)
if (role === 'toolResult') {
return {
id,
role: 'tool',
blocks: [
{
type: 'tool-result',
output: toolResultOutput(message.content),
...(message.isError === true ? { isError: true } : {})
}
],
timestamp,
source: 'transcript'
}
}
if (role === 'bashExecution' || role === 'pythonExecution') {
// Why: omp persists a TUI `!command` / python run as a content-less message
// ({role, command|code, output, exitCode}) and renders it as a command cell.
// Surface it as a tool turn so the output keeps its collapsible affordance.
return {
id,
role: 'tool',
blocks: ompExecutionBlocks(role, message),
timestamp,
source: 'transcript'
}
}
if (role === 'fileMention') {
// Why: an `@path` attachment is another content-less record omp's transcript
// renders. List the paths only — `files[].content` is an auto-read dump that
// would bury the conversation.
const paths = ompFileMentionPaths(message.files)
return paths.length === 0
? null
: {
id,
role: 'system',
blocks: [{ type: 'text', text: paths.map((path) => `@${path}`).join('\n') }],
timestamp,
source: 'transcript'
}
}
// Why: sessions written before version 3 stored extension turns as
// `type: 'message'` with role custom/hookMessage and a message-level
// `display` flag (the v3 migration rewrites hookMessage -> custom). Honor the
// same gate the `custom_message` branch does, or a resumed legacy session
// renders state omp itself hides.
if ((role === 'custom' || role === 'hookMessage') && message.display !== true) {
return null
}
const blocks = ompContentBlocks(message.content)
if (blocks.length === 0) {
// Why: omp stamps an aborted turn onto the assistant message itself
// (`stopReason: 'aborted'`), and when nothing streamed before the abort the
// content is empty — so the turn would silently vanish. Surface it as the
// same interrupted row Claude and Codex emit for their own aborts.
return role === 'assistant' && message.stopReason === 'aborted'
? {
id,
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp,
source: 'transcript'
}
: null
}
const messageRole = role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : 'system'
return { id, role: messageRole, blocks, timestamp, source: 'transcript' }
}
/** A bash/python execution cell: the invocation, then its captured output. */
function ompExecutionBlocks(
role: 'bashExecution' | 'pythonExecution',
message: Record<string, unknown>
): NativeChatBlock[] {
const isBash = role === 'bashExecution'
// Why: read these raw rather than via `extractString` — it trims, and leading
// or trailing whitespace is meaningful in captured command output.
const source = isBash ? message.command : message.code
const output = message.output
// Why: every cancel/timeout path returns `{exitCode: undefined, cancelled: true}`,
// and JSON.stringify drops the undefined key, so a cancelled run carries NO
// exitCode on disk. Without the `cancelled` arm it would render as a clean
// success next to partial output; omp's own cell shows a cancelled marker.
const failed =
message.cancelled === true || (typeof message.exitCode === 'number' && message.exitCode !== 0)
return [
{
type: 'tool-call',
name: isBash ? 'bash' : 'python',
input: typeof source === 'string' ? source : ''
},
{
type: 'tool-result',
output: typeof output === 'string' ? output : '',
...(failed ? { isError: true } : {})
}
]
}
/** The `path` of every entry in a fileMention's `files` array. */
function ompFileMentionPaths(files: unknown): string[] {
if (!Array.isArray(files)) {
return []
}
const paths: string[] = []
for (const file of files) {
const path = extractString(asRecord(file)?.path)
if (path) {
paths.push(path)
}
}
return paths
}
/** Build the blocks for one omp content array. */
function ompContentBlocks(content: unknown): NativeChatBlock[] {
if (typeof content === 'string') {
return content.trim() ? [{ type: 'text', text: content }] : []
}
if (!Array.isArray(content)) {
return []
}
const blocks: NativeChatBlock[] = []
for (const item of content) {
const block = ompContentBlock(asRecord(item))
if (block) {
blocks.push(block)
}
}
return blocks
}
/** Map one omp content entry; unknown block types yield null and are dropped. */
function ompContentBlock(record: Record<string, unknown> | null): NativeChatBlock | null {
if (!record) {
return null
}
switch (record.type) {
case 'text': {
const text = extractString(record.text)
return text ? { type: 'text', text } : null
}
case 'thinking': {
const text = extractString(record.thinking) ?? extractString(record.text)
return text ? { type: 'text', text } : null
}
case 'toolCall': {
const name = extractString(record.name) ?? 'tool'
return { type: 'tool-call', name, input: record.arguments }
}
case 'image':
// Why: omp stores images as content-addressed blob handles
// (`blob:sha256:…`) rather than a path or URL, so there is nothing the
// renderer can load. Dropping the block matches how the Claude mapper
// treats an image record with neither source.
return null
default:
return null
}
}
/** `timestampMs` yields NaN for an unparsable value; the chat model wants null. */
function parseTimestamp(value: unknown): number | null {
const parsed = timestampMs(value)
return Number.isFinite(parsed) ? parsed : null
}
@@ -0,0 +1,280 @@
import { describe, expect, it } from 'vitest'
import { decodeOmpTranscriptLine } from './transcript-line-decoders'
const line = (record: unknown): string => JSON.stringify(record)
const message = (role: string, content: unknown, extra: Record<string, unknown> = {}): string =>
line({
type: 'message',
id: 'rec-1',
parentId: 'rec-0',
timestamp: '2026-07-16T00:27:02.222Z',
message: { role, content, ...extra }
})
describe('decodeOmpTranscriptLine', () => {
it('skips malformed lines and non-conversation records', () => {
expect(decodeOmpTranscriptLine('not json', 'f')).toBeNull()
expect(decodeOmpTranscriptLine(line({ type: 'session_init', id: 'a' }), 'f')).toBeNull()
expect(decodeOmpTranscriptLine(line({ type: 'mode_change', id: 'a' }), 'f')).toBeNull()
expect(
decodeOmpTranscriptLine(line({ type: 'custom', customType: 'tool_execution_start' }), 'f')
).toBeNull()
expect(decodeOmpTranscriptLine(line({ type: 'compaction', shortSummary: 's' }), 'f')).toBeNull()
expect(decodeOmpTranscriptLine(line({ type: 'a-type-from-the-future' }), 'f')).toBeNull()
})
it('decodes a user turn', () => {
const decoded = decodeOmpTranscriptLine(
message('user', [{ type: 'text', text: 'resume' }]),
'f'
)
expect(decoded).toEqual({
id: 'rec-1',
role: 'user',
blocks: [{ type: 'text', text: 'resume' }],
timestamp: Date.parse('2026-07-16T00:27:02.222Z'),
source: 'transcript'
})
})
it('keeps thinking and tool calls together on a mixed assistant turn', () => {
const decoded = decodeOmpTranscriptLine(
message('assistant', [
{ type: 'thinking', thinking: 'Checking the goal' },
{ type: 'text', text: 'Reading it now.' },
{ type: 'toolCall', id: 'call-1', name: 'goal', arguments: { op: 'get' } }
]),
'f'
)
expect(decoded?.role).toBe('assistant')
expect(decoded?.blocks).toEqual([
{ type: 'text', text: 'Checking the goal' },
{ type: 'text', text: 'Reading it now.' },
{ type: 'tool-call', name: 'goal', input: { op: 'get' } }
])
})
it('keeps a thinking-only assistant turn on the assistant role', () => {
const decoded = decodeOmpTranscriptLine(
message('assistant', [{ type: 'thinking', thinking: 'Weighing two options' }]),
'f'
)
expect(decoded?.role).toBe('assistant')
expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Weighing two options' }])
})
it('passes tool arguments through unchanged', () => {
const decoded = decodeOmpTranscriptLine(
message('assistant', [
{ type: 'toolCall', name: 'goal', arguments: { op: 'get', objective: null } }
]),
'f'
)
expect(decoded?.blocks[0]).toEqual({
type: 'tool-call',
name: 'goal',
input: { op: 'get', objective: null }
})
})
it('decodes a tool result', () => {
const decoded = decodeOmpTranscriptLine(
message('toolResult', [{ type: 'text', text: 'ok' }], {
toolCallId: 'call-1',
toolName: 'goal',
isError: false
}),
'f'
)
expect(decoded?.role).toBe('tool')
expect(decoded?.blocks).toEqual([{ type: 'tool-result', output: 'ok' }])
})
it('flags an errored tool result', () => {
const decoded = decodeOmpTranscriptLine(
message('toolResult', [{ type: 'text', text: 'boom' }], {
toolCallId: 'call-2',
isError: true
}),
'f'
)
expect(decoded?.blocks[0]).toEqual({ type: 'tool-result', output: 'boom', isError: true })
})
it('surfaces a displayed custom_message, and hides a state-only one', () => {
const custom = (display: boolean): string =>
line({
type: 'custom_message',
id: 'rec-c',
customType: 'rewind-report',
display,
content: [{ type: 'text', text: 'Investigation summary' }],
timestamp: '2026-07-16T00:27:02.222Z'
})
expect(decodeOmpTranscriptLine(custom(true), 'f')).toEqual({
id: 'rec-c',
role: 'system',
blocks: [{ type: 'text', text: 'Investigation summary' }],
timestamp: Date.parse('2026-07-16T00:27:02.222Z'),
source: 'transcript'
})
expect(decodeOmpTranscriptLine(custom(false), 'f')).toBeNull()
})
it('accepts string content on a custom_message', () => {
const decoded = decodeOmpTranscriptLine(
line({ type: 'custom_message', id: 'rec-s', display: true, content: 'peer said hi' }),
'f'
)
expect(decoded?.blocks).toEqual([{ type: 'text', text: 'peer said hi' }])
})
it('renders a bash execution cell as a tool turn', () => {
const decoded = decodeOmpTranscriptLine(
line({
type: 'message',
id: 'rec-b',
timestamp: '2026-07-16T00:27:02.222Z',
message: { role: 'bashExecution', command: 'ls -a', output: '.git\n', exitCode: 0 }
}),
'f'
)
expect(decoded?.role).toBe('tool')
expect(decoded?.blocks).toEqual([
{ type: 'tool-call', name: 'bash', input: 'ls -a' },
{ type: 'tool-result', output: '.git\n' }
])
})
it('flags a nonzero exit code and reads python cells from `code`', () => {
const decoded = decodeOmpTranscriptLine(
line({
type: 'message',
id: 'rec-p',
message: {
role: 'pythonExecution',
code: 'raise SystemExit(2)',
output: 'boom',
exitCode: 2
}
}),
'f'
)
expect(decoded?.blocks).toEqual([
{ type: 'tool-call', name: 'python', input: 'raise SystemExit(2)' },
{ type: 'tool-result', output: 'boom', isError: true }
])
})
// Why: omp's cancel and timeout paths both return `exitCode: undefined`, which
// JSON.stringify omits — so the record has no exitCode at all and only
// `cancelled` distinguishes it from a clean run.
it('marks a cancelled run, which carries no exitCode at all', () => {
const decoded = decodeOmpTranscriptLine(
line({
type: 'message',
id: 'rec-c1',
message: { role: 'bashExecution', command: 'sleep 99', output: 'partial', cancelled: true }
}),
'f'
)
expect(decoded?.blocks[1]).toEqual({ type: 'tool-result', output: 'partial', isError: true })
})
it('lists file mention paths without dumping their auto-read contents', () => {
const decoded = decodeOmpTranscriptLine(
line({
type: 'message',
id: 'rec-f',
message: {
role: 'fileMention',
files: [
{ path: 'src/a.ts', content: 'SECRET FILE BODY' },
{ path: 'src/b.ts' },
{ image: true }
]
}
}),
'f'
)
expect(decoded?.role).toBe('system')
expect(decoded?.blocks).toEqual([{ type: 'text', text: '@src/a.ts\n@src/b.ts' }])
expect(JSON.stringify(decoded)).not.toContain('SECRET FILE BODY')
})
// Why: pre-v3 sessions stored extension turns as `type:'message'` with role
// custom/hookMessage; the display flag gates them exactly as custom_message.
it('honors the display gate on legacy custom / hookMessage message rows', () => {
const legacy = (role: string, display: boolean): string =>
line({
type: 'message',
id: 'rec-l',
message: { role, customType: 'irc:incoming', display, content: 'peer note' }
})
expect(decodeOmpTranscriptLine(legacy('custom', false), 'f')).toBeNull()
expect(decodeOmpTranscriptLine(legacy('hookMessage', false), 'f')).toBeNull()
expect(decodeOmpTranscriptLine(legacy('custom', true), 'f')).toMatchObject({
role: 'system',
blocks: [{ type: 'text', text: 'peer note' }]
})
})
it('surfaces the developer channel as system', () => {
const decoded = decodeOmpTranscriptLine(
message('developer', [{ type: 'text', text: 'context note' }]),
'f'
)
expect(decoded?.role).toBe('system')
})
it('drops blob-handle images, which the renderer cannot load', () => {
expect(
decodeOmpTranscriptLine(
message('user', [{ type: 'image', data: 'blob:sha256:abc', mimeType: 'image/webp' }]),
'f'
)
).toBeNull()
})
it('surfaces an aborted turn as the interrupted row, not silence', () => {
// Why: omp stamps `stopReason: 'aborted'` on the assistant message itself and
// leaves the content empty when nothing streamed, so the turn used to decode
// to null and vanish. Claude and Codex both emit this row for their aborts.
const decoded = decodeOmpTranscriptLine(
message('assistant', [{ type: 'text', text: '' }], {
stopReason: 'aborted',
errorMessage: 'Stopped before model call'
}),
'f'
)
expect(decoded?.role).toBe('system')
expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Conversation interrupted' }])
})
it('keeps partial content when a turn aborted mid-stream', () => {
// omp spreads the in-flight message and stamps the abort on it, so a partial
// answer is real conversation and must not be replaced by the status row.
const decoded = decodeOmpTranscriptLine(
message('assistant', [{ type: 'text', text: 'Partial answer' }], { stopReason: 'aborted' }),
'f'
)
expect(decoded?.role).toBe('assistant')
expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Partial answer' }])
})
it('still drops an empty non-aborted assistant turn', () => {
expect(
decodeOmpTranscriptLine(message('assistant', [{ type: 'text', text: '' }]), 'f')
).toBeNull()
})
it('falls back to the supplied id when the record carries none', () => {
const decoded = decodeOmpTranscriptLine(
line({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }),
'fallback-9'
)
expect(decoded?.id).toBe('fallback-9')
expect(decoded?.timestamp).toBeNull()
})
})
@@ -12,3 +12,4 @@
export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex'
export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok'
export { decodeOmpTranscriptLine } from './transcript-line-decoders-omp'
+5 -1
View File
@@ -10,7 +10,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio
import {
decodeClaudeTranscriptLine,
decodeCodexTranscriptLine,
decodeGrokTranscriptLine
decodeGrokTranscriptLine,
decodeOmpTranscriptLine
} from './transcript-line-decoders'
import { decodeTranscriptStream } from './transcript-stream-lines'
@@ -55,6 +56,9 @@ export async function readNativeChatTranscript(
if (transcriptAgent === 'grok') {
return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) }
}
if (transcriptAgent === 'omp') {
return { messages: await readTranscript(filePath, decodeOmpTranscriptLine) }
}
return { error: `Unsupported agent for Chat UI transcript: ${agent}` }
} catch (err) {
// Why: ENOENT after a successful resolve is the same first-flush/rotation
@@ -9,7 +9,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio
import {
decodeClaudeTranscriptLine,
decodeCodexTranscriptLine,
decodeGrokTranscriptLine
decodeGrokTranscriptLine,
decodeOmpTranscriptLine
} from './transcript-line-decoders'
import { transcriptFallbackId } from './transcript-fallback-id'
import {
@@ -33,6 +34,9 @@ export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineD
if (transcriptAgent === 'grok') {
return decodeGrokTranscriptLine
}
if (transcriptAgent === 'omp') {
return decodeOmpTranscriptLine
}
return null
}
@@ -90,6 +90,21 @@ describe('canToggleNativeChat', () => {
).toBe(false)
})
// Why: omp discloses no hook transcript path either, so its session file is
// only reachable when this process can read the agent's disk.
it('rejects Model-A SSH omp but accepts it local and runtime-owned', () => {
const forConnection = (connectionId: string | null): boolean =>
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: 'omp',
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId)
})
expect(forConnection('ssh-target-1')).toBe(false)
expect(forConnection(null)).toBe(true)
expect(forConnection('runtime-ssh-env-1')).toBe(true)
})
it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => {
expect(
canToggleNativeChat({
@@ -1,6 +1,9 @@
import type { Tab, TuiAgent } from '../../../../shared/types'
import type { AgentType } from '../../../../shared/agent-status-types'
import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent'
import {
isNativeChatSupportedAgent,
nativeChatRequiresLocalTranscript
} from '@/lib/native-chat-supported-agent'
export { isNativeChatSupportedAgent }
@@ -46,7 +49,10 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean
return true
}
const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent
if (agent === 'grok' && input.nativeChatTranscriptIsLocalReadable !== true) {
if (
nativeChatRequiresLocalTranscript(agent) &&
input.nativeChatTranscriptIsLocalReadable !== true
) {
return false
}
return isNativeChatSupportedAgent(agent)
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import type { Tab } from '../../../shared/types'
import {
decideInitialAgentTabViewMode,
initialAgentTabViewModeProps
@@ -70,6 +71,18 @@ describe('decideInitialAgentTabViewMode', () => {
).toBe('chat')
})
it('keeps Model-A SSH omp in the terminal view but opens it locally', () => {
const forConnection = (connectionId: string | null): Tab['viewMode'] =>
decideInitialAgentTabViewMode({
experimentalNativeChat: true,
openAgentTabsInChatByDefault: true,
agent: 'omp',
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId)
})
expect(forConnection('ssh-target-1')).toBeUndefined()
expect(forConnection(null)).toBe('chat')
})
it('keeps Model-A SSH Grok in the terminal view', () => {
expect(
decideInitialAgentTabViewMode({
@@ -1,6 +1,9 @@
import type { GlobalSettings, Tab, TuiAgent } from '../../../shared/types'
import { canMirrorLaunchDraftToNativeChat } from '@/lib/native-chat-launch-draft-mirrorability'
import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent'
import {
isNativeChatSupportedAgent,
nativeChatRequiresLocalTranscript
} from '@/lib/native-chat-supported-agent'
export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready'
@@ -28,7 +31,10 @@ export function decideInitialAgentTabViewMode(args: {
if (!isNativeChatSupportedAgent(args.agent)) {
return undefined
}
if (args.agent === 'grok' && args.nativeChatTranscriptIsLocalReadable !== true) {
if (
nativeChatRequiresLocalTranscript(args.agent) &&
args.nativeChatTranscriptIsLocalReadable !== true
) {
return undefined
}
if (
@@ -1,4 +1,5 @@
export {
isNativeChatSupportedAgent,
nativeChatRequiresLocalTranscript,
NATIVE_CHAT_SUPPORTED_AGENTS
} from '../../../shared/native-chat-agent-support'
@@ -157,6 +157,24 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => {
expect(tabViewMode('agent-tab')).toBe('chat')
})
it('still opens a local omp draft in chat, despite the local-transcript gate', () => {
// Why: omp discloses no hook transcript path, so it joins Grok in requiring a
// locally readable sessions root. This call site must therefore SUPPLY that
// readability flag for omp too — gating on Grok alone left it undefined and
// parked every omp draft in the terminal view, local workspace or not.
setTabs([{ id: 'agent-tab', launchAgent: 'omp', viewMode: 'terminal' }])
seedAgentTabStateAfterWorktreeCreate({
request: { ...request, agent: 'omp' as const },
worktreeId: 'wt-1',
primaryTabId: 'agent-tab',
startupTerminalTabId: 'agent-tab',
backendSpawned: true
})
expect(tabViewMode('agent-tab')).toBe('chat')
})
it('keys a raw backend tab id and updates the host before its tab mirror lands', async () => {
setTabs([], 'runtime-1')
@@ -5,6 +5,7 @@ import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed
import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { toWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
@@ -63,7 +64,7 @@ function applyBackendSpawnedDraftViewMode(args: {
agent,
promptDelivery: 'draft',
launchDraftText: request.launchDraftPrompt,
...(agent === 'grok'
...(nativeChatRequiresLocalTranscript(agent)
? {
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
getConnectionIdFromState(state, worktreeId)
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import { resolveBackendDraftStartup } from './worktree-draft-startup-view-mode'
type AppState = ReturnType<typeof useAppStore.getState>
const initialSettings = useAppStore.getState().settings!
const initialRepos = useAppStore.getState().repos
const request = {
repoId: 'repo-1',
startup: { launchCommand: 'omp' },
launchDraftPrompt: 'https://github.com/o/r/issues/12'
} as never
function setRepoConnection(connectionId: string | null): void {
useAppStore.setState({
repos: [{ id: 'repo-1', path: '/repo', connectionId }]
} as unknown as Partial<AppState>)
}
function viewModeFor(agent: string): string | undefined {
const startup = resolveBackendDraftStartup({ ...(request as object), agent } as never) as
| { viewMode?: string }
| undefined
return startup?.viewMode
}
beforeEach(() => {
useAppStore.setState({
settings: {
...initialSettings,
experimentalNativeChat: true,
openAgentTabsInChatByDefault: true
}
})
})
afterEach(() => {
useAppStore.setState({ settings: initialSettings, repos: initialRepos } as Partial<AppState>)
})
describe('resolveBackendDraftStartup', () => {
// Why: omp discloses no hook transcript path, so it joins Grok in requiring a
// locally readable sessions root. This call site must SUPPLY that flag for omp
// too — gating on Grok alone left it undefined and parked every omp draft in
// the terminal view, local workspace or not.
it('opens a local omp draft in chat', () => {
setRepoConnection(null)
expect(viewModeFor('omp')).toBe('chat')
})
it('keeps a Model-A SSH omp draft in the terminal view', () => {
setRepoConnection('ssh-target-1')
expect(viewModeFor('omp')).toBe('terminal')
})
it('opens a runtime-owned SSH omp draft in chat, which reads the transcript locally', () => {
setRepoConnection('runtime-ssh-env-1')
expect(viewModeFor('omp')).toBe('chat')
})
it('preserves the same split for Grok', () => {
setRepoConnection(null)
expect(viewModeFor('grok')).toBe('chat')
setRepoConnection('ssh-target-1')
expect(viewModeFor('grok')).toBe('terminal')
})
})
@@ -1,6 +1,7 @@
import { useAppStore } from '@/store'
import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
export function resolveBackendDraftStartup(
@@ -19,7 +20,7 @@ export function resolveBackendDraftStartup(
agent: request.agent,
promptDelivery: 'draft',
launchDraftText: request.launchDraftPrompt,
...(request.agent === 'grok'
...(nativeChatRequiresLocalTranscript(request.agent)
? {
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId)
}
+20 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
isNativeChatSupportedAgent,
nativeChatRequiresLocalTranscript,
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
} from './native-chat-agent-support'
@@ -11,9 +12,10 @@ describe('resolveNativeChatTranscriptAgent', () => {
expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude')
})
it('passes codex and grok through and rejects everything else', () => {
it('passes codex, grok and omp through and rejects everything else', () => {
expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex')
expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok')
expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp')
expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull()
expect(resolveNativeChatTranscriptAgent(null)).toBeNull()
expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull()
@@ -24,12 +26,28 @@ describe('isNativeChatSupportedAgent', () => {
it('recognizes the parseable agents and rejects unknown / nullish input', () => {
expect(isNativeChatSupportedAgent('claude')).toBe(true)
expect(isNativeChatSupportedAgent('openclaude')).toBe(true)
expect(isNativeChatSupportedAgent('omp')).toBe(true)
expect(isNativeChatSupportedAgent('cursor')).toBe(false)
expect(isNativeChatSupportedAgent(null)).toBe(false)
expect(isNativeChatSupportedAgent(undefined)).toBe(false)
})
})
describe('nativeChatRequiresLocalTranscript', () => {
it('covers the agents whose hook discloses no transcript path', () => {
// Claude/Codex report `transcript_path`; Grok and omp report only an id, so
// native chat has to find their file on a disk this process can read.
expect(nativeChatRequiresLocalTranscript('grok')).toBe(true)
expect(nativeChatRequiresLocalTranscript('omp')).toBe(true)
expect(nativeChatRequiresLocalTranscript('claude')).toBe(false)
expect(nativeChatRequiresLocalTranscript('openclaude')).toBe(false)
expect(nativeChatRequiresLocalTranscript('codex')).toBe(false)
expect(nativeChatRequiresLocalTranscript('cursor')).toBe(false)
expect(nativeChatRequiresLocalTranscript(null)).toBe(false)
expect(nativeChatRequiresLocalTranscript(undefined)).toBe(false)
})
})
describe('shouldStepNativeChatAskAnswer', () => {
it('steps the digit-commit selector agents (Claude, OpenClaude, Codex)', () => {
expect(shouldStepNativeChatAskAnswer('claude')).toBe(true)
@@ -41,6 +59,7 @@ describe('shouldStepNativeChatAskAnswer', () => {
it('does not step other or unknown agents', () => {
expect(shouldStepNativeChatAskAnswer('grok')).toBe(false)
expect(shouldStepNativeChatAskAnswer('omp')).toBe(false)
expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false)
expect(shouldStepNativeChatAskAnswer(null)).toBe(false)
expect(shouldStepNativeChatAskAnswer(undefined)).toBe(false)
+13 -3
View File
@@ -1,17 +1,27 @@
export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok'
export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp'
/** Agents whose transcripts the native chat view can parse and render. */
export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet<string> = new Set([
'claude',
'openclaude',
'codex',
'grok'
'grok',
'omp'
])
export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean {
return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent)
}
/** Agents whose hook discloses no transcript path (`extractAgentProviderSession`),
* so native chat can only reach the session file by scanning a sessions root on
* a disk THIS process can read. Under Model-A SSH that disk is the wrong host,
* so the chat view must stay closed instead of loading forever. */
export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean {
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
return transcriptAgent === 'grok' || transcriptAgent === 'omp'
}
/** True when the agent renders a digit-commit question selector that ignores
* typed label text (pasting "Blue" + Enter commits the highlighted FIRST
* option — STA-1860): Claude's AskUserQuestion and Codex 0.145's
@@ -30,7 +40,7 @@ export function resolveNativeChatTranscriptAgent(
if (agent === 'claude' || agent === 'openclaude') {
return 'claude'
}
if (agent === 'codex' || agent === 'grok') {
if (agent === 'codex' || agent === 'grok' || agent === 'omp') {
return agent
}
return null