diff --git a/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.bound.test.ts b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.bound.test.ts index 2b95bf8e34..067d1b86a5 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.bound.test.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.bound.test.ts @@ -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)` diff --git a/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts index cb78f46646..c9b778f6cf 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts @@ -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 }