Files
orca/tests/e2e/__mocks__/native-chat-ssh-delivery.ts
T
bfa0dc01ba fix(mobile): preserve long terminal and chat pastes (#31473)
* fix(mobile): preserve long terminal and chat pastes

Agents can keep only the last ~1 KiB read of unframed input (#13332).
Frame long single-line chat messages like multi-line ones, and frame Live
input appends over 1 KiB (an OS paste) with the terminal's paste rule,
including held text flushed before a terminal click.

Co-authored-by: Carlos Baraza Haro <carlos.baraza@ieee.org>

* fix(mobile): end the live input session after a framed paste

Agents such as Codex collapse a framed paste into one token, so later
per-character erases from the field mirror desynced the line. A long
append to a terminal in bracketed paste mode now commits the whole field
in one frame and clears the field, recording it like other session ends
so a late native echo cannot send the paste twice; without that mode the
paste stays unframed and editable. Chat sends measure the sanitized bytes
when deciding to frame.

---------

Co-authored-by: Carlos Baraza Haro <carlos.baraza@ieee.org>
Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
2026-10-29 03:02:08 +00:00

227 lines
7.9 KiB
TypeScript

import { vi } from 'vitest'
import { build } from 'esbuild'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { sendMobileNativeChatPermissionResponse } from '../../../mobile/src/session/native-chat/input/mobile-native-chat-permission-send'
import {
WRITE_ACCEPTED,
writeUnverifiable,
type WriteSettlement
} from '../../../src/shared/pty-write-settlement'
import { toSshExecutionHostId } from '../../../src/shared/execution-host'
import { createPtyWriteInput } from '../../../src/main/ipc/pty/ipc/write-input'
import type { IPtyProvider } from '../../../src/main/providers/pty-provider-contract'
import type * as PtyProviderRegistry from '../../../src/main/ipc/pty/provider/registry'
import { ptyOwnership } from '../../../src/main/ipc/pty/provider/ownership-state'
import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime'
import { makeStore } from '../../../src/main/runtime/runtime-rpc-worktree-store-fixtures'
const io = vi.hoisted(() => ({
getProvider: (): IPtyProvider => {
throw new Error('provider not installed')
},
rpc: vi.fn()
}))
export { io }
vi.mock('../../../src/main/ipc/pty/provider/registry', async (importOriginal) => {
const original = await importOriginal<typeof PtyProviderRegistry>()
return {
...original,
tryGetProviderForPty: () => io.getProvider(),
getProviderForPty: () => io.getProvider()
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } }))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: (...args: unknown[]) => io.rpc(...args)
}))
vi.mock('@/runtime/runtime-terminal-stream', () => ({
getRemoteRuntimePtyEnvironmentId: (id: string) => (id.startsWith('remote:') ? 'owner' : null),
getRemoteRuntimeTerminalHandle: (id: string) => (id.startsWith('remote:') ? 'terminal' : null),
getRemoteRuntimePtyOwner: (id: string) =>
id.startsWith('remote:')
? { target: { kind: 'environment', environmentId: 'owner' }, terminal: 'terminal' }
: null
}))
type SettlingPtyProvider = Omit<IPtyProvider, 'write'> & {
write: (id: string, data: string) => boolean
hasPty: (id: string) => boolean
dispose: () => void
}
/** A remote PTY owner whose write acknowledgments settle per `mode`, like a host transport. */
function createSettlingProvider(
bytes: string[],
mode: 'accepted' | 'lost' | 'slow'
): SettlingPtyProvider {
const timers = new Set<ReturnType<typeof setTimeout>>()
const settleAfter = (ms: number, settlement: WriteSettlement): Promise<WriteSettlement> =>
new Promise((resolve) => {
const timer = setTimeout(() => {
timers.delete(timer)
resolve(settlement)
}, ms)
timers.add(timer)
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the delivery paths under test touch only these members.
return {
hasPty: () => true,
write: (_id: string, data: string) => {
bytes.push(data)
return true
},
writeWithSettlement: (_id: string, data: string) => {
bytes.push(data)
if (mode === 'lost') {
return settleAfter(10, writeUnverifiable('transport_settlement_lost', true))
}
if (mode === 'slow' && (data.startsWith('X') || data.startsWith('\x1b[200~X'))) {
return settleAfter(1300, WRITE_ACCEPTED)
}
return Promise.resolve(WRITE_ACCEPTED)
},
dispose: () => {
for (const timer of timers) {
clearTimeout(timer)
}
timers.clear()
}
} as unknown as SettlingPtyProvider
}
export function createSshDelivery(mode: 'accepted' | 'lost' | 'slow' = 'accepted') {
const bytes: string[] = []
const provider = createSettlingProvider(bytes, mode)
vi.spyOn(provider, 'hasPty').mockReturnValue(true)
const settlement = vi.spyOn(provider, 'writeWithSettlement')
io.getProvider = () => provider
const input = createPtyWriteInput({})
const id = 'ssh:connection@@pty-1'
ptyOwnership.set(id, toSshExecutionHostId('connection'))
vi.stubGlobal('window', {
api: {
pty: {
write: (ptyId: string, data: string, inputKind: 'driving') =>
input.writePtyInput({ id: ptyId, data, inputKind }),
writeAccepted: (
ptyId: string,
data: string,
inputKind: 'driving',
options?: { requireWriteSettlement?: true }
) => input.writePtyInputAccepted({ id: ptyId, data, inputKind, ...options })
}
}
})
return {
id,
bytes,
provider,
settlement,
close: () => {
ptyOwnership.delete(id)
provider.dispose()
}
}
}
export async function createPairedRuntime(ssh: ReturnType<typeof createSshDelivery>) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This leaf-only fixture uses the existing store stub and never invokes repository mutations.
const runtime = new OrcaRuntimeService(makeStore() as never)
runtime.setPtyController({
write: (_, data) => ssh.provider.write(ssh.id, data),
writeWithSettlement: (_, data) => ssh.provider.writeWithSettlement(ssh.id, data),
hasPty: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Codex',
activeLeafId: '11111111-1111-4111-8111-111111111111',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: '11111111-1111-4111-8111-111111111111',
paneRuntimeId: 1,
ptyId: ssh.id,
paneTitle: null,
title: ''
}
]
})
const { terminals } = await runtime.listTerminals('id:repo-1::/tmp/worktree-a')
if (!terminals[0]) {
throw new Error('terminal fixture missing')
}
return { runtime, handle: terminals[0].handle }
}
let mobilePermissionModule: Promise<unknown> | undefined
async function bundleMobilePermissionModule(): Promise<unknown> {
const result = await build({
entryPoints: [
fileURLToPath(
new URL(
'../../../mobile/src/session/native-chat/input/mobile-native-chat-permission-send.ts',
import.meta.url
)
)
],
bundle: true,
platform: 'node',
format: 'esm',
write: false,
// Root-only test workers have no Expo tsconfig; execute the unchanged source bundle.
tsconfigRaw: {}
})
const source = result.outputFiles[0]?.text
if (!source) {
throw new Error('Mobile permission module did not build')
}
// Why a file, not a data: URL: the Bun test runtime resolves a long data: URL as a package name.
const dir = await mkdtemp(join(tmpdir(), 'orca-mobile-permission-send-'))
try {
const file = join(dir, 'mobile-native-chat-permission-send.mjs')
await writeFile(file, source)
return await import(/* @vite-ignore */ pathToFileURL(file).href)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
export async function sendMobilePermissionResponse(
args: Parameters<typeof sendMobileNativeChatPermissionResponse>[0]
): ReturnType<typeof sendMobileNativeChatPermissionResponse> {
const mobileModule = await (mobilePermissionModule ??= bundleMobilePermissionModule())
if (
typeof mobileModule !== 'object' ||
mobileModule === null ||
!('sendMobileNativeChatPermissionResponse' in mobileModule) ||
typeof mobileModule.sendMobileNativeChatPermissionResponse !== 'function'
) {
throw new Error('Mobile permission module export is unavailable')
}
const outcome: unknown = await mobileModule.sendMobileNativeChatPermissionResponse(args)
if (
outcome === 'accepted' ||
outcome === 'rejected' ||
outcome === 'unknown' ||
outcome === 'queued'
) {
return outcome
}
throw new Error('Mobile permission module returned an invalid outcome')
}