mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
fix(runtime): return authoritative visible TUI output
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -12794,6 +12794,497 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reads and shows the runtime-owned alternate-screen grid without serialization', async () => {
|
||||
const serializeBuffer = vi.fn()
|
||||
const serializeProviderBuffer = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
serializeProviderBuffer
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
'shell history\r\n\x1b[?1049h\x1b[2J\x1b[HClaude Code\r\nWorking on fix\r\nTool: Read\r\n',
|
||||
100
|
||||
)
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
const shown = await runtime.showTerminal(terminal.handle)
|
||||
|
||||
expect(read.tail).toEqual(['Claude Code', 'Working on fix', 'Tool: Read'])
|
||||
expect(shown.preview).toBe('Claude Code\nWorking on fix\nTool: Read')
|
||||
expect(serializeBuffer).not.toHaveBeenCalled()
|
||||
expect(serializeProviderBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies older provider alternate-screen snapshots from ANSI', async () => {
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: '\x1b[?1049h\x1b[2J\x1b[HRemote Vim\r\nediting README.md\r\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless'
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
|
||||
expect(read.tail).toEqual(['Remote Vim', 'editing README.md'])
|
||||
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(true)
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('honors the last ANSI screen transition when older provider metadata is absent', async () => {
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: '\x1b[?1049hOld TUI\r\n\x1b[?1049l',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless'
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
|
||||
expect(read.tail).toEqual(['shell history'])
|
||||
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'live exit wins over an alternate provider snapshot',
|
||||
snapshotData: '\x1b[?1049h\x1b[2J\x1b[HStale TUI\r\n',
|
||||
snapshotMode: true,
|
||||
liveData: '\x1b[?1049lreturned shell\r\n',
|
||||
expectedMode: false,
|
||||
expectedLine: 'shell history'
|
||||
},
|
||||
{
|
||||
name: 'live entry wins over a primary provider snapshot',
|
||||
snapshotData: 'stale shell\r\n',
|
||||
snapshotMode: false,
|
||||
liveData: '\x1b[?1049h\x1b[2J\x1b[HLive TUI\r\nnew frame\r\n',
|
||||
expectedMode: true,
|
||||
expectedLine: 'Live TUI'
|
||||
}
|
||||
])('$name', async ({ snapshotData, snapshotMode, liveData, expectedMode, expectedLine }) => {
|
||||
type Snapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
alternateScreen: boolean
|
||||
}
|
||||
let resolveSnapshot!: (snapshot: Snapshot) => void
|
||||
const serializeProviderBuffer = vi.fn(
|
||||
() =>
|
||||
new Promise<Snapshot>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const readPromise = runtime.readTerminal(terminal.handle)
|
||||
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
|
||||
runtime.onPtyData('pty-1', liveData, 101)
|
||||
resolveSnapshot({
|
||||
data: snapshotData,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: snapshotMode
|
||||
})
|
||||
|
||||
const read = await readPromise
|
||||
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(expectedMode)
|
||||
expect(read.tail.join('\n')).toContain(expectedLine)
|
||||
expect(read.tail.join('\n')).not.toContain('Stale TUI')
|
||||
})
|
||||
|
||||
it('shares one provider snapshot between concurrent read and show calls', async () => {
|
||||
type Snapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
alternateScreen: boolean
|
||||
}
|
||||
let resolveSnapshot!: (snapshot: Snapshot) => void
|
||||
const serializeProviderBuffer = vi.fn(
|
||||
() =>
|
||||
new Promise<Snapshot>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const readPromise = runtime.readTerminal(terminal.handle)
|
||||
const showPromise = runtime.showTerminal(terminal.handle)
|
||||
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
|
||||
resolveSnapshot({
|
||||
data: '\x1b[?1049h\x1b[2J\x1b[HShared TUI frame\r\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
|
||||
const [read, shown] = await Promise.all([readPromise, showPromise])
|
||||
expect(read.tail).toEqual(['Shared TUI frame'])
|
||||
expect(shown.preview).toBe('Shared TUI frame')
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps cursor reads provider-free on restored alternate-screen PTYs', async () => {
|
||||
const serializeProviderBuffer = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle, { cursor: 0 })
|
||||
|
||||
expect(read.tail).toEqual(['shell history'])
|
||||
expect(serializeProviderBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('backs off after a provider snapshot failure instead of retrying for show', async () => {
|
||||
const serializeProviderBuffer = vi.fn().mockRejectedValue(new Error('provider unavailable'))
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
const shown = await runtime.showTerminal(terminal.handle)
|
||||
|
||||
expect(read.tail).toEqual(['shell history'])
|
||||
expect(shown.preview).toBe('shell history')
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back once to a mounted renderer when a known alternate provider fails', async () => {
|
||||
const serializeProviderBuffer = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: '\x1b[?1049h\x1b[2J\x1b[HProvider TUI\r\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
.mockRejectedValue(new Error('provider unavailable'))
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: '\x1b[?1049h\x1b[2J\x1b[HRenderer TUI\r\n',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
let rendererMounted = false
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => rendererMounted
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({
|
||||
tail: ['Provider TUI']
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'new output', 100)
|
||||
rendererMounted = true
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
|
||||
expect(read.tail).toEqual(['Renderer TUI'])
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledTimes(2)
|
||||
expect(serializeBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('bounds a stuck provider snapshot and shares the timed-out request', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const serializeProviderBuffer = vi.fn(() => new Promise<never>(() => undefined))
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const reads = Promise.all([
|
||||
runtime.readTerminal(terminal.handle),
|
||||
runtime.showTerminal(terminal.handle)
|
||||
])
|
||||
await vi.advanceTimersByTimeAsync(750)
|
||||
const [read, shown] = await reads
|
||||
|
||||
expect(read.tail).toEqual(['shell history'])
|
||||
expect(shown.preview).toBe('shell history')
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('discards a provider frame when the PTY generation resets during capture', async () => {
|
||||
type Snapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
alternateScreen: boolean
|
||||
}
|
||||
let resolveSnapshot!: (snapshot: Snapshot) => void
|
||||
const serializeProviderBuffer = vi.fn(
|
||||
() =>
|
||||
new Promise<Snapshot>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'old shell\r\n', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const readPromise = runtime.readTerminal(terminal.handle)
|
||||
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 0, generation: 'reset' },
|
||||
runtime.getPtyOutputSequence('pty-1')
|
||||
)
|
||||
resolveSnapshot({
|
||||
data: '\x1b[?1049hStale generation TUI\r\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
|
||||
const read = await readPromise
|
||||
expect(read.tail.join('\n')).not.toContain('Stale generation TUI')
|
||||
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a visible frame when its terminal handle changes during capture', async () => {
|
||||
type Snapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
alternateScreen: boolean
|
||||
}
|
||||
let resolveSnapshot!: (snapshot: Snapshot) => void
|
||||
const serializeProviderBuffer = vi.fn(
|
||||
() =>
|
||||
new Promise<Snapshot>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const readPromise = runtime.readTerminal(terminal.handle)
|
||||
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
|
||||
syncSinglePty(runtime, 'pty-2')
|
||||
resolveSnapshot({
|
||||
data: '\x1b[?1049hWrong terminal frame\r\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
|
||||
await expect(readPromise).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('bounds provider visible frames for read and show responses', async () => {
|
||||
const visibleLines = [
|
||||
'skip-1',
|
||||
'skip-2',
|
||||
'A'.repeat(49),
|
||||
'B'.repeat(49),
|
||||
'C'.repeat(49),
|
||||
'D'.repeat(49),
|
||||
'E'.repeat(49),
|
||||
'F'.repeat(50)
|
||||
]
|
||||
const expectedTail = visibleLines.slice(-6)
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: `\x1b[?1049h\x1b[2J\x1b[H${visibleLines.join('\r\n')}\r\n`,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle, { limit: 6 })
|
||||
const shown = await runtime.showTerminal(terminal.handle)
|
||||
|
||||
expect(read.tail).toEqual(expectedTail)
|
||||
expect(read.returnedLineCount).toBe(6)
|
||||
expect(shown.preview).toBe(expectedTail.join('\n'))
|
||||
expect(shown.preview.length).toBe(300)
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns renderer visible screen lines through terminal.read RPC JSON result', async () => {
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: '\x1b[?1049hClaude Code\r\nChecking files\r\nWaiting for input\r\n',
|
||||
|
||||
@@ -1194,6 +1194,19 @@ type RuntimeHeadlessTerminal = {
|
||||
writeChain: Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeVisibleTerminalState = {
|
||||
lines: string[]
|
||||
isAlternateScreen: boolean
|
||||
sequence: number
|
||||
generation: number
|
||||
}
|
||||
|
||||
type ProviderBufferAcquisition = {
|
||||
generation: number
|
||||
scrollbackRows: number
|
||||
promise: Promise<PtyProviderBufferSnapshot | null>
|
||||
}
|
||||
|
||||
type HeadlessSeedMetadata = {
|
||||
cwd?: string | null
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
@@ -2289,6 +2302,12 @@ export class OrcaRuntimeService {
|
||||
string,
|
||||
Set<TerminalKittyKeyboardModeTracker>
|
||||
>()
|
||||
private providerBufferAcquisitionsByPtyId = new Map<string, ProviderBufferAcquisition>()
|
||||
private providerVisibleStateByPtyId = new Map<string, RuntimeVisibleTerminalState>()
|
||||
private providerVisibleRetryAtByPtyId = new Map<string, number>()
|
||||
private providerSnapshotsWithLiveModeTransition = new WeakSet<PtyProviderBufferSnapshot>()
|
||||
private ptyLifecycleGenerationById = new Map<string, number>()
|
||||
private nextPtyLifecycleGeneration = 1
|
||||
private recentPtyPathCandidatesById = new Map<string, string[]>()
|
||||
// Why: OSC 9999 status can span PTY chunks. Keeping parser state in the
|
||||
// runtime lets hidden/model-owned terminals observe agent state without a
|
||||
@@ -6906,6 +6925,25 @@ export class OrcaRuntimeService {
|
||||
return this.ptyOutputSequenceById.get(ptyId) ?? 0
|
||||
}
|
||||
|
||||
private getPtyLifecycleGeneration(ptyId: string): number {
|
||||
const existing = this.ptyLifecycleGenerationById.get(ptyId)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
const generation = this.nextPtyLifecycleGeneration++
|
||||
this.ptyLifecycleGenerationById.set(ptyId, generation)
|
||||
return generation
|
||||
}
|
||||
|
||||
private advancePtyLifecycleGeneration(ptyId: string): void {
|
||||
this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++)
|
||||
// Why: a provider response belongs to the process generation that issued
|
||||
// it; a respawn must neither reuse its frame nor join its in-flight call.
|
||||
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
|
||||
this.providerVisibleStateByPtyId.delete(ptyId)
|
||||
this.providerVisibleRetryAtByPtyId.delete(ptyId)
|
||||
}
|
||||
|
||||
synchronizePtyOutputSequenceFromProvider(
|
||||
ptyId: string,
|
||||
providerSequence: { value: number; generation: 'continued' | 'reset' },
|
||||
@@ -6932,6 +6970,7 @@ export class OrcaRuntimeService {
|
||||
const providerBaseline = providerOffset + baseline
|
||||
|
||||
if (providerSequence.generation === 'reset') {
|
||||
this.advancePtyLifecycleGeneration(ptyId)
|
||||
// Why: daemon respawn/cold restore starts a new absolute domain. Old
|
||||
// emulator state cannot remain authoritative over the replacement.
|
||||
if (replacesExistingRuntimeGeneration) {
|
||||
@@ -7606,7 +7645,35 @@ export class OrcaRuntimeService {
|
||||
|
||||
private async serializeProviderTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
opts: { scrollbackRows?: number } = {},
|
||||
wait: { timeoutMs?: number } = {}
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
const generation = this.getPtyLifecycleGeneration(ptyId)
|
||||
const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0))
|
||||
let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId)
|
||||
if (
|
||||
!acquisition ||
|
||||
acquisition.generation !== generation ||
|
||||
acquisition.scrollbackRows < scrollbackRows
|
||||
) {
|
||||
const promise = this.captureProviderTerminalBuffer(ptyId, opts, generation)
|
||||
acquisition = { generation, scrollbackRows, promise }
|
||||
this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition)
|
||||
void promise.finally(() => {
|
||||
if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) {
|
||||
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
|
||||
}
|
||||
})
|
||||
}
|
||||
return typeof wait.timeoutMs === 'number'
|
||||
? withTimeout(acquisition.promise, wait.timeoutMs, null)
|
||||
: acquisition.promise
|
||||
}
|
||||
|
||||
private async captureProviderTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number },
|
||||
generation: number
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
const liveModeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId)
|
||||
@@ -7619,24 +7686,44 @@ export class OrcaRuntimeService {
|
||||
// Why: daemon PTYs survive an app relaunch before any renderer mounts.
|
||||
// Mobile still needs their retained history without navigating desktop.
|
||||
const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts)
|
||||
if (typeof snapshot?.alternateScreen === 'boolean') {
|
||||
if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) {
|
||||
return null
|
||||
}
|
||||
const snapshotModeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
if (typeof snapshot.alternateScreen === 'boolean') {
|
||||
snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
|
||||
} else {
|
||||
// Why: older providers omit mode metadata, but their ANSI snapshot
|
||||
// still carries the DECSET/DECRST needed to classify the active screen.
|
||||
snapshotModeTracker.scanReplay(snapshot.data)
|
||||
}
|
||||
const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch
|
||||
let effectiveAlternateScreen: boolean | undefined
|
||||
if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) {
|
||||
const modeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
if (snapshot.alternateScreen) {
|
||||
modeTracker.scan('\x1b[?1049h')
|
||||
if (observedSnapshotMode) {
|
||||
modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
|
||||
}
|
||||
// Why: stream bytes received after the request began can be newer
|
||||
// than snapshot metadata, so an observed live transition wins.
|
||||
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
|
||||
modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
|
||||
}
|
||||
this.providerModeTrackersByPtyId.set(ptyId, modeTracker)
|
||||
}
|
||||
if (!snapshot) {
|
||||
return null
|
||||
effectiveAlternateScreen = modeTracker.isAlternateScreen
|
||||
}
|
||||
const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0
|
||||
return this.preferTrackedLastTitle(ptyId, {
|
||||
const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, {
|
||||
...snapshot,
|
||||
seq: providerOffset + snapshot.seq
|
||||
seq: providerOffset + snapshot.seq,
|
||||
...(effectiveAlternateScreen !== undefined
|
||||
? { alternateScreen: effectiveAlternateScreen }
|
||||
: {})
|
||||
})
|
||||
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
|
||||
this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot)
|
||||
}
|
||||
return reconciledSnapshot
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
@@ -7652,16 +7739,164 @@ export class OrcaRuntimeService {
|
||||
read: RuntimeTerminalRead,
|
||||
opts: { cursor?: number; limit?: number } = {}
|
||||
): Promise<RuntimeTerminalRead> {
|
||||
if (!shouldFallbackToVisibleTerminalSnapshot(read, opts)) {
|
||||
if (typeof opts.cursor === 'number') {
|
||||
return read
|
||||
}
|
||||
const lines = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts)
|
||||
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
|
||||
const providerModeUnknown =
|
||||
this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId)
|
||||
if (
|
||||
!blankFallback &&
|
||||
!providerModeUnknown &&
|
||||
!knownAlternateScreen &&
|
||||
!this.headlessTerminals.has(ptyId)
|
||||
) {
|
||||
return read
|
||||
}
|
||||
const visibleState = await this.readVisibleTerminalState(ptyId)
|
||||
if (!blankFallback && !knownAlternateScreen && !visibleState?.isAlternateScreen) {
|
||||
return read
|
||||
}
|
||||
let lines = visibleState?.lines ?? []
|
||||
if (lines.length === 0) {
|
||||
lines = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
return read
|
||||
}
|
||||
return buildVisibleSnapshotReadFallback(read, lines, opts.limit)
|
||||
}
|
||||
|
||||
private async visibleSnapshotPreview(ptyId: string, preview: string): Promise<string> {
|
||||
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
|
||||
const providerModeUnknown =
|
||||
this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId)
|
||||
if (!providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId)) {
|
||||
return preview
|
||||
}
|
||||
const visibleState = await this.readVisibleTerminalState(ptyId)
|
||||
if (!knownAlternateScreen && !visibleState?.isAlternateScreen) {
|
||||
return preview
|
||||
}
|
||||
let lines = visibleState?.lines ?? []
|
||||
if (lines.length === 0) {
|
||||
lines = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
}
|
||||
return lines.length > 0 ? buildPreview(lines, '') : preview
|
||||
}
|
||||
|
||||
private async readVisibleTerminalState(
|
||||
ptyId: string
|
||||
): Promise<RuntimeVisibleTerminalState | null> {
|
||||
if (!this.providerSnapshotPreferredPtys.has(ptyId)) {
|
||||
return this.readHeadlessVisibleTerminalState(ptyId)
|
||||
}
|
||||
|
||||
const generation = this.getPtyLifecycleGeneration(ptyId)
|
||||
const outputSequence = this.getPtyOutputSequence(ptyId)
|
||||
const cached = this.providerVisibleStateByPtyId.get(ptyId)
|
||||
const trackedMode = this.providerModeTrackersByPtyId.get(ptyId)
|
||||
if (
|
||||
cached?.generation === generation &&
|
||||
outputSequence <= cached.sequence &&
|
||||
(!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen)
|
||||
) {
|
||||
return cached
|
||||
}
|
||||
if (trackedMode && !trackedMode.isAlternateScreen) {
|
||||
const headlessState = await this.readHeadlessVisibleTerminalState(ptyId)
|
||||
return headlessState
|
||||
? { ...headlessState, isAlternateScreen: false }
|
||||
: {
|
||||
lines: [],
|
||||
isAlternateScreen: false,
|
||||
sequence: outputSequence,
|
||||
generation
|
||||
}
|
||||
}
|
||||
if ((this.providerVisibleRetryAtByPtyId.get(ptyId) ?? 0) > Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = await this.serializeProviderTerminalBuffer(
|
||||
ptyId,
|
||||
{ scrollbackRows: 0 },
|
||||
{ timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS }
|
||||
)
|
||||
if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) {
|
||||
this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS)
|
||||
return null
|
||||
}
|
||||
this.providerVisibleRetryAtByPtyId.delete(ptyId)
|
||||
if (this.providerSnapshotsWithLiveModeTransition.has(snapshot)) {
|
||||
// Why: the provider frame can predate a mode switch observed while its
|
||||
// RPC was pending; the ordered live emulator owns the post-switch grid.
|
||||
const liveState = await this.readHeadlessVisibleTerminalState(ptyId)
|
||||
if (liveState && liveState.isAlternateScreen === (snapshot.alternateScreen ?? false)) {
|
||||
return liveState
|
||||
}
|
||||
}
|
||||
const lines = await this.parseVisibleSnapshotLines(snapshot)
|
||||
if (this.getPtyLifecycleGeneration(ptyId) !== generation) {
|
||||
return null
|
||||
}
|
||||
const visibleState: RuntimeVisibleTerminalState = {
|
||||
lines,
|
||||
isAlternateScreen: snapshot.alternateScreen ?? false,
|
||||
sequence: snapshot.seq,
|
||||
generation
|
||||
}
|
||||
if (this.getPtyOutputSequence(ptyId) <= snapshot.seq) {
|
||||
this.providerVisibleStateByPtyId.set(ptyId, visibleState)
|
||||
}
|
||||
return visibleState
|
||||
}
|
||||
|
||||
private async readHeadlessVisibleTerminalState(
|
||||
ptyId: string
|
||||
): Promise<RuntimeVisibleTerminalState | null> {
|
||||
const state = this.headlessTerminals.get(ptyId)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
const generation = this.getPtyLifecycleGeneration(ptyId)
|
||||
await state.writeChain
|
||||
if (
|
||||
this.headlessTerminals.get(ptyId) !== state ||
|
||||
this.getPtyLifecycleGeneration(ptyId) !== generation
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()),
|
||||
isAlternateScreen: state.emulator.isAlternateScreen,
|
||||
sequence: state.outputSequence,
|
||||
generation
|
||||
}
|
||||
}
|
||||
|
||||
private async parseVisibleSnapshotLines(snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
}): Promise<string[]> {
|
||||
if (snapshot.data.length === 0) {
|
||||
return []
|
||||
}
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
scrollback: 0
|
||||
})
|
||||
try {
|
||||
await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`)
|
||||
return visibleNonBlankTerminalLines(emulator.getVisibleLines())
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private async readRendererVisibleSnapshotLines(ptyId: string): Promise<string[]> {
|
||||
const controller = this.ptyController
|
||||
if (!controller?.serializeBuffer) {
|
||||
@@ -7674,27 +7909,18 @@ export class OrcaRuntimeService {
|
||||
// Why: raw PTY tails can be whitespace-only while a full-screen TUI is
|
||||
// visibly nonblank in renderer xterm. Ask the renderer for the active
|
||||
// screen instead of reusing the headless transcript path.
|
||||
const snapshot = await controller.serializeBuffer(ptyId, {
|
||||
scrollbackRows: 0,
|
||||
altScreenForcesZeroRows: false
|
||||
})
|
||||
const snapshot = await withTimeout(
|
||||
controller.serializeBuffer(ptyId, {
|
||||
scrollbackRows: 0,
|
||||
altScreenForcesZeroRows: false
|
||||
}),
|
||||
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
|
||||
null
|
||||
)
|
||||
if (!snapshot || snapshot.data.length === 0) {
|
||||
return []
|
||||
}
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
scrollback: 0
|
||||
})
|
||||
try {
|
||||
await emulator.write(snapshot.data)
|
||||
return emulator
|
||||
.getVisibleLines()
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0)
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
return this.parseVisibleSnapshotLines(snapshot)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -8803,6 +9029,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
onPtyExit(ptyId: string, exitCode: number): void {
|
||||
this.advancePtyLifecycleGeneration(ptyId)
|
||||
advertisedUrlWatcher.unbindPty(ptyId)
|
||||
// Clean up new mobile state for this PTY
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
@@ -8819,6 +9046,9 @@ export class OrcaRuntimeService {
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
this.providerModeTrackersByPtyId.delete(ptyId)
|
||||
this.providerModeSnapshotScansByPtyId.delete(ptyId)
|
||||
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
|
||||
this.providerVisibleStateByPtyId.delete(ptyId)
|
||||
this.providerVisibleRetryAtByPtyId.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.terminalSpawnCommandsByPtyId.delete(ptyId)
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
@@ -10968,8 +11198,12 @@ export class OrcaRuntimeService {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
const worktreesById = await this.getResolvedWorktreeMap()
|
||||
const summary = this.buildPtyTerminalSummary(pty.pty, worktreesById)
|
||||
const preview = await this.visibleSnapshotPreview(pty.pty.ptyId, summary.preview)
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
|
||||
return {
|
||||
...this.buildPtyTerminalSummary(pty.pty, worktreesById),
|
||||
...summary,
|
||||
preview,
|
||||
tabId: pty.pty.tabId ?? pty.record.tabId,
|
||||
leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId,
|
||||
paneRuntimeId: -1,
|
||||
@@ -10982,8 +11216,16 @@ export class OrcaRuntimeService {
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
const summary = this.buildTerminalSummary(leaf, worktreesById)
|
||||
const preview = leaf.ptyId
|
||||
? await this.visibleSnapshotPreview(leaf.ptyId, summary.preview)
|
||||
: summary.preview
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
if (leaf.ptyId) {
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
|
||||
}
|
||||
return {
|
||||
...summary,
|
||||
preview,
|
||||
paneRuntimeId: leaf.paneRuntimeId,
|
||||
ptyId: leaf.ptyId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch
|
||||
@@ -10997,7 +11239,9 @@ export class OrcaRuntimeService {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
const read = this.readPtyTerminal(handle, pty.pty, opts)
|
||||
return this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
|
||||
const visibleRead = await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
|
||||
return visibleRead
|
||||
}
|
||||
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
@@ -11011,7 +11255,12 @@ export class OrcaRuntimeService {
|
||||
cursor: opts.cursor,
|
||||
limit: opts.limit
|
||||
})
|
||||
return leaf.ptyId ? this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) : read
|
||||
if (!leaf.ptyId) {
|
||||
return read
|
||||
}
|
||||
const visibleRead = await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts)
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
|
||||
return visibleRead
|
||||
}
|
||||
|
||||
async sendTerminal(
|
||||
@@ -21328,6 +21577,8 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private dropDisconnectedPtyRecord(ptyId: string): void {
|
||||
// Why: pruning can remove a PTY without the normal exit callback.
|
||||
this.advancePtyLifecycleGeneration(ptyId)
|
||||
this.ptysById.delete(ptyId)
|
||||
this.recentPtyOutputById.delete(ptyId)
|
||||
this.clearWaitBlockedCheckState(ptyId)
|
||||
@@ -21338,6 +21589,9 @@ export class OrcaRuntimeService {
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
this.providerModeTrackersByPtyId.delete(ptyId)
|
||||
this.providerModeSnapshotScansByPtyId.delete(ptyId)
|
||||
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
|
||||
this.providerVisibleStateByPtyId.delete(ptyId)
|
||||
this.providerVisibleRetryAtByPtyId.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.terminalSpawnCommandsByPtyId.delete(ptyId)
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
@@ -22728,6 +22982,20 @@ export class OrcaRuntimeService {
|
||||
return { record, pty }
|
||||
}
|
||||
|
||||
private assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void {
|
||||
const runtimePty = this.getLivePtyForHandle(handle)
|
||||
if (runtimePty) {
|
||||
if (runtimePty.pty.ptyId !== expectedPtyId) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
return
|
||||
}
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
if (leaf.ptyId !== expectedPtyId) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
}
|
||||
|
||||
private readPtyTerminal(
|
||||
handle: string,
|
||||
pty: RuntimePtyWorktreeRecord,
|
||||
@@ -25867,6 +26135,8 @@ const MAX_TAIL_PENDING_ANSI_CHARS = 4096
|
||||
const DEFAULT_TERMINAL_READ_LIMIT = 120
|
||||
const MAX_TERMINAL_READ_LIMIT = 2000
|
||||
const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
|
||||
const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750
|
||||
const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000
|
||||
const MAX_PREVIEW_LINES = 6
|
||||
const MAX_PREVIEW_CHARS = 300
|
||||
const WORKTREE_STATUS_PRIORITY: Record<RuntimeWorktreeStatus, number> = {
|
||||
@@ -27178,6 +27448,10 @@ function shouldFallbackToVisibleTerminalSnapshot(
|
||||
return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0)
|
||||
}
|
||||
|
||||
function visibleNonBlankTerminalLines(lines: string[]): string[] {
|
||||
return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0)
|
||||
}
|
||||
|
||||
function buildVisibleSnapshotReadFallback(
|
||||
read: RuntimeTerminalRead,
|
||||
visibleLines: string[],
|
||||
|
||||
Reference in New Issue
Block a user