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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user