mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner'
|
||||
import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
|
||||
|
||||
// Real OMP 18.10.3 PTY bytes for one prompt: each user bubble ends with `B C D;0` (#10923).
|
||||
const OMP_TRANSCRIPT = readFileSync(
|
||||
join(__dirname, '../runtime/__fixtures__/omp-user-bubble-osc133-zone.txt'),
|
||||
'utf8'
|
||||
)
|
||||
const A = '\x1b]133;A\x07'
|
||||
const B = '\x1b]133;B\x07'
|
||||
const C = '\x1b]133;C\x07'
|
||||
const D0 = '\x1b]133;D;0\x07'
|
||||
// The shell's prompt and the typed command that launched OMP.
|
||||
const SHELL_LAUNCH = `${A}$ ${B}omp\r\n${C}`
|
||||
// OMP arms modifyOtherKeys, an input mode a command must disarm before its `D`.
|
||||
const ARM_MODIFY_OTHER_KEYS = '\x1b[>4;2m'
|
||||
|
||||
function triggers(chunks: string[]): number[] {
|
||||
const scanner = new TerminalShellLifecycleScanner()
|
||||
const ends: number[] = []
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
let rest = chunk
|
||||
let restOffset = offset
|
||||
while (rest.length > 0) {
|
||||
const events = scanner.scan(rest)
|
||||
if (events.uncleanDeathTriggerEnd === undefined) {
|
||||
break
|
||||
}
|
||||
ends.push(restOffset + events.uncleanDeathTriggerEnd)
|
||||
restOffset += events.uncleanDeathTriggerEnd
|
||||
rest = rest.slice(events.uncleanDeathTriggerEnd)
|
||||
}
|
||||
offset += chunk.length
|
||||
}
|
||||
return ends
|
||||
}
|
||||
|
||||
function splitEvery(text: string, size: number): string[] {
|
||||
const chunks: string[] = []
|
||||
for (let index = 0; index < text.length; index += size) {
|
||||
chunks.push(text.slice(index, index + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe('an OSC 133 zone a running app closes for itself', () => {
|
||||
it.each([1, 3, 7, 64, SHELL_LAUNCH.length + OMP_TRANSCRIPT.length])(
|
||||
"does not read OMP's bubble markers as its death, in %i-char chunks",
|
||||
(size) => {
|
||||
expect(triggers(splitEvery(SHELL_LAUNCH + OMP_TRANSCRIPT, size))).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('still repairs after the app dies with its input mode armed', () => {
|
||||
const stream = `${SHELL_LAUNCH}${ARM_MODIFY_OTHER_KEYS}${OMP_TRANSCRIPT}Killed\r\n${D0}`
|
||||
expect(triggers([stream])).toEqual([stream.length])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['output separates the C from the D', `${B}${C}x${D0}`],
|
||||
['the typed command separates the B from the C', `${B}ls\r\n${C}${D0}`],
|
||||
['the D reports a failure', `${B}${C}\x1b]133;D;1\x07`]
|
||||
])('still reads a D as the end of the command when %s', (_label, zone) => {
|
||||
const stream = `${SHELL_LAUNCH}${ARM_MODIFY_OTHER_KEYS}${zone}`
|
||||
expect(triggers([stream])).toEqual([stream.length])
|
||||
})
|
||||
|
||||
it('passes OMP output through the recovery barrier without holding it for a process probe', () => {
|
||||
const released: string[] = []
|
||||
const confirmShellForeground = vi.fn(async () => false)
|
||||
const barrier = new TerminalShellRecoveryBarrier({
|
||||
confirmShellForeground,
|
||||
release: (emission) => released.push(emission.data),
|
||||
isAlive: () => true
|
||||
})
|
||||
let seq = 0
|
||||
for (const data of splitEvery(SHELL_LAUNCH + OMP_TRANSCRIPT, 4096)) {
|
||||
barrier.accept({ data, rawStartSeq: seq, rawEndSeq: seq + data.length, transformed: false })
|
||||
seq += data.length
|
||||
}
|
||||
expect(confirmShellForeground).not.toHaveBeenCalled()
|
||||
expect(released.join('')).toBe(SHELL_LAUNCH + OMP_TRANSCRIPT)
|
||||
barrier.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildProcessBoundaryGround } from '../../shared/terminal-mode-reset-profiles'
|
||||
import { closesAppOsc133Zone } from '../../shared/terminal-osc133-command-finished'
|
||||
import type { TerminalOwner } from '../../shared/terminal-owner'
|
||||
import { TerminalArmedInputModes } from './terminal-armed-input-modes'
|
||||
|
||||
@@ -60,6 +61,8 @@ export class TerminalShellLifecycleScanner {
|
||||
private generationState = 0
|
||||
private altActive = false
|
||||
private commandEnteredAlternateScreen = false
|
||||
// OSC 133 markers seen back to back since the last other output.
|
||||
private adjacentMarkers = ''
|
||||
private readonly inputModes = new TerminalArmedInputModes()
|
||||
|
||||
get owner(): TerminalOwner | undefined {
|
||||
@@ -109,6 +112,9 @@ export class TerminalShellLifecycleScanner {
|
||||
// with no escape introducer must not pay the regex pass. Split sequences
|
||||
// stay correct because a partial one always left a non-empty scanTail.
|
||||
if (this.scanTail.length === 0 && !chunk.includes('\x1b') && !chunk.includes('\x9b')) {
|
||||
if (chunk.length > 0) {
|
||||
this.adjacentMarkers = ''
|
||||
}
|
||||
return events
|
||||
}
|
||||
const previousTailLength = this.scanTail.length
|
||||
@@ -117,8 +123,13 @@ export class TerminalShellLifecycleScanner {
|
||||
// The shared regex is safe because scan() is synchronous and never re-enters.
|
||||
SEQUENCE_RE.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
let outputEnd = 0
|
||||
while ((match = SEQUENCE_RE.exec(input)) !== null) {
|
||||
const oscPayload = match[1]
|
||||
const markersBefore = match.index > outputEnd ? '' : this.adjacentMarkers
|
||||
outputEnd = match.index + match[0].length
|
||||
this.adjacentMarkers =
|
||||
oscPayload === undefined ? '' : (markersBefore + oscPayload.slice(0, 1)).slice(-2)
|
||||
if (match[0] === '\x1bc') {
|
||||
this.revoke()
|
||||
this.inputModes.reset()
|
||||
@@ -137,7 +148,8 @@ export class TerminalShellLifecycleScanner {
|
||||
if (marker === 'A') {
|
||||
this.inputModes.markPrompt()
|
||||
}
|
||||
if (marker !== 'D') {
|
||||
// Why: an app closing its own zone is still running, so its `D` proves no exit (#10923).
|
||||
if (marker !== 'D' || closesAppOsc133Zone(markersBefore, oscPayload)) {
|
||||
continue
|
||||
}
|
||||
// An alternate screen or a command's input mode still up at command-finished
|
||||
@@ -208,6 +220,9 @@ export class TerminalShellLifecycleScanner {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.length - this.scanTail.length > outputEnd) {
|
||||
this.adjacentMarkers = ''
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,16 @@ function findPrefixCarry(data: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* TUIs such as OMP end each prompt bubble with `B C D;0`, closing their own zone while they keep
|
||||
* running (#10923). A shell's `C` follows the typed command line, never its own prompt's `B`
|
||||
* directly. `adjacentMarkers` holds the markers seen back to back before this one, e.g. 'BC'.
|
||||
*/
|
||||
export function closesAppOsc133Zone(adjacentMarkers: string, payload: string): boolean {
|
||||
const [sequence, exitCode] = payload.split(';')
|
||||
return sequence === 'D' && exitCode === '0' && adjacentMarkers.endsWith('BC')
|
||||
}
|
||||
|
||||
export type Osc133CommandFinishedScanner = {
|
||||
/** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */
|
||||
scan: (data: string) => void
|
||||
@@ -76,10 +86,8 @@ export function createOsc133CommandFinishedScanner(
|
||||
|
||||
const handleOsc133 = (payload: string, endInChunk: number): void => {
|
||||
const [sequence, exitCode] = payload.split(';')
|
||||
// Why: TUIs such as OMP end each prompt bubble with `B C D;0`, closing their own zone while
|
||||
// they keep running (#10923). Read as the command's exit, that `D` retired a live agent's
|
||||
// status. A shell's `C` follows the typed command line, never its own prompt's `B` directly.
|
||||
const closesAppZone = sequence === 'D' && exitCode === '0' && adjacentMarkers.endsWith('BC')
|
||||
// Why: read as the command's exit, that `D` retired a live agent's status.
|
||||
const closesAppZone = closesAppOsc133Zone(adjacentMarkers, payload)
|
||||
adjacentMarkers = (adjacentMarkers + sequence.slice(0, 1)).slice(-2)
|
||||
if (closesAppZone) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user