From 71152bc4c08fcf078695e5f831e4c1ba727206d3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 01:59:59 +0800 Subject: [PATCH] fix(tui): mark disposed when the agent leaves the registry under the TUI agent/disposed only cleared the status line, so an agent-loop-only reload that disposed the agent while the TUI stayed mounted left the local disposed flag false. Since retained agents accept deliveries after detachment, later input drove a zombie agent/session. Set disposed on agent/disposed so dispatchMessage reports it. Adds a regression that sends after disposal and asserts no delivery. --- packages/ui/tui/src/index.ts | 5 +++++ packages/ui/tui/tests/tui.spec.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index b5a76ba56b..4efdc4f684 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -3217,6 +3217,11 @@ export function createTuiChat( }) const disposeAgent = ctx.on('agent/disposed', (subject) => { if (subject !== agent) return + // The agent left the registry (e.g. an agent-loop-only reload) while the + // TUI stays mounted. Retained agents accept deliveries after detachment, so + // without this a later send would drive a zombie agent/session; mark + // disposed so dispatchMessage reports it instead. + disposed = true clearStatus() appendNotice(`Agent "${agent.id}" was disposed.`, 'warning') }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 216660b06b..013b519cda 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2859,6 +2859,25 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(events.terminal.output).toContain('was disposed') await dispose(events) }) + + it('rejects input after the agent is disposed out from under the TUI', async () => { + const result = await setup() + + // The agent leaves the registry (e.g. an agent-loop-only reload) while the + // TUI stays mounted. A later send must report disposal, not drive the + // detached zombie agent. + agentEvents(result.ctx, result.agent).emit('agent/disposed') + await tick() + expect(result.terminal.output).toContain('was disposed') + + result.terminal.send('drive the zombie') + result.terminal.send('\r') + await tick() + expect(result.agent.sent).toHaveLength(0) + expect(result.agent.steered).toHaveLength(0) + expect(result.terminal.output).toContain('is disposed') + await dispose(result) + }) }) describe('skill slash command', () => {