mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -5707,6 +5707,7 @@ export class OrcaRuntimeService {
|
||||
source?: 'headless' | 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null> {
|
||||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||||
}
|
||||
@@ -5741,6 +5742,7 @@ export class OrcaRuntimeService {
|
||||
source?: 'headless' | 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null> {
|
||||
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, {
|
||||
...opts,
|
||||
@@ -6021,6 +6023,7 @@ export class OrcaRuntimeService {
|
||||
source?: 'headless' | 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null> {
|
||||
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
|
||||
if (headlessSnapshot) {
|
||||
@@ -6138,6 +6141,10 @@ export class OrcaRuntimeService {
|
||||
source?: 'headless'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
// Why: dangling mid-escape tail the restorer must write LAST, after any
|
||||
// reset, so the next live chunk completes it instead of rendering it
|
||||
// literally (#7329).
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null> {
|
||||
const state = this.headlessTerminals.get(ptyId)
|
||||
if (!state) {
|
||||
@@ -6166,6 +6173,9 @@ export class OrcaRuntimeService {
|
||||
seq: state.outputSequence,
|
||||
source: 'headless',
|
||||
oscLinks: snapshot.oscLinks,
|
||||
...(snapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
// Why: lets the renderer skip the destructive scrollback clear when
|
||||
// restoring an alt-screen snapshot — clearing wipes xterm's own
|
||||
// history that the TUI relies on for scroll-up after a tab return.
|
||||
|
||||
@@ -55,6 +55,7 @@ type SnapshotFrameOptions = {
|
||||
truncatedByByteBudget?: boolean
|
||||
source?: 'headless' | 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
pendingEscapeTailAnsi?: string
|
||||
}
|
||||
|
||||
type SerializedSnapshot = {
|
||||
@@ -67,6 +68,7 @@ type SerializedSnapshot = {
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
scrollbackRows: number
|
||||
truncatedByByteBudget: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null
|
||||
|
||||
type TerminalViewportClient = {
|
||||
@@ -466,6 +468,7 @@ function sendSnapshotFrames(
|
||||
cwd: options.cwd,
|
||||
source: options.source,
|
||||
oscLinks: options.oscLinks,
|
||||
pendingEscapeTailAnsi: options.pendingEscapeTailAnsi,
|
||||
truncated: options.truncated === true,
|
||||
truncatedByByteBudget: options.truncatedByByteBudget === true
|
||||
})
|
||||
@@ -1398,6 +1401,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
cwd: serialized?.cwd,
|
||||
source: serialized?.source,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
|
||||
truncated: false,
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
data: serialized?.data ?? ''
|
||||
@@ -1602,6 +1606,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
source: serialized?.source,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
|
||||
data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '')
|
||||
})
|
||||
// Why: baseline for resize re-stream gating; the client already
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
|
||||
// Transport-level regression for #7329: the daemon's mid-escape tail
|
||||
// (pendingEscapeTailAnsi) must survive the terminal.multiplex wire so the
|
||||
// renderer can replay it after the reset. Without threading it through the
|
||||
// SnapshotStart JSON frame, the tail is lost and the next live chunk renders
|
||||
// literally ("colors/garbage around what I type").
|
||||
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('terminal.multiplex pending-escape-tail threading (#7329)', () => {
|
||||
it('carries the daemon pendingEscapeTailAnsi through the SnapshotStart frame', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'user@host:~$ ',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
// The dangling partial the emulator could not serialize.
|
||||
pendingEscapeTailAnsi: '\x1b[3'
|
||||
}),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-1',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 5,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
// Drive the subscribe promise chain (readTerminal/serialize awaits + the
|
||||
// output-batcher flush timer) to completion.
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
}
|
||||
|
||||
const snapshotStart = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)!
|
||||
expect(decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
|
||||
pendingEscapeTailAnsi: '\x1b[3'
|
||||
})
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-1')
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user