mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
313 lines
9.9 KiB
TypeScript
313 lines
9.9 KiB
TypeScript
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,
|
||
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<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
|
||
}
|
||
}
|