fix(tui): match reference discard listener on content, not the unassigned id

The reference-admission discard listener matched on followup()'s returned id,
but an agent/inbox/enqueue listener that synchronously cancels emits
agent/inbox/discard before followup() returns to assign that id. The match
then missed, leaking both the submit and discard listeners plus the attached
context per referenced prompt. Match on the content reference instead — the
same value send() carries onto the message, known before followup() runs, and
symmetric with the submit wrapper's content check.

The prior "ordinary allowed path" test passed only because the fake agent
returned a fixed 'stub' id that collided with the id the test constructed;
it now releases each wrapper through its own allowed admission, and a new
regression drives the synchronous-discard timing directly.
This commit is contained in:
_Kerman
2026-07-27 00:21:00 +08:00
parent 6de88f427c
commit 9ef55cda98
2 changed files with 60 additions and 9 deletions
+6 -5
View File
@@ -2822,17 +2822,18 @@ export function createTuiChat(
}, { prepend: true })
// Installed BEFORE followup(): admission runs synchronously inside it on
// the common path, and a listener registered after cleanup() already ran
// would never be released. The id lands before any discard can name it —
// discard is only ever emitted by a later cancel().
let id: AgentMessageId | undefined
// would never be released. Match on the `content` reference, not the
// returned id: an enqueue listener that synchronously cancels emits
// discard before followup() returns to assign the id, and content is the
// same reference send() carries onto the message (mirrors detachSubmit).
const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => {
if (subject === agent && messages.some(message => message.id === id)) cleanup()
if (subject === agent && messages.some(message => message.content === content)) cleanup()
})
// followup() accepts any typed input and contains listener failures;
// this guards a future synchronous throw so the wrapper cannot leak.
/* v8 ignore start -- future-proofing guard, see above */
try {
id = agent.followup({ content, source: { kind: 'user' } })
agent.followup({ content, source: { kind: 'user' } })
} catch (error: unknown) {
cleanup()
throw error