From 9e4e6ddae57b254f796a44d013be6663786a461e Mon Sep 17 00:00:00 2001 From: plotarmordev Date: Fri, 7 Aug 2026 15:58:29 +0800 Subject: [PATCH] feat(native-chat): render omp transcripts (#11523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `_.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> --- .../mobile-native-chat-eligibility.test.ts | 18 ++ .../session/mobile-native-chat-eligibility.ts | 12 +- .../native-chat/session-file-resolver.test.ts | 52 ++++ src/main/native-chat/session-file-resolver.ts | 43 +++ .../transcript-line-decoders-omp.ts | 227 ++++++++++++++ .../transcript-line-decoders.omp.test.ts | 280 ++++++++++++++++++ .../native-chat/transcript-line-decoders.ts | 1 + src/main/native-chat/transcript-reader.ts | 6 +- .../native-chat/transcript-tail-reader.ts | 6 +- .../native-chat-availability.test.ts | 15 + .../native-chat/native-chat-availability.ts | 10 +- .../lib/native-chat-initial-view-mode.test.ts | 13 + .../src/lib/native-chat-initial-view-mode.ts | 10 +- .../src/lib/native-chat-supported-agent.ts | 1 + .../lib/worktree-creation-agent-seeds.test.ts | 18 ++ .../src/lib/worktree-creation-agent-seeds.ts | 3 +- .../worktree-draft-startup-view-mode.test.ts | 69 +++++ .../lib/worktree-draft-startup-view-mode.ts | 3 +- src/shared/native-chat-agent-support.test.ts | 21 +- src/shared/native-chat-agent-support.ts | 16 +- 20 files changed, 808 insertions(+), 16 deletions(-) create mode 100644 src/main/native-chat/transcript-line-decoders-omp.ts create mode 100644 src/main/native-chat/transcript-line-decoders.omp.test.ts create mode 100644 src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 387736f065e..7daa4babea6 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -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() }) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 2f997c2cdc5..abda64b04ab 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -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 { diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 85cd653185b..98aa1f4abad 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -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 + // `_/` artifact dir, and a label-named child CAN end in + // `_`. 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 /codex-runtime-home/home/sessions, NOT ~/.codex/sessions. diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 33cf8c23823..136e3ea2291 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -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 `_.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 { + const files = await walkSessionFiles(sessionsDir, 'omp', [], { + extensions: new Set(['.jsonl']), + // Why: a session's task-subagent transcripts live in its same-named + // `_/` artifact dir, and a label-named child can still end in + // `_` — 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 +} diff --git a/src/main/native-chat/transcript-line-decoders-omp.ts b/src/main/native-chat/transcript-line-decoders-omp.ts new file mode 100644 index 00000000000..60fcd736f35 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-omp.ts @@ -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 +): 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 | 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 +} diff --git a/src/main/native-chat/transcript-line-decoders.omp.test.ts b/src/main/native-chat/transcript-line-decoders.omp.test.ts new file mode 100644 index 00000000000..04e28dfbc22 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.omp.test.ts @@ -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 => + 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() + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 67b1aaf76aa..2d84d1a5586 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -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' diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 86353604e20..8cb57fdb91d 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -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 diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 9fb66705103..35bee906956 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -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 } diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 50d19d8821d..9c77e05992a 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -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({ diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 3d2be38e376..11b9e5af7ad 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -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) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 5744c7684f1..4ef61a5cb78 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -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({ diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index 1d2e17cabbe..9b06a0a0d38 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -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 ( diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index 84135bba8f4..25bac3427ad 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -1,4 +1,5 @@ export { isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript, NATIVE_CHAT_SUPPORTED_AGENTS } from '../../../shared/native-chat-agent-support' diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index 43c48eef25e..9ac7e28ed25 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -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') diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts index 565ea421d71..295b5e5c06c 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -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) diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts new file mode 100644 index 00000000000..360e414ff13 --- /dev/null +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts @@ -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 + +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) +} + +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) +}) + +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') + }) +}) diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts index b4ca4000f62..0c11ea02c80 100644 --- a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts @@ -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) } diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index d32764d3f53..7608f50c98c 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -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) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 2e063d6aa73..8210e907e0a 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -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 = 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