import { readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WebSocket } from 'ws' import type { AgentStatusEntry } from '../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../src/shared/native-chat-types' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTerminalClientTab } from '../../src/shared/runtime-types' import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers' // Read send behavior from control file per request: restarting mock rekeys E2EE and forces re-pair, preventing mid-session native chat repros. const SEND_MODE_FILE = process.env.MOCK_SEND_MODE_FILE ?? join(tmpdir(), 'orca-mock-send-mode') const TERMINAL_LIST_MODE_FILE = process.env.MOCK_TERMINAL_LIST_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-list-mode') // Write dead to model subscribed→end for gone PTY, exercising rearm bound and terminal pruning. const TERMINAL_STREAM_MODE_FILE = process.env.MOCK_TERMINAL_STREAM_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-stream-mode') const TERMINAL_HANDLE = 'chat-term-1' const TAB_ID = 'chat-tab-1' const SESSION_ID = 'mock-chat-session' const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl') const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png') // Exercise legacy OMP hooks without a transcript path; current hooks may include one. const CHAT_AGENT = process.env.MOCK_CHAT_AGENT === 'omp' ? 'omp' : 'claude' const CHAT_TITLE = CHAT_AGENT === 'omp' ? 'OMP' : 'Claude Code' const TRANSCRIPT_START = Date.now() - 1000 * 60 * 5 function readControl(file: string): string { try { return readFileSync(file, 'utf-8').trim() } catch { return '' } } const tabsSnapshots = new Map() const agentStatus: AgentStatusEntry = { state: 'done', prompt: '', updatedAt: Date.now(), stateStartedAt: Date.now(), agentType: CHAT_AGENT, paneKey: `${TAB_ID}:leaf-1`, terminalHandle: TERMINAL_HANDLE, stateHistory: [], providerSession: CHAT_AGENT === 'omp' ? { key: 'session_id', id: SESSION_ID } : { key: 'session_id', id: SESSION_ID, transcriptPath: TRANSCRIPT_PATH } } function buildTab(): RuntimeMobileSessionTerminalClientTab { return { type: 'terminal', id: TAB_ID, title: CHAT_TITLE, parentTabId: TAB_ID, leafId: 'leaf-1', ptyId: 'pty-1', status: 'ready', terminal: TERMINAL_HANDLE, launchAgent: CHAT_AGENT, agentStatus, viewMode: 'chat', isActive: true } } // Advance per-worktree versions only on content changes; runtime has no fake rerender heartbeat. function buildTabsResult(worktree: string): RuntimeMobileSessionTabsResult { const result: RuntimeMobileSessionTabsResult = { worktree, publicationEpoch: 'mock-epoch-1', snapshotVersion: 0, activeGroupId: 'group-1', activeTabId: TAB_ID, activeTabType: 'terminal', tabGroups: [{ id: 'group-1', activeTabId: TAB_ID, tabOrder: [TAB_ID] }], tabs: [buildTab()] } const signature = JSON.stringify(result) const previous = tabsSnapshots.get(worktree) const current = previous?.signature === signature ? previous : { version: (previous?.version ?? 0) + 1, signature } tabsSnapshots.set(worktree, current) result.snapshotVersion = current.version return result } function tabsResultIfChanged(worktree: string): RuntimeMobileSessionTabsResult | null { const before = tabsSnapshots.get(worktree)?.version const result = buildTabsResult(worktree) return result.snapshotVersion === before ? null : result } function worktreeOf(request: RpcRequest): string { const raw = request.params?.worktree return typeof raw === 'string' ? raw : 'id:mock-worktree' } // Mirror OMP decoder’s thinking/text/toolCall/toolResult shapes so phone exercises production render path. function mockTranscript(): NativeChatMessage[] { if (CHAT_AGENT !== 'omp') { return [] } const t = TRANSCRIPT_START return [ { id: 'omp-1', role: 'user', blocks: [{ type: 'text', text: 'why is my deploy failing?' }], timestamp: t, source: 'transcript' }, { id: 'omp-2', role: 'assistant', blocks: [ { type: 'text', text: 'Let me check the deploy logs first.' }, { type: 'tool-call', name: 'bash', input: { command: 'kubectl get pods' } } ], timestamp: t + 1000, source: 'transcript' }, { id: 'omp-3', role: 'tool', blocks: [{ type: 'tool-result', output: 'api-7f9c 0/1 CrashLoopBackOff' }], timestamp: t + 2000, source: 'transcript' }, { id: 'omp-4', role: 'assistant', blocks: [ { type: 'text', text: 'The API pod is crash-looping. Check its logs with kubectl logs.' } ], timestamp: t + 3000, source: 'transcript' } ] } // Cancel by worktree, not request id, so A→B→A navigation cannot stack push loops. const tabsPushLoops = new Map>>() function stopTabsPushLoop(ws: WebSocket, worktree: string): void { const loops = tabsPushLoops.get(ws) const existing = loops?.get(worktree) if (existing !== undefined) { clearInterval(existing) loops?.delete(worktree) } } function startTabsPushLoop(ws: WebSocket, worktree: string, push: () => void): void { stopTabsPushLoop(ws, worktree) const interval = setInterval(() => { if (ws.readyState !== ws.OPEN) { stopTabsPushLoop(ws, worktree) return } push() }, 3000) let loops = tabsPushLoops.get(ws) if (!loops) { loops = new Map() tabsPushLoops.set(ws, loops) ws.once('close', () => { for (const timer of tabsPushLoops.get(ws)?.values() ?? []) { clearInterval(timer) } tabsPushLoops.delete(ws) }) } loops.set(worktree, interval) } type Respond = (response: RpcResponse) => void type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse type Failure = (id: string, code: string, message: string) => RpcResponse // Opt-in MOCK_NATIVE_CHAT=1 replaces terminal fixtures with native chat tabs/transcript/send/upload; pushes no transcript/output frames. Return false for unowned methods. export function handleMockNativeChatRequest( request: RpcRequest, respond: Respond, success: Success, error: Failure, ws: WebSocket ): boolean { if (process.env.MOCK_NATIVE_CHAT !== '1') { return false } switch (request.method) { case 'session.tabs.list': respond(success(request.id, buildTabsResult(worktreeOf(request)))) return true case 'session.tabs.subscribe': { const worktree = worktreeOf(request) respond(success(request.id, buildTabsResult(worktree), true)) startTabsPushLoop(ws, worktree, () => { const changed = tabsResultIfChanged(worktree) if (changed) { respond(success(request.id, changed, true)) } }) return true } case 'session.tabs.unsubscribe': stopTabsPushLoop(ws, worktreeOf(request)) respond(success(request.id, { ok: true })) return true case 'session.tabs.activate': case 'nativeChat.unsubscribe': respond(success(request.id, { ok: true })) return true case 'terminal.list': { // omit means empty list; other means live list without this handle, matching a handle scoped to another worktree. const mode = readControl(TERMINAL_LIST_MODE_FILE) const worktreeId = worktreeOf(request).replace(/^id:/, '') const entry = (handle: string) => ({ handle, worktreeId, title: CHAT_TITLE, isActive: true, hasRunningProcess: true }) const terminals = mode === 'omit' ? [] : mode === 'other' ? [entry('some-other-term')] : [entry(TERMINAL_HANDLE)] respond(success(request.id, { terminals, totalCount: terminals.length, truncated: false })) return true } case 'nativeChat.subscribe': respond( success(request.id, { type: 'snapshot', messages: mockTranscript(), hasMore: false }, true) ) return true case 'nativeChat.readSession': respond(success(request.id, { messages: mockTranscript(), hasMore: false })) return true case 'terminal.subscribe': { // The `subscribed` frame is what releases mobile's native-chat input lease. respond(success(request.id, { type: 'subscribed', terminal: TERMINAL_HANDLE }, true)) if (readControl(TERMINAL_STREAM_MODE_FILE) === 'dead') { respond(success(request.id, { type: 'end' })) return true } respond( success( request.id, { type: 'scrollback', serialized: '', cols: 80, rows: 24, seq: 1 }, true ) ) return true } case 'terminal.send': { const mode = readControl(SEND_MODE_FILE) || 'accept' console.log(`[mock] terminal.send mode=${mode} text=${JSON.stringify(request.params?.text)}`) if (mode === 'error') { respond(error(request.id, 'mobile_input_floor_unavailable', 'Mobile input floor is held')) return true } respond( success(request.id, { send: { handle: TERMINAL_HANDLE, accepted: mode === 'accept', bytesWritten: mode === 'accept' ? String(request.params?.text ?? '').length : 0 } }) ) return true } case 'clipboard.startImageUpload': respond(success(request.id, { uploadId: 'mock-upload-1' })) return true case 'clipboard.appendImageUploadChunk': respond(success(request.id, { ok: true })) return true case 'clipboard.commitImageUpload': case 'clipboard.saveImageAsTempFile': respond(success(request.id, MOCK_IMAGE_PATH)) return true case 'clipboard.abortImageUpload': respond(success(request.id, { ok: true })) return true default: return false } }