Files
orca/mobile/scripts/mock-server-native-chat-scenario.ts
T
Neil 920a83889c refactor: move browser contracts and publication to defining owners (#40209)
* refactor: move atomic publication to its filesystem owner

* refactor: group shared browser definitions by feature

* refactor: bind proxy process and session contracts to defining owners

* refactor: consolidate direct session contract type imports

* refactor: retain atomic publication test timing under moved owner

* fix(benchmark): resolve historical tunnel framing source before loading
2026-11-09 00:45:45 -08:00

311 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/contracts/agent-status-types'
import type { NativeChatMessage } from '../../src/shared/native-chat/native-chat-types'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
import type { RuntimeMobileSessionTerminalClientTab } from '../../src/shared/runtime-mobile-session-tab-contracts'
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<string, { version: number; signature: string }>()
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<WebSocket, Map<string, ReturnType<typeof setInterval>>>()
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
}
}