From b57dad892b866c43fc24a751cbfc36ccd062c4b2 Mon Sep 17 00:00:00 2001 From: MS Seo Date: Sat, 26 Sep 2026 11:12:47 +0900 Subject: [PATCH 1/3] feat(native-chat): render Muse transcripts --- .../mobile-native-chat-eligibility.test.ts | 18 ++ .../ai-vault/session-scanner-muse-parser.ts | 2 +- .../journal-legacy-import.ts | 2 + .../native-chat/session-file-resolver.test.ts | 31 +++ src/main/native-chat/session-file-resolver.ts | 34 +++ .../transcript-line-decoders-muse.ts | 232 ++++++++++++++++++ .../transcript-line-decoders.muse.test.ts | 190 ++++++++++++++ .../native-chat/transcript-line-decoders.ts | 1 + src/main/native-chat/transcript-reader.ts | 4 + .../native-chat/transcript-tail-reader.ts | 4 + .../NativeChatSupportedAgents.test.tsx | 3 +- src/shared/native-chat-agent-support.test.ts | 10 +- src/shared/native-chat-agent-support.ts | 12 +- 13 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 src/main/native-chat/transcript-line-decoders-muse.ts create mode 100644 src/main/native-chat/transcript-line-decoders.muse.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 829af1c3d8cf..59018fe729a2 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -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() }) diff --git a/src/main/ai-vault/session-scanner-muse-parser.ts b/src/main/ai-vault/session-scanner-muse-parser.ts index dbb81a9b85bd..3bb9f43810f6 100644 --- a/src/main/ai-vault/session-scanner-muse-parser.ts +++ b/src/main/ai-vault/session-scanner-muse-parser.ts @@ -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) } diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index cf9ff45805cf..76bae65ba1b2 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -25,6 +25,7 @@ import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, decodeGrokTranscriptLine, + decodeMuseTranscriptLine, decodeOmpTranscriptLine } from '../transcript-line-decoders' import { decodeTranscriptStream } from '../transcript-stream-lines' @@ -149,6 +150,7 @@ const TRANSCRIPT_DECODERS = { claude: decodeClaudeTranscriptLine, codex: decodeCodexTranscriptLine, grok: decodeGrokTranscriptLine, + muse: decodeMuseTranscriptLine, omp: decodeOmpTranscriptLine } as const diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 4865df90af9d..ac5e49f95a43 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -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 /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 0f7d7c7367f7..3c7fc7ed57c6 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -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' @@ -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 @@ -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. @@ -334,3 +347,24 @@ async function resolveOmpSessionFile( }) return files[0] ?? null } + +async function resolveMuseSessionFile( + sessionId: string, + sessionsDir: string, + signal?: AbortSignal +): Promise { + if (!sessionsDir) { + return null + } + const files = await walkSessionFiles(sessionsDir, 'muse', [], { + extensions: new Set(['.jsonl']), + // Why: the session id is the parent directory name + // (/YYYY/MM/DD//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 +} diff --git a/src/main/native-chat/transcript-line-decoders-muse.ts b/src/main/native-chat/transcript-line-decoders-muse.ts new file mode 100644 index 000000000000..d80169777e96 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-muse.ts @@ -0,0 +1,232 @@ +// Muse Code session.jsonl line → NativeChatMessage decoder. +// +// Layout: /YYYY/MM/DD//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 | 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') +} diff --git a/src/main/native-chat/transcript-line-decoders.muse.test.ts b/src/main/native-chat/transcript-line-decoders.muse.test.ts new file mode 100644 index 000000000000..1bc234b4cdfc --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.muse.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { NATIVE_CHAT_INTERRUPTED_STATUS_TEXT } from '../../shared/native-chat-types' +import { decodeMuseTranscriptLine } from './transcript-line-decoders' + +const line = (record: unknown): string => JSON.stringify(record) + +// Why: `recorded_at` is microseconds; 1790357073429109 -> 1790357073429 ms. +const RECORDED_AT = 1790357073429109 +const RECORDED_AT_MS = 1790357073429 + +const envelope = (extra: Record): string => + line({ + schema_version: 1, + id: 'rec-1', + stream: { kind: 'session', id: 'sess-1' }, + sequence: 40, + recorded_at: RECORDED_AT, + record_type: 'event', + payload_type: 'runtime.session', + payload_schema_version: 1, + ...extra + }) + +const runEvent = (event: Record): string => + envelope({ payload: { kind: 'run', run_id: 'run-1', event } }) + +describe('decodeMuseTranscriptLine', () => { + it('skips malformed lines and non-conversation records', () => { + expect(decodeMuseTranscriptLine('not json', 'f')).toBeNull() + expect(decodeMuseTranscriptLine(line({ retained_marker: 'omitted_live_only' }), 'f')).toBeNull() + expect( + decodeMuseTranscriptLine(line({ retained_frame: 'session_permission_transaction' }), 'f') + ).toBeNull() + expect(decodeMuseTranscriptLine(runEvent({ kind: 'status', message: 'x' }), 'f')).toBeNull() + expect( + decodeMuseTranscriptLine(runEvent({ kind: 'model_completed', usage: {} }), 'f') + ).toBeNull() + expect( + decodeMuseTranscriptLine(runEvent({ kind: 'reasoning_summary_committed', text: 't' }), 'f') + ).toBeNull() + expect(decodeMuseTranscriptLine(runEvent({ kind: 'a-kind-from-the-future' }), 'f')).toBeNull() + expect(decodeMuseTranscriptLine(envelope({ payload: { kind: 'run' } }), 'f')).toBeNull() + }) + + it('decodes a user turn from refill blocks', () => { + const decoded = decodeMuseTranscriptLine( + envelope({ + payload_type: 'runtime.user_intent.accepted', + payload: { + intent_id: 'in-1', + surface: 'main', + refill_blocks: [{ kind: 'text', text: 'ship it' }] + } + }), + 'f' + ) + expect(decoded).toEqual({ + id: 'rec-1', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }], + timestamp: RECORDED_AT_MS, + source: 'transcript' + }) + }) + + it('falls back to model messages when refill blocks are missing', () => { + const decoded = decodeMuseTranscriptLine( + envelope({ + payload_type: 'runtime.user_intent.accepted', + payload: { model_messages: [{ content: [{ kind: 'text', text: 'from model' }] }] } + }), + 'f' + ) + expect(decoded?.role).toBe('user') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'from model' }]) + }) + + it('decodes assistant text', () => { + const decoded = decodeMuseTranscriptLine( + runEvent({ kind: 'assistant_message_committed', text: 'Done.' }), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Done.' }]) + expect(decoded?.timestamp).toBe(RECORDED_AT_MS) + }) + + it('decodes tool calls with parsed args and call ids', () => { + const decoded = decodeMuseTranscriptLine( + runEvent({ + kind: 'assistant_tool_calls_committed', + tool_calls: [ + { id: 'fc-1', call_id: 'call-1', name: 'bash', args: '{"command":"ls"}' }, + { name: 'read' } + ] + }), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([ + { type: 'tool-call', name: 'bash', input: { command: 'ls' }, callId: 'call-1' }, + { type: 'tool-call', name: 'read', input: '' } + ]) + }) + + it('keeps unparseable tool args as the raw string', () => { + const decoded = decodeMuseTranscriptLine( + runEvent({ + kind: 'assistant_tool_calls_committed', + tool_calls: [{ name: 'bash', args: '{oops' }] + }), + 'f' + ) + expect(decoded?.blocks).toEqual([{ type: 'tool-call', name: 'bash', input: '{oops' }]) + }) + + it('decodes tool results as a tool turn without trimming output', () => { + const decoded = decodeMuseTranscriptLine( + runEvent({ + kind: 'tool_result_batch_committed', + batch_id: 'b-1', + results: [ + { tool_call_index: 0, tool_call_id: 'call-1', text: ' ok\n' }, + { tool_call_index: 1, tool_call_id: 'call-2', text: 'more' } + ] + }), + 'f' + ) + expect(decoded?.role).toBe('tool') + expect(decoded?.blocks).toEqual([ + { type: 'tool-result', output: ' ok\n' }, + { type: 'tool-result', output: 'more' } + ]) + }) + + it('renders a question card as an assistant turn and answers as the user reply', () => { + const requested = decodeMuseTranscriptLine( + runEvent({ + kind: 'user_input_prompt_requested', + prompt_id: 'p-1', + questions: [ + { + id: 'q-1', + header: 'Next', + question: 'Which way?', + options: [{ label: 'Left' }, { label: 'Right' }] + } + ] + }), + 'f' + ) + expect(requested?.role).toBe('assistant') + expect(requested?.blocks).toEqual([{ type: 'text', text: 'Next: Which way?\n- Left\n- Right' }]) + const settled = decodeMuseTranscriptLine( + runEvent({ + kind: 'user_input_prompt_settled', + prompt_id: 'p-1', + outcome: 'answered', + answers: [{ id: 'q-1', selected_label: 'Left' }] + }), + 'f' + ) + expect(settled?.role).toBe('user') + expect(settled?.blocks).toEqual([{ type: 'text', text: 'Left' }]) + }) + + it('surfaces a cancelled terminal as the interrupted row', () => { + const decoded = decodeMuseTranscriptLine( + runEvent({ kind: 'terminal', terminal: 'cancelled', reason: 'ctrl-c' }), + 'f' + ) + expect(decoded?.role).toBe('system') + expect(decoded?.blocks).toEqual([{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }]) + expect( + decodeMuseTranscriptLine(runEvent({ kind: 'terminal', terminal: 'completed' }), 'f') + ).toBeNull() + }) + + it('uses the fallback id when the record carries none', () => { + const decoded = decodeMuseTranscriptLine( + line({ + recorded_at: RECORDED_AT, + payload_type: 'runtime.session', + payload: { event: { kind: 'assistant_message_committed', text: 'hi' } } + }), + 'fallback-7' + ) + expect(decoded?.id).toBe('fallback-7') + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 2d84d1a55866..3f4d5f2f83cf 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -12,4 +12,5 @@ export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' +export { decodeMuseTranscriptLine } from './transcript-line-decoders-muse' 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 a4d3acb7465f..836d72fbc81e 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -12,6 +12,7 @@ import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, decodeGrokTranscriptLine, + decodeMuseTranscriptLine, decodeOmpTranscriptLine } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' @@ -64,6 +65,9 @@ export async function readNativeChatTranscript( if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } + if (transcriptAgent === 'muse') { + return { messages: await readTranscript(filePath, decodeMuseTranscriptLine) } + } if (transcriptAgent === 'omp') { return { messages: await readTranscript(filePath, decodeOmpTranscriptLine) } } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index b6fa9bafdb0a..de9dfd350c71 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -9,6 +9,7 @@ import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, decodeGrokTranscriptLine, + decodeMuseTranscriptLine, decodeOmpTranscriptLine } from './transcript-line-decoders' import { transcriptFallbackId } from './transcript-fallback-id' @@ -44,6 +45,9 @@ export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineD if (transcriptAgent === 'grok') { return decodeGrokTranscriptLine } + if (transcriptAgent === 'muse') { + return decodeMuseTranscriptLine + } if (transcriptAgent === 'omp') { return decodeOmpTranscriptLine } diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx index e18f9ed2d425..1d2c27e7afcc 100644 --- a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx +++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx @@ -17,7 +17,8 @@ const EXPECTED_SUPPORTED_AGENTS = [ 'openclaude', 'codex', 'grok', - 'omp' + 'omp', + 'muse' ] as const satisfies readonly TuiAgent[] const SUPPORTED_AGENTS_LABEL_KEY = 'auto.components.settings.NativeChatSupportedAgents.label' diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index 7608f50c98cf..1fb17d45e436 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -12,10 +12,11 @@ describe('resolveNativeChatTranscriptAgent', () => { expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude') }) - it('passes codex, grok and omp through and rejects everything else', () => { + it('passes codex, grok, omp and muse through and rejects everything else', () => { expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') + expect(resolveNativeChatTranscriptAgent('muse')).toBe('muse') expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull() expect(resolveNativeChatTranscriptAgent(null)).toBeNull() expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull() @@ -27,6 +28,7 @@ describe('isNativeChatSupportedAgent', () => { expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) expect(isNativeChatSupportedAgent('omp')).toBe(true) + expect(isNativeChatSupportedAgent('muse')).toBe(true) expect(isNativeChatSupportedAgent('cursor')).toBe(false) expect(isNativeChatSupportedAgent(null)).toBe(false) expect(isNativeChatSupportedAgent(undefined)).toBe(false) @@ -35,10 +37,11 @@ describe('isNativeChatSupportedAgent', () => { 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. + // Claude/Codex report `transcript_path`; Grok, omp and muse 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('muse')).toBe(true) expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) expect(nativeChatRequiresLocalTranscript('openclaude')).toBe(false) expect(nativeChatRequiresLocalTranscript('codex')).toBe(false) @@ -60,6 +63,7 @@ describe('shouldStepNativeChatAskAnswer', () => { it('does not step other or unknown agents', () => { expect(shouldStepNativeChatAskAnswer('grok')).toBe(false) expect(shouldStepNativeChatAskAnswer('omp')).toBe(false) + expect(shouldStepNativeChatAskAnswer('muse')).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 70fa92a92bf6..69aa745a48d3 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -1,6 +1,6 @@ import type { TuiAgent } from './tui-agent' -export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' | 'muse' /** Agents whose transcripts the native chat view can parse and render, in the * order the settings pane advertises them. */ @@ -9,7 +9,8 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'openclaude', 'codex', 'grok', - 'omp' + 'omp', + 'muse' ] export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( @@ -21,10 +22,11 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo } /** Agents whose Model-A SSH transcript reader is not supported. A hook path alone - * does not establish owning-host reads, so OMP remains gated even with metadata. */ + * does not establish owning-host reads, so OMP remains gated even with metadata. + * Muse's hook likewise reports no transcript path, so it joins the gate. */ export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) - return transcriptAgent === 'grok' || transcriptAgent === 'omp' + return transcriptAgent === 'grok' || transcriptAgent === 'omp' || transcriptAgent === 'muse' } /** True when the agent renders a digit-commit question selector that ignores @@ -45,7 +47,7 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } - if (agent === 'codex' || agent === 'grok' || agent === 'omp') { + if (agent === 'codex' || agent === 'grok' || agent === 'omp' || agent === 'muse') { return agent } return null From 77d638419d384b89cf0381c4eaa4618d6adf602e Mon Sep 17 00:00:00 2001 From: MS Seo Date: Sat, 26 Sep 2026 14:15:16 +0900 Subject: [PATCH 2/3] feat(native-chat): add Muse live-terminal effort picker --- .../muse-terminal-session-options.test.ts | 51 +++++++++++++++++++ .../muse-terminal-session-options.ts | 32 ++++++++++++ .../use-native-chat-session-options.ts | 5 +- .../agent-session-option-catalog-muse.test.ts | 32 ++++++++++++ .../agent-session-option-catalog-muse.ts | 5 +- 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/native-chat/muse-terminal-session-options.test.ts create mode 100644 src/renderer/src/components/native-chat/muse-terminal-session-options.ts create mode 100644 src/shared/agent-session-option-catalog-muse.test.ts diff --git a/src/renderer/src/components/native-chat/muse-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/muse-terminal-session-options.test.ts new file mode 100644 index 000000000000..14dd3343a0fc --- /dev/null +++ b/src/renderer/src/components/native-chat/muse-terminal-session-options.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { readMuseSessionOptionsFromTerminalScreen } from './muse-terminal-session-options' + +// The TUI status line as the terminal serializer hands it over: ANSI-wrapped, +// ` · · · `, sitting at the bottom of the screen. +const SCREEN_WITH_STATUS = [ + '\u001b[38;2;136;136;136m────────────────────────────────────────\u001b[0m', + '\u001b[0m❯ Try "fix typecheck errors"', + '\u001b[34mmuse-spark-1.3-contributor\u001b[0m · \u001b[34mmax\u001b[0m · ~/Development/GitHub/orca · \u001b[31mYOLO\u001b[0m' +].join('\n') + +describe('readMuseSessionOptionsFromTerminalScreen', () => { + it('returns null without a screen or a status line', () => { + expect(readMuseSessionOptionsFromTerminalScreen(null)).toBeNull() + expect(readMuseSessionOptionsFromTerminalScreen(undefined)).toBeNull() + expect(readMuseSessionOptionsFromTerminalScreen('')).toBeNull() + expect(readMuseSessionOptionsFromTerminalScreen('plain output\nno status here')).toBeNull() + }) + + it('reads model and effort from the status line', () => { + expect(readMuseSessionOptionsFromTerminalScreen(SCREEN_WITH_STATUS)).toEqual({ + model: 'muse-spark-1.3-contributor', + effort: 'max' + }) + }) + + it('reports effort none even though the picker omits it', () => { + const screen = 'muse-spark-1.3-contributor · none · ~/repo · YOLO' + expect(readMuseSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'muse-spark-1.3-contributor', + effort: 'none' + }) + }) + + it('prefers the bottommost status-shaped line', () => { + const screen = [ + 'muse-spark-1.3 · low · ~/old · YOLO', + 'some user text mentioning high effort', + 'muse-spark-1.3 · ultra · ~/repo · YOLO' + ].join('\n') + expect(readMuseSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'muse-spark-1.3', + effort: 'ultra' + }) + }) + + it('rejects unknown effort words', () => { + const screen = 'muse-spark-1.3 · extreme · ~/repo · YOLO' + expect(readMuseSessionOptionsFromTerminalScreen(screen)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/muse-terminal-session-options.ts b/src/renderer/src/components/native-chat/muse-terminal-session-options.ts new file mode 100644 index 000000000000..0116e44c2a42 --- /dev/null +++ b/src/renderer/src/components/native-chat/muse-terminal-session-options.ts @@ -0,0 +1,32 @@ +import { stripAnsiEscapeSequences } from '../../../../shared/ansi-escape-sequences' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' + +// The TUI status line reads ` · · · `, e.g. +// `muse-spark-1.3-contributor · max · ~/Development/GitHub/orca · YOLO0`. +// The effort slot carries the raw `--reasoning-effort` id, including `none` +// (which the picker omits for lack of a translation but the pill renders raw). +const MUSE_STATUS_LINE = + /^\s*(\S+)\s*·\s*(none|minimal|low|medium|high|xhigh|max|ultra)\s*·\s*(.+?)\s*·\s*(\S+)\s*$/ + +/** + * Current Muse model/effort scraped from the TUI status line. The model id is + * reported raw: account-scoped ids have no catalog listing, so there is no + * picker, but the pill still names what the session runs. + */ +export function readMuseSessionOptionsFromTerminalScreen( + screen: string | null | undefined +): Record | null { + if (!screen) { + return null + } + const lines = stripAnsiEscapeSequences(screen).split('\n') + // Why: the status line sits at the bottom; scanning upward takes it before + // any user text that happens to share the four-segment shape. + for (let index = lines.length - 1; index >= 0; index--) { + const match = lines[index].match(MUSE_STATUS_LINE) + if (match) { + return { model: match[1], effort: match[2] } + } + } + return null +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index 6e77b06e445a..dab370827476 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -22,6 +22,7 @@ import { resolveNativeChatModelDiscoveryContext } from './native-chat-session-option-discovery' import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' +import { readMuseSessionOptionsFromTerminalScreen } from './muse-terminal-session-options' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' @@ -112,7 +113,9 @@ export function useNativeChatSessionOptions(args: { readTerminalScreen?.(), discoveredModels ?? undefined ) - : null + : agent === 'muse' + ? readMuseSessionOptionsFromTerminalScreen(readTerminalScreen?.()) + : null return createNativeChatPtySessionOptions({ agent, scopeKey, diff --git a/src/shared/agent-session-option-catalog-muse.test.ts b/src/shared/agent-session-option-catalog-muse.test.ts new file mode 100644 index 000000000000..a5680e0f63c5 --- /dev/null +++ b/src/shared/agent-session-option-catalog-muse.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { MUSE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-muse' + +const effort = () => { + const option = (MUSE_SESSION_OPTION_CATALOG.unknownModelOptions ?? []).find( + (candidate) => candidate.id === 'effort' + ) + if (!option) { + throw new Error('Muse effort option missing from catalog') + } + return option +} + +describe('muse session option catalog', () => { + it('is registered for the muse agent', () => { + expect(getAgentSessionOptionCatalog('muse')).toBe(MUSE_SESSION_OPTION_CATALOG) + }) + + it('switches effort mid-session through the TUI slash command', () => { + const midSession = effort().apply.midSession + expect(midSession?.kind).toBe('command') + if (midSession?.kind !== 'command') { + throw new Error('Muse effort midSession must stay a slash command') + } + expect(midSession.build('low')).toBe('/effort low') + }) + + it('keeps launch args for worker starts', () => { + expect(effort().apply.launchArgs?.('ultra')).toEqual(['--reasoning-effort', 'ultra']) + }) +}) diff --git a/src/shared/agent-session-option-catalog-muse.ts b/src/shared/agent-session-option-catalog-muse.ts index 6b5df60fe4de..2d7bd24d8ac7 100644 --- a/src/shared/agent-session-option-catalog-muse.ts +++ b/src/shared/agent-session-option-catalog-muse.ts @@ -23,7 +23,10 @@ const MUSE_EFFORT: CatalogOption = { apply: { launchArgs: (value) => ['--reasoning-effort', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['--reasoning-effort']), - removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--reasoning-effort']) + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--reasoning-effort']), + // Why: the TUI honors `/effort ` mid-session, so a live terminal tab + // switches the same way Claude's `/model` does — typed into the PTY. + midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } } From ad6167bd6655128457011327339f78d8578e085a Mon Sep 17 00:00:00 2001 From: MS Seo Date: Sat, 26 Sep 2026 14:59:24 +0900 Subject: [PATCH 3/3] fix(native-chat): give unlisted tracked models their unknown-model options --- .../native-chat-session-option-snapshot.test.ts | 17 ++++++++++++++++- .../native-chat-session-option-snapshot.ts | 9 ++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/shared/native-chat-session-option-snapshot.test.ts b/src/shared/native-chat-session-option-snapshot.test.ts index 13779d6042b8..c44b451efdb3 100644 --- a/src/shared/native-chat-session-option-snapshot.test.ts +++ b/src/shared/native-chat-session-option-snapshot.test.ts @@ -10,6 +10,7 @@ import { } from './agent-session-option-catalog-claude-codex' import { CURSOR_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-gemini-cursor' import { GROK_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-grok' +import { MUSE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-muse' import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' import { createNativeChatSessionOptionRecord, @@ -187,13 +188,27 @@ describe('buildNativeChatSessionOptionSnapshot', () => { CLAUDE_SESSION_OPTION_CATALOG.models, record ) + // Why: an unlisted id still gets the catalog's unknown-model options, so + // a reported-but-unseeded model renders its pills instead of none. expect(reconciled.at(-1)).toEqual({ id: 'experimental-model', label: 'experimental-model', - options: [] + options: CLAUDE_SESSION_OPTION_CATALOG.unknownModelOptions ?? [] }) }) + it('gives an unlisted tracked model the catalog unknown-model options', () => { + const record = createNativeChatSessionOptionRecord('muse') + record.model = { value: 'muse-spark-1.3-contributor', source: 'reported' } + const reconciled = withTrackedNativeChatModel( + MUSE_SESSION_OPTION_CATALOG, + MUSE_SESSION_OPTION_CATALOG.models, + record + ) + expect(reconciled).toHaveLength(1) + expect(reconciled[0].options.map((option) => option.id)).toEqual(['effort']) + }) + it('leaves the list alone when the tracked model is already listed', () => { const record = claudeRecord() record.model = { value: 'sonnet', source: 'reported' } diff --git a/src/shared/native-chat-session-option-snapshot.ts b/src/shared/native-chat-session-option-snapshot.ts index c2d17f800aa7..35da54016745 100644 --- a/src/shared/native-chat-session-option-snapshot.ts +++ b/src/shared/native-chat-session-option-snapshot.ts @@ -177,7 +177,14 @@ export function withTrackedNativeChatModel( return [...models] } const seeded = catalog.models.find((model) => model.id === trackedId) - return [...models, seeded ?? { id: trackedId, label: trackedId, options: [] }] + // Why: an unlisted-but-tracked id is exactly what `unknownModelOptions` is + // for — without it a reported model renders its pill with no options, even + // when the catalog defines some (Muse reports raw account-scoped ids and + // seeds no models at all). Agents without unknown options keep `[]`. + return [ + ...models, + seeded ?? { id: trackedId, label: trackedId, options: catalog.unknownModelOptions ?? [] } + ] } /** Why: no tracked model means no `-m` was ever emitted, so the CLI is running its