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:
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
Reference in New Issue
Block a user