Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e

# Conflicts:
#	docs/subsystems/tasks.i18n.yaml
#	docs/subsystems/tasks.md
#	docs/subsystems/tasks.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/tasks/tasks-local/src/index.ts
#	packages/tasks/tasks/README.i18n.yaml
#	packages/tasks/tasks/README.md
#	packages/tasks/tasks/README.zh.md
#	packages/tasks/tasks/src/index.ts
This commit is contained in:
Yichen Jiang
2026-08-11 11:57:33 +08:00
2697 changed files with 39978 additions and 18500 deletions

View File

@@ -1,8 +1,10 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
@@ -15,9 +17,18 @@ declare module '@deepseek-ai/dsh-tasks' {
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
function stubAgent(ctx: Context, rawId: string): Agent {
function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent {
const id = SessionId(rawId)
const scopeFiber = ctx.plugin(() => {})
// `presetScope` reproduces what `agentPresets.compose` does: the agent gets
// its own key parented to the standing mount's, so the registry's chain walk
// reaches that preset's layer.
let agentCtx = scopeFiber.ctx
if (presetScope !== undefined) {
const key = {}
bindScopeParent(key, presetScope)
agentCtx = createScope(scopeFiber.ctx, key).ctx
}
const session = Session.create(id)
const agent = {
id,
@@ -25,7 +36,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle' as const,
ctx: scopeFiber.ctx,
ctx: agentCtx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
@@ -73,6 +84,21 @@ async function harness() {
return ctx
}
/**
* Attach a control surface the way `tool-tasks` does: from a plugin whose own
* `inject` resolves `ctx.tasks`, so the service method binds to the REGISTERING
* context and the surface files into that context's scope layer. Reading the
* service off a bare scoped context instead throws `cannot get property "tasks"
* without inject`, which is the same rule the shipped plugin obeys.
* @param ctx - the context whose scope should own the surface.
*/
async function attachSurfaceIn(ctx: Context): Promise<void> {
await ctx.plugin({
inject: ['tasks'],
apply(pluginCtx: Context) { pluginCtx.tasks.attachSurface('tool-tasks') },
})
}
/** Let the settlement continuation (a `done.then`) run. */
const tick = () => new Promise<void>(r => setTimeout(r, 0))
@@ -89,11 +115,48 @@ describe('LocalTaskService.start', () => {
expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
})
it('refuses to register while no control surface is attached', async () => {
it('refuses to register while no control surface serves the owner', async () => {
const ctx = new Context()
await ctx.plugin(LocalTaskService)
expect(() => ctx.tasks.start(producer().spec))
.toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
.toThrow('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)')
})
it('refuses an owner whose own composition attaches no surface', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
// Two standing preset mounts over one registry; only the first loads the
// task controls. The second must not inherit the first's open gate.
const withControls = createScope(ctx, {})
const withoutControls = createScope(ctx, {})
await attachSurfaceIn(withControls.ctx)
const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx))
const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx))
ctx.agents.register(served)
ctx.agents.register(unserved)
expect(() => ctx.tasks.start(producer({ owner: served }).spec)).not.toThrow()
expect(() => ctx.tasks.start(producer({ owner: unserved }).spec))
.toThrow('no control surface serves this agent')
// An unowned producer has no chain to walk, so only a global surface serves it.
expect(() => ctx.tasks.start(producer().spec))
.toThrow('no control surface serves this agent')
})
it('lets a surface attached without a scope serve every owner', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
// The host-plane composition's own controls: no scope, so the global layer
// holds them and every owner's read includes it.
await attachSurfaceIn(ctx)
const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx))
ctx.agents.register(scoped)
expect(() => ctx.tasks.start(producer({ owner: scoped }).spec)).not.toThrow()
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
})
it('rejects an empty kind, empty label, and invalid output limit', async () => {
@@ -201,7 +264,7 @@ describe('LocalTaskService reads and settlement', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom'))
})
it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
it("contains rejection from the producer's done promise as a failed outcome (producer contract violation)", async () => {
const ctx = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const p = producer()
@@ -742,6 +805,30 @@ describe('LocalTaskService disposal', () => {
expect(ownerEffects()).toHaveLength(0)
})
it('drops a scoped layer when its registrations dispose', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
const standing = createScope(ctx, {})
// One mount contributes both kinds into the same layer, as `tool-tasks`
// does; unloading it must leave nothing serving the agents that joined it.
const mount = await standing.ctx.plugin({
inject: ['tasks'],
apply(pluginCtx: Context) {
pluginCtx.tasks.attachSurface('tool-tasks')
pluginCtx.tasks.onTaskDone(() => {})
},
})
const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx))
ctx.agents.register(owner)
expect(() => ctx.tasks.start(producer({ owner }).spec)).not.toThrow()
await mount.dispose()
expect(() => ctx.tasks.start(producer({ owner }).spec))
.toThrow('no control surface serves this agent')
})
it('detaching the last surface re-arms the register fence', async () => {
const ctx = new Context()
await ctx.plugin(LocalTaskService)
@@ -757,7 +844,7 @@ describe('LocalTaskService disposal', () => {
detachA2()
expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains
await fiber.dispose() // detaches b with its fiber (HMR safety)
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent')
})
})