Files
deepseek-harness/packages/api/terminal-controller/tests/model.client.spec.ts
T

560 lines
26 KiB
TypeScript

/** Browser view ownership across slow RPCs, remounts and transport generations. */
import { afterEach, expect, it, vi } from 'vitest'
import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { RemoteError, type RemoteResult, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteMock, streamMethod, type StreamHandle } from '@deepseek-ai/dsh-remote-mock'
import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
import { TerminalInventory } from '../src/client/inventory.ts'
import type { TerminalInput, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts'
const sessionId = 'session' as SessionId
const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null }
const environment: TerminalEnvironment = {
cwd: info.cwd, maxInputBytes: 1000, maxBufferedBytes: 100_000, maxCols: 200, maxRows: 100, scrollback: 100,
}
const success = <T>(value: T): RemoteResult<T> => ({ ok: true, value })
const cleanups: (() => void | Promise<void>)[] = []
afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close() })
function untilAborted(signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) resolve()
else signal?.addEventListener('abort', () => { resolve() }, { once: true })
})
}
function failure(message: string): RemoteResult<never> {
return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) }
}
function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void, inputWindow?: RemoteUplinkWindow) {
const attachments: StreamHandle[] = []
const sent: TerminalInput[][] = []
const mock = RemoteMock.create().stream('terminal/attach', async (args, stream) => {
attachments.push(stream)
const commands: TerminalInput[] = []
sent.push(commands)
stream.push({ type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id: args[1] }, writable: true, ...inputWindow === undefined ? {} : { inputWindow } })
for await (const command of stream.uplink) commands.push(command as TerminalInput)
})
const remote: TerminalRemote = {
retain: vi.fn<TerminalRemote['retain']>(streamMethod<TerminalRemote['retain']>(async function* (_session, _id, signal) {
yield { type: 'retained' }
await new Promise<void>((resolve) => {
if (signal?.aborted) resolve()
else signal?.addEventListener('abort', () => { resolve() }, { once: true })
})
})),
shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
create: vi.fn<TerminalRemote['create']>(async (_sessionId, request) => success({ ...info, id: request.id })),
close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
attach: mock.remote.terminal.attach,
}
const options: RemoteStreamOptions<unknown>[] = []
const streams: { dispose(): Promise<void>; restart(): void }[] = []
const generation = createSnapshotStore<ReturnType<ConstructorParameters<typeof RemoteStream>[0]['generation']['getSnapshot']>>(undefined)
const gateway: Pick<ClientRemote, '$stream'> = {
$stream: (option) => {
options.push(option)
const stream = new RemoteStream({ generation }, option)
streams.push(stream)
prepareStream?.(stream)
return stream
},
}
cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) })
const inventory = new TerminalInventory(() => remote.list(sessionId))
cleanups.push(() => inventory.dispose())
const model = new TerminalView(sessionId, remote, gateway, inventory, info.id)
cleanups.push(() => model.dispose())
return { model, remote, gateway, options, streams, generation, attachments, sent }
}
async function mount(model: TerminalView) {
const detach = model.mount()
await model.refresh()
return detach
}
function acknowledge(model: TerminalView): void {
const render = model.state.getSnapshot().render
if (render !== undefined) model.acknowledge(render.revision)
}
async function connected(model: TerminalView): Promise<void> {
await mount(model)
await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
acknowledge(model)
}
it('waits for render acknowledgement before publishing subsequent output', async () => {
const { model, remote } = fixture()
vi.mocked(remote.attach).mockImplementation(streamMethod<TerminalRemote['attach']>(async function* (_sessionId, id, signal) {
const frames: TerminalFrame[] = [
{ type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } },
{ type: 'output', sequence: 1, data: 'first' },
{ type: 'output', sequence: 2, data: 'second' },
]
for (const frame of frames) yield frame
await untilAborted(signal)
}))
await mount(model)
await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot')
acknowledge(model)
await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 1 })
acknowledge(model)
await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 })
})
it('treats carrier loss as disconnected only for the active attachment', async () => {
const { model, options } = fixture()
await connected(model)
const first = options[0]!
first.carrierFailed?.(new RemoteStreamCarrierError('offline'))
expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', writable: false })
model.connect()
await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
first.carrierFailed?.(new RemoteStreamCarrierError('late old failure'))
expect(model.state.getSnapshot().phase).toBe('connected')
})
it.each([
['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'],
['sequence gap', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'],
['repeated snapshot', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'snapshot', writable: true, sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'],
] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => {
const { model, remote } = fixture()
vi.mocked(remote.attach).mockImplementation(streamMethod<TerminalRemote['attach']>(async function* () { yield* frames }))
await mount(model)
if (frames[0]?.type === 'snapshot') {
await expect.poll(() => model.state.getSnapshot().render).toBeDefined()
acknowledge(model)
}
await expect.poll(() => model.state.getSnapshot().error).toContain(message)
expect(model.state.getSnapshot().writable).toBe(false)
})
it('retains an exited screen and closes controls when its stream ends', async () => {
const { model, remote } = fixture()
vi.mocked(remote.attach).mockImplementation(streamMethod<TerminalRemote['attach']>(async function* (_sessionId, id) {
yield { type: 'snapshot', writable: true, sequence: 0, screen: 'last screen', info: { ...info, id } }
yield { type: 'state', writable: true, info: { ...info, id, state: 'exited', exitCode: 3 } }
}))
await connected(model)
await expect.poll(() => model.state.getSnapshot().phase).toBe('closed')
expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } })
})
it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => {
const { model, remote } = fixture()
vi.mocked(remote.attach).mockImplementationOnce(streamMethod<TerminalRemote['attach']>(async function* (_sessionId, id) {
yield { type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } }
}))
await connected(model)
await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded')
model.connect()
await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
})
it('publishes remote control transfer without replacing the retained screen', async () => {
const { model, remote } = fixture()
vi.mocked(remote.attach).mockImplementation(streamMethod<TerminalRemote['attach']>(async function* (_sessionId, id, signal) {
yield { type: 'snapshot', writable: true, sequence: 0, screen: 'retained', info: { ...info, id } }
yield { type: 'state', writable: false, info: { ...info, id } }
await untilAborted(signal)
}))
await connected(model)
await expect.poll(() => model.state.getSnapshot().writable).toBe(false)
expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' })
})
/** Pause delivery after the real Gateway iterator settles, before the model observes it. */
function deliveryBarrier() {
const ready = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
let held = false
function prepare<Item>(stream: RemoteStream<Item>): void {
if (held) return
held = true
const iterator = stream[Symbol.asyncIterator]()
let first = true
vi.spyOn(stream, Symbol.asyncIterator).mockReturnValue({
async next() {
if (!first) return iterator.next()
first = false
try {
return await iterator.next()
} finally {
ready.resolve(undefined)
await release.promise
}
},
async return() { return iterator.return!() },
})
}
cleanups.push(() => { release.resolve(undefined) })
return { ready: ready.promise, release: () => { release.resolve(undefined) }, prepare }
}
it('ignores an already delivered screen when its attachment is replaced before the model receives it', async () => {
const barrier = deliveryBarrier()
const { model, remote } = fixture(barrier.prepare)
await mount(model)
await barrier.ready
model.connect()
await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
const current = model.state.getSnapshot()
barrier.release()
await model.rename('settled')
expect(model.state.getSnapshot().render).toBe(current.render)
expect(remote.attach).toHaveBeenCalledTimes(2)
})
it('ignores an already settled stream error after a newer attachment has become writable', async () => {
const barrier = deliveryBarrier()
const { model, remote } = fixture(barrier.prepare)
vi.mocked(remote.attach).mockImplementationOnce(streamMethod<TerminalRemote['attach']>(async function* () {
yield* []
throw new Error('retired attachment failed')
}))
await mount(model)
await barrier.ready
model.connect()
await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
barrier.release()
await model.rename('settled')
expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined })
})
it('releases a pending screen render on carrier generation cancellation and accepts the replacement screen', async () => {
const { model, remote, streams } = fixture()
await mount(model)
await expect.poll(() => model.state.getSnapshot().render).toBeDefined()
const revision = model.state.getSnapshot().render!.revision
streams[0]!.restart()
await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1)
expect(remote.attach).toHaveBeenCalledTimes(2)
expect(model.state.getSnapshot().writable).toBe(true)
})
it('does not wait for the DOM callback of a screen whose generation was already cancelled at delivery', async () => {
const barrier = deliveryBarrier()
const { model, remote, streams } = fixture(barrier.prepare)
await mount(model)
await barrier.ready
streams[0]!.restart()
barrier.release()
await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2)
expect(remote.attach).toHaveBeenCalledTimes(2)
expect(model.state.getSnapshot().writable).toBe(true)
})
it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => {
const { model, remote } = fixture()
await connected(model)
await model.rename(` ${info.title} `)
expect(remote.rename).not.toHaveBeenCalled()
await model.rename(' Build ')
expect(model.state.getSnapshot()).toMatchObject({ title: 'Build', info: { title: 'Build' } })
vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost')
await model.rename('Other')
expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' })
await model.dispose()
await model.rename('Ignored')
expect(remote.rename).toHaveBeenCalledTimes(2)
})
it('classifies a discovery carrier failure as disconnected and supports explicit retry', async () => {
const { model, remote } = fixture()
vi.mocked(remote.environment).mockRejectedValueOnce(new RemoteStreamCarrierError('offline'))
await model.refresh()
expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' })
expect(remote.create).not.toHaveBeenCalled()
await model.refresh()
expect(model.state.getSnapshot().info?.id).toBe(info.id)
expect(remote.create).toHaveBeenCalledOnce()
})
it('retains state when disposal overtakes successful allocation or a failed discovery', async () => {
for (const operation of ['creation', 'discovery'] as const) {
const { model, remote } = fixture()
const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
const discovery = Promise.withResolvers<RemoteResult<TerminalEnvironment>>()
if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise)
else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise)
const loading = model.refresh()
if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce()
await model.dispose()
const before = model.state.getSnapshot()
if (operation === 'creation') creation.resolve(success(info))
else discovery.reject(new Error('late discovery failure'))
await loading
expect(model.state.getSnapshot()).toBe(before)
expect(remote.attach).not.toHaveBeenCalled()
}
})
it('ignores a successful rename after the view is disposed', async () => {
const { model, remote } = fixture()
await connected(model)
const rename = Promise.withResolvers<RemoteResult<void>>()
vi.mocked(remote.rename).mockReturnValueOnce(rename.promise)
const pending = model.rename('Late')
await model.dispose()
const before = model.state.getSnapshot()
rename.resolve(success(undefined))
await pending
expect(model.state.getSnapshot()).toBe(before)
})
it('allows retry after failed process cleanup and never reconnects while close is pending', async () => {
const { model, remote } = fixture()
await connected(model)
const closing = Promise.withResolvers<RemoteResult<void>>()
vi.mocked(remote.close).mockReturnValueOnce(closing.promise)
const first = model.close()
expect(model.close()).toBe(first)
model.connect()
expect(remote.attach).toHaveBeenCalledOnce()
closing.resolve(failure('close refused'))
await expect(first).rejects.toThrow('close refused')
expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' })
await model.close()
expect(model.state.getSnapshot()).toMatchObject({ phase: 'closed', writable: false })
expect(remote.close).toHaveBeenCalledTimes(2)
})
it('does not publish a close result or reconnect after disposal', async () => {
const { model, remote } = fixture()
await connected(model)
const closing = Promise.withResolvers<RemoteResult<void>>()
vi.mocked(remote.close).mockReturnValueOnce(closing.promise)
const pending = model.close()
await model.dispose()
const before = model.state.getSnapshot()
closing.resolve(success(undefined))
await pending
model.mount()
model.connect()
expect(model.state.getSnapshot()).toBe(before)
expect(remote.attach).toHaveBeenCalledOnce()
})
it('exposes a localized quota error and clears it after a successful retry', async () => {
const { model, remote } = fixture()
vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) })
await model.refresh()
expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' })
await connected(model)
expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined })
})
it('sends input and clamped resize in order without waiting for acknowledgements', async () => {
const h = fixture()
await connected(h.model)
h.model.resize(80, 24)
h.model.write('first')
h.model.write('second')
h.model.resize(500, 300)
await expect.poll(() => h.sent[0]?.length).toBe(3)
expect(h.sent[0]).toEqual([
{ type: 'write', sequence: 1, data: 'first' },
{ type: 'write', sequence: 2, data: 'second' },
{ type: 'resize', sequence: 3, cols: 200, rows: 100 },
])
})
it('releases the UTF-8 input budget only on acknowledgement', async () => {
const h = fixture()
vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 }))
await connected(h.model)
h.model.write('界界')
await expect.poll(() => h.sent[0]?.length).toBe(1)
h.attachments[0]!.push({ type: 'ack', sequence: 1 })
h.attachments[0]!.push({ type: 'state', info: { ...info, cols: 90 }, writable: true })
await expect.poll(() => h.model.state.getSnapshot().info?.cols).toBe(90)
h.model.write('界界')
await expect.poll(() => h.sent[0]?.length).toBe(2)
h.model.write('a')
expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' })
})
it('keeps output attached after an input refusal and stops further input', async () => {
const h = fixture()
await connected(h.model)
h.model.write('before transfer')
h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' })
await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false)
h.model.write('refused')
h.model.resize(100, 30)
h.attachments[0]!.push({ type: 'output', sequence: 1, data: 'still following' })
await expect.poll(() => h.model.state.getSnapshot().render?.frame).toMatchObject({ data: 'still following' })
expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined })
expect(h.remote.attach).toHaveBeenCalledOnce()
expect(h.sent[0]).toHaveLength(1)
})
it('discards the old generation budget and never replays unacknowledged input after reconnect', async () => {
const h = fixture()
h.generation.set({ id: 1, host: { home: '/home/fixture' } })
await connected(h.model)
h.model.write('unconfirmed')
await expect.poll(() => h.sent[0]?.length).toBe(1)
h.generation.set(undefined)
h.attachments[0]!.fail(new RemoteStreamCarrierError('connection lost'))
await expect.poll(() => h.model.state.getSnapshot().phase).toBe('disconnected')
h.model.write('offline')
h.generation.set({ id: 2, host: { home: '/home/fixture' } })
await expect.poll(() => h.attachments.length).toBe(2)
await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true)
acknowledge(h.model)
h.model.write('fresh')
await expect.poll(() => h.sent[1]?.length).toBe(1)
expect(h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'fresh' }])
h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' })
expect(h.model.state.getSnapshot().writable).toBe(true)
expect(h.remote.create).toHaveBeenCalledOnce()
})
it('ignores controls before mount and after detach, then reuses the process on remount', async () => {
const h = fixture()
h.model.write('early')
h.model.resize(100, 30)
const unmount = await mount(h.model)
await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true)
unmount()
h.model.write('detached')
h.model.resize(100, 30)
expect(h.sent[0]).toEqual([])
h.model.mount()
await expect.poll(() => h.remote.attach).toHaveBeenCalledTimes(2)
expect(h.remote.create).toHaveBeenCalledOnce()
})
it('rejects an out-of-order acknowledgement and reports synchronous send failures', async () => {
const h = fixture()
await connected(h.model)
h.model.write('input')
h.attachments[0]!.push({ type: 'ack', sequence: 2 })
await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput')
h.model.connect()
await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true)
const handle = vi.mocked(h.remote.attach).mock.results.at(-1)!.value as ReturnType<TerminalRemote['attach']>
vi.spyOn(handle, 'send').mockImplementation(() => { throw new Error('send failed') })
h.model.write('next')
expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'send failed' })
})
it.each(['write', 'resize'] as const)('ignores empty input and stale render acknowledgements and refuses %s without limits', async (operation) => {
const h = fixture()
await connected(h.model)
h.model.acknowledge(-1)
h.model.write('')
const state = h.model.state.getSnapshot()
h.model.state.set({ ...state, info: undefined })
h.model.write('missing')
h.model.resize(100, 30)
expect(h.sent[0]).toEqual([])
h.model.state.set({ ...state, environment: undefined })
if (operation === 'write') h.model.write('no budget')
else h.model.resize(100, 30)
expect(h.sent[0]).toEqual([])
expect(h.model.state.getSnapshot().issue).toBe('inputFull')
})
function encodedFrame(command: TerminalInput): number {
return new TextEncoder().encode(JSON.stringify({ type: 'item', streamId: '00000000-0000-0000-0000-000000000000', value: command })).byteLength
}
function inputWindow(maxBytes: number): RemoteUplinkWindow {
const command: TerminalInput = { type: 'write', sequence: 1, data: 'a' }
return { maxBytes, frameOverheadBytes: encodedFrame(command) - new TextEncoder().encode(JSON.stringify(command)).byteLength }
}
it('waits for complete-frame capacity and sends queued write and resize commands in order', async () => {
const commands: TerminalInput[] = [
{ type: 'write', sequence: 1, data: '\u0001界' },
{ type: 'resize', sequence: 2, cols: 90, rows: 25 },
{ type: 'write', sequence: 3, data: 'last' },
]
const h = fixture(undefined, inputWindow(Math.max(...commands.map(encodedFrame))))
await connected(h.model)
h.model.write('\u0001界')
h.model.resize(90, 25)
h.model.write('last')
await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 1))
h.attachments[0]!.push({ type: 'ack', sequence: 1 })
await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 2))
h.attachments[0]!.push({ type: 'ack', sequence: 2 })
await expect.poll(() => h.sent[0]).toEqual(commands)
expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined })
})
it('keeps a burst of short commands within the advertised inbox and resumes on acknowledgements', async () => {
const window = inputWindow(262_144)
const h = fixture(undefined, window)
vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 65_536, maxBufferedBytes: 2_097_152 }))
await connected(h.model)
for (let index = 0; index < 3000; index++) h.model.write('a')
await expect.poll(() => h.sent[0]?.length).toBe(2250)
const first = h.sent[0]!
expect(first.reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes)
for (let sequence = 1; sequence <= 1000; sequence++) h.attachments[0]!.push({ type: 'ack', sequence })
await expect.poll(() => h.sent[0]?.length).toBe(3000)
expect(h.sent[0]!.slice(1000).reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes)
expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined })
})
it('counts queued resize frames against the Client buffer and cancels unsent commands on overflow', async () => {
const command: TerminalInput = { type: 'resize', sequence: 1, cols: 90, rows: 25 }
const bytes = encodedFrame(command)
const h = fixture(undefined, inputWindow(bytes))
vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxBufferedBytes: 2 * bytes }))
await connected(h.model)
h.model.resize(90, 25)
h.model.resize(91, 25)
await expect.poll(() => h.sent[0]?.length).toBe(1)
h.model.resize(92, 25)
expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' })
expect(h.sent[0]).toEqual([command])
})
it('refuses a single escaped input frame larger than the carrier window before sending it', async () => {
const h = fixture(undefined, inputWindow(150))
await connected(h.model)
h.model.write('\u0001'.repeat(20))
expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' })
expect(h.sent[0]).toEqual([])
})
it('rejects acknowledgements for commands still waiting in the Client queue', async () => {
const h = fixture(undefined, inputWindow(120))
await connected(h.model)
h.model.write('a')
h.model.write('b')
await expect.poll(() => h.sent[0]?.length).toBe(1)
h.attachments[0]!.push({ type: 'ack', sequence: 2 })
await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput')
})
it('drops waiting commands when input is refused and never replays them on a new attachment', async () => {
const h = fixture(undefined, inputWindow(120))
await connected(h.model)
h.model.write('a')
h.model.write('b')
await expect.poll(() => h.sent[0]?.length).toBe(1)
h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' })
await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false)
expect(h.sent[0]).toHaveLength(1)
h.model.connect()
await expect.poll(() => h.attachments.length).toBe(2)
await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true)
acknowledge(h.model)
h.model.write('c')
await expect.poll(() => h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'c' }])
})