mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* 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>
227 lines
7.9 KiB
TypeScript
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')
|
|
}
|