mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): stop "104;255" leaking onto agent prompts after a tab switch (#31411)
A CSI scan now stops at ESC/CAN/SUB (as xterm's parser does), so a hidden-startup carry ending mid-CSI no longer swallows the next OSC's introducer and leaks `104;255` onto the prompt. Fixes #31410. Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
+112
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
|
||||
import { flushAsyncTicks, writeHeadlessTerminal } from './pty-connection-test-async'
|
||||
import {
|
||||
createManager,
|
||||
createMockTransport,
|
||||
createPane,
|
||||
type ConnectCallbacks,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
|
||||
// Why: a hidden carry ending mid-CSI must not swallow the next chunk's `ESC ]` (#31410).
|
||||
const HIDDEN_STRAGGLER_ENDING_MID_SGR = '\x1b[?2026h\x1b[H\x1b[38;2;177;1'
|
||||
const CLAUDE_PALETTE_RESET = '\x1b]104;255\x07'
|
||||
const PROMPT_SNAPSHOT = { data: '\x1b[H❯ ', cols: 120, rows: 40, seq: 1000 }
|
||||
|
||||
describe('hidden query carry vs a following OSC', () => {
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
bindPtyConnectionTestMocks({
|
||||
getState: () => mockStoreState,
|
||||
getTransportQueue: () => transportFactoryQueue,
|
||||
getTransportOptions: () => createdTransportOptions,
|
||||
getStoreSubscribers: () => storeSubscribers,
|
||||
setStoreSubscribers: (subscribers) => {
|
||||
storeSubscribers = subscribers
|
||||
}
|
||||
})
|
||||
await installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
it('does not paint an OSC payload after the reveal restore when a hidden chunk ended mid-CSI', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const dataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
dataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
getMainBufferSnapshot.mockResolvedValue(PROMPT_SNAPSHOT)
|
||||
|
||||
const isVisibleRef = { current: false }
|
||||
const pane = createPane(1)
|
||||
const deps = buildPaneConnectionDeps(() => mockStoreState, {
|
||||
isVisibleRef,
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads.
|
||||
const args = [pane, createManager(1), deps] as unknown as Parameters<typeof connectPanePty>
|
||||
const binding = connectPanePty(...args)
|
||||
const rendered = new Terminal({ cols: 120, rows: 40, allowProposedApi: true })
|
||||
try {
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
dataCallback.current?.(HIDDEN_STRAGGLER_ENDING_MID_SGR, {
|
||||
seq: 1000,
|
||||
rawLength: HIDDEN_STRAGGLER_ENDING_MID_SGR.length
|
||||
})
|
||||
isVisibleRef.current = true
|
||||
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
|
||||
_dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'unhide', markerSeq: 1000 })
|
||||
await flushAsyncTicks(40)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
dataCallback.current?.(CLAUDE_PALETTE_RESET, {
|
||||
seq: 1000 + CLAUDE_PALETTE_RESET.length,
|
||||
rawLength: CLAUDE_PALETTE_RESET.length
|
||||
})
|
||||
await flushAsyncTicks(40)
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map(([data]) => data).join('')
|
||||
await writeHeadlessTerminal(rendered, writes)
|
||||
const promptLine = rendered.buffer.active.getLine(0)?.translateToString(true)
|
||||
expect(promptLine).toBe('❯ ')
|
||||
} finally {
|
||||
rendered.dispose()
|
||||
binding.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
containsStatefulRendererQuery,
|
||||
extractHiddenStartupRendererQueryData,
|
||||
findCsiFinalByteIndex
|
||||
} from './terminal-reply-query-extraction'
|
||||
|
||||
describe('findCsiFinalByteIndex', () => {
|
||||
it('returns the final byte of a complete CSI', () => {
|
||||
expect(findCsiFinalByteIndex('\x1b[38;2;1;2;3m', 2)).toBe(12)
|
||||
})
|
||||
|
||||
it('keeps a CSI with no final byte open', () => {
|
||||
expect(findCsiFinalByteIndex('\x1b[38;2;177;1', 2)).toBe(-1)
|
||||
})
|
||||
|
||||
it('ends a CSI where ESC, CAN or SUB aborts it, as xterm does', () => {
|
||||
// Why: without the abort, the `]` of the next OSC read as this CSI's final byte.
|
||||
expect(findCsiFinalByteIndex('\x1b[38;2;177;1\x1b]104;255\x07', 2)).toBe(11)
|
||||
expect(findCsiFinalByteIndex('\x1b[38;2\x18A', 2)).toBe(5)
|
||||
expect(findCsiFinalByteIndex('\x1b[38;2\x1aA', 2)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reply query extraction after an aborted CSI', () => {
|
||||
it('finds a CPR that follows a carried partial CSI', () => {
|
||||
const result = extractHiddenStartupRendererQueryData('\x1b[6n', '\x1b[38;2;177;1')
|
||||
|
||||
expect(result.statefulQueryData).toBe('\x1b[6n')
|
||||
expect(result.pending).toBe('')
|
||||
})
|
||||
|
||||
it('reports a CPR that follows a truncated CSI in the same chunk', () => {
|
||||
expect(containsStatefulRendererQuery('\x1b[38;2;1\x1b[6n')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -133,12 +133,16 @@ export function containsStatefulRendererQuery(data: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: ESC/CAN/SUB abort a CSI (as in xterm), so a carried partial can't eat the next sequence's introducer.
|
||||
export function findCsiFinalByteIndex(data: string, offset: number): number {
|
||||
for (let index = offset; index < data.length; index++) {
|
||||
const code = data.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return index
|
||||
}
|
||||
if (code === 0x1b || code === 0x18 || code === 0x1a) {
|
||||
return index - 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user