fix(runtime): seed the model when a buffer read falls back to the pane

With no host model, a terminal buffer read answered with the desktop
pane's screen at the pane's size. The phone's resize restream and
pending-output recovery read through it, so a hidden pane could still
hand the phone a desktop-sized screen.

The fallback now seeds the model from that screen onto the PTY grid and
answers from the model, unless output landed during the read. The
desktop's own hidden-output recovery keeps the raw renderer fallback.

Claude-Session: lane-host-hydrate
This commit is contained in:
Jinwoo-H
2026-10-06 01:32:43 -04:00
parent 5782bb5888
commit 09f37efd0d
3 changed files with 68 additions and 5 deletions
@@ -23,7 +23,9 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
} | null> {
const restoredSnapshot = await this.serializePreferredRestoredTerminalBuffer(ptyId, opts)
const restoredSnapshot = await this.serializePreferredRestoredTerminalBuffer(ptyId, opts, {
seedModelFromRenderer: true
})
if (restoredSnapshot) {
return restoredSnapshot
}
@@ -32,7 +34,7 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or
return headlessSnapshot
}
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
const rendererSnapshot = await this.serializeRendererScreenOntoModel(ptyId, opts)
if (!rendererSnapshot) {
return this.serializeProviderTerminalBuffer(ptyId, opts)
}
@@ -51,7 +53,8 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or
protected async serializePreferredRestoredTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
opts: { scrollbackRows?: number } = {},
fallback: { seedModelFromRenderer?: boolean } = {}
) {
if (!this.providerSnapshotPreferredPtys.has(ptyId)) {
return null
@@ -59,10 +62,27 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or
// Pre-attach bytes are only a suffix; older providers can fall back to the renderer.
return (
(await this.serializeProviderTerminalBuffer(ptyId, opts)) ??
(await this.serializeRendererTerminalBuffer(ptyId, opts))
(await (fallback.seedModelFromRenderer
? this.serializeRendererScreenOntoModel(ptyId, opts)
: this.serializeRendererTerminalBuffer(ptyId, opts)))
)
}
// Why: a pane answers at its own size; seed the model onto the PTY grid and serve that instead.
protected async serializeRendererScreenOntoModel(
ptyId: string,
opts: { scrollbackRows?: number }
) {
const outputSequence = this.getPtyOutputSequence(ptyId)
const screen = await this.serializeRendererTerminalBuffer(ptyId, opts)
// Why: output that landed during the read is in neither the screen nor a fresh model.
if (!screen?.data.length || this.getPtyOutputSequence(ptyId) !== outputSequence) {
return screen
}
await this.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, screen)
return (await this.serializeHeadlessTerminalBuffer(ptyId, opts)) ?? screen
}
async serializeRendererTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
@@ -52,7 +52,8 @@ describe('terminal side-effect fact channel', () => {
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 })
expect(snapshot?.source).toBe('renderer')
// The renderer screen is seeded into the host model, which then answers.
expect(snapshot?.source).toBe('headless')
expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent')
})
@@ -0,0 +1,42 @@
/**
* With no host model, a terminal buffer read falls back to the desktop pane. A hidden pane answers
* at its own size, so the read must seed the model onto the PTY grid and answer from it. This read
* backs the phone's resize restream and pending-output recovery.
*/
import { describe, expect, it } from 'vitest'
import {
DESKTOP,
EXPECTED_PHONE_ROWS,
PHONE,
PTY_ID,
internals,
paintedRows,
setupPhoneSubscribe
} from './mobile-phone-subscribe-test-fixture'
async function readOnPhoneGrid(opts: { providerPreferred: boolean }) {
const { runtime, sizes } = setupPhoneSubscribe({ paneMounted: true })
sizes.set(PTY_ID, { ...PHONE })
if (opts.providerPreferred) {
internals(runtime).providerSnapshotPreferredPtys.add(PTY_ID)
}
const snapshot = await runtime.serializeTerminalBuffer(PTY_ID, { scrollbackRows: 100 })
return { runtime, snapshot }
}
describe('terminal buffer read with no host model and a desktop-sized pane', () => {
it('answers from a model seeded onto the PTY grid', async () => {
const { runtime, snapshot } = await readOnPhoneGrid({ providerPreferred: false })
expect(snapshot).toMatchObject({ source: 'headless', ...PHONE })
expect(snapshot && (await paintedRows(snapshot)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS)
expect(runtime.hasHeadlessTerminalState(PTY_ID)).toBe(true)
})
it('answers from the model when a restored snapshot is preferred but the provider has none', async () => {
const { snapshot } = await readOnPhoneGrid({ providerPreferred: true })
expect(snapshot).toMatchObject({ source: 'headless', ...PHONE })
expect(snapshot?.cols).not.toBe(DESKTOP.cols)
})
})