mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Harden daemon PTY generation handoff
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -132,6 +132,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
const result = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
expect(result.id).toBeDefined()
|
||||
expect(typeof result.id).toBe('string')
|
||||
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
|
||||
})
|
||||
|
||||
it('uses worktreeId as session prefix when provided', async () => {
|
||||
@@ -506,7 +507,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(second.launchAgent).toBe('droid')
|
||||
expect(second.snapshot).toBeDefined()
|
||||
expect(second.snapshot).toContain('hello from shell')
|
||||
expect(second.snapshotOutputSequence).toBe('hello from shell\r\n'.length)
|
||||
expect(second.providerSequence).toEqual({
|
||||
value: 'hello from shell\r\n'.length,
|
||||
generation: 'continued'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes rehydrateSequences in snapshot when terminal modes are active', async () => {
|
||||
@@ -529,6 +533,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(result.id).toBe('brand-new')
|
||||
expect(result.isReattach).toBeUndefined()
|
||||
expect(result.snapshot).toBeUndefined()
|
||||
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +46,17 @@ function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
|
||||
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
|
||||
}
|
||||
|
||||
function providerSequenceForSpawn(
|
||||
result: CreateOrAttachResult
|
||||
): PtySpawnResult['providerSequence'] {
|
||||
if (result.isNew) {
|
||||
return { value: 0, generation: 'reset' }
|
||||
}
|
||||
return typeof result.snapshot?.outputSequence === 'number'
|
||||
? { value: result.snapshot.outputSequence, generation: 'continued' }
|
||||
: undefined
|
||||
}
|
||||
|
||||
export type DaemonPtyAdapterOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
@@ -317,6 +328,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
|
||||
this.activeSessionIds.add(sessionId)
|
||||
const providerSequence = providerSequenceForSpawn(result)
|
||||
|
||||
// Cold restore: daemon created a new session but disk history shows
|
||||
// an unclean shutdown → return saved scrollback so the renderer can
|
||||
@@ -348,10 +360,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
pid,
|
||||
...launchIdentity(),
|
||||
coldRestore,
|
||||
...(providerSequence ? { providerSequence } : {}),
|
||||
...(!result.isNew ? { isReattach: true } : {})
|
||||
}
|
||||
}
|
||||
return { id: sessionId, pid, ...launchIdentity() }
|
||||
return {
|
||||
id: sessionId,
|
||||
pid,
|
||||
...launchIdentity(),
|
||||
...(providerSequence ? { providerSequence } : {})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.historyManager && result.isNew) {
|
||||
@@ -389,6 +407,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
id: sessionId,
|
||||
pid,
|
||||
...launchIdentity(),
|
||||
...(providerSequence ? { providerSequence } : {}),
|
||||
...(isReattach ? { isReattach: true } : {})
|
||||
}
|
||||
}
|
||||
@@ -410,9 +429,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
snapshot: snapshotPayload,
|
||||
snapshotCols: result.snapshot.cols,
|
||||
snapshotRows: result.snapshot.rows,
|
||||
...(typeof result.snapshot.outputSequence === 'number'
|
||||
? { snapshotOutputSequence: result.snapshot.outputSequence }
|
||||
: {}),
|
||||
...(providerSequence ? { providerSequence } : {}),
|
||||
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
|
||||
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
|
||||
: {}),
|
||||
|
||||
@@ -10437,12 +10437,12 @@ describe('registerPtyHandlers', () => {
|
||||
expect(mockProc.proc.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('anchors runtime output sequencing from a provider reattach snapshot', async () => {
|
||||
it('synchronizes runtime output sequencing from a provider reattach snapshot', async () => {
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => ({
|
||||
id: 'pty-restored',
|
||||
isReattach: true,
|
||||
snapshotOutputSequence: 900
|
||||
providerSequence: { value: 900, generation: 'continued' as const }
|
||||
})),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
@@ -10456,7 +10456,8 @@ describe('registerPtyHandlers', () => {
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
anchorPtyOutputSequenceFromProviderSnapshot: vi.fn(),
|
||||
getPtyOutputSequence: vi.fn().mockReturnValue(7),
|
||||
synchronizePtyOutputSequenceFromProvider: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
@@ -10467,9 +10468,10 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot).toHaveBeenCalledWith(
|
||||
expect(runtime.synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith(
|
||||
'pty-restored',
|
||||
900
|
||||
{ value: 900, generation: 'continued' },
|
||||
7
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10510,7 +10512,7 @@ describe('registerPtyHandlers', () => {
|
||||
'pty-cold-restore',
|
||||
'restored history\r\n',
|
||||
undefined,
|
||||
{ cwd: '/projects/restored', oscLinks }
|
||||
{ cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+18
-7
@@ -3125,11 +3125,16 @@ export function registerPtyHandlers(
|
||||
if (args.preAllocatedHandle) {
|
||||
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
|
||||
}
|
||||
const expectedPtyId = effectiveSessionAppId ?? sessionId
|
||||
const sequenceBeforeProviderSpawn = expectedPtyId
|
||||
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
|
||||
: 0
|
||||
result = await provider.spawn(spawnOptions)
|
||||
if (typeof result.snapshotOutputSequence === 'number') {
|
||||
runtime?.anchorPtyOutputSequenceFromProviderSnapshot?.(
|
||||
if (result.providerSequence) {
|
||||
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
result.id,
|
||||
result.snapshotOutputSequence
|
||||
result.providerSequence,
|
||||
sequenceBeforeProviderSpawn
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -4049,11 +4054,16 @@ export function registerPtyHandlers(
|
||||
)
|
||||
}
|
||||
spawnTiming.mark('options')
|
||||
const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId
|
||||
const sequenceBeforeProviderSpawn = expectedPtyId
|
||||
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
|
||||
: 0
|
||||
result = await provider.spawn(spawnOptions)
|
||||
if (typeof result.snapshotOutputSequence === 'number') {
|
||||
runtime?.anchorPtyOutputSequenceFromProviderSnapshot?.(
|
||||
if (result.providerSequence) {
|
||||
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
result.id,
|
||||
result.snapshotOutputSequence
|
||||
result.providerSequence,
|
||||
sequenceBeforeProviderSpawn
|
||||
)
|
||||
}
|
||||
spawnTiming.mark('provider_spawn')
|
||||
@@ -4290,7 +4300,8 @@ export function registerPtyHandlers(
|
||||
) {
|
||||
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, {
|
||||
cwd: result.coldRestore.cwd,
|
||||
oscLinks: result.coldRestore.oscLinks
|
||||
oscLinks: result.coldRestore.oscLinks,
|
||||
preferProviderIfExisting: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +117,12 @@ export type PtySpawnResult = {
|
||||
* writing the snapshot so ANSI cursor positions land correctly. */
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
/** Provider output sequence at snapshot capture. Main uses this to keep
|
||||
* post-reattach live bytes in the same deduplication domain. */
|
||||
snapshotOutputSequence?: number
|
||||
/** Provider sequence at the attach boundary. `reset` starts a new provider
|
||||
* generation; `continued` resumes the existing absolute domain. */
|
||||
providerSequence?: {
|
||||
value: number
|
||||
generation: 'continued' | 'reset'
|
||||
}
|
||||
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
|
||||
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
|
||||
* (terminal-query-authority.md §kitty). Never replayed into a renderer
|
||||
|
||||
@@ -7117,12 +7117,18 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
|
||||
})
|
||||
|
||||
it('anchors post-reattach live output to the provider snapshot sequence', async () => {
|
||||
it('aligns a restored session and pre-response bytes to the provider sequence', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
|
||||
// Simulate a stream-socket byte winning the race with the spawn response.
|
||||
runtime.onPtyData('pty-restored', 'queued', 100)
|
||||
expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot('pty-restored', 900)).toBe(906)
|
||||
expect(
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-restored',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
).toBe(906)
|
||||
runtime.onPtyData('pty-restored', 'fresh', 101)
|
||||
|
||||
expect(runtime.getPtyOutputSequence('pty-restored')).toBe(911)
|
||||
@@ -7131,8 +7137,131 @@ describe('OrcaRuntimeService', () => {
|
||||
seq: 911,
|
||||
source: 'headless'
|
||||
})
|
||||
// Renderer remounts can attach the same provider PTY again in one main lifetime.
|
||||
expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot('pty-restored', 911)).toBe(911)
|
||||
})
|
||||
|
||||
it('does not double a fresh daemon sequence on same-main reattach', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
|
||||
expect(
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-fresh',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
).toBe(0)
|
||||
runtime.onPtyData('pty-fresh', 'fresh output', 100)
|
||||
const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-fresh')
|
||||
|
||||
expect(
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-fresh',
|
||||
{ value: sequenceBeforeReattach, generation: 'continued' },
|
||||
sequenceBeforeReattach
|
||||
)
|
||||
).toBe(sequenceBeforeReattach)
|
||||
})
|
||||
|
||||
it('does not jump ahead of delayed bytes covered by a reattach snapshot', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-delayed',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData('pty-delayed', 'before', 100)
|
||||
const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-delayed')
|
||||
const delayedCoveredBytes = 'queued'
|
||||
|
||||
expect(
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-delayed',
|
||||
{
|
||||
value: sequenceBeforeReattach + delayedCoveredBytes.length,
|
||||
generation: 'continued'
|
||||
},
|
||||
sequenceBeforeReattach
|
||||
)
|
||||
).toBe(sequenceBeforeReattach)
|
||||
runtime.onPtyData('pty-delayed', delayedCoveredBytes, 101)
|
||||
|
||||
expect(runtime.getPtyOutputSequence('pty-delayed')).toBe(
|
||||
sequenceBeforeReattach + delayedCoveredBytes.length
|
||||
)
|
||||
})
|
||||
|
||||
it('retains bytes emitted before a fresh daemon spawn resolves', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const earlyOutput = '\x1b]0;Fresh shell\x07early prompt'
|
||||
runtime.onPtyData('pty-fresh', earlyOutput, 100)
|
||||
|
||||
expect(
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-fresh',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
).toBe(earlyOutput.length)
|
||||
|
||||
await expect(runtime.serializeMainTerminalBuffer('pty-fresh')).resolves.toMatchObject({
|
||||
data: expect.stringContaining('early prompt'),
|
||||
lastTitle: 'Fresh shell',
|
||||
seq: earlyOutput.length
|
||||
})
|
||||
})
|
||||
|
||||
it('drops stale headless state without rewinding the runtime sequence', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-restarted',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData('pty-restarted', 'old generation', 100)
|
||||
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-restarted')
|
||||
|
||||
expect(
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-restarted',
|
||||
{ value: 0, generation: 'reset' },
|
||||
sequenceBeforeRespawn
|
||||
)
|
||||
).toBe(sequenceBeforeRespawn)
|
||||
runtime.onPtyData('pty-restarted', 'new generation', 101)
|
||||
|
||||
await expect(runtime.serializeMainTerminalBuffer('pty-restarted')).resolves.toMatchObject({
|
||||
data: expect.not.stringContaining('old generation'),
|
||||
seq: sequenceBeforeRespawn + 'new generation'.length
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps active listener sequences monotonic across a daemon reset', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-reset',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData('pty-reset', 'old', 100)
|
||||
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-reset')
|
||||
const observedSequences: number[] = []
|
||||
runtime.subscribeToTerminalData('pty-reset', (_data, meta) => {
|
||||
if (typeof meta?.seq === 'number') {
|
||||
observedSequences.push(meta.seq)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-reset', 'early', 101)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-reset',
|
||||
{ value: 0, generation: 'reset' },
|
||||
sequenceBeforeRespawn
|
||||
)
|
||||
runtime.onPtyData('pty-reset', 'later', 102)
|
||||
|
||||
expect(observedSequences).toEqual([
|
||||
sequenceBeforeRespawn + 'early'.length,
|
||||
sequenceBeforeRespawn + 'early'.length + 'later'.length
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the synthetic permission BEL as a bell fact', () => {
|
||||
@@ -7275,6 +7404,214 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let pre-response bytes hide restored provider history', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'restored history\r\nqueued',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 906,
|
||||
source: 'headless'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
runtime.onPtyData('pty-restored', 'queued', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-restored',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
|
||||
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
|
||||
expect(snapshot?.data).toContain('restored history')
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps restored provider history authoritative after later live output', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'restored history\r\nlater output',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 912,
|
||||
source: 'headless'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-restored',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData('pty-restored', 'later output', 100)
|
||||
|
||||
const snapshot = await runtime.serializeTerminalBuffer('pty-restored')
|
||||
|
||||
expect(snapshot?.data).toContain('restored history')
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses provider alternate-screen state while a partial model is unsafe', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'restored tui',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 903,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
}),
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
runtime.onPtyData('pty-tui', 'tui', 100)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-tui',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
|
||||
await runtime.serializeTerminalBuffer('pty-tui')
|
||||
|
||||
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks live alternate-screen transitions after a provider snapshot', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'restored tui',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-tui',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
await runtime.serializeTerminalBuffer('pty-tui')
|
||||
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
|
||||
|
||||
runtime.onPtyData('pty-tui', '\x1b[?1049l', 100)
|
||||
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(false)
|
||||
runtime.onPtyData('pty-tui', '\x1b[?1049h', 101)
|
||||
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps mode transitions that race a provider snapshot response', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
let resolveProviderSnapshot:
|
||||
| ((snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
alternateScreen: boolean
|
||||
}) => void)
|
||||
| undefined
|
||||
const serializeProviderBuffer = vi.fn(
|
||||
() =>
|
||||
new Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
alternateScreen: boolean
|
||||
}>((resolve) => {
|
||||
resolveProviderSnapshot = resolve
|
||||
})
|
||||
)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-tui-race',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
|
||||
const snapshotPromise = runtime.serializeTerminalBuffer('pty-tui-race')
|
||||
await vi.waitFor(() => expect(resolveProviderSnapshot).toBeDefined())
|
||||
runtime.onPtyData('pty-tui-race', '\x1b[?1049l', 100)
|
||||
resolveProviderSnapshot?.({
|
||||
data: 'captured alt screen',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
await snapshotPromise
|
||||
|
||||
expect(runtime.isTerminalAlternateScreen('pty-tui-race')).toBe(false)
|
||||
})
|
||||
|
||||
it('translates reset provider snapshots without retaining the old title', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-replaced',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData('pty-replaced', '\x1b]0;Old process\x07old', 100)
|
||||
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-replaced')
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'new process',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 'new process'.length,
|
||||
source: 'headless',
|
||||
lastTitle: 'New process'
|
||||
}),
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-replaced',
|
||||
{ value: 0, generation: 'reset' },
|
||||
sequenceBeforeRespawn
|
||||
)
|
||||
|
||||
await expect(runtime.serializeTerminalBuffer('pty-replaced')).resolves.toMatchObject({
|
||||
lastTitle: 'New process',
|
||||
seq: sequenceBeforeRespawn + 'new process'.length
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the tracked title over the headless emulator lastTitle', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
TerminalSideEffectFact
|
||||
} from '../../shared/terminal-side-effect-facts'
|
||||
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
isFreshNonDoneAgentStatus,
|
||||
@@ -1180,6 +1181,8 @@ type RuntimeHeadlessTerminal = {
|
||||
type HeadlessSeedMetadata = {
|
||||
cwd?: string | null
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
/** Cold restore history must outrank a model that only saw new-generation bytes. */
|
||||
preferProviderIfExisting?: boolean
|
||||
/** Persisted kitty flags from the daemon snapshot, re-applied to the fresh
|
||||
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
|
||||
* (terminal-query-authority.md §kitty). */
|
||||
@@ -2249,7 +2252,14 @@ export class OrcaRuntimeService {
|
||||
private titleObservationSequence = 0
|
||||
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
|
||||
private ptyOutputSequenceById = new Map<string, number>()
|
||||
private providerSequenceAnchoredPtys = new Set<string>()
|
||||
private providerSequenceInitializedPtys = new Set<string>()
|
||||
private providerSequenceOffsetByPtyId = new Map<string, number>()
|
||||
private providerSnapshotPreferredPtys = new Set<string>()
|
||||
private providerModeTrackersByPtyId = new Map<string, TerminalKittyKeyboardModeTracker>()
|
||||
private providerModeSnapshotScansByPtyId = new Map<
|
||||
string,
|
||||
Set<TerminalKittyKeyboardModeTracker>
|
||||
>()
|
||||
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
|
||||
@@ -5686,6 +5696,10 @@ export class OrcaRuntimeService {
|
||||
onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number {
|
||||
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars
|
||||
this.ptyOutputSequenceById.set(ptyId, outputSequence)
|
||||
this.providerModeTrackersByPtyId.get(ptyId)?.scan(data)
|
||||
for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) {
|
||||
tracker.scan(data)
|
||||
}
|
||||
const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data)
|
||||
const cwd = osc7Metadata.cwd
|
||||
const cwdChanged = osc7Metadata.cwdChanged
|
||||
@@ -6410,6 +6424,29 @@ export class OrcaRuntimeService {
|
||||
this.ptyTitleTrackersByPtyId.delete(ptyId)
|
||||
}
|
||||
|
||||
private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void {
|
||||
// Why: a replacement daemon session can reuse the PTY id, but title/parser
|
||||
// state from the prior process must not bleed into its snapshots or chunks.
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.osc7ScanTailByPtyId.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
pty.lastOscTitle = null
|
||||
pty.lastOscTitleAt = null
|
||||
pty.lastAgentStatus = null
|
||||
pty.managementTitle = null
|
||||
pty.managementTitleAt = null
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
leaf.lastOscTitle = null
|
||||
leaf.lastOscTitleAt = null
|
||||
leaf.lastAgentStatus = null
|
||||
}
|
||||
this.clearAgentRowSnapshotsForPty(ptyId)
|
||||
}
|
||||
|
||||
private setTerminalSideEffectConsumerAvailable(available: boolean): void {
|
||||
const nextAvailable = available && this.onTerminalSideEffects !== null
|
||||
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
|
||||
@@ -6600,27 +6637,78 @@ export class OrcaRuntimeService {
|
||||
return this.ptyOutputSequenceById.get(ptyId) ?? 0
|
||||
}
|
||||
|
||||
anchorPtyOutputSequenceFromProviderSnapshot(ptyId: string, providerSequence: number): number {
|
||||
synchronizePtyOutputSequenceFromProvider(
|
||||
ptyId: string,
|
||||
providerSequence: { value: number; generation: 'continued' | 'reset' },
|
||||
runtimeSequenceAtSpawnStart = 0
|
||||
): number {
|
||||
if (
|
||||
this.providerSequenceAnchoredPtys.has(ptyId) ||
|
||||
!Number.isFinite(providerSequence) ||
|
||||
providerSequence < 0
|
||||
!Number.isFinite(providerSequence.value) ||
|
||||
providerSequence.value < 0 ||
|
||||
!Number.isFinite(runtimeSequenceAtSpawnStart) ||
|
||||
runtimeSequenceAtSpawnStart < 0
|
||||
) {
|
||||
return this.getPtyOutputSequence(ptyId)
|
||||
}
|
||||
const baseline = Math.floor(providerSequence)
|
||||
const anchoredSequence = baseline + this.getPtyOutputSequence(ptyId)
|
||||
this.ptyOutputSequenceById.set(ptyId, anchoredSequence)
|
||||
this.providerSequenceAnchoredPtys.add(ptyId)
|
||||
const baseline = Math.floor(providerSequence.value)
|
||||
const currentSequence = this.getPtyOutputSequence(ptyId)
|
||||
const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart))
|
||||
const postSpawnSequence = currentSequence - sequenceAtSpawnStart
|
||||
const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId)
|
||||
const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0
|
||||
const providerOffset =
|
||||
providerSequence.generation === 'reset'
|
||||
? sequenceAtSpawnStart
|
||||
: (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0)
|
||||
const providerBaseline = providerOffset + baseline
|
||||
|
||||
if (providerSequence.generation === 'reset') {
|
||||
// Why: daemon respawn/cold restore starts a new absolute domain. Old
|
||||
// emulator state cannot remain authoritative over the replacement.
|
||||
if (replacesExistingRuntimeGeneration) {
|
||||
this.disposeHeadlessTerminal(ptyId)
|
||||
}
|
||||
this.providerModeTrackersByPtyId.delete(ptyId)
|
||||
if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) {
|
||||
this.resetTrackedTerminalStateForProviderGeneration(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
const synchronizedSequence =
|
||||
providerSequence.generation === 'reset'
|
||||
? currentSequence
|
||||
: wasInitialized
|
||||
? currentSequence
|
||||
: providerBaseline + postSpawnSequence
|
||||
this.ptyOutputSequenceById.set(ptyId, synchronizedSequence)
|
||||
this.providerSequenceInitializedPtys.add(ptyId)
|
||||
this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset)
|
||||
|
||||
const snapshotMayCoverMissingState =
|
||||
(providerSequence.generation === 'continued' && !wasInitialized) ||
|
||||
(postSpawnSequence > 0 &&
|
||||
providerSequence.generation === 'reset' &&
|
||||
replacesExistingRuntimeGeneration) ||
|
||||
(providerSequence.generation === 'continued' &&
|
||||
wasInitialized &&
|
||||
providerBaseline > currentSequence)
|
||||
if (snapshotMayCoverMissingState) {
|
||||
// Why: bytes can cross the control/stream sockets around attach. Until a
|
||||
// full renderer/provider snapshot is available, a partial model is unsafe.
|
||||
this.providerSnapshotPreferredPtys.add(ptyId)
|
||||
} else if (providerSequence.generation === 'reset') {
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
}
|
||||
|
||||
const headless = this.headlessTerminals.get(ptyId)
|
||||
if (headless) {
|
||||
if (headless && !wasInitialized && providerSequence.generation === 'continued') {
|
||||
// Why: daemon bytes can reach main just before spawn resolves. Queue the
|
||||
// baseline behind those writes so their emulator sequence is rebased too.
|
||||
headless.writeChain = headless.writeChain.then(() => {
|
||||
headless.outputSequence += baseline
|
||||
headless.outputSequence = synchronizedSequence
|
||||
})
|
||||
}
|
||||
return anchoredSequence
|
||||
return synchronizedSequence
|
||||
}
|
||||
|
||||
subscribeToTerminalData(
|
||||
@@ -6821,10 +6909,17 @@ export class OrcaRuntimeService {
|
||||
// scrollback to mobile so it rewraps at the new cols, but alternate-screen
|
||||
// TUIs (vim, Claude Code) own their repaint and have no scrollback — for
|
||||
// those the mobile client just resizes xterm geometry and consumes the
|
||||
// TUI's own redraw, so the resize re-stream must be skipped. Returns false
|
||||
// when there is no headless emulator (resize falls back to geometry-only).
|
||||
// TUI's own redraw, so the resize re-stream must be skipped. Provider state
|
||||
// covers restored PTYs whose main-side emulator is only a partial suffix.
|
||||
isTerminalAlternateScreen(ptyId: string): boolean {
|
||||
return this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? false
|
||||
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
|
||||
return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false
|
||||
}
|
||||
return (
|
||||
this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ??
|
||||
this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ??
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
// Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch
|
||||
@@ -6848,10 +6943,14 @@ export class OrcaRuntimeService {
|
||||
if (existing) {
|
||||
// Why: emulator already has live data — re-seeding would duplicate
|
||||
// every byte. The seed is only valid when the emulator is fresh.
|
||||
if (metadata.preferProviderIfExisting) {
|
||||
this.providerSnapshotPreferredPtys.add(ptyId)
|
||||
}
|
||||
return
|
||||
}
|
||||
const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
|
||||
state.outputSequence = this.getPtyOutputSequence(ptyId)
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
this.recordOsc7MetadataForPty(ptyId, data)
|
||||
this.recordRecentPtyOutputForPathProvenance(ptyId, data)
|
||||
@@ -6873,6 +6972,7 @@ export class OrcaRuntimeService {
|
||||
if (metadata.oscLinks !== undefined) {
|
||||
state.emulator.setRestoredOscLinks(metadata.oscLinks)
|
||||
}
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
})
|
||||
.catch(() => {
|
||||
// Seeding is best-effort; live data will continue to populate the
|
||||
@@ -6890,7 +6990,8 @@ export class OrcaRuntimeService {
|
||||
if (this.headlessHydrationState.has(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (this.headlessTerminals.has(ptyId)) {
|
||||
const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId)
|
||||
if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) {
|
||||
// Daemon-snapshot seed already populated the emulator — skip hydration.
|
||||
this.headlessHydrationState.set(ptyId, 'done')
|
||||
return
|
||||
@@ -6905,11 +7006,18 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
|
||||
if (providerSnapshotPreferred) {
|
||||
// Why: a stream byte can create a partial model before restored history
|
||||
// arrives. A mounted renderer snapshot can safely replace that model.
|
||||
this.disposeHeadlessTerminal(ptyId)
|
||||
}
|
||||
|
||||
this.headlessHydrationState.set(ptyId, 'pending')
|
||||
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
// Why: hydration writes below never set forwardQueryReplies (main-side
|
||||
// replay guard) — renderer-buffer snapshots can embed stale queries.
|
||||
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
|
||||
state.outputSequence = this.getPtyOutputSequence(ptyId)
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
|
||||
// Why: append the seed work to writeChain so live writes queued by
|
||||
@@ -6948,6 +7056,7 @@ export class OrcaRuntimeService {
|
||||
state.emulator.setLastTitle(seedTitle)
|
||||
this.applySeededAgentStatus(ptyId, seedTitle)
|
||||
}
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
} catch {
|
||||
// Hydration is best-effort. Live writes continue via the same
|
||||
// writeChain that this catch-arm leaves intact.
|
||||
@@ -7140,6 +7249,19 @@ export class OrcaRuntimeService {
|
||||
alternateScreen?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null> {
|
||||
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
|
||||
// Why: pre-attach stream bytes only form a suffix of restored state. A
|
||||
// sequenced provider snapshot safely reconciles live bytes; renderer is
|
||||
// the fallback when an older provider cannot expose that boundary.
|
||||
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
|
||||
if (providerSnapshot) {
|
||||
return providerSnapshot
|
||||
}
|
||||
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
|
||||
if (rendererSnapshot) {
|
||||
return rendererSnapshot
|
||||
}
|
||||
}
|
||||
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
|
||||
if (headlessSnapshot) {
|
||||
return headlessSnapshot
|
||||
@@ -7198,13 +7320,42 @@ export class OrcaRuntimeService {
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
const liveModeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId)
|
||||
if (!liveModeTrackers) {
|
||||
liveModeTrackers = new Set()
|
||||
this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers)
|
||||
}
|
||||
liveModeTrackers.add(liveModeTracker)
|
||||
try {
|
||||
// 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)
|
||||
return snapshot ? this.preferTrackedLastTitle(ptyId, snapshot) : null
|
||||
if (typeof snapshot?.alternateScreen === 'boolean') {
|
||||
const modeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
if (snapshot.alternateScreen) {
|
||||
modeTracker.scan('\x1b[?1049h')
|
||||
}
|
||||
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
|
||||
modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
|
||||
}
|
||||
this.providerModeTrackersByPtyId.set(ptyId, modeTracker)
|
||||
}
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0
|
||||
return this.preferTrackedLastTitle(ptyId, {
|
||||
...snapshot,
|
||||
seq: providerOffset + snapshot.seq
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
liveModeTrackers.delete(liveModeTracker)
|
||||
if (liveModeTrackers.size === 0) {
|
||||
this.providerModeSnapshotScansByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8293,7 +8444,11 @@ export class OrcaRuntimeService {
|
||||
this.clearWaitBlockedCheckState(ptyId)
|
||||
this.recentPtyPathCandidatesById.delete(ptyId)
|
||||
this.ptyOutputSequenceById.delete(ptyId)
|
||||
this.providerSequenceAnchoredPtys.delete(ptyId)
|
||||
this.providerSequenceInitializedPtys.delete(ptyId)
|
||||
this.providerSequenceOffsetByPtyId.delete(ptyId)
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
this.providerModeTrackersByPtyId.delete(ptyId)
|
||||
this.providerModeSnapshotScansByPtyId.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.terminalSpawnCommandsByPtyId.delete(ptyId)
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
@@ -20511,7 +20666,11 @@ export class OrcaRuntimeService {
|
||||
this.clearWaitBlockedCheckState(ptyId)
|
||||
this.recentPtyPathCandidatesById.delete(ptyId)
|
||||
this.ptyOutputSequenceById.delete(ptyId)
|
||||
this.providerSequenceAnchoredPtys.delete(ptyId)
|
||||
this.providerSequenceInitializedPtys.delete(ptyId)
|
||||
this.providerSequenceOffsetByPtyId.delete(ptyId)
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
this.providerModeTrackersByPtyId.delete(ptyId)
|
||||
this.providerModeSnapshotScansByPtyId.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.terminalSpawnCommandsByPtyId.delete(ptyId)
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
|
||||
@@ -92,3 +92,96 @@ it('replays post-capture output in the provider snapshot sequence domain', async
|
||||
runtime.cleanupSubscription('terminal-restored:phone-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('keeps provider-backed alternate-screen resizes geometry-only', async () => {
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
let resizeListener:
|
||||
| ((event: {
|
||||
cols: number
|
||||
rows: number
|
||||
displayMode: string
|
||||
reason: string
|
||||
seq: number
|
||||
}) => void)
|
||||
| undefined
|
||||
const serializeTerminalBuffer = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: 'restored tui', cols: 80, rows: 24 })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-tui' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer,
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
isTerminalAlternateScreen: vi.fn().mockReturnValue(true),
|
||||
handleMobileSubscribe: vi.fn().mockResolvedValue(undefined),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn((_ptyId, listener) => {
|
||||
resizeListener = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
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>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
{
|
||||
...request,
|
||||
id: 'req-provider-alt-screen',
|
||||
params: {
|
||||
terminal: 'terminal-tui',
|
||||
client: { id: 'phone-1', type: 'mobile' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}
|
||||
},
|
||||
vi.fn(),
|
||||
{
|
||||
connectionId: 'conn-tui-resize',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(resizeListener).toBeDefined())
|
||||
binaryFrames.splice(0)
|
||||
resizeListener?.({
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
displayMode: 'auto',
|
||||
reason: 'apply-layout',
|
||||
seq: 2
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames.some(
|
||||
(frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.Resized
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
expect(serializeTerminalBuffer).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
binaryFrames.some(
|
||||
(frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
runtime.cleanupSubscription('terminal-tui:phone-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
@@ -60,10 +60,13 @@ describe('TerminalKittyKeyboardModeTracker', () => {
|
||||
tracker.scan('\x1b[>1u')
|
||||
expect(tracker.flags).toBe(1)
|
||||
tracker.scan('\x1b[?1049h')
|
||||
expect(tracker.hasObservedAlternateScreenSwitch).toBe(true)
|
||||
expect(tracker.isAlternateScreen).toBe(true)
|
||||
expect(tracker.flags).toBe(0)
|
||||
tracker.scan('\x1b[>2u')
|
||||
expect(tracker.flags).toBe(2)
|
||||
tracker.scan('\x1b[?1049l')
|
||||
expect(tracker.isAlternateScreen).toBe(false)
|
||||
expect(tracker.flags).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -30,12 +30,21 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
private mainStack: number[] = []
|
||||
private altStack: number[] = []
|
||||
private alternateScreenActive = false
|
||||
private alternateScreenSwitchObserved = false
|
||||
|
||||
/** Current effective kitty keyboard flags (0 = protocol inactive). */
|
||||
get flags(): number {
|
||||
return this.currentFlags
|
||||
}
|
||||
|
||||
get isAlternateScreen(): boolean {
|
||||
return this.alternateScreenActive
|
||||
}
|
||||
|
||||
get hasObservedAlternateScreenSwitch(): boolean {
|
||||
return this.alternateScreenSwitchObserved
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.scanTail = ''
|
||||
this.currentFlags = 0
|
||||
@@ -44,6 +53,7 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
this.mainStack = []
|
||||
this.altStack = []
|
||||
this.alternateScreenActive = false
|
||||
this.alternateScreenSwitchObserved = false
|
||||
}
|
||||
|
||||
scan(data: string): void {
|
||||
@@ -77,6 +87,7 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
const tail = this.scanTail
|
||||
this.reset()
|
||||
this.scanTail = tail
|
||||
this.alternateScreenSwitchObserved = true
|
||||
continue
|
||||
}
|
||||
if (match[0].endsWith('!p')) {
|
||||
@@ -108,6 +119,7 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
if (param !== 47 && param !== 1047 && param !== 1049) {
|
||||
continue
|
||||
}
|
||||
this.alternateScreenSwitchObserved = true
|
||||
// Why: xterm swaps the current flags with the inactive screen's slot on
|
||||
// every 47/1047/1049 transition, without an already-active guard —
|
||||
// mirror it exactly so this state matches what the renderer encodes.
|
||||
|
||||
Reference in New Issue
Block a user