fix(tui,tool-tasks): finish removing dispose-ness from delivery

The TUI's referenced-prompt snapshot now rides the prompt's own
admission transaction instead of a pre-admission inject: while idle, a
one-shot prepended agent/prompt-submit wrapper appends the snapshot to
the allow decision's additionalContexts, so a blocking hook discards
the prompt and its attached context together instead of stranding the
snapshot in history for the next unrelated prompt. A prompt discarded
before admission releases the wrapper; steering keeps the inject path
since it bypasses admission and drains at the same boundary. The
session-reference snapshot adapter pinned the old context-before-prompt
order; the branch-wide order (prompt first, its contexts after) is now
asserted and the fixture re-recorded.

tool-tasks drops the last consumer of the removed thrown-disposed
contract: completion notices now inject unconditionally, which is
well-defined during owner teardown — the loop treats disposal like any
cancel, so the notice appends as durable idle context (persisted for
resume while the session is attached, dropped with the detached log
after). README pair and the owner-disposal tests state the new
delivery contract.
This commit is contained in:
_Kerman
2026-07-26 22:08:35 +08:00
parent 04f0435cc9
commit 225552fb32
9 changed files with 196 additions and 52 deletions

View File

@@ -64,6 +64,7 @@ import {
type SessionEvent,
type SessionHeader,
type TodoItem,
type UserMessageData,
} from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import {
@@ -2777,15 +2778,65 @@ export function createTuiChat(
).finally(() => { commandControllers.delete(controller) })
}
const dispatchMessage = (content: ContentBlock[]): void => {
const dispatchMessage = (content: ContentBlock[], attachedContext?: UserMessageData): void => {
if (disposed) {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
return
}
if (agent.status === 'running') {
// Steering is never subject to prompt admission; an attached snapshot
// drains beside it at the same step boundary through the outbox.
if (attachedContext !== undefined) {
agent.inject({ content: attachedContext.content, source: attachedContext.source })
}
pendingSteering.add(agent.steer({ content, source: { kind: 'user' } }))
refreshStatus()
} else {
agent.followup({ content, source: { kind: 'user' } })
return
}
if (attachedContext === undefined) {
agent.followup({ content, source: { kind: 'user' } })
return
}
// Idle: the snapshot rides the prompt's own admission transaction
// (PromptDecision.additionalContexts), so a blocking hook discards the
// prompt and its attached context together instead of stranding the
// snapshot in history for the next unrelated prompt.
let cleanedUp = false
// Assigned after followup(); cleanup() can run earlier from the catch.
let detachDiscard: (() => void) | undefined = undefined
const cleanup = (): void => {
// Both triggers detach themselves, so a second call needs a future
// third trigger; kept so adding one cannot double-release.
/* v8 ignore next -- unreachable idempotence guard, see above */
if (cleanedUp) return
cleanedUp = true
detachSubmit()
detachDiscard?.()
}
// Prepended so this wrapper is outermost: it observes the admission
// whether a downstream hook allows or blocks, and detaches either way.
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _source, _signal, next) => {
if (subject !== agent || submitted !== content) return next()
cleanup()
const decision = await next()
if (decision.kind !== 'allow') return decision
return { ...decision, additionalContexts: [...decision.additionalContexts ?? [], attachedContext] }
}, { prepend: true })
let id: AgentMessageId
// 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' } })
} catch (error: unknown) {
cleanup()
throw error
}
/* v8 ignore stop */
// A prompt discarded before admission (broad cancel) releases the wrapper.
detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => {
if (subject === agent && messages.some(message => message.id === id)) cleanup()
})
}
/** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
@@ -3071,10 +3122,9 @@ export function createTuiChat(
if (disposed) return
editor.addToHistory(text)
if (editor.getText() === value) editor.setText('')
if (prepared.additionalContext !== undefined) {
agent.inject({ content: prepared.additionalContext.content, source: prepared.additionalContext.source })
}
dispatchMessage(prepared.content)
// The snapshot travels with the prompt so a blocking admission hook
// discards them together — see dispatchMessage's attached-context path.
dispatchMessage(prepared.content, prepared.additionalContext)
}, (error: unknown) => {
if (!disposed && !controller.signal.aborted) {
restoreSubmittedInput()