mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
co-authored by
plotarmordev
Brennan Benson
parent
f057cbc85f
commit
9e4e6ddae5
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user