feat(dx): scopeHost, agent-aware ACP presentation, and the scoped-dispatch drift gate

scopeHost(ctx, services) is the sanctioned way to mint scopes in tests: it
names absent services loudly instead of the cryptic cordis without-inject
dead end, and catches the silent-no-op host (cordis resolves a
dependency-pending fiber's await without running the inject callback).

The ACP ToolPresenter resolves presentations through the session agent's
view (tools.get(name, agent)) so a scoped/shadowed tool renders with the
same definition that executed.

verify-scoped-dispatch (doc-sync + pre-push) pins the dev-invariants
carrier table against the declaration JSDoc set: an event enforced but
undocumented, documented but unenforced, or a registry-subject notification
leaking into the table fails the build. subagent/start|end docs gain their
scoped-dispatch sentence (a real gap the gate caught on first run).
This commit is contained in:
Tianyi Cui
2026-07-09 02:38:54 +08:00
parent f91eb39538
commit e7bcbb8bc6
10 changed files with 188 additions and 15 deletions

View File

@@ -237,13 +237,13 @@ Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/sr
### `subagent/end` — emit
A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start'].
A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
```ts cordis-catalog
'subagent/end'(info: SubagentRunEndInfo): void
```
Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:107`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-added` — emit
@@ -267,13 +267,13 @@ Source: [`packages/subagent/subagent/src/index.ts:84`](../../packages/subagent/s
### `subagent/start` — emit
A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end'].
A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
```ts cordis-catalog
'subagent/start'(info: SubagentRunInfo): void
```
Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:96`](../../packages/subagent/subagent/src/index.ts)
## `system-prompt/*`

View File

@@ -197,7 +197,7 @@ list(): string[]
start(name: string, request: SubagentStartRequest): SubagentRun
```
Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
## `ctx.systemPrompt` — `SystemPrompt`

View File

@@ -25,10 +25,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:107`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:73`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:84`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:96`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:112`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |

View File

@@ -57,9 +57,10 @@
"gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
"verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
"verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts",
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts",
"doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
"doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types",
"demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
"demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",

View File

@@ -235,3 +235,59 @@ export function carrierKeyOf(value: unknown): ScopeKey | undefined {
// the Scoped<> brand carries no structural kCarrier member to narrow from.
return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier]?.key
}
/**
* A test/tooling host for minting scopes: one mounted plugin whose `inject`
* list is the service surface every scope minted through it can reach.
*/
export interface ScopeHost {
/**
* Mint a scope under the host (see {@link createScope}); the scoped context
* resolves exactly the host's injected services.
* @param key - the scope's identity ({@link ScopeKey}).
* @returns the minted scope.
*/
mint(key: ScopeKey): Scope
/**
* Dispose the host fiber and with it every scope minted through it.
* @returns resolves when all collected disposers have settled.
*/
dispose(): Promise<void>
}
/**
* Mount a scope-minting host plugin that injects `services`, THE sanctioned
* way to mint scopes in tests (production scopes are minted by the agent
* loop). Exists because the naive spelling fails confusingly twice over:
* a plugin with no `inject` mints scopes whose service reads throw Cordis's
* cryptic `cannot get property … without inject`, and a plugin whose inject
* can never be satisfied RESOLVES its fiber await without ever running the
* callback — a silent no-op host. This helper fails LOUD instead: when the
* callback did not run, it names the absent services and disposes the host.
* @param ctx - the context to mount the host under.
* @param services - the service names scopes minted through this host reach
* (the host plugin's `inject` list).
* @returns the host (mint scopes, dispose them all at once).
* @throws when any of `services` is not available on `ctx` — named, not the
* Cordis dead end.
*/
export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> {
let hostCtx: Context | undefined
// A named function statement (not Object.assign({name}) — Function.name is
// read-only) so diagnostics read `scopeHost`.
function scopeHostPlugin(inner: Context): void { hostCtx = inner }
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services }))
await fiber
if (hostCtx === undefined) {
// Dependency-pending: cordis resolves the await without running the
// callback. Name the absentees and unwind the pending fiber.
const missing = services.filter(name => ctx.get(name) === undefined)
await fiber.dispose()
throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${missing.map(name => `"${name}"`).join(', ') || '(unknown)'} not available on this context — load the providing plugin(s) before minting scopes`)
}
const host = hostCtx
return {
mint: (key: ScopeKey) => createScope(host, key),
dispose: () => Promise.resolve(fiber.dispose()),
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import { carrierKeyOf, createScope, isScopeCarrier, scopeHost, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
declare module 'cordis' {
@@ -207,3 +207,24 @@ describe('carrier marks', () => {
expectTypeOf(base).not.toExtend<Scoped<{ name: string }>>()
})
})
describe('scopeHost', () => {
it('mints scopes that reach the injected services; dispose unwinds them all', async () => {
const ctx = new Context()
ctx.provide('answers', { value: 42 })
const host = await scopeHost(ctx, ['answers'])
const scope = host.mint({ name: 'a' })
expect((scope.ctx as Context & { answers: { value: number } }).answers.value).toBe(42)
const order: string[] = []
scope.ctx.effect(() => () => void order.push('scoped-disposed'))
await host.dispose()
expect(order).toEqual(['scoped-disposed'])
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
})
it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
const ctx = new Context()
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
})
})

View File

@@ -86,6 +86,10 @@ declare module 'cordis' {
* A subagent run started — emitted after the provider is resolved and its
* capabilities validated, as the child run begins. Paired with
* {@link Events['subagent/end']}.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
* by the DELEGATING PARENT — a listener registered through the parent's
* `agent.ctx` observes only its own delegations; a plain plugin listener
* observes every run.
* @param info - which provider started which child agent.
* @mode emit
*/
@@ -93,6 +97,10 @@ declare module 'cordis' {
/**
* A subagent run settled — emitted when {@link SubagentRun.result}
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
* by the DELEGATING PARENT — a listener registered through the parent's
* `agent.ctx` observes only its own delegations; a plain plugin listener
* observes every run.
* @param info - the run identity plus stop reason and final output.
* @mode emit
*/

View File

@@ -213,7 +213,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const tools = ctx.tools
// A new ToolPresenter per session (and a throwaway per load replay), each given
// this warn sink so a throwing tool presenter is logged, not propagated.
const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
// reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
@@ -449,7 +449,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
sessionId,
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(),
presenter: makePresenter(handle.agent),
terminalEnabled: terminalOutputCap,
inflight: undefined,
})
@@ -526,7 +526,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
sessionId,
agent,
dispose: () => handle.dispose(),
presenter: makePresenter(),
presenter: makePresenter(agent),
terminalEnabled,
inflight: undefined,
}
@@ -544,7 +544,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// future live events for this session. The throwaway pairs call→result
// as the log replays in order (same as live) and is discarded after,
// so the record's presenter starts clean for the post-load live stream.
const replayPresenter = makePresenter()
const replayPresenter = makePresenter(agent)
const replayTerminal: TerminalRendering = {
enabled: terminalEnabled,
cwd: agent.session.header.cwd,
@@ -897,6 +897,13 @@ export class ToolPresenter {
constructor(
private readonly tools: Pick<ToolRegistry, 'get'>,
private readonly onError: (message: string) => void = () => {},
/**
* The agent whose view resolves tool presentations: a scoped/shadowed
* tool presents with ITS OWN presentCall/presentResult — the same
* definition that executed — not a same-named global's. Absent (a replay
* with no live agent) the global view presents.
*/
private readonly agent?: Agent,
) {}
/**
@@ -913,7 +920,7 @@ export class ToolPresenter {
const args = parseToolArguments(argsJson)
let present: ToolCallView | undefined
try {
present = this.tools.get(name)?.presentCall?.(args)
present = this.tools.get(name, this.agent)?.presentCall?.(args)
} catch (error: unknown) {
// A throwing presentCall must not break streaming: log and fall back.
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
@@ -947,7 +954,8 @@ export class ToolPresenter {
if (call === undefined) return { card: 'generic', content }
let present: ToolResultView | undefined
try {
present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
present = this.tools.get(call.name, this.agent)
?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
} catch (error: unknown) {
// A throwing presentResult must not break streaming/replay: log + fall back.
this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)

View File

@@ -262,6 +262,7 @@ function docSyncLeafGates(): Gate[] {
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
pnpmScript('scoped-dispatch', 'verify-scoped-dispatch', { label: 'scoped dispatch' }),
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),

View File

@@ -0,0 +1,78 @@
/**
* Scoped-dispatch drift gate: the set of scope-filtered events is declared in
* TWO places that must never diverge — the dev-invariants runtime table (the
* `scopedSubject` map in `packages/support/invariants/src/index.ts`, which
* enforces carriers at dispatch time) and the event declarations' JSDoc (the
* "Scope-filtered dispatch" sentence rendered into the events catalog, which
* tells plugin authors what a scoped listener will and won't hear). An event
* added to one side without the other either silently escapes runtime
* enforcement or documents filtering that never happens; this gate fails the
* build instead.
*
* Sources of truth: the invariant table is parsed from the invariants source;
* the documented set is parsed from every `declare module 'cordis'` Events
* JSDoc in packages/*\/*\/src carrying the marker sentence. Registry-subject
* notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`)
* are deliberately unfiltered and must appear in NEITHER set.
*/
import { globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
/** The marker sentence every scope-filtered event's JSDoc carries. */
const MARKER = 'Scope-filtered dispatch'
/** Events that are deliberately UNFILTERED registry-subject notifications. */
const REGISTRY_SUBJECT = new Set(['tools/change', 'system-prompt/change', 'subagent/provider-added', 'subagent/provider-removed'])
function invariantTable(): Set<string> {
const source = readFileSync(resolve(root, 'packages/support/invariants/src/index.ts'), 'utf8')
const start = source.indexOf('const scopedSubject')
if (start < 0) throw new Error('verify-scoped-dispatch: cannot find the scopedSubject table in dsh-invariants')
const block = source.slice(start, source.indexOf('}', start))
return new Set([...block.matchAll(/'([a-z-]+\/[a-z-]+)':/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]))
}
function documentedSet(): Set<string> {
const documented = new Set<string>()
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root })) {
const source = readFileSync(resolve(root, rel), 'utf8')
if (!source.includes(MARKER)) continue
// Each event declaration: a JSDoc block followed by the quoted event name.
// Tolerate `//` comment lines between the JSDoc and the declaration
// (e.g. an inline TODO under the doc block).
for (const match of source.matchAll(/\/\*\*([\s\S]*?)\*\/\s*\n(?:\s*\/\/[^\n]*\n)*\s*'([a-z-]+\/[a-z-]+)'\(/g)) {
const [, doc, event] = match
if (doc === undefined || event === undefined) continue
if (doc.includes(MARKER)) documented.add(event)
}
}
return documented
}
const table = invariantTable()
const documented = documentedSet()
const problems: string[] = []
for (const event of table) {
if (!documented.has(event)) {
problems.push(`"${event}" is enforced by the dev-invariants carrier table but its declaration JSDoc carries no "${MARKER}" sentence — document the filtering plugin authors will observe.`)
}
if (REGISTRY_SUBJECT.has(event)) {
problems.push(`"${event}" is a registry-subject notification (deliberately unfiltered) but appears in the dev-invariants carrier table.`)
}
}
for (const event of documented) {
if (!table.has(event)) {
problems.push(`"${event}" documents scope-filtered dispatch but is missing from the dev-invariants carrier table (packages/support/invariants) — a bare dispatch of it would silently revert to global delivery.`)
}
}
if (problems.length > 0) {
console.error(`verify-scoped-dispatch: ${problems.length} drift(s) between the invariant table and the documented scoped-event set:`)
for (const problem of problems) console.error(` - ${problem}`)
process.exit(1)
}
console.log(`verify-scoped-dispatch: ${table.size} scope-filtered event(s) consistent between the invariant table and the declaration docs.`)