fix(tool-tasks): claim completion notices only for the mount's own scope

Moving the task registry to the host plane put every preset's `tool-tasks`
listener on ONE `LocalTaskService`. `settle()` computes a single snapshot and
walks every registered listener with no scope filter, and it marks `reported`
only when a waiter is present — so a task settling without a waiter reached
each mount's listener with `reported` false and every one of them injected the
same completion into the same owner. Three shipped presets carry `tool-tasks`,
and a preset file edit adds a second generation of the same mount, so an agent
read N copies of one notice as model-visible durable context.

A mount now claims an owner only when the owner's scope chain reaches the
mount's own scope. An unscoped mount is the host-plane instance that serves
every agent, which keeps the TUI composition and every existing test intact.

Registry-side ownership was the alternative: mark `reported` once the first
listener claims it. It is wrong because `onTaskDone` is not a notice-only
seam — the `dsh-tasks` invariant companion registers a validating listener —
so first-claim-wins would silence observers that are not delivering anything.

The regression test mounts two scoped `tool-tasks` over one registry and
settles an unowned-wait task, which is the only path that reaches the notice
listeners at all: the shipped-composition e2e uses `wait: true`, and a waiter
marks `reported` before settlement, so that test structurally cannot cover it.

Also corrects the standing-mounts Agent Note, which still listed `tasks-local`
among the stateful PRESET plugins.

Refs #2141
This commit is contained in:
Yichen Jiang
2026-08-10 15:27:10 +08:00
parent 02d45ed004
commit 37ebe87087
17 changed files with 88 additions and 15 deletions

View File

@@ -6,6 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
@@ -445,6 +446,54 @@ describe('tool-owned UI presentation (presentCall)', () => {
})
})
describe('completion notices across scoped mounts', () => {
/**
* Two agent presets mounting `tool-tasks` over ONE host registry: each mount
* registers its own `onTaskDone` listener on the shared service, and
* `settle()` broadcasts one snapshot to every listener with no scope filter.
* Only the mount whose scope the owner belongs to may deliver the notice.
*/
it('delivers one notice from the owning scope when two mounts share the registry', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
const standingA = createScope(ctx, {})
const standingB = createScope(ctx, {})
await standingA.ctx.plugin(ToolTasks)
await standingB.ctx.plugin(ToolTasks)
// The agent joins preset A exactly as `agentPresets.compose` binds it.
const agentKey = {}
const agentScope = createScope(ctx, agentKey)
bindScopeParent(agentKey, scopeOf(standingA.ctx) as object)
const inject = vi.fn()
const owner = {
id: SessionId('sess-scoped'),
ctx: agentScope.ctx,
inject,
session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } },
} as unknown as Agent
const dispose = ctx.agents.register(owner)
try {
// No waiter: `settle()` leaves `reported` false, which is the only path
// that reaches the notice listeners at all.
const p = producer({ owner, label: 'pnpm test' })
ctx.tasks.start(p.spec)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
} finally {
dispose()
}
})
})
describe('completion notices', () => {
it('injects a notice into the owning agent when an unreported task settles', async () => {
const { ctx } = await setup()