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

@@ -573,27 +573,21 @@ describe('completion notices', () => {
expect(inject).not.toHaveBeenCalled()
})
it('drops the notice for unowned tasks and for a disposed owner (benign race)', async () => {
it('drops the notice for unowned tasks without throwing', async () => {
const { ctx } = await setup()
// Unowned: settles with nobody to notify — nothing throws.
const unowned = producer()
ctx.tasks.start(unowned.spec)
unowned.settle({ status: 'completed' })
await tick()
// Disposed owner: inject throws the disposed message — contained.
const inject = vi.fn(() => { throw new Error('agent "sess-1" is disposed') })
const owner = fakeAgent(ctx, 'sess-1', inject)
const p = producer({ owner })
ctx.tasks.start(p.spec)
p.settle({ status: 'completed' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
})
it('does not route an old owner completion notice to a same-session replacement', async () => {
const { ctx } = await setup()
const oldInject = vi.fn(() => { throw new Error('agent "shared" is disposed') })
// Delivery into a tearing-down owner is a plain inject: the loop has no
// terminal state, so the notice lands in the old owner's (detached)
// session instead of throwing or re-routing.
const oldInject = vi.fn()
const oldOwner = fakeAgent(ctx, 'shared', oldInject)
const p = producer({ owner: oldOwner })
ctx.tasks.start(p.spec)
@@ -608,7 +602,7 @@ describe('completion notices', () => {
expect(replacementInject).not.toHaveBeenCalled()
})
it('propagates a non-disposed inject failure (a real bug must surface)', async () => {
it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
const { ctx } = await setup()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })