Files
deepseek-harness/packages/terminal/terminal-bash/tests/session-buffer.spec.ts
T
Tianyi Cui 3693c2b402 fix(pty): bound the wait for a seen prompt marker's printable tail
Windows sessions on the self-hosted lane settled one or two of seven sends on
the controlled prompt and charged the silence tier for the rest (issue 2487).
The marker is written by the shell's own prompt function and the printable tail
by the same render, so a tail that arrives past `idleSilenceMs +
handoffGraceMs` means a stalled delivery, not an absent prompt.

`dsh-terminal-bash` gains a validated `promptTailGraceMs` bound (default 0,
which reproduces the previous bound exactly). While a send has seen the marker
and its printable tail has not arrived, the silence bound grows by it. The
pwsh Loader-composition case sets 5000 ms, the host class the bound is for, and
still fails when no marker appears at all.
2026-09-27 20:34:19 +08:00

298 lines
13 KiB
TypeScript

import { Buffer } from 'node:buffer'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
import type { TerminalReadRequest } from '@deepseek-ai/dsh-terminal'
import type { ResolvedConfig } from '../src/config.ts'
import { LocalPtySession } from '../src/session.ts'
class OutputProducer implements SubprocessTerminalHandle {
readonly pid = 123
readonly output = new PassThrough()
private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
readonly done = this.outcome.promise
emit(text: string | Uint8Array): void {
this.output.write(typeof text === 'string' ? Buffer.from(text) : text)
}
exit(): void {
this.output.end()
this.outcome.resolve({ exitCode: 0, signal: null })
}
async write(): Promise<void> {}
async resize(): Promise<void> {}
async inspectActivity() { return { state: 'unknown' as const, revision: 0 } }
async inspectForeground() { return undefined }
async signalForeground(): Promise<number> { return this.pid }
async terminate(): Promise<void> {
this.exit()
await this.done
}
}
const sessions: LocalPtySession[] = []
afterEach(async () => {
await Promise.all(sessions.splice(0).map(session => session.close('buffer test cleanup')))
})
function fixture(overrides: Partial<ResolvedConfig> = {}) {
const config: ResolvedConfig = {
backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10_000, scrollbackMaxBytes: 4 * 1024 * 1024, maxReadBytes: 256 * 1024,
pollIntervalMs: 60_000, exactProbeAfterMs: 60_000, idleSilenceMs: 60_000,
handoffGraceMs: 60_000, promptTailGraceMs: 0, timeoutMs: 60_000, disposeGraceMs: 60_000,
...overrides,
}
const producer = new OutputProducer()
const session = new LocalPtySession(producer, config)
sessions.push(session)
return { producer, session }
}
// The reference deliberately retains the eager line-first, then UTF-8-tail algorithm.
function referenceTail(text: string, maxBytes: number) {
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
const chars = Array.from(text)
let bytes = 0
let start = chars.length
while (start > 0) {
const size = Buffer.byteLength(chars[start - 1] as string)
if (bytes + size > maxBytes) break
bytes += size
start -= 1
}
return { text: chars.slice(start).join(''), truncated: true }
}
class ReferenceBuffer {
private text = ''
private truncated = false
constructor(private readonly maxBytes: number, private readonly maxLines?: number) {}
append(chunk: string): void {
if (chunk.length === 0) return
this.text += chunk
if (this.maxLines !== undefined) {
const lines = this.text.split('\n')
if (lines.length > this.maxLines) {
this.text = lines.slice(-this.maxLines).join('\n')
this.truncated = true
}
}
const bounded = referenceTail(this.text, this.maxBytes)
this.text = bounded.text
this.truncated ||= bounded.truncated
}
snapshot() { return { text: this.text, truncated: this.truncated } }
consume() {
const result = { delta: this.text, truncated: this.truncated }
this.text = ''
this.truncated = false
return result
}
}
function referenceRead(buffer: ReferenceBuffer, maxBytes: number, request: TerminalReadRequest = {}) {
const snapshot = buffer.snapshot()
const lines = snapshot.text.split('\n')
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
const offset = request.offset ?? 0
if (offset >= totalLines) {
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
}
const end = totalLines - offset
const bounded = referenceTail(lines.slice(Math.max(0, end - (request.count ?? 500)), end).join('\n'), maxBytes)
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
return {
text: bounded.text, totalLines, lineBegin: offset, lineEnd: offset + returnedLines,
truncated: snapshot.truncated || bounded.truncated,
}
}
function deterministicChunks(alphabet: readonly string[], count: number): string[] {
let state = 0x12345678
return Array.from({ length: count }, () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
return alphabet[state % alphabet.length] as string
})
}
describe('LocalPtySession incremental output compatibility', () => {
it('retains the exact tail across the default 4 MiB scrollback limit while consuming active output', async () => {
const limit = 4 * 1024 * 1024
const { producer, session } = fixture({ maxReadBytes: limit })
const operation = session.startSend({ text: '', submit: false })
const chunk = 'a'.repeat(4096)
for (let index = 0; index < limit / chunk.length; index += 1) producer.emit(chunk)
expect(session.read({})).toEqual({
text: 'a'.repeat(limit), totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false,
})
producer.emit('界😀TAIL')
const retained = `${'a'.repeat(limit - 11)}界😀TAIL`
expect(session.read({})).toEqual({
text: retained, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: true,
})
expect(operation.readOutput()).toEqual({ delta: retained, truncated: true })
expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
producer.emit('é')
expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false })
producer.emit('done')
producer.exit()
await expect(operation.done).resolves.toEqual({
viewport: 'done', waitReason: 'session_exit',
sessionStatus: { kind: 'exited', exitCode: 0, signal: null }, truncated: true,
})
expect(operation.readOutput()).toEqual({ delta: 'done', truncated: false })
expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
})
it('counts trailing empty lines and keeps scrollback truncation sticky after empty reads', () => {
const { producer, session } = fixture({ scrollbackLines: 3, scrollbackMaxBytes: 64, maxReadBytes: 64 })
producer.emit('a\nb\n')
expect(session.read({})).toEqual({ text: 'a\nb\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: false })
producer.emit('\n')
expect(session.read({})).toEqual({ text: 'b\n\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: true })
expect(session.read({ count: 1 })).toEqual({ text: '', totalLines: 3, lineBegin: 0, lineEnd: 0, truncated: true })
expect(session.read({ offset: 2, count: 1 }).text).toBe('b')
expect(session.read({ offset: 3 }).truncated).toBe(true)
producer.emit('')
producer.emit('c')
expect(session.read({}).text).toBe('b\n\nc')
expect(session.read({}).truncated).toBe(true)
})
it('decodes split UTF-8 before byte eviction and bounds an oversized multibyte chunk', async () => {
const { producer, session } = fixture({ scrollbackMaxBytes: 11, maxReadBytes: 11 })
const operation = session.startSend({ text: '', submit: false })
producer.emit('abc')
const encoded = Buffer.from('界😀éz')
producer.emit(encoded.subarray(0, 5))
producer.emit(encoded.subarray(5, 7))
producer.emit(encoded.subarray(7))
expect(session.read({}).text).toBe('c界😀éz')
expect(operation.readOutput()).toEqual({ delta: 'c界😀éz', truncated: true })
producer.emit('界😀'.repeat(1000) + 'éEND')
expect(session.read({}).text).toBe('😀éEND')
expect(operation.readOutput()).toEqual({ delta: '😀éEND', truncated: true })
expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
producer.emit('ok')
producer.exit()
expect((await operation.done).viewport).toBe('ok')
})
it('resets operation truncation independently of retained scrollback', async () => {
const { producer, session } = fixture({ scrollbackMaxBytes: 128, maxReadBytes: 5 })
const operation = session.startSend({ text: '', submit: false })
for (let index = 0; index < 4; index += 1) {
producer.emit('123456')
expect(operation.readOutput()).toEqual({ delta: '23456', truncated: true })
expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
producer.emit('é')
expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false })
}
producer.exit()
expect(await operation.done).toMatchObject({ viewport: '', truncated: false, waitReason: 'session_exit' })
expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
})
it.each([[1, 1, 1], [17, 7, 3], [64, 13, 5]])(
'matches eager reads and active output with byte caps %i/%i and %i lines',
async (scrollbackMaxBytes, maxReadBytes, scrollbackLines) => {
const { producer, session } = fixture({ scrollbackMaxBytes, maxReadBytes, scrollbackLines })
const scrollback = new ReferenceBuffer(scrollbackMaxBytes, scrollbackLines)
const output = new ReferenceBuffer(maxReadBytes)
const operation = session.startSend({ text: '', submit: false })
const chunks = deterministicChunks(['a', 'bc', '\n', '\n\n', '界', '😀', 'éz', '', 'long line\nend\n'], 120)
for (const [index, chunk] of chunks.entries()) {
producer.emit(chunk)
scrollback.append(chunk)
output.append(chunk)
for (const request of [{}, { offset: 1, count: 2 }, { offset: 9, count: 1 }]) {
expect(session.read(request)).toEqual(referenceRead(scrollback, maxReadBytes, request))
}
if (index % 7 === 0) expect(operation.readOutput()).toEqual(output.consume())
}
producer.exit()
const expected = output.snapshot()
expect(await operation.done).toMatchObject({
viewport: expected.text, truncated: expected.truncated || scrollback.snapshot().truncated,
waitReason: 'session_exit',
})
expect(operation.readOutput()).toEqual(output.consume())
expect(operation.readOutput()).toEqual(output.consume())
},
)
it('retains tiny producer chunks through multiple coalesced-node rollovers', async () => {
const limit = 5000
const { producer, session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit })
const operation = session.startSend({ text: '', submit: false })
const text = '0123456789'.repeat(1000)
const checkpoints = new Set([1, 4096, 4097, 5000, 5001, 8193, text.length])
for (let index = 0; index < text.length; index += 1) {
producer.emit(text[index] as string)
if (checkpoints.has(index + 1)) {
expect(session.read({})).toEqual({
text: text.slice(Math.max(0, index + 1 - limit), index + 1),
totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: index + 1 > limit,
})
}
}
expect(operation.readOutput()).toEqual({ delta: text.slice(-limit), truncated: true })
expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
producer.emit('fresh')
producer.exit()
expect(await operation.done).toMatchObject({ viewport: 'fresh', truncated: true, waitReason: 'session_exit' })
})
it('preserves split surrogate pairs when copied coalesced text reaches the eviction head', () => {
const limit = 4100
const { session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit })
// Lone UTF-16 halves cannot pass through the session's TextDecoder unchanged.
const buffer = session['scrollback']
const reference = new ReferenceBuffer(limit, 10_000)
const chunks = ['H', 'a'.repeat(4095), '\ud83d', '\ude00', 'xy', 'b'.repeat(4094), 'z', '\ud800', '\udfff']
for (const chunk of chunks) {
buffer.append(chunk)
reference.append(chunk)
expect(buffer.snapshot()).toEqual(reference.snapshot())
}
expect(buffer.snapshot()).toEqual({ text: `y${'b'.repeat(4094)}z\ud800\udfff`, truncated: true })
expect(buffer.consume()).toEqual(reference.consume())
expect(buffer.consume()).toEqual(reference.consume())
})
it.each([1, 2, 3, 4, 5, 8, 17])('preserves lone and split surrogates with a %i-byte limit', (maxBytes) => {
const { session } = fixture({ scrollbackMaxBytes: maxBytes, maxReadBytes: maxBytes, scrollbackLines: 3 })
// TextDecoder replaces lone surrogates; only these UTF-16 cases use the private buffer.
const buffer = session['scrollback']
const reference = new ReferenceBuffer(maxBytes, 3)
const chunks = [
'\ud83d', '\ude00', 'x', '\ud83d', '', '\ude00', '\n', '\ud800', 'abc', '\udfff',
'prefix'.repeat(20) + '\ud800', '\udfff', '\n\n\n',
...deterministicChunks(['a', '\ud800', '\udfff', '\ud83d\ude00', '\n', 'é', '界', '', '\n\n'], 150),
]
for (const [index, chunk] of chunks.entries()) {
buffer.append(chunk)
reference.append(chunk)
expect(buffer.snapshot()).toEqual(reference.snapshot())
if (index % 19 === 18) {
expect(buffer.consume()).toEqual(reference.consume())
expect(buffer.consume()).toEqual(reference.consume())
}
}
expect(buffer.consume()).toEqual(reference.consume())
buffer.append('x')
reference.append('x')
expect(buffer.snapshot()).toEqual(reference.snapshot())
})
})