Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions mobile/src/session/mobile-native-chat-eligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,24 @@ describe('resolveMobileNativeChat', () => {
)
})

// Why: like omp, Muse's hook reports no transcript path, so mobile can only
// show its chat when the serving host is the one holding the session file.
it('admits muse only when its transcript is readable by the serving host', () => {
const tab = { type: 'terminal', launchAgent: 'muse' }
expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({
agent: 'muse'
})
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment'))
).toMatchObject({ agent: 'muse' })
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()
})
Expand Down
2 changes: 1 addition & 1 deletion src/main/ai-vault/session-scanner-muse-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type MuseRecord = {
// Why: `recorded_at` is microseconds since epoch; the shared timeline helpers
// take milliseconds (or ISO strings), so convert here. Values below the
// microsecond floor fall through to the shared parser (seconds/ISO).
function museTimestampMs(value: unknown): number | null {
export function museTimestampMs(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value >= 1e14) {
return Math.floor(value / 1000)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
decodeClaudeTranscriptLine,
decodeCodexTranscriptLine,
decodeGrokTranscriptLine,
decodeMuseTranscriptLine,
decodeOmpTranscriptLine
} from '../transcript-line-decoders'
import { decodeTranscriptStream } from '../transcript-stream-lines'
Expand Down Expand Up @@ -149,6 +150,7 @@ const TRANSCRIPT_DECODERS = {
claude: decodeClaudeTranscriptLine,
codex: decodeCodexTranscriptLine,
grok: decodeGrokTranscriptLine,
muse: decodeMuseTranscriptLine,
omp: decodeOmpTranscriptLine
} as const

Expand Down
31 changes: 31 additions & 0 deletions src/main/native-chat/session-file-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,37 @@ describe('resolveSessionFilePath', () => {
}
})

it('resolves Muse session.jsonl by parent-dir session id', async () => {
const root = await makeRoot('orca-native-chat-resolve-muse-')
const museSessionsDir = join(root, 'muse-sessions')
const sessionDir = join(museSessionsDir, '2026', '09', '26', 'sess-muse-1')
await mkdir(sessionDir, { recursive: true })
const target = join(sessionDir, 'session.jsonl')
await writeFile(target, '{}\n')

const resolved = await resolveSessionFilePath('muse', 'sess-muse-1', { museSessionsDir })
expect(resolved).toBe(target)
})

it('never matches a Muse subagent log for the parent session id', async () => {
const root = await makeRoot('orca-native-chat-resolve-muse-subagent-')
const museSessionsDir = join(root, 'muse-sessions')
const sessionDir = join(museSessionsDir, '2026', '09', '26', 'sess-muse-parent')
await mkdir(join(sessionDir, 'subagent', 'sess-muse-child'), { recursive: true })
await writeFile(join(sessionDir, 'session.jsonl'), '{}\n')
const childLog = join(sessionDir, 'subagent', 'sess-muse-child', 'session.jsonl')
await writeFile(childLog, '{}\n')

// The parent id resolves to the parent file even with a child log present.
await expect(
resolveSessionFilePath('muse', 'sess-muse-parent', { museSessionsDir })
).resolves.toBe(join(sessionDir, 'session.jsonl'))
// A child id resolves to the child log through the same predicate.
await expect(
resolveSessionFilePath('muse', 'sess-muse-child', { museSessionsDir })
).resolves.toBe(childLog)
})

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.
Expand Down
34 changes: 34 additions & 0 deletions src/main/native-chat/session-file-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
} from '../../shared/native-chat-agent-support'
import { isWslUncPath } from '../../shared/wsl-paths'
import { walkSessionFiles } from '../ai-vault/session-scanner-discovery'
import {
museSessionIdFromFilePath,
resolveMuseSessionsDir
} from '../ai-vault/session-scanner-muse-paths'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts'
import { resolveOmpSessionsDir } from '../ai-vault/omp-session-root'
import { resolveOrcaManagedCodexHomePath } from '../codex/codex-home-paths'
Expand Down Expand Up @@ -74,6 +78,8 @@ export type ResolveSessionFileOptions = {
grokSessionsDir?: string
/** Override the omp sessions root (`~/.omp/agent/sessions`). */
ompSessionsDir?: string
/** Override the Muse sessions root (`~/.local/share/muse/sessions`). */
museSessionsDir?: 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
Expand Down Expand Up @@ -195,6 +201,13 @@ async function resolveSessionFileById(
signal
)
}
if (transcriptAgent === 'muse') {
return resolveMuseSessionFile(
trimmedId,
resolveMuseSessionsDir(options.museSessionsDir),
signal
)
}
// Why: a new transcript agent must pick its own resolver. Falling through to
// OMP's scan would search the wrong root with a foreign session id, so fail
// the build here instead of resolving silently wrong at runtime.
Expand Down Expand Up @@ -334,3 +347,24 @@ async function resolveOmpSessionFile(
})
return files[0] ?? null
}

async function resolveMuseSessionFile(
sessionId: string,
sessionsDir: string,
signal?: AbortSignal
): Promise<string | null> {
if (!sessionsDir) {
return null
}
const files = await walkSessionFiles(sessionsDir, 'muse', [], {
extensions: new Set(['.jsonl']),
// Why: the session id is the parent directory name
// (<root>/YYYY/MM/DD/<uuid>/session.jsonl), and a subagent log nests one
// deeper, so its parent dir is the child id — the predicate matches the
// parent file for a parent id and the child file for a child id, never
// crossing between them.
filePredicate: (path) => museSessionIdFromFilePath(path) === sessionId,
signal
})
return files[0] ?? null
}
232 changes: 232 additions & 0 deletions src/main/native-chat/transcript-line-decoders-muse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Muse Code session.jsonl line → NativeChatMessage decoder.
//
// Layout: <root>/YYYY/MM/DD/<uuid>/session.jsonl. Conversation turns are a few
// record shapes amid heavy bookkeeping (task lifecycle, reasoning, usage,
// reminders): `runtime.user_intent.accepted` (user), `assistant_message_committed`
// (assistant text), `assistant_tool_calls_committed` / `tool_result_batch_committed`
// (tool turns), `user_input_prompt_requested/settled` (question cards), and a
// cancelled `terminal` (interrupt row). Everything else skips, including the
// `retained_frame` / `retained_marker` retention envelopes, which carry no
// conversation payload.

import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatBlock,
type NativeChatMessage
} from '../../shared/native-chat-types'
import { museTimestampMs } from '../ai-vault/session-scanner-muse-parser'
import {
arrayValue,
asRecord,
extractString,
parseJsonObject
} from '../ai-vault/session-scanner-values'
import { toolResultOutput } from './transcript-record-blocks'

/**
* Muse session rows: `runtime.user_intent.accepted` user turns plus the run
* events the assistant emits (message, tool calls, tool results, question
* prompts and their answers, terminal interrupts). Records of any other shape
* are engine bookkeeping and return null.
*/
export function decodeMuseTranscriptLine(
line: string,
fallbackId: string
): NativeChatMessage | null {
const record = parseJsonObject(line)
if (!record) {
return null
}
const id = extractString(record.id) ?? fallbackId
const timestamp = museTimestampMs(record.recorded_at)
const payload = asRecord(record.payload)
if (!payload) {
return null
}

if (extractString(record.payload_type) === 'runtime.user_intent.accepted') {
const text = museUserText(payload)
return text
? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
: null
}

const event = asRecord(payload.event)
if (!event) {
return null
}
const kind = extractString(event.kind)
if (!kind) {
return null
}
switch (kind) {
case 'assistant_message_committed': {
const text = extractString(event.text)
return text
? {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
: null
}
case 'assistant_tool_calls_committed': {
const blocks = museToolCallBlocks(event.tool_calls)
return blocks.length === 0
? null
: { id, role: 'assistant', blocks, timestamp, source: 'transcript' }
}
case 'tool_result_batch_committed': {
const blocks = museToolResultBlocks(event.results)
return blocks.length === 0
? null
: { id, role: 'tool', blocks, timestamp, source: 'transcript' }
}
case 'user_input_prompt_requested': {
const text = museQuestionText(event.questions)
return text
? {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
: null
}
case 'user_input_prompt_settled': {
const text = museAnswerText(event.answers)
return text
? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
: null
}
case 'terminal':
// Why: a cancelled run stamps `terminal: 'cancelled'` on its own record
// (a separate task `failed`/`cancelled` row carries only the reason, with
// no tool link a stateless decoder could join). Surface the same
// interrupted row the other decoders emit for their aborts.
return event.terminal === 'cancelled'
? {
id,
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp,
source: 'transcript'
}
: null
default:
return null
}
}

/** All text blocks of a user intent: `refill_blocks`, then `model_messages` content. */
function museUserText(payload: Record<string, unknown>): string | null {
const texts = textBlocks(payload.refill_blocks)
if (texts.length === 0) {
for (const message of arrayValue(payload.model_messages)) {
texts.push(...textBlocks(asRecord(message)?.content))
}
}
return texts.length === 0 ? null : texts.join('\n')
}

function textBlocks(value: unknown): string[] {
const texts: string[] = []
for (const block of arrayValue(value)) {
const text = extractString(asRecord(block)?.text)
if (text) {
texts.push(text)
}
}
return texts
}

function museToolCallBlocks(value: unknown): NativeChatBlock[] {
const blocks: NativeChatBlock[] = []
for (const item of arrayValue(value)) {
const call = asRecord(item)
if (!call) {
continue
}
const callId = extractString(call.call_id)
blocks.push({
type: 'tool-call',
name: extractString(call.name) ?? 'tool',
input: parseToolArgs(call.args),
...(callId ? { callId } : {})
})
}
return blocks
}

/** Muse writes tool args as a JSON string; keep the raw string when it won't parse. */
function parseToolArgs(value: unknown): unknown {
if (typeof value !== 'string') {
return value ?? ''
}
try {
const parsed: unknown = JSON.parse(value)
return parsed
} catch {
return value
}
}

function museToolResultBlocks(value: unknown): NativeChatBlock[] {
const blocks: NativeChatBlock[] = []
for (const item of arrayValue(value)) {
const result = asRecord(item)
if (!result) {
continue
}
// Why: read the output raw rather than via `extractString` — it trims, and
// leading/trailing whitespace is meaningful in captured command output.
// Muse results carry no error flag; the detached task `failed` row is skipped.
blocks.push({ type: 'tool-result', output: toolResultOutput(result.text) })
}
return blocks
}

/** One assistant turn per question card: header, question, and option labels. */
function museQuestionText(value: unknown): string | null {
const cards: string[] = []
for (const item of arrayValue(value)) {
const question = asRecord(item)
if (!question) {
continue
}
const header = extractString(question.header)
const prompt = extractString(question.question)
const options: string[] = []
for (const option of arrayValue(question.options)) {
const label = extractString(asRecord(option)?.label)
if (label) {
options.push(`- ${label}`)
}
}
const head = [header, prompt].filter((part) => part).join(': ')
const card = [head, ...options].filter((part) => part).join('\n')
if (card) {
cards.push(card)
}
}
return cards.length === 0 ? null : cards.join('\n\n')
}

/** The user's answers render as their reply turn. */
function museAnswerText(value: unknown): string | null {
const answers: string[] = []
for (const item of arrayValue(value)) {
const answer = asRecord(item)
const label =
extractString(answer?.selected_label) ??
extractString(answer?.label) ??
extractString(answer?.text)
if (label) {
answers.push(label)
}
}
return answers.length === 0 ? null : answers.join('\n')
}
Loading