diff --git a/src/shared/agent-status-osc.test.ts b/src/shared/agent-status-osc.test.ts index 4a0711cc426..5b7c0ff69d1 100644 --- a/src/shared/agent-status-osc.test.ts +++ b/src/shared/agent-status-osc.test.ts @@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest' import { createAgentStatusOscProcessor } from './agent-status-osc' describe('createAgentStatusOscProcessor', () => { + it('keeps ordinary chunks on the clean-data identity path', () => { + const process = createAgentStatusOscProcessor() + const data = 'plain terminal output\r\nwith ANSI-like text [0m\n' + + const result = process(data) + + expect(result.cleanData).toBe(data) + expect(result.payloads).toEqual([]) + expect(result.lastPayloadCleanOffset).toBeNull() + }) + it('strips OSC 9999 payloads from terminal data and returns parsed statuses', () => { const process = createAgentStatusOscProcessor() @@ -35,4 +46,48 @@ describe('createAgentStatusOscProcessor', () => { ]) expect(result.lastPayloadCleanOffset).toBe(0) }) + + it('retains a marker prefix split across chunks without leaking it as output', () => { + const process = createAgentStatusOscProcessor() + + const first = 'before\x1b]99' + expect(process(first)).toMatchObject({ + cleanData: 'before', + payloads: [], + lastPayloadCleanOffset: null + }) + + const result = process('99;{"state":"working","prompt":"split"}\x07after') + + expect(result.cleanData).toBe('after') + expect(result.payloads).toEqual([{ state: 'working', prompt: 'split' }]) + expect(result.lastPayloadCleanOffset).toBe(0) + }) + + it('retains an unterminated OSC 9999 payload until its terminator arrives', () => { + const process = createAgentStatusOscProcessor() + + expect(process('before\x1b]9999;{"state":"working","prompt":"par')).toMatchObject({ + cleanData: 'before', + payloads: [], + lastPayloadCleanOffset: null + }) + + const result = process('tial"}\x1b\\after') + + expect(result.cleanData).toBe('after') + expect(result.payloads).toEqual([{ state: 'working', prompt: 'partial' }]) + expect(result.lastPayloadCleanOffset).toBe(0) + }) + + it('does not treat malformed or unrelated control data as an OSC status marker', () => { + const process = createAgentStatusOscProcessor() + const data = '\x1b[31mwarning\x07\x1b]999x\n' + + const result = process(data) + + expect(result.cleanData).toBe(data) + expect(result.payloads).toEqual([]) + expect(result.lastPayloadCleanOffset).toBeNull() + }) }) diff --git a/src/shared/agent-status-osc.ts b/src/shared/agent-status-osc.ts index 39f9703bf85..75036888dbc 100644 --- a/src/shared/agent-status-osc.ts +++ b/src/shared/agent-status-osc.ts @@ -3,6 +3,22 @@ import { parseAgentStatusPayload } from './agent-status-types' const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' +/** Return a suffix that can only be the beginning of an OSC 9999 marker. */ +function findAgentStatusPrefixCarry(data: string): string { + const lastChar = data.charCodeAt(data.length - 1) + if (lastChar !== 0x1b && lastChar !== 0x5d && lastChar !== 0x39 && lastChar !== 0x3b) { + return '' + } + const maxCarryLength = Math.min(data.length, OSC_AGENT_STATUS_PREFIX.length - 1) + for (let length = maxCarryLength; length > 0; length -= 1) { + const suffix = data.slice(data.length - length) + if (OSC_AGENT_STATUS_PREFIX.startsWith(suffix)) { + return suffix + } + } + return '' +} + export type ProcessedAgentStatusChunk = { cleanData: string payloads: ParsedAgentStatusPayload[] @@ -37,6 +53,22 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen let pending = '' return (data: string): ProcessedAgentStatusChunk => { + // Ordinary terminal output is by far the common case. Keep it on the + // identity path unless the chunk ends with a split OSC marker; this avoids + // rebuilding a clean-data string for every PTY frame. + if (pending.length === 0 && !data.includes(OSC_AGENT_STATUS_PREFIX)) { + const carry = findAgentStatusPrefixCarry(data) + if (carry.length === 0) { + return { cleanData: data, payloads: [], lastPayloadCleanOffset: null } + } + pending = carry + return { + cleanData: data.slice(0, data.length - carry.length), + payloads: [], + lastPayloadCleanOffset: null + } + } + const combined = pending + data pending = '' @@ -49,17 +81,10 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen const start = combined.indexOf(OSC_AGENT_STATUS_PREFIX, cursor) if (start === -1) { const tail = combined.slice(cursor) - const prefixLen = OSC_AGENT_STATUS_PREFIX.length - let partialPrefixLen = 0 - for (let k = Math.min(prefixLen - 1, tail.length); k > 0; k--) { - if (tail.endsWith(OSC_AGENT_STATUS_PREFIX.slice(0, k))) { - partialPrefixLen = k - break - } - } - if (partialPrefixLen > 0) { - cleanData += tail.slice(0, tail.length - partialPrefixLen) - pending = tail.slice(tail.length - partialPrefixLen) + const carry = findAgentStatusPrefixCarry(tail) + if (carry.length > 0) { + cleanData += tail.slice(0, tail.length - carry.length) + pending = carry } else { cleanData += tail }