fix(desktop): prefer the live backlog over the last snapshot fence when appending a catch-up delta

syncAgentTerminalSnapshot checked the stale lastSnapshots prefix-delta before
the authoritative backlog (body) prefix-delta. Live agent.terminal.output
chunks advance the backlog but never lastSnapshots, so once any chunk has
streamed since the last snapshot, body is strictly longer than previous and
both are prefixes of a catch-up process.list snapshot. The fence-first branch
then emitted output.slice(previous.length), re-appending bytes the chunks had
already written and duplicating them on screen and in the replay backlog.

body is the authoritative on-screen state and is always >= previous, so its
delta is the correct, smaller one; check it first. Regression case added to
agent-terminal-stream.bound.test.ts (red without the swap).

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson
2026-09-28 20:24:05 -05:00
committed by brooklyn!
parent 63ee01c78f
commit 4b2682e9fa
2 changed files with 30 additions and 6 deletions

View File

@@ -133,6 +133,26 @@ describe('agent terminal retention is bounded', () => {
expect(replay('reset-11')).toContain(tail.slice(0, 1000))
})
it('a catch-up snapshot does not re-print bytes already streamed by live chunks', () => {
// Live `agent.terminal.output` chunks advance the backlog but never the
// lastSnapshots fence. A catch-up snapshot that contains everything shown so
// far matches BOTH prefix checks, and the fence-first order emitted
// output.slice(previous.length) — re-appending bytes the chunks had already
// written, so the screen showed `AAABBBAAABBBCCC` for `AAABBB` + `CCC`.
const proc = 'dup-proc'
let screen = ''
const stop = registerAgentTerminalWriter(proc, chunk => {
screen = chunk.startsWith('\x1bc') ? chunk.slice(2) : screen + chunk
})
writeAgentTerminalChunk(proc, 'AAA')
writeAgentTerminalChunk(proc, 'BBB')
syncAgentTerminalSnapshot(proc, 'AAABBBCCC')
stop()
expect(screen).toBe('AAABBBCCC')
})
it('an evicted process does not diff against a tail it no longer has', () => {
// `lastSnapshots` is the delta fence for `backlog`. If eviction dropped the
// backlog but KEPT the fence, the next snapshot takes the `startsWith(previous)`

View File

@@ -137,18 +137,22 @@ export function syncAgentTerminalSnapshot(procId: string, output: string): void
return
}
// The fence is updated BEFORE the write so the write's eviction pass counts it:
// after, it would re-create a fence for a process that pass just forgot.
if (output.startsWith(previous)) {
// The live backlog (body) is the authoritative on-screen state and is always at
// least as long as the last snapshot fence, so check its prefix-delta FIRST: the
// fence-first order would re-append bytes already streamed by live chunks and
// print them twice. The fence is updated BEFORE the write so the write's eviction
// pass counts it: after, it would re-create a fence for a process that pass just
// forgot.
if (output.startsWith(body)) {
lastSnapshots.set(procId, output)
writeAgentTerminalChunk(procId, output.slice(previous.length))
writeAgentTerminalChunk(procId, output.slice(body.length))
return
}
if (output.startsWith(body)) {
if (output.startsWith(previous)) {
lastSnapshots.set(procId, output)
writeAgentTerminalChunk(procId, output.slice(body.length))
writeAgentTerminalChunk(procId, output.slice(previous.length))
return
}