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

@@ -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)}`)